@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
 
3
4
  // Mock Comlink. windowEndpoint mirrors the real one (comlink 4.4.2): its
4
5
  // add/removeEventListener are window's own methods bound to window. This lets the
@@ -98,7 +99,7 @@ describe('BridgeParent', () => {
98
99
  new BridgeParent(mockIframe)
99
100
 
100
101
  // Allow the async initializeConnection to run
101
- await vi.advanceTimersByTimeAsync(200)
102
+ await vi.advanceTimersByTimeAsync(400)
102
103
 
103
104
  expect(Comlink.expose).toHaveBeenCalled()
104
105
  })
@@ -109,7 +110,7 @@ describe('BridgeParent', () => {
109
110
 
110
111
  new BridgeParent(mockIframe)
111
112
 
112
- await vi.advanceTimersByTimeAsync(200)
113
+ await vi.advanceTimersByTimeAsync(400)
113
114
 
114
115
  expect(Comlink.windowEndpoint).toHaveBeenCalledWith(
115
116
  mockIframe.contentWindow,
@@ -125,7 +126,7 @@ describe('BridgeParent', () => {
125
126
  const iframeNoContent = { src: 'https://example.com' } as any
126
127
  new BridgeParent(iframeNoContent)
127
128
 
128
- await vi.advanceTimersByTimeAsync(200)
129
+ await vi.advanceTimersByTimeAsync(400)
129
130
 
130
131
  expect(Comlink.expose).not.toHaveBeenCalled()
131
132
  })
@@ -215,7 +216,7 @@ describe('BridgeParent', () => {
215
216
  })
216
217
 
217
218
  const bridge = new BridgeParent(mockIframe)
218
- await vi.advanceTimersByTimeAsync(200)
219
+ await vi.advanceTimersByTimeAsync(400)
219
220
 
220
221
  // Live before teardown: a window 'message' from the iframe reaches the
221
222
  // listener (source must match — the guard drops anything else).
@@ -272,7 +273,7 @@ describe('BridgeParent', () => {
272
273
  const { BridgeParent } = await import('./BridgeParent')
273
274
 
274
275
  const bridge = new BridgeParent(mockIframe)
275
- await vi.advanceTimersByTimeAsync(200)
276
+ await vi.advanceTimersByTimeAsync(400)
276
277
 
277
278
  const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any
278
279
  // Solana readiness is true right after init — a real precondition, not a setup.
@@ -294,7 +295,7 @@ describe('BridgeParent', () => {
294
295
 
295
296
  const iframeNoContent = { src: 'https://child.example.com' } as any
296
297
  const bridge = new BridgeParent(iframeNoContent)
297
- await vi.advanceTimersByTimeAsync(200)
298
+ await vi.advanceTimersByTimeAsync(400)
298
299
 
299
300
  expect(() => bridge.destroy()).not.toThrow()
300
301
  })
@@ -303,7 +304,7 @@ describe('BridgeParent', () => {
303
304
  const { BridgeParent } = await import('./BridgeParent')
304
305
 
305
306
  const bridge = new BridgeParent(mockIframe)
306
- await vi.advanceTimersByTimeAsync(200)
307
+ await vi.advanceTimersByTimeAsync(400)
307
308
 
308
309
  bridge.destroy()
309
310
  expect(() => bridge.destroy()).not.toThrow()
@@ -317,7 +318,7 @@ describe('BridgeParent', () => {
317
318
 
318
319
  new BridgeParent(mockIframe)
319
320
 
320
- await vi.advanceTimersByTimeAsync(200)
321
+ await vi.advanceTimersByTimeAsync(400)
321
322
 
322
323
  expect(dispatchSpy).toHaveBeenCalledWith(
323
324
  expect.objectContaining({ type: 'eip6963:requestProvider' })
@@ -348,7 +349,7 @@ describe('BridgeParent', () => {
348
349
 
349
350
  new BridgeParent(mockIframe)
350
351
 
351
- await vi.advanceTimersByTimeAsync(200)
352
+ await vi.advanceTimersByTimeAsync(400)
352
353
 
353
354
  // The exposed parentAPI should contain the discovered wallet
354
355
  const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any
@@ -382,7 +383,7 @@ describe('BridgeParent', () => {
382
383
  })
383
384
 
384
385
  new BridgeParent(mockIframe)
385
- await vi.advanceTimersByTimeAsync(200)
386
+ await vi.advanceTimersByTimeAsync(400)
386
387
 
387
388
  const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any
388
389
  expect(exposedAPI.eip6963Wallets).toHaveLength(1)
@@ -415,7 +416,7 @@ describe('BridgeParent', () => {
415
416
  } as any)
416
417
 
417
418
  new BridgeParent(mockIframe)
418
- await vi.advanceTimersByTimeAsync(200)
419
+ await vi.advanceTimersByTimeAsync(400)
419
420
 
420
421
  const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any
421
422
  expect(exposedAPI.walletStandardWallets).toHaveLength(1)
@@ -435,7 +436,7 @@ describe('BridgeParent', () => {
435
436
  } as any)
436
437
 
437
438
  new BridgeParent(mockIframe)
438
- await vi.advanceTimersByTimeAsync(200)
439
+ await vi.advanceTimersByTimeAsync(400)
439
440
 
440
441
  const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any
441
442
  expect(exposedAPI.walletStandardWallets).toEqual([])
@@ -448,7 +449,7 @@ describe('BridgeParent', () => {
448
449
  const { BridgeParent } = await import('./BridgeParent')
449
450
 
450
451
  new BridgeParent(mockIframe)
451
- await vi.advanceTimersByTimeAsync(200)
452
+ await vi.advanceTimersByTimeAsync(400)
452
453
 
453
454
  const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any
454
455
  expect(exposedAPI.tronWallets).toEqual([])
@@ -479,7 +480,7 @@ describe('BridgeParent', () => {
479
480
  }
480
481
 
481
482
  new BridgeParent(mockIframe)
482
- await vi.advanceTimersByTimeAsync(200)
483
+ await vi.advanceTimersByTimeAsync(400)
483
484
 
484
485
  const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any
485
486
 
@@ -521,7 +522,7 @@ describe('BridgeParent', () => {
521
522
  }
522
523
 
523
524
  new BridgeParent(mockIframe)
524
- await vi.advanceTimersByTimeAsync(200)
525
+ await vi.advanceTimersByTimeAsync(400)
525
526
 
526
527
  const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any
527
528
  exposedAPI.discoverTronWallets(['tokenpocket.tron'])
@@ -537,7 +538,7 @@ describe('BridgeParent', () => {
537
538
  const { BridgeParent } = await import('./BridgeParent')
538
539
 
539
540
  new BridgeParent(mockIframe)
540
- await vi.advanceTimersByTimeAsync(200)
541
+ await vi.advanceTimersByTimeAsync(400)
541
542
 
542
543
  const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any
543
544
  exposedAPI.discoverTronWallets([])
@@ -556,7 +557,7 @@ describe('BridgeParent', () => {
556
557
  }
557
558
 
558
559
  new BridgeParent(mockIframe)
559
- await vi.advanceTimersByTimeAsync(200)
560
+ await vi.advanceTimersByTimeAsync(400)
560
561
 
561
562
  const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any
562
563
  exposedAPI.discoverTronWallets(['badWallet'])
@@ -589,7 +590,7 @@ describe('BridgeParent', () => {
589
590
  }
590
591
 
591
592
  new BridgeParent(mockIframe)
592
- await vi.advanceTimersByTimeAsync(200)
593
+ await vi.advanceTimersByTimeAsync(400)
593
594
 
594
595
  const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any
595
596
  exposedAPI.discoverTronWallets(['tronLink', 'tronLink'])
@@ -606,7 +607,7 @@ describe('BridgeParent', () => {
606
607
  const { BridgeParent } = await import('./BridgeParent')
607
608
 
608
609
  new BridgeParent(mockIframe)
609
- await vi.advanceTimersByTimeAsync(200)
610
+ await vi.advanceTimersByTimeAsync(400)
610
611
 
611
612
  const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any
612
613
  expect(exposedAPI.tonWallets).toEqual([])
@@ -646,7 +647,7 @@ describe('BridgeParent', () => {
646
647
  }
647
648
 
648
649
  new BridgeParent(mockIframe)
649
- await vi.advanceTimersByTimeAsync(200)
650
+ await vi.advanceTimersByTimeAsync(400)
650
651
 
651
652
  const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any
652
653
  exposedAPI.discoverTonWallets(['trustwalletTon'])
@@ -692,7 +693,7 @@ describe('BridgeParent', () => {
692
693
  }
693
694
 
694
695
  new BridgeParent(mockIframe)
695
- await vi.advanceTimersByTimeAsync(200)
696
+ await vi.advanceTimersByTimeAsync(400)
696
697
 
697
698
  const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any
698
699
  exposedAPI.discoverTonWallets(['trustwalletTon'])
@@ -717,7 +718,7 @@ describe('BridgeParent', () => {
717
718
  ;(window as any).fakeTon = { tonconnect: { notABridge: true } }
718
719
 
719
720
  new BridgeParent(mockIframe)
720
- await vi.advanceTimersByTimeAsync(200)
721
+ await vi.advanceTimersByTimeAsync(400)
721
722
 
722
723
  const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any
723
724
  exposedAPI.discoverTonWallets(['fakeTon'])
@@ -741,7 +742,7 @@ describe('BridgeParent', () => {
741
742
  }
742
743
 
743
744
  new BridgeParent(mockIframe)
744
- await vi.advanceTimersByTimeAsync(200)
745
+ await vi.advanceTimersByTimeAsync(400)
745
746
 
746
747
  const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any
747
748
  exposedAPI.discoverTonWallets(['trustwalletTon', 'trustwalletTon'])
@@ -794,7 +795,7 @@ describe('BridgeParent', () => {
794
795
  baseAdapterMock.readyState = 'Installed'
795
796
 
796
797
  new BridgeParent(mockIframe)
797
- await vi.advanceTimersByTimeAsync(200)
798
+ await vi.advanceTimersByTimeAsync(400)
798
799
 
799
800
  const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any
800
801
  const baseWallet = exposedAPI.walletStandardWallets.find(
@@ -814,7 +815,7 @@ describe('BridgeParent', () => {
814
815
  baseAdapterMock.readyState = 'Loadable'
815
816
 
816
817
  new BridgeParent(mockIframe)
817
- await vi.advanceTimersByTimeAsync(200)
818
+ await vi.advanceTimersByTimeAsync(400)
818
819
 
819
820
  const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any
820
821
  const baseWallet = exposedAPI.walletStandardWallets.find(
@@ -828,7 +829,7 @@ describe('BridgeParent', () => {
828
829
  const { BridgeParent } = await import('./BridgeParent')
829
830
 
830
831
  new BridgeParent(mockIframe)
831
- await vi.advanceTimersByTimeAsync(200)
832
+ await vi.advanceTimersByTimeAsync(400)
832
833
 
833
834
  const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any
834
835
  const baseWallet = exposedAPI.walletStandardWallets.find(
@@ -858,7 +859,7 @@ describe('BridgeParent', () => {
858
859
  baseAdapterMock.readyState = 'Installed'
859
860
 
860
861
  new BridgeParent(mockIframe)
861
- await vi.advanceTimersByTimeAsync(200)
862
+ await vi.advanceTimersByTimeAsync(400)
862
863
 
863
864
  const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any
864
865
  const baseWallets = exposedAPI.walletStandardWallets.filter(
@@ -875,11 +876,905 @@ describe('BridgeParent', () => {
875
876
  baseAdapterMock.shouldThrow = true
876
877
 
877
878
  new BridgeParent(mockIframe)
878
- await vi.advanceTimersByTimeAsync(200)
879
+ await vi.advanceTimersByTimeAsync(400)
879
880
 
880
881
  const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any
881
882
  expect(exposedAPI).toBeDefined()
882
883
  expect(exposedAPI.walletStandardWallets).toEqual([])
883
884
  })
884
885
  })
886
+
887
+ describe('subscribeEip6963ProviderEvent', () => {
888
+ function makeMockProvider() {
889
+ return {
890
+ request: vi.fn(),
891
+ on: vi.fn(),
892
+ removeListener: vi.fn()
893
+ }
894
+ }
895
+
896
+ // Lets the real one-shot discovery window (300ms) resolve first, THEN
897
+ // overwrites eip6963Wallets with the test's fixture — otherwise the
898
+ // discovery's own `.then()` (see BridgeParent.ts's initializeConnection)
899
+ // would overwrite the fixture with `[]` out from under the test the
900
+ // moment `subscribeEip6963ProviderEvent`'s internal await lets it run.
901
+ async function readyBridgeParent(mockIframe: HTMLIFrameElement) {
902
+ const { BridgeParent } = await import('./BridgeParent')
903
+ const bridgeParent = new BridgeParent(mockIframe)
904
+ await vi.advanceTimersByTimeAsync(400)
905
+ return bridgeParent
906
+ }
907
+
908
+ it('attaches a real listener to the detected provider via a LOCAL wrapper, not the raw callback', async () => {
909
+ const bridgeParent = await readyBridgeParent(mockIframe)
910
+ const provider = makeMockProvider()
911
+ // Simulate a prior EIP-6963 discovery having populated the wallet list.
912
+ ;(bridgeParent as any).parentAPI.eip6963Wallets = [
913
+ {
914
+ uuid: 'uuid-1',
915
+ name: 'MetaMask',
916
+ icon: '',
917
+ rdns: 'io.metamask',
918
+ provider
919
+ }
920
+ ]
921
+
922
+ const callback = vi.fn()
923
+ const teardown = await (
924
+ bridgeParent as any
925
+ ).parentAPI.subscribeEip6963ProviderEvent(
926
+ 'uuid-1',
927
+ 'accountsChanged',
928
+ callback
929
+ )
930
+
931
+ expect(provider.on).toHaveBeenCalledWith(
932
+ 'accountsChanged',
933
+ expect.any(Function)
934
+ )
935
+ // Regression guard: `callback` here IS the Comlink proxy the child
936
+ // passed. Registering it directly on the real provider means Comlink
937
+ // must serialize EVERY argument the wallet's own dispatch calls it
938
+ // with — some wallets' internal EventEmitter pass extra, non-EIP-1193
939
+ // arguments (observed in production: a wallet's own internal handler
940
+ // function) alongside the real payload, and Comlink can't clone a
941
+ // function, throwing "DataCloneError" the moment the wallet fires the
942
+ // event. The registered listener must NOT be `callback` itself.
943
+ expect(provider.on).not.toHaveBeenCalledWith('accountsChanged', callback)
944
+ expect(typeof teardown).toBe('function')
945
+ // Regression guard: the teardown crosses back to the child as a
946
+ // Comlink return value — an un-proxied function throws "Unserializable
947
+ // return value" the moment a real postMessage transport (not this
948
+ // identity-mocked one) tries to deserialize it, exactly like TON's
949
+ // `listen()` teardown above must be (and is) proxied.
950
+ expect(Comlink.proxy).toHaveBeenCalledWith(expect.any(Function))
951
+ })
952
+
953
+ it('the local wrapper forwards ONLY the first argument to callback, discarding any extra ones the wallet passes', async () => {
954
+ const bridgeParent = await readyBridgeParent(mockIframe)
955
+ const provider = makeMockProvider()
956
+ ;(bridgeParent as any).parentAPI.eip6963Wallets = [
957
+ {
958
+ uuid: 'uuid-1',
959
+ name: 'MetaMask',
960
+ icon: '',
961
+ rdns: 'io.metamask',
962
+ provider
963
+ }
964
+ ]
965
+
966
+ const callback = vi.fn()
967
+ await (bridgeParent as any).parentAPI.subscribeEip6963ProviderEvent(
968
+ 'uuid-1',
969
+ 'accountsChanged',
970
+ callback
971
+ )
972
+
973
+ const registeredWrapper = provider.on.mock.calls[0]?.[1]
974
+ const extraNonCloneableArg = () => {}
975
+ registeredWrapper(['0xNEW'], extraNonCloneableArg, { some: 'extra' })
976
+
977
+ expect(callback).toHaveBeenCalledTimes(1)
978
+ expect(callback).toHaveBeenCalledWith(['0xNEW'])
979
+ })
980
+
981
+ it("teardown returns undefined even when the real EventEmitter's removeListener returns `this` (the whole provider)", async () => {
982
+ // By EventEmitter convention (Node's `events` module, and wallet
983
+ // providers built on it, e.g. Trust Wallet's inpage.js), .removeListener
984
+ // returns `this` for chaining. An expression-bodied teardown
985
+ // (`() => provider.removeListener?.(...)`) would implicitly return that
986
+ // — the whole provider object — across the Comlink boundary, which is
987
+ // itself unserializable and reproduces the exact same failure one level
988
+ // down, the moment a caller actually invokes the teardown.
989
+ const bridgeParent = await readyBridgeParent(mockIframe)
990
+ const provider: any = {
991
+ request: vi.fn(),
992
+ on: vi.fn(),
993
+ removeListener: vi.fn(function (this: unknown) {
994
+ return this
995
+ })
996
+ }
997
+ ;(bridgeParent as any).parentAPI.eip6963Wallets = [
998
+ {
999
+ uuid: 'uuid-1',
1000
+ name: 'Trust Wallet',
1001
+ icon: '',
1002
+ rdns: 'com.trustwallet.app',
1003
+ provider
1004
+ }
1005
+ ]
1006
+
1007
+ const teardown = await (
1008
+ bridgeParent as any
1009
+ ).parentAPI.subscribeEip6963ProviderEvent(
1010
+ 'uuid-1',
1011
+ 'accountsChanged',
1012
+ vi.fn()
1013
+ )
1014
+
1015
+ expect(teardown()).toBeUndefined()
1016
+ })
1017
+
1018
+ it('teardown calls removeListener with the SAME wrapper reference registered via .on', async () => {
1019
+ const bridgeParent = await readyBridgeParent(mockIframe)
1020
+ const provider = makeMockProvider()
1021
+ ;(bridgeParent as any).parentAPI.eip6963Wallets = [
1022
+ {
1023
+ uuid: 'uuid-1',
1024
+ name: 'MetaMask',
1025
+ icon: '',
1026
+ rdns: 'io.metamask',
1027
+ provider
1028
+ }
1029
+ ]
1030
+
1031
+ const callback = vi.fn()
1032
+ const teardown = await (
1033
+ bridgeParent as any
1034
+ ).parentAPI.subscribeEip6963ProviderEvent(
1035
+ 'uuid-1',
1036
+ 'accountsChanged',
1037
+ callback
1038
+ )
1039
+ const registeredWrapper = provider.on.mock.calls[0]?.[1]
1040
+ teardown?.()
1041
+
1042
+ expect(provider.removeListener).toHaveBeenCalledWith(
1043
+ 'accountsChanged',
1044
+ registeredWrapper
1045
+ )
1046
+ })
1047
+
1048
+ it('throws for an unknown uuid (wallet not detected)', async () => {
1049
+ const bridgeParent = await readyBridgeParent(mockIframe)
1050
+ ;(bridgeParent as any).parentAPI.eip6963Wallets = []
1051
+
1052
+ await expect(
1053
+ (bridgeParent as any).parentAPI.subscribeEip6963ProviderEvent(
1054
+ 'unknown-uuid',
1055
+ 'accountsChanged',
1056
+ vi.fn()
1057
+ )
1058
+ ).rejects.toThrow(/no wallet found for uuid/)
1059
+ })
1060
+
1061
+ it('throws when no wallet matches the given uuid (the uuid-mismatch bug this guards against)', async () => {
1062
+ const bridgeParent = await readyBridgeParent(mockIframe)
1063
+ ;(bridgeParent as any).parentAPI.eip6963Wallets = []
1064
+
1065
+ await expect(
1066
+ (bridgeParent as any).parentAPI.subscribeEip6963ProviderEvent(
1067
+ 'uuid-not-discovered-by-this-parent',
1068
+ 'accountsChanged',
1069
+ vi.fn()
1070
+ )
1071
+ ).rejects.toThrow(/no wallet found for uuid/)
1072
+ })
1073
+
1074
+ it('falls back to matching by rdns when the uuid does not match (the uuid-mismatch bug this closes structurally)', async () => {
1075
+ const bridgeParent = await readyBridgeParent(mockIframe)
1076
+ const provider = makeMockProvider()
1077
+ // This parent's own top-frame discovery knows the wallet under
1078
+ // 'uuid-from-top-frame' — simulating the exact scenario where the
1079
+ // child instead discovered the SAME wallet locally inside the
1080
+ // iframe under an independent, freshly-minted uuid.
1081
+ ;(bridgeParent as any).parentAPI.eip6963Wallets = [
1082
+ {
1083
+ uuid: 'uuid-from-top-frame',
1084
+ name: 'MetaMask',
1085
+ icon: '',
1086
+ rdns: 'io.metamask',
1087
+ provider
1088
+ }
1089
+ ]
1090
+
1091
+ const callback = vi.fn()
1092
+ const teardown = await (
1093
+ bridgeParent as any
1094
+ ).parentAPI.subscribeEip6963ProviderEvent(
1095
+ 'uuid-from-local-iframe-injection',
1096
+ 'accountsChanged',
1097
+ callback,
1098
+ 'io.metamask'
1099
+ )
1100
+
1101
+ expect(typeof teardown).toBe('function')
1102
+ expect(provider.on).toHaveBeenCalledWith(
1103
+ 'accountsChanged',
1104
+ expect.any(Function)
1105
+ )
1106
+ })
1107
+
1108
+ it('still throws when neither uuid nor rdns match anything', async () => {
1109
+ const bridgeParent = await readyBridgeParent(mockIframe)
1110
+ ;(bridgeParent as any).parentAPI.eip6963Wallets = []
1111
+
1112
+ await expect(
1113
+ (bridgeParent as any).parentAPI.subscribeEip6963ProviderEvent(
1114
+ 'uuid-not-discovered-by-this-parent',
1115
+ 'accountsChanged',
1116
+ vi.fn(),
1117
+ 'com.nonexistent.wallet'
1118
+ )
1119
+ ).rejects.toThrow(/no wallet found for uuid/)
1120
+ })
1121
+
1122
+ it('awaits the in-flight discovery window instead of failing permanently when subscribe is called BEFORE it resolves', async () => {
1123
+ // Regression for the startup race: a subscribe call arriving while the
1124
+ // one-shot 300ms discovery window is still open (e.g. immediately on
1125
+ // page load, during a session restore) used to look up against a
1126
+ // still-empty eip6963Wallets, miss, and throw — permanently, since the
1127
+ // child never retries a rejected subscribe (see BridgeChild.ts's
1128
+ // window.__uwcSubscribeEip6963ProviderEvent .catch()).
1129
+ const provider = makeMockProvider()
1130
+ window.addEventListener('eip6963:requestProvider', () => {
1131
+ window.dispatchEvent(
1132
+ new CustomEvent('eip6963:announceProvider', {
1133
+ detail: {
1134
+ info: {
1135
+ uuid: 'uuid-1',
1136
+ name: 'MetaMask',
1137
+ icon: '',
1138
+ rdns: 'io.metamask'
1139
+ },
1140
+ provider
1141
+ }
1142
+ })
1143
+ )
1144
+ })
1145
+
1146
+ const { BridgeParent } = await import('./BridgeParent')
1147
+ const bridgeParent = new BridgeParent(mockIframe)
1148
+
1149
+ // Call subscribe IMMEDIATELY — well before the 300ms discovery window
1150
+ // (dispatched synchronously above, so the wallet IS in the discovery
1151
+ // listener's closure, but the parent hasn't resolved/flipped
1152
+ // eip6963WalletsReady yet).
1153
+ const subscribePromise = (
1154
+ bridgeParent as any
1155
+ ).parentAPI.subscribeEip6963ProviderEvent(
1156
+ 'uuid-1',
1157
+ 'accountsChanged',
1158
+ vi.fn()
1159
+ )
1160
+
1161
+ // Let the 300ms discovery window elapse.
1162
+ await vi.advanceTimersByTimeAsync(400)
1163
+
1164
+ const teardown = await subscribePromise
1165
+ expect(typeof teardown).toBe('function')
1166
+ expect(provider.on).toHaveBeenCalledWith(
1167
+ 'accountsChanged',
1168
+ expect.any(Function)
1169
+ )
1170
+ })
1171
+ })
1172
+
1173
+ describe('subscribeTronProviderEvent', () => {
1174
+ function makeMockTronProvider() {
1175
+ return {
1176
+ tronWeb: {},
1177
+ on: vi.fn(),
1178
+ removeListener: vi.fn()
1179
+ }
1180
+ }
1181
+
1182
+ it('resolves the provider by injectedId window path and attaches a real listener via a LOCAL wrapper', async () => {
1183
+ const { BridgeParent } = await import('./BridgeParent')
1184
+ const bridgeParent = new BridgeParent(mockIframe)
1185
+ const provider = makeMockTronProvider()
1186
+ ;(window as any).tronLink = provider
1187
+
1188
+ const callback = vi.fn()
1189
+ const teardown = (
1190
+ bridgeParent as any
1191
+ ).parentAPI.subscribeTronProviderEvent(
1192
+ 'tronLink',
1193
+ 'accountsChanged',
1194
+ callback
1195
+ )
1196
+
1197
+ expect(provider.on).toHaveBeenCalledWith(
1198
+ 'accountsChanged',
1199
+ expect.any(Function)
1200
+ )
1201
+ // Regression guard: same "the registered listener must not be the raw
1202
+ // Comlink-proxied callback" requirement as the EIP-6963 method above —
1203
+ // a wallet's own dispatch calling the listener with an extra,
1204
+ // non-serializable argument (e.g. an internal handler function)
1205
+ // otherwise throws DataCloneError the moment the wallet fires the
1206
+ // event.
1207
+ expect(provider.on).not.toHaveBeenCalledWith('accountsChanged', callback)
1208
+ expect(typeof teardown).toBe('function')
1209
+ // Regression guard: same "must cross the bridge as a Comlink return
1210
+ // value" requirement as the EIP-6963 subscribe method above.
1211
+ expect(Comlink.proxy).toHaveBeenCalledWith(expect.any(Function))
1212
+
1213
+ const registeredWrapper = provider.on.mock.calls[0]?.[1]
1214
+ teardown?.()
1215
+ expect(provider.removeListener).toHaveBeenCalledWith(
1216
+ 'accountsChanged',
1217
+ registeredWrapper
1218
+ )
1219
+
1220
+ delete (window as any).tronLink
1221
+ })
1222
+
1223
+ it('the local wrapper forwards ONLY the first argument to callback, discarding any extra ones the wallet passes', async () => {
1224
+ const { BridgeParent } = await import('./BridgeParent')
1225
+ const bridgeParent = new BridgeParent(mockIframe)
1226
+ const provider = makeMockTronProvider()
1227
+ ;(window as any).tronLink = provider
1228
+
1229
+ const callback = vi.fn()
1230
+ ;(bridgeParent as any).parentAPI.subscribeTronProviderEvent(
1231
+ 'tronLink',
1232
+ 'accountsChanged',
1233
+ callback
1234
+ )
1235
+
1236
+ const registeredWrapper = provider.on.mock.calls[0]?.[1]
1237
+ const extraNonCloneableArg = () => {}
1238
+ registeredWrapper('TSwitchedAddress', extraNonCloneableArg)
1239
+
1240
+ expect(callback).toHaveBeenCalledTimes(1)
1241
+ expect(callback).toHaveBeenCalledWith('TSwitchedAddress')
1242
+
1243
+ delete (window as any).tronLink
1244
+ })
1245
+
1246
+ it("teardown returns undefined even when the real provider's removeListener returns `this`", async () => {
1247
+ const { BridgeParent } = await import('./BridgeParent')
1248
+ const bridgeParent = new BridgeParent(mockIframe)
1249
+ const provider: any = {
1250
+ tronWeb: {},
1251
+ on: vi.fn(),
1252
+ removeListener: vi.fn(function (this: unknown) {
1253
+ return this
1254
+ })
1255
+ }
1256
+ ;(window as any).tronLink = provider
1257
+
1258
+ const teardown = (
1259
+ bridgeParent as any
1260
+ ).parentAPI.subscribeTronProviderEvent(
1261
+ 'tronLink',
1262
+ 'accountsChanged',
1263
+ vi.fn()
1264
+ )
1265
+
1266
+ expect(teardown()).toBeUndefined()
1267
+
1268
+ delete (window as any).tronLink
1269
+ })
1270
+
1271
+ it('throws when the injectedId window path resolves to nothing', async () => {
1272
+ const { BridgeParent } = await import('./BridgeParent')
1273
+ const bridgeParent = new BridgeParent(mockIframe)
1274
+
1275
+ expect(() =>
1276
+ (bridgeParent as any).parentAPI.subscribeTronProviderEvent(
1277
+ 'nonexistent.path',
1278
+ 'accountsChanged',
1279
+ vi.fn()
1280
+ )
1281
+ ).toThrow(/no provider found at injectedId/)
1282
+ })
1283
+
1284
+ it('throws when no provider resolves at the given injectedId path', async () => {
1285
+ const { BridgeParent } = await import('./BridgeParent')
1286
+ const bridgeParent = new BridgeParent(mockIframe)
1287
+
1288
+ expect(() =>
1289
+ (bridgeParent as any).parentAPI.subscribeTronProviderEvent(
1290
+ 'nonExistentWallet',
1291
+ 'accountsChanged',
1292
+ vi.fn()
1293
+ )
1294
+ ).toThrow(/no provider found at injectedId/)
1295
+ })
1296
+
1297
+ // MFS-778: TronLink resolves `window.tronLink` (request+tronWeb, no `.on`)
1298
+ // for discovery/signing, but emits account/disconnect events on its
1299
+ // separate TIP-1193 `window.tron`. Binding on the resolved provider alone
1300
+ // silently dropped every account switch.
1301
+ it('subscribes on the TIP-1193 window.tron when the resolved provider has no .on', async () => {
1302
+ const { BridgeParent } = await import('./BridgeParent')
1303
+ const bridgeParent = new BridgeParent(mockIframe)
1304
+ // Resolved-by-injectedId provider: request+tronWeb, NOT an emitter.
1305
+ ;(window as any).tronLink = { tronWeb: {}, request: vi.fn() }
1306
+ // TIP-1193 emitter TronLink actually fires events on.
1307
+ const emitter = {
1308
+ tronWeb: {},
1309
+ request: vi.fn(),
1310
+ on: vi.fn(),
1311
+ removeListener: vi.fn()
1312
+ }
1313
+ ;(window as any).tron = emitter
1314
+
1315
+ const callback = vi.fn()
1316
+ const teardown = (
1317
+ bridgeParent as any
1318
+ ).parentAPI.subscribeTronProviderEvent(
1319
+ 'tronLink',
1320
+ 'accountsChanged',
1321
+ callback
1322
+ )
1323
+
1324
+ expect(emitter.on).toHaveBeenCalledWith(
1325
+ 'accountsChanged',
1326
+ expect.any(Function)
1327
+ )
1328
+ // Same regression guard as the eip6963/direct paths: the raw
1329
+ // Comlink-proxied callback is never registered directly.
1330
+ expect(emitter.on).not.toHaveBeenCalledWith('accountsChanged', callback)
1331
+ const registeredWrapper = emitter.on.mock.calls[0]?.[1]
1332
+ registeredWrapper(['TSwitched'], () => {})
1333
+ expect(callback).toHaveBeenCalledTimes(1)
1334
+ expect(callback).toHaveBeenCalledWith(['TSwitched'])
1335
+
1336
+ teardown?.()
1337
+ expect(emitter.removeListener).toHaveBeenCalledWith(
1338
+ 'accountsChanged',
1339
+ registeredWrapper
1340
+ )
1341
+
1342
+ delete (window as any).tronLink
1343
+ delete (window as any).tron
1344
+ })
1345
+
1346
+ // A non-TronLink wallet whose own Tron provider lacks `.on` (confirmed live
1347
+ // for OKX's `okxwallet.tronLink`) must NOT inherit TronLink's `window.tron`.
1348
+ // OKX broadcasts account switches on the window `message` channel; binding
1349
+ // its `accountsChanged` on TronLink's `window.tron` means OKX's switches
1350
+ // fire on the wrong wallet's provider and are silently dropped (the OKX+Tron
1351
+ // account-switch bug). It must fall through to the `message` channel.
1352
+ it('does NOT inherit TronLink window.tron for OKX (emitter-less, non-TronLink) — falls back to window `message`', async () => {
1353
+ const { BridgeParent } = await import('./BridgeParent')
1354
+ const bridgeParent = new BridgeParent(mockIframe)
1355
+ // OKX's Tron provider: request+tronWeb, NOT an event emitter.
1356
+ ;(window as any).okxwallet = {
1357
+ tronLink: { tronWeb: {}, request: vi.fn() }
1358
+ }
1359
+ // TronLink's TIP-1193 emitter IS present (both wallets installed) and is a
1360
+ // valid Tron provider — so ONLY the identity gate keeps OKX off it.
1361
+ const tronLinkEmitter = {
1362
+ tronWeb: {},
1363
+ request: vi.fn(),
1364
+ on: vi.fn(),
1365
+ removeListener: vi.fn()
1366
+ }
1367
+ ;(window as any).tron = tronLinkEmitter
1368
+
1369
+ const addSpy = vi.spyOn(window, 'addEventListener')
1370
+ const callback = vi.fn()
1371
+ const teardown = (
1372
+ bridgeParent as any
1373
+ ).parentAPI.subscribeTronProviderEvent(
1374
+ 'okxwallet.tronLink',
1375
+ 'accountsChanged',
1376
+ callback
1377
+ )
1378
+
1379
+ // Must NOT bind on TronLink's window.tron.
1380
+ expect(tronLinkEmitter.on).not.toHaveBeenCalled()
1381
+ // Instead subscribes to the window `message` channel — OKX's real channel.
1382
+ expect(addSpy).toHaveBeenCalledWith('message', expect.any(Function))
1383
+ window.dispatchEvent(
1384
+ new MessageEvent('message', {
1385
+ data: {
1386
+ message: {
1387
+ action: 'accountsChanged',
1388
+ data: { address: 'TOkxSwitched' }
1389
+ }
1390
+ },
1391
+ source: window,
1392
+ origin: window.location.origin
1393
+ })
1394
+ )
1395
+ expect(callback).toHaveBeenCalledWith(['TOkxSwitched'])
1396
+
1397
+ teardown?.()
1398
+ delete (window as any).okxwallet
1399
+ delete (window as any).tron
1400
+ })
1401
+
1402
+ it('ignores a forged Tron `setAccount` from a foreign source/origin (runs in the top frame)', async () => {
1403
+ // This handler runs in the client's TOP frame and feeds the live session
1404
+ // address, so a same-origin sibling frame or window.opener must not be
1405
+ // able to post `{ message: { action: 'setAccount' } }` and move the account.
1406
+ const { BridgeParent } = await import('./BridgeParent')
1407
+ const bridgeParent = new BridgeParent(mockIframe)
1408
+ ;(window as any).tronLink = { tronWeb: {}, request: vi.fn() }
1409
+
1410
+ const callback = vi.fn()
1411
+ const teardown = (
1412
+ bridgeParent as any
1413
+ ).parentAPI.subscribeTronProviderEvent(
1414
+ 'tronLink',
1415
+ 'accountsChanged',
1416
+ callback
1417
+ )
1418
+
1419
+ // Foreign source.
1420
+ window.dispatchEvent(
1421
+ new MessageEvent('message', {
1422
+ data: {
1423
+ message: { action: 'setAccount', data: { address: 'TAttacker' } }
1424
+ },
1425
+ source: {} as Window,
1426
+ origin: window.location.origin
1427
+ })
1428
+ )
1429
+ // Right source, wrong origin.
1430
+ window.dispatchEvent(
1431
+ new MessageEvent('message', {
1432
+ data: {
1433
+ message: { action: 'setAccount', data: { address: 'TAttacker' } }
1434
+ },
1435
+ source: window,
1436
+ origin: 'https://evil.example'
1437
+ })
1438
+ )
1439
+ expect(callback).not.toHaveBeenCalled()
1440
+
1441
+ teardown?.()
1442
+ delete (window as any).tronLink
1443
+ })
1444
+
1445
+ it('falls back to the window `message` channel when neither the resolved provider nor window.tron has .on', async () => {
1446
+ const { BridgeParent } = await import('./BridgeParent')
1447
+ const bridgeParent = new BridgeParent(mockIframe)
1448
+ ;(window as any).tronLink = { tronWeb: {}, request: vi.fn() }
1449
+ // No window.tron emitter at all.
1450
+
1451
+ const addSpy = vi.spyOn(window, 'addEventListener')
1452
+ const removeSpy = vi.spyOn(window, 'removeEventListener')
1453
+
1454
+ const callback = vi.fn()
1455
+ const teardown = (
1456
+ bridgeParent as any
1457
+ ).parentAPI.subscribeTronProviderEvent(
1458
+ 'tronLink',
1459
+ 'accountsChanged',
1460
+ callback
1461
+ )
1462
+ expect(addSpy).toHaveBeenCalledWith('message', expect.any(Function))
1463
+
1464
+ window.dispatchEvent(
1465
+ new MessageEvent('message', {
1466
+ data: {
1467
+ message: { action: 'setAccount', data: { address: 'TSwitched' } }
1468
+ },
1469
+ source: window,
1470
+ origin: window.location.origin
1471
+ })
1472
+ )
1473
+ expect(callback).toHaveBeenCalledWith(['TSwitched'])
1474
+
1475
+ teardown?.()
1476
+ expect(removeSpy).toHaveBeenCalledWith('message', expect.any(Function))
1477
+
1478
+ delete (window as any).tronLink
1479
+ })
1480
+ })
1481
+
1482
+ describe('subscribeSolanaWalletEvent', () => {
1483
+ beforeEach(async () => {
1484
+ // Re-set mocks that vi.restoreAllMocks() may have cleared.
1485
+ const { getWallets } = await import('@wallet-standard/app')
1486
+ vi.mocked(getWallets).mockReturnValue({
1487
+ get: () => [],
1488
+ on: vi.fn(),
1489
+ register: vi.fn()
1490
+ } as any)
1491
+ const { isWalletAdapterCompatibleStandardWallet } =
1492
+ await import('@solana/wallet-adapter-base')
1493
+ vi.mocked(isWalletAdapterCompatibleStandardWallet).mockReturnValue(false)
1494
+ baseAdapterMock.readyState = 'NotDetected'
1495
+ baseAdapterMock.shouldThrow = false
1496
+ baseAdapterMock.fn!.mockImplementation(function () {
1497
+ if (baseAdapterMock.shouldThrow) throw new Error('init failed')
1498
+ return {
1499
+ readyState: baseAdapterMock.readyState,
1500
+ name: 'Base Wallet',
1501
+ on: vi.fn(),
1502
+ off: vi.fn()
1503
+ }
1504
+ })
1505
+ })
1506
+
1507
+ function stubStandardWalletAdapter(adapter: {
1508
+ on: ReturnType<typeof vi.fn>
1509
+ off: ReturnType<typeof vi.fn>
1510
+ }) {
1511
+ return import('@solana/wallet-standard-wallet-adapter-base').then(
1512
+ ({ StandardWalletAdapter }) => {
1513
+ vi.mocked(StandardWalletAdapter).mockImplementation(function () {
1514
+ return adapter
1515
+ } as unknown as typeof StandardWalletAdapter)
1516
+ }
1517
+ )
1518
+ }
1519
+
1520
+ it('resolves the connected wallet-standard adapter by walletId and attaches connect/disconnect listeners via a LOCAL wrapper', async () => {
1521
+ const { getWallets } = await import('@wallet-standard/app')
1522
+ const { isWalletAdapterCompatibleStandardWallet } =
1523
+ await import('@solana/wallet-adapter-base')
1524
+ const { BridgeParent } = await import('./BridgeParent')
1525
+
1526
+ vi.mocked(getWallets).mockReturnValue({
1527
+ get: () =>
1528
+ [
1529
+ {
1530
+ name: 'Phantom',
1531
+ chains: ['solana:mainnet'],
1532
+ features: {},
1533
+ accounts: []
1534
+ }
1535
+ ] as any,
1536
+ on: vi.fn(),
1537
+ register: vi.fn()
1538
+ } as any)
1539
+ vi.mocked(isWalletAdapterCompatibleStandardWallet).mockReturnValue(true)
1540
+
1541
+ const adapterOn = vi.fn()
1542
+ const adapterOff = vi.fn()
1543
+ await stubStandardWalletAdapter({ on: adapterOn, off: adapterOff })
1544
+
1545
+ const bridgeParent = new BridgeParent(mockIframe)
1546
+ await vi.advanceTimersByTimeAsync(400)
1547
+
1548
+ const callback = vi.fn()
1549
+ const teardown = (
1550
+ bridgeParent as any
1551
+ ).parentAPI.subscribeSolanaWalletEvent('phantom-solana:mainnet', callback)
1552
+
1553
+ expect(adapterOn).toHaveBeenCalledWith('connect', expect.any(Function))
1554
+ expect(adapterOn).toHaveBeenCalledWith('disconnect', expect.any(Function))
1555
+ expect(typeof teardown).toBe('function')
1556
+ // Regression guard: the teardown crosses back to the child as a
1557
+ // Comlink return value, same requirement as the eip155/tron teardowns.
1558
+ expect(Comlink.proxy).toHaveBeenCalledWith(expect.any(Function))
1559
+
1560
+ teardown?.()
1561
+ expect(adapterOff).toHaveBeenCalledWith('connect', expect.any(Function))
1562
+ expect(adapterOff).toHaveBeenCalledWith(
1563
+ 'disconnect',
1564
+ expect.any(Function)
1565
+ )
1566
+ })
1567
+
1568
+ it('reduces the PublicKey to a plain base58 string BEFORE forwarding to callback (never the object itself)', async () => {
1569
+ const { getWallets } = await import('@wallet-standard/app')
1570
+ const { isWalletAdapterCompatibleStandardWallet } =
1571
+ await import('@solana/wallet-adapter-base')
1572
+ const { BridgeParent } = await import('./BridgeParent')
1573
+
1574
+ vi.mocked(getWallets).mockReturnValue({
1575
+ get: () =>
1576
+ [
1577
+ {
1578
+ name: 'Phantom',
1579
+ chains: ['solana:mainnet'],
1580
+ features: {},
1581
+ accounts: []
1582
+ }
1583
+ ] as any,
1584
+ on: vi.fn(),
1585
+ register: vi.fn()
1586
+ } as any)
1587
+ vi.mocked(isWalletAdapterCompatibleStandardWallet).mockReturnValue(true)
1588
+
1589
+ const adapterOn = vi.fn()
1590
+ const adapterOff = vi.fn()
1591
+ await stubStandardWalletAdapter({ on: adapterOn, off: adapterOff })
1592
+
1593
+ const bridgeParent = new BridgeParent(mockIframe)
1594
+ await vi.advanceTimersByTimeAsync(400)
1595
+
1596
+ const callback = vi.fn()
1597
+ ;(bridgeParent as any).parentAPI.subscribeSolanaWalletEvent(
1598
+ 'phantom-solana:mainnet',
1599
+ callback
1600
+ )
1601
+
1602
+ const onConnectWrapper = adapterOn.mock.calls.find(
1603
+ call => call[0] === 'connect'
1604
+ )?.[1]
1605
+ const onDisconnectWrapper = adapterOn.mock.calls.find(
1606
+ call => call[0] === 'disconnect'
1607
+ )?.[1]
1608
+
1609
+ const fakePublicKey = { toBase58: () => 'SoLaNaAddr_NEW' }
1610
+ onConnectWrapper(fakePublicKey)
1611
+ expect(callback).toHaveBeenCalledWith('SoLaNaAddr_NEW')
1612
+ expect(callback).not.toHaveBeenCalledWith(fakePublicKey)
1613
+
1614
+ onDisconnectWrapper()
1615
+ expect(callback).toHaveBeenCalledWith(null)
1616
+ })
1617
+
1618
+ it('throws when no wallet matches the given walletId', async () => {
1619
+ const { BridgeParent } = await import('./BridgeParent')
1620
+ const bridgeParent = new BridgeParent(mockIframe)
1621
+ await vi.advanceTimersByTimeAsync(400)
1622
+
1623
+ expect(() =>
1624
+ (bridgeParent as any).parentAPI.subscribeSolanaWalletEvent(
1625
+ 'unknown-wallet-id',
1626
+ vi.fn()
1627
+ )
1628
+ ).toThrow(/no wallet found for walletId/)
1629
+ })
1630
+
1631
+ it('resolves the traditional Base Wallet adapter by its "-traditional" walletId', async () => {
1632
+ const { BridgeParent } = await import('./BridgeParent')
1633
+
1634
+ baseAdapterMock.readyState = 'Installed'
1635
+
1636
+ const bridgeParent = new BridgeParent(mockIframe)
1637
+ await vi.advanceTimersByTimeAsync(400)
1638
+
1639
+ const callback = vi.fn()
1640
+ const teardown = (
1641
+ bridgeParent as any
1642
+ ).parentAPI.subscribeSolanaWalletEvent(
1643
+ 'base-wallet-traditional',
1644
+ callback
1645
+ )
1646
+
1647
+ expect(typeof teardown).toBe('function')
1648
+ })
1649
+
1650
+ // --- Phantom multi-switch regression (MFS-778) ------------------------
1651
+ // The bridge resolves a FRESH, never-connected StandardWalletAdapter, whose
1652
+ // connect/disconnect derivation ignores account-to-account switches — so it
1653
+ // must observe the wallet's `standard:events` change stream directly.
1654
+
1655
+ function stubAdapterInstance(adapter: unknown) {
1656
+ return import('@solana/wallet-standard-wallet-adapter-base').then(
1657
+ ({ StandardWalletAdapter }) => {
1658
+ // Must be a regular function (not an arrow): the SUT calls
1659
+ // `new StandardWalletAdapter(...)`, and arrows can't be constructed.
1660
+ vi.mocked(StandardWalletAdapter).mockImplementation(function () {
1661
+ return adapter
1662
+ } as unknown as typeof StandardWalletAdapter)
1663
+ }
1664
+ )
1665
+ }
1666
+
1667
+ async function armStandardEventsWallet(
1668
+ initialAccounts: { address: string }[]
1669
+ ) {
1670
+ const { getWallets } = await import('@wallet-standard/app')
1671
+ const { isWalletAdapterCompatibleStandardWallet } =
1672
+ await import('@solana/wallet-adapter-base')
1673
+ vi.mocked(getWallets).mockReturnValue({
1674
+ get: () =>
1675
+ [
1676
+ {
1677
+ name: 'Phantom',
1678
+ chains: ['solana:mainnet'],
1679
+ features: {},
1680
+ accounts: []
1681
+ }
1682
+ ] as any,
1683
+ on: vi.fn(),
1684
+ register: vi.fn()
1685
+ } as any)
1686
+ vi.mocked(isWalletAdapterCompatibleStandardWallet).mockReturnValue(true)
1687
+
1688
+ let changeListener: ((props: unknown) => void) | undefined
1689
+ const off = vi.fn()
1690
+ // `accounts` is mutable: the wallet updates it as the user switches, and
1691
+ // the handler must read the CURRENT active account on each change.
1692
+ const wallet = {
1693
+ accounts: initialAccounts,
1694
+ features: {
1695
+ 'standard:events': {
1696
+ on: vi.fn((event: string, listener: (props: unknown) => void) => {
1697
+ if (event === 'change') changeListener = listener
1698
+ return off
1699
+ })
1700
+ }
1701
+ }
1702
+ }
1703
+ const adapterOn = vi.fn()
1704
+ const adapterOff = vi.fn()
1705
+ await stubAdapterInstance({ on: adapterOn, off: adapterOff, wallet })
1706
+
1707
+ const { BridgeParent } = await import('./BridgeParent')
1708
+ const bridgeParent = new BridgeParent(mockIframe)
1709
+ await vi.advanceTimersByTimeAsync(400)
1710
+
1711
+ const callback = vi.fn()
1712
+ const teardown = (
1713
+ bridgeParent as any
1714
+ ).parentAPI.subscribeSolanaWalletEvent('phantom-solana:mainnet', callback)
1715
+
1716
+ return {
1717
+ wallet,
1718
+ off,
1719
+ adapterOn,
1720
+ callback,
1721
+ teardown,
1722
+ fire: (props: unknown) => changeListener?.(props)
1723
+ }
1724
+ }
1725
+
1726
+ it('subscribes to the wallet `standard:events` change stream and NOT the fresh adapter connect/disconnect (the multi-switch fix)', async () => {
1727
+ const { wallet, adapterOn, teardown, off } =
1728
+ await armStandardEventsWallet([{ address: 'ACC_1' }])
1729
+
1730
+ // Subscribed to the change stream — the signal that fires on every switch.
1731
+ expect(wallet.features['standard:events'].on).toHaveBeenCalledWith(
1732
+ 'change',
1733
+ expect.any(Function)
1734
+ )
1735
+ // NOT the never-connected adapter's connect/disconnect, which silently
1736
+ // dropped account-to-account switches on the bridge.
1737
+ expect(adapterOn).not.toHaveBeenCalledWith(
1738
+ 'connect',
1739
+ expect.any(Function)
1740
+ )
1741
+ expect(adapterOn).not.toHaveBeenCalledWith(
1742
+ 'disconnect',
1743
+ expect.any(Function)
1744
+ )
1745
+ // Teardown crosses back to the child as a Comlink return value.
1746
+ expect(typeof teardown).toBe('function')
1747
+ expect(Comlink.proxy).toHaveBeenCalledWith(expect.any(Function))
1748
+ expect(off).not.toHaveBeenCalled()
1749
+ })
1750
+
1751
+ it('propagates each of multiple switches, then null on de-auth, and ignores non-account changes', async () => {
1752
+ const { wallet, callback, off, teardown, fire } =
1753
+ await armStandardEventsWallet([{ address: 'ACC_1' }])
1754
+
1755
+ // Switch #1
1756
+ wallet.accounts = [{ address: 'ACC_2' }]
1757
+ fire({ accounts: wallet.accounts })
1758
+ expect(callback).toHaveBeenNthCalledWith(1, 'ACC_2')
1759
+
1760
+ // Switch #2 (the intermediate switch that used to be dropped)
1761
+ wallet.accounts = [{ address: 'ACC_3' }]
1762
+ fire({ accounts: wallet.accounts })
1763
+ expect(callback).toHaveBeenNthCalledWith(2, 'ACC_3')
1764
+
1765
+ // A pure chain/feature change (no `accounts` key) must be ignored.
1766
+ fire({ chains: ['solana:devnet'] })
1767
+ expect(callback).toHaveBeenCalledTimes(2)
1768
+
1769
+ // Switch to an account not authorized for the dapp → empty accounts → null,
1770
+ // which the child maps to [] (disconnect).
1771
+ wallet.accounts = []
1772
+ fire({ accounts: [] })
1773
+ expect(callback).toHaveBeenNthCalledWith(3, null)
1774
+
1775
+ // Teardown unsubscribes the change stream.
1776
+ teardown?.()
1777
+ expect(off).toHaveBeenCalledTimes(1)
1778
+ })
1779
+ })
885
1780
  })