Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add RateLimitController #698

Merged
merged 25 commits into from
Mar 4, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ff64db4
Initial implementation of NotificationControllerV2
FrederikBolding Feb 15, 2022
e2c39f6
Simplify implementation
FrederikBolding Feb 15, 2022
ad6ecf9
Fix small type issue
FrederikBolding Feb 15, 2022
423c060
Simplify and start using messaging system
FrederikBolding Feb 16, 2022
433526f
Adapt tests for controller messaging
FrederikBolding Feb 17, 2022
daddebe
Simplify tests a bit
FrederikBolding Feb 17, 2022
7b801fc
Add action handler
FrederikBolding Feb 17, 2022
8209b91
Fix types
FrederikBolding Feb 24, 2022
bd682a5
Fix tests
FrederikBolding Feb 24, 2022
f3a2d9d
Add some documentation and exports
FrederikBolding Feb 24, 2022
d1fe0ff
Pivot to RateLimitController
FrederikBolding Feb 24, 2022
784bc95
Fix export
FrederikBolding Feb 24, 2022
932f868
Remove subject metadata type
FrederikBolding Feb 24, 2022
57f4193
Make controller generic and allow caller to pass implementation mapping
FrederikBolding Feb 25, 2022
4fda406
Fix PR comments
FrederikBolding Mar 1, 2022
5717f71
Fix type name casing
rekmarks Mar 1, 2022
8a2de75
Fix controller docstrings
rekmarks Mar 1, 2022
a8cb7e3
Return potential result from implementation, Wrap in promise in case …
FrederikBolding Mar 2, 2022
e28d96e
Improve typing and API
FrederikBolding Mar 2, 2022
0a2c885
Fix type issue
FrederikBolding Mar 2, 2022
70cffb7
Fix PR comments
FrederikBolding Mar 3, 2022
b4e6946
Cast action handler
FrederikBolding Mar 3, 2022
5b9437c
Change use of useFakeTimers
FrederikBolding Mar 3, 2022
09b1352
Throw error when API is rate-limited
FrederikBolding Mar 4, 2022
d850d1a
Small fixes
FrederikBolding Mar 4, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,5 @@ export * from './assets/TokenDetectionController';
export * from './assets/CollectibleDetectionController';
export * from './permissions';
export * from './subject-metadata';
export * from './ratelimit/RateLimitController';
export { util };
150 changes: 150 additions & 0 deletions src/ratelimit/RateLimitController.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { ControllerMessenger } from '../ControllerMessenger';
import {
ControllerActions,
RateLimitStateChange,
RateLimitController,
RateLimitMessenger,
GetRateLimitState,
CallApi,
} from './RateLimitController';

const name = 'RateLimitController';

const implementations = {
showNativeNotification: jest.fn(),
};

type RateLimitedApis = typeof implementations;

/**
* Constructs a unrestricted controller messenger.
*
* @returns A unrestricted controller messenger.
*/
function getUnrestrictedMessenger() {
return new ControllerMessenger<
GetRateLimitState<RateLimitedApis> | CallApi<RateLimitedApis>,
RateLimitStateChange<RateLimitedApis>
>();
}

/**
* Constructs a restricted controller messenger.
*
* @param controllerMessenger - An optional unrestricted messenger
* @returns A restricted controller messenger.
*/
function getRestrictedMessenger(
controllerMessenger = getUnrestrictedMessenger(),
) {
return controllerMessenger.getRestricted<
typeof name,
ControllerActions<RateLimitedApis>['type'],
never
>({
name,
allowedActions: ['RateLimitController:call'],
}) as RateLimitMessenger<RateLimitedApis>;
}

const origin = 'snap_test';
const message = 'foo';

describe('RateLimitController', () => {
beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
implementations.showNativeNotification.mockClear();
jest.useRealTimers();
});

