📄 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