accounts
Version:
Tempo Accounts SDK
1,156 lines (1,030 loc) • 36.8 kB
text/typescript
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"`)
})
})