it('action: RateLimitController:call', async () => {
const unrestricted = getUnrestrictedMessenger();
const messenger = getRestrictedMessenger(unrestricted);

// Registers action handlers
new RateLimitController({
implementations,
messenger,
});

expect(
await unrestricted.call(
'RateLimitController:call',
origin,
'showNativeNotification',
origin,
message,
),
).toBeUndefined();

expect(implementations.showNativeNotification).toHaveBeenCalledWith(
origin,
message,
);
});

it('uses showNativeNotification to show a notification', async () => {
const messenger = getRestrictedMessenger();

const controller = new RateLimitController({
implementations,
messenger,
});
expect(
await controller.call(origin, 'showNativeNotification', origin, message),
).toBeUndefined();

expect(implementations.showNativeNotification).toHaveBeenCalledWith(
origin,
message,
);
});

it('returns false if rate-limited', async () => {
const messenger = getRestrictedMessenger();
const controller = new RateLimitController({
implementations,
messenger,
rateLimitCount: 1,
});

expect(
await controller.call(origin, 'showNativeNotification', origin, message),
).toBeUndefined();

await expect(
controller.call(origin, 'showNativeNotification', origin, message),
).rejects.toThrow(
`"showNativeNotification" is currently rate-limited. Please try again later`,
);
rekmarks marked this conversation as resolved.
Show resolved Hide resolved
expect(implementations.showNativeNotification).toHaveBeenCalledTimes(1);
expect(implementations.showNativeNotification).toHaveBeenCalledWith(
origin,
message,
);
});

it('rate limit is reset after timeout', async () => {
const messenger = getRestrictedMessenger();
const controller = new RateLimitController({
implementations,
messenger,
rateLimitCount: 1,
});
expect(
await controller.call(origin, 'showNativeNotification', origin, message),
).toBeUndefined();
jest.runAllTimers();
expect(
await controller.call(origin, 'showNativeNotification', origin, message),
).toBeUndefined();
expect(implementations.showNativeNotification).toHaveBeenCalledTimes(2);
expect(implementations.showNativeNotification).toHaveBeenCalledWith(
origin,
message,
);
});
});
193 changes: 193 additions & 0 deletions src/ratelimit/RateLimitController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { ethErrors } from 'eth-rpc-errors';
import type { Patch } from 'immer';

import { BaseController } from '../BaseControllerV2';

import type { RestrictedControllerMessenger } from '../ControllerMessenger';

/**
* @type RateLimitState
* @property requests - Object containing number of requests in a given interval for each origin and api type combination
*/
export type RateLimitState<
RateLimitedApis extends Record<string, (...args: any[]) => any>
> = {
requests: Record<keyof RateLimitedApis, Record<string, number>>;
};

const name = 'RateLimitController';

export type RateLimitStateChange<
RateLimitedApis extends Record<string, (...args: any[]) => any>
> = {
type: `${typeof name}:stateChange`;
payload: [RateLimitState<RateLimitedApis>, Patch[]];
};

export type GetRateLimitState<
RateLimitedApis extends Record<string, (...args: any[]) => any>
> = {
type: `${typeof name}:getState`;
handler: () => RateLimitState<RateLimitedApis>;
};

export type CallApi<
RateLimitedApis extends Record<string, (...args: any[]) => any>
> = {
type: `${typeof name}:call`;
handler: RateLimitController<RateLimitedApis>['call'];
};

export type ControllerActions<
RateLimitedApis extends Record<string, (...args: any[]) => any>
> = GetRateLimitState<RateLimitedApis> | CallApi<RateLimitedApis>;

export type RateLimitMessenger<
RateLimitedApis extends Record<string, (...args: any[]) => any>
> = RestrictedControllerMessenger<
typeof name,
ControllerActions<RateLimitedApis>,
RateLimitStateChange<RateLimitedApis>,
never,
never
>;

const metadata = {
requests: { persist: false, anonymous: false },
};

