UNPKG

accounts

Version:

Tempo Accounts SDK

330 lines 12.7 kB
import { AbiFunction, Address, Hex, Provider, PublicKey, WebCryptoP256 } from 'ox'; import { KeyAuthorization, SignatureEnvelope } from 'ox/tempo'; import { Account as TempoAccount, Actions } from 'viem/tempo'; import * as ExecutionError from './ExecutionError.js'; const removalErrorNames = new Set([ 'InvalidSignature', 'InvalidSignatureFormat', 'InvalidSignatureType', 'KeyAlreadyRevoked', 'KeyExpired', 'KeyNotFound', 'SignatureTypeMismatch', ]); /** Access-key publication states. */ export const status = { /** No matching usable access key was found. */ missing: 'missing', /** A matching key exists locally and still needs its first transaction to publish the authorization. */ 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', }; /** Returns the pending key authorization for an access key account without removing it. */ export function getPending(account, options) { if (account.source !== 'accessKey') return undefined; const { store } = options; const accessKeyAddress = account.accessKeyAddress; const { accessKeys } = store.getState(); const entry = accessKeys.find((a) => a.address?.toLowerCase() === accessKeyAddress.toLowerCase()); return entry?.keyAuthorization; } /** Generates a P256 key pair and access key account. */ export async function generate(options = {}) { const { account } = options; const keyPair = await WebCryptoP256.createKeyPair(); const accessKey = TempoAccount.fromWebCryptoP256(keyPair, account ? { access: account } : undefined); return { accessKey, keyPair }; } /** Prepares an unsigned key authorization and local key material when needed. */ export async function prepareAuthorization(options) { const { address, chainId, expiry, keyType, limits, publicKey, scopes } = options; if (address || publicKey) { const keyAuthorization = KeyAuthorization.from({ address: address ?? Address.fromPublicKey(PublicKey.from(publicKey)), chainId: BigInt(chainId), expiry, limits, scopes, type: keyType ?? 'secp256k1', }); return { keyAuthorization }; } const keyPair = await WebCryptoP256.createKeyPair(); const keyAuthorization = KeyAuthorization.from({ address: Address.fromPublicKey(PublicKey.from(keyPair.publicKey)), chainId: BigInt(chainId), expiry, limits, scopes, type: 'p256', }); return { keyAuthorization, keyPair }; } /** Saves a prepared access key authorization with an existing signature. */ export function saveAuthorization(options) { const { address, prepared, signature, store } = options; const keyAuthorization = KeyAuthorization.from(prepared.keyAuthorization, { signature: SignatureEnvelope.from(signature), }); save({ address, keyAuthorization, ...(prepared.keyPair ? { keyPair: prepared.keyPair } : {}), store, }); return KeyAuthorization.toRpc(keyAuthorization); } /** Prepares, signs, and saves an access key authorization. */ export async function authorize(options) { const { account, chainId, parameters, store } = options; const prepared = await prepareAuthorization({ ...parameters, chainId: parameters.chainId ?? chainId, }); return await signAuthorization({ account, prepared, store }); } async function signAuthorization(options) { const { account, prepared, store } = options; const digest = KeyAuthorization.getSignPayload(prepared.keyAuthorization); const signature = await account.sign({ hash: digest }); return saveAuthorization({ address: account.address, prepared, signature, store }); } /** Hydrates an access key entry to a viem Account. Only works for locally-generated keys. */ export function hydrate(accessKey) { if ('keyPair' in accessKey && accessKey.keyPair) return TempoAccount.fromWebCryptoP256(accessKey.keyPair, { access: accessKey.access }); if ('privateKey' in accessKey && accessKey.privateKey) { switch (accessKey.keyType) { case 'secp256k1': return TempoAccount.fromSecp256k1(accessKey.privateKey, { access: accessKey.access }); case 'p256': return TempoAccount.fromP256(accessKey.privateKey, { access: accessKey.access }); } } throw new Provider.UnauthorizedError({ message: 'External access key cannot be hydrated for signing.', }); } /** Removes an access key entry for the given account from the store. */ export function remove(account, options) { if (account.source !== 'accessKey') return; const { store } = options; const accessKeyAddress = account.accessKeyAddress; store.setState((state) => ({ accessKeys: state.accessKeys.filter((a) => a.address?.toLowerCase() !== accessKeyAddress?.toLowerCase()), })); } /** Invalidates a stored access key when the error proves it is no longer usable. */ export function invalidate(account, error, options) { if (account.source !== 'accessKey') return false; if (!shouldRemoveForError(error)) return false; remove(account, options); return true; } /** Permanently removes the pending key authorization for an access key account. */ export function removePending(account, options) { if (account.source !== 'accessKey') return; const { store } = options; const accessKeyAddress = account.accessKeyAddress; store.setState((state) => ({ accessKeys: state.accessKeys.map((a) => a.address.toLowerCase() === accessKeyAddress.toLowerCase() ? { ...a, keyAuthorization: undefined } : a), })); } /** Selects and hydrates a locally-signable access key account for a root account. */ export function selectAccount(options) { const { address, calls, chainId, store } = options; const { accessKeys } = store.getState(); let accessKeys_next = accessKeys; for (const key of accessKeys) { if (key.access.toLowerCase() !== address.toLowerCase()) continue; if (key.chainId !== chainId) continue; if (!('keyPair' in key && !!key.keyPair) && !('privateKey' in key && !!key.privateKey)) continue; if (key.expiry && key.expiry < Date.now() / 1000) { accessKeys_next = accessKeys_next.filter((a) => a !== key); store.setState({ accessKeys: accessKeys_next }); continue; } if (scopesMatch(key, { calls })) return hydrate(key); } return undefined; } /** Returns publication status for a stored or on-chain access key. */ export async function getStatus(options) { const { accessKey, address, calls, chainId, client, store } = options; const now = options.now ?? Date.now() / 1000; const local = store .getState() .accessKeys.find((key) => matches(key, { accessKey, address, calls, chainId })); if (local) { if (isExpired(local.expiry, now)) return status.expired; if (local.keyAuthorization) return status.pending; if (client) return await getPublishedStatus(client, { accessKey: local.address, address, now }); return status.published; } if (accessKey && client) return await getPublishedStatus(client, { accessKey, address, now }); return status.missing; } /** Removes an access key from the store. */ export function revoke(options) { const { address, store } = options; const { accessKeys } = store.getState(); store.setState({ accessKeys: accessKeys.filter((a) => a.access.toLowerCase() !== address.toLowerCase()), }); } function scopesMatch(key, options) { const scopes = key.scopes; 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; if (!scope.selector) return scope.recipients ? scope.recipients.length === 0 : true; const scopeSelector = getSelector(scope.selector); if (!scopeSelector || callSelector !== scopeSelector) return false; return recipientsMatch(scope.recipients, call.data); }); }); } function matches(key, options) { const { accessKey, address, calls, chainId } = options; if (key.access.toLowerCase() !== address.toLowerCase()) return false; if (key.chainId !== chainId) return false; if (accessKey && key.address.toLowerCase() !== accessKey.toLowerCase()) return false; return scopesMatch(key, { calls }); } function isExpired(expiry, now) { return typeof expiry === 'number' && expiry < now; } async function getPublishedStatus(client, options) { const { accessKey, address, now } = options; try { const metadata = await Actions.accessKey.getMetadata(client, { account: address, accessKey, }); 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 (!(error instanceof Error)) throw error; const parsed = ExecutionError.parse(error); if (parsed.errorName === 'KeyNotFound' || parsed.errorName === 'KeyAlreadyRevoked') return status.missing; throw error; } } 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; } function getSelector(selector) { try { return (selector.startsWith('0x') && selector.length === 10 ? selector : AbiFunction.getSelector(selector)).toLowerCase(); } catch { return undefined; } } function recipientsMatch(recipients, data) { if (!recipients || recipients.length === 0) return true; const recipient = getCallRecipient(data); if (!recipient) return false; return recipients.some((address) => address.toLowerCase() === recipient.toLowerCase()); } function getCallRecipient(data) { if (!data || data.length < 74) return undefined; const recipient = `0x${data.slice(34, 74)}`; if (!Address.validate(recipient)) return undefined; return recipient; } function shouldRemoveForError(error) { if (!(error instanceof Error)) return false; const parsed = ExecutionError.parse(error); return removalErrorNames.has(parsed.errorName); } /** Saves an access key to the store with its one-time key authorization. */ export function save(options) { const { address, keyAuthorization, keyPair, privateKey, store } = options; const base = { address: keyAuthorization.address, access: address, chainId: Number(keyAuthorization.chainId), expiry: keyAuthorization.expiry ?? undefined, keyAuthorization, keyType: keyAuthorization.type, limits: keyAuthorization.limits, scopes: keyAuthorization.scopes, }; const accessKey = privateKey ? { ...base, privateKey } : keyPair ? { ...base, keyPair } : { ...base }; store.setState((state) => ({ accessKeys: [ accessKey, ...state.accessKeys.filter((entry) => entry.address.toLowerCase() !== keyAuthorization.address.toLowerCase()), ], })); } //# sourceMappingURL=AccessKey.js.map