accounts
Version:
Tempo Accounts SDK
957 lines (846 loc) • 31.6 kB
text/typescript
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')
})
})