/**
* Controller with logic for rate-limiting API endpoints per requesting origin.
*/
export class RateLimitController<
RateLimitedApis extends Record<string, (...args: any[]) => any>
> extends BaseController<
typeof name,
RateLimitState<RateLimitedApis>,
RateLimitMessenger<RateLimitedApis>
> {
private implementations;

private rateLimitTimeout;

private rateLimitCount;

/**
* Creates a RateLimitController instance.
*
* @param options - Constructor options.
* @param options.messenger - A reference to the messaging system.
* @param options.state - Initial state to set on this controller.
* @param options.implementations - Mapping from API type to API implementation.
* @param options.rateLimitTimeout - The time window in which the rate limit is applied (in ms).
* @param options.rateLimitCount - The amount of calls an origin can make in the rate limit time window.
*/
constructor({
rateLimitTimeout = 5000,
rateLimitCount = 1,
messenger,
state,
implementations,
}: {
rateLimitTimeout?: number;
rateLimitCount?: number;
messenger: RateLimitMessenger<RateLimitedApis>;
state?: Partial<RateLimitState<RateLimitedApis>>;
implementations: RateLimitedApis;
}) {
const defaultState = {
requests: Object.keys(implementations).reduce(
(acc, key) => ({ ...acc, [key]: {} }),
{} as Record<keyof RateLimitedApis, Record<string, number>>,
),
};
super({
name,
metadata,
messenger,
state: { ...defaultState, ...state },
});
this.implementations = implementations;
this.rateLimitTimeout = rateLimitTimeout;
this.rateLimitCount = rateLimitCount;

this.messagingSystem.registerActionHandler(
`${name}:call` as const,
((
origin: string,
type: keyof RateLimitedApis,
...args: Parameters<RateLimitedApis[keyof RateLimitedApis]>
) => this.call(origin, type, ...args)) as any,
);
}

/**
* Calls an API if the requesting origin is not rate-limited.
*
* @param origin - The requesting origin.
* @param type - The type of API call to make.
* @param args - Arguments for the API call.
* @returns `false` if rate-limited, and `true` otherwise.
*/
async call<ApiType extends keyof RateLimitedApis>(
origin: string,
type: ApiType,
...args: Parameters<RateLimitedApis[ApiType]>
): Promise<ReturnType<RateLimitedApis[ApiType]>> {
if (this.isRateLimited(type, origin)) {
throw ethErrors.rpc.limitExceeded({
message: `"${type}" is currently rate-limited. Please try again later.`,
});
}
this.recordRequest(type, origin);

const implementation = this.implementations[type];

if (!implementation) {
throw new Error('Invalid api type');
}

return implementation(...args);
}

/**
* Checks whether an origin is rate limited for the a specific API.
*
* @param api - The API the origin is trying to access.
* @param origin - The origin trying to access the API.
* @returns `true` if rate-limited, and `false` otherwise.
*/
private isRateLimited(api: keyof RateLimitedApis, origin: string) {
return this.state.requests[api][origin] >= this.rateLimitCount;
}

/**
* Records that an origin has made a request to call an API, for rate-limiting purposes.
*
* @param api - The API the origin is trying to access.
* @param origin - The origin trying to access the API.
*/
private recordRequest(api: keyof RateLimitedApis, origin: string) {
this.update((state) => {
(state as any).requests[api][origin] =
mcmire marked this conversation as resolved.
Show resolved Hide resolved
((state as any).requests[api][origin] ?? 0) + 1;

setTimeout(
() => this.resetRequestCount(api, origin),
this.rateLimitTimeout,
);
});
}

/**
* Resets the request count for a given origin and API combination, for rate-limiting purposes.
*
* @param api - The API in question.
* @param origin - The origin in question.
*/
private resetRequestCount(api: keyof RateLimitedApis, origin: string) {
this.update((state) => {
(state as any).requests[api][origin] = 0;
});
}
}