@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.
- 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
package/src/BridgeParent.test.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
})
|