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

refactor(sdk): modernize wallet provider detection with EIP-6963 #1126

Merged
merged 3 commits into from
Nov 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
28 changes: 23 additions & 5 deletions packages/sdk/src/services/Ethereum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,37 @@
autoRequestAccounts: false,
});

const proxiedProvieer = new Proxy(provider, {
// some common libraries, e.g. [email protected], can confict with our API.
const proxiedProvider = new Proxy(provider, {
// some common libraries, e.g. [email protected], can conflict with our API.
deleteProperty: () => true,
});

this.provider = proxiedProvieer;
this.provider = proxiedProvider;
this.sdkInstance = sdkInstance;

// Add try-catch block around window modifications
if (shouldSetOnWindow && typeof window !== 'undefined') {
setGlobalProvider(provider);
try {
setGlobalProvider(provider);

Check warning on line 49 in packages/sdk/src/services/Ethereum.ts

View check run for this annotation

Codecov / codecov/patch

packages/sdk/src/services/Ethereum.ts#L48-L49

Added lines #L48 - L49 were not covered by tests
} catch (error) {
logger(

Check warning on line 51 in packages/sdk/src/services/Ethereum.ts

View check run for this annotation

Codecov / codecov/patch

packages/sdk/src/services/Ethereum.ts#L51

Added line #L51 was not covered by tests
'[Ethereum] Unable to set global provider - window.ethereum may be read-only',
error,
);
// Continue execution without throwing
}
}

if (shouldShimWeb3 && typeof window !== 'undefined') {
shimWeb3(this.provider);
try {
shimWeb3(this.provider);

Check warning on line 61 in packages/sdk/src/services/Ethereum.ts

View check run for this annotation

Codecov / codecov/patch

packages/sdk/src/services/Ethereum.ts#L60-L61

Added lines #L60 - L61 were not covered by tests
} catch (error) {
logger(

Check warning on line 63 in packages/sdk/src/services/Ethereum.ts

View check run for this annotation

Codecov / codecov/patch

packages/sdk/src/services/Ethereum.ts#L63

Added line #L63 was not covered by tests
'[Ethereum] Unable to shim web3 - window.web3 may be read-only',
error,
);
// Continue execution without throwing
}
}

// Propagate display_uri events to the SDK
Expand Down
116 changes: 22 additions & 94 deletions packages/sdk/src/utils/get-browser-extension.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { MetaMaskSDK } from '../sdk';
import { getBrowserExtension } from './get-browser-extension';
import { eip6963RequestProvider } from './eip6963RequestProvider';
import { getBrowserExtension } from './get-browser-extension';

jest.mock('./eip6963RequestProvider');

Expand All @@ -9,127 +9,55 @@ describe('getBrowserExtension', () => {

beforeEach(() => {
jest.clearAllMocks();

global.window = {} as any;
sdkInstance = {
options: {
dappMetadata: {},
},
platformManager: {
getPlatformType: jest.fn(),
},
options: { dappMetadata: {} },
platformManager: { getPlatformType: jest.fn() },
} as unknown as MetaMaskSDK;
});

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

it('should throw if window is undefined', async () => {
global.window = undefined as any;
await expect(
getBrowserExtension({ mustBeMetaMask: true, sdkInstance }),
).rejects.toThrow('window not available');
});

it('should return baseProvider if eip6963RequestProvider resolves successfully', async () => {
it('should return provider from EIP-6963 if available', async () => {
const mockProvider = { isMetaMask: true };
global.window = { ethereum: {} } as any;
(eip6963RequestProvider as jest.Mock).mockResolvedValue(mockProvider);

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

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;

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

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;

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

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;

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

expect(res).toStrictEqual(mockProvider);
expect(result).toStrictEqual(expect.objectContaining({ isMetaMask: true }));
});

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

const res = await getBrowserExtension({
const result = await getBrowserExtension({
mustBeMetaMask: false,
sdkInstance,
});

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

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'),
expect(result).toStrictEqual(
expect.objectContaining({ isMetaMask: false }),
);
global.window = { ethereum: { isMetaMask: false } } as any;

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

it('should throw an error if mustBeMetaMask is true but uniswap wallet installed instead of Metamask', async () => {
(eip6963RequestProvider as jest.Mock).mockRejectedValue(
new Error('Provider request failed'),
);

global.window = {
ethereum: { isMetaMask: true, isUniswapWallet: true },
} as any;
it('should throw if no provider found', async () => {
(eip6963RequestProvider as jest.Mock).mockRejectedValue(new Error());
global.window = {} as any;

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

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;

const res = await getBrowserExtension({
mustBeMetaMask: false,
sdkInstance,
});

expect(res).toStrictEqual(ethereumObj);
getBrowserExtension({ mustBeMetaMask: false, sdkInstance }),
).rejects.toThrow('Provider not found');
});
});
83 changes: 10 additions & 73 deletions packages/sdk/src/utils/get-browser-extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,85 +11,22 @@ export async function getBrowserExtension({
sdkInstance: MetaMaskSDK;
}): Promise<MetaMaskInpageProvider> {
if (typeof window === 'undefined') {
throw new Error(`window not available`);
throw new Error('window not available');
}

let extensionProvider: MetaMaskInpageProvider;

try {
extensionProvider = await eip6963RequestProvider();
// Try EIP-6963 first
const extensionProvider = await eip6963RequestProvider();
return wrapExtensionProvider({ provider: extensionProvider, sdkInstance });
} catch (e) {
const { ethereum } = window;

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

// 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) =>
isRealMetaMaskExtensionInstalled(p),
)
: ethereum.providers[0];

if (!provider) {
throw new Error('No suitable provider found');
}

return wrapExtensionProvider({ provider, sdkInstance });
}
} else if (mustBeMetaMask && !isRealMetaMaskExtensionInstalled(ethereum)) {
throw new Error('MetaMask provider not found in Ethereum');
// Legacy fallback only for non-MetaMask cases
if (!mustBeMetaMask && window.ethereum) {
return wrapExtensionProvider({
provider: window.ethereum,
sdkInstance,
});
}

return wrapExtensionProvider({
provider: ethereum,
sdkInstance,
});
throw new Error('Provider not found');
}
}

function isRealMetaMaskExtensionInstalled(eth: any) {
if (!eth.isMetaMask) {
return false;
}

// Brave tries to make itself look like MetaMask
// Could also try RPC `web3_clientVersion` if following is unreliable
if (eth.isBraveWallet && !eth._events && !eth._state) {
return false;
}

// Other wallets that try to look like MetaMask
const flags: string[] = [
'isApexWallet',
'isAvalanche',
'isBitKeep',
'isBlockWallet',
'isKuCoinWallet',
'isMathWallet',
'isOkxWallet',
'isOKExWallet',
'isOneInchIOSWallet',
'isOneInchAndroidWallet',
'isOpera',
'isPortal',
'isRabby',
'isTokenPocket',
'isTokenary',
'isUniswapWallet',
'isZerion',
];
for (const flag of flags) {
if (eth[flag]) {
return false;
}
}

return true;
}
Loading