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