UNPKG

accounts

Version:

Tempo Accounts SDK

1,477 lines (1,256 loc) 94.6 kB
import { Hex, Provider as core_Provider, WebCryptoP256 } from 'ox' import { KeyAuthorization } from 'ox/tempo' import { type Address, createClient, createWalletClient, custom, parseUnits } from 'viem' import { getBalance, sendCalls, sendTransactionSync, signMessage, verifyHash, verifyMessage, verifyTypedData, waitForTransactionReceipt, } from 'viem/actions' import { tempo, tempoModerato } from 'viem/chains' import { Account as TempoAccount, Actions, Addresses } from 'viem/tempo' import { afterAll, beforeAll, describe, expect, test } from 'vp/test' import { headlessWebAuthn, secp256k1 } from '../../test/adapters.js' import { accounts, chain, getClient, http } from '../../test/config.js' import { createServer, type Server } from '../../test/utils.js' import * as Handler from '../server/Handler.js' import * as Adapter from './Adapter.js' import { local as core_local } from './adapters/local.js' import * as Expiry from './Expiry.js' import * as Provider from './Provider.js' import * as Storage from './Storage.js' const adapters = [ { name: 'headlessWebAuthn', adapter: headlessWebAuthn }, { name: 'secp256k1', adapter: secp256k1 }, ] as const describe.each(adapters)('$name', ({ adapter }: (typeof adapters)[number]) => { function transfer(amount: string) { return Actions.token.transfer.call({ to: '0x0000000000000000000000000000000000000001', token: Addresses.pathUsd, amount: parseUnits(amount, 6), }) } const transferCall = transfer('1') /** Connects via login (or register if login returns no accounts), returns the active account address. */ async function connect(provider: ReturnType<typeof Provider.create>) { const login = await provider.request({ method: 'wallet_connect' }) if (login.accounts.length > 0) return login.accounts[0]!.address const register = await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'register' } }], }) return register.accounts[0]!.address } /** Funds an address with PathUSD from the pre-funded test account. */ async function fund(address: Address) { const client = getClient() await Actions.token.transferSync(client, { account: accounts[0]!, feeToken: Addresses.pathUsd, to: address, token: Addresses.pathUsd, amount: parseUnits('10', 6), }) } describe('create', () => { test('default: returns an EIP-1193 provider', async () => { const provider = Provider.create({ adapter: adapter() }) expect(typeof provider.request).toMatch(/function/) }) }) describe('eth_chainId', () => { test('default: returns configured chain ID as hex', async () => { const provider = Provider.create({ adapter: adapter() }) const chainId = await provider.request({ method: 'eth_chainId' }) expect(chainId).toMatchInlineSnapshot(`"0x1079"`) }) }) describe('eth_accounts', () => { test('default: returns empty array initially', async () => { const provider = Provider.create({ adapter: adapter() }) const accounts = await provider.request({ method: 'eth_accounts' }) expect(accounts).toMatchInlineSnapshot(`[]`) }) test('behavior: returns accounts after connecting', async () => { const provider = Provider.create({ adapter: adapter() }) await connect(provider) const result = await provider.request({ method: 'eth_accounts' }) expect(result.length).toBeGreaterThanOrEqual(1) }) }) describe('eth_requestAccounts', () => { test('default: returns accounts after connecting', async () => { const provider = Provider.create({ adapter: adapter() }) await connect(provider) const result = await provider.request({ method: 'eth_requestAccounts' }) expect(result.length).toBeGreaterThanOrEqual(1) }) test('behavior: returns connected accounts without reloading', async () => { let calls = 0 const provider = Provider.create({ adapter: core_local({ loadAccounts: async () => { calls++ return { accounts: [accounts[0]!] } }, }), }) const first = await provider.request({ method: 'eth_requestAccounts' }) const result = await provider.request({ method: 'eth_requestAccounts' }) expect(calls).toBe(1) expect(result).toEqual(first) }) }) describe('wallet_connect', () => { test('default: without capabilities calls loadAccounts', async () => { const provider = Provider.create({ adapter: adapter() }) const result = await provider.request({ method: 'wallet_connect' }) for (const account of result.accounts) { expect(account.address).toMatch(/^0x[0-9a-f]{40}$/i) expect(account.capabilities).toMatchInlineSnapshot(`{}`) } }) test('behavior: with register capability calls createAccount', async () => { const provider = Provider.create({ adapter: adapter() }) const result = await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'register' } }], }) expect(result.accounts.length).toMatchInlineSnapshot(`1`) expect(result.accounts[0]!.address).toMatch(/^0x[0-9a-f]{40}$/i) expect(result.accounts[0]!.capabilities).toMatchInlineSnapshot(`{}`) }) test('behavior: register passes name to createAccount', async () => { const provider = Provider.create({ adapter: adapter() }) await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'register', name: 'alice' } }], }) expect(provider.store.getState().accounts.length).toBeGreaterThanOrEqual(1) }) test('behavior: register defaults name to "default"', async () => { const provider = Provider.create({ adapter: adapter() }) await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'register' } }], }) expect(provider.store.getState().accounts.length).toBeGreaterThanOrEqual(1) }) test('behavior: login sets activeAccount to loaded account', async () => { const provider = Provider.create({ adapter: adapter() }) await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'register' } }], }) const login = await provider.request({ method: 'wallet_connect' }) const result = await provider.request({ method: 'wallet_connect' }) expect(result.accounts[0]!.address).toBe(login.accounts[0]!.address) }) test('behavior: login with digest returns signature in account capabilities', async () => { const provider = Provider.create({ adapter: adapter() }) await connect(provider) const result = await provider.request({ method: 'wallet_connect', params: [{ capabilities: { digest: '0x1234' } }], }) expect(result.accounts[0]!.capabilities.signature).toMatch(/^0x[0-9a-f]+$/) }) test('behavior: digest signature is verifiable on-chain', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) const client = provider.getClient() await connect(provider) const digest = '0x00000000000000000000000000000000000000000000000000000000deadbeef' as const const result = await provider.request({ method: 'wallet_connect', params: [{ capabilities: { digest } }], }) const valid = await verifyHash(client, { address: result.accounts[0]!.address, hash: digest, signature: result.accounts[0]!.capabilities.signature!, }) expect(valid).toMatchInlineSnapshot(`true`) }) test('behavior: login without digest returns empty capabilities', async () => { const provider = Provider.create({ adapter: adapter() }) await connect(provider) const result = await provider.request({ method: 'wallet_connect' }) expect(result.accounts[0]!.capabilities).toMatchInlineSnapshot(`{}`) }) test('behavior: register without digest returns empty capabilities', async () => { const provider = Provider.create({ adapter: adapter() }) const result = await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'register' } }], }) expect(result.accounts[0]!.capabilities).toMatchInlineSnapshot(`{}`) }) test('behavior: register with digest returns signature in capabilities', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) const result = await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'register', digest: '0x1234' } }], }) expect(result.accounts[0]!.capabilities.signature).toMatch(/^0x[0-9a-f]+$/) }) test('behavior: register digest signature is verifiable on-chain', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) const client = provider.getClient() const digest = '0x00000000000000000000000000000000000000000000000000000000deadbeef' as const const result = await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'register', digest } }], }) const valid = await verifyHash(client, { address: result.accounts[0]!.address, hash: digest, signature: result.accounts[0]!.capabilities.signature!, }) expect(valid).toMatchInlineSnapshot(`true`) }) test('behavior: login with personalSign echoes { message } and surfaces signature at root', async () => { const provider = Provider.create({ adapter: adapter() }) await connect(provider) const result = await provider.request({ method: 'wallet_connect', params: [{ capabilities: { personalSign: { message: 'hello' } } }], }) expect(result.accounts[0]!.capabilities.personalSign).toEqual({ message: 'hello' }) expect(result.accounts[0]!.capabilities.signature).toMatch(/^0x[0-9a-f]+$/) }) test('behavior: login personalSign signature is verifiable via verifyMessage', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) const client = provider.getClient() await connect(provider) const result = await provider.request({ method: 'wallet_connect', params: [{ capabilities: { personalSign: { message: 'hello' } } }], }) const valid = await verifyMessage(client, { address: result.accounts[0]!.address, message: 'hello', signature: result.accounts[0]!.capabilities.signature!, }) expect(valid).toMatchInlineSnapshot(`true`) }) test('behavior: register with personalSign echoes { message } and surfaces signature at root', async () => { const provider = Provider.create({ adapter: adapter() }) const result = await provider.request({ method: 'wallet_connect', params: [ { capabilities: { method: 'register', personalSign: { message: 'hi' } }, }, ], }) expect(result.accounts[0]!.capabilities.personalSign).toEqual({ message: 'hi' }) expect(result.accounts[0]!.capabilities.signature).toMatch(/^0x[0-9a-f]+$/) }) test('behavior: register personalSign signature is verifiable via verifyMessage', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) const client = provider.getClient() const result = await provider.request({ method: 'wallet_connect', params: [ { capabilities: { method: 'register', personalSign: { message: 'hi' } }, }, ], }) const valid = await verifyMessage(client, { address: result.accounts[0]!.address, message: 'hi', signature: result.accounts[0]!.capabilities.signature!, }) expect(valid).toMatchInlineSnapshot(`true`) }) test('error: personalSign + digest is rejected as invalid params', async () => { const provider = Provider.create({ adapter: adapter() }) await connect(provider) await expect( provider.request({ method: 'wallet_connect', params: [ { capabilities: { digest: '0x1234', personalSign: { message: 'hello' }, }, }, ], }), ).rejects.toThrowErrorMatchingInlineSnapshot( `[RpcResponse.InvalidParamsError: \`digest\` and \`personalSign\` cannot both be set on \`wallet_connect\`.]`, ) }) describe('auth (Server Authentication)', () => { let server: Server let badServer: Server let authBase: string beforeAll(async () => { // Real Hono app: mount the auth handler under `/auth` and add a // protected `/me` route — exactly as a dapp would compose them // — so the e2e test below exercises the full flow. const auth = Handler.auth() const app = Handler.compose([auth], { path: '/auth' }) app.get('/me', async (c) => { const session = await auth.getSession(c.req.raw) if (!session) return c.json({ error: 'unauthenticated' }, 401) return c.json({ address: session.address, chainId: session.chainId }) }) // Bad-challenge / bad-verify endpoints mounted on the same origin // as `/auth` so the same-origin enforcement (`absolutizeAuth`) // doesn't reject the request before the bad-content paths under // test can run. `app.all` so we don't depend on the SDK's request // method (POST). app.all('/bad/verify-401', (c) => c.json({ error: 'unauthorized' }, 401)) app.all('/bad/challenge-500', (c) => c.json({ error: 'boom' }, 500)) app.all('/bad/challenge-empty', (c) => c.json({})) app.all('/bad/challenge-evil-domain', (c) => c.json({ message: [ 'evil.example wants you to sign in with your Ethereum account:', '0x0000000000000000000000000000000000000000', '', '', 'URI: https://evil.example', 'Version: 1', 'Chain ID: 0', 'Nonce: deadbeef00', 'Issued At: 2025-01-01T00:00:00Z', ].join('\n'), }), ) server = await createServer(app.listener) authBase = `${server.url}/auth` // Cross-origin bad server kept around for the same-origin enforcement // tests below — its only job is to be on a different port from // `server` so origins genuinely differ. badServer = await createServer((_req, res) => { res.statusCode = 404 res.end() }) }) afterAll(() => { server.close() badServer.close() }) test('default: auth as string shorthand fetches challenge, signs once, posts verify', async () => { const provider = Provider.create({ adapter: adapter() }) const result = await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'register', auth: authBase } }], }) const capabilities = result.accounts[0]!.capabilities expect(capabilities.auth).toEqual({ token: expect.any(String) }) expect(capabilities.personalSign).toEqual({ message: expect.stringContaining('wants you to sign in'), }) expect(capabilities.signature).toMatch(/^0x[0-9a-f]+$/) }) test('default: object-form auth with explicit endpoints uses the override URLs', async () => { const provider = Provider.create({ adapter: adapter() }) const result = await provider.request({ method: 'wallet_connect', params: [ { capabilities: { method: 'register', auth: { challenge: `${authBase}/challenge`, verify: authBase, }, }, }, ], }) expect(result.accounts[0]!.capabilities.auth).toEqual({ token: expect.any(String) }) }) test('error: verify endpoint returns 401 → InternalError; user already signed', async () => { const provider = Provider.create({ adapter: adapter() }) await expect( provider.request({ method: 'wallet_connect', params: [ { capabilities: { method: 'register', auth: { challenge: `${authBase}/challenge`, verify: `${server.url}/bad/verify-401`, }, }, }, ], }), ).rejects.toThrow( /Server Authentication verify endpoint `http:\/\/localhost:\d+\/bad\/verify-401` returned 401\./, ) }) test('error: auth + personalSign throws InvalidParamsError synchronously', async () => { const provider = Provider.create({ adapter: adapter() }) await expect( provider.request({ method: 'wallet_connect', params: [ { capabilities: { method: 'register', auth: authBase, personalSign: { message: 'hi' }, }, }, ], }), ).rejects.toThrowErrorMatchingInlineSnapshot( `[RpcResponse.InvalidParamsError: \`auth\` and \`personalSign\` cannot both be set on \`wallet_connect\`.]`, ) }) test('default: auth + authorizeAccessKey surfaces both capabilities (two ceremonies)', async () => { const provider = Provider.create({ adapter: adapter() }) const result = await provider.request({ method: 'wallet_connect', params: [ { capabilities: { method: 'register', auth: authBase, authorizeAccessKey: { expiry: 0 }, }, }, ], }) expect(result.accounts[0]!.capabilities.auth).toEqual({ token: expect.any(String) }) expect(result.accounts[0]!.capabilities.keyAuthorization).toBeDefined() expect(result.accounts[0]!.capabilities.personalSign).toEqual({ message: expect.any(String), }) expect(result.accounts[0]!.capabilities.signature).toMatch(/^0x[0-9a-f]+$/) }) test('error: challenge endpoint returns 500 → InvalidParamsError; no verify', async () => { const provider = Provider.create({ adapter: adapter() }) await expect( provider.request({ method: 'wallet_connect', params: [ { capabilities: { method: 'register', auth: { challenge: `${server.url}/bad/challenge-500`, verify: authBase, }, }, }, ], }), ).rejects.toThrow( /Server Authentication challenge endpoint `http:\/\/localhost:\d+\/bad\/challenge-500` returned 500\./, ) }) test('error: challenge response missing `message` → InvalidParamsError', async () => { const provider = Provider.create({ adapter: adapter() }) await expect( provider.request({ method: 'wallet_connect', params: [ { capabilities: { method: 'register', auth: { challenge: `${server.url}/bad/challenge-empty`, verify: authBase, }, }, }, ], }), ).rejects.toThrow( /Server Authentication challenge endpoint `http:\/\/localhost:\d+\/bad\/challenge-empty` response missing `message`\./, ) }) test('error: challenge bound to a different domain → InvalidParamsError; never signs', async () => { const provider = Provider.create({ adapter: adapter() }) await expect( provider.request({ method: 'wallet_connect', params: [ { capabilities: { method: 'register', auth: { challenge: `${server.url}/bad/challenge-evil-domain`, verify: authBase, }, }, }, ], }), ).rejects.toThrow(/returned a message bound to `evil\.example`/) }) test('error: `challenge` and `verify` on different origins → InvalidParamsError', async () => { // Phishing guard: a malicious dapp must not be able to point // `challenge` at the victim and `verify` at attacker.com to // harvest a valid signed payload. const provider = Provider.create({ adapter: adapter() }) await expect( provider.request({ method: 'wallet_connect', params: [ { capabilities: { method: 'register', auth: { challenge: `${authBase}/challenge`, verify: `${badServer.url}/collect`, }, }, }, ], }), ).rejects.toThrow( /`auth` endpoints \(`challenge`, `verify`, `logout`\) must share the same origin\./, ) }) test('error: `logout` on a different origin → InvalidParamsError', async () => { const provider = Provider.create({ adapter: adapter() }) await expect( provider.request({ method: 'wallet_connect', params: [ { capabilities: { method: 'register', auth: { challenge: `${authBase}/challenge`, verify: authBase, logout: `${badServer.url}/logout`, }, }, }, ], }), ).rejects.toThrow( /`auth` endpoints \(`challenge`, `verify`, `logout`\) must share the same origin\./, ) }) test('default: no auth capability → no auth/personalSign on result', async () => { const provider = Provider.create({ adapter: adapter() }) const result = await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'register' } }], }) expect(result.accounts[0]!.capabilities.auth).toBeUndefined() expect(result.accounts[0]!.capabilities.personalSign).toBeUndefined() }) test('default: login (post-register) + auth populates capabilities.auth', async () => { const provider = Provider.create({ adapter: adapter() }) // Register first so login has an account to load. await provider.request({ method: 'wallet_connect', params: [{ capabilities: { method: 'register' } }], }) const result = await provider.request({ method: 'wallet_connect', params: [{ capabilities: { auth: authBase } }], }) expect(result.accounts[0]!.capabilities.auth).toEqual({ token: expect.any(String) }) expect(result.accounts[0]!.capabilities.personalSign).toEqual({ message: expect.stringContaining('wants you to sign in'), }) expect(result.accounts[0]!.capabilities.signature).toMatch(/^0x[0-9a-f]+$/) }) test('end-to-end: connect → call protected /me with bearer token', async () => { const provider = Provider.create({ adapter: adapter() }) // Token mode: the server returns the session token in the body // (no cookie) and the SDK surfaces it on `capabilities.auth.token`. const result = await provider.request({ method: 'wallet_connect', params: [ { capabilities: { method: 'register', auth: { url: authBase, returnToken: true }, }, }, ], }) const token = result.accounts[0]!.capabilities.auth?.token expect(token).toMatch(/^[a-z0-9]+$/) // Authenticated request resolves the connected address. const me = await fetch(`${server.url}/me`, { headers: { authorization: `Bearer ${token}` }, }) expect(me.status).toBe(200) expect(await me.json()).toEqual({ address: result.accounts[0]!.address, chainId: expect.any(Number), }) // Unauthenticated request is rejected. const anon = await fetch(`${server.url}/me`) expect(anon.status).toBe(401) }) }) }) describe('wallet_disconnect', () => { test('default: disconnects and clears accounts', async () => { const provider = Provider.create({ adapter: adapter() }) await connect(provider) await provider.request({ method: 'wallet_disconnect' }) const accounts = await provider.request({ method: 'eth_accounts' }) expect(accounts).toMatchInlineSnapshot(`[]`) }) }) describe('wallet_switchEthereumChain', () => { test('default: switches chain', async () => { const provider = Provider.create({ adapter: adapter() }) await provider.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: `0x${tempoModerato.id.toString(16)}` }], }) const chainId = await provider.request({ method: 'eth_chainId' }) expect(chainId).toMatchInlineSnapshot(`"0xa5bf"`) }) test('error: throws for unconfigured chain', async () => { const provider = Provider.create({ adapter: adapter() }) await expect( provider.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: '0x1' }], }), ).rejects.toThrowErrorMatchingInlineSnapshot( `[Provider.UnsupportedChainIdError: Chain 1 not configured.]`, ) }) }) describe('events', () => { test('behavior: emits accountsChanged on connect', async () => { const provider = Provider.create({ adapter: adapter() }) const events: unknown[] = [] provider.on('accountsChanged', (accounts) => events.push(accounts)) const connected = await connect(provider) expect(events).toEqual([[connected]]) }) test('behavior: emits connect on status change', async () => { const provider = Provider.create({ adapter: adapter() }) const events: unknown[] = [] provider.on('connect', (info) => events.push(info)) await connect(provider) expect(events).toMatchInlineSnapshot(` [ { "chainId": "0x1079", }, ] `) }) test('behavior: emits disconnect on disconnect', async () => { const provider = Provider.create({ adapter: adapter() }) await connect(provider) const events: unknown[] = [] provider.on('disconnect', (error) => events.push(error)) await provider.request({ method: 'wallet_disconnect' }) expect(events.length).toMatchInlineSnapshot(`1`) expect(events[0]).toBeInstanceOf(core_Provider.DisconnectedError) }) test('behavior: does not emit accountsChanged on duplicate login', async () => { const provider = Provider.create({ adapter: adapter() }) await connect(provider) const events: unknown[] = [] provider.on('accountsChanged', (accounts) => events.push(accounts)) await provider.request({ method: 'wallet_connect' }) expect(events).toMatchInlineSnapshot(`[]`) }) test('behavior: emits chainChanged on switch', async () => { const provider = Provider.create({ adapter: adapter() }) const events: unknown[] = [] provider.on('chainChanged', (chainId) => events.push(chainId)) await provider.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: `0x${tempoModerato.id.toString(16)}` }], }) expect(events).toMatchInlineSnapshot(` [ "0xa5bf", ] `) }) }) describe('eth_sendTransaction', () => { test('default: sends transaction and returns hash', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) const connected = await connect(provider) await fund(connected) const hash = await provider.request({ method: 'eth_sendTransaction', params: [{ calls: [transferCall] }], }) expect(hash).toMatch(/^0x[0-9a-f]{64}$/) }) test('behavior: accepts standard to/data fields', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) const connected = await connect(provider) await fund(connected) const hash = await provider.request({ method: 'eth_sendTransaction', params: [{ to: transferCall.to, data: transferCall.data }], }) expect(hash).toMatch(/^0x[0-9a-f]{64}$/) }) test('behavior: transaction is confirmed on-chain', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) const connected = await connect(provider) await fund(connected) const hash = await provider.request({ method: 'eth_sendTransaction', params: [{ calls: [transferCall] }], }) const client = provider.getClient() const receipt = await waitForTransactionReceipt(client, { hash }) const { blockHash, blockNumber, cumulativeGasUsed, effectiveGasPrice, feePayer, from, gasUsed, logs, logsBloom, transactionHash, transactionIndex, ...rest } = receipt expect(blockHash).toMatch(/^0x[0-9a-f]{64}$/) expect(typeof blockNumber).toMatch(/bigint/) expect(typeof cumulativeGasUsed).toMatch(/bigint/) expect(typeof effectiveGasPrice).toMatch(/bigint/) expect(feePayer).toMatch(/^0x[0-9a-f]{40}$/i) expect(from).toMatch(/^0x[0-9a-f]{40}$/i) expect(typeof gasUsed).toMatch(/bigint/) for (const log of logs) expect(log.address).toMatch(/^0x[0-9a-f]{40}$/i) expect(logsBloom).toMatch(/^0x/) expect(transactionHash).toMatch(/^0x[0-9a-f]{64}$/) expect(typeof transactionIndex).toMatch(/number/) expect(rest).toMatchInlineSnapshot(` { "contractAddress": null, "feeToken": "0x20c0000000000000000000000000000000000000", "status": "success", "to": "0x20c0000000000000000000000000000000000000", "type": "0x76", } `) }) }) describe('eth_sendTransactionSync', () => { test('default: sends transaction and returns receipt', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) const connected = await connect(provider) await fund(connected) const receipt = await provider.request({ method: 'eth_sendTransactionSync', params: [{ calls: [transferCall] }], }) const { blockHash, blockNumber, cumulativeGasUsed, effectiveGasPrice, feePayer, from, gasUsed, logs, logsBloom, transactionHash, transactionIndex, ...rest } = receipt expect(blockHash).toMatch(/^0x[0-9a-f]{64}$/) expect(blockNumber).toMatch(/^0x/) expect(cumulativeGasUsed).toMatch(/^0x/) expect(effectiveGasPrice).toMatch(/^0x/) expect(feePayer).toMatch(/^0x[0-9a-f]{40}$/i) expect(from).toMatch(/^0x[0-9a-f]{40}$/i) expect(gasUsed).toMatch(/^0x/) for (const log of logs) expect(log.address).toMatch(/^0x[0-9a-f]{40}$/i) expect(logsBloom).toMatch(/^0x/) expect(transactionHash).toMatch(/^0x[0-9a-f]{64}$/) expect(transactionIndex).toMatch(/^0x/) expect(rest).toMatchInlineSnapshot(` { "contractAddress": null, "feeToken": "0x20c0000000000000000000000000000000000000", "status": "0x1", "to": "0x20c0000000000000000000000000000000000000", "type": "0x76", } `) }) }) describe('eth_signTransaction', () => { test('default: signs transaction and returns serialized', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) const connected = await connect(provider) await fund(connected) const signed = await provider.request({ method: 'eth_signTransaction', params: [{ calls: [transferCall] }], }) expect(signed).toMatch(/^0x/) }) test('behavior: signed transaction can be sent via eth_sendRawTransactionSync', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) const connected = await connect(provider) await fund(connected) const signed = await provider.request({ method: 'eth_signTransaction', params: [{ calls: [transferCall] }], }) const receipt = await provider.request({ method: 'eth_sendRawTransactionSync', params: [signed], }) const { blockHash, blockNumber, cumulativeGasUsed, effectiveGasPrice, // @ts-expect-error feePayer, from, gasUsed, logs, logsBloom, transactionHash, transactionIndex, ...rest } = receipt expect(blockHash).toMatch(/^0x[0-9a-f]{64}$/) expect(blockNumber).toMatch(/^0x/) expect(cumulativeGasUsed).toMatch(/^0x/) expect(effectiveGasPrice).toMatch(/^0x/) expect(feePayer).toMatch(/^0x[0-9a-f]{40}$/i) expect(from).toMatch(/^0x[0-9a-f]{40}$/i) expect(gasUsed).toMatch(/^0x/) for (const log of logs) expect(log.address).toMatch(/^0x[0-9a-f]{40}$/i) expect(logsBloom).toMatch(/^0x/) expect(transactionHash).toMatch(/^0x[0-9a-f]{64}$/) expect(transactionIndex).toMatch(/^0x/) expect(rest).toMatchInlineSnapshot(` { "contractAddress": null, "feeToken": "0x20c0000000000000000000000000000000000000", "status": "0x1", "to": "0x20c0000000000000000000000000000000000000", "type": "0x76", } `) }) test('behavior: signing keeps pending access key retryable', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) const address = await connect(provider) await fund(address) await provider.request({ method: 'wallet_authorizeAccessKey', params: [{ expiry: Expiry.days(1) }], }) expect(provider.store.getState().accessKeys[0]!.keyAuthorization).toBeDefined() const signed = await provider.request({ method: 'eth_signTransaction', params: [{ calls: [transferCall] }], }) expect(signed).toMatch(/^0x/) expect(provider.store.getState().accessKeys[0]!.keyAuthorization).toBeDefined() const receipt = await provider.request({ method: 'eth_sendTransactionSync', params: [{ calls: [transferCall] }], }) expect(receipt.status).toMatchInlineSnapshot(`"0x1"`) expect(provider.store.getState().accessKeys[0]!.keyAuthorization).toBeUndefined() }) test('error: throws when not connected', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) await expect( provider.request({ method: 'eth_signTransaction', params: [{ calls: [transferCall] }], }), ).rejects.toThrowErrorMatchingInlineSnapshot( `[Provider.DisconnectedError: No accounts connected.]`, ) }) }) describe('wallet_sendCalls', () => { test('default: sends calls and returns id', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) const connected = await connect(provider) await fund(connected) const result = await provider.request({ method: 'wallet_sendCalls', params: [{ calls: [transferCall] }], }) expect(result.id).toMatch(/^0x[0-9a-f]+$/) }) test('behavior: with sync capability returns id and receipt is available', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) const connected = await connect(provider) await fund(connected) const result = await provider.request({ method: 'wallet_sendCalls', params: [ { calls: [transferCall], capabilities: { sync: true }, }, ], }) expect(result.id).toMatch(/^0x[0-9a-f]+$/) expect(result.capabilities).toMatchInlineSnapshot(` { "sync": true, } `) expect(result.atomic).toMatchInlineSnapshot(`true`) expect(result.chainId).toMatch(/^0x[0-9a-f]+$/) expect(result.status).toMatchInlineSnapshot(`200`) expect(result.version).toMatchInlineSnapshot(`"2.0.0"`) expect(result.receipts?.length).toMatchInlineSnapshot(`1`) expect(result.receipts?.[0]?.status).toMatchInlineSnapshot(`"0x1"`) }) test('error: preserves adapter failure details for viem fallback handling', async () => { const failing = Adapter.define({}, () => ({ actions: { async createAccount() { return { accounts: [{ address: accounts[0]!.address }] } }, async loadAccounts() { return { accounts: [{ address: accounts[0]!.address }] } }, async sendTransaction() { throw new Error('plain send failure') }, async sendTransactionSync() { throw new Error('plain sync failure') }, async signPersonalMessage() { return '0x' }, async signTransaction() { return '0x' }, async signTypedData() { return '0x' }, }, })) const provider = Provider.create({ adapter: failing, chains: [chain], storage: Storage.memory(), }) await provider.request({ method: 'wallet_connect' }) const client = createWalletClient({ chain, transport: custom(provider) }) await expect( sendCalls(client, { account: accounts[0]!.address, calls: [transferCall], experimental_fallback: true, experimental_fallbackDelay: 0, }), ).rejects.toThrowErrorMatchingInlineSnapshot(` [TransactionExecutionError: An internal error was received. Request Arguments: from: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 Details: plain send failure Version: viem@2.49.2] `) }) }) describe('wallet_getCallsStatus', () => { test('default: returns encoded status for a sent call batch', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) const connected = await connect(provider) await fund(connected) const { id } = await provider.request({ method: 'wallet_sendCalls', params: [ { calls: [transferCall], capabilities: { sync: true }, }, ], }) const result = await provider.request({ method: 'wallet_getCallsStatus', params: [id], }) expect(result.atomic).toMatchInlineSnapshot(`true`) expect(result.chainId).toMatch(/^0x[0-9a-f]+$/) expect(result.status).toMatchInlineSnapshot(`200`) expect(result.version).toMatchInlineSnapshot(`"2.0.0"`) expect(result.receipts?.length).toMatchInlineSnapshot(`1`) expect(result.receipts?.[0]?.status).toMatchInlineSnapshot(`"0x1"`) }) test('error: throws for unsupported id format', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) await expect( provider.request({ method: 'wallet_getCallsStatus', params: ['0xdeadbeef'], }), ).rejects.toThrowErrorMatchingInlineSnapshot( `[RpcResponse.InternalError: \`id\` not supported]`, ) }) }) describe('wallet_transfer', () => { test('error: throws UnsupportedMethodError when adapter has no transfer action', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) await connect(provider) await expect( provider.request({ method: 'wallet_transfer', params: [ { amount: '1', editable: true, to: '0x0000000000000000000000000000000000000001', token: Addresses.pathUsd, }, ], }), ).rejects.toThrowErrorMatchingInlineSnapshot( `[Provider.UnsupportedMethodError: \`transfer\` not supported by adapter.]`, ) }) }) describe('wallet_swap', () => { test('error: throws UnsupportedMethodError when adapter has no swap action', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) await connect(provider) await expect( provider.request({ method: 'wallet_swap', params: [{ amount: '1', token: Addresses.pathUsd, type: 'sell' }], }), ).rejects.toThrowErrorMatchingInlineSnapshot( `[Provider.UnsupportedMethodError: \`swap\` not supported by adapter.]`, ) }) }) describe('wallet_getCapabilities', () => { test('default: returns atomic supported for all chains', async () => { const provider = Provider.create({ adapter: adapter() }) const result = await provider.request({ method: 'wallet_getCapabilities' }) expect(result).toMatchInlineSnapshot(` { "0x1079": { "accessKeys": { "status": "supported", }, "atomic": { "status": "supported", }, }, "0x7a56": { "accessKeys": { "status": "supported", }, "atomic": { "status": "supported", }, }, "0xa5bf": { "accessKeys": { "status": "supported", }, "atomic": { "status": "supported", }, }, } `) }) test('behavior: filters by chainIds', async () => { const provider = Provider.create({ adapter: adapter() }) const connected = await connect(provider) const result = await provider.request({ method: 'wallet_getCapabilities', params: [connected, [Hex.fromNumber(tempoModerato.id)]], }) expect(result).toMatchInlineSnapshot(` { "0xa5bf": { "accessKeys": { "status": "supported", }, "atomic": { "status": "supported", }, }, } `) }) test('behavior: returns empty object for unknown chainIds', async () => { const provider = Provider.create({ adapter: adapter() }) const connected = await connect(provider) const result = await provider.request({ method: 'wallet_getCapabilities', params: [connected, ['0x1']], }) expect(result).toMatchInlineSnapshot(`{}`) }) test('error: throws UnauthorizedError for unconnected address', async () => { const provider = Provider.create({ adapter: adapter() }) await expect( provider.request({ method: 'wallet_getCapabilities', params: ['0x0000000000000000000000000000000000000001'], }), ).rejects.toThrow(core_Provider.UnauthorizedError) }) test('behavior: succeeds with connected address', async () => { const provider = Provider.create({ adapter: adapter() }) const connected = await connect(provider) const result = await provider.request({ method: 'wallet_getCapabilities', params: [connected], }) expect(Object.keys(result).length).toMatchInlineSnapshot(`3`) expect(result[Hex.fromNumber(tempo.id)]!.atomic.status).toMatchInlineSnapshot(`"supported"`) }) test('behavior: includes feePayer when configured', async () => { const provider = Provider.create({ adapter: adapter(), feePayer: 'https://fee-payer.example.com', }) const result = await provider.request({ method: 'wallet_getCapabilities' }) expect(result[Hex.fromNumber(tempo.id)]!.feePayer).toMatchInlineSnapshot(` { "status": "supported", } `) }) test('behavior: excludes feePayer when not configured', async () => { const provider = Provider.create({ adapter: adapter() }) const result = await provider.request({ method: 'wallet_getCapabilities' }) expect(result[Hex.fromNumber(tempo.id)]!.feePayer).toBeUndefined() }) }) describe('wallet_getBalances', () => { test('error: throws when no tokens provided', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) await connect(provider) await expect( provider.request({ method: 'wallet_getBalances' }), ).rejects.toThrowErrorMatchingInlineSnapshot( `[RpcResponse.InvalidParamsError: \`tokens\` is required.]`, ) }) test('default: returns token balances with metadata', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) await connect(provider) const result = await provider.request({ method: 'wallet_getBalances', params: [{ tokens: ['0x20c0000000000000000000000000000000000001'] }], }) expect(result.length).toMatchInlineSnapshot(`1`) expect(result[0]!.address).toMatchInlineSnapshot( `"0x20c0000000000000000000000000000000000001"`, ) expect(typeof result[0]!.name).toMatch(/string/) expect(typeof result[0]!.symbol).toMatch(/string/) expect(typeof result[0]!.decimals).toMatchInlineSnapshot(`"number"`) expect(result[0]!.balance).toMatch(/^0x/) }) test('behavior: accepts explicit account param', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) const connected = await connect(provider) const result = await provider.request({ method: 'wallet_getBalances', params: [ { account: connected, tokens: ['0x20c0000000000000000000000000000000000001'], }, ], }) expect(result.length).toMatchInlineSnapshot(`1`) expect(result[0]!.balance).toMatch(/^0x/) }) test('error: throws DisconnectedError when no accounts connected', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) await expect( provider.request({ method: 'wallet_getBalances', params: [{ tokens: ['0x20c0000000000000000000000000000000000001'] }], }), ).rejects.toThrowErrorMatchingInlineSnapshot( `[Provider.DisconnectedError: No accounts connected.]`, ) }) }) describe('eth_signTypedData_v4', () => { const typedData = { domain: { name: 'Test', version: '1', chainId: 1 }, types: { Person: [ { name: 'name', type: 'string' }, { name: 'wallet', type: 'address' }, ], }, primaryType: 'Person' as const, message: { name: 'Bob', wallet: '0x0000000000000000000000000000000000000000' }, } test('default: signs typed data and returns signature', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) const connected = await connect(provider) const signature = await provider.request({ method: 'eth_signTypedData_v4', params: [connected, JSON.stringify(typedData)], }) expect(signature).toMatch(/^0x[0-9a-f]+$/) }) test('behavior: signature is verifiable on-chain', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) const client = provider.getClient() const connected = await connect(provider) const signature = await provider.request({ method: 'eth_signTypedData_v4', params: [connected, JSON.stringify(typedData)], }) const valid = await verifyTypedData(client, { address: connected, signature, ...typedData, }) expect(valid).toMatchInlineSnapshot(`true`) }) test('error: throws when not connected', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) await expect( provider.request({ method: 'eth_signTypedData_v4', params: ['0x0000000000000000000000000000000000000001', JSON.stringify(typedData)], }), ).rejects.toThrowErrorMatchingInlineSnapshot( `[Provider.DisconnectedError: No accounts connected.]`, ) }) }) describe('personal_sign', () => { test('default: signs a message and returns signature', async () => { const provider = Provider.create({ adapter: adapter(), chains: [chain] }) const connected = await connect(provider) const message = Hex.fromString('hello world') const signature = await provider.request({ method: 'personal_sign', params: [message, connected], }) expect(