@meshconnect/uwc-bridge-parent 1.4.2 → 1.5.0

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.
@@ -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(200);
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(200);
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(200);
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(200);
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(200);
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(200);
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(200);
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(200);
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(200);
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(200);
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(200);
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(200);
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(200);
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(200);
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(200);
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(200);
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(200);
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(200);
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(200);
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(200);
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(200);
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(200);
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(200);
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(200);
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(200);
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(200);
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(200);
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(200);
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