accounts
Version:
Tempo Accounts SDK
818 lines (741 loc) • 28 kB
text/typescript
import { Hex } from 'ox'
import { custom } from 'viem'
import { createSiweMessage } from 'viem/siwe'
import { tempo, tempoModerato } from 'viem/tempo/chains'
import { afterEach, describe, expect, test, vi } from 'vp/test'
import { accounts, privateKeys } from '../../test/config.js'
import * as Adapter from './Adapter.js'
import * as Provider from './Provider.js'
import * as Storage from './Storage.js'
const address = '0x0000000000000000000000000000000000000001'
describe('eth_fillTransaction', () => {
test('behavior: forwards Tempo-formatted request capabilities', async () => {
const capabilities: unknown[] = []
const request = tempo.formatters.transactionRequest.format(
{
capabilities: { balanceDiffs: false, errors: true },
from: address,
to: address,
type: 'tempo',
},
'fillTransaction',
)
const provider = Provider.create({
chains: [tempo],
storage: Storage.memory(),
transports: {
[tempo.id]: custom(
{
async request(request) {
if (request.method === 'eth_fillTransaction')
capabilities.push(
(request.params?.[0] as { capabilities?: unknown } | undefined)?.capabilities,
)
throw new Error('capture complete')
},
},
{ retryCount: 0 },
),
},
})
await expect(
provider.request({
method: 'eth_fillTransaction',
params: [request as never],
}),
).rejects.toThrow('capture complete')
expect(capabilities).toMatchInlineSnapshot(`
[
{
"balanceDiffs": false,
"errors": true,
},
]
`)
})
})
describe('getMppxParameters', () => {
test('error: rejects unsupported chain IDs', () => {
const provider = Provider.create({ storage: Storage.memory() })
provider.store.setState({ accounts: [{ address }], activeAccount: 0 })
expect(() =>
provider.getMppxParameters().getClient({ chainId: 1 }),
).toThrowErrorMatchingInlineSnapshot(
`[Provider.UnsupportedChainIdError: Chain 1 not configured.]`,
)
})
test('behavior: returns account-scoped client without mutating cached client', () => {
const provider = Provider.create({ storage: Storage.memory() })
provider.store.setState({ accounts: [{ address }], activeAccount: 0 })
const client = provider.getClient()
const mppxClient = provider.getMppxParameters().getClient({})
expect(mppxClient).not.toBe(client)
expect((mppxClient as { account?: unknown }).account).toEqual({
address,
type: 'json-rpc',
})
expect((client as { account?: unknown }).account).toBeUndefined()
})
test('behavior: routes chain RPCs to the requested chain', async () => {
const provider = Provider.create({
chains: [tempo, tempoModerato],
storage: Storage.memory(),
transports: {
[tempoModerato.id]: custom({ request: async () => '0xa5bf' }),
},
})
provider.store.setState({ accounts: [{ address }], activeAccount: 0 })
const client = await provider.getMppxParameters().getClient({ chainId: tempoModerato.id })
expect(await client.request({ method: 'eth_chainId' })).toBe('0xa5bf')
})
test('behavior: resolves existing access key for connected account', async () => {
const payer = accounts[0]!
const provider = Provider.create({ storage: Storage.memory() })
const chainId = provider.store.getState().chainId
await provider.store.accessKeys.authorize({
account: payer,
chainId,
parameters: { expiry: 9999999999, privateKey: privateKeys[2] },
})
const key = provider.store.getState().accessKeys[0]!
provider.store.setState({ accounts: [{ address: payer.address }], activeAccount: 0 })
const resolved = await provider.getMppxParameters().resolveAccount({
account: { address: payer.address, type: 'json-rpc' },
chainId,
operation: { kind: 'authorizePaymentChannel' },
})
const resolvedKey = resolved as
| { accessKeyAddress: string; address: string; source: string }
| undefined
expect(resolvedKey?.accessKeyAddress.toLowerCase()).toBe(key.address.toLowerCase())
expect(resolvedKey?.address).toBe(payer.address)
expect(resolvedKey?.source).toBe('accessKey')
})
test('behavior: resolves requested access key for connected account', async () => {
const payer = accounts[0]!
const provider = Provider.create({ storage: Storage.memory() })
const chainId = provider.store.getState().chainId
const token = '0x0000000000000000000000000000000000000abc' as const
await provider.store.accessKeys.authorize({
account: payer,
chainId,
parameters: {
expiry: 9999999999,
privateKey: privateKeys[1],
scopes: [{ address: token, selector: 'transfer(address,uint256)' }],
},
})
await provider.store.accessKeys.authorize({
account: payer,
chainId,
parameters: { expiry: 9999999999, privateKey: privateKeys[2] },
})
const requested = provider.store.getState().accessKeys[1]!
provider.store.setState({ accounts: [{ address: payer.address }], activeAccount: 0 })
const resolved = await provider
.getMppxParameters({ accessKey: requested.address })
.resolveAccount({
account: { address: payer.address, type: 'json-rpc' },
chainId,
operation: { kind: 'authorizePaymentChannel' },
})
const resolvedKey = resolved as
| { accessKeyAddress: string; address: string; source: string }
| undefined
expect(resolvedKey?.accessKeyAddress.toLowerCase()).toBe(requested.address.toLowerCase())
expect(resolvedKey?.address).toBe(payer.address)
expect(resolvedKey?.source).toBe('accessKey')
})
test('behavior: resolves scoped access key for channel authority', async () => {
const payer = accounts[0]!
const provider = Provider.create({ storage: Storage.memory() })
const chainId = provider.store.getState().chainId
const token = '0x0000000000000000000000000000000000000abc' as const
await provider.store.accessKeys.authorize({
account: payer,
chainId,
parameters: {
expiry: 9999999999,
privateKey: privateKeys[1],
scopes: [{ address: token, selector: 'transfer(address,uint256)' }],
},
})
const key = provider.store.getState().accessKeys[0]!
provider.store.setState({ accounts: [{ address: payer.address }], activeAccount: 0 })
const resolved = await provider.getMppxParameters().resolveAccount({
account: { address: payer.address, type: 'json-rpc' },
chainId,
operation: { kind: 'authorizePaymentChannel', authority: key.address },
})
const resolvedKey = resolved as
| { accessKeyAddress: string; address: string; source: string }
| undefined
expect(resolvedKey?.accessKeyAddress.toLowerCase()).toBe(key.address.toLowerCase())
expect(resolvedKey?.address).toBe(payer.address)
expect(resolvedKey?.source).toBe('accessKey')
})
test('error: rejects requested access key that cannot sign for account', async () => {
const payer = accounts[0]!
const provider = Provider.create({ storage: Storage.memory() })
const chainId = provider.store.getState().chainId
provider.store.setState({ accounts: [{ address: payer.address }], activeAccount: 0 })
await expect(
provider.getMppxParameters({ accessKey: accounts[1]!.address }).resolveAccount({
account: { address: payer.address, type: 'json-rpc' },
chainId,
operation: { kind: 'authorizePaymentChannel' },
}),
).rejects.toThrowError(`Access key "${accounts[1]!.address}" cannot sign`)
})
test('error: rejects requested access key that cannot satisfy channel authority', async () => {
const payer = accounts[0]!
const provider = Provider.create({ storage: Storage.memory() })
const chainId = provider.store.getState().chainId
await provider.store.accessKeys.authorize({
account: payer,
chainId,
parameters: { expiry: 9999999999, privateKey: privateKeys[1] },
})
const key = provider.store.getState().accessKeys[0]!
provider.store.setState({ accounts: [{ address: payer.address }], activeAccount: 0 })
for (const authority of [
accounts[2]!.address,
payer.address,
'0x0000000000000000000000000000000000000000',
] as const)
await expect(
provider.getMppxParameters({ accessKey: key.address }).resolveAccount({
account: { address: payer.address, type: 'json-rpc' },
chainId,
operation: { kind: 'authorizePaymentChannel', authority },
}),
).rejects.toThrowError(
`Access key "${key.address}" cannot satisfy channel authority "${authority}".`,
)
})
test('error: rejects requested access key when transaction calls do not match scopes', async () => {
const payer = accounts[0]!
const provider = Provider.create({ storage: Storage.memory() })
const chainId = provider.store.getState().chainId
const token = '0x0000000000000000000000000000000000000abc' as const
await provider.store.accessKeys.authorize({
account: payer,
chainId,
parameters: {
expiry: 9999999999,
privateKey: privateKeys[2],
scopes: [{ address: token, selector: 'transfer(address,uint256)' }],
},
})
const key = provider.store.getState().accessKeys[0]!
provider.store.setState({ accounts: [{ address: payer.address }], activeAccount: 0 })
await expect(
provider.getMppxParameters({ accessKey: key.address }).resolveAccount({
account: { address: payer.address, type: 'json-rpc' },
chainId,
operation: { kind: 'executeCalls' },
}),
).rejects.toThrowError(
`Access key "${key.address}" cannot be selected for executeCalls without transaction call details.`,
)
await expect(
provider.getMppxParameters({ accessKey: key.address }).resolveAccount({
account: { address: payer.address, type: 'json-rpc' },
chainId,
operation: {
kind: 'executeCalls',
calls: [{ to: '0x0000000000000000000000000000000000000def', data: '0xdeadbeef' }],
},
}),
).rejects.toThrowError(`Access key "${key.address}" cannot sign`)
})
test('error: does not resolve access keys for disconnected accounts', async () => {
const payer = accounts[0]!
const active = accounts[1]!
const provider = Provider.create({ storage: Storage.memory() })
await provider.store.accessKeys.authorize({
account: payer,
chainId: provider.store.getState().chainId,
parameters: { expiry: 123, privateKey: privateKeys[2] },
})
provider.store.setState({ accounts: [{ address: active.address }], activeAccount: 0 })
await expect(
provider.getMppxParameters().resolveAccount({
account: { address: payer.address, type: 'json-rpc' },
chainId: provider.store.getState().chainId,
operation: { kind: 'authorizePaymentChannel' },
}),
).rejects.toThrowError(`Account "${payer.address}" not found.`)
})
})
describe('wallet_connect', () => {
test('behavior: forwards allowed credential IDs to the adapter', async () => {
const loadAccounts = vi.fn(async (_parameters?: Adapter.loadAccounts.Parameters) => ({
accounts: [{ address }] as const,
}))
const adapter = Adapter.define({ name: 'Test Wallet', rdns: 'com.example.test' }, () => ({
actions: {
async createAccount() {
return { accounts: [{ address }] }
},
loadAccounts,
},
}))
const provider = Provider.create({ adapter, storage: Storage.memory() })
await provider.request({
method: 'wallet_connect',
params: [{ capabilities: { credentialId: ['cred-1', 'cred-2'] } }],
})
expect(loadAccounts.mock.calls[0]?.[0]).toMatchInlineSnapshot(`
{
"authorizeAccessKey": undefined,
"credentialId": [
"cred-1",
"cred-2",
],
"digest": undefined,
"selectAccount": undefined,
}
`)
})
test('behavior: validates auth before preparing access key material', async () => {
const createKey = vi.fn(() => {
throw new Error('createKey called')
})
const adapter = Adapter.define({ name: 'Test Wallet', rdns: 'com.example.test' }, () => ({
actions: {
async createAccount() {
return { accounts: [{ address }] }
},
async loadAccounts() {
return { accounts: [{ address }] }
},
},
}))
const provider = Provider.create({
accessKey: {
keystores: {
p256: {
createKey,
toAccount() {
throw new Error('unused')
},
},
},
},
adapter,
storage: Storage.memory(),
})
await expect(
provider.request({
method: 'wallet_connect',
params: [
{
capabilities: {
auth: { returnToken: true },
authorizeAccessKey: { expiry: 123 },
},
},
],
}),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[RpcResponse.InvalidParamsError: \`auth\` capability must include either \`url\` or an explicit \`challenge\` endpoint.]`,
)
expect(createKey.mock.calls).toMatchInlineSnapshot(`[]`)
})
test('behavior: forwards identity.email + identity.idToken onto the authenticated account', async () => {
const adapter = Adapter.define({ name: 'Test Wallet', rdns: 'com.example.test' }, () => ({
actions: {
async createAccount() {
return { accounts: [{ address }] }
},
async loadAccounts() {
return {
accounts: [{ address }],
identity: { email: 'alice@example.com', idToken: 'eyJhbG.payload.sig' },
}
},
},
}))
const provider = Provider.create({ adapter, storage: Storage.memory() })
const result = await provider.request({
method: 'wallet_connect',
params: [{ capabilities: { identity: { email: true } } }],
})
expect(result.accounts[0]?.capabilities?.identity).toMatchInlineSnapshot(`
{
"email": "alice@example.com",
"idToken": "eyJhbG.payload.sig",
}
`)
})
describe('identity + auth', () => {
afterEach(() => vi.unstubAllGlobals())
test('uses the default credential mode when omitted', async () => {
let request: RequestInit | undefined
vi.stubGlobal('fetch', async (_input: string | URL, init?: RequestInit) => {
request = init
return Response.json({
idToken: 'eyJhbGciOiJub25lIn0.eyJlbWFpbCI6ImFsaWNlQGV4YW1wbGUuY29tIn0.sig',
})
})
const adapter = Adapter.define({ name: 'Test Wallet', rdns: 'com.example.test' }, () => ({
actions: {
async createAccount() {
return { accounts: [{ address }] }
},
async loadAccounts() {
return { accounts: [{ address }] }
},
},
}))
const provider = Provider.create({
adapter,
identity: {
audience: 'https://console.example.com',
issuer: 'https://wallet.example.com/oidc',
},
storage: Storage.memory(),
})
await provider.request({
method: 'wallet_connect',
params: [{ capabilities: { identity: { email: true } } }],
})
expect(request?.credentials).toMatchInlineSnapshot(`undefined`)
})
test('includes configured credentials when minting identity tokens', async () => {
let request: RequestInit | undefined
vi.stubGlobal('fetch', async (_input: string | URL, init?: RequestInit) => {
request = init
return Response.json({
idToken: 'eyJhbGciOiJub25lIn0.eyJlbWFpbCI6ImFsaWNlQGV4YW1wbGUuY29tIn0.sig',
})
})
const adapter = Adapter.define({ name: 'Test Wallet', rdns: 'com.example.test' }, () => ({
actions: {
async createAccount() {
return { accounts: [{ address }] }
},
async loadAccounts() {
return { accounts: [{ address }] }
},
},
}))
const provider = Provider.create({
adapter,
identity: {
audience: 'https://console.example.com',
credentials: 'include',
issuer: 'https://wallet.example.com/oidc',
},
storage: Storage.memory(),
})
await provider.request({
method: 'wallet_connect',
params: [{ capabilities: { identity: { email: true } } }],
})
expect(request?.credentials).toMatchInlineSnapshot(`"include"`)
})
test('behavior: forwards identity.idToken into the auth verify request body', async () => {
// Capture the body POSTed to the verify endpoint. The challenge endpoint
// echoes the SDK-sent chainId into a valid SIWE message so the SDK's
// challenge validation (domain/uri/chainId/nonce) passes.
let verifyBody: Record<string, unknown> | undefined
vi.stubGlobal('fetch', async (input: string | URL, init?: RequestInit) => {
const url = String(input)
if (url.endsWith('/auth/challenge')) {
const { chainId } = JSON.parse(String(init?.body)) as { chainId: number }
const message = [
'app.example.com wants you to sign in with your Ethereum account:',
address,
'',
'',
'URI: https://app.example.com',
'Version: 1',
`Chain ID: ${chainId}`,
'Nonce: deadbeef00',
'Issued At: 2025-01-01T00:00:00Z',
].join('\n')
return new Response(JSON.stringify({ message }), { status: 200 })
}
if (url === 'https://app.example.com/auth') {
verifyBody = JSON.parse(String(init?.body))
return new Response(JSON.stringify({ token: 'session' }), { status: 200 })
}
throw new Error(`unexpected fetch: ${url}`)
})
const adapter = Adapter.define({ name: 'Test Wallet', rdns: 'com.example.test' }, () => ({
actions: {
async createAccount() {
return { accounts: [{ address }] }
},
async loadAccounts() {
// Wallet host minted the token during the ceremony, reusing the
// SIWE nonce; it surfaces on the connect result's identity.
return {
accounts: [{ address }],
identity: { email: 'alice@example.com', idToken: 'eyJhbG.payload.sig' },
signature: '0xabc',
}
},
},
}))
const provider = Provider.create({ adapter, storage: Storage.memory() })
await provider.request({
method: 'wallet_connect',
params: [
{
capabilities: {
auth: { url: 'https://app.example.com/auth' },
identity: { email: true },
},
},
],
})
expect(verifyBody?.idToken).toBe('eyJhbG.payload.sig')
})
})
describe('auth resources', () => {
afterEach(() => vi.unstubAllGlobals())
function authAdapter() {
return Adapter.define({ name: 'Test Wallet', rdns: 'com.example.test' }, () => ({
actions: {
async createAccount() {
return { accounts: [{ address }], signature: '0xabc' }
},
async loadAccounts() {
return { accounts: [{ address }], signature: '0xabc' }
},
},
}))
}
function message(options: {
chainId: number
resources?: readonly string[] | undefined
statement?: string | undefined
}) {
const { chainId, resources, statement } = options
return createSiweMessage({
address,
chainId,
domain: 'app.example.com',
uri: 'https://app.example.com',
version: '1',
nonce: 'deadbeef00',
issuedAt: new Date('2025-01-01T00:00:00Z'),
...(resources ? { resources: [...resources] } : {}),
...(statement ? { statement } : {}),
})
}
function stubAuthFetch(options: {
resources?: readonly string[] | undefined
statement?: string | undefined
onChallenge?: ((body: Record<string, unknown>) => void) | undefined
verify?: Record<string, unknown> | undefined
}) {
vi.stubGlobal('fetch', async (input: string | URL, init?: RequestInit) => {
const url = String(input)
if (url.endsWith('/auth/challenge')) {
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
options.onChallenge?.(body)
const { chainId } = body as { chainId: number }
return new Response(
JSON.stringify({
message: message({
chainId,
resources: options.resources,
statement: options.statement,
}),
}),
{ status: 200 },
)
}
if (url === 'https://app.example.com/auth')
return new Response(JSON.stringify(options.verify ?? { token: 'session' }), {
status: 200,
})
throw new Error(`unexpected fetch: ${url}`)
})
}
function connect(
provider: ReturnType<typeof Provider.create>,
options: {
chainId?: `0x${string}` | undefined
resources: readonly string[]
},
) {
const { chainId, resources } = options
return provider.request({
method: 'wallet_connect',
params: [
{
...(chainId ? { chainId } : {}),
capabilities: {
auth: {
url: 'https://app.example.com/auth',
resources,
},
},
},
],
})
}
test('behavior: sends resources and accepts a server-provided statement', async () => {
const resources = ['urn:tempo:api-signing-key:test', 'https://api.example.com/signing-keys/1']
let challengeBody: Record<string, unknown> | undefined
stubAuthFetch({
resources,
statement: 'Authorize a scoped API signing key.',
onChallenge(body) {
challengeBody = body
},
})
const provider = Provider.create({ adapter: authAdapter(), storage: Storage.memory() })
await connect(provider, {
chainId: Hex.fromNumber(1),
resources,
})
expect(challengeBody).toMatchInlineSnapshot(`
{
"chainId": 1,
"resources": [
"urn:tempo:api-signing-key:test",
"https://api.example.com/signing-keys/1",
],
}
`)
})
test('error: rejects when the challenge omits requested resources', async () => {
const resources = ['https://api.example.com/signing-keys/1']
stubAuthFetch({})
const provider = Provider.create({ adapter: authAdapter(), storage: Storage.memory() })
await expect(
connect(provider, {
resources,
}),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[RpcResponse.InvalidParamsError: Server Authentication challenge endpoint \`https://app.example.com/auth/challenge\` did not echo the requested SIWE resources.]`,
)
})
test('error: rejects when the challenge changes requested resources', async () => {
const resources = ['https://api.example.com/signing-keys/1']
stubAuthFetch({
resources: ['https://api.example.com/signing-keys/2'],
})
const provider = Provider.create({ adapter: authAdapter(), storage: Storage.memory() })
await expect(
connect(provider, {
resources,
}),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[RpcResponse.InvalidParamsError: Server Authentication challenge endpoint \`https://app.example.com/auth/challenge\` did not echo the requested SIWE resources.]`,
)
})
test('error: rejects when forwarded auth message omits requested resources', async () => {
const resources = ['https://api.example.com/signing-keys/1']
const signed = message({ chainId: 1 })
let verifyCalled = false
vi.stubGlobal('fetch', async (input: string | URL) => {
const url = String(input)
if (url === 'https://app.example.com/auth') {
verifyCalled = true
return new Response(JSON.stringify({ token: 'session' }), { status: 200 })
}
throw new Error(`unexpected fetch: ${url}`)
})
const adapter = Adapter.define({ name: 'Test Wallet', rdns: 'com.example.test' }, () => ({
actions: {
async createAccount() {
return {
accounts: [{ address }],
personalSign: { message: signed },
signature: '0xabc',
}
},
async loadAccounts() {
return {
accounts: [{ address }],
personalSign: { message: signed },
signature: '0xabc',
}
},
},
forwardsAuth: true,
}))
const provider = Provider.create({ adapter, storage: Storage.memory() })
await expect(
connect(provider, {
chainId: Hex.fromNumber(1),
resources,
}),
).rejects.toThrowErrorMatchingInlineSnapshot(
`[RpcResponse.InvalidParamsError: Server Authentication challenge endpoint \`https://app.example.com/auth/challenge\` did not echo the requested SIWE resources.]`,
)
expect(verifyCalled).toMatchInlineSnapshot(`false`)
})
test('behavior: preserves extra verify JSON under capabilities.auth', async () => {
const resources = ['https://api.example.com/signing-keys/1']
stubAuthFetch({
resources,
verify: {
apiSigningKey: { id: 'key_1', scope: 'read' },
token: 'session',
},
})
const provider = Provider.create({ adapter: authAdapter(), storage: Storage.memory() })
const result = await connect(provider, {
resources,
})
expect(result.accounts[0]?.capabilities.auth).toMatchInlineSnapshot(`
{
"apiSigningKey": {
"id": "key_1",
"scope": "read",
},
"token": "session",
}
`)
})
})
})
describe('adapter actions', () => {
test('behavior: explicit adapter action overrides provider default', async () => {
const getAccount = vi.fn(() => {
throw new Error('getAccount called')
})
const signPersonalMessage = vi.fn(
async (_parameters: Adapter.signPersonalMessage.Parameters) => {
return '0x1234' as const
},
)
const adapter = Adapter.define({ name: 'Test Wallet', rdns: 'com.example.test' }, () => ({
actions: {
async createAccount() {
return { accounts: [{ address }] }
},
async loadAccounts() {
return { accounts: [{ address }] }
},
signPersonalMessage,
},
getAccount: getAccount as never,
}))
const provider = Provider.create({ adapter, storage: Storage.memory() })
provider.store.setState({ accounts: [{ address }], activeAccount: 0 })
await expect(
provider.request({
method: 'personal_sign',
params: ['0x68656c6c6f', address],
}),
).resolves.toMatchInlineSnapshot(`"0x1234"`)
expect(getAccount.mock.calls).toMatchInlineSnapshot(`[]`)
expect(signPersonalMessage.mock.calls.map(([parameters]) => parameters)).toMatchInlineSnapshot(`
[
{
"address": "0x0000000000000000000000000000000000000001",
"data": "0x68656c6c6f",
},
]
`)
})
})