UNPKG

accounts

Version:

Tempo Accounts SDK

554 lines 21.1 kB
import { AbiFunction, Address, Hex, PublicKey, RpcResponse, WebCryptoP256 } from 'ox'; import { KeyAuthorization } from 'ox/tempo'; import { BaseError } from 'viem'; import { Account as TempoAccount, Actions, KeyAuthorizationManager as TempoKeyAuthorizationManager, } from 'viem/tempo'; import * as ExecutionError from './ExecutionError.js'; import * as Keystore from './Keystore.js'; const status = { /** No matching usable access key was found. */ missing: 'missing', /** A matching key has a stored authorization that has not been observed on-chain yet. */ pending: 'pending', /** A matching key exists on-chain and can be used. */ published: 'published', /** A matching key exists but is past its expiry. */ expired: 'expired', }; const unavailableErrorNames = new Set(['KeyAlreadyRevoked', 'KeyNotFound']); /** Creates store-bound access-key operations. */ export function createManager(options) { return { add: (parameters) => add({ ...parameters, store: options }), authorize: (parameters) => authorize({ ...parameters, store: options }), clear: () => clear({ store: options }), get: (parameters) => get({ ...parameters, store: options }), prepareAuthorization: (parameters) => prepareAuthorization({ ...parameters, keystores: options.keystores }), getStatus: (parameters) => getStatus({ ...parameters, store: options }), list: (parameters) => list({ ...parameters, store: options }), remove: (parameters) => remove({ ...parameters, store: options }), select: (parameters) => select({ ...parameters, store: options }), updateAuthorization: (parameters) => updateAuthorization({ ...parameters, store: options }), }; } /** Prepares an unsigned key authorization and local key material when needed. */ export async function prepareAuthorization(options) { const { address, chainId, expiry, keystores, keyType, limits, privateKey, publicKey, scopes, witness, } = options; if (privateKey) { const type = keyType ?? 'secp256k1'; const accessKey = (() => { switch (type) { case 'secp256k1': return TempoAccount.fromSecp256k1(privateKey); case 'p256': return TempoAccount.fromP256(privateKey); case 'webAuthn': throw new RpcResponse.InvalidParamsError({ message: '`privateKey` cannot be used with `keyType: "webAuthn"`.', }); } })(); const keyAuthorization = KeyAuthorization.from({ address: accessKey.address, chainId: BigInt(chainId), expiry, limits, scopes, type, ...(witness ? { witness } : {}), }); return { keyAuthorization, privateKey }; } if (address || publicKey) { const keyAuthorization = KeyAuthorization.from({ address: address ?? Address.fromPublicKey(PublicKey.from(publicKey)), chainId: BigInt(chainId), expiry, limits, scopes, type: keyType ?? 'secp256k1', ...(witness ? { witness } : {}), }); return { keyAuthorization }; } // p256 is the default key type everywhere (WebCrypto-backed where // available, safest); secp256k1 is opt-in via an explicit `keyType`. const keystores_ = keystores ?? Keystore.defaults; const type = keyType ?? 'p256'; const keystore = type === 'webAuthn' ? undefined : keystores_[type]; if (!keystore) throw new RpcResponse.InvalidParamsError({ message: `\`keyType: "${type}"\` requires externally generated key material; provide \`publicKey\` or \`address\`.`, }); const key = await keystore.createKey(); const keyAuthorization = KeyAuthorization.from({ address: Address.fromPublicKey(PublicKey.fromHex(key.publicKey)), chainId: BigInt(chainId), expiry, limits, scopes, type, ...(witness ? { witness } : {}), }); return { key: { handle: key.handle, publicKey: key.publicKey }, keyAuthorization }; } /** Prepares, signs, and saves an access key authorization. */ export async function authorize(options) { const { account, chainId, parameters } = options; const { store } = options; const prepared = await prepareAuthorization({ ...parameters, chainId: parameters.chainId ?? chainId, keystores: store.keystores, }); const digest = KeyAuthorization.getSignPayload(prepared.keyAuthorization); const signature = await account.sign({ hash: digest }); const keyAuthorization = KeyAuthorization.from(prepared.keyAuthorization, { signature, }); add({ account: account.address, authorization: keyAuthorization, ...(prepared.key ? { handle: prepared.key.handle, publicKey: prepared.key.publicKey } : {}), ...(prepared.privateKey ? { privateKey: prepared.privateKey } : {}), store, }); return KeyAuthorization.toRpc(keyAuthorization); } /** Returns whether a local access key satisfies reusable authorization parameters. */ export async function hasReusableAuthorization(options) { const { account, calls, chainId, parameters, store } = options; const now = options.now ?? Date.now() / 1000; const records = list({ account, chainId, store }); for (const record of records) { if (isExpired(record.expiry, now)) continue; if (!authorizationMatches(record, parameters)) continue; if (calls && !recordScopesMatch(record, { calls })) continue; if (!(await hydrate(record, store))) continue; return true; } return false; } /** Returns whether an authorization request could sign the provided calls. */ export function canAuthorizeCalls(options) { return scopesMatch(options.parameters.scopes, { calls: options.calls }); } /** Returns publication status for a stored or on-chain access key. */ export async function getStatus(options) { const { accessKey, account, calls, chainId, client } = options; const { store } = options; const now = options.now ?? Date.now() / 1000; const local = list({ account, accessKey, chainId, store }).find((key) => recordScopesMatch(key, { calls })); if (local) { if (isExpired(local.expiry, now)) return status.expired; if (local.keyAuthorization) { const publicationStatus = await getPublishedStatus(client, { accessKey: local.address, account, now, }); if (publicationStatus === status.published) clearAuthorization({ accessKey: local.address, account, chainId, store, }); return publicationStatus === status.published ? status.published : status.pending; } return await getPublishedStatus(client, { accessKey: local.address, account, now }); } if (accessKey) return await getPublishedStatus(client, { accessKey, account, now }); return status.missing; } /** Selects a locally-signable access key account for an intent. */ export async function select(options) { const { account, calls, chainId, store } = options; const now = options.now ?? Date.now() / 1000; const records = list({ account, chainId, store }); for (const record of records) { if (!recordScopesMatch(record, { calls })) continue; if (isExpired(record.expiry, now)) { await remove({ accessKey: record.address, account: record.access, chainId: record.chainId, store, }); continue; } const account_accessKey = await hydrate(record, store); if (!account_accessKey) continue; return account_accessKey; } } /** Returns a locally-signable access key account by exact address. */ export async function get(options) { const { accessKey, account, calls, chainId } = options; const { store } = options; const now = options.now ?? Date.now() / 1000; const record = list({ account, accessKey, chainId, store })[0]; if (!record) return undefined; if ('calls' in options && !recordScopesMatch(record, { calls })) return undefined; if (isExpired(record.expiry, now)) { await remove({ accessKey: record.address, account: record.access, chainId: record.chainId, store, }); return undefined; } return await hydrate(record, store); } function createKeyAuthorizationManager(store) { return TempoKeyAuthorizationManager.from({ source: { get(key) { return list({ account: key.address, accessKey: key.accessKey, chainId: key.chainId, store, })[0]?.keyAuthorization; }, remove(key) { clearAuthorization({ account: key.address, accessKey: key.accessKey, chainId: key.chainId, store, }); }, set(key, keyAuthorization) { updateAuthorization({ account: key.address, accessKey: key.accessKey, authorization: keyAuthorization, chainId: key.chainId, store, }); }, }, }); } /** Adds a signed access key authorization. */ export function add(options) { const { account, authorization, handle, keyPair, privateKey, publicKey } = options; const { store } = options; const base = { address: authorization.address, access: account, chainId: Number(authorization.chainId), expiry: authorization.expiry ?? undefined, keyAuthorization: authorization, keyType: authorization.type, limits: authorization.limits, scopes: authorization.scopes, }; const material = privateKey ? { privateKey } : keyPair ? { keyPair } : typeof handle !== 'undefined' && publicKey ? { handle, publicKey } : {}; const record = { ...base, ...material }; store.state.setState((state) => ({ accessKeys: [ record, ...state.accessKeys.filter((entry) => !matches(entry, { account: record.access, accessKey: record.address, chainId: record.chainId, })), ], })); return record; } function clearAuthorization(options) { const { store, ...key } = options; patch({ ...key, patch: { keyAuthorization: undefined }, store, }); } function updateAuthorization(options) { const { authorization, store, ...key } = options; patch({ ...key, patch: { expiry: authorization.expiry ?? undefined, keyAuthorization: authorization, limits: authorization.limits, scopes: authorization.scopes, }, store, }); } /** Removes an access key record. */ export function remove(options) { const { store, ...key } = options; store.state.setState((state) => ({ accessKeys: state.accessKeys.filter((record) => !matches(record, key)), })); } /** Clears all access-key records. */ function clear(options) { const { store } = options; store.state.setState({ accessKeys: [] }); } /** Returns whether an error means an access key is already unavailable on-chain. */ export function isUnavailableError(error) { if (error instanceof BaseError) { const found = error.walk((e) => { const errorName = e.data?.errorName; return !!errorName && unavailableErrorNames.has(errorName); }); if (found) return true; } if (!(error instanceof Error)) return false; return unavailableErrorNames.has(ExecutionError.parse(error).errorName); } function recordScopesMatch(key, options) { return scopesMatch(key.scopes, options); } function scopesMatch(scopes, options) { if (typeof scopes === 'undefined') return true; if (!Array.isArray(scopes)) return false; if (!options.calls) return false; return options.calls.every((call) => { if (!call.to) return false; const callTo = call.to.toLowerCase(); const callSelector = call.data?.slice(0, 10).toLowerCase(); return scopes.some((scope) => { if (!isScope(scope)) return false; if (scope.address.toLowerCase() !== callTo) return false; const selector = scope.selector; if (!selector) return scope.recipients ? scope.recipients.length === 0 : true; const scopeSelector = normalizeSelector(selector); if (!scopeSelector || callSelector !== scopeSelector) return false; if (!scope.recipients || scope.recipients.length === 0) return true; if (!call.data || call.data.length < 74) return false; const recipient = `0x${call.data.slice(34, 74)}`; if (!Address.validate(recipient)) return false; return scope.recipients.some((address) => address.toLowerCase() === recipient.toLowerCase()); }); }); } function authorizationMatches(key, parameters) { if (!scopesCover(key.scopes, parameters.scopes)) return false; if (typeof parameters.reuse?.minExpiry === 'number' && key.expiry && key.expiry < parameters.reuse.minExpiry) return false; if (!limitsCover(key.limits, parameters.reuse?.minLimits)) return false; return true; } function scopesCover(existing, requested) { if (!requested) return true; if (!existing) return true; return requested.every((scope) => existing.some((candidate) => scopeCovers(candidate, scope))); } function scopeCovers(existing, requested) { if (!isScope(existing) || !isScope(requested)) return false; if (existing.address.toLowerCase() !== requested.address.toLowerCase()) return false; if (!selectorCovers(existing.selector, requested.selector)) return false; return recipientsCover(existing.recipients, requested.recipients); } function selectorCovers(existing, requested) { if (!existing) return true; if (!requested) return false; const selector_existing = normalizeSelector(existing); const selector_requested = normalizeSelector(requested); return !!selector_existing && selector_existing === selector_requested; } function normalizeSelector(value) { try { return (value.startsWith('0x') && value.length === 10 ? value : AbiFunction.getSelector(value)).toLowerCase(); } catch { return undefined; } } function recipientsCover(existing, requested) { if (!existing || existing.length === 0) return true; if (!requested || requested.length === 0) return false; return requested.every((address) => existing.some((candidate) => candidate.toLowerCase() === address.toLowerCase())); } function limitsCover(existing, requested) { if (!requested) return true; if (!existing) return true; return requested.every((limit) => existing.some((candidate) => candidate.token.toLowerCase() === limit.token.toLowerCase() && Number(candidate.period ?? 0) === Number(limit.period ?? 0) && BigInt(candidate.limit) >= BigInt(limit.limit))); } function isScope(scope) { if (!scope || typeof scope !== 'object') return false; const value = scope; if (typeof value.address !== 'string' || !Address.validate(value.address)) return false; if (typeof value.selector !== 'undefined' && typeof value.selector !== 'string') return false; if (typeof value.recipients !== 'undefined') { if (!Array.isArray(value.recipients)) return false; if (value.recipients.some((recipient) => typeof recipient !== 'string')) return false; if (value.recipients.some((recipient) => !Address.validate(recipient))) return false; } return true; } /** Keystore accounts hydrated this launch, keyed by record identity. */ const keystoreAccounts = new WeakMap(); async function hydrate(accessKey, store) { const keyAuthorizationManager = createKeyAuthorizationManager(store); if ('keyPair' in accessKey && accessKey.keyPair) return TempoAccount.fromWebCryptoP256(accessKey.keyPair, { access: accessKey.access, keyAuthorizationManager, }); if ('privateKey' in accessKey && accessKey.privateKey) { switch (accessKey.keyType) { case 'secp256k1': return TempoAccount.fromSecp256k1(accessKey.privateKey, { access: accessKey.access, keyAuthorizationManager, }); case 'p256': return TempoAccount.fromP256(accessKey.privateKey, { access: accessKey.access, keyAuthorizationManager, }); } } if ('handle' in accessKey && typeof accessKey.handle !== 'undefined' && accessKey.publicKey) { const keystore = accessKey.keyType === 'p256' || accessKey.keyType === 'secp256k1' ? store.keystores[accessKey.keyType] : undefined; if (!keystore) return undefined; let account = keystoreAccounts.get(accessKey); if (!account) { account = (async () => await keystore.toAccount({ handle: accessKey.handle, keyType: accessKey.keyType, publicKey: accessKey.publicKey, }, { access: accessKey.access, keyAuthorizationManager }))(); keystoreAccounts.set(accessKey, account); } try { return await account; } catch (error) { // The backend cannot materialize this key right now — treat the record // as unusable so callers fall back to re-authorization. Uncached so // transient failures (e.g. device locked) can retry; keystore-signaled // permanent loss (e.g. hardware key deleted) evicts the record. keystoreAccounts.delete(accessKey); if (Keystore.isKeyUnavailableError(error)) remove({ accessKey: accessKey.address, account: accessKey.access, chainId: accessKey.chainId, store, }); return undefined; } } return undefined; } function isExpired(expiry, now) { return typeof expiry === 'number' && expiry < now; } async function getPublishedStatus(client, options) { const { accessKey, account, now } = options; try { const metadata = await Actions.accessKey.getMetadata(client, { account, accessKey, }); if (metadata.address.toLowerCase() !== accessKey.toLowerCase()) return status.missing; if (metadata.isRevoked) return status.missing; if (metadata.expiry > 0n && metadata.expiry < BigInt(Math.floor(now))) return status.expired; return status.published; } catch (error) { if (isUnavailableError(error)) return status.missing; throw error; } } function list(options) { const { store, ...query } = options; return store.state.getState().accessKeys.filter((key) => matches(key, query)); } function patch(options) { const { patch, store, ...key } = options; store.state.setState((state) => ({ accessKeys: state.accessKeys.map((record) => { if (!matches(record, key)) return record; const next = { ...record }; for (const [name, value] of Object.entries(patch)) { if (typeof value === 'undefined') delete next[name]; else next[name] = value; } return next; }), })); } function matches(record, options) { const { accessKey, account, chainId } = options; if (record.access.toLowerCase() !== account.toLowerCase()) return false; if (record.chainId !== chainId) return false; if (accessKey && record.address.toLowerCase() !== accessKey.toLowerCase()) return false; return true; } //# sourceMappingURL=AccessKey.js.map