Skip to content

Commit

Permalink
feat: add support to EIP-6963 protocol (#423)
Browse files Browse the repository at this point in the history
* feat: add support to eip6963 protocol

* feat: fix PR comments

* feat: fix PR comments

* feat: fix unit tests

* fix: the timeout logic

* fix: pr comments

* fix: pr comments

* fix: tests
  • Loading branch information
omridan159 authored Oct 24, 2023
1 parent a72a26c commit b6c325d
Show file tree
Hide file tree
Showing 8 changed files with 220 additions and 45 deletions.
8 changes: 8 additions & 0 deletions packages/sdk/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,13 @@ export const METAMASK_CONNECT_BASE_URL = 'https://metamask.app.link/connect';

export const METAMASK_DEEPLINK_BASE = 'metamask://connect';

export const METAMASK_EIP_6369_PROVIDER_INFO = {
NAME: 'MetaMask Main',
RDNS: 'io.metamask',
};

export const UUID_V4_REGEX =
/(?:^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[a-f0-9]{4}-[a-f0-9]{12}$)|(?:^0{8}-0{4}-0{4}-0{4}-0{12}$)/u;

export const ONE_MINUTE_IN_MS = 60 * 1000;
export const ONE_HOUR_IN_MS = ONE_MINUTE_IN_MS * 60;
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ describe('performSDKInitialization', () => {
mockSetupExtensionPreferencesReturnValue = {
shouldReturn: false,
preferExtension: false,
metamaskBrowserExtension: null,
metamaskBrowserExtension: undefined,
};

mockSetupExtensionPreferences.mockImplementation(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,10 @@ export async function setupExtensionPreferences(instance: MetaMaskSDK) {
localStorage.getItem(STORAGE_PROVIDER_TYPE) === 'extension';

try {
metamaskBrowserExtension = getBrowserExtension({
metamaskBrowserExtension = await getBrowserExtension({
mustBeMetaMask: true,
});

window.extension = metamaskBrowserExtension;
} catch (err) {
// Ignore error if metamask extension not found
Expand Down
63 changes: 63 additions & 0 deletions packages/sdk/src/utils/eip6963RequestProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { SDKProvider } from '../provider/SDKProvider';
import {
EIP6963EventNames,
EIP6963ProviderDetail,
EIP6963ProviderInfo,
eip6963RequestProvider,
} from './eip6963RequestProvider';

describe('eip6963RequestProvider', () => {
beforeEach(() => {
global.window = {
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
} as any;

jest.useFakeTimers();
jest.clearAllMocks();
});

afterEach(() => {
jest.useRealTimers();
});

it('should reject after timeout', async () => {
const requestProviderPromise = eip6963RequestProvider();

jest.advanceTimersByTime(501);

await expect(requestProviderPromise).rejects.toThrow(
'eip6963RequestProvider timed out',
);
});

it('should resolve with valid provider', async () => {
const mockProvider: SDKProvider = {} as SDKProvider;
const mockInfo: EIP6963ProviderInfo = {
uuid: 'a1d2c588-106f-4d05-958d-5e7d6c57c822',
name: 'MetaMask Main',
icon: 'icon-path',
rdns: 'io.metamask',
};
const mockEventDetail: EIP6963ProviderDetail = {
info: mockInfo,
provider: mockProvider,
};

(window.addEventListener as jest.Mock).mockImplementationOnce(
(eventName, callback) => {
if (eventName === EIP6963EventNames.Announce) {
callback({
type: EIP6963EventNames.Announce,
detail: mockEventDetail,
});
}
},
);

const requestProviderPromise = await eip6963RequestProvider();

expect(requestProviderPromise).toBe(mockProvider);
});
});
57 changes: 57 additions & 0 deletions packages/sdk/src/utils/eip6963RequestProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { METAMASK_EIP_6369_PROVIDER_INFO, UUID_V4_REGEX } from '../constants';
import { SDKProvider } from '../provider/SDKProvider';

export enum EIP6963EventNames {
Announce = 'eip6963:announceProvider',
Request = 'eip6963:requestProvider', // eslint-disable-line @typescript-eslint/no-shadow
}

export interface EIP6963ProviderInfo {
uuid: string;
name: string;
icon: string;
rdns: string;
}

export interface EIP6963ProviderDetail {
info: EIP6963ProviderInfo;
provider: SDKProvider;
}

export type EIP6963AnnounceProviderEvent = CustomEvent & {
type: EIP6963EventNames.Announce;
detail: EIP6963ProviderDetail;
};

export function eip6963RequestProvider(): Promise<SDKProvider> {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error('eip6963RequestProvider timed out'));
}, 500);

window.addEventListener(
EIP6963EventNames.Announce,
(eip6963AnnounceProviderEvent) => {
const event =
eip6963AnnounceProviderEvent as EIP6963AnnounceProviderEvent;

const { detail: { info, provider } = {} } = event;

const { name, rdns, uuid } = info ?? {};

const isValid =
UUID_V4_REGEX.test(uuid) &&
name === METAMASK_EIP_6369_PROVIDER_INFO.NAME &&
rdns === METAMASK_EIP_6369_PROVIDER_INFO.RDNS;

if (isValid) {
clearTimeout(timeoutId);

resolve(provider);
}
},
);

window.dispatchEvent(new Event(EIP6963EventNames.Request));
});
}
71 changes: 51 additions & 20 deletions packages/sdk/src/utils/get-browser-extension.test.ts
Original file line number Diff line number Diff line change
@@ -1,66 +1,97 @@
import { getBrowserExtension } from './get-browser-extension';
import { eip6963RequestProvider } from './eip6963RequestProvider';

jest.mock('./eip6963RequestProvider');

describe('getBrowserExtension', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should throw an error if window is undefined', () => {
it('should throw an error if window is undefined', async () => {
global.window = undefined as any;

expect(() => getBrowserExtension({ mustBeMetaMask: true })).toThrow(
await expect(getBrowserExtension({ mustBeMetaMask: true })).rejects.toThrow(
'window not available',
);
});

it('should throw an error if ethereum is not found in window object', () => {
it('should return baseProvider if eip6963RequestProvider resolves successfully', async () => {
const mockProvider = { isMetaMask: true };
global.window = { ethereum: {} } as any;
(eip6963RequestProvider as jest.Mock).mockResolvedValue(mockProvider);

const res = await getBrowserExtension({ mustBeMetaMask: true });

expect(res).toStrictEqual(mockProvider);
});

it('should throw an error if eip6963RequestProvider rejects and ethereum is not found in window object', async () => {
(eip6963RequestProvider as jest.Mock).mockRejectedValue(
new Error('Provider request failed'),
);
global.window = {} as any;

expect(() => getBrowserExtension({ mustBeMetaMask: true })).toThrow(
await expect(getBrowserExtension({ mustBeMetaMask: true })).rejects.toThrow(
'Ethereum not found in window object',
);
});

it('should throw an error if no suitable provider is found', () => {
it('should throw an error if no suitable provider is found', async () => {
(eip6963RequestProvider as jest.Mock).mockRejectedValue(
new Error('Provider request failed'),
);
global.window = { ethereum: { providers: [] } } as any;

expect(() => getBrowserExtension({ mustBeMetaMask: true })).toThrow(
await expect(getBrowserExtension({ mustBeMetaMask: true })).rejects.toThrow(
'No suitable provider found',
);
});

it('should return MetaMask provider if mustBeMetaMask is true', () => {
it('should return MetaMask provider if mustBeMetaMask is true', async () => {
const mockProvider = { isMetaMask: true };
(eip6963RequestProvider as jest.Mock).mockRejectedValue(
new Error('Provider request failed'),
);
global.window = { ethereum: { providers: [mockProvider] } } as any;

expect(getBrowserExtension({ mustBeMetaMask: true })).toStrictEqual(
mockProvider,
);
const res = await getBrowserExtension({ mustBeMetaMask: true });

expect(res).toStrictEqual(mockProvider);
});

it('should return the first provider if mustBeMetaMask is false', () => {
it('should return the first provider if mustBeMetaMask is false', async () => {
const mockProvider = { isMetaMask: false };
(eip6963RequestProvider as jest.Mock).mockRejectedValue(
new Error('Provider request failed'),
);
global.window = { ethereum: { providers: [mockProvider] } } as any;

expect(getBrowserExtension({ mustBeMetaMask: false })).toStrictEqual(
mockProvider,
);
const res = await getBrowserExtension({ mustBeMetaMask: false });

expect(res).toStrictEqual(mockProvider);
});

it('should throw an error if mustBeMetaMask is true but MetaMask provider not found', () => {
it('should throw an error if mustBeMetaMask is true but MetaMask provider not found', async () => {
(eip6963RequestProvider as jest.Mock).mockRejectedValue(
new Error('Provider request failed'),
);
global.window = { ethereum: { isMetaMask: false } } as any;

expect(() => getBrowserExtension({ mustBeMetaMask: true })).toThrow(
await expect(getBrowserExtension({ mustBeMetaMask: true })).rejects.toThrow(
'MetaMask provider not found in Ethereum',
);
});

it('should return ethereum object if mustBeMetaMask is false and ethereum object exists', () => {
it('should return ethereum object if mustBeMetaMask is false and ethereum object exists', async () => {
const ethereumObj = { isMetaMask: true };
(eip6963RequestProvider as jest.Mock).mockRejectedValue(
new Error('Provider request failed'),
);
global.window = { ethereum: ethereumObj } as any;

expect(getBrowserExtension({ mustBeMetaMask: false })).toStrictEqual(
ethereumObj,
);
const res = await getBrowserExtension({ mustBeMetaMask: false });

expect(res).toStrictEqual(ethereumObj);
});
});
59 changes: 37 additions & 22 deletions packages/sdk/src/utils/get-browser-extension.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,51 @@
export function getBrowserExtension({
import { SDKProvider } from '../provider/SDKProvider';
import { eip6963RequestProvider } from './eip6963RequestProvider';

export async function getBrowserExtension({
mustBeMetaMask,
}: {
mustBeMetaMask: boolean;
}) {
}): Promise<SDKProvider> {
if (typeof window === 'undefined') {
throw new Error(`window not available`);
}

const { ethereum } = window as { ethereum: any };

if (!ethereum) {
throw new Error('Ethereum not found in window object');
}
try {
const baseProvider = await eip6963RequestProvider();

// The `providers` field is populated when CoinBase Wallet extension is also installed
// The expected object is an array of providers, the MetaMask provider is inside
// See https://docs.cloud.coinbase.com/wallet-sdk/docs/injected-provider-guidance for
if (Array.isArray(ethereum.providers)) {
const provider = mustBeMetaMask
? ethereum.providers.find((p: any) => p.isMetaMask)
: ethereum.providers[0];
return baseProvider;
} catch (e) {
const { ethereum } = window as unknown as {
ethereum:
| (SDKProvider & {
isMetaMask: boolean;
})
| { providers: any[] };
};

if (!provider) {
throw new Error('No suitable provider found');
if (!ethereum) {
throw new Error('Ethereum not found in window object');
}

return provider;
}
// The `providers` field is populated when CoinBase Wallet extension is also installed
// The expected object is an array of providers, the MetaMask provider is inside
// See https://docs.cloud.coinbase.com/wallet-sdk/docs/injected-provider-guidance for
if ('providers' in ethereum) {
if (Array.isArray(ethereum.providers)) {
const provider = mustBeMetaMask
? ethereum.providers.find((p: any) => p.isMetaMask)
: ethereum.providers[0];

if (mustBeMetaMask && !ethereum.isMetaMask) {
throw new Error('MetaMask provider not found in Ethereum');
}
if (!provider) {
throw new Error('No suitable provider found');
}

return provider as SDKProvider;
}
} else if (mustBeMetaMask && !ethereum.isMetaMask) {
throw new Error('MetaMask provider not found in Ethereum');
}

return ethereum;
return ethereum as SDKProvider;
}
}
2 changes: 1 addition & 1 deletion packages/sdk/src/utils/shouldInjectProvider.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { blockedDomainCheck } from './blockedDomainCheck';
import { doctypeCheck } from './doctypeCheck';
import { documentElementCheck } from './documentElementCheck';
import { suffixCheck } from './suffixCheck';
import { doctypeCheck } from './doctypeCheck';

export const shouldInjectProvider = () => {
if (
Expand Down

0 comments on commit b6c325d

Please sign in to comment.