UNPKG

accounts

Version:

Tempo Accounts SDK

957 lines (846 loc) 31.6 kB
import { Address, Hex, Json, PublicKey } from 'ox' import { KeyAuthorization } from 'ox/tempo' import { parseUnits, type Address as viem_Address } from 'viem' import { Actions, Addresses } from 'viem/tempo' import { afterEach, describe, expect, test, vi } from 'vp/test' import { Wata as HostWata, postMessage as hostPostMessage } from 'wata/host' import * as z from 'zod/mini' import { accounts, chain, getClient } from '../../../../test/config.js' import * as Keystore from '../../Keystore.js' import * as Provider from '../../Provider.js' import * as Storage from '../../Storage.js' import * as Store from '../../Store.js' import * as Rpc from '../../zod/rpc.js' import { postMessage } from './postMessage.js' import { tempoWallet } from './tempoWallet.js' const root = accounts[0]! const transferCall = Actions.token.transfer.call({ to: '0x0000000000000000000000000000000000000001', token: Addresses.pathUsd, amount: parseUnits('1', 6), }) const hosts: Promise<Pick<Awaited<ReturnType<typeof createHost>>, 'close' | 'notify'>>[] = [] afterEach(async () => { vi.unstubAllGlobals() await Promise.all(hosts.splice(0).map((host) => host.then((session) => session.close()))) }) async function fund(address: viem_Address) { await Actions.token.transferSync(getClient(), { account: root, feeToken: Addresses.pathUsd, to: address, token: Addresses.pathUsd, amount: parseUnits('10', 6), }) } function asyncJsonStorage(options: Storage.from.Options = {}) { const store = new Map<string, string>() return Storage.from( { async getItem(name) { const raw = store.get(name) if (!raw) return null return Json.parse(raw) }, async setItem(name, value) { store.set(name, Json.stringify(value)) }, async removeItem(name) { store.delete(name) }, }, options, ) } /** * In-process wallet endpoint. `target` is called whenever the adapter * (re-)opens its session: it mints a fresh `MessageChannel` and parks a wata * host on the far port — the port-pair stand-in for the wallet window. The * `opened` log therefore counts session mounts, not requests. */ function createWallet() { const opened: string[] = [] const requests: { method: string; params: unknown }[] = [] return { opened: () => opened, requests: () => requests, target: ({ host }: { host: string | undefined }) => { opened.push(host ?? '') const channel = new MessageChannel() hosts.push(createHost(requests, channel.port2)) return channel.port1 }, } } async function createHost(requests: { method: string; params: unknown }[], port: MessagePort) { const host = HostWata.create({ transports: [hostPostMessage({ target: () => port })], }) const session = await host.start() session.onRequest(async (event) => { requests.push({ method: event.method, params: event.params }) if (event.method === 'wallet_connect') { const [parameters] = z.decode(Rpc.wallet_connect.schema.params!, event.params as never) ?? [] const capabilities = parameters?.capabilities const authorization = capabilities?.authorizeAccessKey await event.respond({ accounts: [ { address: root.address, capabilities: { ...(authorization ? { keyAuthorization: KeyAuthorization.toRpc( await signKeyAuthorization(authorization), ), } : {}), ...(capabilities?.personalSign ? { personalSign: capabilities.personalSign } : {}), ...(await signature(capabilities)), }, }, ], }) return } if (event.method === 'wallet_authorizeAccessKey') { const [parameters] = z.decode( Rpc.wallet_authorizeAccessKey.schema.params!, event.params as never, ) await event.respond({ keyAuthorization: KeyAuthorization.toRpc(await signKeyAuthorization(parameters)), rootAddress: root.address, }) return } if (event.method === 'personal_sign') { const [data] = z.decode(Rpc.personal_sign.schema.params!, event.params as never) await event.respond(await root.signMessage({ message: { raw: data } })) return } if (event.method === 'wallet_deposit') { await event.respond({ receipts: [] }) return } if (event.method === 'wallet_revokeAccessKey') { await event.respond(null) } }) return session } async function signKeyAuthorization(parameters: AdapterAuthorizeParameters) { return await root.signKeyAuthorization( { accessKeyAddress: accessKeyAddress(parameters), keyType: parameters.keyType ?? 'p256', }, { chainId: parameters.chainId ?? BigInt(chain.id), expiry: parameters.expiry, ...(parameters.limits ? { limits: parameters.limits } : {}), }, ) } function accessKeyAddress(parameters: AdapterAuthorizeParameters) { if (parameters.address) return parameters.address if (!parameters.publicKey) throw new Error('Expected access key address or public key in wallet request.') return Address.fromPublicKey(PublicKey.fromHex(parameters.publicKey)) } function connectParameters(wallet: ReturnType<typeof createWallet>, index: number) { const [parameters] = z.decode(Rpc.wallet_connect.schema.params!, wallet.requests()[index]!.params as never) ?? [] return parameters } async function signature( capabilities: | NonNullable<NonNullable<Rpc.wallet_connect.Decoded['params']>[number]['capabilities']> | undefined, ) { if (capabilities?.digest) return { signature: await root.sign({ hash: capabilities.digest }) } if (capabilities?.personalSign) return { signature: await root.signMessage({ message: capabilities.personalSign.message }) } return {} } function personalSignParameters(wallet: ReturnType<typeof createWallet>, index: number) { const [data, address] = z.decode( Rpc.personal_sign.schema.params!, wallet.requests()[index]!.params as never, ) return { address, data } } type AdapterAuthorizeParameters = NonNullable< Rpc.wallet_authorizeAccessKey.Decoded['params'] >[number] function createProvider( wallet: ReturnType<typeof createWallet>, options: Omit<Provider.create.Options, 'adapter' | 'chains'> & Partial<Pick<postMessage.Options, 'host' | 'name' | 'rdns'>> = {}, ) { const { host = 'https://wallet.tempo.xyz/post-message', name = 'Accounts Web Test', rdns = 'xyz.tempo.accounts.playground', ...rest } = options return Provider.create({ chains: [chain], ...rest, adapter: postMessage({ host, name, rdns, target: wallet.target, }), }) } describe('create', () => { test('behavior: tempoWallet defaults host to the Tempo Wallet post-message page', async () => { const wallet = createWallet() const provider = Provider.create({ adapter: tempoWallet({ target: wallet.target }), chains: [chain], storage: Storage.memory(), }) await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }) expect(wallet.opened()).toMatchInlineSnapshot(` [ "https://wallet.tempo.xyz/post-message", ] `) expect(wallet.requests()[0]?.method).toBe('wallet_connect') }) test('behavior: tempoWallet bakes theme onto the wallet page URL', async () => { const wallet = createWallet() const provider = Provider.create({ adapter: tempoWallet({ target: wallet.target, theme: { accent: 'blue', radius: 'medium', scheme: 'dark' }, }), chains: [chain], storage: Storage.memory(), }) await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }) expect(wallet.opened()).toMatchInlineSnapshot(` [ "https://wallet.tempo.xyz/post-message?accent=blue&radius=medium&scheme=dark", ] `) }) test('behavior: accepts explicit postMessage adapter', async () => { const wallet = createWallet() const provider = Provider.create({ adapter: postMessage({ host: 'https://wallet.example/post-message', name: 'Custom Wallet', rdns: 'xyz.tempo.custom', target: wallet.target, }), chains: [chain], storage: Storage.memory(), }) const result = await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }) expect(result.accounts[0]!.address).toBe(root.address) expect(wallet.opened()).toMatchInlineSnapshot(` [ "https://wallet.example/post-message", ] `) }) test('behavior: tags the wallet page URL with the app origin', async () => { vi.stubGlobal('window', { addEventListener() {}, dispatchEvent: () => true, location: { origin: 'https://app.example' }, removeEventListener() {}, }) const wallet = createWallet() const provider = createProvider(wallet, { storage: Storage.memory() }) await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }) expect(wallet.opened()).toMatchInlineSnapshot(` [ "https://wallet.tempo.xyz/post-message?origin=https%3A%2F%2Fapp.example", ] `) }) test('behavior: persists managed access keys through provider storage', async () => { const storage = asyncJsonStorage({ key: 'post-message-managed-key' }) // String-based storage can't persist the non-extractable WebCrypto // default, so an extractable keystore is required for the managed key to // survive a reload here (structured-clone storage would persist either). const accessKey = { keystores: { p256: Keystore.webCryptoP256({ extractable: true }) } } const wallet = createWallet() const provider1 = createProvider(wallet, { accessKey, authorizeAccessKey: () => ({ expiry: Math.floor(Date.now() / 1000) + 3600, }), storage, }) const result = await provider1.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'register', name: 'Accounts Web Test' } }], }) expect(result.accounts[0]!.address).toBe(root.address) expect( connectParameters(wallet, 0)?.capabilities?.authorizeAccessKey?.keyType, ).toMatchInlineSnapshot(`"p256"`) await fund(root.address) const provider2 = createProvider(wallet, { accessKey, storage, }) await Store.waitForHydration(provider2.store) const receipt = await provider2.request({ method: 'eth_sendTransactionSync', params: [{ calls: [transferCall], feeToken: Addresses.pathUsd }], }) expect(receipt.status).toBe('0x1') expect(wallet.opened()).toHaveLength(1) }) test('behavior: reuses one wallet page session for sequential requests', async () => { const wallet = createWallet() const provider = createProvider(wallet, { storage: Storage.memory() }) await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }) await provider.request({ method: 'personal_sign', params: [Hex.fromString('hello'), root.address], }) await provider.request({ method: 'wallet_deposit', params: [{ amount: '25', token: 'USDC' }], }) expect(wallet.requests().map((request) => request.method)).toMatchInlineSnapshot(` [ "wallet_connect", "personal_sign", "wallet_deposit", ] `) expect(wallet.opened()).toHaveLength(1) }) test('behavior: reuses the wallet page session across disconnect', async () => { const wallet = createWallet() const provider = createProvider(wallet, { storage: Storage.memory() }) await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }) expect(provider.store.getState().accounts).toHaveLength(1) // `wallet_disconnect` is a local teardown (logout + clear); it never // reaches the wallet. The wallet-page session is a persistent channel, so // reconnecting reuses the already-handshaked session rather than reopening // a new one — the second login must still round-trip and succeed. await provider.request({ method: 'wallet_disconnect' }) expect(provider.store.getState().accounts).toHaveLength(0) const result = await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }) expect(result.accounts[0]!.address).toBe(root.address) expect(wallet.opened()).toHaveLength(1) expect(wallet.requests().filter((request) => request.method === 'wallet_connect')).toHaveLength( 2, ) }) test('behavior: serializes overlapping requests over one session', async () => { const wallet = createWallet() const provider = createProvider(wallet, { storage: Storage.memory() }) const [result, deposit] = await Promise.all([ provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }), provider.request({ method: 'wallet_deposit', params: [{ amount: '25', token: 'USDC' }] }), ]) expect(result.accounts[0]!.address).toBe(root.address) expect(deposit).toMatchInlineSnapshot(` { "receipts": [], } `) expect(wallet.opened()).toHaveLength(1) // Arrival order at the adapter is not defined for overlapping calls — // only that both round-trip through the single session. expect( wallet .requests() .map((request) => request.method) .sort(), ).toMatchInlineSnapshot(` [ "wallet_connect", "wallet_deposit", ] `) }) test('behavior: forwards personal_sign through the wallet page', async () => { const wallet = createWallet() const provider = createProvider(wallet, { storage: Storage.memory(), }) await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }) const data = Hex.fromString('hello') const signature = await provider.request({ method: 'personal_sign', params: [data, root.address], }) const forwarded = personalSignParameters(wallet, 1) expect(forwarded.address).toBe(root.address) expect(forwarded.data).toBe(data) expect(signature).toMatch(/^0x[0-9a-f]+$/) }) test('behavior: forwards wallet_deposit through the wallet page', async () => { const wallet = createWallet() const provider = createProvider(wallet, { storage: Storage.memory(), }) const result = await provider.request({ method: 'wallet_deposit', params: [{ amount: '25', token: 'USDC' }], }) expect(wallet.requests()[0]).toMatchInlineSnapshot(` { "method": "wallet_deposit", "params": [ { "amount": "25", "token": "USDC", }, ], } `) expect(result).toMatchInlineSnapshot(` { "receipts": [], } `) }) test('behavior: disconnects locally without opening the wallet page', async () => { const wallet = createWallet() const provider = createProvider(wallet, { storage: Storage.memory(), }) await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }) expect(provider.store.getState().accounts).toHaveLength(1) await provider.request({ method: 'wallet_disconnect' }) expect(provider.store.getState().accounts).toHaveLength(0) expect(wallet.opened()).toHaveLength(1) }) test('behavior: reconciles disconnect when the wallet asserts no accounts', async () => { const wallet = createWallet() const provider = createProvider(wallet, { storage: Storage.memory() }) await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }) expect(provider.store.getState().accounts).toHaveLength(1) // The wallet reports the user is no longer connected (logout / revoke); // the SDK drops the stale persisted session. await (await hosts[0]!).notify({ method: 'accountsChanged', params: [] }) await vi.waitFor(() => expect(provider.store.getState().accounts).toHaveLength(0)) }) test('behavior: keeps the session when the wallet still asserts the account', async () => { const wallet = createWallet() const provider = createProvider(wallet, { storage: Storage.memory() }) await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }) await (await hosts[0]!).notify({ method: 'accountsChanged', params: [root.address] }) // The assertion still lists the account, so nothing is dropped. await new Promise((resolve) => setTimeout(resolve, 50)) expect(provider.store.getState().accounts).toHaveLength(1) }) test('behavior: switches chains locally without opening the wallet page', async () => { const wallet = createWallet() const provider = createProvider(wallet, { storage: Storage.memory(), }) await provider.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: Hex.fromNumber(chain.id) }], }) expect(await provider.request({ method: 'eth_chainId' })).toBe(Hex.fromNumber(chain.id)) expect(wallet.opened()).toHaveLength(0) }) test('behavior: rejects when the wallet page closes without answering', async () => { // Window targets receive inbound frames through the global `window`; // stub one so the transport can attach its listener, and hand back a // popup stand-in that reports itself closed. vi.stubGlobal('window', { addEventListener() {}, dispatchEvent: () => true, location: { origin: 'https://app.example' }, removeEventListener() {}, }) const provider = Provider.create({ adapter: postMessage({ host: 'https://wallet.example/post-message', name: 'Custom Wallet', rdns: 'xyz.tempo.custom', target: () => ({ addEventListener() {}, closed: true, postMessage() {}, removeEventListener() {}, }) as unknown as Window, }), chains: [chain], storage: Storage.memory(), }) await expect( provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }), ).rejects.toThrowErrorMatchingInlineSnapshot( '[Provider.UserRejectedRequestError: The user rejected the request.]', ) }) }) describe('mount', () => { const consumerOrigin = 'https://app.example' const walletOrigin = 'https://wallet.tempo.xyz' type RealmListener = (event: { data: unknown; origin: string }) => void function realm() { const listeners = new Set<RealmListener>() return { addEventListener: (_type: string, listener: RealmListener) => void listeners.add(listener), deliver(data: unknown, origin: string) { for (const listener of [...listeners]) listener({ data, origin }) }, removeEventListener: (_type: string, listener: RealmListener) => void listeners.delete(listener), } } /** * Fakes the browser side of a mount: a consumer realm installed as the * global `window` (with `window.open` minting a fresh in-process wallet * window per call) and a `document` stub for `Mount.popup()`'s overlay. */ function installBrowser(wire = wireWallet) { const consumerRealm = realm() const opened: string[] = [] const opened_handles: ReturnType<typeof walletWindow>['handle'][] = [] const window_ = Object.assign(consumerRealm, { dispatchEvent: () => true, innerWidth: 1024, location: { origin: consumerOrigin }, open: (url: string) => { opened.push(url) const window_wallet = walletWindow(consumerRealm, wire) opened_handles.push(window_wallet.handle) return window_wallet.handle }, screenX: 0, screenY: 0, }) vi.stubGlobal('window', window_) vi.stubGlobal('document', fakeDocument()) return { consumerRealm, opened, opened_handles } } /** Minimal DOM for `Mount.popup()`'s overlay. */ function fakeDocument() { const element = () => ({ addEventListener() {}, appendChild() {}, remove() {}, style: {}, textContent: '', }) return { body: element(), createElement: () => element() } } /** * In-process wallet window: a wata host on its own realm, wired so frames * cross between realms stamped with the sender's origin. */ function walletWindow(consumerRealm: ReturnType<typeof realm>, wire = wireWallet) { const walletRealm = realm() let wired = false const buffered: unknown[] = [] const handle = { addEventListener() {}, closed: false, close() { this.closed = true }, focus() {}, postMessage: (data: unknown) => walletRealm.deliver(data, consumerOrigin), } const opener = { addEventListener() {}, postMessage: (data: unknown) => { if (wired) consumerRealm.deliver(data, walletOrigin) else buffered.push(data) }, } const session = createWalletHost(walletRealm, opener).start() wire(session) wired = true for (const data of buffered.splice(0)) consumerRealm.deliver(data, walletOrigin) const host = Promise.resolve(session) hosts.push(host) return { handle, host } } function createWalletHost( walletRealm: ReturnType<typeof realm>, opener: { addEventListener: () => void; postMessage: (data: unknown) => void }, ) { return HostWata.create({ transports: [ hostPostMessage({ source: walletRealm as never, target: () => opener as unknown as Window, targetOrigin: consumerOrigin, }), ], }) } type WalletSession = Awaited<ReturnType<ReturnType<typeof createWalletHost>['start']>> /** Answers wallet RPC like the live endpoint, plus dialog notifications. */ function wireWallet(session: WalletSession) { const live: { reject: (error: { code: number; message: string }) => Promise<void> }[] = [] session.onRequest(async (event) => { live.push(event) if (event.method === 'wallet_connect') { await event.respond({ accounts: [{ address: root.address, capabilities: {} }] }) return } if (event.method === 'personal_sign') { const [data] = z.decode(Rpc.personal_sign.schema.params!, event.params as never) await event.respond(await root.signMessage({ message: { raw: data } })) } // Other methods stay pending (e.g. awaiting a cancel). }) session.onNotification((event) => { if (event.method !== 'cancel') return for (const pending of live.splice(0)) void pending.reject({ code: 4001, message: 'User rejected the request.' }).catch(() => {}) }) } function scriptedMount( events: string[], consumerRealm: ReturnType<typeof realm>, wire = wireWallet, ) { let dismiss: (() => void) | undefined const factory = Object.assign( (parameters: { host: string; onDismiss: () => void }) => { dismiss = parameters.onDismiss events.push(`mount:${parameters.host}`) const window_wallet = walletWindow(consumerRealm, wire) return { close: () => void events.push('close'), destroy: () => void events.push('destroy'), hide: () => void events.push('hide'), mode: 'iframe' as const, show: () => void events.push('show'), target: () => { events.push('target') return window_wallet.handle as unknown as Window }, } }, { mode: 'iframe' as const }, ) return { dismiss: () => dismiss?.(), factory } } test('behavior: mounts eagerly and surfaces only while requests are pending', async () => { const { consumerRealm } = installBrowser() const events: string[] = [] const scripted = scriptedMount(events, consumerRealm) const provider = Provider.create({ adapter: postMessage({ host: `${walletOrigin}/post-message`, mount: scripted.factory, name: 'Accounts Web Test', rdns: 'xyz.tempo.accounts.playground', }), chains: [chain], storage: Storage.memory(), }) expect(events[0]).toMatchInlineSnapshot( `"mount:https://wallet.tempo.xyz/post-message?origin=https%3A%2F%2Fapp.example&mode=iframe"`, ) expect(events.filter((event) => event === 'target')).toHaveLength(1) expect(events).not.toContain('show') await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }) expect(events.filter((event) => event === 'show')).toHaveLength(1) await vi.waitFor(() => expect(events.at(-1)).toBe('hide')) await provider.request({ method: 'personal_sign', params: [Hex.fromString('hello'), root.address], }) expect(events.filter((event) => event === 'show')).toHaveLength(2) await vi.waitFor(() => expect(events.at(-1)).toBe('hide')) // One mount, one session for the provider's lifetime. expect(events.filter((event) => event.startsWith('mount:'))).toHaveLength(1) expect(events.filter((event) => event === 'target')).toHaveLength(1) }) test('behavior: keeps the iframe mount up across disconnect', async () => { const { consumerRealm } = installBrowser() const events: string[] = [] const scripted = scriptedMount(events, consumerRealm) const provider = Provider.create({ adapter: postMessage({ host: `${walletOrigin}/post-message`, mount: scripted.factory, name: 'Accounts Web Test', rdns: 'xyz.tempo.accounts.playground', }), chains: [chain], storage: Storage.memory(), }) await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }) await provider.request({ method: 'wallet_disconnect' }) // Disconnect is a local teardown — the mount and its handshaked session // stay up, so the iframe is never destroyed and the next login reuses the // same session instead of stranding on a stale handshake. expect(events).not.toContain('destroy') const result = await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }) expect(result.accounts[0]!.address).toBe(root.address) expect(events.filter((event) => event.startsWith('mount:'))).toHaveLength(1) expect(events.filter((event) => event === 'target')).toHaveLength(1) }) test('behavior: cleanup closes in-flight Safari popup fallback', async () => { const { consumerRealm, opened_handles } = installBrowser((session) => session.onRequest(() => {}), ) vi.stubGlobal('navigator', { userAgent: 'Version/17.0 Safari/605.1.15' }) const events: string[] = [] const scripted = scriptedMount(events, consumerRealm) const storage = Storage.memory() const store = Store.create({ chainId: chain.id, storage }) const adapter = postMessage({ host: `${walletOrigin}/post-message`, mount: scripted.factory, name: 'Accounts Web Test', rdns: 'xyz.tempo.accounts.playground', })({ getAccount: () => ({ address: root.address, type: 'json-rpc' }) as never, getClient: () => ({}) as never, storage, store, }) const pending = adapter.actions.loadAccounts(undefined, { method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }) void pending.catch(() => {}) await vi.waitFor(() => expect(opened_handles).toHaveLength(1)) expect(opened_handles[0]!.closed).toBe(false) adapter.cleanup?.() await vi.waitFor(() => expect(opened_handles[0]!.closed).toBe(true)) await expect(pending).rejects.toMatchObject({ code: 4001 }) }) test('behavior: dismissing the mount cancels the in-flight request', async () => { const { consumerRealm } = installBrowser() const events: string[] = [] const scripted = scriptedMount(events, consumerRealm) const provider = Provider.create({ adapter: postMessage({ host: `${walletOrigin}/post-message`, mount: scripted.factory, name: 'Accounts Web Test', rdns: 'xyz.tempo.accounts.playground', }), chains: [chain], storage: Storage.memory(), }) // wallet_deposit stays pending wallet-side until cancelled. const denied = provider.request({ method: 'wallet_deposit', params: [{ amount: '25', token: 'USDC' }], }) await vi.waitFor(() => expect(events).toContain('show')) await vi.waitFor(() => expect(events).toContain('target')) scripted.dismiss() await expect(denied).rejects.toMatchObject({ code: 4001 }) await vi.waitFor(() => expect(events.at(-1)).toBe('hide')) }) test('behavior: dismissing rejects locally even when the wallet ignores the cancel', async () => { const { consumerRealm } = installBrowser() const events: string[] = [] // Wallet accepts the request but never answers and ignores notifications // (a wedged iframe). Dismiss must still reject — there is no closed poll. const scripted = scriptedMount(events, consumerRealm, (session) => session.onRequest(() => {})) const provider = Provider.create({ adapter: postMessage({ host: `${walletOrigin}/post-message`, mount: scripted.factory, name: 'Accounts Web Test', rdns: 'xyz.tempo.accounts.playground', }), chains: [chain], storage: Storage.memory(), }) const denied = provider.request({ method: 'wallet_deposit', params: [{ amount: '25', token: 'USDC' }], }) await vi.waitFor(() => expect(events).toContain('show')) scripted.dismiss() await expect(denied).rejects.toMatchObject({ code: 4001 }) await vi.waitFor(() => expect(events.at(-1)).toBe('hide')) }) test('behavior: switch notification remounts in a popup and replays the request', async () => { const { consumerRealm, opened } = installBrowser() const events: string[] = [] // The iframe wallet asks to continue in a popup instead of answering. const scripted = scriptedMount(events, consumerRealm, (session) => session.onRequest(() => void session.notify({ method: 'switch-mode', params: [] })), ) const provider = Provider.create({ adapter: postMessage({ host: `${walletOrigin}/post-message`, mount: scripted.factory, name: 'Accounts Web Test', rdns: 'xyz.tempo.accounts.playground', }), chains: [chain], storage: Storage.memory(), }) const result = await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'login' } }], }) expect(result.accounts[0]!.address).toBe(root.address) // The iframe mount was destroyed and the popup (window.open) answered. expect(events).toContain('destroy') expect(opened).toHaveLength(1) expect(opened[0]).toContain('mode=popup') }) })