UNPKG

ox

Version:

Ethereum Standard Library

472 lines (442 loc) 13.9 kB
import * as Address from '../core/Address.js' import type * as Bytes from '../core/Bytes.js' import * as Errors from '../core/Errors.js' import * as Hash from '../core/Hash.js' import * as Hex from '../core/Hex.js' import type { Compute, OneOf } from '../core/internal/types.js' /** Maximum number of owners allowed in a native multisig config. */ export const maxOwners = 255 /** Maximum threshold accepted by a native multisig config. */ export const maxThreshold = 8 /** Maximum number of owner approvals in a native multisig signature. */ export const maxSignatures = maxThreshold /** * Maximum number of native multisig signatures in one nested authorization * path, including the top-level transaction signature. */ export const maxNestingDepth = 2 /** Maximum encoded byte length for one owner approval. */ export const maxOwnerSignatureBytes = 2049 /** Tempo signature type byte for native multisig signatures. */ export const signatureTypeByte = '0x05' as const /** Zero 32-byte salt (the default when no salt is provided). */ export const zeroSalt = `0x${'00'.repeat(32)}` as const /** Domain prefix for the native multisig account address derivation. */ const accountDomain = 'tempo:multisig:account' /** Domain prefix for native multisig owner approvals. */ const signatureDomain = 'tempo:multisig:signature' /** * Native multisig configuration. Determines the stable multisig account * address. */ export type Config<numberType = number> = Compute<{ /** * Caller-chosen 32-byte salt mixed into the derived account address. * Defaults to the zero salt (`MultisigConfig.zeroSalt`) when omitted. */ salt?: Hex.Hex | undefined /** Minimum total owner weight required to authorize a transaction. */ threshold: numberType /** Weighted owner list (strictly ascending by `owner` address). */ owners: readonly Owner<numberType>[] }> /** Native multisig owner entry. */ export type Owner<numberType = number> = { /** Owner address (recovered from the owner's approval). */ owner: Address.Address /** Nonzero owner weight. */ weight: numberType } /** RLP tuple representation of a {@link ox#MultisigConfig.Config}. */ export type Tuple = readonly [ salt: Hex.Hex, threshold: Hex.Hex, owners: readonly Hex.Hex[][], ] /** * Asserts that a native multisig {@link ox#MultisigConfig.Config} is valid. * * Mirrors the Tempo `InitMultisig::validate` rules: owners non-empty and * `<= maxOwners`, strictly ascending unique nonzero owner addresses, nonzero * integer owner weights, integer `threshold` between `1` and `maxThreshold`, * total weight `<= 255` (u8 max), and `threshold <= total weight`. * * @example * ```ts twoslash * import { MultisigConfig } from 'ox/tempo' * * MultisigConfig.assert({ * threshold: 1, * owners: [ * { * owner: '0x1111111111111111111111111111111111111111', * weight: 1 * } * ] * }) * ``` * * @param config - The multisig config. */ export function assert<numberType = number>(config: Config<numberType>): void { const { salt, threshold, owners } = config if (typeof salt !== 'undefined' && Hex.size(salt) !== 32) throw new InvalidConfigError({ reason: 'salt must be 32 bytes' }) if (owners.length === 0) throw new InvalidConfigError({ reason: 'owners cannot be empty' }) if (owners.length > maxOwners) throw new InvalidConfigError({ reason: 'too many owners' }) if (!Number.isInteger(Number(threshold))) throw new InvalidConfigError({ reason: 'threshold must be an integer' }) if (Number(threshold) < 1) throw new InvalidConfigError({ reason: 'threshold cannot be zero' }) if (Number(threshold) > maxThreshold) throw new InvalidConfigError({ reason: 'threshold exceeds max threshold' }) let totalWeight = 0 let previous: bigint | undefined for (const owner of owners) { if (!Address.validate(owner.owner) || Hex.toBigInt(owner.owner) === 0n) throw new InvalidConfigError({ reason: 'owner cannot be zero' }) if (!Number.isInteger(Number(owner.weight))) throw new InvalidConfigError({ reason: 'owner weight must be an integer', }) if (Number(owner.weight) < 1) throw new InvalidConfigError({ reason: 'owner weight cannot be zero' }) const current = Hex.toBigInt(owner.owner) if (typeof previous !== 'undefined' && previous >= current) throw new InvalidConfigError({ reason: 'owners must be strictly ascending', }) previous = current totalWeight += Number(owner.weight) } if (totalWeight > 0xff) throw new InvalidConfigError({ reason: 'total owner weight exceeds u8 max', }) if (Number(threshold) > totalWeight) throw new InvalidConfigError({ reason: 'threshold exceeds total owner weight', }) } export declare namespace assert { type ErrorType = InvalidConfigError | Errors.GlobalErrorType } /** * Normalizes a native multisig {@link ox#MultisigConfig.Config}. * * Sorts owners into strictly ascending `owner` address order (the canonical * form required for account derivation) and asserts the config is valid. * * @example * ```ts twoslash * import { MultisigConfig } from 'ox/tempo' * * const config = MultisigConfig.from({ * threshold: 2, * owners: [ * { * owner: '0x2222222222222222222222222222222222222222', * weight: 1 * }, * { * owner: '0x1111111111111111111111111111111111111111', * weight: 1 * } * ] * }) * // owners are now sorted ascending by address * ``` * * @param config - The multisig config. * @returns The normalized multisig config. */ export function from<numberType = number>( config: Config<numberType>, ): Config<numberType> { const owners = [...config.owners].sort((a, b) => Hex.toBigInt(a.owner) < Hex.toBigInt(b.owner) ? -1 : 1, ) const normalized = { salt: config.salt ? Hex.padLeft(config.salt, 32) : zeroSalt, threshold: config.threshold, owners, } as Config<numberType> assert(normalized) return normalized } /** * Converts an RLP {@link ox#MultisigConfig.Tuple} back to a * {@link ox#MultisigConfig.Config}. * * @example * ```ts twoslash * import { MultisigConfig } from 'ox/tempo' * * const config = MultisigConfig.fromTuple([ * `0x${'00'.repeat(32)}`, * '0x01', * [['0x1111111111111111111111111111111111111111', '0x01']] * ]) * ``` * * @param tuple - The RLP tuple. * @returns The multisig config. */ export function fromTuple(tuple: Tuple): Config { const [salt, threshold, owners] = tuple return { salt: salt && salt !== '0x' ? Hex.padLeft(salt, 32) : zeroSalt, threshold: threshold === '0x' ? 0 : Hex.toNumber(threshold), owners: owners.map((owner) => { const [ownerAddress, weight] = owner as readonly Hex.Hex[] return { owner: ownerAddress as Address.Address, weight: !weight || weight === '0x' ? 0 : Hex.toNumber(weight), } }), } } /** * Derives the stable native multisig account address. * * Preimage (fixed-width big-endian, **not** RLP): * `keccak256("tempo:multisig:account" || salt || u8(threshold) || u8(owners.length) || (owner || u8(weight)) for each owner)[12:32]`. * * The address is derived once from the initial (bootstrap) config and never * changes — config updates do not affect it. * * @example * ```ts twoslash * import { MultisigConfig } from 'ox/tempo' * * const genesisConfig = MultisigConfig.from({ * threshold: 1, * owners: [ * { * owner: '0x1111111111111111111111111111111111111111', * weight: 1 * } * ] * }) * * const address = MultisigConfig.getAddress(genesisConfig) * ``` * * @param config - The initial (bootstrap) multisig config. * @returns The multisig account address. */ export function getAddress(config: Config): Address.Address { assert(config) const hash = Hash.keccak256( Hex.concat( Hex.fromString(accountDomain), Hex.padLeft(config.salt ?? zeroSalt, 32), Hex.fromNumber(config.threshold, { size: 1 }), Hex.fromNumber(config.owners.length, { size: 1 }), ...config.owners.flatMap((owner) => [ owner.owner, Hex.fromNumber(owner.weight, { size: 1 }), ]), ), ) const account = Address.from(Hex.slice(hash, 12, 32)) if (Hex.toBigInt(account) === 0n) throw new InvalidConfigError({ reason: 'derived account cannot be zero' }) return account } export declare namespace getAddress { type ErrorType = | assert.ErrorType | Address.from.ErrorType | Hash.keccak256.ErrorType | Hex.concat.ErrorType | Hex.fromNumber.ErrorType | Hex.fromString.ErrorType | Hex.slice.ErrorType | Errors.GlobalErrorType } /** * Computes the digest a native multisig owner approves (signs). * * `keccak256("tempo:multisig:signature" || inner_digest || account)`, * where `inner_digest` is the transaction sign payload * ({@link ox#TxEnvelopeTempo.(getSignPayload:function)}). * * The digest is keyed on the permanent `account` derived from the genesis * (bootstrap) config — config updates never change it, so the genesis config * is the correct input even for post-update transactions. * * For a nested multisig owner approval, the parent digest becomes the nested * approval's `payload`, with the nested multisig `account`. * * @example * ```ts twoslash * import { MultisigConfig, TxEnvelopeTempo } from 'ox/tempo' * * const genesisConfig = MultisigConfig.from({ * threshold: 1, * owners: [ * { * owner: '0x1111111111111111111111111111111111111111', * weight: 1 * } * ] * }) * * const envelope = TxEnvelopeTempo.from({ * chainId: 1, * calls: [] * }) * * const digest = MultisigConfig.getSignPayload({ * payload: TxEnvelopeTempo.getSignPayload(envelope), * genesisConfig * }) * ``` * * @example * ### From `account` * * If you already have the permanent `account` (for example, recovered from a * stored envelope), pass it directly: * * ```ts twoslash * import { MultisigConfig, TxEnvelopeTempo } from 'ox/tempo' * * const genesisConfig = MultisigConfig.from({ * threshold: 1, * owners: [ * { * owner: '0x1111111111111111111111111111111111111111', * weight: 1 * } * ] * }) * const account = MultisigConfig.getAddress(genesisConfig) * * const envelope = TxEnvelopeTempo.from({ * chainId: 1, * calls: [] * }) * * const digest = MultisigConfig.getSignPayload({ * payload: TxEnvelopeTempo.getSignPayload(envelope), * account * }) * ``` * * @param value - The digest derivation parameters. * @returns The owner approval digest. */ export function getSignPayload(value: getSignPayload.Value): Hex.Hex { const { payload } = value const account = 'account' in value && value.account ? value.account : getAddress((value as { genesisConfig: Config }).genesisConfig) return Hash.keccak256( Hex.concat(Hex.fromString(signatureDomain), Hex.from(payload), account), ) } export declare namespace getSignPayload { type Value = { /** The inner transaction sign payload (`tx.signature_hash()`). */ payload: Hex.Hex | Bytes.Bytes } & OneOf< | { /** The native multisig account address. */ account: Address.Address } | { /** * The initial multisig config (the bootstrap config that derived the * permanent `account`). Used to derive the account automatically. * Config updates never change `account`, so the genesis config is * also the correct input for post-update transactions. */ genesisConfig: Config } > type ErrorType = | getAddress.ErrorType | Hash.keccak256.ErrorType | Hex.concat.ErrorType | Hex.from.ErrorType | Errors.GlobalErrorType } /** * Converts a {@link ox#MultisigConfig.Config} to its RLP tuple form (carried * by the multisig signature `init`). * * Tuple shape: `[salt, threshold, [[owner, weight], ...]]`. The * 32-byte `salt` encodes as a full fixed-width string; other integers use * canonical RLP encoding (zero values encode as `0x`). * * @example * ```ts twoslash * import { MultisigConfig } from 'ox/tempo' * * const tuple = MultisigConfig.toTuple({ * threshold: 1, * owners: [ * { * owner: '0x1111111111111111111111111111111111111111', * weight: 1 * } * ] * }) * ``` * * @param config - The multisig config. * @returns The RLP tuple. */ export function toTuple(config: Config): Tuple { assert(config) const owners = config.owners.map( (owner) => [owner.owner, Hex.fromNumber(owner.weight)] as Hex.Hex[], ) // `salt` is a fixed 32-byte value: it RLP-encodes as a full 32-byte string // (including the zero salt), never trimmed like an integer. const salt = config.salt ? Hex.padLeft(config.salt, 32) : zeroSalt return [salt, Hex.fromNumber(config.threshold), owners] as const } /** * Validates a native multisig {@link ox#MultisigConfig.Config}. Returns `true` * if valid, `false` otherwise. * * @example * ```ts twoslash * import { MultisigConfig } from 'ox/tempo' * * const valid = MultisigConfig.validate({ * threshold: 1, * owners: [ * { * owner: '0x1111111111111111111111111111111111111111', * weight: 1 * } * ] * }) * // @log: true * ``` * * @param config - The multisig config. * @returns Whether the config is valid. */ export function validate(config: Config): boolean { try { assert(config) return true } catch { return false } } /** Thrown when a native multisig config is invalid. */ export class InvalidConfigError extends Errors.BaseError { override readonly name = 'MultisigConfig.InvalidConfigError' constructor({ reason }: { reason: string }) { super(`Invalid native multisig config: ${reason}.`) } }