accounts
Version:
Tempo Accounts SDK
554 lines • 21.1 kB
JavaScript
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