import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import * as Comlink from 'comlink' // Mock Comlink. windowEndpoint mirrors the real one (comlink 4.4.2): its // add/removeEventListener are window's own methods bound to window. This lets the // destroy tests assert real attachment/detachment on window via dispatchEvent, // rather than against a decoupled spy. vi.mock('comlink', () => ({ expose: vi.fn(), windowEndpoint: vi.fn(() => ({ addEventListener: window.addEventListener.bind(window), removeEventListener: window.removeEventListener.bind(window), postMessage: vi.fn() })), proxy: vi.fn((obj: unknown) => obj) })) // Mock @wallet-standard/app vi.mock('@wallet-standard/app', () => ({ getWallets: vi.fn().mockReturnValue({ get: () => [] }) })) // Mock @solana/wallet-adapter-base vi.mock('@solana/wallet-adapter-base', () => ({ isWalletAdapterCompatibleStandardWallet: vi.fn().mockReturnValue(false), WalletReadyState: { Installed: 'Installed', NotDetected: 'NotDetected', Loadable: 'Loadable', Unsupported: 'Unsupported' } })) // Mock @solana/wallet-standard-wallet-adapter-base vi.mock('@solana/wallet-standard-wallet-adapter-base', () => ({ StandardWalletAdapter: vi.fn() })) // Mock @solana/web3.js vi.mock('@solana/web3.js', () => ({ Connection: vi.fn(), Transaction: { from: vi.fn() }, VersionedTransaction: { deserialize: vi.fn() } })) // Hoisted mock for BaseWalletAdapter - direct reference avoids restoreAllMocks issues const baseAdapterMock = vi.hoisted(() => ({ readyState: 'NotDetected' as string, shouldThrow: false, fn: null as ReturnType | null })) baseAdapterMock.fn = vi.fn() // Mock ./BaseWalletAdapter vi.mock('./BaseWalletAdapter', () => ({ BaseWalletAdapter: baseAdapterMock.fn })) describe('BridgeParent', () => { let mockIframe: HTMLIFrameElement beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers() mockIframe = { contentWindow: {}, src: 'https://child.example.com/page' } as any }) afterEach(() => { vi.useRealTimers() vi.restoreAllMocks() }) describe('constructor', () => { it('should throw if no iframe provided', async () => { const { BridgeParent } = await import('./BridgeParent') expect(() => new BridgeParent(null as any)).toThrow( 'BridgeParent requires an iframe element' ) }) it('should accept a valid iframe', async () => { const { BridgeParent } = await import('./BridgeParent') const bridge = new BridgeParent(mockIframe) expect(bridge).toBeDefined() }) it('should call Comlink.expose after initialization', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') new BridgeParent(mockIframe) // Allow the async initializeConnection to run await vi.advanceTimersByTimeAsync(400) expect(Comlink.expose).toHaveBeenCalled() }) it('should call Comlink.windowEndpoint with iframe contentWindow', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) expect(Comlink.windowEndpoint).toHaveBeenCalledWith( mockIframe.contentWindow, window, 'https://child.example.com' ) }) it('should not initialize if iframe has no contentWindow', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') const iframeNoContent = { src: 'https://example.com' } as any new BridgeParent(iframeNoContent) await vi.advanceTimersByTimeAsync(400) expect(Comlink.expose).not.toHaveBeenCalled() }) }) describe('inbound origin/source hardening', () => { it('restricts comlink expose() to the iframe origin (not the "*" default)', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(200) // mockIframe.src is https://child.example.com/page → origin only. expect(Comlink.expose).toHaveBeenCalledWith( expect.anything(), expect.anything(), ['https://child.example.com'] ) }) it('drops a message whose source is not the iframe window', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') // Mirror how real comlink registers its endpoint listener. const exposedListener = vi.fn() vi.mocked(Comlink.expose).mockImplementationOnce((_api, endpoint) => { const ep = endpoint as { addEventListener: (type: string, listener: unknown) => void } ep.addEventListener('message', exposedListener) }) new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(200) // A foreign window (different reference than mockIframe.contentWindow). const foreignWindow = {} as Window const evt = new MessageEvent('message', { data: { id: 1 } }) Object.defineProperty(evt, 'source', { value: foreignWindow }) window.dispatchEvent(evt) expect(exposedListener).not.toHaveBeenCalled() }) it('delivers a message that comes from the iframe window', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') const exposedListener = vi.fn() vi.mocked(Comlink.expose).mockImplementationOnce((_api, endpoint) => { const ep = endpoint as { addEventListener: (type: string, listener: unknown) => void } ep.addEventListener('message', exposedListener) }) new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(200) const evt = new MessageEvent('message', { data: { id: 1 } }) Object.defineProperty(evt, 'source', { value: mockIframe.contentWindow }) window.dispatchEvent(evt) expect(exposedListener).toHaveBeenCalledTimes(1) }) }) describe('destroy', () => { it('detaches from window the listener expose() registered (no zombie across sessions)', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') // Real Comlink registers its endpoint listener via // ep.addEventListener('message', cb); simulate that so we can prove destroy() // detaches it. windowEndpoint's add/removeEventListener are window's own // methods (see the comlink mock), so this exercises real window attachment. const exposedListener = vi.fn() vi.mocked(Comlink.expose).mockImplementationOnce((_api, endpoint) => { const ep = endpoint as { addEventListener: (type: string, listener: unknown) => void } ep.addEventListener('message', exposedListener) }) const bridge = new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) // Live before teardown: a window 'message' from the iframe reaches the // listener (source must match — the guard drops anything else). const liveEvt = new MessageEvent('message', { data: { id: 1 } }) Object.defineProperty(liveEvt, 'source', { value: mockIframe.contentWindow }) window.dispatchEvent(liveEvt) expect(exposedListener).toHaveBeenCalledTimes(1) bridge.destroy() // Gone after teardown: the zombie no longer answers. const deadEvt = new MessageEvent('message', { data: { id: 2 } }) Object.defineProperty(deadEvt, 'source', { value: mockIframe.contentWindow }) window.dispatchEvent(deadEvt) expect(exposedListener).toHaveBeenCalledTimes(1) }) it('detaches the guarded wrapper when comlink removes its ORIGINAL listener (RELEASE teardown)', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') // We register a source-guarded wrapper, not comlink's own callback. On a // child RELEASE comlink calls removeEventListener('message', ), // so removeEventListener must translate the original back to the wrapper — // otherwise the wrapper stays attached as a zombie. const exposedListener = vi.fn() let ep!: { addEventListener: (type: string, listener: unknown) => void removeEventListener: (type: string, listener: unknown) => void } vi.mocked(Comlink.expose).mockImplementationOnce((_api, endpoint) => { ep = endpoint as typeof ep ep.addEventListener('message', exposedListener) }) new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(200) // comlink's RELEASE removal uses its ORIGINAL callback reference. ep.removeEventListener('message', exposedListener) const evt = new MessageEvent('message', { data: { id: 1 } }) Object.defineProperty(evt, 'source', { value: mockIframe.contentWindow }) window.dispatchEvent(evt) expect(exposedListener).not.toHaveBeenCalled() }) it('resets readiness flags so a stale read cannot report ready', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') const bridge = new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any // Solana/Bitcoin readiness is true right after init — a real // precondition, not a setup. expect(exposedAPI.walletStandardWalletsReady).toBe(true) expect(exposedAPI.bip122WalletsReady).toBe(true) exposedAPI.tronWalletsReady = true exposedAPI.tonWalletsReady = true exposedAPI.eip6963WalletsReady = true bridge.destroy() expect(exposedAPI.tronWalletsReady).toBe(false) expect(exposedAPI.tonWalletsReady).toBe(false) expect(exposedAPI.eip6963WalletsReady).toBe(false) expect(exposedAPI.walletStandardWalletsReady).toBe(false) expect(exposedAPI.bip122WalletsReady).toBe(false) }) it('resets bip122Wallets to an empty array on destroy', async () => { const { getWallets } = await import('@wallet-standard/app') const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') vi.mocked(getWallets).mockReturnValue({ get: () => [ { name: 'MetaMask', chains: ['bitcoin:mainnet'], features: { 'bitcoin:connect': { connect: vi.fn() } }, accounts: [] } ] as any, on: vi.fn(), register: vi.fn() } as any) const bridge = new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(200) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any expect(exposedAPI.bip122Wallets).toHaveLength(1) bridge.destroy() expect(exposedAPI.bip122Wallets).toEqual([]) }) it('is a safe no-op when the iframe never had a contentWindow', async () => { const { BridgeParent } = await import('./BridgeParent') const iframeNoContent = { src: 'https://child.example.com' } as any const bridge = new BridgeParent(iframeNoContent) await vi.advanceTimersByTimeAsync(400) expect(() => bridge.destroy()).not.toThrow() }) it('clears state and tolerates a second destroy()', async () => { const { BridgeParent } = await import('./BridgeParent') const bridge = new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) bridge.destroy() expect(() => bridge.destroy()).not.toThrow() }) }) describe('EIP6963 wallet discovery', () => { it('should dispatch eip6963:requestProvider event', async () => { const dispatchSpy = vi.spyOn(window, 'dispatchEvent') const { BridgeParent } = await import('./BridgeParent') new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) expect(dispatchSpy).toHaveBeenCalledWith( expect.objectContaining({ type: 'eip6963:requestProvider' }) ) }) it('should collect announced wallets', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') // Set up a listener that announces a wallet when requestProvider fires const mockProvider = { request: vi.fn() } window.addEventListener('eip6963:requestProvider', () => { window.dispatchEvent( new CustomEvent('eip6963:announceProvider', { detail: { info: { uuid: 'wallet-1', name: 'TestWallet', icon: 'test-icon', rdns: 'com.test.wallet' }, provider: mockProvider } }) ) }) new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) // The exposed parentAPI should contain the discovered wallet const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any expect(exposedAPI).toBeDefined() expect(exposedAPI.eip6963Wallets).toHaveLength(1) expect(exposedAPI.eip6963Wallets[0].uuid).toBe('wallet-1') expect(exposedAPI.eip6963Wallets[0].name).toBe('TestWallet') expect(exposedAPI.eip6963WalletsReady).toBe(true) }) it('should deduplicate wallets by uuid', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') const mockProvider = { request: vi.fn() } window.addEventListener('eip6963:requestProvider', () => { // Announce same wallet twice const event = new CustomEvent('eip6963:announceProvider', { detail: { info: { uuid: 'wallet-1', name: 'TestWallet', icon: 'icon', rdns: 'com.test' }, provider: mockProvider } }) window.dispatchEvent(event) window.dispatchEvent(event) }) new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any expect(exposedAPI.eip6963Wallets).toHaveLength(1) }) }) describe('Solana wallet discovery', () => { it('should call getWallets and filter Solana wallets', async () => { const { getWallets } = await import('@wallet-standard/app') const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') const mockSolanaWallet = { name: 'Phantom', chains: ['solana:mainnet'], features: { 'standard:connect': {} }, accounts: [] } const mockNonSolanaWallet = { name: 'SomeWallet', chains: ['ethereum:1'], features: {}, accounts: [] } vi.mocked(getWallets).mockReturnValue({ get: () => [mockSolanaWallet, mockNonSolanaWallet] as any, on: vi.fn(), register: vi.fn() } as any) new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any expect(exposedAPI.walletStandardWallets).toHaveLength(1) expect(exposedAPI.walletStandardWallets[0].name).toBe('Phantom') expect(exposedAPI.walletStandardWalletsReady).toBe(true) }) it('should return empty array when no Solana wallets found', async () => { const { getWallets } = await import('@wallet-standard/app') const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') vi.mocked(getWallets).mockReturnValue({ get: () => [], on: vi.fn(), register: vi.fn() } as any) new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any expect(exposedAPI.walletStandardWallets).toEqual([]) }) }) describe('Bitcoin wallet discovery', () => { it('should call getWallets and filter bitcoin:* wallets', async () => { const { getWallets } = await import('@wallet-standard/app') const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') const mockBitcoinWallet = { name: 'MetaMask', chains: ['bitcoin:mainnet'], features: { 'bitcoin:connect': { connect: vi.fn() }, 'bitcoin:signMessage': { signMessage: vi.fn() }, 'bitcoin:signTransaction': { signTransaction: vi.fn() } }, accounts: [] } const mockNonBitcoinWallet = { name: 'SomeWallet', chains: ['ethereum:1'], features: {}, accounts: [] } vi.mocked(getWallets).mockReturnValue({ get: () => [mockBitcoinWallet, mockNonBitcoinWallet] as any, on: vi.fn(), register: vi.fn() } as any) new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(200) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any expect(exposedAPI.bip122Wallets).toHaveLength(1) expect(exposedAPI.bip122Wallets[0].name).toBe('MetaMask') expect(exposedAPI.bip122Wallets[0].features).toEqual([ 'bitcoin:connect', 'bitcoin:signMessage', 'bitcoin:signTransaction' ]) expect(exposedAPI.bip122Wallets[0].wallet.accounts).toEqual([]) expect( exposedAPI.bip122Wallets[0].wallet.features['bitcoin:connect'] ).toBeDefined() expect(exposedAPI.bip122WalletsReady).toBe(true) }) it('keys the uuid on the bitcoin: chain, not chains[0], for a multi-chain wallet', async () => { const { getWallets } = await import('@wallet-standard/app') const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') // MetaMask-shaped: eip155 chains registered first, bitcoin: chain last. const mockMultiChainWallet = { name: 'MetaMask', chains: ['eip155:1', 'eip155:137', 'bitcoin:mainnet'], features: { 'bitcoin:connect': { connect: vi.fn() }, 'bitcoin:signMessage': { signMessage: vi.fn() }, 'bitcoin:signTransaction': { signTransaction: vi.fn() } }, accounts: [] } vi.mocked(getWallets).mockReturnValue({ get: () => [mockMultiChainWallet] as any, on: vi.fn(), register: vi.fn() } as any) new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(200) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any expect(exposedAPI.bip122Wallets).toHaveLength(1) // Must key on 'bitcoin:mainnet' (bitcoinChain), not 'eip155:1' // (chains[0]) — otherwise this uuid disagrees with the local // (unframed) discovery path's uuid for the exact same wallet. expect(exposedAPI.bip122Wallets[0].uuid).toBe('metamask-bitcoin:mainnet') }) it('should return empty array when no bitcoin wallets found', async () => { const { getWallets } = await import('@wallet-standard/app') const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') vi.mocked(getWallets).mockReturnValue({ get: () => [], on: vi.fn(), register: vi.fn() } as any) new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(200) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any expect(exposedAPI.bip122Wallets).toEqual([]) }) it('omits a feature entry the wallet does not declare', async () => { const { getWallets } = await import('@wallet-standard/app') const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') const mockWallet = { name: 'PartialWallet', chains: ['bitcoin:mainnet'], features: { 'bitcoin:connect': { connect: vi.fn() } }, accounts: [] } vi.mocked(getWallets).mockReturnValue({ get: () => [mockWallet] as any, on: vi.fn(), register: vi.fn() } as any) new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(200) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any const wallet = exposedAPI.bip122Wallets[0].wallet expect(wallet.features['bitcoin:connect']).toBeDefined() expect(wallet.features['bitcoin:signMessage']).toBeUndefined() expect(wallet.features['bitcoin:signTransaction']).toBeUndefined() }) }) describe('subscribeBip122WalletEvent', () => { // Bitcoin has no adapter class to construct (unlike Solana's // StandardWalletAdapter) — `getBitcoinWallets()` reshapes the raw // registry wallet directly, so the resolver just re-scans the registry // for a `bitcoin:*` wallet whose uuid matches. `standard:events` is the // ONLY path (no connect/disconnect fallback), since there is no adapter // to re-emit them. /** * Arm a discovered bip122 wallet whose account-change stream lives under * `eventsKey`, and subscribe to it through the bridge. * * Parameterized on the key because wallets disagree on where that stream * lives — Trust publishes `standard:events`, MetaMask `bitcoin:events` — * and the bridge must handle both identically. */ async function armBitcoinWallet( initialAccounts: { address: string }[], opts: { eventsKey?: 'standard:events' | 'bitcoin:events' chains?: string[] /** Adds an uncallable `standard:events`, which must not shadow a working key. */ malformedStandardEvents?: boolean } = {} ) { const { eventsKey = 'standard:events', chains = ['bitcoin:mainnet'], malformedStandardEvents = false } = opts const { getWallets } = await import('@wallet-standard/app') let changeListener: ((props: unknown) => void) | undefined const off = vi.fn() const wallet: any = { name: 'MetaMask', chains, accounts: initialAccounts, features: { 'bitcoin:connect': { connect: vi.fn() }, [eventsKey]: { on: vi.fn((event: string, listener: (props: unknown) => void) => { if (event === 'change') changeListener = listener return off }) } } } if (malformedStandardEvents) wallet.features['standard:events'] = {} vi.mocked(getWallets).mockReturnValue({ get: () => [wallet] as any, on: vi.fn(), register: vi.fn() } as any) const { BridgeParent } = await import('./BridgeParent') const bridgeParent = new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const callback = vi.fn() const walletId = `metamask-${chains.find(c => c.startsWith('bitcoin:')) ?? chains[0]}` const teardown = ( bridgeParent as any ).parentAPI.subscribeBip122WalletEvent(walletId, callback) return { wallet, off, callback, teardown, fire: (props: unknown) => changeListener?.(props) } } it('falls back to bitcoin:events and forwards the active address (MetaMask)', async () => { const { wallet, callback, fire, teardown, off } = await armBitcoinWallet( [{ address: 'bc1qoriginal' }], { eventsKey: 'bitcoin:events' } ) expect(wallet.features['bitcoin:events'].on).toHaveBeenCalledWith( 'change', expect.any(Function) ) wallet.accounts = [{ address: 'bc1qswitched' }] fire({ accounts: wallet.accounts }) expect(callback).toHaveBeenCalledWith('bc1qswitched') // De-authorized → null, which the child maps to [] (a disconnect). wallet.accounts = [] fire({ accounts: [] }) expect(callback).toHaveBeenCalledWith(null) // A pure chain/feature delta carries no `accounts` and must be ignored. fire({ chains: ['bitcoin:mainnet'] }) expect(callback).toHaveBeenCalledTimes(2) teardown?.() expect(off).toHaveBeenCalledTimes(1) }) it('prefers bitcoin:events over a present-but-uncallable standard:events', async () => { const { wallet, callback, fire } = await armBitcoinWallet( [{ address: 'bc1qoriginal' }], { eventsKey: 'bitcoin:events', malformedStandardEvents: true } ) wallet.accounts = [{ address: 'bc1qswitched' }] fire({ accounts: wallet.accounts }) expect(callback).toHaveBeenCalledWith('bc1qswitched') }) it('throws when no wallet matches the given walletId', async () => { const { getWallets } = await import('@wallet-standard/app') vi.mocked(getWallets).mockReturnValue({ get: () => [], on: vi.fn(), register: vi.fn() } as any) const { BridgeParent } = await import('./BridgeParent') const bridgeParent = new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) expect(() => (bridgeParent as any).parentAPI.subscribeBip122WalletEvent( 'unknown-wallet-id', vi.fn() ) ).toThrow(/no wallet found for walletId/) }) it('returns undefined when the resolved wallet exposes no standard:events', async () => { const { getWallets } = await import('@wallet-standard/app') vi.mocked(getWallets).mockReturnValue({ get: () => [ { name: 'MetaMask', chains: ['bitcoin:mainnet'], accounts: [], features: { 'bitcoin:connect': { connect: vi.fn() } } } ] as any, on: vi.fn(), register: vi.fn() } as any) const { BridgeParent } = await import('./BridgeParent') const bridgeParent = new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const teardown = ( bridgeParent as any ).parentAPI.subscribeBip122WalletEvent( 'metamask-bitcoin:mainnet', vi.fn() ) expect(teardown).toBeUndefined() }) it('resolves a multi-chain wallet (eip155 first) by its bitcoin:-keyed walletId, not chains[0]', async () => { const { teardown } = await armBitcoinWallet( [{ address: 'bc1qexisting' }], ['eip155:1', 'eip155:137', 'bitcoin:mainnet'] ) expect(typeof teardown).toBe('function') }) it('subscribes to standard:events change and forwards the active address as a plain string', async () => { const { wallet, teardown, off } = await armBitcoinWallet([ { address: 'bc1qoriginal' } ]) expect(wallet.features['standard:events'].on).toHaveBeenCalledWith( 'change', expect.any(Function) ) expect(typeof teardown).toBe('function') expect(Comlink.proxy).toHaveBeenCalledWith(expect.any(Function)) expect(off).not.toHaveBeenCalled() }) it('propagates a switch, then null on de-auth, and ignores non-account changes', async () => { const { wallet, callback, off, teardown, fire } = await armBitcoinWallet([ { address: 'bc1qACC1' } ]) wallet.accounts = [{ address: 'bc1qACC2' }] fire({ accounts: wallet.accounts }) expect(callback).toHaveBeenNthCalledWith(1, 'bc1qACC2') // A pure chain/feature change (no `accounts` key) must be ignored. fire({ chains: ['bitcoin:mainnet'] }) expect(callback).toHaveBeenCalledTimes(1) wallet.accounts = [] fire({ accounts: [] }) expect(callback).toHaveBeenNthCalledWith(2, null) teardown?.() expect(off).toHaveBeenCalledTimes(1) }) it('never lets the wallet-standard unsubscribe return value escape the teardown (unserializable-return-value guard)', async () => { const { off, teardown } = await armBitcoinWallet([ { address: 'bc1qACC1' } ]) off.mockReturnValue({ some: 'non-cloneable-emitter-return' }) expect(() => teardown?.()).not.toThrow() }) }) describe('Tron wallet discovery', () => { it('should start with empty tron wallets and not ready', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any expect(exposedAPI.tronWallets).toEqual([]) expect(exposedAPI.tronWalletsReady).toBe(false) }) it('should discover tron wallets when discoverTronWallets is called', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') // Set up a tron provider on window ;(window as any).tronLink = { ready: true, request: vi.fn(), tronWeb: { defaultAddress: { base58: 'TAddr1', hex: '0x1' }, trx: { sign: vi.fn(), signMessageV2: vi.fn(), sendRawTransaction: vi.fn() }, transactionBuilder: { sendTrx: vi.fn(), triggerSmartContract: vi.fn() }, toHex: vi.fn() } } new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any // Simulate child calling discoverTronWallets exposedAPI.discoverTronWallets(['tronLink']) expect(exposedAPI.tronWalletsReady).toBe(true) expect(exposedAPI.tronWallets).toHaveLength(1) expect(exposedAPI.tronWallets[0].uuid).toBe('tron-tronlink') expect(exposedAPI.tronWallets[0].name).toBe('tronLink') expect(exposedAPI.tronWallets[0].injectedId).toBe('tronLink') // Cleanup delete (window as any).tronLink }) it('should handle nested tron provider paths', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') ;(window as any).tokenpocket = { tron: { ready: true, request: vi.fn(), tronWeb: { defaultAddress: { base58: 'TAddr2' }, trx: { sign: vi.fn(), signMessageV2: vi.fn(), sendRawTransaction: vi.fn() }, transactionBuilder: { sendTrx: vi.fn(), triggerSmartContract: vi.fn() }, toHex: vi.fn() } } } new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any exposedAPI.discoverTronWallets(['tokenpocket.tron']) expect(exposedAPI.tronWallets).toHaveLength(1) expect(exposedAPI.tronWallets[0].uuid).toBe('tron-tokenpocket-tron') delete (window as any).tokenpocket }) it('should return empty when injectedIds is empty', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any exposedAPI.discoverTronWallets([]) expect(exposedAPI.tronWallets).toEqual([]) expect(exposedAPI.tronWalletsReady).toBe(true) }) it('should skip providers without valid tronWeb.trx', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') ;(window as any).badWallet = { ready: true, tronWeb: { notTrx: true } } new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any exposedAPI.discoverTronWallets(['badWallet']) expect(exposedAPI.tronWallets).toEqual([]) delete (window as any).badWallet }) it('should deduplicate tron wallets by injectedId', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') ;(window as any).tronLink = { ready: true, request: vi.fn(), tronWeb: { defaultAddress: { base58: 'TAddr1' }, trx: { sign: vi.fn(), signMessageV2: vi.fn(), sendRawTransaction: vi.fn() }, transactionBuilder: { sendTrx: vi.fn(), triggerSmartContract: vi.fn() }, toHex: vi.fn() } } new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any exposedAPI.discoverTronWallets(['tronLink', 'tronLink']) expect(exposedAPI.tronWallets).toHaveLength(1) delete (window as any).tronLink }) }) describe('TON wallet discovery', () => { it('should start with empty ton wallets and not ready', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any expect(exposedAPI.tonWallets).toEqual([]) expect(exposedAPI.tonWalletsReady).toBe(false) }) it('should discover TON wallets when discoverTonWallets is called', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') ;(window as any).trustwalletTon = { tonconnect: { deviceInfo: { platform: 'chrome', appName: 'Trust Wallet', appVersion: '1.0', maxProtocolVersion: 2, features: [] }, walletInfo: { name: 'Trust Wallet', app_name: 'trustwallet', image: 'https://example.com/icon.png', about_url: 'https://example.com', tondns: '', platforms: ['chrome'], features: [] }, protocolVersion: 2, isWalletBrowser: false, connect: vi.fn(), restoreConnection: vi.fn(), send: vi.fn(), listen: vi.fn(() => vi.fn()), disconnect: vi.fn() } } new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any exposedAPI.discoverTonWallets(['trustwalletTon']) expect(exposedAPI.tonWalletsReady).toBe(true) expect(exposedAPI.tonWallets).toHaveLength(1) expect(exposedAPI.tonWallets[0].jsBridgeKey).toBe('trustwalletTon') expect(exposedAPI.tonWallets[0].uuid).toBe('ton-trustwalletton') delete (window as any).trustwalletTon }) it('should proxy walletInfo for isJSBridgeWithMetadata compatibility', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') ;(window as any).trustwalletTon = { tonconnect: { deviceInfo: { platform: 'chrome', appName: 'Trust Wallet', appVersion: '1.0', maxProtocolVersion: 2, features: [] }, walletInfo: { name: 'Trust Wallet', app_name: 'trustwallet', image: 'https://icon.png', about_url: 'https://tw.com', tondns: '', platforms: ['chrome'], features: [] }, protocolVersion: 2, isWalletBrowser: false, connect: vi.fn(), restoreConnection: vi.fn(), send: vi.fn(), listen: vi.fn(() => vi.fn()), disconnect: vi.fn() } } new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any exposedAPI.discoverTonWallets(['trustwalletTon']) const provider = exposedAPI.tonWallets[0].provider // Comlink.proxy mock returns the object as-is expect(provider.walletInfo).toBeDefined() expect(provider.walletInfo.name).toBe('Trust Wallet') expect(provider.walletInfo.app_name).toBe('trustwallet') expect(provider.walletInfo.platforms).toEqual(['chrome']) expect(provider.protocolVersion).toBe(2) expect(provider.isWalletBrowser).toBe(false) expect(typeof provider.restoreConnection).toBe('function') delete (window as any).trustwalletTon }) it('should skip wallets without valid tonconnect bridge', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') ;(window as any).fakeTon = { tonconnect: { notABridge: true } } new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any exposedAPI.discoverTonWallets(['fakeTon']) expect(exposedAPI.tonWallets).toEqual([]) delete (window as any).fakeTon }) it('should deduplicate TON wallets by jsBridgeKey', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') ;(window as any).trustwalletTon = { tonconnect: { connect: vi.fn(), send: vi.fn(), listen: vi.fn(() => vi.fn()), deviceInfo: { appName: 'Trust Wallet' } } } new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any exposedAPI.discoverTonWallets(['trustwalletTon', 'trustwalletTon']) expect(exposedAPI.tonWallets).toHaveLength(1) delete (window as any).trustwalletTon }) }) describe('Base Wallet detection', () => { beforeEach(async () => { baseAdapterMock.readyState = 'NotDetected' baseAdapterMock.shouldThrow = false // Re-set mocks that vi.restoreAllMocks() may have cleared const { getWallets } = await import('@wallet-standard/app') vi.mocked(getWallets).mockReturnValue({ get: () => [], on: vi.fn(), register: vi.fn() } as any) // Re-set BaseWalletAdapter mock - must use 'function' (not arrow) for 'new' compatibility baseAdapterMock.fn!.mockImplementation(function () { if (baseAdapterMock.shouldThrow) throw new Error('init failed') return { readyState: baseAdapterMock.readyState, name: 'Base Wallet', icon: 'data:image/svg+xml;base64,test', url: 'https://example.com', publicKey: null, connecting: false, connected: false, supportedTransactionVersions: new Set(['legacy', 0]), connect: vi.fn(), disconnect: vi.fn(), sendTransaction: vi.fn(), signTransaction: vi.fn(), signAllTransactions: vi.fn(), signMessage: vi.fn() } }) }) it('should include Base Wallet when detected as Installed', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') baseAdapterMock.readyState = 'Installed' new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any const baseWallet = exposedAPI.walletStandardWallets.find( (w: any) => w.name === 'Base Wallet' ) expect(baseWallet).toBeDefined() expect(baseWallet.uuid).toBe('base-wallet-traditional') expect(baseWallet.chains).toEqual(['solana:mainnet']) expect(baseWallet.features).toEqual(['traditional-adapter']) expect(baseWallet.adapter).toBeDefined() }) it('should include Base Wallet when detected as Loadable', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') baseAdapterMock.readyState = 'Loadable' new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any const baseWallet = exposedAPI.walletStandardWallets.find( (w: any) => w.name === 'Base Wallet' ) expect(baseWallet).toBeDefined() }) it('should not include Base Wallet when not detected', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any const baseWallet = exposedAPI.walletStandardWallets.find( (w: any) => w.name === 'Base Wallet' ) expect(baseWallet).toBeUndefined() }) it('should skip Base Wallet if name already in Wallet Standard wallets', async () => { const { getWallets } = await import('@wallet-standard/app') const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') const existingWallet = { name: 'Base Wallet', chains: ['solana:mainnet'], features: { 'standard:connect': {} }, accounts: [] } vi.mocked(getWallets).mockReturnValue({ get: () => [existingWallet] as any, on: vi.fn(), register: vi.fn() } as any) baseAdapterMock.readyState = 'Installed' new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any const baseWallets = exposedAPI.walletStandardWallets.filter( (w: any) => w.name === 'Base Wallet' ) expect(baseWallets).toHaveLength(1) expect(baseWallets[0].features).not.toContain('traditional-adapter') }) it('should handle BaseWalletAdapter constructor errors gracefully', async () => { const Comlink = await import('comlink') const { BridgeParent } = await import('./BridgeParent') baseAdapterMock.shouldThrow = true new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const exposedAPI = vi.mocked(Comlink.expose).mock.calls[0]?.[0] as any expect(exposedAPI).toBeDefined() expect(exposedAPI.walletStandardWallets).toEqual([]) }) }) describe('subscribeEip6963ProviderEvent', () => { function makeMockProvider() { return { request: vi.fn(), on: vi.fn(), removeListener: vi.fn() } } // Lets the real one-shot discovery window (300ms) resolve first, THEN // overwrites eip6963Wallets with the test's fixture — otherwise the // discovery's own `.then()` (see BridgeParent.ts's initializeConnection) // would overwrite the fixture with `[]` out from under the test the // moment `subscribeEip6963ProviderEvent`'s internal await lets it run. async function readyBridgeParent(mockIframe: HTMLIFrameElement) { const { BridgeParent } = await import('./BridgeParent') const bridgeParent = new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) return bridgeParent } it('attaches a real listener to the detected provider via a LOCAL wrapper, not the raw callback', async () => { const bridgeParent = await readyBridgeParent(mockIframe) const provider = makeMockProvider() // Simulate a prior EIP-6963 discovery having populated the wallet list. ;(bridgeParent as any).parentAPI.eip6963Wallets = [ { uuid: 'uuid-1', name: 'MetaMask', icon: '', rdns: 'io.metamask', provider } ] const callback = vi.fn() const teardown = await ( bridgeParent as any ).parentAPI.subscribeEip6963ProviderEvent( 'uuid-1', 'accountsChanged', callback ) expect(provider.on).toHaveBeenCalledWith( 'accountsChanged', expect.any(Function) ) // Regression guard: `callback` here IS the Comlink proxy the child // passed. Registering it directly on the real provider means Comlink // must serialize EVERY argument the wallet's own dispatch calls it // with — some wallets' internal EventEmitter pass extra, non-EIP-1193 // arguments (observed in production: a wallet's own internal handler // function) alongside the real payload, and Comlink can't clone a // function, throwing "DataCloneError" the moment the wallet fires the // event. The registered listener must NOT be `callback` itself. expect(provider.on).not.toHaveBeenCalledWith('accountsChanged', callback) expect(typeof teardown).toBe('function') // Regression guard: the teardown crosses back to the child as a // Comlink return value — an un-proxied function throws "Unserializable // return value" the moment a real postMessage transport (not this // identity-mocked one) tries to deserialize it, exactly like TON's // `listen()` teardown above must be (and is) proxied. expect(Comlink.proxy).toHaveBeenCalledWith(expect.any(Function)) }) it('the local wrapper forwards ONLY the first argument to callback, discarding any extra ones the wallet passes', async () => { const bridgeParent = await readyBridgeParent(mockIframe) const provider = makeMockProvider() ;(bridgeParent as any).parentAPI.eip6963Wallets = [ { uuid: 'uuid-1', name: 'MetaMask', icon: '', rdns: 'io.metamask', provider } ] const callback = vi.fn() await (bridgeParent as any).parentAPI.subscribeEip6963ProviderEvent( 'uuid-1', 'accountsChanged', callback ) const registeredWrapper = provider.on.mock.calls[0]?.[1] const extraNonCloneableArg = () => {} registeredWrapper(['0xNEW'], extraNonCloneableArg, { some: 'extra' }) expect(callback).toHaveBeenCalledTimes(1) expect(callback).toHaveBeenCalledWith(['0xNEW']) }) it("teardown returns undefined even when the real EventEmitter's removeListener returns `this` (the whole provider)", async () => { // By EventEmitter convention (Node's `events` module, and wallet // providers built on it, e.g. Trust Wallet's inpage.js), .removeListener // returns `this` for chaining. An expression-bodied teardown // (`() => provider.removeListener?.(...)`) would implicitly return that // — the whole provider object — across the Comlink boundary, which is // itself unserializable and reproduces the exact same failure one level // down, the moment a caller actually invokes the teardown. const bridgeParent = await readyBridgeParent(mockIframe) const provider: any = { request: vi.fn(), on: vi.fn(), removeListener: vi.fn(function (this: unknown) { return this }) } ;(bridgeParent as any).parentAPI.eip6963Wallets = [ { uuid: 'uuid-1', name: 'Trust Wallet', icon: '', rdns: 'com.trustwallet.app', provider } ] const teardown = await ( bridgeParent as any ).parentAPI.subscribeEip6963ProviderEvent( 'uuid-1', 'accountsChanged', vi.fn() ) expect(teardown()).toBeUndefined() }) it('teardown calls removeListener with the SAME wrapper reference registered via .on', async () => { const bridgeParent = await readyBridgeParent(mockIframe) const provider = makeMockProvider() ;(bridgeParent as any).parentAPI.eip6963Wallets = [ { uuid: 'uuid-1', name: 'MetaMask', icon: '', rdns: 'io.metamask', provider } ] const callback = vi.fn() const teardown = await ( bridgeParent as any ).parentAPI.subscribeEip6963ProviderEvent( 'uuid-1', 'accountsChanged', callback ) const registeredWrapper = provider.on.mock.calls[0]?.[1] teardown?.() expect(provider.removeListener).toHaveBeenCalledWith( 'accountsChanged', registeredWrapper ) }) it('throws for an unknown uuid (wallet not detected)', async () => { const bridgeParent = await readyBridgeParent(mockIframe) ;(bridgeParent as any).parentAPI.eip6963Wallets = [] await expect( (bridgeParent as any).parentAPI.subscribeEip6963ProviderEvent( 'unknown-uuid', 'accountsChanged', vi.fn() ) ).rejects.toThrow(/no wallet found for uuid/) }) it('throws when no wallet matches the given uuid (the uuid-mismatch bug this guards against)', async () => { const bridgeParent = await readyBridgeParent(mockIframe) ;(bridgeParent as any).parentAPI.eip6963Wallets = [] await expect( (bridgeParent as any).parentAPI.subscribeEip6963ProviderEvent( 'uuid-not-discovered-by-this-parent', 'accountsChanged', vi.fn() ) ).rejects.toThrow(/no wallet found for uuid/) }) it('falls back to matching by rdns when the uuid does not match (the uuid-mismatch bug this closes structurally)', async () => { const bridgeParent = await readyBridgeParent(mockIframe) const provider = makeMockProvider() // This parent's own top-frame discovery knows the wallet under // 'uuid-from-top-frame' — simulating the exact scenario where the // child instead discovered the SAME wallet locally inside the // iframe under an independent, freshly-minted uuid. ;(bridgeParent as any).parentAPI.eip6963Wallets = [ { uuid: 'uuid-from-top-frame', name: 'MetaMask', icon: '', rdns: 'io.metamask', provider } ] const callback = vi.fn() const teardown = await ( bridgeParent as any ).parentAPI.subscribeEip6963ProviderEvent( 'uuid-from-local-iframe-injection', 'accountsChanged', callback, 'io.metamask' ) expect(typeof teardown).toBe('function') expect(provider.on).toHaveBeenCalledWith( 'accountsChanged', expect.any(Function) ) }) it('still throws when neither uuid nor rdns match anything', async () => { const bridgeParent = await readyBridgeParent(mockIframe) ;(bridgeParent as any).parentAPI.eip6963Wallets = [] await expect( (bridgeParent as any).parentAPI.subscribeEip6963ProviderEvent( 'uuid-not-discovered-by-this-parent', 'accountsChanged', vi.fn(), 'com.nonexistent.wallet' ) ).rejects.toThrow(/no wallet found for uuid/) }) it('awaits the in-flight discovery window instead of failing permanently when subscribe is called BEFORE it resolves', async () => { // Regression for the startup race: a subscribe call arriving while the // one-shot 300ms discovery window is still open (e.g. immediately on // page load, during a session restore) used to look up against a // still-empty eip6963Wallets, miss, and throw — permanently, since the // child never retries a rejected subscribe (see BridgeChild.ts's // window.__uwcSubscribeEip6963ProviderEvent .catch()). const provider = makeMockProvider() window.addEventListener('eip6963:requestProvider', () => { window.dispatchEvent( new CustomEvent('eip6963:announceProvider', { detail: { info: { uuid: 'uuid-1', name: 'MetaMask', icon: '', rdns: 'io.metamask' }, provider } }) ) }) const { BridgeParent } = await import('./BridgeParent') const bridgeParent = new BridgeParent(mockIframe) // Call subscribe IMMEDIATELY — well before the 300ms discovery window // (dispatched synchronously above, so the wallet IS in the discovery // listener's closure, but the parent hasn't resolved/flipped // eip6963WalletsReady yet). const subscribePromise = ( bridgeParent as any ).parentAPI.subscribeEip6963ProviderEvent( 'uuid-1', 'accountsChanged', vi.fn() ) // Let the 300ms discovery window elapse. await vi.advanceTimersByTimeAsync(400) const teardown = await subscribePromise expect(typeof teardown).toBe('function') expect(provider.on).toHaveBeenCalledWith( 'accountsChanged', expect.any(Function) ) }) }) describe('subscribeTronProviderEvent', () => { function makeMockTronProvider() { return { tronWeb: {}, on: vi.fn(), removeListener: vi.fn() } } it('resolves the provider by injectedId window path and attaches a real listener via a LOCAL wrapper', async () => { const { BridgeParent } = await import('./BridgeParent') const bridgeParent = new BridgeParent(mockIframe) const provider = makeMockTronProvider() ;(window as any).tronLink = provider const callback = vi.fn() const teardown = ( bridgeParent as any ).parentAPI.subscribeTronProviderEvent( 'tronLink', 'accountsChanged', callback ) expect(provider.on).toHaveBeenCalledWith( 'accountsChanged', expect.any(Function) ) // Regression guard: same "the registered listener must not be the raw // Comlink-proxied callback" requirement as the EIP-6963 method above — // a wallet's own dispatch calling the listener with an extra, // non-serializable argument (e.g. an internal handler function) // otherwise throws DataCloneError the moment the wallet fires the // event. expect(provider.on).not.toHaveBeenCalledWith('accountsChanged', callback) expect(typeof teardown).toBe('function') // Regression guard: same "must cross the bridge as a Comlink return // value" requirement as the EIP-6963 subscribe method above. expect(Comlink.proxy).toHaveBeenCalledWith(expect.any(Function)) const registeredWrapper = provider.on.mock.calls[0]?.[1] teardown?.() expect(provider.removeListener).toHaveBeenCalledWith( 'accountsChanged', registeredWrapper ) delete (window as any).tronLink }) it('the local wrapper forwards ONLY the first argument to callback, discarding any extra ones the wallet passes', async () => { const { BridgeParent } = await import('./BridgeParent') const bridgeParent = new BridgeParent(mockIframe) const provider = makeMockTronProvider() ;(window as any).tronLink = provider const callback = vi.fn() ;(bridgeParent as any).parentAPI.subscribeTronProviderEvent( 'tronLink', 'accountsChanged', callback ) const registeredWrapper = provider.on.mock.calls[0]?.[1] const extraNonCloneableArg = () => {} registeredWrapper('TSwitchedAddress', extraNonCloneableArg) expect(callback).toHaveBeenCalledTimes(1) expect(callback).toHaveBeenCalledWith('TSwitchedAddress') delete (window as any).tronLink }) it("teardown returns undefined even when the real provider's removeListener returns `this`", async () => { const { BridgeParent } = await import('./BridgeParent') const bridgeParent = new BridgeParent(mockIframe) const provider: any = { tronWeb: {}, on: vi.fn(), removeListener: vi.fn(function (this: unknown) { return this }) } ;(window as any).tronLink = provider const teardown = ( bridgeParent as any ).parentAPI.subscribeTronProviderEvent( 'tronLink', 'accountsChanged', vi.fn() ) expect(teardown()).toBeUndefined() delete (window as any).tronLink }) it('throws when the injectedId window path resolves to nothing', async () => { const { BridgeParent } = await import('./BridgeParent') const bridgeParent = new BridgeParent(mockIframe) expect(() => (bridgeParent as any).parentAPI.subscribeTronProviderEvent( 'nonexistent.path', 'accountsChanged', vi.fn() ) ).toThrow(/no provider found at injectedId/) }) it('throws when no provider resolves at the given injectedId path', async () => { const { BridgeParent } = await import('./BridgeParent') const bridgeParent = new BridgeParent(mockIframe) expect(() => (bridgeParent as any).parentAPI.subscribeTronProviderEvent( 'nonExistentWallet', 'accountsChanged', vi.fn() ) ).toThrow(/no provider found at injectedId/) }) // MFS-778: TronLink resolves `window.tronLink` (request+tronWeb, no `.on`) // for discovery/signing, but emits account/disconnect events on its // separate TIP-1193 `window.tron`. Binding on the resolved provider alone // silently dropped every account switch. it('subscribes on the TIP-1193 window.tron when the resolved provider has no .on', async () => { const { BridgeParent } = await import('./BridgeParent') const bridgeParent = new BridgeParent(mockIframe) // Resolved-by-injectedId provider: request+tronWeb, NOT an emitter. ;(window as any).tronLink = { tronWeb: {}, request: vi.fn() } // TIP-1193 emitter TronLink actually fires events on. const emitter = { tronWeb: {}, request: vi.fn(), on: vi.fn(), removeListener: vi.fn() } ;(window as any).tron = emitter const callback = vi.fn() const teardown = ( bridgeParent as any ).parentAPI.subscribeTronProviderEvent( 'tronLink', 'accountsChanged', callback ) expect(emitter.on).toHaveBeenCalledWith( 'accountsChanged', expect.any(Function) ) // Same regression guard as the eip6963/direct paths: the raw // Comlink-proxied callback is never registered directly. expect(emitter.on).not.toHaveBeenCalledWith('accountsChanged', callback) const registeredWrapper = emitter.on.mock.calls[0]?.[1] registeredWrapper(['TSwitched'], () => {}) expect(callback).toHaveBeenCalledTimes(1) expect(callback).toHaveBeenCalledWith(['TSwitched']) teardown?.() expect(emitter.removeListener).toHaveBeenCalledWith( 'accountsChanged', registeredWrapper ) delete (window as any).tronLink delete (window as any).tron }) // A non-TronLink wallet whose own Tron provider lacks `.on` (confirmed live // for OKX's `okxwallet.tronLink`) must NOT inherit TronLink's `window.tron`. // OKX broadcasts account switches on the window `message` channel; binding // its `accountsChanged` on TronLink's `window.tron` means OKX's switches // fire on the wrong wallet's provider and are silently dropped (the OKX+Tron // account-switch bug). It must fall through to the `message` channel. it('does NOT inherit TronLink window.tron for OKX (emitter-less, non-TronLink) — falls back to window `message`', async () => { const { BridgeParent } = await import('./BridgeParent') const bridgeParent = new BridgeParent(mockIframe) // OKX's Tron provider: request+tronWeb, NOT an event emitter. ;(window as any).okxwallet = { tronLink: { tronWeb: {}, request: vi.fn() } } // TronLink's TIP-1193 emitter IS present (both wallets installed) and is a // valid Tron provider — so ONLY the identity gate keeps OKX off it. const tronLinkEmitter = { tronWeb: {}, request: vi.fn(), on: vi.fn(), removeListener: vi.fn() } ;(window as any).tron = tronLinkEmitter const addSpy = vi.spyOn(window, 'addEventListener') const callback = vi.fn() const teardown = ( bridgeParent as any ).parentAPI.subscribeTronProviderEvent( 'okxwallet.tronLink', 'accountsChanged', callback ) // Must NOT bind on TronLink's window.tron. expect(tronLinkEmitter.on).not.toHaveBeenCalled() // Instead subscribes to the window `message` channel — OKX's real channel. expect(addSpy).toHaveBeenCalledWith('message', expect.any(Function)) window.dispatchEvent( new MessageEvent('message', { data: { message: { action: 'accountsChanged', data: { address: 'TOkxSwitched' } } }, source: window, origin: window.location.origin }) ) expect(callback).toHaveBeenCalledWith(['TOkxSwitched']) teardown?.() delete (window as any).okxwallet delete (window as any).tron }) it('ignores a forged Tron `setAccount` from a foreign source/origin (runs in the top frame)', async () => { // This handler runs in the client's TOP frame and feeds the live session // address, so a same-origin sibling frame or window.opener must not be // able to post `{ message: { action: 'setAccount' } }` and move the account. const { BridgeParent } = await import('./BridgeParent') const bridgeParent = new BridgeParent(mockIframe) ;(window as any).tronLink = { tronWeb: {}, request: vi.fn() } const callback = vi.fn() const teardown = ( bridgeParent as any ).parentAPI.subscribeTronProviderEvent( 'tronLink', 'accountsChanged', callback ) // Foreign source. window.dispatchEvent( new MessageEvent('message', { data: { message: { action: 'setAccount', data: { address: 'TAttacker' } } }, source: {} as Window, origin: window.location.origin }) ) // Right source, wrong origin. window.dispatchEvent( new MessageEvent('message', { data: { message: { action: 'setAccount', data: { address: 'TAttacker' } } }, source: window, origin: 'https://evil.example' }) ) expect(callback).not.toHaveBeenCalled() teardown?.() delete (window as any).tronLink }) it('falls back to the window `message` channel when neither the resolved provider nor window.tron has .on', async () => { const { BridgeParent } = await import('./BridgeParent') const bridgeParent = new BridgeParent(mockIframe) ;(window as any).tronLink = { tronWeb: {}, request: vi.fn() } // No window.tron emitter at all. const addSpy = vi.spyOn(window, 'addEventListener') const removeSpy = vi.spyOn(window, 'removeEventListener') const callback = vi.fn() const teardown = ( bridgeParent as any ).parentAPI.subscribeTronProviderEvent( 'tronLink', 'accountsChanged', callback ) expect(addSpy).toHaveBeenCalledWith('message', expect.any(Function)) window.dispatchEvent( new MessageEvent('message', { data: { message: { action: 'setAccount', data: { address: 'TSwitched' } } }, source: window, origin: window.location.origin }) ) expect(callback).toHaveBeenCalledWith(['TSwitched']) teardown?.() expect(removeSpy).toHaveBeenCalledWith('message', expect.any(Function)) delete (window as any).tronLink }) }) describe('subscribeSolanaWalletEvent', () => { beforeEach(async () => { // Re-set mocks that vi.restoreAllMocks() may have cleared. const { getWallets } = await import('@wallet-standard/app') vi.mocked(getWallets).mockReturnValue({ get: () => [], on: vi.fn(), register: vi.fn() } as any) const { isWalletAdapterCompatibleStandardWallet } = await import( '@solana/wallet-adapter-base' ) vi.mocked(isWalletAdapterCompatibleStandardWallet).mockReturnValue(false) baseAdapterMock.readyState = 'NotDetected' baseAdapterMock.shouldThrow = false baseAdapterMock.fn!.mockImplementation(function () { if (baseAdapterMock.shouldThrow) throw new Error('init failed') return { readyState: baseAdapterMock.readyState, name: 'Base Wallet', on: vi.fn(), off: vi.fn() } }) }) function stubStandardWalletAdapter(adapter: { on: ReturnType off: ReturnType }) { return import('@solana/wallet-standard-wallet-adapter-base').then( ({ StandardWalletAdapter }) => { vi.mocked(StandardWalletAdapter).mockImplementation(function () { return adapter } as unknown as typeof StandardWalletAdapter) } ) } it('resolves the connected wallet-standard adapter by walletId and attaches connect/disconnect listeners via a LOCAL wrapper', async () => { const { getWallets } = await import('@wallet-standard/app') const { isWalletAdapterCompatibleStandardWallet } = await import( '@solana/wallet-adapter-base' ) const { BridgeParent } = await import('./BridgeParent') vi.mocked(getWallets).mockReturnValue({ get: () => [ { name: 'Phantom', chains: ['solana:mainnet'], features: {}, accounts: [] } ] as any, on: vi.fn(), register: vi.fn() } as any) vi.mocked(isWalletAdapterCompatibleStandardWallet).mockReturnValue(true) const adapterOn = vi.fn() const adapterOff = vi.fn() await stubStandardWalletAdapter({ on: adapterOn, off: adapterOff }) const bridgeParent = new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const callback = vi.fn() const teardown = ( bridgeParent as any ).parentAPI.subscribeSolanaWalletEvent('phantom-solana:mainnet', callback) expect(adapterOn).toHaveBeenCalledWith('connect', expect.any(Function)) expect(adapterOn).toHaveBeenCalledWith('disconnect', expect.any(Function)) expect(typeof teardown).toBe('function') // Regression guard: the teardown crosses back to the child as a // Comlink return value, same requirement as the eip155/tron teardowns. expect(Comlink.proxy).toHaveBeenCalledWith(expect.any(Function)) teardown?.() expect(adapterOff).toHaveBeenCalledWith('connect', expect.any(Function)) expect(adapterOff).toHaveBeenCalledWith( 'disconnect', expect.any(Function) ) }) it('reduces the PublicKey to a plain base58 string BEFORE forwarding to callback (never the object itself)', async () => { const { getWallets } = await import('@wallet-standard/app') const { isWalletAdapterCompatibleStandardWallet } = await import( '@solana/wallet-adapter-base' ) const { BridgeParent } = await import('./BridgeParent') vi.mocked(getWallets).mockReturnValue({ get: () => [ { name: 'Phantom', chains: ['solana:mainnet'], features: {}, accounts: [] } ] as any, on: vi.fn(), register: vi.fn() } as any) vi.mocked(isWalletAdapterCompatibleStandardWallet).mockReturnValue(true) const adapterOn = vi.fn() const adapterOff = vi.fn() await stubStandardWalletAdapter({ on: adapterOn, off: adapterOff }) const bridgeParent = new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const callback = vi.fn() ;(bridgeParent as any).parentAPI.subscribeSolanaWalletEvent( 'phantom-solana:mainnet', callback ) const onConnectWrapper = adapterOn.mock.calls.find( call => call[0] === 'connect' )?.[1] const onDisconnectWrapper = adapterOn.mock.calls.find( call => call[0] === 'disconnect' )?.[1] const fakePublicKey = { toBase58: () => 'SoLaNaAddr_NEW' } onConnectWrapper(fakePublicKey) expect(callback).toHaveBeenCalledWith('SoLaNaAddr_NEW') expect(callback).not.toHaveBeenCalledWith(fakePublicKey) onDisconnectWrapper() expect(callback).toHaveBeenCalledWith(null) }) it('throws when no wallet matches the given walletId', async () => { const { BridgeParent } = await import('./BridgeParent') const bridgeParent = new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) expect(() => (bridgeParent as any).parentAPI.subscribeSolanaWalletEvent( 'unknown-wallet-id', vi.fn() ) ).toThrow(/no wallet found for walletId/) }) it('resolves the traditional Base Wallet adapter by its "-traditional" walletId', async () => { const { BridgeParent } = await import('./BridgeParent') baseAdapterMock.readyState = 'Installed' const bridgeParent = new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const callback = vi.fn() const teardown = ( bridgeParent as any ).parentAPI.subscribeSolanaWalletEvent( 'base-wallet-traditional', callback ) expect(typeof teardown).toBe('function') }) // --- Phantom multi-switch regression (MFS-778) ------------------------ // The bridge resolves a FRESH, never-connected StandardWalletAdapter, whose // connect/disconnect derivation ignores account-to-account switches — so it // must observe the wallet's `standard:events` change stream directly. function stubAdapterInstance(adapter: unknown) { return import('@solana/wallet-standard-wallet-adapter-base').then( ({ StandardWalletAdapter }) => { // Must be a regular function (not an arrow): the SUT calls // `new StandardWalletAdapter(...)`, and arrows can't be constructed. vi.mocked(StandardWalletAdapter).mockImplementation(function () { return adapter } as unknown as typeof StandardWalletAdapter) } ) } async function armStandardEventsWallet( initialAccounts: { address: string }[] ) { const { getWallets } = await import('@wallet-standard/app') const { isWalletAdapterCompatibleStandardWallet } = await import( '@solana/wallet-adapter-base' ) vi.mocked(getWallets).mockReturnValue({ get: () => [ { name: 'Phantom', chains: ['solana:mainnet'], features: {}, accounts: [] } ] as any, on: vi.fn(), register: vi.fn() } as any) vi.mocked(isWalletAdapterCompatibleStandardWallet).mockReturnValue(true) let changeListener: ((props: unknown) => void) | undefined const off = vi.fn() // `accounts` is mutable: the wallet updates it as the user switches, and // the handler must read the CURRENT active account on each change. const wallet = { accounts: initialAccounts, features: { 'standard:events': { on: vi.fn((event: string, listener: (props: unknown) => void) => { if (event === 'change') changeListener = listener return off }) } } } const adapterOn = vi.fn() const adapterOff = vi.fn() await stubAdapterInstance({ on: adapterOn, off: adapterOff, wallet }) const { BridgeParent } = await import('./BridgeParent') const bridgeParent = new BridgeParent(mockIframe) await vi.advanceTimersByTimeAsync(400) const callback = vi.fn() const teardown = ( bridgeParent as any ).parentAPI.subscribeSolanaWalletEvent('phantom-solana:mainnet', callback) return { wallet, off, adapterOn, callback, teardown, fire: (props: unknown) => changeListener?.(props) } } it('subscribes to the wallet `standard:events` change stream and NOT the fresh adapter connect/disconnect (the multi-switch fix)', async () => { const { wallet, adapterOn, teardown, off } = await armStandardEventsWallet([{ address: 'ACC_1' }]) // Subscribed to the change stream — the signal that fires on every switch. expect(wallet.features['standard:events'].on).toHaveBeenCalledWith( 'change', expect.any(Function) ) // NOT the never-connected adapter's connect/disconnect, which silently // dropped account-to-account switches on the bridge. expect(adapterOn).not.toHaveBeenCalledWith( 'connect', expect.any(Function) ) expect(adapterOn).not.toHaveBeenCalledWith( 'disconnect', expect.any(Function) ) // Teardown crosses back to the child as a Comlink return value. expect(typeof teardown).toBe('function') expect(Comlink.proxy).toHaveBeenCalledWith(expect.any(Function)) expect(off).not.toHaveBeenCalled() }) it('propagates each of multiple switches, then null on de-auth, and ignores non-account changes', async () => { const { wallet, callback, off, teardown, fire } = await armStandardEventsWallet([{ address: 'ACC_1' }]) // Switch #1 wallet.accounts = [{ address: 'ACC_2' }] fire({ accounts: wallet.accounts }) expect(callback).toHaveBeenNthCalledWith(1, 'ACC_2') // Switch #2 (the intermediate switch that used to be dropped) wallet.accounts = [{ address: 'ACC_3' }] fire({ accounts: wallet.accounts }) expect(callback).toHaveBeenNthCalledWith(2, 'ACC_3') // A pure chain/feature change (no `accounts` key) must be ignored. fire({ chains: ['solana:devnet'] }) expect(callback).toHaveBeenCalledTimes(2) // Switch to an account not authorized for the dapp → empty accounts → null, // which the child maps to [] (disconnect). wallet.accounts = [] fire({ accounts: [] }) expect(callback).toHaveBeenNthCalledWith(3, null) // Teardown unsubscribes the change stream. teardown?.() expect(off).toHaveBeenCalledTimes(1) }) }) })