📄 src/web3/wallet/MetaMaskConnector.test.ts
D-OPEN SOVEREIGN
1import { describe, it, expect, vi, beforeEach } from 'vitest';
2
3const testUtils = vi.hoisted(() => {
4  function makeFakeWindow() {
5    class FakeWindow extends EventTarget {
6      ethereum: any = undefined;
7    }
8    return new FakeWindow();
9  }
10  (globalThis as any).window = makeFakeWindow();
11  return { makeFakeWindow };
12});
13
14import { MetaMaskConnector, createMetaMaskConnector } from './MetaMaskConnector';
15
16function fakeProvider(overrides: Partial<Record<string, any>> = {}) {
17  return {
18    isMetaMask: true,
19    request: vi.fn(),
20    on: vi.fn(),
21    removeListener: vi.fn(),
22    ...overrides,
23  };
24}
25
26beforeEach(() => {
27  (testUtils as any).win = (globalThis as any).window;
28  ((globalThis as any).window as any).ethereum = undefined;
29});
30
31describe('MetaMaskConnector.isAvailable', () => {
32  it('is false when window.ethereum is not set', () => {
33    expect(new MetaMaskConnector().isAvailable()).toBe(false);
34  });
35
36  it('is true when window.ethereum is set', () => {
37    ((globalThis as any).window as any).ethereum = fakeProvider();
38    expect(new MetaMaskConnector().isAvailable()).toBe(true);
39  });
40});
41
42describe('MetaMaskConnector.connect', () => {
43  it('throws when there is no provider at all', async () => {
44    const connector = new MetaMaskConnector();
45    await expect(connector.connect()).rejects.toThrow('MetaMask provider not found');
46  });
47
48  it('returns the connected address and chainId on success', async () => {
49    const provider = fakeProvider({
50      request: vi.fn(async ({ method }: any) => {
51        if (method === 'eth_requestAccounts') return ['0xABC'];
52        if (method === 'eth_chainId') return '0x45c'; // 1116
53        throw new Error(`unexpected method ${method}`);
54      }),
55    });
56    ((globalThis as any).window as any).ethereum = provider;
57
58    const result = await new MetaMaskConnector().connect();
59
60    expect(result).toEqual({ address: '0xABC', chainId: 1116 });
61  });
62
63  it('registers accountsChanged/chainChanged listeners exactly once even across repeated connect() calls', async () => {
64    const provider = fakeProvider({
65      request: vi.fn(async ({ method }: any) => (method === 'eth_requestAccounts' ? ['0xABC'] : '0x1')),
66    });
67    ((globalThis as any).window as any).ethereum = provider;
68    const connector = new MetaMaskConnector();
69
70    await connector.connect();
71    await connector.connect();
72
73    const registeredEvents = provider.on.mock.calls.map((c: any[]) => c[0]);
74    expect(registeredEvents.filter((e: string) => e === 'accountsChanged')).toHaveLength(1);
75    expect(registeredEvents.filter((e: string) => e === 'chainChanged')).toHaveLength(1);
76  });
77});
78
79describe('MetaMaskConnector.getAccounts', () => {
80  it('returns [] when there is no provider', async () => {
81    expect(await new MetaMaskConnector().getAccounts()).toEqual([]);
82  });
83
84  it('returns the accounts reported by the provider', async () => {
85    ((globalThis as any).window as any).ethereum = fakeProvider({
86      request: vi.fn().mockResolvedValue(['0xACC1', '0xACC2']),
87    });
88    expect(await new MetaMaskConnector().getAccounts()).toEqual(['0xACC1', '0xACC2']);
89  });
90
91  it('returns [] (not a rejection) when the provider request throws', async () => {
92    ((globalThis as any).window as any).ethereum = fakeProvider({
93      request: vi.fn().mockRejectedValue(new Error('user rejected')),
94    });
95    expect(await new MetaMaskConnector().getAccounts()).toEqual([]);
96  });
97
98  it('returns [] when the provider resolves accounts as null/undefined', async () => {
99    ((globalThis as any).window as any).ethereum = fakeProvider({ request: vi.fn().mockResolvedValue(null) });
100    expect(await new MetaMaskConnector().getAccounts()).toEqual([]);
101  });
102});
103
104describe('MetaMaskConnector.getChainId', () => {
105  it('returns 0 when there is no provider', async () => {
106    expect(await new MetaMaskConnector().getChainId()).toBe(0);
107  });
108
109  it('parses the hex chainId from the provider', async () => {
110    ((globalThis as any).window as any).ethereum = fakeProvider({ request: vi.fn().mockResolvedValue('0x1') });
111    expect(await new MetaMaskConnector().getChainId()).toBe(1);
112  });
113
114  it('returns 0 (not a rejection) when the provider request throws', async () => {
115    ((globalThis as any).window as any).ethereum = fakeProvider({ request: vi.fn().mockRejectedValue(new Error('down')) });
116    expect(await new MetaMaskConnector().getChainId()).toBe(0);
117  });
118});
119
120describe('MetaMaskConnector.requestSwitchChain', () => {
121  it('throws when there is no provider', async () => {
122    await expect(new MetaMaskConnector().requestSwitchChain(1116)).rejects.toThrow('MetaMask provider not found');
123  });
124
125  it('requests wallet_switchEthereumChain with the hex-encoded target chain', async () => {
126    const request = vi.fn().mockResolvedValue(undefined);
127    ((globalThis as any).window as any).ethereum = fakeProvider({ request });
128
129    await new MetaMaskConnector().requestSwitchChain(1116);
130
131    expect(request).toHaveBeenCalledWith({
132      method: 'wallet_switchEthereumChain',
133      params: [{ chainId: '0x45c' }],
134    });
135  });
136
137  it('adds the chain via wallet_addEthereumChain when MetaMask reports it is unknown (error code 4902)', async () => {
138    const request = vi.fn(async ({ method }: any) => {
139      if (method === 'wallet_switchEthereumChain') {
140        const err: any = new Error('Unrecognized chain');
141        err.code = 4902;
142        throw err;
143      }
144      if (method === 'wallet_addEthereumChain') return undefined;
145      throw new Error(`unexpected method ${method}`);
146    });
147    ((globalThis as any).window as any).ethereum = fakeProvider({ request });
148
149    await new MetaMaskConnector().requestSwitchChain(1116);
150
151    expect(request).toHaveBeenCalledWith(
152      expect.objectContaining({
153        method: 'wallet_addEthereumChain',
154        params: [
155          expect.objectContaining({
156            chainId: '0x45c',
157            chainName: 'Core Blockchain MainNet',
158            nativeCurrency: expect.objectContaining({ symbol: 'CORE', decimals: 18 }),
159          }),
160        ],
161      })
162    );
163  });
164
165  it('propagates a wallet_addEthereumChain failure (e.g. the user rejects adding the network)', async () => {
166    const request = vi.fn(async ({ method }: any) => {
167      if (method === 'wallet_switchEthereumChain') {
168        const err: any = new Error('Unrecognized chain');
169        err.code = 4902;
170        throw err;
171      }
172      if (method === 'wallet_addEthereumChain') throw new Error('user rejected adding chain');
173      throw new Error(`unexpected method ${method}`);
174    });
175    ((globalThis as any).window as any).ethereum = fakeProvider({ request });
176
177    await expect(new MetaMaskConnector().requestSwitchChain(1116)).rejects.toThrow('user rejected adding chain');
178  });
179
180  it('rethrows switch errors that are not the "unrecognized chain" code, without attempting to add it', async () => {
181    const request = vi.fn(async ({ method }: any) => {
182      if (method === 'wallet_switchEthereumChain') {
183        const err: any = new Error('User rejected the request');
184        err.code = 4001;
185        throw err;
186      }
187      throw new Error(`unexpected method ${method}`);
188    });
189    ((globalThis as any).window as any).ethereum = fakeProvider({ request });
190
191    await expect(new MetaMaskConnector().requestSwitchChain(1116)).rejects.toMatchObject({ code: 4001 });
192    expect(request).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'wallet_addEthereumChain' }));
193  });
194});
195
196describe('MetaMaskConnector: guard clauses for provider/account-dependent write methods', () => {
197  it('sendTransaction throws when there is no provider', async () => {
198    await expect(new MetaMaskConnector().sendTransaction({} as any, 1116)).rejects.toThrow('MetaMask provider not found');
199  });
200
201  it('sendTransaction throws when there is a provider but no connected accounts', async () => {
202    ((globalThis as any).window as any).ethereum = fakeProvider({ request: vi.fn().mockResolvedValue([]) });
203    await expect(new MetaMaskConnector().sendTransaction({} as any, 1116)).rejects.toThrow('No accounts connected');
204  });
205
206  it('signMessage throws when there is no provider', async () => {
207    await expect(new MetaMaskConnector().signMessage('hi')).rejects.toThrow('MetaMask provider not found');
208  });
209
210  it('signMessage throws when there is a provider but no connected accounts', async () => {
211    ((globalThis as any).window as any).ethereum = fakeProvider({ request: vi.fn().mockResolvedValue([]) });
212    await expect(new MetaMaskConnector().signMessage('hi')).rejects.toThrow('No accounts connected');
213  });
214
215  it('getWalletClient throws when there is no provider', async () => {
216    await expect(new MetaMaskConnector().getWalletClient(1116)).rejects.toThrow('MetaMask provider not found');
217  });
218
219  it('getWalletClient throws when there is a provider but no connected accounts', async () => {
220    ((globalThis as any).window as any).ethereum = fakeProvider({ request: vi.fn().mockResolvedValue([]) });
221    await expect(new MetaMaskConnector().getWalletClient(1116)).rejects.toThrow('No accounts connected');
222  });
223
224  it('getWalletClient returns a client bound to the connected account and requested chain', async () => {
225    ((globalThis as any).window as any).ethereum = fakeProvider({ request: vi.fn().mockResolvedValue(['0xACC1']) });
226    const client = await new MetaMaskConnector().getWalletClient(1116);
227    expect(client.account?.address).toBe('0xACC1');
228    expect(client.chain?.id).toBe(1116);
229  });
230});
231
232describe('MetaMaskConnector: account/chain change subscriptions', () => {
233  it('onAccountChanged fires registered callbacks with the new address when the provider emits accountsChanged', async () => {
234    let accountsChangedHandler: ((accounts: string[]) => void) | undefined;
235    const provider = fakeProvider({
236      on: vi.fn((event: string, handler: any) => {
237        if (event === 'accountsChanged') accountsChangedHandler = handler;
238      }),
239    });
240    ((globalThis as any).window as any).ethereum = provider;
241    const connector = new MetaMaskConnector();
242    const cb = vi.fn();
243    connector.onAccountChanged(cb);
244
245    accountsChangedHandler?.(['0xNEW']);
246
247    expect(cb).toHaveBeenCalledWith('0xNEW');
248  });
249
250  it('onAccountChanged reports an empty string (not undefined) when the account list becomes empty', async () => {
251    let accountsChangedHandler: ((accounts: string[]) => void) | undefined;
252    const provider = fakeProvider({
253      on: vi.fn((event: string, handler: any) => {
254        if (event === 'accountsChanged') accountsChangedHandler = handler;
255      }),
256    });
257    ((globalThis as any).window as any).ethereum = provider;
258    const connector = new MetaMaskConnector();
259    const cb = vi.fn();
260    connector.onAccountChanged(cb);
261
262    accountsChangedHandler?.([]);
263
264    expect(cb).toHaveBeenCalledWith('');
265  });
266
267  it('unsubscribing an account-changed callback stops it from firing on subsequent events', async () => {
268    let accountsChangedHandler: ((accounts: string[]) => void) | undefined;
269    const provider = fakeProvider({
270      on: vi.fn((event: string, handler: any) => {
271        if (event === 'accountsChanged') accountsChangedHandler = handler;
272      }),
273    });
274    ((globalThis as any).window as any).ethereum = provider;
275    const connector = new MetaMaskConnector();
276    const cb = vi.fn();
277    const unsubscribe = connector.onAccountChanged(cb);
278    unsubscribe();
279
280    accountsChangedHandler?.(['0xNEW']);
281
282    expect(cb).not.toHaveBeenCalled();
283  });
284
285  it('onChainChanged fires registered callbacks with the parsed decimal chainId', async () => {
286    let chainChangedHandler: ((chainIdHex: string) => void) | undefined;
287    const provider = fakeProvider({
288      on: vi.fn((event: string, handler: any) => {
289        if (event === 'chainChanged') chainChangedHandler = handler;
290      }),
291    });
292    ((globalThis as any).window as any).ethereum = provider;
293    const connector = new MetaMaskConnector();
294    const cb = vi.fn();
295    connector.onChainChanged(cb);
296
297    chainChangedHandler?.('0x45c');
298
299    expect(cb).toHaveBeenCalledWith(1116);
300  });
301
302  it('does not register provider listeners twice when both onAccountChanged and onChainChanged are used', async () => {
303    const provider = fakeProvider();
304    ((globalThis as any).window as any).ethereum = provider;
305    const connector = new MetaMaskConnector();
306
307    connector.onAccountChanged(vi.fn());
308    connector.onChainChanged(vi.fn());
309
310    expect(provider.on).toHaveBeenCalledTimes(2); // one 'accountsChanged', one 'chainChanged' — not duplicated
311  });
312});
313
314describe('MetaMaskConnector.disconnect', () => {
315  it('does not throw (EIP-1193 has no programmatic disconnect)', () => {
316    expect(() => new MetaMaskConnector().disconnect()).not.toThrow();
317  });
318});
319
320describe('createMetaMaskConnector', () => {
321  it('returns a fresh MetaMaskConnector instance', () => {
322    expect(createMetaMaskConnector()).toBeInstanceOf(MetaMaskConnector);
323  });
324});
325
326describe('EIP-6963 provider announcement', () => {
327  it('prefers an announced io.metamask provider over window.ethereum', async () => {
328    vi.resetModules();
329    const win = testUtils.makeFakeWindow();
330    (globalThis as any).window = win;
331    const fallbackProvider = fakeProvider({ request: vi.fn().mockResolvedValue(['0xFALLBACK']) });
332    const announcedProvider = fakeProvider({ request: vi.fn().mockResolvedValue(['0xANNOUNCED']) });
333    win.ethereum = fallbackProvider;
334
335    const { MetaMaskConnector: FreshConnector } = await import('./MetaMaskConnector');
336    win.dispatchEvent(
337      new CustomEvent('eip6963:announceProvider', {
338        detail: { info: { uuid: '1', name: 'MetaMask', icon: '', rdns: 'io.metamask' }, provider: announcedProvider },
339      })
340    );
341
342    const accounts = await new FreshConnector().getAccounts();
343
344    expect(accounts).toEqual(['0xANNOUNCED']);
345    expect(announcedProvider.request).toHaveBeenCalled();
346    expect(fallbackProvider.request).not.toHaveBeenCalled();
347  });
348
349  it('accepts the MetaMask Flask rdns as well', async () => {
350    vi.resetModules();
351    const win = testUtils.makeFakeWindow();
352    (globalThis as any).window = win;
353    const announcedProvider = fakeProvider({ request: vi.fn().mockResolvedValue(['0xFLASK']) });
354
355    const { MetaMaskConnector: FreshConnector } = await import('./MetaMaskConnector');
356    win.dispatchEvent(
357      new CustomEvent('eip6963:announceProvider', {
358        detail: { info: { uuid: '1', name: 'MetaMask Flask', icon: '', rdns: 'io.metamask.flask' }, provider: announcedProvider },
359      })
360    );
361
362    expect(await new FreshConnector().getAccounts()).toEqual(['0xFLASK']);
363  });
364
365  it('ignores an announced provider from an unrelated wallet (different rdns)', async () => {
366    vi.resetModules();
367    const win = testUtils.makeFakeWindow();
368    (globalThis as any).window = win;
369    const fallbackProvider = fakeProvider({ request: vi.fn().mockResolvedValue(['0xFALLBACK']) });
370    const rabbyProvider = fakeProvider({ request: vi.fn().mockResolvedValue(['0xRABBY']) });
371    win.ethereum = fallbackProvider;
372
373    const { MetaMaskConnector: FreshConnector } = await import('./MetaMaskConnector');
374    win.dispatchEvent(
375      new CustomEvent('eip6963:announceProvider', {
376        detail: { info: { uuid: '2', name: 'Rabby', icon: '', rdns: 'io.rabby' }, provider: rabbyProvider },
377      })
378    );
379
380    const accounts = await new FreshConnector().getAccounts();
381
382    expect(accounts).toEqual(['0xFALLBACK']);
383    expect(rabbyProvider.request).not.toHaveBeenCalled();
384  });
385});
386