accounts
Version:
Tempo Accounts SDK
1,477 lines (1,256 loc) • 94.6 kB
text/typescript
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(