UNPKG

accounts

Version:

Tempo Accounts SDK

1,156 lines (1,030 loc) 36.8 kB
import { Address, Hex, PublicKey, WebCryptoP256 } from 'ox' import { KeyAuthorization } from 'ox/tempo' import { BaseError, encodeErrorResult, encodeFunctionResult } from 'viem' import { Abis, Account as TempoAccount } from 'viem/tempo' import { describe, expect, test } from 'vp/test' import { accounts, privateKeys } from '../../test/config.js' import { testKeystore } from '../../test/keystore.js' import * as AccessKey from './AccessKey.js' import * as AccessKeyTransaction from './internal/AccessKeyTransaction.js' import * as Keystore from './Keystore.js' import * as Storage from './Storage.js' import * as Store from './Store.js' function createStore() { return Store.create({ chainId: 1 }) } const rootAddress = accounts[0]!.address function createKeyAuthorization( address: `0x${string}`, options: { chainId?: bigint | undefined expiry?: number | undefined keyType?: KeyAuthorization.KeyAuthorization['type'] | undefined limits?: { token: `0x${string}`; limit: bigint; period?: number | undefined }[] | undefined scopes?: KeyAuthorization.Scope[] | undefined } = {}, ) { return KeyAuthorization.from( { address, chainId: options.chainId ?? 1n, expiry: options.expiry, limits: options.limits, scopes: options.scopes, type: options.keyType ?? 'p256', }, { signature: `0x${'00'.repeat(65)}` }, ) } function createRevert(errorName: string) { return Object.assign(new Error('reverted'), { data: encodeErrorResult({ abi: Abis.all, errorName, args: [] } as never), }) } function createMetadataClient( accessKey: Hex.Hex, options: { isRevoked?: boolean | undefined; keyId?: Hex.Hex | undefined } = {}, ) { return { call: async () => ({ data: encodeFunctionResult({ abi: Abis.accountKeychain, functionName: 'getKey', result: { enforceLimits: false, expiry: 0n, isRevoked: options.isRevoked ?? false, keyId: options.keyId ?? accessKey, signatureType: 1, }, } as never), }), } } function createMissingClient() { return { call: async () => { throw createRevert('KeyNotFound') }, } } function addAuthorization(options: { address: `0x${string}` keyAuthorization: KeyAuthorization.Signed keyPair?: Awaited<ReturnType<typeof WebCryptoP256.createKeyPair>> | undefined privateKey?: Hex.Hex | undefined store: Store.Store }) { const { address, keyAuthorization, keyPair, privateKey, store } = options store.accessKeys.add({ account: address, authorization: keyAuthorization, ...(keyPair ? { keyPair } : {}), ...(privateKey ? { privateKey } : {}), }) } function removeStoredAuthorization(options: { accessKey: `0x${string}` address?: `0x${string}` | undefined chainId?: number | undefined store: Store.Store }) { const { accessKey, store } = options const account = options.address ?? rootAddress const chainId = options.chainId ?? 1 store.setState((state) => ({ accessKeys: state.accessKeys.map((key) => key.address.toLowerCase() === accessKey.toLowerCase() && key.access.toLowerCase() === account.toLowerCase() && key.chainId === chainId ? { ...key, keyAuthorization: undefined } : key, ), })) } function signStub() { return { ...accounts[0]!, sign: async () => `0x${'11'.repeat(32)}${'22'.repeat(32)}1b` as const, } as TempoAccount.Account } function expiry() { return Math.floor(Date.now() / 1000) + 3600 } describe('add', () => { test('default: saves a signed authorization', async () => { const store = createStore() const keyPair = await WebCryptoP256.createKeyPair() const accessKey = TempoAccount.fromWebCryptoP256(keyPair) const expiry = Math.floor(Date.now() / 1000) + 3600 const limits = [{ token: '0x20c0000000000000000000000000000000000001' as const, limit: 1000n }] const keyAuthorization = createKeyAuthorization(accessKey.address, { expiry, limits }) addAuthorization({ address: rootAddress, keyAuthorization, store, }) const { accessKeys } = store.getState() expect(accessKeys.length).toMatchInlineSnapshot(`1`) expect(accessKeys[0]!.address).toBe(accessKey.address) expect(accessKeys[0]!.access).toBe(rootAddress) expect(accessKeys[0]!.chainId).toMatchInlineSnapshot(`1`) expect(accessKeys[0]!.expiry).toBe(expiry) expect(accessKeys[0]!.keyType).toMatchInlineSnapshot(`"p256"`) expect(accessKeys[0]!.keyAuthorization).toBe(keyAuthorization) expect(store.getState().accessKeys[0]!.limits).toMatchInlineSnapshot(` [ { "limit": 1000n, "token": "0x20c0000000000000000000000000000000000001", }, ] `) }) test('behavior: saves locally signable material', async () => { const store = createStore() const keyAuthorization = createKeyAuthorization(accounts[1]!.address, { keyType: 'secp256k1', }) store.accessKeys.add({ account: rootAddress, authorization: keyAuthorization, privateKey: privateKeys[1], }) const account = await store.accessKeys.get({ accessKey: accounts[1]!.address, account: rootAddress, chainId: 1, }) expect(account?.accessKeyAddress).toMatchInlineSnapshot( `"${accounts[1]!.address.toLowerCase()}"`, ) }) test('behavior: skips locally signable material when credential persistence is disabled', async () => { const storage = Storage.memory() const store = Store.create({ chainId: 1, persistCredentials: false, storage, }) const keyAuthorization = createKeyAuthorization(accounts[1]!.address, { keyType: 'secp256k1', }) store.accessKeys.add({ account: rootAddress, authorization: keyAuthorization, privateKey: privateKeys[1], }) const store2 = Store.create({ chainId: 1, storage }) await Store.waitForHydration(store2) await expect( store2.accessKeys.get({ accessKey: accounts[1]!.address, account: rootAddress, chainId: 1, }), ).resolves.toMatchInlineSnapshot(`undefined`) }) }) describe('create invalidation', () => { async function setup(options: { other?: boolean | undefined } = {}) { const store = createStore() const keyPair_other = await WebCryptoP256.createKeyPair() const account_other = TempoAccount.fromWebCryptoP256(keyPair_other, { access: rootAddress }) if (options.other) await addAuthorization({ address: rootAddress, keyAuthorization: createKeyAuthorization(account_other.accessKeyAddress), keyPair: keyPair_other, store, }) const keyPair = await WebCryptoP256.createKeyPair() const account = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress }) await addAuthorization({ address: rootAddress, keyAuthorization: createKeyAuthorization(account.accessKeyAddress), keyPair, store, }) return { account_other, store } } test('behavior: removes selected access key for stale-key errors', async () => { const { account_other, store } = await setup({ other: true }) const transaction = await AccessKeyTransaction.create({ address: rootAddress, chainId: 1, client: { request: async () => { throw createRevert('KeyNotFound') }, } as never, store, }) await expect(transaction?.fill({ chainId: 1, from: rootAddress })).rejects.toThrowError() expect(store.getState().accessKeys.length).toMatchInlineSnapshot(`1`) expect( store.getState().accessKeys.some((key) => key.address === account_other.accessKeyAddress), ).toMatchInlineSnapshot(`true`) }) test('behavior: preserves access key for recoverable execution errors', async () => { const { store } = await setup() const transaction = await AccessKeyTransaction.create({ address: rootAddress, chainId: 1, client: { request: async () => { throw createRevert('SpendingLimitExceeded') }, } as never, store, }) await expect(transaction?.fill({ chainId: 1, from: rootAddress })).rejects.toThrowError() expect(store.getState().accessKeys.length).toMatchInlineSnapshot(`1`) }) test('behavior: preserves access key for unknown errors', async () => { const { store } = await setup() const transaction = await AccessKeyTransaction.create({ address: rootAddress, chainId: 1, client: { request: async () => { throw new Error('network failed') }, } as never, store, }) await expect(transaction?.fill({ chainId: 1, from: rootAddress })).rejects.toThrowError() expect(store.getState().accessKeys.length).toMatchInlineSnapshot(`1`) }) }) describe('isUnavailableError', () => { test('default: recognizes unavailable key revert errors', () => { expect(AccessKey.isUnavailableError(createRevert('KeyNotFound'))).toMatchInlineSnapshot(`true`) expect(AccessKey.isUnavailableError(createRevert('KeyAlreadyRevoked'))).toMatchInlineSnapshot( `true`, ) expect( AccessKey.isUnavailableError(createRevert('SpendingLimitExceeded')), ).toMatchInlineSnapshot(`false`) }) test('behavior: recognizes nested viem error data', () => { const error = new BaseError('revoke failed', { cause: Object.assign(new Error('execution reverted'), { data: { errorName: 'KeyAlreadyRevoked' }, }), }) expect(AccessKey.isUnavailableError(error)).toMatchInlineSnapshot(`true`) }) }) describe('prepareAuthorization', () => { test('default: prepares generated p256 key authorization', async () => { const result = await AccessKey.prepareAuthorization({ chainId: 1, expiry: 123 }) expect(result.keyAuthorization.address).toMatch(/^0x[0-9a-f]{40}$/i) expect(result.keyAuthorization.chainId).toMatchInlineSnapshot(`1n`) expect(result.keyAuthorization.expiry).toMatchInlineSnapshot(`123`) expect(result.keyAuthorization.type).toMatchInlineSnapshot(`"p256"`) expect(result.key).toBeDefined() }) test('behavior: unspecified key type defaults to p256 even when secp256k1 is configured', async () => { const result = await AccessKey.prepareAuthorization({ chainId: 1, expiry: 123, keystores: { p256: Keystore.p256(), secp256k1: Keystore.secp256k1() }, }) expect(result.keyAuthorization.type).toMatchInlineSnapshot(`"p256"`) expect(result.key?.handle).toMatchObject({ kind: 'p256' }) }) test('behavior: secp256k1 is used only when explicitly requested', async () => { const result = await AccessKey.prepareAuthorization({ chainId: 1, expiry: 123, keyType: 'secp256k1', keystores: { p256: Keystore.p256(), secp256k1: Keystore.secp256k1() }, }) expect(result.keyAuthorization.type).toMatchInlineSnapshot(`"secp256k1"`) expect(result.key?.handle).toMatchObject({ kind: 'secp256k1' }) }) test('error: rejects secp256k1 authorization without external key material', async () => { await expect( AccessKey.prepareAuthorization({ chainId: 1, expiry: 123, keyType: 'secp256k1' }), ).rejects.toThrowErrorMatchingInlineSnapshot( `[RpcResponse.InvalidParamsError: \`keyType: "secp256k1"\` requires externally generated key material; provide \`publicKey\` or \`address\`.]`, ) }) test('error: rejects webAuthn authorization without external key material', async () => { await expect( AccessKey.prepareAuthorization({ chainId: 1, expiry: 123, keyType: 'webAuthn' }), ).rejects.toThrowErrorMatchingInlineSnapshot( `[RpcResponse.InvalidParamsError: \`keyType: "webAuthn"\` requires externally generated key material; provide \`publicKey\` or \`address\`.]`, ) }) test('behavior: prepares external key authorization from address', async () => { const result = await AccessKey.prepareAuthorization({ address: accounts[1]!.address, chainId: 123n, expiry: 456, keyType: 'webAuthn', limits: [ { limit: 1000n, period: 60, token: '0x20c0000000000000000000000000000000000001', }, ], scopes: [ { address: '0x0000000000000000000000000000000000000abc', recipients: ['0x0000000000000000000000000000000000000def'], selector: 'transfer(address,uint256)', }, ], }) expect(result.key).toBeUndefined() expect(result.keyAuthorization).toMatchInlineSnapshot(` { "address": "${accounts[1]!.address}", "chainId": 123n, "expiry": 456, "limits": [ { "limit": 1000n, "period": 60, "token": "0x20c0000000000000000000000000000000000001", }, ], "scopes": [ { "address": "0x0000000000000000000000000000000000000abc", "recipients": [ "0x0000000000000000000000000000000000000def", ], "selector": "0xa9059cbb", }, ], "type": "webAuthn", } `) }) test('behavior: prepares external key authorization from public key', async () => { const keyPair = await WebCryptoP256.createKeyPair() const account = TempoAccount.fromWebCryptoP256(keyPair) const result = await AccessKey.prepareAuthorization({ chainId: 123n, expiry: 456, keyType: 'p256', publicKey: account.publicKey, }) expect(result.key).toBeUndefined() expect(result.keyAuthorization).toMatchInlineSnapshot(` { "address": "${account.address.toLowerCase()}", "chainId": 123n, "expiry": 456, "limits": undefined, "scopes": undefined, "type": "p256", } `) }) test('behavior: prepares external secp256k1 authorization from public key', async () => { const result = await AccessKey.prepareAuthorization({ chainId: 123n, expiry: 456, keyType: 'secp256k1', publicKey: accounts[1]!.publicKey, }) expect(result.key).toBeUndefined() expect(result.keyAuthorization).toMatchInlineSnapshot(` { "address": "${accounts[1]!.address.toLowerCase()}", "chainId": 123n, "expiry": 456, "limits": undefined, "scopes": undefined, "type": "secp256k1", } `) }) test('behavior: defaults external key type to secp256k1', async () => { const result = await AccessKey.prepareAuthorization({ address: accounts[1]!.address, chainId: 1, expiry: 123, }) expect(result.keyAuthorization.type).toMatchInlineSnapshot(`"secp256k1"`) }) }) describe('authorize', () => { test('default: prepares, signs, and saves authorization', async () => { const store = createStore() const digests: Hex.Hex[] = [] const signature = `0x${'11'.repeat(32)}${'22'.repeat(32)}1b` as const const account = { ...accounts[0]!, sign: async (parameters: { hash: Hex.Hex }) => { digests.push(parameters.hash) return signature }, } as TempoAccount.Account const result = await store.accessKeys.authorize({ account, chainId: 1, parameters: { address: accounts[1]!.address, expiry: 123, }, }) expect(digests).toMatchInlineSnapshot(` [ "0xea47721547363fc82a5dca62b4544e4718d861b3df10bfac65d30102594b5c26", ] `) expect(result).toMatchInlineSnapshot(` { "chainId": "0x1", "expiry": "0x7b", "keyId": "0x8C8d35429F74ec245F8Ef2f4Fd1e551cFF97d650", "keyType": "secp256k1", "limits": undefined, "signature": { "r": "0x1111111111111111111111111111111111111111111111111111111111111111", "s": "0x2222222222222222222222222222222222222222222222222222222222222222", "type": "secp256k1", "yParity": "0x0", }, } `) expect(store.getState().accessKeys.map(({ keyAuthorization: _, ...accessKey }) => accessKey)) .toMatchInlineSnapshot(` [ { "access": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", "address": "0x8C8d35429F74ec245F8Ef2f4Fd1e551cFF97d650", "chainId": 1, "expiry": 123, "keyType": "secp256k1", "limits": undefined, "scopes": undefined, }, ] `) }) test('behavior: provisions a handle-backed record via the keystore', async () => { const keystore = testKeystore() const store = Store.create({ chainId: 1, keystores: { p256: keystore } }) await store.accessKeys.authorize({ account: signStub(), chainId: 1, parameters: { expiry: expiry() }, }) const record = store.getState().accessKeys[0]! expect(record.keyType).toBe('p256') expect(record.handle).toMatchObject({ kind: 'test' }) expect(record.publicKey).toMatch(/^0x[0-9a-f]+$/i) expect(record.address).toBe(Address.fromPublicKey(PublicKey.fromHex(record.publicKey!))) expect(record.privateKey).toBeUndefined() expect(record.keyPair).toBeUndefined() const hydrated = await store.accessKeys.get({ accessKey: record.address, account: rootAddress, chainId: 1, }) expect(hydrated?.accessKeyAddress).toBe(record.address.toLowerCase()) expect(keystore.stats.toAccountCalls).toBe(1) }) test('behavior: the built-in keystore provisions handle-backed records', async () => { const store = createStore() await store.accessKeys.authorize({ account: signStub(), chainId: 1, parameters: { expiry: expiry() }, }) const record = store.getState().accessKeys[0]! expect(record.keyType).toBe('p256') expect(record.handle).toMatchObject({ kind: 'webcrypto-p256' }) expect(record.privateKey).toBeUndefined() expect(record.keyPair).toBeUndefined() const hydrated = await store.accessKeys.get({ accessKey: record.address, account: rootAddress, chainId: 1, }) expect(hydrated?.accessKeyAddress).toBe(record.address.toLowerCase()) }) test('behavior: saves provided private key material', async () => { const store = createStore() const accessKey = TempoAccount.fromSecp256k1(privateKeys[1]) const account = { ...accounts[0]!, sign: async () => `0x${'11'.repeat(32)}${'22'.repeat(32)}1b` as const, } as TempoAccount.Account await store.accessKeys.authorize({ account, chainId: 1, parameters: { expiry: 123, keyType: 'secp256k1', privateKey: privateKeys[1], }, }) expect(store.getState().accessKeys.map(({ keyAuthorization: _, ...accessKey }) => accessKey)) .toMatchInlineSnapshot(` [ { "access": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", "address": "${accessKey.address}", "chainId": 1, "expiry": 123, "keyType": "secp256k1", "limits": undefined, "privateKey": "${privateKeys[1]}", "scopes": undefined, }, ] `) }) }) describe('select', () => { async function setup() { const store = createStore() const keyPair = await WebCryptoP256.createKeyPair() const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress }) const keyAuthorization = createKeyAuthorization(accessKey.accessKeyAddress) await addAuthorization({ address: rootAddress, keyAuthorization, keyPair, store, }) return { accessKey, keyAuthorization, store } } test('behavior: skips access keys for another root address', async () => { const store = createStore() const keyPair = await WebCryptoP256.createKeyPair() const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: accounts[1]!.address }) await addAuthorization({ address: accounts[1]!.address, keyAuthorization: createKeyAuthorization(accessKey.accessKeyAddress), keyPair, store, }) const result = await store.accessKeys.select({ account: rootAddress, chainId: 1, }) expect(result).toMatchInlineSnapshot(`undefined`) }) test('behavior: skips access keys for another chain', async () => { const { store } = await setup() const result = await store.accessKeys.select({ account: rootAddress, chainId: 42_431, }) expect(result).toMatchInlineSnapshot(`undefined`) }) test('behavior: skips external access keys without signer material', async () => { const store = createStore() const keyAuthorization = createKeyAuthorization('0x0000000000000000000000000000000000000099') await addAuthorization({ address: rootAddress, keyAuthorization, store, }) const result = await store.accessKeys.select({ account: rootAddress, chainId: 1, }) expect(result).toMatchInlineSnapshot(`undefined`) }) test('behavior: matches access key scopes against transaction calls', async () => { const store = createStore() const keyPair = await WebCryptoP256.createKeyPair() const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress }) const token = '0x0000000000000000000000000000000000000abc' as const await addAuthorization({ address: rootAddress, keyAuthorization: createKeyAuthorization(accessKey.accessKeyAddress, { scopes: [{ address: token, selector: 'transfer(address,uint256)' }], }), keyPair, store, }) const match = await store.accessKeys.select({ account: rootAddress, calls: [{ to: token, data: '0xa9059cbb0000000000000000000000000000000000000001' }], chainId: 1, }) const miss = await store.accessKeys.select({ account: rootAddress, calls: [{ to: '0x0000000000000000000000000000000000000def', data: '0xdeadbeef' }], chainId: 1, }) expect({ match: !!match, miss: !!miss }).toMatchInlineSnapshot(` { "match": true, "miss": false, } `) }) }) describe('get', () => { test('behavior: caches hydrated accounts per record', async () => { const keystore = testKeystore() const store = Store.create({ chainId: 1, keystores: { p256: keystore } }) const key = await keystore.createKey() const address = Address.fromPublicKey(PublicKey.fromHex(key.publicKey)) store.accessKeys.add({ account: rootAddress, authorization: createKeyAuthorization(address), handle: key.handle, publicKey: key.publicKey, }) const query = { accessKey: address, account: rootAddress, chainId: 1 } const first = await store.accessKeys.get(query) const second = await store.accessKeys.get(query) expect(first).toBeDefined() expect(second).toBe(first) expect(keystore.stats.toAccountCalls).toBe(1) }) test('behavior: matches access key scopes against exact-key transaction calls', async () => { const store = createStore() const keyPair = await WebCryptoP256.createKeyPair() const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress }) const token = '0x0000000000000000000000000000000000000abc' as const await addAuthorization({ address: rootAddress, keyAuthorization: createKeyAuthorization(accessKey.accessKeyAddress, { scopes: [{ address: token, selector: 'transfer(address,uint256)' }], }), keyPair, store, }) const query = { accessKey: accessKey.accessKeyAddress, account: rootAddress, chainId: 1 } const channel = await store.accessKeys.get(query) const match = await store.accessKeys.get({ ...query, calls: [{ to: token, data: '0xa9059cbb0000000000000000000000000000000000000001' }], }) const miss = await store.accessKeys.get({ ...query, calls: [{ to: '0x0000000000000000000000000000000000000def', data: '0xdeadbeef' }], }) expect({ channel: !!channel, match: !!match, miss: !!miss }).toMatchInlineSnapshot(` { "channel": true, "match": true, "miss": false, } `) }) test('behavior: unrecognized handles are unusable and retained', async () => { const foreign = testKeystore('foreign') const key = await foreign.createKey() const address = Address.fromPublicKey(PublicKey.fromHex(key.publicKey)) // The default keystore does not recognize the foreign handle. const store = createStore() store.accessKeys.add({ account: rootAddress, authorization: createKeyAuthorization(address), handle: key.handle, publicKey: key.publicKey, }) await expect( store.accessKeys.get({ accessKey: address, account: rootAddress, chainId: 1 }), ).resolves.toBeUndefined() expect(store.getState().accessKeys).toHaveLength(1) }) test('behavior: keystore failures are not cached', async () => { let calls = 0 const keystore: Keystore.Keystore = { async createKey() { throw new Error('unused') }, toAccount() { calls++ throw new Error('hardware key unavailable') }, } const store = Store.create({ chainId: 1, keystores: { p256: keystore } }) store.accessKeys.add({ account: rootAddress, authorization: createKeyAuthorization(accounts[1]!.address), handle: { kind: 'test' }, publicKey: `0x${'11'.repeat(64)}`, }) const query = { accessKey: accounts[1]!.address, account: rootAddress, chainId: 1 } await expect(store.accessKeys.get(query)).resolves.toBeUndefined() await expect(store.accessKeys.get(query)).resolves.toBeUndefined() expect(calls).toBe(2) // Transient failures keep the record so a recovered backend can retry. expect(store.getState().accessKeys).toHaveLength(1) }) test('behavior: permanently unavailable keys are evicted', async () => { const keystore: Keystore.Keystore = { async createKey() { throw new Error('unused') }, toAccount() { throw new Keystore.KeyUnavailableError() }, } const store = Store.create({ chainId: 1, keystores: { p256: keystore } }) store.accessKeys.add({ account: rootAddress, authorization: createKeyAuthorization(accounts[1]!.address), handle: { kind: 'test' }, publicKey: `0x${'11'.repeat(64)}`, }) await expect( store.accessKeys.get({ accessKey: accounts[1]!.address, account: rootAddress, chainId: 1 }), ).resolves.toBeUndefined() expect(store.getState().accessKeys).toHaveLength(0) }) test('behavior: records round-trip the backend that created them', async () => { const backendA = testKeystore('backend-a') const backendB = testKeystore('backend-b') // Bespoke composition: one keystore routing two backends by handle kind // (e.g. a hardware keystore with a software fallback). const keystore: Keystore.Keystore = { createKey: () => backendA.createKey(), toAccount(record, context) { const handle = record.handle as { kind: string } if (handle.kind === 'backend-a') return backendA.toAccount(record, context) return backendB.toAccount(record, context) }, } const store = Store.create({ chainId: 1, keystores: { p256: keystore } }) for (const backend of [backendA, backendB]) { const key = await backend.createKey() store.accessKeys.add({ account: rootAddress, authorization: createKeyAuthorization( Address.fromPublicKey(PublicKey.fromHex(key.publicKey)), ), handle: key.handle, publicKey: key.publicKey, }) } for (const record of store.getState().accessKeys) { const hydrated = await store.accessKeys.get({ accessKey: record.address, account: rootAddress, chainId: 1, }) expect(hydrated?.accessKeyAddress).toBe(record.address.toLowerCase()) } }) test('behavior: privateKey records hydrate without consulting the keystore', async () => { const keystore = testKeystore() const store = Store.create({ chainId: 1, keystores: { p256: keystore } }) store.accessKeys.add({ account: rootAddress, authorization: createKeyAuthorization(accounts[1]!.address, { keyType: 'secp256k1' }), privateKey: privateKeys[1], }) const account = await store.accessKeys.get({ accessKey: accounts[1]!.address, account: rootAddress, chainId: 1, }) expect(account?.accessKeyAddress).toBe(accounts[1]!.address.toLowerCase()) expect(keystore.stats.toAccountCalls).toBe(0) }) }) describe('hasReusableAuthorization', () => { test('behavior: matches scopes and optional reuse policy', async () => { const store = createStore() const keyPair = await WebCryptoP256.createKeyPair() const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress }) const token = '0x0000000000000000000000000000000000000abc' as const const merchant = '0x0000000000000000000000000000000000000def' as const addAuthorization({ address: rootAddress, keyAuthorization: createKeyAuthorization(accessKey.accessKeyAddress, { expiry: 200, limits: [{ token, limit: 100n, period: 86_400 }], scopes: [ { address: token, recipients: [merchant], selector: 'transfer(address,uint256)', }, ], }), keyPair, store, }) const match = await AccessKey.hasReusableAuthorization({ account: rootAddress, chainId: 1, now: 100, parameters: { expiry: 300, reuse: { minExpiry: 150, minLimits: [{ token, limit: 10n, period: 86_400 }], }, scopes: [ { address: token, recipients: [merchant], selector: 'transfer(address,uint256)', }, ], }, store: { keystores: Keystore.defaults, state: store }, }) const miss = await AccessKey.hasReusableAuthorization({ account: rootAddress, chainId: 1, now: 100, parameters: { expiry: 300, reuse: { minExpiry: 250, }, scopes: [ { address: token, recipients: [merchant], selector: 'transfer(address,uint256)', }, ], }, store: { keystores: Keystore.defaults, state: store }, }) expect({ match, miss }).toMatchInlineSnapshot(` { "match": true, "miss": false, } `) }) }) describe('canAuthorizeCalls', () => { test('behavior: checks whether requested scopes cover calls', () => { const token = '0x0000000000000000000000000000000000000abc' as const const merchant = '0x0000000000000000000000000000000000000def' as const const data = `0xa9059cbb000000000000000000000000${merchant.slice(2)}0000000000000000000000000000000000000000000000000000000000000001` as const const match = AccessKey.canAuthorizeCalls({ calls: [{ data, to: token }], parameters: { scopes: [ { address: token, recipients: [merchant], selector: 'transfer(address,uint256)', }, ], }, }) const miss = AccessKey.canAuthorizeCalls({ calls: [{ data, to: token }], parameters: { scopes: [ { address: token, recipients: ['0x0000000000000000000000000000000000000099'], selector: 'transfer(address,uint256)', }, ], }, }) expect({ match, miss }).toMatchInlineSnapshot(` { "match": true, "miss": false, } `) }) }) describe('getStatus', () => { test('behavior: returns pending while key authorization is stored', async () => { const store = createStore() const keyPair = await WebCryptoP256.createKeyPair() const accessKey = TempoAccount.fromWebCryptoP256(keyPair) const keyAuthorization = createKeyAuthorization(accessKey.address) await addAuthorization({ address: rootAddress, keyAuthorization, keyPair, store, }) const result = await store.accessKeys.getStatus({ account: rootAddress, chainId: 1, client: createMissingClient() as never, }) expect(result).toMatchInlineSnapshot(`"pending"`) }) test('behavior: clears stored authorization when local key is published', async () => { const store = createStore() const keyPair = await WebCryptoP256.createKeyPair() const accessKey = TempoAccount.fromWebCryptoP256(keyPair) const keyAuthorization = createKeyAuthorization(accessKey.address) await addAuthorization({ address: rootAddress, keyAuthorization, keyPair, store, }) const result = await store.accessKeys.getStatus({ account: rootAddress, chainId: 1, client: createMetadataClient(accessKey.address) as never, }) expect(result).toMatchInlineSnapshot(`"published"`) expect(store.getState().accessKeys[0]!.keyAuthorization).toMatchInlineSnapshot(`undefined`) }) test('error: propagates publication lookup failures', async () => { const store = createStore() const accessKey = TempoAccount.fromP256(privateKeys[1]!) const keyAuthorization = createKeyAuthorization(accessKey.address) await addAuthorization({ address: rootAddress, keyAuthorization, privateKey: privateKeys[1], store, }) await expect( store.accessKeys.getStatus({ account: rootAddress, chainId: 1, client: { call: async () => { throw new Error('RPC unavailable.') }, } as never, }), ).rejects.toThrowErrorMatchingInlineSnapshot(` [ContractFunctionExecutionError: An unknown error occurred while executing the contract function "getKey". Contract Call: address: 0xaAAAaaAA00000000000000000000000000000000 function: getKey(address account, address keyId) args: (0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, 0xB08a557649C30B96c28825748da6a940D6c8972e) Docs: https://viem.sh/docs/contract/readContract Details: RPC unavailable. Version: viem@2.56.0] `) }) test('behavior: returns published for local key without stored authorization', async () => { const store = createStore() const keyPair = await WebCryptoP256.createKeyPair() const accessKey = TempoAccount.fromWebCryptoP256(keyPair, { access: rootAddress }) const keyAuthorization = createKeyAuthorization(accessKey.accessKeyAddress) await addAuthorization({ address: rootAddress, keyAuthorization, keyPair, store, }) removeStoredAuthorization({ accessKey: accessKey.accessKeyAddress, store }) const result = await store.accessKeys.getStatus({ account: rootAddress, chainId: 1, client: createMetadataClient(accessKey.accessKeyAddress) as never, }) expect(result).toMatchInlineSnapshot(`"published"`) }) test('behavior: returns expired for expired local key', async () => { const store = createStore() const keyPair = await WebCryptoP256.createKeyPair() const accessKey = TempoAccount.fromWebCryptoP256(keyPair) const keyAuthorization = createKeyAuthorization(accessKey.address, { expiry: 100 }) await addAuthorization({ address: rootAddress, keyAuthorization, keyPair, store, }) const result = await store.accessKeys.getStatus({ account: rootAddress, chainId: 1, client: createMetadataClient(accessKey.address) as never, now: 101, }) expect(result).toMatchInlineSnapshot(`"expired"`) }) test('behavior: returns missing when no local key matches the policy', async () => { const store = createStore() const keyPair = await WebCryptoP256.createKeyPair() const accessKey = TempoAccount.fromWebCryptoP256(keyPair) const keyAuthorization = createKeyAuthorization(accessKey.address, { scopes: [{ address: '0x0000000000000000000000000000000000000abc' }], }) await addAuthorization({ address: rootAddress, keyAuthorization, keyPair, store, }) const result = await store.accessKeys.getStatus({ account: rootAddress, calls: [{ to: '0x0000000000000000000000000000000000000def', data: '0xdeadbeef' }], chainId: 1, client: createMissingClient() as never, }) expect(result).toMatchInlineSnapshot(`"missing"`) }) })