UNPKG

accounts

Version:

Tempo Accounts SDK

818 lines (741 loc) 28 kB
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", }, ] `) }) })