@meshconnect/uwc-bridge-parent 1.4.2 → 1.5.0-snapshot.d9f6da2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/BridgeParent.d.ts +76 -0
- package/dist/BridgeParent.d.ts.map +1 -1
- package/dist/BridgeParent.js +331 -6
- package/dist/BridgeParent.js.map +1 -1
- package/dist/BridgeParent.test.js +621 -28
- package/dist/BridgeParent.test.js.map +1 -1
- package/package.json +1 -1
- package/src/BridgeParent.test.ts +923 -28
- package/src/BridgeParent.ts +455 -8
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import * as Comlink from 'comlink';
|
|
2
3
|
// Mock Comlink. windowEndpoint mirrors the real one (comlink 4.4.2): its
|
|
3
4
|
// add/removeEventListener are window's own methods bound to window. This lets the
|
|
4
5
|
// destroy tests assert real attachment/detachment on window via dispatchEvent,
|
|
@@ -76,14 +77,14 @@ describe('BridgeParent', () => {
|
|
|
76
77
|
const { BridgeParent } = await import('./BridgeParent');
|
|
77
78
|
new BridgeParent(mockIframe);
|
|
78
79
|
// Allow the async initializeConnection to run
|
|
79
|
-
await vi.advanceTimersByTimeAsync(
|
|
80
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
80
81
|
expect(Comlink.expose).toHaveBeenCalled();
|
|
81
82
|
});
|
|
82
83
|
it('should call Comlink.windowEndpoint with iframe contentWindow', async () => {
|
|
83
84
|
const Comlink = await import('comlink');
|
|
84
85
|
const { BridgeParent } = await import('./BridgeParent');
|
|
85
86
|
new BridgeParent(mockIframe);
|
|
86
|
-
await vi.advanceTimersByTimeAsync(
|
|
87
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
87
88
|
expect(Comlink.windowEndpoint).toHaveBeenCalledWith(mockIframe.contentWindow, window, 'https://child.example.com');
|
|
88
89
|
});
|
|
89
90
|
it('should not initialize if iframe has no contentWindow', async () => {
|
|
@@ -91,7 +92,7 @@ describe('BridgeParent', () => {
|
|
|
91
92
|
const { BridgeParent } = await import('./BridgeParent');
|
|
92
93
|
const iframeNoContent = { src: 'https://example.com' };
|
|
93
94
|
new BridgeParent(iframeNoContent);
|
|
94
|
-
await vi.advanceTimersByTimeAsync(
|
|
95
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
95
96
|
expect(Comlink.expose).not.toHaveBeenCalled();
|
|
96
97
|
});
|
|
97
98
|
});
|
|
@@ -154,7 +155,7 @@ describe('BridgeParent', () => {
|
|
|
154
155
|
ep.addEventListener('message', exposedListener);
|
|
155
156
|
});
|
|
156
157
|
const bridge = new BridgeParent(mockIframe);
|
|
157
|
-
await vi.advanceTimersByTimeAsync(
|
|
158
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
158
159
|
// Live before teardown: a window 'message' from the iframe reaches the
|
|
159
160
|
// listener (source must match — the guard drops anything else).
|
|
160
161
|
const liveEvt = new MessageEvent('message', { data: { id: 1 } });
|
|
@@ -198,7 +199,7 @@ describe('BridgeParent', () => {
|
|
|
198
199
|
const Comlink = await import('comlink');
|
|
199
200
|
const { BridgeParent } = await import('./BridgeParent');
|
|
200
201
|
const bridge = new BridgeParent(mockIframe);
|
|
201
|
-
await vi.advanceTimersByTimeAsync(
|
|
202
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
202
203
|
const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0];
|
|
203
204
|
// Solana readiness is true right after init — a real precondition, not a setup.
|
|
204
205
|
expect(exposedAPI.walletStandardWalletsReady).toBe(true);
|
|
@@ -215,13 +216,13 @@ describe('BridgeParent', () => {
|
|
|
215
216
|
const { BridgeParent } = await import('./BridgeParent');
|
|
216
217
|
const iframeNoContent = { src: 'https://child.example.com' };
|
|
217
218
|
const bridge = new BridgeParent(iframeNoContent);
|
|
218
|
-
await vi.advanceTimersByTimeAsync(
|
|
219
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
219
220
|
expect(() => bridge.destroy()).not.toThrow();
|
|
220
221
|
});
|
|
221
222
|
it('clears state and tolerates a second destroy()', async () => {
|
|
222
223
|
const { BridgeParent } = await import('./BridgeParent');
|
|
223
224
|
const bridge = new BridgeParent(mockIframe);
|
|
224
|
-
await vi.advanceTimersByTimeAsync(
|
|
225
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
225
226
|
bridge.destroy();
|
|
226
227
|
expect(() => bridge.destroy()).not.toThrow();
|
|
227
228
|
});
|
|
@@ -231,7 +232,7 @@ describe('BridgeParent', () => {
|
|
|
231
232
|
const dispatchSpy = vi.spyOn(window, 'dispatchEvent');
|
|
232
233
|
const { BridgeParent } = await import('./BridgeParent');
|
|
233
234
|
new BridgeParent(mockIframe);
|
|
234
|
-
await vi.advanceTimersByTimeAsync(
|
|
235
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
235
236
|
expect(dispatchSpy).toHaveBeenCalledWith(expect.objectContaining({ type: 'eip6963:requestProvider' }));
|
|
236
237
|
});
|
|
237
238
|
it('should collect announced wallets', async () => {
|
|
@@ -253,7 +254,7 @@ describe('BridgeParent', () => {
|
|
|
253
254
|
}));
|
|
254
255
|
});
|
|
255
256
|
new BridgeParent(mockIframe);
|
|
256
|
-
await vi.advanceTimersByTimeAsync(
|
|
257
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
257
258
|
// The exposed parentAPI should contain the discovered wallet
|
|
258
259
|
const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0];
|
|
259
260
|
expect(exposedAPI).toBeDefined();
|
|
@@ -283,7 +284,7 @@ describe('BridgeParent', () => {
|
|
|
283
284
|
window.dispatchEvent(event);
|
|
284
285
|
});
|
|
285
286
|
new BridgeParent(mockIframe);
|
|
286
|
-
await vi.advanceTimersByTimeAsync(
|
|
287
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
287
288
|
const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0];
|
|
288
289
|
expect(exposedAPI.eip6963Wallets).toHaveLength(1);
|
|
289
290
|
});
|
|
@@ -311,7 +312,7 @@ describe('BridgeParent', () => {
|
|
|
311
312
|
register: vi.fn()
|
|
312
313
|
});
|
|
313
314
|
new BridgeParent(mockIframe);
|
|
314
|
-
await vi.advanceTimersByTimeAsync(
|
|
315
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
315
316
|
const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0];
|
|
316
317
|
expect(exposedAPI.walletStandardWallets).toHaveLength(1);
|
|
317
318
|
expect(exposedAPI.walletStandardWallets[0].name).toBe('Phantom');
|
|
@@ -327,7 +328,7 @@ describe('BridgeParent', () => {
|
|
|
327
328
|
register: vi.fn()
|
|
328
329
|
});
|
|
329
330
|
new BridgeParent(mockIframe);
|
|
330
|
-
await vi.advanceTimersByTimeAsync(
|
|
331
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
331
332
|
const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0];
|
|
332
333
|
expect(exposedAPI.walletStandardWallets).toEqual([]);
|
|
333
334
|
});
|
|
@@ -337,7 +338,7 @@ describe('BridgeParent', () => {
|
|
|
337
338
|
const Comlink = await import('comlink');
|
|
338
339
|
const { BridgeParent } = await import('./BridgeParent');
|
|
339
340
|
new BridgeParent(mockIframe);
|
|
340
|
-
await vi.advanceTimersByTimeAsync(
|
|
341
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
341
342
|
const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0];
|
|
342
343
|
expect(exposedAPI.tronWallets).toEqual([]);
|
|
343
344
|
expect(exposedAPI.tronWalletsReady).toBe(false);
|
|
@@ -363,7 +364,7 @@ describe('BridgeParent', () => {
|
|
|
363
364
|
}
|
|
364
365
|
};
|
|
365
366
|
new BridgeParent(mockIframe);
|
|
366
|
-
await vi.advanceTimersByTimeAsync(
|
|
367
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
367
368
|
const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0];
|
|
368
369
|
// Simulate child calling discoverTronWallets
|
|
369
370
|
exposedAPI.discoverTronWallets(['tronLink']);
|
|
@@ -398,7 +399,7 @@ describe('BridgeParent', () => {
|
|
|
398
399
|
}
|
|
399
400
|
};
|
|
400
401
|
new BridgeParent(mockIframe);
|
|
401
|
-
await vi.advanceTimersByTimeAsync(
|
|
402
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
402
403
|
const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0];
|
|
403
404
|
exposedAPI.discoverTronWallets(['tokenpocket.tron']);
|
|
404
405
|
expect(exposedAPI.tronWallets).toHaveLength(1);
|
|
@@ -409,7 +410,7 @@ describe('BridgeParent', () => {
|
|
|
409
410
|
const Comlink = await import('comlink');
|
|
410
411
|
const { BridgeParent } = await import('./BridgeParent');
|
|
411
412
|
new BridgeParent(mockIframe);
|
|
412
|
-
await vi.advanceTimersByTimeAsync(
|
|
413
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
413
414
|
const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0];
|
|
414
415
|
exposedAPI.discoverTronWallets([]);
|
|
415
416
|
expect(exposedAPI.tronWallets).toEqual([]);
|
|
@@ -423,7 +424,7 @@ describe('BridgeParent', () => {
|
|
|
423
424
|
tronWeb: { notTrx: true }
|
|
424
425
|
};
|
|
425
426
|
new BridgeParent(mockIframe);
|
|
426
|
-
await vi.advanceTimersByTimeAsync(
|
|
427
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
427
428
|
const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0];
|
|
428
429
|
exposedAPI.discoverTronWallets(['badWallet']);
|
|
429
430
|
expect(exposedAPI.tronWallets).toEqual([]);
|
|
@@ -450,7 +451,7 @@ describe('BridgeParent', () => {
|
|
|
450
451
|
}
|
|
451
452
|
};
|
|
452
453
|
new BridgeParent(mockIframe);
|
|
453
|
-
await vi.advanceTimersByTimeAsync(
|
|
454
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
454
455
|
const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0];
|
|
455
456
|
exposedAPI.discoverTronWallets(['tronLink', 'tronLink']);
|
|
456
457
|
expect(exposedAPI.tronWallets).toHaveLength(1);
|
|
@@ -462,7 +463,7 @@ describe('BridgeParent', () => {
|
|
|
462
463
|
const Comlink = await import('comlink');
|
|
463
464
|
const { BridgeParent } = await import('./BridgeParent');
|
|
464
465
|
new BridgeParent(mockIframe);
|
|
465
|
-
await vi.advanceTimersByTimeAsync(
|
|
466
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
466
467
|
const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0];
|
|
467
468
|
expect(exposedAPI.tonWallets).toEqual([]);
|
|
468
469
|
expect(exposedAPI.tonWalletsReady).toBe(false);
|
|
@@ -498,7 +499,7 @@ describe('BridgeParent', () => {
|
|
|
498
499
|
}
|
|
499
500
|
};
|
|
500
501
|
new BridgeParent(mockIframe);
|
|
501
|
-
await vi.advanceTimersByTimeAsync(
|
|
502
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
502
503
|
const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0];
|
|
503
504
|
exposedAPI.discoverTonWallets(['trustwalletTon']);
|
|
504
505
|
expect(exposedAPI.tonWalletsReady).toBe(true);
|
|
@@ -538,7 +539,7 @@ describe('BridgeParent', () => {
|
|
|
538
539
|
}
|
|
539
540
|
};
|
|
540
541
|
new BridgeParent(mockIframe);
|
|
541
|
-
await vi.advanceTimersByTimeAsync(
|
|
542
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
542
543
|
const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0];
|
|
543
544
|
exposedAPI.discoverTonWallets(['trustwalletTon']);
|
|
544
545
|
const provider = exposedAPI.tonWallets[0].provider;
|
|
@@ -557,7 +558,7 @@ describe('BridgeParent', () => {
|
|
|
557
558
|
const { BridgeParent } = await import('./BridgeParent');
|
|
558
559
|
window.fakeTon = { tonconnect: { notABridge: true } };
|
|
559
560
|
new BridgeParent(mockIframe);
|
|
560
|
-
await vi.advanceTimersByTimeAsync(
|
|
561
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
561
562
|
const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0];
|
|
562
563
|
exposedAPI.discoverTonWallets(['fakeTon']);
|
|
563
564
|
expect(exposedAPI.tonWallets).toEqual([]);
|
|
@@ -575,7 +576,7 @@ describe('BridgeParent', () => {
|
|
|
575
576
|
}
|
|
576
577
|
};
|
|
577
578
|
new BridgeParent(mockIframe);
|
|
578
|
-
await vi.advanceTimersByTimeAsync(
|
|
579
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
579
580
|
const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0];
|
|
580
581
|
exposedAPI.discoverTonWallets(['trustwalletTon', 'trustwalletTon']);
|
|
581
582
|
expect(exposedAPI.tonWallets).toHaveLength(1);
|
|
@@ -620,7 +621,7 @@ describe('BridgeParent', () => {
|
|
|
620
621
|
const { BridgeParent } = await import('./BridgeParent');
|
|
621
622
|
baseAdapterMock.readyState = 'Installed';
|
|
622
623
|
new BridgeParent(mockIframe);
|
|
623
|
-
await vi.advanceTimersByTimeAsync(
|
|
624
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
624
625
|
const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0];
|
|
625
626
|
const baseWallet = exposedAPI.walletStandardWallets.find((w) => w.name === 'Base Wallet');
|
|
626
627
|
expect(baseWallet).toBeDefined();
|
|
@@ -634,7 +635,7 @@ describe('BridgeParent', () => {
|
|
|
634
635
|
const { BridgeParent } = await import('./BridgeParent');
|
|
635
636
|
baseAdapterMock.readyState = 'Loadable';
|
|
636
637
|
new BridgeParent(mockIframe);
|
|
637
|
-
await vi.advanceTimersByTimeAsync(
|
|
638
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
638
639
|
const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0];
|
|
639
640
|
const baseWallet = exposedAPI.walletStandardWallets.find((w) => w.name === 'Base Wallet');
|
|
640
641
|
expect(baseWallet).toBeDefined();
|
|
@@ -643,7 +644,7 @@ describe('BridgeParent', () => {
|
|
|
643
644
|
const Comlink = await import('comlink');
|
|
644
645
|
const { BridgeParent } = await import('./BridgeParent');
|
|
645
646
|
new BridgeParent(mockIframe);
|
|
646
|
-
await vi.advanceTimersByTimeAsync(
|
|
647
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
647
648
|
const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0];
|
|
648
649
|
const baseWallet = exposedAPI.walletStandardWallets.find((w) => w.name === 'Base Wallet');
|
|
649
650
|
expect(baseWallet).toBeUndefined();
|
|
@@ -665,7 +666,7 @@ describe('BridgeParent', () => {
|
|
|
665
666
|
});
|
|
666
667
|
baseAdapterMock.readyState = 'Installed';
|
|
667
668
|
new BridgeParent(mockIframe);
|
|
668
|
-
await vi.advanceTimersByTimeAsync(
|
|
669
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
669
670
|
const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0];
|
|
670
671
|
const baseWallets = exposedAPI.walletStandardWallets.filter((w) => w.name === 'Base Wallet');
|
|
671
672
|
expect(baseWallets).toHaveLength(1);
|
|
@@ -676,11 +677,603 @@ describe('BridgeParent', () => {
|
|
|
676
677
|
const { BridgeParent } = await import('./BridgeParent');
|
|
677
678
|
baseAdapterMock.shouldThrow = true;
|
|
678
679
|
new BridgeParent(mockIframe);
|
|
679
|
-
await vi.advanceTimersByTimeAsync(
|
|
680
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
680
681
|
const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0];
|
|
681
682
|
expect(exposedAPI).toBeDefined();
|
|
682
683
|
expect(exposedAPI.walletStandardWallets).toEqual([]);
|
|
683
684
|
});
|
|
684
685
|
});
|
|
686
|
+
describe('subscribeEip6963ProviderEvent', () => {
|
|
687
|
+
function makeMockProvider() {
|
|
688
|
+
return {
|
|
689
|
+
request: vi.fn(),
|
|
690
|
+
on: vi.fn(),
|
|
691
|
+
removeListener: vi.fn()
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
// Lets the real one-shot discovery window (300ms) resolve first, THEN
|
|
695
|
+
// overwrites eip6963Wallets with the test's fixture — otherwise the
|
|
696
|
+
// discovery's own `.then()` (see BridgeParent.ts's initializeConnection)
|
|
697
|
+
// would overwrite the fixture with `[]` out from under the test the
|
|
698
|
+
// moment `subscribeEip6963ProviderEvent`'s internal await lets it run.
|
|
699
|
+
async function readyBridgeParent(mockIframe) {
|
|
700
|
+
const { BridgeParent } = await import('./BridgeParent');
|
|
701
|
+
const bridgeParent = new BridgeParent(mockIframe);
|
|
702
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
703
|
+
return bridgeParent;
|
|
704
|
+
}
|
|
705
|
+
it('attaches a real listener to the detected provider via a LOCAL wrapper, not the raw callback', async () => {
|
|
706
|
+
const bridgeParent = await readyBridgeParent(mockIframe);
|
|
707
|
+
const provider = makeMockProvider();
|
|
708
|
+
bridgeParent.parentAPI.eip6963Wallets = [
|
|
709
|
+
{
|
|
710
|
+
uuid: 'uuid-1',
|
|
711
|
+
name: 'MetaMask',
|
|
712
|
+
icon: '',
|
|
713
|
+
rdns: 'io.metamask',
|
|
714
|
+
provider
|
|
715
|
+
}
|
|
716
|
+
];
|
|
717
|
+
const callback = vi.fn();
|
|
718
|
+
const teardown = await bridgeParent.parentAPI.subscribeEip6963ProviderEvent('uuid-1', 'accountsChanged', callback);
|
|
719
|
+
expect(provider.on).toHaveBeenCalledWith('accountsChanged', expect.any(Function));
|
|
720
|
+
// Regression guard: `callback` here IS the Comlink proxy the child
|
|
721
|
+
// passed. Registering it directly on the real provider means Comlink
|
|
722
|
+
// must serialize EVERY argument the wallet's own dispatch calls it
|
|
723
|
+
// with — some wallets' internal EventEmitter pass extra, non-EIP-1193
|
|
724
|
+
// arguments (observed in production: a wallet's own internal handler
|
|
725
|
+
// function) alongside the real payload, and Comlink can't clone a
|
|
726
|
+
// function, throwing "DataCloneError" the moment the wallet fires the
|
|
727
|
+
// event. The registered listener must NOT be `callback` itself.
|
|
728
|
+
expect(provider.on).not.toHaveBeenCalledWith('accountsChanged', callback);
|
|
729
|
+
expect(typeof teardown).toBe('function');
|
|
730
|
+
// Regression guard: the teardown crosses back to the child as a
|
|
731
|
+
// Comlink return value — an un-proxied function throws "Unserializable
|
|
732
|
+
// return value" the moment a real postMessage transport (not this
|
|
733
|
+
// identity-mocked one) tries to deserialize it, exactly like TON's
|
|
734
|
+
// `listen()` teardown above must be (and is) proxied.
|
|
735
|
+
expect(Comlink.proxy).toHaveBeenCalledWith(expect.any(Function));
|
|
736
|
+
});
|
|
737
|
+
it('the local wrapper forwards ONLY the first argument to callback, discarding any extra ones the wallet passes', async () => {
|
|
738
|
+
const bridgeParent = await readyBridgeParent(mockIframe);
|
|
739
|
+
const provider = makeMockProvider();
|
|
740
|
+
bridgeParent.parentAPI.eip6963Wallets = [
|
|
741
|
+
{
|
|
742
|
+
uuid: 'uuid-1',
|
|
743
|
+
name: 'MetaMask',
|
|
744
|
+
icon: '',
|
|
745
|
+
rdns: 'io.metamask',
|
|
746
|
+
provider
|
|
747
|
+
}
|
|
748
|
+
];
|
|
749
|
+
const callback = vi.fn();
|
|
750
|
+
await bridgeParent.parentAPI.subscribeEip6963ProviderEvent('uuid-1', 'accountsChanged', callback);
|
|
751
|
+
const registeredWrapper = provider.on.mock.calls[0]?.[1];
|
|
752
|
+
const extraNonCloneableArg = () => { };
|
|
753
|
+
registeredWrapper(['0xNEW'], extraNonCloneableArg, { some: 'extra' });
|
|
754
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
755
|
+
expect(callback).toHaveBeenCalledWith(['0xNEW']);
|
|
756
|
+
});
|
|
757
|
+
it("teardown returns undefined even when the real EventEmitter's removeListener returns `this` (the whole provider)", async () => {
|
|
758
|
+
// By EventEmitter convention (Node's `events` module, and wallet
|
|
759
|
+
// providers built on it, e.g. Trust Wallet's inpage.js), .removeListener
|
|
760
|
+
// returns `this` for chaining. An expression-bodied teardown
|
|
761
|
+
// (`() => provider.removeListener?.(...)`) would implicitly return that
|
|
762
|
+
// — the whole provider object — across the Comlink boundary, which is
|
|
763
|
+
// itself unserializable and reproduces the exact same failure one level
|
|
764
|
+
// down, the moment a caller actually invokes the teardown.
|
|
765
|
+
const bridgeParent = await readyBridgeParent(mockIframe);
|
|
766
|
+
const provider = {
|
|
767
|
+
request: vi.fn(),
|
|
768
|
+
on: vi.fn(),
|
|
769
|
+
removeListener: vi.fn(function () {
|
|
770
|
+
return this;
|
|
771
|
+
})
|
|
772
|
+
};
|
|
773
|
+
bridgeParent.parentAPI.eip6963Wallets = [
|
|
774
|
+
{
|
|
775
|
+
uuid: 'uuid-1',
|
|
776
|
+
name: 'Trust Wallet',
|
|
777
|
+
icon: '',
|
|
778
|
+
rdns: 'com.trustwallet.app',
|
|
779
|
+
provider
|
|
780
|
+
}
|
|
781
|
+
];
|
|
782
|
+
const teardown = await bridgeParent.parentAPI.subscribeEip6963ProviderEvent('uuid-1', 'accountsChanged', vi.fn());
|
|
783
|
+
expect(teardown()).toBeUndefined();
|
|
784
|
+
});
|
|
785
|
+
it('teardown calls removeListener with the SAME wrapper reference registered via .on', async () => {
|
|
786
|
+
const bridgeParent = await readyBridgeParent(mockIframe);
|
|
787
|
+
const provider = makeMockProvider();
|
|
788
|
+
bridgeParent.parentAPI.eip6963Wallets = [
|
|
789
|
+
{
|
|
790
|
+
uuid: 'uuid-1',
|
|
791
|
+
name: 'MetaMask',
|
|
792
|
+
icon: '',
|
|
793
|
+
rdns: 'io.metamask',
|
|
794
|
+
provider
|
|
795
|
+
}
|
|
796
|
+
];
|
|
797
|
+
const callback = vi.fn();
|
|
798
|
+
const teardown = await bridgeParent.parentAPI.subscribeEip6963ProviderEvent('uuid-1', 'accountsChanged', callback);
|
|
799
|
+
const registeredWrapper = provider.on.mock.calls[0]?.[1];
|
|
800
|
+
teardown?.();
|
|
801
|
+
expect(provider.removeListener).toHaveBeenCalledWith('accountsChanged', registeredWrapper);
|
|
802
|
+
});
|
|
803
|
+
it('throws for an unknown uuid (wallet not detected)', async () => {
|
|
804
|
+
const bridgeParent = await readyBridgeParent(mockIframe);
|
|
805
|
+
bridgeParent.parentAPI.eip6963Wallets = [];
|
|
806
|
+
await expect(bridgeParent.parentAPI.subscribeEip6963ProviderEvent('unknown-uuid', 'accountsChanged', vi.fn())).rejects.toThrow(/no wallet found for uuid/);
|
|
807
|
+
});
|
|
808
|
+
it('throws when no wallet matches the given uuid (the uuid-mismatch bug this guards against)', async () => {
|
|
809
|
+
const bridgeParent = await readyBridgeParent(mockIframe);
|
|
810
|
+
bridgeParent.parentAPI.eip6963Wallets = [];
|
|
811
|
+
await expect(bridgeParent.parentAPI.subscribeEip6963ProviderEvent('uuid-not-discovered-by-this-parent', 'accountsChanged', vi.fn())).rejects.toThrow(/no wallet found for uuid/);
|
|
812
|
+
});
|
|
813
|
+
it('falls back to matching by rdns when the uuid does not match (the uuid-mismatch bug this closes structurally)', async () => {
|
|
814
|
+
const bridgeParent = await readyBridgeParent(mockIframe);
|
|
815
|
+
const provider = makeMockProvider();
|
|
816
|
+
bridgeParent.parentAPI.eip6963Wallets = [
|
|
817
|
+
{
|
|
818
|
+
uuid: 'uuid-from-top-frame',
|
|
819
|
+
name: 'MetaMask',
|
|
820
|
+
icon: '',
|
|
821
|
+
rdns: 'io.metamask',
|
|
822
|
+
provider
|
|
823
|
+
}
|
|
824
|
+
];
|
|
825
|
+
const callback = vi.fn();
|
|
826
|
+
const teardown = await bridgeParent.parentAPI.subscribeEip6963ProviderEvent('uuid-from-local-iframe-injection', 'accountsChanged', callback, 'io.metamask');
|
|
827
|
+
expect(typeof teardown).toBe('function');
|
|
828
|
+
expect(provider.on).toHaveBeenCalledWith('accountsChanged', expect.any(Function));
|
|
829
|
+
});
|
|
830
|
+
it('still throws when neither uuid nor rdns match anything', async () => {
|
|
831
|
+
const bridgeParent = await readyBridgeParent(mockIframe);
|
|
832
|
+
bridgeParent.parentAPI.eip6963Wallets = [];
|
|
833
|
+
await expect(bridgeParent.parentAPI.subscribeEip6963ProviderEvent('uuid-not-discovered-by-this-parent', 'accountsChanged', vi.fn(), 'com.nonexistent.wallet')).rejects.toThrow(/no wallet found for uuid/);
|
|
834
|
+
});
|
|
835
|
+
it('awaits the in-flight discovery window instead of failing permanently when subscribe is called BEFORE it resolves', async () => {
|
|
836
|
+
// Regression for the startup race: a subscribe call arriving while the
|
|
837
|
+
// one-shot 300ms discovery window is still open (e.g. immediately on
|
|
838
|
+
// page load, during a session restore) used to look up against a
|
|
839
|
+
// still-empty eip6963Wallets, miss, and throw — permanently, since the
|
|
840
|
+
// child never retries a rejected subscribe (see BridgeChild.ts's
|
|
841
|
+
// window.__uwcSubscribeEip6963ProviderEvent .catch()).
|
|
842
|
+
const provider = makeMockProvider();
|
|
843
|
+
window.addEventListener('eip6963:requestProvider', () => {
|
|
844
|
+
window.dispatchEvent(new CustomEvent('eip6963:announceProvider', {
|
|
845
|
+
detail: {
|
|
846
|
+
info: {
|
|
847
|
+
uuid: 'uuid-1',
|
|
848
|
+
name: 'MetaMask',
|
|
849
|
+
icon: '',
|
|
850
|
+
rdns: 'io.metamask'
|
|
851
|
+
},
|
|
852
|
+
provider
|
|
853
|
+
}
|
|
854
|
+
}));
|
|
855
|
+
});
|
|
856
|
+
const { BridgeParent } = await import('./BridgeParent');
|
|
857
|
+
const bridgeParent = new BridgeParent(mockIframe);
|
|
858
|
+
// Call subscribe IMMEDIATELY — well before the 300ms discovery window
|
|
859
|
+
// (dispatched synchronously above, so the wallet IS in the discovery
|
|
860
|
+
// listener's closure, but the parent hasn't resolved/flipped
|
|
861
|
+
// eip6963WalletsReady yet).
|
|
862
|
+
const subscribePromise = bridgeParent.parentAPI.subscribeEip6963ProviderEvent('uuid-1', 'accountsChanged', vi.fn());
|
|
863
|
+
// Let the 300ms discovery window elapse.
|
|
864
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
865
|
+
const teardown = await subscribePromise;
|
|
866
|
+
expect(typeof teardown).toBe('function');
|
|
867
|
+
expect(provider.on).toHaveBeenCalledWith('accountsChanged', expect.any(Function));
|
|
868
|
+
});
|
|
869
|
+
});
|
|
870
|
+
describe('subscribeTronProviderEvent', () => {
|
|
871
|
+
function makeMockTronProvider() {
|
|
872
|
+
return {
|
|
873
|
+
tronWeb: {},
|
|
874
|
+
on: vi.fn(),
|
|
875
|
+
removeListener: vi.fn()
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
it('resolves the provider by injectedId window path and attaches a real listener via a LOCAL wrapper', async () => {
|
|
879
|
+
const { BridgeParent } = await import('./BridgeParent');
|
|
880
|
+
const bridgeParent = new BridgeParent(mockIframe);
|
|
881
|
+
const provider = makeMockTronProvider();
|
|
882
|
+
window.tronLink = provider;
|
|
883
|
+
const callback = vi.fn();
|
|
884
|
+
const teardown = bridgeParent.parentAPI.subscribeTronProviderEvent('tronLink', 'accountsChanged', callback);
|
|
885
|
+
expect(provider.on).toHaveBeenCalledWith('accountsChanged', expect.any(Function));
|
|
886
|
+
// Regression guard: same "the registered listener must not be the raw
|
|
887
|
+
// Comlink-proxied callback" requirement as the EIP-6963 method above —
|
|
888
|
+
// a wallet's own dispatch calling the listener with an extra,
|
|
889
|
+
// non-serializable argument (e.g. an internal handler function)
|
|
890
|
+
// otherwise throws DataCloneError the moment the wallet fires the
|
|
891
|
+
// event.
|
|
892
|
+
expect(provider.on).not.toHaveBeenCalledWith('accountsChanged', callback);
|
|
893
|
+
expect(typeof teardown).toBe('function');
|
|
894
|
+
// Regression guard: same "must cross the bridge as a Comlink return
|
|
895
|
+
// value" requirement as the EIP-6963 subscribe method above.
|
|
896
|
+
expect(Comlink.proxy).toHaveBeenCalledWith(expect.any(Function));
|
|
897
|
+
const registeredWrapper = provider.on.mock.calls[0]?.[1];
|
|
898
|
+
teardown?.();
|
|
899
|
+
expect(provider.removeListener).toHaveBeenCalledWith('accountsChanged', registeredWrapper);
|
|
900
|
+
delete window.tronLink;
|
|
901
|
+
});
|
|
902
|
+
it('the local wrapper forwards ONLY the first argument to callback, discarding any extra ones the wallet passes', async () => {
|
|
903
|
+
const { BridgeParent } = await import('./BridgeParent');
|
|
904
|
+
const bridgeParent = new BridgeParent(mockIframe);
|
|
905
|
+
const provider = makeMockTronProvider();
|
|
906
|
+
window.tronLink = provider;
|
|
907
|
+
const callback = vi.fn();
|
|
908
|
+
bridgeParent.parentAPI.subscribeTronProviderEvent('tronLink', 'accountsChanged', callback);
|
|
909
|
+
const registeredWrapper = provider.on.mock.calls[0]?.[1];
|
|
910
|
+
const extraNonCloneableArg = () => { };
|
|
911
|
+
registeredWrapper('TSwitchedAddress', extraNonCloneableArg);
|
|
912
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
913
|
+
expect(callback).toHaveBeenCalledWith('TSwitchedAddress');
|
|
914
|
+
delete window.tronLink;
|
|
915
|
+
});
|
|
916
|
+
it("teardown returns undefined even when the real provider's removeListener returns `this`", async () => {
|
|
917
|
+
const { BridgeParent } = await import('./BridgeParent');
|
|
918
|
+
const bridgeParent = new BridgeParent(mockIframe);
|
|
919
|
+
const provider = {
|
|
920
|
+
tronWeb: {},
|
|
921
|
+
on: vi.fn(),
|
|
922
|
+
removeListener: vi.fn(function () {
|
|
923
|
+
return this;
|
|
924
|
+
})
|
|
925
|
+
};
|
|
926
|
+
window.tronLink = provider;
|
|
927
|
+
const teardown = bridgeParent.parentAPI.subscribeTronProviderEvent('tronLink', 'accountsChanged', vi.fn());
|
|
928
|
+
expect(teardown()).toBeUndefined();
|
|
929
|
+
delete window.tronLink;
|
|
930
|
+
});
|
|
931
|
+
it('throws when the injectedId window path resolves to nothing', async () => {
|
|
932
|
+
const { BridgeParent } = await import('./BridgeParent');
|
|
933
|
+
const bridgeParent = new BridgeParent(mockIframe);
|
|
934
|
+
expect(() => bridgeParent.parentAPI.subscribeTronProviderEvent('nonexistent.path', 'accountsChanged', vi.fn())).toThrow(/no provider found at injectedId/);
|
|
935
|
+
});
|
|
936
|
+
it('throws when no provider resolves at the given injectedId path', async () => {
|
|
937
|
+
const { BridgeParent } = await import('./BridgeParent');
|
|
938
|
+
const bridgeParent = new BridgeParent(mockIframe);
|
|
939
|
+
expect(() => bridgeParent.parentAPI.subscribeTronProviderEvent('nonExistentWallet', 'accountsChanged', vi.fn())).toThrow(/no provider found at injectedId/);
|
|
940
|
+
});
|
|
941
|
+
// MFS-778: TronLink resolves `window.tronLink` (request+tronWeb, no `.on`)
|
|
942
|
+
// for discovery/signing, but emits account/disconnect events on its
|
|
943
|
+
// separate TIP-1193 `window.tron`. Binding on the resolved provider alone
|
|
944
|
+
// silently dropped every account switch.
|
|
945
|
+
it('subscribes on the TIP-1193 window.tron when the resolved provider has no .on', async () => {
|
|
946
|
+
const { BridgeParent } = await import('./BridgeParent');
|
|
947
|
+
const bridgeParent = new BridgeParent(mockIframe);
|
|
948
|
+
window.tronLink = { tronWeb: {}, request: vi.fn() };
|
|
949
|
+
// TIP-1193 emitter TronLink actually fires events on.
|
|
950
|
+
const emitter = {
|
|
951
|
+
tronWeb: {},
|
|
952
|
+
request: vi.fn(),
|
|
953
|
+
on: vi.fn(),
|
|
954
|
+
removeListener: vi.fn()
|
|
955
|
+
};
|
|
956
|
+
window.tron = emitter;
|
|
957
|
+
const callback = vi.fn();
|
|
958
|
+
const teardown = bridgeParent.parentAPI.subscribeTronProviderEvent('tronLink', 'accountsChanged', callback);
|
|
959
|
+
expect(emitter.on).toHaveBeenCalledWith('accountsChanged', expect.any(Function));
|
|
960
|
+
// Same regression guard as the eip6963/direct paths: the raw
|
|
961
|
+
// Comlink-proxied callback is never registered directly.
|
|
962
|
+
expect(emitter.on).not.toHaveBeenCalledWith('accountsChanged', callback);
|
|
963
|
+
const registeredWrapper = emitter.on.mock.calls[0]?.[1];
|
|
964
|
+
registeredWrapper(['TSwitched'], () => { });
|
|
965
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
966
|
+
expect(callback).toHaveBeenCalledWith(['TSwitched']);
|
|
967
|
+
teardown?.();
|
|
968
|
+
expect(emitter.removeListener).toHaveBeenCalledWith('accountsChanged', registeredWrapper);
|
|
969
|
+
delete window.tronLink;
|
|
970
|
+
delete window.tron;
|
|
971
|
+
});
|
|
972
|
+
// A non-TronLink wallet whose own Tron provider lacks `.on` (confirmed live
|
|
973
|
+
// for OKX's `okxwallet.tronLink`) must NOT inherit TronLink's `window.tron`.
|
|
974
|
+
// OKX broadcasts account switches on the window `message` channel; binding
|
|
975
|
+
// its `accountsChanged` on TronLink's `window.tron` means OKX's switches
|
|
976
|
+
// fire on the wrong wallet's provider and are silently dropped (the OKX+Tron
|
|
977
|
+
// account-switch bug). It must fall through to the `message` channel.
|
|
978
|
+
it('does NOT inherit TronLink window.tron for OKX (emitter-less, non-TronLink) — falls back to window `message`', async () => {
|
|
979
|
+
const { BridgeParent } = await import('./BridgeParent');
|
|
980
|
+
const bridgeParent = new BridgeParent(mockIframe);
|
|
981
|
+
window.okxwallet = {
|
|
982
|
+
tronLink: { tronWeb: {}, request: vi.fn() }
|
|
983
|
+
};
|
|
984
|
+
// TronLink's TIP-1193 emitter IS present (both wallets installed) and is a
|
|
985
|
+
// valid Tron provider — so ONLY the identity gate keeps OKX off it.
|
|
986
|
+
const tronLinkEmitter = {
|
|
987
|
+
tronWeb: {},
|
|
988
|
+
request: vi.fn(),
|
|
989
|
+
on: vi.fn(),
|
|
990
|
+
removeListener: vi.fn()
|
|
991
|
+
};
|
|
992
|
+
window.tron = tronLinkEmitter;
|
|
993
|
+
const addSpy = vi.spyOn(window, 'addEventListener');
|
|
994
|
+
const callback = vi.fn();
|
|
995
|
+
const teardown = bridgeParent.parentAPI.subscribeTronProviderEvent('okxwallet.tronLink', 'accountsChanged', callback);
|
|
996
|
+
// Must NOT bind on TronLink's window.tron.
|
|
997
|
+
expect(tronLinkEmitter.on).not.toHaveBeenCalled();
|
|
998
|
+
// Instead subscribes to the window `message` channel — OKX's real channel.
|
|
999
|
+
expect(addSpy).toHaveBeenCalledWith('message', expect.any(Function));
|
|
1000
|
+
window.dispatchEvent(new MessageEvent('message', {
|
|
1001
|
+
data: {
|
|
1002
|
+
message: {
|
|
1003
|
+
action: 'accountsChanged',
|
|
1004
|
+
data: { address: 'TOkxSwitched' }
|
|
1005
|
+
}
|
|
1006
|
+
},
|
|
1007
|
+
source: window,
|
|
1008
|
+
origin: window.location.origin
|
|
1009
|
+
}));
|
|
1010
|
+
expect(callback).toHaveBeenCalledWith(['TOkxSwitched']);
|
|
1011
|
+
teardown?.();
|
|
1012
|
+
delete window.okxwallet;
|
|
1013
|
+
delete window.tron;
|
|
1014
|
+
});
|
|
1015
|
+
it('ignores a forged Tron `setAccount` from a foreign source/origin (runs in the top frame)', async () => {
|
|
1016
|
+
// This handler runs in the client's TOP frame and feeds the live session
|
|
1017
|
+
// address, so a same-origin sibling frame or window.opener must not be
|
|
1018
|
+
// able to post `{ message: { action: 'setAccount' } }` and move the account.
|
|
1019
|
+
const { BridgeParent } = await import('./BridgeParent');
|
|
1020
|
+
const bridgeParent = new BridgeParent(mockIframe);
|
|
1021
|
+
window.tronLink = { tronWeb: {}, request: vi.fn() };
|
|
1022
|
+
const callback = vi.fn();
|
|
1023
|
+
const teardown = bridgeParent.parentAPI.subscribeTronProviderEvent('tronLink', 'accountsChanged', callback);
|
|
1024
|
+
// Foreign source.
|
|
1025
|
+
window.dispatchEvent(new MessageEvent('message', {
|
|
1026
|
+
data: {
|
|
1027
|
+
message: { action: 'setAccount', data: { address: 'TAttacker' } }
|
|
1028
|
+
},
|
|
1029
|
+
source: {},
|
|
1030
|
+
origin: window.location.origin
|
|
1031
|
+
}));
|
|
1032
|
+
// Right source, wrong origin.
|
|
1033
|
+
window.dispatchEvent(new MessageEvent('message', {
|
|
1034
|
+
data: {
|
|
1035
|
+
message: { action: 'setAccount', data: { address: 'TAttacker' } }
|
|
1036
|
+
},
|
|
1037
|
+
source: window,
|
|
1038
|
+
origin: 'https://evil.example'
|
|
1039
|
+
}));
|
|
1040
|
+
expect(callback).not.toHaveBeenCalled();
|
|
1041
|
+
teardown?.();
|
|
1042
|
+
delete window.tronLink;
|
|
1043
|
+
});
|
|
1044
|
+
it('falls back to the window `message` channel when neither the resolved provider nor window.tron has .on', async () => {
|
|
1045
|
+
const { BridgeParent } = await import('./BridgeParent');
|
|
1046
|
+
const bridgeParent = new BridgeParent(mockIframe);
|
|
1047
|
+
window.tronLink = { tronWeb: {}, request: vi.fn() };
|
|
1048
|
+
// No window.tron emitter at all.
|
|
1049
|
+
const addSpy = vi.spyOn(window, 'addEventListener');
|
|
1050
|
+
const removeSpy = vi.spyOn(window, 'removeEventListener');
|
|
1051
|
+
const callback = vi.fn();
|
|
1052
|
+
const teardown = bridgeParent.parentAPI.subscribeTronProviderEvent('tronLink', 'accountsChanged', callback);
|
|
1053
|
+
expect(addSpy).toHaveBeenCalledWith('message', expect.any(Function));
|
|
1054
|
+
window.dispatchEvent(new MessageEvent('message', {
|
|
1055
|
+
data: {
|
|
1056
|
+
message: { action: 'setAccount', data: { address: 'TSwitched' } }
|
|
1057
|
+
},
|
|
1058
|
+
source: window,
|
|
1059
|
+
origin: window.location.origin
|
|
1060
|
+
}));
|
|
1061
|
+
expect(callback).toHaveBeenCalledWith(['TSwitched']);
|
|
1062
|
+
teardown?.();
|
|
1063
|
+
expect(removeSpy).toHaveBeenCalledWith('message', expect.any(Function));
|
|
1064
|
+
delete window.tronLink;
|
|
1065
|
+
});
|
|
1066
|
+
});
|
|
1067
|
+
describe('subscribeSolanaWalletEvent', () => {
|
|
1068
|
+
beforeEach(async () => {
|
|
1069
|
+
// Re-set mocks that vi.restoreAllMocks() may have cleared.
|
|
1070
|
+
const { getWallets } = await import('@wallet-standard/app');
|
|
1071
|
+
vi.mocked(getWallets).mockReturnValue({
|
|
1072
|
+
get: () => [],
|
|
1073
|
+
on: vi.fn(),
|
|
1074
|
+
register: vi.fn()
|
|
1075
|
+
});
|
|
1076
|
+
const { isWalletAdapterCompatibleStandardWallet } = await import('@solana/wallet-adapter-base');
|
|
1077
|
+
vi.mocked(isWalletAdapterCompatibleStandardWallet).mockReturnValue(false);
|
|
1078
|
+
baseAdapterMock.readyState = 'NotDetected';
|
|
1079
|
+
baseAdapterMock.shouldThrow = false;
|
|
1080
|
+
baseAdapterMock.fn.mockImplementation(function () {
|
|
1081
|
+
if (baseAdapterMock.shouldThrow)
|
|
1082
|
+
throw new Error('init failed');
|
|
1083
|
+
return {
|
|
1084
|
+
readyState: baseAdapterMock.readyState,
|
|
1085
|
+
name: 'Base Wallet',
|
|
1086
|
+
on: vi.fn(),
|
|
1087
|
+
off: vi.fn()
|
|
1088
|
+
};
|
|
1089
|
+
});
|
|
1090
|
+
});
|
|
1091
|
+
function stubStandardWalletAdapter(adapter) {
|
|
1092
|
+
return import('@solana/wallet-standard-wallet-adapter-base').then(({ StandardWalletAdapter }) => {
|
|
1093
|
+
vi.mocked(StandardWalletAdapter).mockImplementation(function () {
|
|
1094
|
+
return adapter;
|
|
1095
|
+
});
|
|
1096
|
+
});
|
|
1097
|
+
}
|
|
1098
|
+
it('resolves the connected wallet-standard adapter by walletId and attaches connect/disconnect listeners via a LOCAL wrapper', async () => {
|
|
1099
|
+
const { getWallets } = await import('@wallet-standard/app');
|
|
1100
|
+
const { isWalletAdapterCompatibleStandardWallet } = await import('@solana/wallet-adapter-base');
|
|
1101
|
+
const { BridgeParent } = await import('./BridgeParent');
|
|
1102
|
+
vi.mocked(getWallets).mockReturnValue({
|
|
1103
|
+
get: () => [
|
|
1104
|
+
{
|
|
1105
|
+
name: 'Phantom',
|
|
1106
|
+
chains: ['solana:mainnet'],
|
|
1107
|
+
features: {},
|
|
1108
|
+
accounts: []
|
|
1109
|
+
}
|
|
1110
|
+
],
|
|
1111
|
+
on: vi.fn(),
|
|
1112
|
+
register: vi.fn()
|
|
1113
|
+
});
|
|
1114
|
+
vi.mocked(isWalletAdapterCompatibleStandardWallet).mockReturnValue(true);
|
|
1115
|
+
const adapterOn = vi.fn();
|
|
1116
|
+
const adapterOff = vi.fn();
|
|
1117
|
+
await stubStandardWalletAdapter({ on: adapterOn, off: adapterOff });
|
|
1118
|
+
const bridgeParent = new BridgeParent(mockIframe);
|
|
1119
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
1120
|
+
const callback = vi.fn();
|
|
1121
|
+
const teardown = bridgeParent.parentAPI.subscribeSolanaWalletEvent('phantom-solana:mainnet', callback);
|
|
1122
|
+
expect(adapterOn).toHaveBeenCalledWith('connect', expect.any(Function));
|
|
1123
|
+
expect(adapterOn).toHaveBeenCalledWith('disconnect', expect.any(Function));
|
|
1124
|
+
expect(typeof teardown).toBe('function');
|
|
1125
|
+
// Regression guard: the teardown crosses back to the child as a
|
|
1126
|
+
// Comlink return value, same requirement as the eip155/tron teardowns.
|
|
1127
|
+
expect(Comlink.proxy).toHaveBeenCalledWith(expect.any(Function));
|
|
1128
|
+
teardown?.();
|
|
1129
|
+
expect(adapterOff).toHaveBeenCalledWith('connect', expect.any(Function));
|
|
1130
|
+
expect(adapterOff).toHaveBeenCalledWith('disconnect', expect.any(Function));
|
|
1131
|
+
});
|
|
1132
|
+
it('reduces the PublicKey to a plain base58 string BEFORE forwarding to callback (never the object itself)', async () => {
|
|
1133
|
+
const { getWallets } = await import('@wallet-standard/app');
|
|
1134
|
+
const { isWalletAdapterCompatibleStandardWallet } = await import('@solana/wallet-adapter-base');
|
|
1135
|
+
const { BridgeParent } = await import('./BridgeParent');
|
|
1136
|
+
vi.mocked(getWallets).mockReturnValue({
|
|
1137
|
+
get: () => [
|
|
1138
|
+
{
|
|
1139
|
+
name: 'Phantom',
|
|
1140
|
+
chains: ['solana:mainnet'],
|
|
1141
|
+
features: {},
|
|
1142
|
+
accounts: []
|
|
1143
|
+
}
|
|
1144
|
+
],
|
|
1145
|
+
on: vi.fn(),
|
|
1146
|
+
register: vi.fn()
|
|
1147
|
+
});
|
|
1148
|
+
vi.mocked(isWalletAdapterCompatibleStandardWallet).mockReturnValue(true);
|
|
1149
|
+
const adapterOn = vi.fn();
|
|
1150
|
+
const adapterOff = vi.fn();
|
|
1151
|
+
await stubStandardWalletAdapter({ on: adapterOn, off: adapterOff });
|
|
1152
|
+
const bridgeParent = new BridgeParent(mockIframe);
|
|
1153
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
1154
|
+
const callback = vi.fn();
|
|
1155
|
+
bridgeParent.parentAPI.subscribeSolanaWalletEvent('phantom-solana:mainnet', callback);
|
|
1156
|
+
const onConnectWrapper = adapterOn.mock.calls.find(call => call[0] === 'connect')?.[1];
|
|
1157
|
+
const onDisconnectWrapper = adapterOn.mock.calls.find(call => call[0] === 'disconnect')?.[1];
|
|
1158
|
+
const fakePublicKey = { toBase58: () => 'SoLaNaAddr_NEW' };
|
|
1159
|
+
onConnectWrapper(fakePublicKey);
|
|
1160
|
+
expect(callback).toHaveBeenCalledWith('SoLaNaAddr_NEW');
|
|
1161
|
+
expect(callback).not.toHaveBeenCalledWith(fakePublicKey);
|
|
1162
|
+
onDisconnectWrapper();
|
|
1163
|
+
expect(callback).toHaveBeenCalledWith(null);
|
|
1164
|
+
});
|
|
1165
|
+
it('throws when no wallet matches the given walletId', async () => {
|
|
1166
|
+
const { BridgeParent } = await import('./BridgeParent');
|
|
1167
|
+
const bridgeParent = new BridgeParent(mockIframe);
|
|
1168
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
1169
|
+
expect(() => bridgeParent.parentAPI.subscribeSolanaWalletEvent('unknown-wallet-id', vi.fn())).toThrow(/no wallet found for walletId/);
|
|
1170
|
+
});
|
|
1171
|
+
it('resolves the traditional Base Wallet adapter by its "-traditional" walletId', async () => {
|
|
1172
|
+
const { BridgeParent } = await import('./BridgeParent');
|
|
1173
|
+
baseAdapterMock.readyState = 'Installed';
|
|
1174
|
+
const bridgeParent = new BridgeParent(mockIframe);
|
|
1175
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
1176
|
+
const callback = vi.fn();
|
|
1177
|
+
const teardown = bridgeParent.parentAPI.subscribeSolanaWalletEvent('base-wallet-traditional', callback);
|
|
1178
|
+
expect(typeof teardown).toBe('function');
|
|
1179
|
+
});
|
|
1180
|
+
// --- Phantom multi-switch regression (MFS-778) ------------------------
|
|
1181
|
+
// The bridge resolves a FRESH, never-connected StandardWalletAdapter, whose
|
|
1182
|
+
// connect/disconnect derivation ignores account-to-account switches — so it
|
|
1183
|
+
// must observe the wallet's `standard:events` change stream directly.
|
|
1184
|
+
function stubAdapterInstance(adapter) {
|
|
1185
|
+
return import('@solana/wallet-standard-wallet-adapter-base').then(({ StandardWalletAdapter }) => {
|
|
1186
|
+
// Must be a regular function (not an arrow): the SUT calls
|
|
1187
|
+
// `new StandardWalletAdapter(...)`, and arrows can't be constructed.
|
|
1188
|
+
vi.mocked(StandardWalletAdapter).mockImplementation(function () {
|
|
1189
|
+
return adapter;
|
|
1190
|
+
});
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
async function armStandardEventsWallet(initialAccounts) {
|
|
1194
|
+
const { getWallets } = await import('@wallet-standard/app');
|
|
1195
|
+
const { isWalletAdapterCompatibleStandardWallet } = await import('@solana/wallet-adapter-base');
|
|
1196
|
+
vi.mocked(getWallets).mockReturnValue({
|
|
1197
|
+
get: () => [
|
|
1198
|
+
{
|
|
1199
|
+
name: 'Phantom',
|
|
1200
|
+
chains: ['solana:mainnet'],
|
|
1201
|
+
features: {},
|
|
1202
|
+
accounts: []
|
|
1203
|
+
}
|
|
1204
|
+
],
|
|
1205
|
+
on: vi.fn(),
|
|
1206
|
+
register: vi.fn()
|
|
1207
|
+
});
|
|
1208
|
+
vi.mocked(isWalletAdapterCompatibleStandardWallet).mockReturnValue(true);
|
|
1209
|
+
let changeListener;
|
|
1210
|
+
const off = vi.fn();
|
|
1211
|
+
// `accounts` is mutable: the wallet updates it as the user switches, and
|
|
1212
|
+
// the handler must read the CURRENT active account on each change.
|
|
1213
|
+
const wallet = {
|
|
1214
|
+
accounts: initialAccounts,
|
|
1215
|
+
features: {
|
|
1216
|
+
'standard:events': {
|
|
1217
|
+
on: vi.fn((event, listener) => {
|
|
1218
|
+
if (event === 'change')
|
|
1219
|
+
changeListener = listener;
|
|
1220
|
+
return off;
|
|
1221
|
+
})
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
};
|
|
1225
|
+
const adapterOn = vi.fn();
|
|
1226
|
+
const adapterOff = vi.fn();
|
|
1227
|
+
await stubAdapterInstance({ on: adapterOn, off: adapterOff, wallet });
|
|
1228
|
+
const { BridgeParent } = await import('./BridgeParent');
|
|
1229
|
+
const bridgeParent = new BridgeParent(mockIframe);
|
|
1230
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
1231
|
+
const callback = vi.fn();
|
|
1232
|
+
const teardown = bridgeParent.parentAPI.subscribeSolanaWalletEvent('phantom-solana:mainnet', callback);
|
|
1233
|
+
return {
|
|
1234
|
+
wallet,
|
|
1235
|
+
off,
|
|
1236
|
+
adapterOn,
|
|
1237
|
+
callback,
|
|
1238
|
+
teardown,
|
|
1239
|
+
fire: (props) => changeListener?.(props)
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
it('subscribes to the wallet `standard:events` change stream and NOT the fresh adapter connect/disconnect (the multi-switch fix)', async () => {
|
|
1243
|
+
const { wallet, adapterOn, teardown, off } = await armStandardEventsWallet([{ address: 'ACC_1' }]);
|
|
1244
|
+
// Subscribed to the change stream — the signal that fires on every switch.
|
|
1245
|
+
expect(wallet.features['standard:events'].on).toHaveBeenCalledWith('change', expect.any(Function));
|
|
1246
|
+
// NOT the never-connected adapter's connect/disconnect, which silently
|
|
1247
|
+
// dropped account-to-account switches on the bridge.
|
|
1248
|
+
expect(adapterOn).not.toHaveBeenCalledWith('connect', expect.any(Function));
|
|
1249
|
+
expect(adapterOn).not.toHaveBeenCalledWith('disconnect', expect.any(Function));
|
|
1250
|
+
// Teardown crosses back to the child as a Comlink return value.
|
|
1251
|
+
expect(typeof teardown).toBe('function');
|
|
1252
|
+
expect(Comlink.proxy).toHaveBeenCalledWith(expect.any(Function));
|
|
1253
|
+
expect(off).not.toHaveBeenCalled();
|
|
1254
|
+
});
|
|
1255
|
+
it('propagates each of multiple switches, then null on de-auth, and ignores non-account changes', async () => {
|
|
1256
|
+
const { wallet, callback, off, teardown, fire } = await armStandardEventsWallet([{ address: 'ACC_1' }]);
|
|
1257
|
+
// Switch #1
|
|
1258
|
+
wallet.accounts = [{ address: 'ACC_2' }];
|
|
1259
|
+
fire({ accounts: wallet.accounts });
|
|
1260
|
+
expect(callback).toHaveBeenNthCalledWith(1, 'ACC_2');
|
|
1261
|
+
// Switch #2 (the intermediate switch that used to be dropped)
|
|
1262
|
+
wallet.accounts = [{ address: 'ACC_3' }];
|
|
1263
|
+
fire({ accounts: wallet.accounts });
|
|
1264
|
+
expect(callback).toHaveBeenNthCalledWith(2, 'ACC_3');
|
|
1265
|
+
// A pure chain/feature change (no `accounts` key) must be ignored.
|
|
1266
|
+
fire({ chains: ['solana:devnet'] });
|
|
1267
|
+
expect(callback).toHaveBeenCalledTimes(2);
|
|
1268
|
+
// Switch to an account not authorized for the dapp → empty accounts → null,
|
|
1269
|
+
// which the child maps to [] (disconnect).
|
|
1270
|
+
wallet.accounts = [];
|
|
1271
|
+
fire({ accounts: [] });
|
|
1272
|
+
expect(callback).toHaveBeenNthCalledWith(3, null);
|
|
1273
|
+
// Teardown unsubscribes the change stream.
|
|
1274
|
+
teardown?.();
|
|
1275
|
+
expect(off).toHaveBeenCalledTimes(1);
|
|
1276
|
+
});
|
|
1277
|
+
});
|
|
685
1278
|
});
|
|
686
1279
|
//# sourceMappingURL=BridgeParent.test.js.map
|