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

feat: cp-8359 chainagnostic provider #6

Merged
merged 14 commits into from
Aug 22, 2024
Merged
220 changes: 220 additions & 0 deletions src/background/providers/ChainAgnosticProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { ethErrors } from 'eth-rpc-errors';
import AutoPairingPostMessageConnection from '../utils/messaging/AutoPairingPostMessageConnection';
import { ChainAgnosticProvider } from './ChainAgnosticProvider';
import onDomReady from './utils/onDomReady';
import { DAppProviderRequest } from '../connections/dAppConnection/models';

jest.mock('../utils/messaging/AutoPairingPostMessageConnection', () => {
const mocks = {
connect: jest.fn().mockResolvedValue(undefined),
on: jest.fn(),
request: jest.fn().mockResolvedValue({}),
};
return jest.fn().mockReturnValue(mocks);
});

export const matchingPayload = (payload) =>
expect.objectContaining({
data: expect.objectContaining(payload),
});

jest.mock('./utils/onDomReady');
jest.mock('../utils/messaging/AutoPairingPostMessageConnection', () => {
const mocks = {
connect: jest.fn().mockResolvedValue(undefined),
on: jest.fn(),
request: jest.fn().mockResolvedValue({}),
};
return jest.fn().mockReturnValue(mocks);
});
describe('src/background/providers/ChainAgnosticProvider', () => {
const channelMock = new AutoPairingPostMessageConnection(false);

describe('initialization', () => {
it('should connect to the backgroundscript', async () => {
new ChainAgnosticProvider(channelMock);

expect(channelMock.connect).toHaveBeenCalled();
expect(channelMock.request).not.toHaveBeenCalled();
});
it('should wait for message channel to be connected', async () => {
const mockedChannel = new AutoPairingPostMessageConnection(false);

const provider = new ChainAgnosticProvider(channelMock);

await new Promise(process.nextTick);

(onDomReady as jest.Mock).mock.calls[0][0]();

expect(mockedChannel.connect).toHaveBeenCalled();
expect(mockedChannel.request).not.toHaveBeenCalled();

await provider.request({
data: { method: 'some-method', params: [{ param1: 1 }] },
sessionId: '00000000-0000-0000-0000-000000000000',
chainId: '1',
});
expect(mockedChannel.request).toHaveBeenCalled();
});
it('should call the `DOMAIN_METADATA_METHOD` adter domReady', async () => {
new ChainAgnosticProvider(channelMock);
await new Promise(process.nextTick);
expect(channelMock.request).toHaveBeenCalledTimes(0);
(onDomReady as jest.Mock).mock.calls[0][0]();
await new Promise(process.nextTick);

expect(channelMock.request).toHaveBeenCalledTimes(1);

expect(channelMock.request).toHaveBeenCalledWith(
// matchingPayload({
// method: DAppProviderRequest.INIT_DAPP_STATE,
// })
expect.objectContaining({
params: expect.objectContaining({
request: expect.objectContaining({
method: DAppProviderRequest.DOMAIN_METADATA_METHOD,
}),
}),
})
);
});
});

describe('request', () => {
it('should collect pending requests till the dom is ready', async () => {
const provider = new ChainAgnosticProvider(channelMock);
// wait for init to finish
await new Promise(process.nextTick);

expect(channelMock.request).toHaveBeenCalledTimes(0);

(channelMock.request as jest.Mock).mockResolvedValue('success');
const rpcResultCallback = jest.fn();
provider
.request({
data: {
method: 'some-method',
params: [{ param1: 1 }],
},
})
.then(rpcResultCallback);
await new Promise(process.nextTick);

expect(channelMock.request).toHaveBeenCalledTimes(0);

// domReady triggers sending pending requests as well
(onDomReady as jest.Mock).mock.calls[0][0]();
await new Promise(process.nextTick);

expect(channelMock.request).toHaveBeenCalledTimes(2);

expect(rpcResultCallback).toHaveBeenCalledWith('success');
});
it('should use the rate limits on `eth_requestAccounts` requests', async () => {
const provider = new ChainAgnosticProvider(channelMock);
(channelMock.request as jest.Mock).mockResolvedValue('success');

await new Promise(process.nextTick);

(onDomReady as jest.Mock).mock.calls[0][0]();

const firstCallCallback = jest.fn();
const secondCallCallback = jest.fn();
provider
.request({
data: { method: 'eth_requestAccounts' },
} as any)
.then(firstCallCallback)
.catch(firstCallCallback);
provider
.request({
data: { method: 'eth_requestAccounts' },
} as any)
.then(secondCallCallback)
.catch(secondCallCallback);

await new Promise(process.nextTick);
expect(firstCallCallback).toHaveBeenCalledWith('success');
expect(secondCallCallback).toHaveBeenCalledWith(
ethErrors.rpc.resourceUnavailable(
`Request of type eth_requestAccounts already pending for origin. Please wait.`
)
);
});
it('shoud not use the rate limits on `random_method` requests', async () => {
const provider = new ChainAgnosticProvider(channelMock);
(channelMock.request as jest.Mock).mockResolvedValue('success');

await new Promise(process.nextTick);

(onDomReady as jest.Mock).mock.calls[0][0]();

const firstCallCallback = jest.fn();
const secondCallCallback = jest.fn();
provider
.request({
data: { method: 'random_method' },
} as any)
.then(firstCallCallback)
.catch(firstCallCallback);
provider
.request({
data: { method: 'random_method' },
} as any)
.then(secondCallCallback)
.catch(secondCallCallback);

await new Promise(process.nextTick);
expect(firstCallCallback).toHaveBeenCalledWith('success');
expect(secondCallCallback).toHaveBeenCalledWith('success');
});

it('should call the request of the connection', async () => {
const provider = new ChainAgnosticProvider(channelMock);
(channelMock.request as jest.Mock).mockResolvedValueOnce('success');

await new Promise(process.nextTick);

(onDomReady as jest.Mock).mock.calls[0][0]();

await provider.request({
data: { method: 'some-method', params: [{ param1: 1 }] },
sessionId: '00000000-0000-0000-0000-000000000000',
chainId: '1',
});
expect(channelMock.request).toHaveBeenCalled();
});
describe('CAIP-27', () => {
it('should wrap the incoming request into CAIP-27 envelope and reuses the provided ID', async () => {
const provider = new ChainAgnosticProvider(channelMock);
// response for the actual call
(channelMock.request as jest.Mock).mockResolvedValueOnce('success');

await new Promise(process.nextTick);

(onDomReady as jest.Mock).mock.calls[0][0]();

provider.request({
data: { method: 'some-method', params: [{ param1: 1 }] },
sessionId: '00000000-0000-0000-0000-000000000000',
chainId: '1',
});

await new Promise(process.nextTick);

expect(channelMock.request).toHaveBeenCalledWith({
jsonrpc: '2.0',
method: 'provider_request',
params: {
scope: 'eip155:1',
sessionId: '00000000-0000-0000-0000-000000000000',
request: {
method: 'some-method',
params: [{ param1: 1 }],
},
},
});
});
});
});
});
113 changes: 113 additions & 0 deletions src/background/providers/ChainAgnosticProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import EventEmitter from 'events';
import {
DAppProviderRequest,
JsonRpcRequest,
JsonRpcRequestPayload,
} from '../connections/dAppConnection/models';
import { PartialBy } from '../models';
import { ethErrors, serializeError } from 'eth-rpc-errors';
import AbstractConnection from '../utils/messaging/AbstractConnection';
import { ChainId } from '@avalabs/core-chains-sdk';
import RequestRatelimiter from './utils/RequestRatelimiter';
import {
InitializationStep,
ProviderReadyPromise,
} from './utils/ProviderReadyPromise';
import onDomReady from './utils/onDomReady';
import { getSiteMetadata } from './utils/getSiteMetadata';

