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