UNPKG

accounts

Version:

Tempo Accounts SDK

505 lines 19.9 kB
import { Hex, RpcResponse, Signature } from 'ox'; import { MultisigConfig, SignatureEnvelope, TxEnvelopeTempo } from 'ox/tempo'; import { Transaction } from 'viem/tempo'; import * as Sponsorship from './sponsorship.js'; import * as Utils from './utils.js'; /** Default submission-claim TTL in milliseconds. */ export const defaultClaimTtl = 30_000; /** Returns whether a raw transaction carries a native multisig signature. */ export function isMultisigTransaction(serialized) { if (!Utils.isSerializedTempoTransaction(serialized)) return false; try { const transaction = Transaction.deserialize(serialized); return isMultisigSignature(transaction.signature); } catch { return false; } } /** Handles a native multisig raw transaction submission. */ export async function handleRawTransaction(options) { const result = await collect(options); if (result.status.status === 'pending' || result.status.status === 'submitting') return result.status.operation.id; if (result.status.status === 'submitted') return result.status.operation.submittedHash ?? result.status.operation.id; if (options.method === 'eth_sendRawTransactionSync') return result.broadcastResult; return result.status.operation.submittedHash; } async function collect(options) { const { claimTtl = defaultClaimTtl, finalize = 'sync', getClient, method, request, resolveConfig, sponsor, store, } = options; const serialized = request.params?.[0]; if (!Utils.isSerializedTempoTransaction(serialized)) throw new RpcResponse.InvalidParamsError({ message: 'Only Tempo (0x76/0x78) transactions are supported.', }); const input = parse(serialized); const pending = await store.get(input.id); const config = await resolveValidatedConfig({ account: input.account, chainId: input.chainId, init: input.init, pending, resolveConfig, }); if (!config) throw new RpcResponse.InvalidParamsError({ message: 'Multisig config is required to collect approvals. Provide it in the bootstrap transaction or configure `multisig.resolveConfig`.', }); const initConfig = resolveInitConfig({ config, init: input.init, nonce: input.nonce, pending, }); const now = Date.now(); let operation = { account: input.account, chainId: input.chainId, claimExpiresAt: pending?.claimExpiresAt, config, createdAt: pending?.createdAt ?? now, id: input.id, init: pending?.init || !!initConfig, initConfig, payload: input.payload, signatures: mergeSignatures([...(pending?.signatures ?? []), ...input.signatures]), status: pending?.status === 'submitted' ? 'submitted' : 'pending', submittedHash: pending?.submittedHash, transaction: serialized, updatedAt: now, }; let approvals = getApprovals({ account: input.account, config, payload: input.payload, signatures: operation.signatures, }); if (operation.status === 'submitted') return { broadcastResult: operation.submittedHash, status: { operation, status: 'submitted' }, }; if (approvals.weight < approvals.threshold) { operation = await store.savePending(operation); approvals = getApprovals({ account: operation.account, config, payload: operation.payload, signatures: operation.signatures, }); if (approvals.weight < approvals.threshold) return { status: { operation, status: operation.status } }; } const claim = await store.claimSubmission(operation, { ttl: claimTtl }); if (claim.status !== 'claimed') return { status: claim }; operation = claim.operation; approvals = getApprovals({ account: operation.account, config, payload: operation.payload, signatures: operation.signatures, }); const client = getClient(operation.chainId); const final = await serializeFinal({ account: operation.account, init: operation.initConfig ?? initConfig, signatures: approvals.signatures, transaction: input.transaction, }); const broadcastMethod = method === 'eth_sendRawTransaction' && finalize === 'sync' ? 'eth_sendRawTransactionSync' : method; const feePayerState = (() => { try { const transaction = Transaction.deserialize(final); // No fee-payer field means finalized transaction does not request sponsorship. if (!('feePayerSignature' in transaction)) return {}; // Preserve fee-token/signature marker for sponsorship decision below. return { feeToken: typeof transaction.feeToken === 'string' ? transaction.feeToken : undefined, signature: transaction.feePayerSignature, }; } catch { // Decode failure falls back to direct broadcast. return {}; } })(); const sponsorFeeToken = await (async () => { // No sponsor configured, so finalized multisig transaction broadcasts as-is. if (!sponsor) return undefined; // Already fee-payer signed transactions must not be rewritten by this relay. if (feePayerState.signature !== null) return undefined; // Explicit relay fee token wins when the transaction requests sponsorship. if (sponsor.feeToken) return sponsor.feeToken; // `null` requests relay-added fee-payer signature. return await sponsor.resolveFeeToken?.(operation.chainId); })(); const shouldSponsor = (() => { // No sponsor configured, so no sponsorship path to take. if (!sponsor) return false; // `null` requests relay-added fee-payer signature. if (feePayerState.signature === null) return true; // Missing or already-present fee-payer signatures broadcast unchanged. return false; })(); const result = await (async () => { // Sponsorship enabled, so sponsor adds/signs fee-payer fields. if (shouldSponsor && sponsor) return await Sponsorship.handleRawTransaction({ account: sponsor.account, feeToken: sponsorFeeToken, getFeeToken: sponsor.resolveFeeToken ? async (chainId) => await sponsor.resolveFeeToken?.(chainId) : undefined, getClient, method: broadcastMethod, request: { params: [final] }, sender: operation.account, validate: sponsor.validate, }); // Otherwise, multisig transaction is final and can broadcast directly. return await client.request({ method: broadcastMethod, params: [final], }); })(); const submittedHash = (() => { if (typeof result === 'string') return result; if (result && typeof result === 'object' && 'transactionHash' in result) { const response = result; if (typeof response.transactionHash === 'string') return response.transactionHash; } throw new Error('Expected transaction hash in multisig broadcast result.'); })(); const submitted = await store.setSubmitted(operation.id, submittedHash); return { broadcastResult: result, status: { operation: submitted, status: 'claimed' }, }; } /** Resolves a multisig operation id for standard transaction lookup methods. */ export async function handleGetTransaction(options) { const id = options.request.params?.[0]; if (typeof id !== 'string') return undefined; const operation = await options.store.get(id); if (!operation) return undefined; if (operation.status !== 'submitted' || !operation.submittedHash) return { result: null }; const result = await options.getClient(operation.chainId).request({ method: options.method, params: [operation.submittedHash], }); return { result }; } /** Returns a stored native multisig operation status. */ export async function getStatus(options) { const operation = await options.store.get(options.id); if (!operation) return null; const config = await resolveValidatedConfig({ account: operation.account, chainId: operation.chainId, pending: operation, resolveConfig: options.resolveConfig, }); if (!config) return { account: operation.account, chainId: operation.chainId, id: operation.id, signatures: operation.signatures.length, status: operation.status, submittedHash: operation.submittedHash, }; const approvals = getApprovals({ account: operation.account, config, payload: operation.payload, signatures: operation.signatures, }); return toStatus({ approvals, operation }); } /** Returns pending native multisig operation statuses for an account. */ export async function listStatuses(options) { const operations = await options.store.listPendingByAddress(options.account); return await Promise.all(operations.map(async (operation) => { const config = await resolveValidatedConfig({ account: operation.account, chainId: operation.chainId, pending: operation, resolveConfig: options.resolveConfig, }); if (!config) return { account: operation.account, chainId: operation.chainId, id: operation.id, signatures: operation.signatures.length, status: operation.status, submittedHash: operation.submittedHash, }; const approvals = getApprovals({ account: operation.account, config, payload: operation.payload, signatures: operation.signatures, }); return toStatus({ approvals, operation }); })); } /** Creates an in-memory multisig operation store for development and tests only. */ export function memoryStore() { const operations = new Map(); return { async claimSubmission(operation, options) { const now = Date.now(); const existing = operations.get(operation.id); if (existing?.status === 'submitted') return { operation: existing, status: 'submitted' }; if (existing?.status === 'submitting' && (existing.claimExpiresAt ?? 0) > now) return { operation: existing, status: 'submitting' }; const claimed = mergeOperation(existing, operation, { claimExpiresAt: now + options.ttl, status: 'submitting', updatedAt: now, }); operations.set(operation.id, claimed); return { operation: claimed, status: 'claimed' }; }, async get(id) { return operations.get(id); }, async listPendingByAddress(address) { return [...operations.values()].filter((operation) => operation.account.toLowerCase() === address.toLowerCase() && operation.status !== 'submitted'); }, async savePending(operation) { const now = Date.now(); const existing = operations.get(operation.id); if (existing?.status === 'submitted') return existing; const pending = mergeOperation(existing, operation, { status: existing?.status === 'submitting' && (existing.claimExpiresAt ?? 0) > now ? 'submitting' : 'pending', updatedAt: now, }); operations.set(operation.id, pending); return pending; }, async setSubmitted(id, submittedHash) { const existing = operations.get(id); if (!existing) throw new Error('Cannot mark an unknown multisig operation submitted.'); const submitted = { ...existing, claimExpiresAt: undefined, status: 'submitted', submittedHash, updatedAt: Date.now(), }; operations.set(id, submitted); return submitted; }, }; } function parse(serialized) { const transaction = Transaction.deserialize(serialized); const signature = transaction.signature; if (!isMultisigSignature(signature)) throw new RpcResponse.InvalidParamsError({ message: 'Transaction does not contain a native multisig signature.', }); const { signature: _, ...unsigned } = transaction; const payload = TxEnvelopeTempo.getSignPayload(TxEnvelopeTempo.from(unsigned)); const chainId = Number(transaction.chainId); const id = MultisigConfig.getSignPayload({ account: signature.account, payload, }); const init = signature.init ? MultisigConfig.from(signature.init) : undefined; const nonce = typeof transaction.nonce === 'number' ? transaction.nonce : undefined; return { account: signature.account, chainId, id, init, nonce, payload, signatures: signature.signatures.map((value) => SignatureEnvelope.serialize(value)), transaction, }; } function isMultisigSignature(value) { return (typeof value === 'object' && value !== null && value.type === 'multisig' && typeof value.account === 'string' && Array.isArray(value.signatures)); } async function resolveValidatedConfig(options) { const { account, chainId, init, pending, resolveConfig } = options; const config = (await resolveConfig?.({ account, chainId, init })) ?? pending?.config ?? init; if (!config) return undefined; const normalized = MultisigConfig.from(config); // The multisig account address derives from the genesis config, so a // bootstrap init config must derive the transaction's account. if (init && MultisigConfig.getAddress(MultisigConfig.from(init)).toLowerCase() !== account.toLowerCase()) throw new RpcResponse.InvalidParamsError({ message: 'Bootstrap multisig init config does not match the multisig account.', }); return normalized; } function resolveInitConfig(options) { const { config, init, nonce, pending } = options; if (pending?.initConfig) return pending.initConfig; if (pending?.init && pending.config) return pending.config; if (init) return init; if (nonce === 0) return config; return undefined; } function mergeSignatures(signatures) { const seen = new Set(); const out = []; for (const signature of signatures) { const key = signature.toLowerCase(); if (seen.has(key)) continue; seen.add(key); out.push(signature); } return out; } function getApprovals(options) { const { account, config, payload } = options; const digest = MultisigConfig.getSignPayload({ account, payload }); const owners = new Map(config.owners.map((owner) => [ owner.owner.toLowerCase(), { address: owner.owner, weight: Number(owner.weight) }, ])); const seen = new Set(); const signatures = []; let weight = 0; for (const value of options.signatures) { const signature = SignatureEnvelope.from(value); const owner = SignatureEnvelope.extractAddress({ payload: digest, signature }); const key = owner.toLowerCase(); const configured = owners.get(key); if (!configured) throw new RpcResponse.InvalidParamsError({ message: `Signature from non-owner ${owner}.` }); if (seen.has(key)) continue; const valid = (() => { try { return SignatureEnvelope.verify(signature, { payload: digest, address: configured.address }); } catch { return false; } })(); if (!valid) throw new RpcResponse.InvalidParamsError({ message: `Invalid signature from owner ${configured.address}.`, }); seen.add(key); signatures.push(SignatureEnvelope.serialize(signature)); weight += configured.weight; } return { signatures, threshold: Number(config.threshold), weight }; } async function serializeFinal(options) { const { signature: _, ...transaction } = options.transaction; const envelope = TxEnvelopeTempo.from({ ...transaction, feePayerSignature: (() => { const value = transaction.feePayerSignature; // Preserve missing/null fee-payer markers so sponsorship can add signature later. if (!value) return value; // Normalize RPC-shaped signatures before rebuilding final transaction envelope. if (typeof value === 'object' && 'r' in value && 's' in value) { const signature = value; const yParity = (() => { const value = signature.yParity ?? signature.v; // Some signature shapes omit parity until later normalization. if (typeof value === 'undefined') return undefined; const number = Number(value); // RPC signatures may carry Ethereum 27/28 `v`; `ox` expects 0/1 parity. if (number === 27 || number === 28) return number - 27; return number; })(); if (typeof yParity === 'number') return Signature.from({ r: BigInt(signature.r), s: BigInt(signature.s), yParity }); } // Fall back to `ox` for already-normalized signature inputs. return Signature.from(value); })(), }); const payload = TxEnvelopeTempo.getSignPayload(envelope); const signatures = options.signatures.map((approval) => SignatureEnvelope.from(approval)); const sorted = SignatureEnvelope.sortMultisigApprovals({ account: options.account, payload, signatures, }); const signature = SignatureEnvelope.from({ account: options.account, signatures: sorted, ...(options.init ? { init: options.init } : {}), }); return TxEnvelopeTempo.serialize(envelope, { ...('feePayerSignature' in transaction ? { feePayerSignature: transaction.feePayerSignature } : {}), signature, }); } function mergeOperation(existing, operation, patch) { return { ...operation, ...existing, ...patch, config: operation.config ?? existing?.config, init: existing?.init || operation.init, initConfig: existing?.initConfig ?? operation.initConfig, signatures: mergeSignatures([...(existing?.signatures ?? []), ...operation.signatures]), transaction: operation.transaction, }; } function toStatus(options) { const { approvals, operation } = options; return { account: operation.account, chainId: operation.chainId, id: operation.id, signatures: approvals.signatures.length, status: operation.status, submittedHash: operation.submittedHash, threshold: approvals.threshold, weight: approvals.weight, }; } //# sourceMappingURL=multisig.js.map