export class ChainAgnosticProvider extends EventEmitter {
#contentScriptConnection: AbstractConnection;
#providerReadyPromise = new ProviderReadyPromise([
InitializationStep.DOMAIN_METADATA_SENT,
]);

#requestRateLimiter = new RequestRatelimiter([
'eth_requestAccounts',
'avalanche_selectWallet',
]);

constructor(connection) {
super();
connection.connect();
this.#contentScriptConnection = connection;
this.#init();
}

async #init() {
await this.#contentScriptConnection.connect();

onDomReady(async () => {
const domainMetadata = await getSiteMetadata();

this.#request({
data: {
method: DAppProviderRequest.DOMAIN_METADATA_METHOD,
params: domainMetadata,
},
});

this.#providerReadyPromise.check(InitializationStep.DOMAIN_METADATA_SENT);
});
}

#request = async ({
data,
sessionId,
chainId,
}: {
data: PartialBy<JsonRpcRequestPayload, 'id' | 'params'>;
sessionId?: string;
chainId?: string | null;
}) => {
if (!data) {
throw ethErrors.rpc.invalidRequest();
}

const result = this.#contentScriptConnection
.request({
method: 'provider_request',
jsonrpc: '2.0',
params: {
scope: `eip155:${
chainId ? parseInt(chainId) : ChainId.AVALANCHE_MAINNET_ID
}`,
sessionId,
request: {
params: [],
...data,
},
},
} as JsonRpcRequest)
.catch((err) => {
// If the error is already a JsonRPCErorr do not serialize them.
// eth-rpc-errors always wraps errors if they have an unkown error code
// even if the code is valid like 4902 for unrecognized chain ID.
if (!!err.code && Number.isInteger(err.code) && !!err.message) {
throw err;
}
throw serializeError(err);
});
return result;
};

request = async ({
data,
sessionId,
chainId,
}: {
data: PartialBy<JsonRpcRequestPayload, 'id' | 'params'>;
sessionId?: string;
chainId?: string | null;
}) => {
return this.#providerReadyPromise.call(() => {
return this.#requestRateLimiter.call(data.method, () =>
this.#request({ data, chainId, sessionId })
);
});
};

subscribeToMessage = (callback) => {
this.#contentScriptConnection.on('message', callback);
};
}
Loading
Loading