import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import type { ExtensionInjectedProvider, Network } from '@meshconnect/uwc-types' import { ConnectionManager } from './connection-manager' function makeProvider( namespaces: Partial< Record< 'eip155' | 'solana' | 'tron' | 'tvm', { detectedWallet?: { uuid?: string jsBridgeKey?: string legacyInjected?: boolean } injectedId?: string } > > ): ExtensionInjectedProvider { return { supportedNetworkIds: [ ...(namespaces.eip155 ? ['eip155:1'] : []), ...(namespaces.solana ? ['solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'] : []), ...(namespaces.tron ? ['tron:0x2b6653dc'] : []), ...(namespaces.tvm ? ['tvm:-239'] : []) ], namespaceMetaData: namespaces } as unknown as ExtensionInjectedProvider } function makeNetwork(id: string, namespace: string): Network { return { id, namespace } as Network } function makeEthService() { return { getAccount: vi.fn().mockReturnValue(null), findWalletByUuid: vi.fn().mockReturnValue(null), checkExistingConnection: vi.fn().mockResolvedValue(null), connect: vi.fn().mockResolvedValue('0xABC'), setConnectionState: vi.fn(), buildAvailableAddresses: vi .fn() .mockImplementation((ids, addr) => ids .filter((id: string) => id.startsWith('eip155')) .map((id: string) => ({ address: addr, networkId: id })) ), getConnectedProvider: vi.fn().mockReturnValue(null), switchNetwork: vi.fn(), disconnect: vi.fn() } } function makeSolanaService() { return { getAccount: vi.fn().mockReturnValue(null), findWalletByUuid: vi.fn().mockReturnValue(null), checkExistingConnection: vi.fn().mockResolvedValue(null), connect: vi.fn().mockResolvedValue('SolAddr1'), setConnectionState: vi.fn(), buildAvailableAddresses: vi .fn() .mockImplementation((ids, addr) => ids .filter((id: string) => id.startsWith('solana')) .map((id: string) => ({ address: addr, networkId: id })) ), getConnectedAdapter: vi.fn().mockReturnValue(null), disconnect: vi.fn() } } function makeTronService() { return { getAccount: vi.fn().mockReturnValue(null), findWalletByUuid: vi.fn().mockReturnValue(null), checkExistingConnection: vi.fn().mockResolvedValue(null), connect: vi.fn().mockResolvedValue('TronAddr1'), setConnectionState: vi.fn(), buildAvailableAddresses: vi .fn() .mockImplementation((ids, addr) => ids .filter((id: string) => id.startsWith('tron')) .map((id: string) => ({ address: addr, networkId: id })) ), getConnectedProvider: vi.fn().mockReturnValue(null), disconnect: vi.fn() } } function makeTonService() { return { getAccount: vi.fn().mockReturnValue(null), checkExistingConnection: vi.fn().mockResolvedValue(null), connect: vi.fn().mockResolvedValue('TonAddr1'), setConnectionState: vi.fn(), buildAvailableAddresses: vi .fn() .mockImplementation((ids, addr) => ids .filter((id: string) => id.startsWith('tvm')) .map((id: string) => ({ address: addr, networkId: id })) ), isConnected: vi.fn().mockReturnValue(false), getConnectedJsBridgeKey: vi.fn().mockReturnValue(null), disconnect: vi.fn() } } describe('ConnectionManager — cross-namespace error resilience (ONC-2696)', () => { let ethService: ReturnType let solanaService: ReturnType let tronService: ReturnType let tonService: ReturnType let manager: ConnectionManager beforeEach(() => { ethService = makeEthService() solanaService = makeSolanaService() tronService = makeTronService() tonService = makeTonService() manager = new ConnectionManager( ethService as any, solanaService as any, tronService as any, tonService as any ) }) it('connectEthereum succeeds when Solana cross-connect throws', async () => { // MetaMask detected for EVM ethService.findWalletByUuid.mockReturnValue({ provider: {} }) // Solana adapter found but .connect() throws (namespace not available) solanaService.findWalletByUuid.mockReturnValue({ adapter: {} }) solanaService.checkExistingConnection.mockResolvedValue(null) solanaService.connect.mockRejectedValue( new Error('User rejected the request') ) const provider = makeProvider({ eip155: { detectedWallet: { uuid: 'mm-eip155' } }, solana: { detectedWallet: { uuid: 'mm-solana' } } }) const result = await manager.connectEthereum( makeNetwork('eip155:1', 'eip155'), provider ) expect(result.address).toBe('0xABC') expect(result.networkId).toBe('eip155:1') // Should have EVM addresses, Solana should be skipped (not throw) expect(result.availableAddresses).toEqual([ { address: '0xABC', networkId: 'eip155:1' } ]) }) it('connectEthereum succeeds when Tron cross-connect throws', async () => { ethService.findWalletByUuid.mockReturnValue({ provider: {} }) tronService.findWalletByUuid.mockReturnValue({ provider: {} }) tronService.checkExistingConnection.mockResolvedValue(null) tronService.connect.mockRejectedValue( new Error('User rejected the request') ) const provider = makeProvider({ eip155: { detectedWallet: { uuid: 'mm-eip155' } }, tron: { detectedWallet: { uuid: 'mm-tron' } } }) const result = await manager.connectEthereum( makeNetwork('eip155:1', 'eip155'), provider ) expect(result.address).toBe('0xABC') expect(result.availableAddresses).toEqual([ { address: '0xABC', networkId: 'eip155:1' } ]) }) it('connectEthereum still collects Tron when Solana throws', async () => { ethService.findWalletByUuid.mockReturnValue({ provider: {} }) // Solana throws solanaService.findWalletByUuid.mockReturnValue({ adapter: {} }) solanaService.checkExistingConnection.mockResolvedValue(null) solanaService.connect.mockRejectedValue(new Error('Not supported')) // Tron succeeds tronService.findWalletByUuid.mockReturnValue({ provider: {} }) tronService.checkExistingConnection.mockResolvedValue(null) tronService.connect.mockResolvedValue('TronAddr1') const provider = makeProvider({ eip155: { detectedWallet: { uuid: 'mm-eip155' } }, solana: { detectedWallet: { uuid: 'mm-solana' } }, tron: { detectedWallet: { uuid: 'mm-tron' } } }) const result = await manager.connectEthereum( makeNetwork('eip155:1', 'eip155'), provider ) expect(result.address).toBe('0xABC') // Should have BOTH eip155 and tron, but NOT solana const namespaces = result.availableAddresses.map( a => a.networkId.split(':')[0] ) expect(namespaces).toContain('eip155') expect(namespaces).toContain('tron') expect(namespaces).not.toContain('solana') }) it('connectSolana succeeds when EVM cross-connect throws', async () => { solanaService.findWalletByUuid.mockReturnValue({ adapter: {} }) solanaService.connect.mockResolvedValue('SolAddr1') // EVM check throws ethService.findWalletByUuid.mockReturnValue({ provider: {} }) ethService.checkExistingConnection.mockRejectedValue( new Error('Provider error') ) const provider = makeProvider({ eip155: { detectedWallet: { uuid: 'mm-eip155' } }, solana: { detectedWallet: { uuid: 'mm-solana' } } }) const result = await manager.connectSolana( makeNetwork('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', 'solana'), provider ) expect(result.address).toBe('SolAddr1') }) it('connectTron succeeds when EVM cross-connect throws', async () => { tronService.findWalletByUuid.mockReturnValue({ provider: {} }) tronService.connect.mockResolvedValue('TronAddr1') ethService.findWalletByUuid.mockReturnValue({ provider: {} }) ethService.checkExistingConnection.mockRejectedValue( new Error('Provider error') ) const provider = makeProvider({ eip155: { detectedWallet: { uuid: 'mm-eip155' } }, tron: { detectedWallet: { uuid: 'mm-tron' } } }) const result = await manager.connectTron( makeNetwork('tron:0x2b6653dc', 'tron'), provider ) expect(result.address).toBe('TronAddr1') expect(result.networkId).toBe('tron:0x2b6653dc') }) it('connectTon succeeds when EVM cross-connect throws', async () => { tonService.connect.mockResolvedValue({ address: 'TonAddr1' }) ethService.findWalletByUuid.mockReturnValue({ provider: {} }) ethService.checkExistingConnection.mockRejectedValue( new Error('Provider error') ) const provider = makeProvider({ eip155: { detectedWallet: { uuid: 'mm-eip155' } }, tvm: { detectedWallet: { jsBridgeKey: 'tonkeeper' } } }) const result = await manager.connectTon( makeNetwork('tvm:-239', 'tvm'), provider ) expect(result.address).toBe('TonAddr1') expect(result.networkId).toBe('tvm:-239') }) it('connectTon succeeds when Solana cross-connect throws', async () => { tonService.connect.mockResolvedValue({ address: 'TonAddr1' }) solanaService.findWalletByUuid.mockReturnValue({ adapter: {} }) solanaService.checkExistingConnection.mockResolvedValue(null) solanaService.connect.mockRejectedValue(new Error('Not supported')) const provider = makeProvider({ solana: { detectedWallet: { uuid: 'mm-solana' } }, tvm: { detectedWallet: { jsBridgeKey: 'tonkeeper' } } }) const result = await manager.connectTon( makeNetwork('tvm:-239', 'tvm'), provider ) expect(result.address).toBe('TonAddr1') }) it('connectSolana still collects EVM when Tron throws', async () => { solanaService.findWalletByUuid.mockReturnValue({ adapter: {} }) solanaService.connect.mockResolvedValue('SolAddr1') // EVM succeeds ethService.findWalletByUuid.mockReturnValue({ provider: {} }) ethService.checkExistingConnection.mockResolvedValue(['0xABC']) // Tron throws tronService.findWalletByUuid.mockReturnValue({ provider: {} }) tronService.checkExistingConnection.mockResolvedValue(null) tronService.connect.mockRejectedValue(new Error('Not supported')) const provider = makeProvider({ eip155: { detectedWallet: { uuid: 'mm-eip155' } }, solana: { detectedWallet: { uuid: 'mm-solana' } }, tron: { detectedWallet: { uuid: 'mm-tron' } } }) const result = await manager.connectSolana( makeNetwork('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', 'solana'), provider ) expect(result.address).toBe('SolAddr1') const namespaces = result.availableAddresses.map( a => a.networkId.split(':')[0] ) expect(namespaces).toContain('solana') expect(namespaces).toContain('eip155') expect(namespaces).not.toContain('tron') // already EVM-permitted: silent path only, no extra connect()/prompt expect(ethService.connect).not.toHaveBeenCalled() }) it('connectEthereum succeeds when TON cross-connect throws', async () => { ethService.findWalletByUuid.mockReturnValue({ provider: {} }) tonService.connect.mockRejectedValue(new Error('User rejected')) const provider = makeProvider({ eip155: { detectedWallet: { uuid: 'mm-eip155' } }, tvm: { detectedWallet: { jsBridgeKey: 'tonkeeper' } } }) const result = await manager.connectEthereum( makeNetwork('eip155:1', 'eip155'), provider ) expect(result.address).toBe('0xABC') expect(result.availableAddresses).toEqual([ { address: '0xABC', networkId: 'eip155:1' } ]) }) it('connectTron succeeds when Solana cross-connect throws', async () => { tronService.findWalletByUuid.mockReturnValue({ provider: {} }) tronService.connect.mockResolvedValue('TronAddr1') solanaService.findWalletByUuid.mockReturnValue({ adapter: {} }) solanaService.checkExistingConnection.mockResolvedValue(null) solanaService.connect.mockRejectedValue(new Error('Not supported')) const provider = makeProvider({ solana: { detectedWallet: { uuid: 'mm-solana' } }, tron: { detectedWallet: { uuid: 'mm-tron' } } }) const result = await manager.connectTron( makeNetwork('tron:0x2b6653dc', 'tron'), provider ) expect(result.address).toBe('TronAddr1') expect(result.networkId).toBe('tron:0x2b6653dc') const namespaces = result.availableAddresses.map( a => a.networkId.split(':')[0] ) expect(namespaces).toContain('tron') expect(namespaces).not.toContain('solana') }) it('connectEthereum collects all working namespaces when one throws', async () => { ethService.findWalletByUuid.mockReturnValue({ provider: {} }) // Solana throws solanaService.findWalletByUuid.mockReturnValue({ adapter: {} }) solanaService.checkExistingConnection.mockResolvedValue(null) solanaService.connect.mockRejectedValue(new Error('Not supported')) // Tron succeeds tronService.findWalletByUuid.mockReturnValue({ provider: {} }) tronService.checkExistingConnection.mockResolvedValue(null) tronService.connect.mockResolvedValue('TronAddr1') // TON succeeds tonService.checkExistingConnection.mockResolvedValue(null) tonService.connect.mockResolvedValue({ address: 'TonAddr1' }) const provider = makeProvider({ eip155: { detectedWallet: { uuid: 'mm-eip155' } }, solana: { detectedWallet: { uuid: 'mm-solana' } }, tron: { detectedWallet: { uuid: 'mm-tron' } }, tvm: { detectedWallet: { jsBridgeKey: 'tonkeeper' } } }) const result = await manager.connectEthereum( makeNetwork('eip155:1', 'eip155'), provider ) expect(result.address).toBe('0xABC') const namespaces = result.availableAddresses.map( a => a.networkId.split(':')[0] ) expect(namespaces).toContain('eip155') expect(namespaces).toContain('tron') expect(namespaces).toContain('tvm') expect(namespaces).not.toContain('solana') }) // ONC-3429: the EVM cross-namespace gather must fall back to an explicit // connect (eth_requestAccounts) when the silent eth_accounts returns nothing, // matching Solana/Tron/TON. MetaMask v13.32.0 stopped pre-permitting EVM on a // Solana Wallet Standard connect, so the silent check alone drops the EOA. it('connectSolana gathers EVM via the connect() fallback when eth_accounts is empty', async () => { solanaService.findWalletByUuid.mockReturnValue({ adapter: {} }) solanaService.connect.mockResolvedValue('SolAddr1') // EVM detected, but origin not yet EVM-permitted: silent check returns null, // explicit connect surfaces the EOA. ethService.findWalletByUuid.mockReturnValue({ provider: {} }) ethService.checkExistingConnection.mockResolvedValue(null) ethService.connect.mockResolvedValue('0xABC') const provider = makeProvider({ eip155: { detectedWallet: { uuid: 'mm-eip155' } }, solana: { detectedWallet: { uuid: 'mm-solana' } } }) const result = await manager.connectSolana( makeNetwork('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', 'solana'), provider ) expect(result.address).toBe('SolAddr1') expect(ethService.connect).toHaveBeenCalled() const namespaces = result.availableAddresses.map( a => a.networkId.split(':')[0] ) expect(namespaces).toContain('solana') expect(namespaces).toContain('eip155') }) it('connectSolana stays Solana-only (no throw) when the EVM connect() fallback is declined', async () => { solanaService.findWalletByUuid.mockReturnValue({ adapter: {} }) solanaService.connect.mockResolvedValue('SolAddr1') ethService.findWalletByUuid.mockReturnValue({ provider: {} }) ethService.checkExistingConnection.mockResolvedValue(null) ethService.connect.mockRejectedValue(new Error('User rejected the request')) const provider = makeProvider({ eip155: { detectedWallet: { uuid: 'mm-eip155' } }, solana: { detectedWallet: { uuid: 'mm-solana' } } }) const result = await manager.connectSolana( makeNetwork('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', 'solana'), provider ) expect(result.address).toBe('SolAddr1') const namespaces = result.availableAddresses.map( a => a.networkId.split(':')[0] ) expect(namespaces).toContain('solana') expect(namespaces).not.toContain('eip155') }) }) // ONC-3383: inside an iframe, an injected wallet's connect prompt — and, more // importantly, the catalog's follow-up ownership-verification SIGNATURE — is // frame-position-gated (Trust Wallet → dapp.frames-disallowed). The eager // cross-namespace gather must therefore NOT prompt-connect a SECONDARY namespace // while framed, even when that namespace's bridge list is populated (the connect // is bridged, but the verification signature still gates). checkExistingConnection // still runs (non-prompting where the wallet supports it; the Solana adapter // reconnect can still prompt), so an already-permitted namespace is still surfaced; // the TARGET namespace connects via its own path. describe('ConnectionManager — iframe-safe cross-namespace gather (ONC-3383)', () => { let ethService: ReturnType let solanaService: ReturnType let tronService: ReturnType let tonService: ReturnType let manager: ConnectionManager beforeEach(() => { ethService = makeEthService() solanaService = makeSolanaService() tronService = makeTronService() tonService = makeTonService() manager = new ConnectionManager( ethService as any, solanaService as any, tronService as any, tonService as any ) // Simulate running inside an iframe: window.top !== window.self Object.defineProperty(window, 'top', { value: {}, configurable: true }) }) afterEach(() => { Object.defineProperty(window, 'top', { value: window, configurable: true }) const w = window as unknown as Record delete w['eip6963Wallets'] delete w['walletStandardWallets'] delete w['tronWallets'] delete w['tonWallets'] }) it('skips the eager secondary EVM connect when framed (avoids the frames-disallowed verification prompt)', async () => { solanaService.findWalletByUuid.mockReturnValue({ adapter: {} }) solanaService.connect.mockResolvedValue('SolAddr1') // EVM detected but not already permitted; while framed the eager EVM connect // (and its gating ownership-verification signature) must not fire. ethService.findWalletByUuid.mockReturnValue({ provider: {} }) ethService.checkExistingConnection.mockResolvedValue(null) ethService.connect.mockResolvedValue('0xABC') const provider = makeProvider({ eip155: { detectedWallet: { uuid: 'mm-eip155' } }, solana: { detectedWallet: { uuid: 'mm-solana' } } }) const result = await manager.connectSolana( makeNetwork('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', 'solana'), provider ) expect(result.address).toBe('SolAddr1') // The eager EVM connect (eth_requestAccounts) must NOT fire in the iframe. expect(ethService.connect).not.toHaveBeenCalled() const namespaces = result.availableAddresses.map( a => a.networkId.split(':')[0] ) expect(namespaces).toContain('solana') expect(namespaces).not.toContain('eip155') }) it('skips the eager secondary connect when framed EVEN IF the bridge list is populated (the verification signature still gates)', async () => { solanaService.findWalletByUuid.mockReturnValue({ adapter: {} }) solanaService.connect.mockResolvedValue('SolAddr1') ethService.findWalletByUuid.mockReturnValue({ provider: {} }) ethService.checkExistingConnection.mockResolvedValue(null) ethService.connect.mockResolvedValue('0xABC') // Bridge populated the EVM list, so the connect itself would route to the top // frame — but the catalog's follow-up ownership-verification SIGNATURE still // gates (dapp.frames-disallowed). So a framed session must NOT eagerly // prompt-connect a secondary namespace, regardless of the bridge list. ;(window as unknown as Record)['eip6963Wallets'] = [ { uuid: 'trust', provider: {} } ] const provider = makeProvider({ eip155: { detectedWallet: { uuid: 'mm-eip155' } }, solana: { detectedWallet: { uuid: 'mm-solana' } } }) const result = await manager.connectSolana( makeNetwork('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', 'solana'), provider ) expect(ethService.connect).not.toHaveBeenCalled() const namespaces = result.availableAddresses.map( a => a.networkId.split(':')[0] ) expect(namespaces).not.toContain('eip155') }) it('still surfaces an ALREADY-permitted secondary namespace via the silent check when framed (no eager connect)', async () => { solanaService.findWalletByUuid.mockReturnValue({ adapter: {} }) solanaService.connect.mockResolvedValue('SolAddr1') // EVM origin already permitted: the silent checkExistingConnection returns the // EOA without prompting, so it is still gathered even while framed. Only the // prompting connect() is skipped — ONC-3429's silent path is preserved. ethService.findWalletByUuid.mockReturnValue({ provider: {} }) ethService.checkExistingConnection.mockResolvedValue(['0xABC']) const provider = makeProvider({ eip155: { detectedWallet: { uuid: 'mm-eip155' } }, solana: { detectedWallet: { uuid: 'mm-solana' } } }) const result = await manager.connectSolana( makeNetwork('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', 'solana'), provider ) expect(ethService.connect).not.toHaveBeenCalled() const namespaces = result.availableAddresses.map( a => a.networkId.split(':')[0] ) expect(namespaces).toContain('solana') expect(namespaces).toContain('eip155') }) it('still connects the TARGET namespace when framed (only the secondary gather is skipped)', async () => { // Target = Solana; the secondary EVM gather is skipped, but the target Solana // connect proceeds via its own path. solanaService.findWalletByUuid.mockReturnValue({ adapter: {} }) solanaService.connect.mockResolvedValue('SolAddr1') ethService.findWalletByUuid.mockReturnValue({ provider: {} }) ethService.checkExistingConnection.mockResolvedValue(null) ethService.connect.mockResolvedValue('0xABC') const provider = makeProvider({ eip155: { detectedWallet: { uuid: 'mm-eip155' } }, solana: { detectedWallet: { uuid: 'mm-solana' } } }) const result = await manager.connectSolana( makeNetwork('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', 'solana'), provider ) expect(result.address).toBe('SolAddr1') expect(solanaService.connect).toHaveBeenCalled() expect(ethService.connect).not.toHaveBeenCalled() const namespaces = result.availableAddresses.map( a => a.networkId.split(':')[0] ) expect(namespaces).toContain('solana') expect(namespaces).not.toContain('eip155') }) // ONC-3383 follow-up: Solana is the wallet-standard exception. Phantom/Solflare // inject directly into the iframe and connect + sign fine framed (no // dapp.frames-disallowed), and the adapter is discovered via wallet-standard // registration — NOT the bridge window.walletStandardWallets list, which is // frequently empty in real embeds. So the eager Solana gather MUST run when framed // regardless of that list; otherwise a wallet connected on a non-Solana target // (e.g. Base) inside the catalog iframe never surfaces its Solana address. it('performs the eager secondary SOLANA connect when framed EVEN IF the bridge list is empty', async () => { ethService.findWalletByUuid.mockReturnValue({ provider: {} }) ethService.connect.mockResolvedValue('0xABC') solanaService.findWalletByUuid.mockReturnValue({ adapter: {} }) solanaService.checkExistingConnection.mockResolvedValue(null) solanaService.connect.mockResolvedValue('SolAddr1') // window.walletStandardWallets intentionally NOT populated — mirrors the real // framed embed where Phantom injects directly and the bridge list is empty. const provider = makeProvider({ eip155: { detectedWallet: { uuid: 'mm-eip155' } }, solana: { detectedWallet: { uuid: 'mm-solana' } } }) const result = await manager.connectEthereum( makeNetwork('eip155:8453', 'eip155'), provider ) expect(result.address).toBe('0xABC') expect(solanaService.connect).toHaveBeenCalled() const namespaces = result.availableAddresses.map( a => a.networkId.split(':')[0] ) expect(namespaces).toContain('eip155') expect(namespaces).toContain('solana') }) it('still blanket-skips the eager secondary EVM connect when framed (ONC-3383 unchanged)', async () => { // The EVM verification signature IS frame-gated (Trust → dapp.frames-disallowed), // so unlike Solana the EVM secondary gather stays skipped when framed. solanaService.findWalletByUuid.mockReturnValue({ adapter: {} }) solanaService.connect.mockResolvedValue('SolAddr1') ethService.findWalletByUuid.mockReturnValue({ provider: {} }) ethService.checkExistingConnection.mockResolvedValue(null) ethService.connect.mockResolvedValue('0xABC') const provider = makeProvider({ eip155: { detectedWallet: { uuid: 'mm-eip155' } }, solana: { detectedWallet: { uuid: 'mm-solana' } } }) const result = await manager.connectSolana( makeNetwork('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', 'solana'), provider ) expect(solanaService.connect).toHaveBeenCalled() expect(ethService.connect).not.toHaveBeenCalled() const namespaces = result.availableAddresses.map( a => a.networkId.split(':')[0] ) expect(namespaces).toContain('solana') expect(namespaces).not.toContain('eip155') }) }) // ONC-3536 + ONC-231: wallets that declare a legacy `solana.injectedId` // (Coinbase/Base Wallet's `coinbaseSolana`) treat the active namespace as // exclusive in their OWN in-app browser — ANY secondary Solana connect after an // EVM connect parks the wallet on Solana and the following EVM // eth_sendTransaction never surfaces an approval sheet (60s walletTimedOut, the // USDC.base Legacy-mode hang, PoC'd on Coinbase Wallet 30.3.3). The gate keys on // `usingIntegratedBrowser` (set only in the wallet's top-level dapp-deeplink // flow), NOT `isFramedEmbed()`. ONC-231: the old iframe-based gate over-fired — // every SDK client is framed — and silently dropped Base Wallet's Solana address // (and SOL/SPL deposits) in ordinary embedded clients. So a framed embed that is // NOT the wallet's in-app browser MUST still gather; Phantom-style // Wallet-Standard wallets and primary Solana connects are untouched. describe('ConnectionManager — legacy-injectedId Solana gather gate (ONC-3536 + ONC-231)', () => { let ethService: ReturnType let solanaService: ReturnType let tronService: ReturnType let tonService: ReturnType let onSkip: ReturnType let onGathered: ReturnType const makeManager = (usingIntegratedBrowser = false) => new ConnectionManager( ethService as any, solanaService as any, tronService as any, tonService as any, { usingIntegratedBrowser, onSecondarySolanaConnectSkipped: onSkip, onSecondarySolanaConnectGathered: onGathered } ) beforeEach(() => { ethService = makeEthService() solanaService = makeSolanaService() tronService = makeTronService() tonService = makeTonService() onSkip = vi.fn() onGathered = vi.fn() // Default: running inside an iframe (window.top !== window.self) — the // ordinary SDK-client case. usingIntegratedBrowser stays false. Object.defineProperty(window, 'top', { value: {}, configurable: true }) ethService.findWalletByUuid.mockReturnValue({ provider: {} }) ethService.connect.mockResolvedValue('0xABC') solanaService.findWalletByUuid.mockReturnValue({ adapter: {} }) }) afterEach(() => { Object.defineProperty(window, 'top', { value: window, configurable: true }) }) const coinbaseLikeProvider = () => makeProvider({ eip155: { detectedWallet: { uuid: 'cb-eip155' } }, solana: { injectedId: 'coinbaseSolana', detectedWallet: { uuid: 'cb-solana', legacyInjected: true } } }) // ── ONC-231 regression guards: framed (SDK client) but NOT in-app browser ── it('ONC-231: GATHERS via connect() when framed but not the wallet in-app browser, and reports gathered(source=connect)', async () => { solanaService.connect.mockResolvedValue('SolAddr1') const result = await makeManager( /* usingIntegratedBrowser */ false ).connectEthereum( makeNetwork('eip155:8453', 'eip155'), coinbaseLikeProvider() ) expect(result.address).toBe('0xABC') // The gate is OFF: silent reconnect allowed AND the connect() fallback runs. expect(solanaService.checkExistingConnection).toHaveBeenCalledWith( expect.anything(), 'cb-solana', { allowSilentReconnect: true } ) expect(solanaService.connect).toHaveBeenCalled() const namespaces = result.availableAddresses.map( a => a.networkId.split(':')[0] ) expect(namespaces).toContain('solana') // No skip; positive signal fired with framed:true (verifies the fix in prod). expect(onSkip).not.toHaveBeenCalled() expect(onGathered).toHaveBeenCalledTimes(1) expect(onGathered).toHaveBeenCalledWith({ injectedId: 'coinbaseSolana', source: 'connect', legacyInjected: true, framed: true }) }) it('ONC-231: GATHERS via the silent check when framed, and reports gathered(source=existing)', async () => { solanaService.checkExistingConnection.mockResolvedValue('SolAddr1') await makeManager(false).connectEthereum( makeNetwork('eip155:8453', 'eip155'), coinbaseLikeProvider() ) expect(solanaService.connect).not.toHaveBeenCalled() expect(onSkip).not.toHaveBeenCalled() expect(onGathered).toHaveBeenCalledWith({ injectedId: 'coinbaseSolana', source: 'existing', legacyInjected: true, framed: true }) }) // ── Gate ON: only inside the wallet's own in-app browser ── it('skips TOP-LEVEL in the wallet in-app browser (usingIntegratedBrowser) — the dapp deep-link flow that reproduced the hang', async () => { // isFramedEmbed() is false here; the gate must key on usingIntegratedBrowser. Object.defineProperty(window, 'top', { value: window, configurable: true }) const result = await makeManager( /* usingIntegratedBrowser */ true ).connectEthereum( makeNetwork('eip155:8453', 'eip155'), coinbaseLikeProvider() ) expect(result.address).toBe('0xABC') expect(solanaService.connect).not.toHaveBeenCalled() expect(solanaService.checkExistingConnection).toHaveBeenCalledWith( expect.anything(), 'cb-solana', { allowSilentReconnect: false } ) expect(onGathered).not.toHaveBeenCalled() expect(onSkip).toHaveBeenCalledWith({ injectedId: 'coinbaseSolana', outcome: 'connectSuppressed', legacyInjected: true, framed: false }) }) it('skips regardless of frame position when usingIntegratedBrowser is set (framed embed inside the in-app browser)', async () => { // window.top !== window.self (framed) AND usingIntegratedBrowser — the gate // must still fire; framed:true in the report. const result = await makeManager( /* usingIntegratedBrowser */ true ).connectEthereum( makeNetwork('eip155:8453', 'eip155'), coinbaseLikeProvider() ) expect(result.address).toBe('0xABC') expect(solanaService.connect).not.toHaveBeenCalled() expect(onSkip).toHaveBeenCalledWith({ injectedId: 'coinbaseSolana', outcome: 'connectSuppressed', legacyInjected: true, framed: true }) }) it('reports alreadyConnected (not suppressed) when the gate is on but the provider is pre-authorized', async () => { Object.defineProperty(window, 'top', { value: window, configurable: true }) solanaService.checkExistingConnection.mockResolvedValue('SolAddr1') const result = await makeManager(true).connectEthereum( makeNetwork('eip155:8453', 'eip155'), coinbaseLikeProvider() ) expect(solanaService.connect).not.toHaveBeenCalled() expect( result.availableAddresses.map(a => a.networkId.split(':')[0]) ).toContain('solana') expect(onGathered).not.toHaveBeenCalled() expect(onSkip).toHaveBeenCalledWith({ injectedId: 'coinbaseSolana', outcome: 'alreadyConnected', legacyInjected: true, framed: false }) }) // ── Untouched paths ── it('keeps the framed gather for Wallet-Standard-only wallets (no injectedId — Phantom), no gathered event', async () => { solanaService.connect.mockResolvedValue('SolAddr1') const result = await makeManager(false).connectEthereum( makeNetwork('eip155:8453', 'eip155'), makeProvider({ eip155: { detectedWallet: { uuid: 'ph-eip155' } }, solana: { detectedWallet: { uuid: 'ph-solana' } } }) ) expect(solanaService.connect).toHaveBeenCalled() expect(solanaService.checkExistingConnection).toHaveBeenCalledWith( expect.anything(), 'ph-solana', { allowSilentReconnect: true } ) expect( result.availableAddresses.map(a => a.networkId.split(':')[0]) ).toContain('solana') // Gathered is scoped to legacy-injectedId wallets — Phantom has none. expect(onSkip).not.toHaveBeenCalled() expect(onGathered).not.toHaveBeenCalled() }) it('keeps the eager gather in the top frame (extension) with an injectedId, and reports gathered(framed=false)', async () => { Object.defineProperty(window, 'top', { value: window, configurable: true }) solanaService.connect.mockResolvedValue('SolAddr1') await makeManager(false).connectEthereum( makeNetwork('eip155:8453', 'eip155'), coinbaseLikeProvider() ) expect(solanaService.connect).toHaveBeenCalled() expect(solanaService.checkExistingConnection).toHaveBeenCalledWith( expect.anything(), 'cb-solana', { allowSilentReconnect: true } ) expect(onSkip).not.toHaveBeenCalled() expect(onGathered).toHaveBeenCalledWith({ injectedId: 'coinbaseSolana', source: 'connect', legacyInjected: true, framed: false }) }) // ── Dedup + hardening ── it('reports the skip once per injectedId across repeated gathers (dedup)', async () => { const inApp = makeManager(true) await inApp.connectEthereum( makeNetwork('eip155:8453', 'eip155'), coinbaseLikeProvider() ) await inApp.connectEthereum( makeNetwork('eip155:8453', 'eip155'), coinbaseLikeProvider() ) expect(onSkip).toHaveBeenCalledTimes(1) }) it('reports gathered once per injectedId x source across repeated gathers (dedup)', async () => { solanaService.connect.mockResolvedValue('SolAddr1') const framed = makeManager(false) await framed.connectEthereum( makeNetwork('eip155:8453', 'eip155'), coinbaseLikeProvider() ) await framed.connectEthereum( makeNetwork('eip155:8453', 'eip155'), coinbaseLikeProvider() ) expect(onGathered).toHaveBeenCalledTimes(1) }) it('caps an oversized injectedId to 64 chars before keying/reporting', async () => { Object.defineProperty(window, 'top', { value: window, configurable: true }) await makeManager(true).connectEthereum( makeNetwork('eip155:8453', 'eip155'), makeProvider({ eip155: { detectedWallet: { uuid: 'cb-eip155' } }, solana: { injectedId: 'x'.repeat(200), detectedWallet: { uuid: 'cb-solana', legacyInjected: true } } }) ) expect(onSkip).toHaveBeenCalledWith( expect.objectContaining({ injectedId: 'x'.repeat(64) }) ) }) it('reports legacyInjected=false when the gated wallet registered via the Wallet Standard', async () => { Object.defineProperty(window, 'top', { value: window, configurable: true }) await makeManager(true).connectEthereum( makeNetwork('eip155:8453', 'eip155'), makeProvider({ eip155: { detectedWallet: { uuid: 'cb-eip155' } }, solana: { injectedId: 'coinbaseSolana', detectedWallet: { uuid: 'cb-solana' } } }) ) expect(solanaService.connect).not.toHaveBeenCalled() expect(onSkip).toHaveBeenCalledWith({ injectedId: 'coinbaseSolana', outcome: 'connectSuppressed', legacyInjected: false, framed: false }) }) }) describe('ConnectionManager — wallet switch releases the other wallet’s connections', () => { let ethService: ReturnType let solanaService: ReturnType let tronService: ReturnType let tonService: ReturnType let onWalletConnectionsReleased: ReturnType let manager: ConnectionManager beforeEach(() => { ethService = makeEthService() solanaService = makeSolanaService() tronService = makeTronService() tonService = makeTonService() onWalletConnectionsReleased = vi.fn() manager = new ConnectionManager( ethService as any, solanaService as any, tronService as any, tonService as any, { onWalletConnectionsReleased } ) }) it('does not reuse another wallet’s EVM connection — releases it and silently checks the requested wallet', async () => { const walletAProvider = { isWalletA: true } const walletBProvider = { isWalletB: true } // Wallet A is live in the service; disconnect() clears that state (mirror // the real service so the post-release branches see the cleared state). ethService.getAccount.mockReturnValue('0xAAA') ethService.getConnectedProvider.mockReturnValue(walletAProvider) ethService.disconnect.mockImplementation(() => { ethService.getAccount.mockReturnValue(null) ethService.getConnectedProvider.mockReturnValue(null) }) // The requested wallet (B) is detected with its own provider, and its // origin permission already exists (silent check succeeds). ethService.findWalletByUuid.mockReturnValue({ provider: walletBProvider }) ethService.checkExistingConnection.mockResolvedValue(['0xBBB']) const result = await manager.checkExistingConnections( 'eip155', makeProvider({ eip155: { detectedWallet: { uuid: 'wallet-b-eip155' } } }) ) expect(ethService.disconnect).toHaveBeenCalledTimes(1) expect(ethService.checkExistingConnection).toHaveBeenCalledWith( walletBProvider ) expect(result.address).toBe('0xBBB') expect(ethService.setConnectionState).toHaveBeenCalledWith( walletBProvider, '0xBBB' ) // The release is surfaced to the telemetry sink with the namespaces dropped. expect(onWalletConnectionsReleased).toHaveBeenCalledTimes(1) expect(onWalletConnectionsReleased).toHaveBeenCalledWith({ namespaces: ['eip155'] }) }) it('keeps the same wallet’s EVM connection (no release, no re-check)', async () => { const walletAProvider = { isWalletA: true } ethService.getAccount.mockReturnValue('0xAAA') ethService.getConnectedProvider.mockReturnValue(walletAProvider) ethService.findWalletByUuid.mockReturnValue({ provider: walletAProvider }) const result = await manager.checkExistingConnections( 'eip155', makeProvider({ eip155: { detectedWallet: { uuid: 'wallet-a-eip155' } } }) ) expect(ethService.disconnect).not.toHaveBeenCalled() expect(ethService.checkExistingConnection).not.toHaveBeenCalled() expect(result.address).toBe('0xAAA') // Nothing was released — the sink must stay silent (an emit here would be // the prod signal that the identity comparison broke). expect(onWalletConnectionsReleased).not.toHaveBeenCalled() }) it('releases another wallet’s gathered Solana state so it does not leak into the new session', async () => { const walletAAdapter = { isWalletAAdapter: true } // Wallet A's Solana (from an earlier gather) is live. solanaService.getAccount.mockReturnValue('SolAAA') solanaService.getConnectedAdapter.mockReturnValue(walletAAdapter) solanaService.disconnect.mockImplementation(() => { solanaService.getAccount.mockReturnValue(null) solanaService.getConnectedAdapter.mockReturnValue(null) }) // Wallet B declares no Solana namespace at all. solanaService.findWalletByUuid.mockReturnValue(null) // Wallet B connects on EVM. ethService.findWalletByUuid.mockReturnValue({ provider: {} }) ethService.checkExistingConnection.mockResolvedValue(['0xBBB']) const result = await manager.checkExistingConnections( 'eip155', makeProvider({ eip155: { detectedWallet: { uuid: 'wallet-b-eip155' } } }) ) expect(solanaService.disconnect).toHaveBeenCalledTimes(1) expect(result.availableAddresses.some(a => a.address === 'SolAAA')).toBe( false ) expect(onWalletConnectionsReleased).toHaveBeenCalledWith({ namespaces: ['solana'] }) }) it('ends another wallet’s TON session over the bridge before connecting', async () => { tonService.isConnected.mockReturnValue(true) tonService.getConnectedJsBridgeKey.mockReturnValue('tonkeeper') tonService.disconnect.mockImplementation(async () => { tonService.isConnected.mockReturnValue(false) tonService.getAccount.mockReturnValue(null) tonService.getConnectedJsBridgeKey.mockReturnValue(null) }) await manager.checkExistingConnections( 'tvm', makeProvider({ tvm: { detectedWallet: { jsBridgeKey: 'okxTonWallet' } } }) ) expect(tonService.disconnect).toHaveBeenCalledTimes(1) expect(onWalletConnectionsReleased).toHaveBeenCalledWith({ namespaces: ['tvm'] }) }) it('keeps the same TON wallet’s session (no release)', async () => { tonService.isConnected.mockReturnValue(true) tonService.getConnectedJsBridgeKey.mockReturnValue('tonkeeper') tonService.getAccount.mockReturnValue('TonAAA') const result = await manager.checkExistingConnections( 'tvm', makeProvider({ tvm: { detectedWallet: { jsBridgeKey: 'tonkeeper' } } }) ) expect(tonService.disconnect).not.toHaveBeenCalled() expect(result.address).toBe('TonAAA') expect(onWalletConnectionsReleased).not.toHaveBeenCalled() }) })