From 854591a0f155a2cb6c98a7efaefb542896354021 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:30:08 +0200 Subject: [PATCH 1/8] feat(tempo): native multisig (ConfigurableAccount) for TIP-1061 Adds tempo/ConfigurableAccount for deriving stable multisig account addresses and permanent config IDs from a weighted owner config, plus the owner-approval digest owners sign. tempo/SignatureEnvelope gains a multisig variant (type 0x05) aggregating primitive owner approvals and carrying the optional bootstrap config (init) on the first tx, plus SignatureEnvelope.sortMultisigApprovals to order approvals by recovered owner. Verified end-to-end against the multisig devnet. Amp-Thread-ID: https://ampcode.com/threads/T-019e86f0-0dd6-71fc-8b67-efa5b7a4b9c5 --- .changeset/tempo-native-multisig.md | 5 + src/tempo/ConfigurableAccount.test.ts | 233 ++++++++++++++ src/tempo/ConfigurableAccount.ts | 424 ++++++++++++++++++++++++++ src/tempo/SignatureEnvelope.test.ts | 215 ++++++++++++- src/tempo/SignatureEnvelope.ts | 271 +++++++++++++++- src/tempo/e2e.test.ts | 209 +++++++++++++ src/tempo/index.ts | 26 ++ test/tempo/config.ts | 4 +- test/tempo/prool.ts | 2 + 9 files changed, 1376 insertions(+), 13 deletions(-) create mode 100644 .changeset/tempo-native-multisig.md create mode 100644 src/tempo/ConfigurableAccount.test.ts create mode 100644 src/tempo/ConfigurableAccount.ts diff --git a/.changeset/tempo-native-multisig.md b/.changeset/tempo-native-multisig.md new file mode 100644 index 00000000..2f6df52f --- /dev/null +++ b/.changeset/tempo-native-multisig.md @@ -0,0 +1,5 @@ +--- +'ox': minor +--- + +Added support for TIP-1061 native multisig accounts. A new `tempo/ConfigurableAccount` module derives stable multisig account addresses and permanent config IDs from a weighted owner configuration and computes the owner approval digest that owners sign. `tempo/SignatureEnvelope` gains a `multisig` signature variant (type `0x05`) that aggregates primitive owner approvals and carries the optional bootstrap config (`init`) on the first transaction from a derived account. diff --git a/src/tempo/ConfigurableAccount.test.ts b/src/tempo/ConfigurableAccount.test.ts new file mode 100644 index 00000000..1becaaa4 --- /dev/null +++ b/src/tempo/ConfigurableAccount.test.ts @@ -0,0 +1,233 @@ +import { ConfigurableAccount } from 'ox/tempo' +import { describe, expect, test } from 'vitest' + +// Ground-truth vectors independently computed via `cast keccak` over the exact +// preimages defined by TIP-1061 / the Tempo reference implementation. +const owner1 = '0x1111111111111111111111111111111111111111' +const owner2 = '0x2222222222222222222222222222222222222222' + +const singleOwnerConfig = { + threshold: 1, + owners: [{ owner: owner1, weight: 1 }], +} as const + +describe('from', () => { + test('sorts owners ascending by address', () => { + const config = ConfigurableAccount.from({ + threshold: 2, + owners: [ + { owner: owner2, weight: 1 }, + { owner: owner1, weight: 1 }, + ], + }) + expect(config.owners.map((o) => o.owner)).toEqual([owner1, owner2]) + }) + + test('asserts validity', () => { + expect(() => + ConfigurableAccount.from({ threshold: 0, owners: [] }), + ).toThrowError() + }) +}) + +describe('configId', () => { + test('matches independent ground truth', () => { + expect( + ConfigurableAccount.toConfigId(singleOwnerConfig), + ).toMatchInlineSnapshot( + `"0xd1f20e1a5bfdd89488f57f68db5bd1aae9a51b510f4a042b2604b57a0b7b471d"`, + ) + }) + + test('is stable across calls', () => { + expect(ConfigurableAccount.toConfigId(singleOwnerConfig)).toBe( + ConfigurableAccount.toConfigId(singleOwnerConfig), + ) + }) + + test('differs for a different salt', () => { + expect(ConfigurableAccount.toConfigId(singleOwnerConfig)).not.toBe( + ConfigurableAccount.toConfigId({ + ...singleOwnerConfig, + salt: `0x${'42'.repeat(32)}`, + }), + ) + }) + + test('throws on invalid config', () => { + expect(() => + ConfigurableAccount.toConfigId({ + threshold: 5, + owners: singleOwnerConfig.owners, + }), + ).toThrowError() + }) +}) + +describe('getAddress', () => { + test('matches independent ground truth', () => { + expect( + ConfigurableAccount.getAddress({ config: singleOwnerConfig }), + ).toMatchInlineSnapshot(`"0x6ca655065b1de473d903eebd50e5cb4996e10468"`) + }) + + test('derives from config or configId identically', () => { + const configId = ConfigurableAccount.toConfigId(singleOwnerConfig) + expect(ConfigurableAccount.getAddress({ configId })).toBe( + ConfigurableAccount.getAddress({ config: singleOwnerConfig }), + ) + }) + + test('config ID and address are chain-independent', () => { + // Derivation does not include chain ID; identical config → identical id/address. + const a = ConfigurableAccount.toConfigId(singleOwnerConfig) + const b = ConfigurableAccount.toConfigId( + ConfigurableAccount.from(singleOwnerConfig), + ) + expect(a).toBe(b) + }) +}) + +describe('getSignPayload', () => { + test('matches independent ground truth', () => { + const configId = ConfigurableAccount.toConfigId(singleOwnerConfig) + const account = ConfigurableAccount.getAddress({ configId }) + expect( + ConfigurableAccount.getSignPayload({ + payload: `0x${'42'.repeat(32)}`, + account, + configId, + }), + ).toMatchInlineSnapshot( + `"0xe3d66f6118b89a67c71c8137c46abf0c829056a46ee6a038a1b42c84529fc17e"`, + ) + }) +}) + +describe('toTuple / fromTuple', () => { + test('round-trips', () => { + const config = ConfigurableAccount.from({ + threshold: 3, + owners: [ + { owner: owner1, weight: 1 }, + { owner: owner2, weight: 2 }, + ], + }) + const tuple = ConfigurableAccount.toTuple(config) + expect(ConfigurableAccount.fromTuple(tuple)).toEqual(config) + }) + + test('encodes each owner as `[owner, weight]`', () => { + const [, , owners] = ConfigurableAccount.toTuple(singleOwnerConfig) + expect(owners[0]).toEqual([owner1, '0x1']) + }) + + test('encodes salt as a full 32-byte string (first element)', () => { + const [salt] = ConfigurableAccount.toTuple(singleOwnerConfig) + expect(salt).toBe(ConfigurableAccount.zeroSalt) + }) + + test('round-trips a non-zero salt', () => { + const config = ConfigurableAccount.from({ + ...singleOwnerConfig, + salt: `0x${'42'.repeat(32)}`, + }) + const tuple = ConfigurableAccount.toTuple(config) + expect(tuple[0]).toBe(`0x${'42'.repeat(32)}`) + expect(ConfigurableAccount.fromTuple(tuple)).toEqual(config) + }) +}) + +describe('assert / validate', () => { + test('valid config', () => { + expect(ConfigurableAccount.validate(singleOwnerConfig)).toBe(true) + }) + + test('empty owners', () => { + expect(ConfigurableAccount.validate({ threshold: 1, owners: [] })).toBe( + false, + ) + }) + + test('too many owners', () => { + const owners = Array.from({ length: 11 }, (_, i) => ({ + owner: `0x${(i + 1).toString(16).padStart(40, '0')}` as `0x${string}`, + weight: 1, + })) + expect(ConfigurableAccount.validate({ threshold: 1, owners })).toBe(false) + }) + + test('zero threshold', () => { + expect( + ConfigurableAccount.validate({ + threshold: 0, + owners: singleOwnerConfig.owners, + }), + ).toBe(false) + }) + + test('threshold exceeds total weight', () => { + expect( + ConfigurableAccount.validate({ + threshold: 2, + owners: singleOwnerConfig.owners, + }), + ).toBe(false) + }) + + test('zero owner weight', () => { + expect( + ConfigurableAccount.validate({ + threshold: 1, + owners: [{ owner: owner1, weight: 0 }], + }), + ).toBe(false) + }) + + test('zero owner address', () => { + expect( + ConfigurableAccount.validate({ + threshold: 1, + owners: [ + { + owner: '0x0000000000000000000000000000000000000000', + weight: 1, + }, + ], + }), + ).toBe(false) + }) + + test('unsorted owners', () => { + expect( + ConfigurableAccount.validate({ + threshold: 1, + owners: [ + { owner: owner2, weight: 1 }, + { owner: owner1, weight: 1 }, + ], + }), + ).toBe(false) + }) + + test('duplicate owners', () => { + expect( + ConfigurableAccount.validate({ + threshold: 1, + owners: [ + { owner: owner1, weight: 1 }, + { owner: owner1, weight: 1 }, + ], + }), + ).toBe(false) + }) + + test('invalid salt size', () => { + expect( + ConfigurableAccount.validate({ + ...singleOwnerConfig, + salt: '0x42', + }), + ).toBe(false) + }) +}) diff --git a/src/tempo/ConfigurableAccount.ts b/src/tempo/ConfigurableAccount.ts new file mode 100644 index 00000000..4004773d --- /dev/null +++ b/src/tempo/ConfigurableAccount.ts @@ -0,0 +1,424 @@ +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 } from '../core/internal/types.js' + +/** Maximum number of owners allowed in a native multisig config. */ +export const maxOwners = 10 + +/** Maximum encoded byte length for one primitive 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 the native multisig config ID derivation. */ +const configDomain = 'tempo:multisig:config' + +/** Domain prefix for native multisig owner approvals. */ +const signatureDomain = 'tempo:multisig:signature' + +/** + * Native multisig configuration. Determines the permanent config ID and the + * stable multisig account address. + */ +export type Config = Compute<{ + /** + * Caller-chosen 32-byte salt mixed into the permanent config ID. Defaults to + * the {@link ox#ConfigurableAccount.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[] +}> + +/** Native multisig owner entry. */ +export type Owner = { + /** Owner address (recovered from the owner's primitive signature). */ + owner: Address.Address + /** Nonzero owner weight. */ + weight: numberType +} + +/** RLP tuple representation of a {@link ox#ConfigurableAccount.Config}. */ +export type Tuple = readonly [ + salt: Hex.Hex, + threshold: Hex.Hex, + owners: readonly Hex.Hex[][], +] + +/** + * Asserts that a native multisig {@link ox#ConfigurableAccount.Config} is valid. + * + * Mirrors the Tempo `validate_multisig_config` rules: owners non-empty and + * `<= maxOwners`, strictly ascending unique nonzero owner addresses, nonzero + * owner weights, `threshold >= 1`, total weight `<= u32::MAX`, and + * `threshold <= total weight`. + * + * @example + * ```ts twoslash + * import { ConfigurableAccount } from 'ox/tempo' + * + * ConfigurableAccount.assert({ + * threshold: 1, + * owners: [ + * { owner: '0x1111111111111111111111111111111111111111', weight: 1 }, + * ], + * }) + * ``` + * + * @param config - The multisig config. + */ +export function assert(config: Config): 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(threshold) < 1) + throw new InvalidConfigError({ reason: 'threshold cannot be zero' }) + + 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(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 > 0xffffffff) + throw new InvalidConfigError({ + reason: 'total owner weight exceeds u32 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#ConfigurableAccount.Config}. + * + * Sorts owners into strictly ascending `owner` address order (the canonical + * form required for config ID derivation) and asserts the config is valid. + * + * @example + * ```ts twoslash + * import { ConfigurableAccount } from 'ox/tempo' + * + * const config = ConfigurableAccount.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( + config: Config, +): Config { + 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 + assert(normalized) + return normalized +} + +/** + * Converts an RLP {@link ox#ConfigurableAccount.Tuple} back to a + * {@link ox#ConfigurableAccount.Config}. + * + * @example + * ```ts twoslash + * import { ConfigurableAccount } from 'ox/tempo' + * + * const config = ConfigurableAccount.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. + * + * `keccak256("tempo:multisig:account" || config_id)[12:32]`. + * + * @example + * ```ts twoslash + * import { ConfigurableAccount } from 'ox/tempo' + * + * const config = ConfigurableAccount.from({ + * threshold: 1, + * owners: [ + * { owner: '0x1111111111111111111111111111111111111111', weight: 1 }, + * ], + * }) + * + * const address = ConfigurableAccount.getAddress({ config }) + * ``` + * + * @param value - The config or config ID to derive the address from. + * @returns The multisig account address. + */ +export function getAddress(value: getAddress.Value): Address.Address { + const id = 'configId' in value ? value.configId : toConfigId(value.config) + const hash = Hash.keccak256(Hex.concat(Hex.fromString(accountDomain), id)) + return Address.from(Hex.slice(hash, 12, 32)) +} + +export declare namespace getAddress { + type Value = { config: Config } | { configId: Hex.Hex } + + type ErrorType = + | toConfigId.ErrorType + | Address.from.ErrorType + | Hash.keccak256.ErrorType + | Hex.concat.ErrorType + | Hex.slice.ErrorType + | Errors.GlobalErrorType +} + +/** + * Computes the digest a native multisig owner approves (signs). + * + * `keccak256("tempo:multisig:signature" || inner_digest || account || config_id)`, + * where `inner_digest` is the transaction sign payload + * ({@link ox#TxEnvelopeTempo.(getSignPayload:function)}). + * + * @example + * ```ts twoslash + * import { ConfigurableAccount, TxEnvelopeTempo } from 'ox/tempo' + * + * const config = ConfigurableAccount.from({ + * threshold: 1, + * owners: [ + * { owner: '0x1111111111111111111111111111111111111111', weight: 1 }, + * ], + * }) + * const configId = ConfigurableAccount.toConfigId(config) + * const account = ConfigurableAccount.getAddress({ configId }) + * + * const envelope = TxEnvelopeTempo.from({ + * chainId: 1, + * calls: [], + * }) + * + * const digest = ConfigurableAccount.getSignPayload({ + * payload: TxEnvelopeTempo.getSignPayload(envelope), + * account, + * configId, + * }) + * ``` + * + * @param value - The digest derivation parameters. + * @returns The owner approval digest. + */ +export function getSignPayload(value: getSignPayload.Value): Hex.Hex { + const { payload, account, configId } = value + return Hash.keccak256( + Hex.concat( + Hex.fromString(signatureDomain), + Hex.from(payload), + account, + configId, + ), + ) +} + +export declare namespace getSignPayload { + type Value = { + /** The inner transaction sign payload (`tx.signature_hash()`). */ + payload: Hex.Hex | Bytes.Bytes + /** The native multisig account address. */ + account: Address.Address + /** The permanent config ID. */ + configId: Hex.Hex + } + + type ErrorType = + | Hash.keccak256.ErrorType + | Hex.concat.ErrorType + | Hex.from.ErrorType + | Errors.GlobalErrorType +} + +/** + * Derives the permanent config ID for a native multisig + * {@link ox#ConfigurableAccount.Config}. + * + * Preimage (fixed-width big-endian, **not** RLP): + * `keccak256("tempo:multisig:config" || salt || be_u32(threshold) || be_u32(owners.length) + * || (owner || be_u32(weight)) for each owner)`. + * + * @example + * ```ts twoslash + * import { ConfigurableAccount } from 'ox/tempo' + * + * const config = ConfigurableAccount.from({ + * threshold: 1, + * owners: [ + * { owner: '0x1111111111111111111111111111111111111111', weight: 1 }, + * ], + * }) + * + * const configId = ConfigurableAccount.toConfigId(config) + * ``` + * + * @param config - The multisig config. + * @returns The 32-byte config ID. + */ +export function toConfigId(config: Config): Hex.Hex { + assert(config) + const id = Hash.keccak256( + Hex.concat( + Hex.fromString(configDomain), + Hex.padLeft(config.salt ?? zeroSalt, 32), + Hex.fromNumber(config.threshold, { size: 4 }), + Hex.fromNumber(config.owners.length, { size: 4 }), + ...config.owners.flatMap((owner) => [ + owner.owner, + Hex.fromNumber(owner.weight, { size: 4 }), + ]), + ), + ) + if (Hex.toBigInt(id) === 0n) + throw new InvalidConfigError({ reason: 'config ID cannot be zero' }) + return id +} + +export declare namespace toConfigId { + type ErrorType = + | assert.ErrorType + | Hash.keccak256.ErrorType + | Hex.concat.ErrorType + | Hex.fromNumber.ErrorType + | Hex.fromString.ErrorType + | Errors.GlobalErrorType +} + +/** + * Converts a {@link ox#ConfigurableAccount.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 { ConfigurableAccount } from 'ox/tempo' + * + * const tuple = ConfigurableAccount.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#ConfigurableAccount.Config}. Returns `true` + * if valid, `false` otherwise. + * + * @example + * ```ts twoslash + * import { ConfigurableAccount } from 'ox/tempo' + * + * const valid = ConfigurableAccount.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 = 'ConfigurableAccount.InvalidConfigError' + constructor({ reason }: { reason: string }) { + super(`Invalid native multisig config: ${reason}.`) + } +} diff --git a/src/tempo/SignatureEnvelope.test.ts b/src/tempo/SignatureEnvelope.test.ts index 4bb4ebed..da8f4853 100644 --- a/src/tempo/SignatureEnvelope.test.ts +++ b/src/tempo/SignatureEnvelope.test.ts @@ -9,6 +9,7 @@ import { WebCryptoP256, } from 'ox' import { describe, expect, test } from 'vitest' +import * as ConfigurableAccount from './ConfigurableAccount.js' import * as SignatureEnvelope from './SignatureEnvelope.js' const publicKey = PublicKey.from({ @@ -512,7 +513,7 @@ describe('deserialize', () => { SignatureEnvelope.deserialize('0xdeadbeef'), ).toThrowErrorMatchingInlineSnapshot( ` - [SignatureEnvelope.InvalidSerializedError: Unable to deserialize signature envelope: Unknown signature type identifier: 0xde. Expected 0x01 (P256), 0x02 (WebAuthn), 0x03 (Keychain V1), or 0x04 (Keychain V2) + [SignatureEnvelope.InvalidSerializedError: Unable to deserialize signature envelope: Unknown signature type identifier: 0xde. Expected 0x01 (P256), 0x02 (WebAuthn), 0x03 (Keychain V1), 0x04 (Keychain V2), or 0x05 (Multisig) Serialized: 0xdeadbeef] `, @@ -672,7 +673,7 @@ describe('deserialize', () => { SignatureEnvelope.deserialize(unknownType), ).toThrowErrorMatchingInlineSnapshot( ` - [SignatureEnvelope.InvalidSerializedError: Unable to deserialize signature envelope: Unknown signature type identifier: 0xff. Expected 0x01 (P256), 0x02 (WebAuthn), 0x03 (Keychain V1), or 0x04 (Keychain V2) + [SignatureEnvelope.InvalidSerializedError: Unable to deserialize signature envelope: Unknown signature type identifier: 0xff. Expected 0x01 (P256), 0x02 (WebAuthn), 0x03 (Keychain V1), 0x04 (Keychain V2), or 0x05 (Multisig) Serialized: 0xff000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000] `, @@ -1696,6 +1697,67 @@ describe('serialize', () => { }) }) +describe('sortMultisigApprovals', () => { + const account = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as const + const configId = `0x${'11'.repeat(32)}` as const + const payload = `0x${'42'.repeat(32)}` as const + const digest = ConfigurableAccount.getSignPayload({ + account, + configId, + payload, + }) + + const owners = Array.from({ length: 3 }, () => { + const privateKey = Secp256k1.randomPrivateKey() + const address = Address.fromPublicKey( + Secp256k1.getPublicKey({ privateKey }), + ) + const signature = SignatureEnvelope.from( + Secp256k1.sign({ payload: digest, privateKey }), + ) + return { address, signature } as const + }) + const ascending = [...owners].sort((a, b) => + Hex.toBigInt(a.address) < Hex.toBigInt(b.address) ? -1 : 1, + ) + + test('behavior: orders approvals ascending by recovered owner address', () => { + const ordered = SignatureEnvelope.sortMultisigApprovals({ + account, + configId, + payload, + // Provide approvals in reverse of the canonical order. + signatures: [...ascending].reverse().map((owner) => owner.signature), + }) + expect(ordered).toEqual(ascending.map((owner) => owner.signature)) + }) + + test('behavior: already-sorted input is unchanged', () => { + const signatures = ascending.map((owner) => owner.signature) + expect( + SignatureEnvelope.sortMultisigApprovals({ + account, + configId, + payload, + signatures, + }), + ).toEqual(signatures) + }) + + test('behavior: recovered order matches the config owner order', () => { + const ordered = SignatureEnvelope.sortMultisigApprovals({ + account, + configId, + payload, + signatures: owners.map((owner) => owner.signature), + }) + const recovered = ordered.map((signature) => + SignatureEnvelope.extractAddress({ payload: digest, signature }), + ) + expect(recovered).toEqual(ascending.map((owner) => owner.address)) + }) +}) + describe('validate', () => { describe('secp256k1', () => { test('behavior: returns true for valid signature', () => { @@ -2692,3 +2754,152 @@ describe('CoercionError', () => { ) }) }) + +describe('multisig', () => { + const account = '0x8ba6d26ff5c4e82ba0c8caf8c8ca794e1489a7ae' + const configId = + '0x01781fe551182476f2422c759e82d81c92e3263737afbbad57def6e8b69d21f5' + + // P256 signatures do not carry `yParity` in the wire format, so use a clean + // inner signature for round-trip equality checks. + const innerP256 = SignatureEnvelope.from({ + signature: { r: p256Signature.r, s: p256Signature.s }, + publicKey, + prehash: true, + }) + + const envelope = SignatureEnvelope.from({ + type: 'multisig', + account, + configId, + signatures: [SignatureEnvelope.from(signature_secp256k1), innerP256], + }) + + test('serialize: type byte 0x05 prefix', () => { + const serialized = SignatureEnvelope.serialize(envelope) + expect(serialized.startsWith('0x05')).toBe(true) + }) + + test('serialize/deserialize round-trip', () => { + const serialized = SignatureEnvelope.serialize(envelope) + expect(SignatureEnvelope.deserialize(serialized)).toEqual(envelope) + }) + + test('getType', () => { + expect(SignatureEnvelope.getType(envelope)).toBe('multisig') + }) + + test('extractAddress returns the multisig account', () => { + expect( + SignatureEnvelope.extractAddress({ + payload: '0xdeadbeef', + signature: envelope, + }), + ).toBe(account) + }) + + test('toRpc/fromRpc round-trip', () => { + const rpc = SignatureEnvelope.toRpc(envelope) + expect(rpc.type).toBe('multisig') + expect(SignatureEnvelope.fromRpc(rpc)).toEqual(envelope) + }) + + test('assert: missing properties', () => { + expect(() => + SignatureEnvelope.assert({ type: 'multisig', account } as never), + ).toThrowError() + }) + + describe('init (bootstrap)', () => { + const init = { + salt: `0x${'00'.repeat(32)}` as const, + threshold: 1, + owners: [ + { + owner: '0x1111111111111111111111111111111111111111' as const, + weight: 1, + }, + ], + } + + const bootstrapEnvelope = SignatureEnvelope.from({ + type: 'multisig', + account, + configId, + signatures: [SignatureEnvelope.from(signature_secp256k1), innerP256], + init, + }) + + test('serialize/deserialize round-trip with init', () => { + const serialized = SignatureEnvelope.serialize(bootstrapEnvelope) + expect(SignatureEnvelope.deserialize(serialized)).toEqual( + bootstrapEnvelope, + ) + }) + + test('serialize/deserialize round-trip preserves non-zero salt', () => { + const salted = SignatureEnvelope.from({ + type: 'multisig', + account, + configId, + signatures: [SignatureEnvelope.from(signature_secp256k1), innerP256], + init: { ...init, salt: `0x${'42'.repeat(32)}` }, + }) + const serialized = SignatureEnvelope.serialize(salted) + const deserialized = SignatureEnvelope.deserialize( + serialized, + ) as SignatureEnvelope.Multisig + expect(deserialized.init?.salt).toBe(`0x${'42'.repeat(32)}`) + expect(deserialized).toEqual(salted) + }) + + test('absent init has no `init` key after deserialize', () => { + const serialized = SignatureEnvelope.serialize(envelope) + const deserialized = SignatureEnvelope.deserialize(serialized) + expect('init' in deserialized).toBe(false) + }) + + test('init absent vs present produce different serializations', () => { + expect(SignatureEnvelope.serialize(envelope)).not.toBe( + SignatureEnvelope.serialize(bootstrapEnvelope), + ) + }) + + test('toRpc/fromRpc round-trip with init', () => { + const rpc = SignatureEnvelope.toRpc(bootstrapEnvelope) + expect(rpc.init).toEqual(init) + expect(SignatureEnvelope.fromRpc(rpc)).toEqual(bootstrapEnvelope) + }) + + test('toRpc encodes owner approvals as serialized hex (node `Vec`)', () => { + const multisig = bootstrapEnvelope as SignatureEnvelope.Multisig + const rpc = SignatureEnvelope.toRpc( + multisig, + ) as SignatureEnvelope.MultisigRpc + expect(rpc.signatures).toEqual( + multisig.signatures.map((s) => SignatureEnvelope.serialize(s)), + ) + }) + + test('fromRpc detects multisig by shape (no `type` field)', () => { + const rpc = SignatureEnvelope.toRpc(bootstrapEnvelope) + // The node omits the `type` discriminant; detection is shape-based. + const { type: _type, ...untyped } = rpc + expect(SignatureEnvelope.fromRpc(untyped as never)).toEqual( + bootstrapEnvelope, + ) + }) + + test('assert: invalid init config throws', () => { + expect(() => + SignatureEnvelope.assert({ + type: 'multisig', + account, + configId, + signatures: [], + init: { threshold: 1, owners: [] }, + } as never), + ).toThrowError() + }) + }) +}) diff --git a/src/tempo/SignatureEnvelope.ts b/src/tempo/SignatureEnvelope.ts index beeb8845..62b3b35b 100644 --- a/src/tempo/SignatureEnvelope.ts +++ b/src/tempo/SignatureEnvelope.ts @@ -13,16 +13,19 @@ import type { import * as Json from '../core/Json.js' import * as ox_P256 from '../core/P256.js' import type * as PublicKey from '../core/PublicKey.js' +import * as Rlp from '../core/Rlp.js' import * as ox_Secp256k1 from '../core/Secp256k1.js' import * as Signature from '../core/Signature.js' import type * as WebAuthnP256 from '../core/WebAuthnP256.js' import * as ox_WebAuthnP256 from '../core/WebAuthnP256.js' +import * as ConfigurableAccount from './ConfigurableAccount.js' /** Signature type identifiers for encoding/decoding */ const serializedP256Type = '0x01' const serializedWebAuthnType = '0x02' const serializedKeychainType = '0x03' const serializedKeychainV2Type = '0x04' +const serializedMultisigType = '0x05' /** Serialized magic identifier for Tempo signature envelopes. */ export const magicBytes = @@ -69,7 +72,13 @@ export type GetType< userAddress: Address.Address } ? 'keychain' - : never + : envelope extends { + account: Address.Address + configId: `0x${string}` + signatures: any + } + ? 'multisig' + : never /** * Represents a signature envelope that can contain different signature types. @@ -99,13 +108,14 @@ export type SignatureEnvelope = OneOf< | P256 | WebAuthn | Keychain + | Multisig > /** * RPC-formatted signature envelope. */ export type SignatureEnvelopeRpc = OneOf< - Secp256k1Rpc | P256Rpc | WebAuthnRpc | KeychainRpc + Secp256k1Rpc | P256Rpc | WebAuthnRpc | KeychainRpc | MultisigRpc > /** @@ -137,6 +147,43 @@ export type KeychainRpc = { version?: KeychainVersion | undefined } +/** + * Native multisig signature (type `0x05`). + * + * Wraps a set of primitive owner approvals (secp256k1, p256, or webAuthn) over the + * multisig owner approval digest. The transaction sender is the derived `account`, + * authorized once the recovered owner weights meet the configured threshold. + * + * [TIP-1061](https://tips.sh/1061) + */ +export type Multisig = { + type: 'multisig' + /** Native multisig account address. */ + account: Address.Address + /** Permanent config ID derived from the initial multisig config. */ + configId: Hex.Hex + /** Primitive owner approvals over the multisig owner approval digest. */ + signatures: readonly SignatureEnvelope[] + /** + * Initial native multisig config for bootstrapping this account. Present only on + * the first (bootstrap) transaction from the derived account; absent on every + * subsequent transaction. + */ + init?: ConfigurableAccount.Config | undefined +} + +export type MultisigRpc = { + type: 'multisig' + account: Address.Address + configId: Hex.Hex + /** + * Encoded primitive owner approvals (raw serialized signatures), matching the + * node's `Vec` representation. + */ + signatures: readonly Serialized[] + init?: ConfigurableAccount.Config | undefined +} + export type P256 = { prehash: boolean publicKey: PublicKey.PublicKey @@ -276,12 +323,26 @@ export function assert(envelope: PartialBy): void { assert(keychain.inner) return } + + if (type === 'multisig') { + const multisig = envelope as Multisig + const missing: string[] = [] + if (!multisig.account) missing.push('account') + if (!multisig.configId) missing.push('configId') + if (!Array.isArray(multisig.signatures)) missing.push('signatures') + if (missing.length > 0) + throw new MissingPropertiesError({ envelope, missing, type: 'multisig' }) + for (const inner of multisig.signatures) assert(inner) + if (multisig.init) ConfigurableAccount.assert(multisig.init) + return + } } export declare namespace assert { type ErrorType = | CoercionError | MissingPropertiesError + | ConfigurableAccount.assert.ErrorType | Signature.assert.ErrorType | Errors.GlobalErrorType } @@ -319,6 +380,9 @@ export function extractAddress( if (root) return signature.userAddress return extractAddress({ ...options, signature: signature.inner }) } + // Native multisig signatures have no single signer; the recovered sender is the + // derived multisig account address. + if (signature.type === 'multisig') return signature.account return Address.fromPublicKey(extractPublicKey(options)) } @@ -381,6 +445,10 @@ export function extractPublicKey( return signature.publicKey case 'keychain': return extractPublicKey({ payload, signature: signature.inner }) + case 'multisig': + // A multisig signature aggregates multiple owner approvals and has no + // single public key; recover the multisig account via `extractAddress`. + throw new CoercionError({ envelope: signature }) } } @@ -395,6 +463,7 @@ export declare namespace extractPublicKey { type ReturnType = PublicKey.PublicKey type ErrorType = + | CoercionError | ox_Secp256k1.recoverPublicKey.ErrorType | Errors.GlobalErrorType } @@ -543,8 +612,33 @@ export function deserialize(value: Serialized): SignatureEnvelope { } satisfies Keychain } + if (typeId === serializedMultisigType) { + // Wire format: `0x05 || rlp([account, configId, signatures, init])`. `init` + // is optional: absent when the element is missing or the `0x80` placeholder + // (decoded as the empty string `0x`), otherwise the bootstrap config list. + const [account, configId, signatures, init] = Rlp.toHex(data) as [ + Hex.Hex, + Hex.Hex, + readonly Hex.Hex[], + (Hex.Hex | ConfigurableAccount.Tuple)?, + ] + return { + type: 'multisig', + account, + configId, + signatures: signatures.map((signature) => deserialize(signature)), + ...(init && init !== '0x' + ? { + init: ConfigurableAccount.fromTuple( + init as ConfigurableAccount.Tuple, + ), + } + : {}), + } satisfies Multisig + } + throw new InvalidSerializedError({ - reason: `Unknown signature type identifier: ${typeId}. Expected ${serializedP256Type} (P256), ${serializedWebAuthnType} (WebAuthn), ${serializedKeychainType} (Keychain V1), or ${serializedKeychainV2Type} (Keychain V2)`, + reason: `Unknown signature type identifier: ${typeId}. Expected ${serializedP256Type} (P256), ${serializedWebAuthnType} (WebAuthn), ${serializedKeychainType} (Keychain V1), ${serializedKeychainV2Type} (Keychain V2), or ${serializedMultisigType} (Multisig)`, serialized, }) } @@ -680,6 +774,21 @@ export function from( return { signature: value, type: 'secp256k1' } as never const type = getType(value) + + if (type === 'multisig') { + const multisig = value as Multisig + return { + ...multisig, + signatures: multisig.signatures.map((signature) => from(signature)), + // Normalize the bootstrap config (sorts owners, defaults the salt) so the + // in-memory envelope matches what `deserialize` reconstructs. + ...(multisig.init + ? { init: ConfigurableAccount.from(multisig.init) } + : {}), + type, + } as never + } + return { ...value, ...(type === 'p256' ? { prehash: value.prehash } : {}), @@ -837,14 +946,37 @@ export function fromRpc(envelope: SignatureEnvelopeRpc): SignatureEnvelope { if ( envelope.type === 'keychain' || ('userAddress' in envelope && 'signature' in envelope) - ) + ) { + const keychain = envelope as KeychainRpc return { type: 'keychain', - userAddress: envelope.userAddress, - inner: fromRpc(envelope.signature), - ...(envelope.keyId ? { keyId: envelope.keyId } : {}), - ...(envelope.version ? { version: envelope.version } : {}), + userAddress: keychain.userAddress, + inner: fromRpc(keychain.signature), + ...(keychain.keyId ? { keyId: keychain.keyId } : {}), + ...(keychain.version ? { version: keychain.version } : {}), } + } + + if ( + envelope.type === 'multisig' || + ('account' in envelope && + 'configId' in envelope && + 'signatures' in envelope) + ) { + const multisig = envelope as MultisigRpc + return { + type: 'multisig', + account: multisig.account, + configId: multisig.configId, + // Owner approvals are raw serialized signatures (node `Vec`). + signatures: multisig.signatures.map((signature) => + deserialize(signature), + ), + ...(multisig.init + ? { init: ConfigurableAccount.from(multisig.init) } + : {}), + } + } throw new CoercionError({ envelope }) } @@ -922,6 +1054,14 @@ export function getType< if ('userAddress' in envelope && 'inner' in envelope) return 'keychain' as never + // Detect Multisig signature + if ( + 'account' in envelope && + 'configId' in envelope && + 'signatures' in envelope + ) + return 'multisig' as never + throw new CoercionError({ envelope, }) @@ -1015,6 +1155,24 @@ export function serialize( ) } + if (type === 'multisig') { + const multisig = envelope as Multisig + // Format: `0x05 || rlp([account, configId, signatures, init])`, where each + // owner approval is an encoded primitive signature. `init` is the bootstrap + // config (an RLP list) when present, otherwise the canonical empty-string + // placeholder (`0x` → RLP `0x80`). + return Hex.concat( + serializedMultisigType, + Rlp.fromHex([ + multisig.account, + multisig.configId, + multisig.signatures.map((signature) => serialize(signature)), + multisig.init ? ConfigurableAccount.toTuple(multisig.init) : '0x', + ]), + options.magic ? magicBytes : '0x', + ) + } + throw new CoercionError({ envelope }) } @@ -1028,6 +1186,89 @@ export declare namespace serialize { } } +/** + * Orders native multisig owner approvals into the strictly-ascending + * recovered-owner order the Tempo node requires for the multisig `signatures` + * array (the node enforces "recovered owners must be strictly ascending"). + * + * Each approval is signed over the multisig owner approval digest + * ({@link ox#ConfigurableAccount.(getSignPayload:function)}), so the signer of + * every approval is recovered against that digest and the list is sorted by the + * recovered owner address. Works for any owner key type (secp256k1, p256, + * webAuthn, keychain). + * + * @example + * ```ts twoslash + * import { Secp256k1 } from 'ox' + * import { ConfigurableAccount, SignatureEnvelope, TxEnvelopeTempo } from 'ox/tempo' + * + * const config = ConfigurableAccount.from({ + * threshold: 2, + * owners: [ + * { owner: '0x1111111111111111111111111111111111111111', weight: 1 }, + * { owner: '0x2222222222222222222222222222222222222222', weight: 1 }, + * ], + * }) + * const configId = ConfigurableAccount.toConfigId(config) + * const account = ConfigurableAccount.getAddress({ configId }) + * + * const tx = TxEnvelopeTempo.from({ chainId: 1, calls: [] }) + * const payload = TxEnvelopeTempo.getSignPayload(tx) + * const digest = ConfigurableAccount.getSignPayload({ payload, account, configId }) + * + * const signatures = ['0x...', '0x...'].map((privateKey) => + * SignatureEnvelope.from(Secp256k1.sign({ payload: digest, privateKey })), + * ) + * + * const ordered = SignatureEnvelope.sortMultisigApprovals({ // [!code focus] + * account, // [!code focus] + * configId, // [!code focus] + * payload, // [!code focus] + * signatures, // [!code focus] + * }) // [!code focus] + * ``` + * + * @param value - The approval ordering parameters. + * @returns The owner approvals ordered ascending by recovered owner address. + */ +export function sortMultisigApprovals( + value: sortMultisigApprovals.Value, +): readonly SignatureEnvelope[] { + const { account, configId, payload, signatures } = value + const digest = ConfigurableAccount.getSignPayload({ + account, + configId, + payload, + }) + // Recover each signer once (decorate–sort–undecorate) rather than inside the + // comparator. + return signatures + .map((signature) => ({ + key: Hex.toBigInt(extractAddress({ payload: digest, signature })), + signature, + })) + .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0)) + .map((entry) => entry.signature) +} + +export declare namespace sortMultisigApprovals { + type Value = { + /** The native multisig account address. */ + account: Address.Address + /** The permanent config ID. */ + configId: Hex.Hex + /** The inner transaction sign payload (`tx.signature_hash()`). */ + payload: Hex.Hex | Bytes.Bytes + /** The primitive owner approvals to order. */ + signatures: readonly SignatureEnvelope[] + } + + type ErrorType = + | ConfigurableAccount.getSignPayload.ErrorType + | extractAddress.ErrorType + | Errors.GlobalErrorType +} + /** * Converts a signature envelope to RPC format. * @@ -1095,6 +1336,18 @@ export function toRpc(envelope: SignatureEnvelope): SignatureEnvelopeRpc { } } + if (type === 'multisig') { + const multisig = envelope as Multisig + return { + type: 'multisig', + account: multisig.account, + configId: multisig.configId, + // Owner approvals are raw serialized signatures (node `Vec`). + signatures: multisig.signatures.map((signature) => serialize(signature)), + ...(multisig.init ? { init: multisig.init } : {}), + } + } + throw new CoercionError({ envelope }) } @@ -1332,7 +1585,7 @@ export class MissingPropertiesError extends Errors.BaseError { }: { envelope: unknown missing: string[] - type: Type + type: Type | 'keychain' | 'multisig' }) { super( `Signature envelope of type "${type}" is missing required properties: ${missing.map((m) => `\`${m}\``).join(', ')}.\n\nProvided: ${Json.stringify(envelope)}`, diff --git a/src/tempo/e2e.test.ts b/src/tempo/e2e.test.ts index 07e2f07a..83408a2b 100644 --- a/src/tempo/e2e.test.ts +++ b/src/tempo/e2e.test.ts @@ -13,6 +13,7 @@ import { beforeEach, describe, expect, test } from 'vitest' import { chain, client, fundAddress, nodeEnv } from '../../test/tempo/config.js' import { AuthorizationTempo, + ConfigurableAccount, KeyAuthorization, Period, SignatureEnvelope, @@ -2993,3 +2994,211 @@ describe('behavior: keyAuthorization', () => { }, ) }) + +describe('behavior: multisig (TIP-1061)', () => { + // Helper: builds a fresh set of secp256k1 owners + the derived config. + function setup(parameters: { count: number; threshold: number }) { + const { count, threshold } = parameters + const ownerKeys = Array.from({ length: count }, () => { + const privateKey = Secp256k1.randomPrivateKey() + const address = Address.fromPublicKey( + Secp256k1.getPublicKey({ privateKey }), + ) + return { address, privateKey } as const + }) + + const config = ConfigurableAccount.from({ + // A fresh random salt yields a distinct account each run, exercising the + // salt-inclusive config-ID derivation against the node. + salt: Hex.random(32), + threshold, + owners: ownerKeys.map((key) => ({ + owner: key.address, + weight: 1, + })), + }) + const configId = ConfigurableAccount.toConfigId(config) + const account = ConfigurableAccount.getAddress({ configId }) + + return { account, config, configId, ownerKeys } as const + } + + // Signs the multisig owner digest with the provided owner keys, returning + // primitive approval envelopes ordered strictly ascending by recovered owner + // address (required by the node: "recovered owners must be strictly + // ascending"). + function approve(parameters: { + account: Address.Address + configId: Hex.Hex + payload: Hex.Hex + signers: readonly { privateKey: Hex.Hex }[] + }) { + const { account, configId, payload, signers } = parameters + const digest = ConfigurableAccount.getSignPayload({ + account, + configId, + payload, + }) + const signatures = signers.map((signer) => + SignatureEnvelope.from( + Secp256k1.sign({ payload: digest, privateKey: signer.privateKey }), + ), + ) + return SignatureEnvelope.sortMultisigApprovals({ + account, + configId, + payload, + signatures, + }) + } + + test('behavior: bootstrap + spend (2-of-3 secp256k1)', async () => { + const { account, config, configId, ownerKeys } = setup({ + count: 3, + threshold: 2, + }) + + // The derived multisig account pays its own fees. + await fundAddress(client, { address: account }) + + // Bootstrap (first transaction): the bootstrap config travels in the + // multisig signature `init`, nonce 0. + const bootstrap = TxEnvelopeTempo.from({ + calls: [{ to: '0x0000000000000000000000000000000000000000' }], + chainId, + feeToken: '0x20c0000000000000000000000000000000000001', + nonce: 0n, + gas: 2_000_000n, + maxFeePerGas: Value.fromGwei('20'), + maxPriorityFeePerGas: Value.fromGwei('10'), + }) + + const bootstrap_signed = TxEnvelopeTempo.serialize(bootstrap, { + signature: SignatureEnvelope.from({ + type: 'multisig', + account, + configId, + // The bootstrap config is carried by the signature `init`. + init: config, + // Approve with 2 of the 3 owners to satisfy the threshold. + signatures: approve({ + account, + configId, + payload: TxEnvelopeTempo.getSignPayload(bootstrap), + signers: [ownerKeys[0]!, ownerKeys[1]!], + }), + }), + }) + + const bootstrap_receipt = (await client + .request({ + method: 'eth_sendRawTransactionSync', + params: [bootstrap_signed], + }) + .then((tx) => TransactionReceipt.fromRpc(tx as any)))! + expect(bootstrap_receipt).toBeDefined() + expect(bootstrap_receipt.status).toBe('success') + expect(bootstrap_receipt.from).toBe(account) + + { + const response = await client + .request({ + method: 'eth_getTransactionByHash', + params: [bootstrap_receipt.transactionHash], + }) + .then((tx) => Transaction.fromRpc(tx as any)) + if (!response) throw new Error() + expect(response.from).toBe(account) + expect(response.signature?.type).toBe('multisig') + // The bootstrap config is carried by the multisig signature `init`. + expect( + (response.signature as SignatureEnvelope.Multisig | undefined)?.init, + ).toEqual(config) + } + + // Spend (subsequent transaction): no signature `init`, nonce 1, uses the + // stored config loaded by the node. + const nonce = await getTransactionCount(client, { + address: account, + blockTag: 'pending', + }) + + const spend = TxEnvelopeTempo.from({ + calls: [{ to: '0x0000000000000000000000000000000000000000' }], + chainId, + feeToken: '0x20c0000000000000000000000000000000000001', + nonce: BigInt(nonce), + gas: 2_000_000n, + maxFeePerGas: Value.fromGwei('20'), + maxPriorityFeePerGas: Value.fromGwei('10'), + }) + + const spend_signed = TxEnvelopeTempo.serialize(spend, { + signature: SignatureEnvelope.from({ + type: 'multisig', + account, + configId, + // A different 2-of-3 subset still authorizes the transaction. + signatures: approve({ + account, + configId, + payload: TxEnvelopeTempo.getSignPayload(spend), + signers: [ownerKeys[1]!, ownerKeys[2]!], + }), + }), + }) + + const spend_receipt = (await client + .request({ + method: 'eth_sendRawTransactionSync', + params: [spend_signed], + }) + .then((tx) => TransactionReceipt.fromRpc(tx as any)))! + expect(spend_receipt).toBeDefined() + expect(spend_receipt.status).toBe('success') + expect(spend_receipt.from).toBe(account) + }) + + test('behavior: rejects below-threshold approvals', async () => { + const { account, config, configId, ownerKeys } = setup({ + count: 3, + threshold: 2, + }) + + await fundAddress(client, { address: account }) + + const bootstrap = TxEnvelopeTempo.from({ + calls: [{ to: '0x0000000000000000000000000000000000000000' }], + chainId, + feeToken: '0x20c0000000000000000000000000000000000001', + nonce: 0n, + gas: 2_000_000n, + maxFeePerGas: Value.fromGwei('20'), + maxPriorityFeePerGas: Value.fromGwei('10'), + }) + + const serialized_signed = TxEnvelopeTempo.serialize(bootstrap, { + signature: SignatureEnvelope.from({ + type: 'multisig', + account, + configId, + // The bootstrap config is carried by the signature `init`. + init: config, + // Only one approval — below the threshold of 2. + signatures: approve({ + account, + configId, + payload: TxEnvelopeTempo.getSignPayload(bootstrap), + signers: [ownerKeys[0]!], + }), + }), + }) + + await expect( + client.request({ + method: 'eth_sendRawTransactionSync', + params: [serialized_signed], + }), + ).rejects.toThrow() + }) +}) diff --git a/src/tempo/index.ts b/src/tempo/index.ts index f4d53eef..d336de2a 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -62,6 +62,32 @@ export * as AuthorizationTempo from './AuthorizationTempo.js' * @category Reference */ export * as Channel from './Channel.js' +/** + * Native multisig account utilities (TIP-1061). + * + * Derives stable multisig account addresses and permanent config IDs from a weighted + * owner configuration, and computes the owner approval digest that owners sign. + * + * [TIP-1061](https://tips.sh/1061) + * + * @example + * ```ts twoslash + * import { ConfigurableAccount } from 'ox/tempo' + * + * const config = ConfigurableAccount.from({ + * threshold: 2, + * owners: [ + * { owner: '0x1111111111111111111111111111111111111111', weight: 1 }, + * { owner: '0x2222222222222222222222222222222222222222', weight: 1 }, + * ], + * }) + * + * const account = ConfigurableAccount.getAddress({ config }) + * ``` + * + * @category Reference + */ +export * as ConfigurableAccount from './ConfigurableAccount.js' /** * Tempo key authorization utilities for provisioning and signing access keys. * diff --git a/test/tempo/config.ts b/test/tempo/config.ts index ae05d7b5..bbfc51bb 100644 --- a/test/tempo/config.ts +++ b/test/tempo/config.ts @@ -27,11 +27,11 @@ export const client = createClient({ pollingInterval: 100, transport: http(rpcUrl, { ...debugOptions({ rpcUrl }), - ...(nodeEnv === 'devnet' + ...(nodeEnv === 'devnet' && import.meta.env.VITE_TEMPO_CREDENTIALS ? { fetchOptions: { headers: { - Authorization: `Basic ${btoa(import.meta.env.VITE_TEMPO_CREDENTIALS ?? '')}`, + Authorization: `Basic ${btoa(import.meta.env.VITE_TEMPO_CREDENTIALS)}`, }, }, } diff --git a/test/tempo/prool.ts b/test/tempo/prool.ts index 9658b5da..7c8098d7 100644 --- a/test/tempo/prool.ts +++ b/test/tempo/prool.ts @@ -5,6 +5,8 @@ import * as TestContainers from 'prool/testcontainers' export const port = 3000 export const rpcUrl = (() => { + if (import.meta.env.VITE_TEMPO_RPC_URL) + return import.meta.env.VITE_TEMPO_RPC_URL if (import.meta.env.VITE_TEMPO_ENV === 'devnet') return 'https://rpc.devnet.tempoxyz.dev' if (import.meta.env.VITE_TEMPO_ENV === 'testnet') From c6333b199a5dee81a8dc846414e02bda49bd2bbb Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:46:51 +0200 Subject: [PATCH 2/8] refactor(tempo): rename ConfigurableAccount to MultisigConfig Amp-Thread-ID: https://ampcode.com/threads/T-019e86f0-0dd6-71fc-8b67-efa5b7a4b9c5 --- .changeset/tempo-native-multisig.md | 2 +- ...Account.test.ts => MultisigConfig.test.ts} | 80 +++++++++---------- ...nfigurableAccount.ts => MultisigConfig.ts} | 62 +++++++------- src/tempo/SignatureEnvelope.test.ts | 4 +- src/tempo/SignatureEnvelope.ts | 42 +++++----- src/tempo/e2e.test.ts | 10 +-- src/tempo/index.ts | 52 ++++++------ 7 files changed, 120 insertions(+), 132 deletions(-) rename src/tempo/{ConfigurableAccount.test.ts => MultisigConfig.test.ts} (65%) rename src/tempo/{ConfigurableAccount.ts => MultisigConfig.ts} (86%) diff --git a/.changeset/tempo-native-multisig.md b/.changeset/tempo-native-multisig.md index 2f6df52f..f77bc616 100644 --- a/.changeset/tempo-native-multisig.md +++ b/.changeset/tempo-native-multisig.md @@ -2,4 +2,4 @@ 'ox': minor --- -Added support for TIP-1061 native multisig accounts. A new `tempo/ConfigurableAccount` module derives stable multisig account addresses and permanent config IDs from a weighted owner configuration and computes the owner approval digest that owners sign. `tempo/SignatureEnvelope` gains a `multisig` signature variant (type `0x05`) that aggregates primitive owner approvals and carries the optional bootstrap config (`init`) on the first transaction from a derived account. +Added support for TIP-1061 native multisig accounts. A new `tempo/MultisigConfig` module derives stable multisig account addresses and permanent config IDs from a weighted owner configuration and computes the owner approval digest that owners sign. `tempo/SignatureEnvelope` gains a `multisig` signature variant (type `0x05`) that aggregates primitive owner approvals and carries the optional bootstrap config (`init`) on the first transaction from a derived account. diff --git a/src/tempo/ConfigurableAccount.test.ts b/src/tempo/MultisigConfig.test.ts similarity index 65% rename from src/tempo/ConfigurableAccount.test.ts rename to src/tempo/MultisigConfig.test.ts index 1becaaa4..b121f00b 100644 --- a/src/tempo/ConfigurableAccount.test.ts +++ b/src/tempo/MultisigConfig.test.ts @@ -1,4 +1,4 @@ -import { ConfigurableAccount } from 'ox/tempo' +import { MultisigConfig } from 'ox/tempo' import { describe, expect, test } from 'vitest' // Ground-truth vectors independently computed via `cast keccak` over the exact @@ -13,7 +13,7 @@ const singleOwnerConfig = { describe('from', () => { test('sorts owners ascending by address', () => { - const config = ConfigurableAccount.from({ + const config = MultisigConfig.from({ threshold: 2, owners: [ { owner: owner2, weight: 1 }, @@ -25,29 +25,27 @@ describe('from', () => { test('asserts validity', () => { expect(() => - ConfigurableAccount.from({ threshold: 0, owners: [] }), + MultisigConfig.from({ threshold: 0, owners: [] }), ).toThrowError() }) }) describe('configId', () => { test('matches independent ground truth', () => { - expect( - ConfigurableAccount.toConfigId(singleOwnerConfig), - ).toMatchInlineSnapshot( + expect(MultisigConfig.toConfigId(singleOwnerConfig)).toMatchInlineSnapshot( `"0xd1f20e1a5bfdd89488f57f68db5bd1aae9a51b510f4a042b2604b57a0b7b471d"`, ) }) test('is stable across calls', () => { - expect(ConfigurableAccount.toConfigId(singleOwnerConfig)).toBe( - ConfigurableAccount.toConfigId(singleOwnerConfig), + expect(MultisigConfig.toConfigId(singleOwnerConfig)).toBe( + MultisigConfig.toConfigId(singleOwnerConfig), ) }) test('differs for a different salt', () => { - expect(ConfigurableAccount.toConfigId(singleOwnerConfig)).not.toBe( - ConfigurableAccount.toConfigId({ + expect(MultisigConfig.toConfigId(singleOwnerConfig)).not.toBe( + MultisigConfig.toConfigId({ ...singleOwnerConfig, salt: `0x${'42'.repeat(32)}`, }), @@ -56,7 +54,7 @@ describe('configId', () => { test('throws on invalid config', () => { expect(() => - ConfigurableAccount.toConfigId({ + MultisigConfig.toConfigId({ threshold: 5, owners: singleOwnerConfig.owners, }), @@ -67,33 +65,31 @@ describe('configId', () => { describe('getAddress', () => { test('matches independent ground truth', () => { expect( - ConfigurableAccount.getAddress({ config: singleOwnerConfig }), + MultisigConfig.getAddress({ config: singleOwnerConfig }), ).toMatchInlineSnapshot(`"0x6ca655065b1de473d903eebd50e5cb4996e10468"`) }) test('derives from config or configId identically', () => { - const configId = ConfigurableAccount.toConfigId(singleOwnerConfig) - expect(ConfigurableAccount.getAddress({ configId })).toBe( - ConfigurableAccount.getAddress({ config: singleOwnerConfig }), + const configId = MultisigConfig.toConfigId(singleOwnerConfig) + expect(MultisigConfig.getAddress({ configId })).toBe( + MultisigConfig.getAddress({ config: singleOwnerConfig }), ) }) test('config ID and address are chain-independent', () => { // Derivation does not include chain ID; identical config → identical id/address. - const a = ConfigurableAccount.toConfigId(singleOwnerConfig) - const b = ConfigurableAccount.toConfigId( - ConfigurableAccount.from(singleOwnerConfig), - ) + const a = MultisigConfig.toConfigId(singleOwnerConfig) + const b = MultisigConfig.toConfigId(MultisigConfig.from(singleOwnerConfig)) expect(a).toBe(b) }) }) describe('getSignPayload', () => { test('matches independent ground truth', () => { - const configId = ConfigurableAccount.toConfigId(singleOwnerConfig) - const account = ConfigurableAccount.getAddress({ configId }) + const configId = MultisigConfig.toConfigId(singleOwnerConfig) + const account = MultisigConfig.getAddress({ configId }) expect( - ConfigurableAccount.getSignPayload({ + MultisigConfig.getSignPayload({ payload: `0x${'42'.repeat(32)}`, account, configId, @@ -106,47 +102,45 @@ describe('getSignPayload', () => { describe('toTuple / fromTuple', () => { test('round-trips', () => { - const config = ConfigurableAccount.from({ + const config = MultisigConfig.from({ threshold: 3, owners: [ { owner: owner1, weight: 1 }, { owner: owner2, weight: 2 }, ], }) - const tuple = ConfigurableAccount.toTuple(config) - expect(ConfigurableAccount.fromTuple(tuple)).toEqual(config) + const tuple = MultisigConfig.toTuple(config) + expect(MultisigConfig.fromTuple(tuple)).toEqual(config) }) test('encodes each owner as `[owner, weight]`', () => { - const [, , owners] = ConfigurableAccount.toTuple(singleOwnerConfig) + const [, , owners] = MultisigConfig.toTuple(singleOwnerConfig) expect(owners[0]).toEqual([owner1, '0x1']) }) test('encodes salt as a full 32-byte string (first element)', () => { - const [salt] = ConfigurableAccount.toTuple(singleOwnerConfig) - expect(salt).toBe(ConfigurableAccount.zeroSalt) + const [salt] = MultisigConfig.toTuple(singleOwnerConfig) + expect(salt).toBe(MultisigConfig.zeroSalt) }) test('round-trips a non-zero salt', () => { - const config = ConfigurableAccount.from({ + const config = MultisigConfig.from({ ...singleOwnerConfig, salt: `0x${'42'.repeat(32)}`, }) - const tuple = ConfigurableAccount.toTuple(config) + const tuple = MultisigConfig.toTuple(config) expect(tuple[0]).toBe(`0x${'42'.repeat(32)}`) - expect(ConfigurableAccount.fromTuple(tuple)).toEqual(config) + expect(MultisigConfig.fromTuple(tuple)).toEqual(config) }) }) describe('assert / validate', () => { test('valid config', () => { - expect(ConfigurableAccount.validate(singleOwnerConfig)).toBe(true) + expect(MultisigConfig.validate(singleOwnerConfig)).toBe(true) }) test('empty owners', () => { - expect(ConfigurableAccount.validate({ threshold: 1, owners: [] })).toBe( - false, - ) + expect(MultisigConfig.validate({ threshold: 1, owners: [] })).toBe(false) }) test('too many owners', () => { @@ -154,12 +148,12 @@ describe('assert / validate', () => { owner: `0x${(i + 1).toString(16).padStart(40, '0')}` as `0x${string}`, weight: 1, })) - expect(ConfigurableAccount.validate({ threshold: 1, owners })).toBe(false) + expect(MultisigConfig.validate({ threshold: 1, owners })).toBe(false) }) test('zero threshold', () => { expect( - ConfigurableAccount.validate({ + MultisigConfig.validate({ threshold: 0, owners: singleOwnerConfig.owners, }), @@ -168,7 +162,7 @@ describe('assert / validate', () => { test('threshold exceeds total weight', () => { expect( - ConfigurableAccount.validate({ + MultisigConfig.validate({ threshold: 2, owners: singleOwnerConfig.owners, }), @@ -177,7 +171,7 @@ describe('assert / validate', () => { test('zero owner weight', () => { expect( - ConfigurableAccount.validate({ + MultisigConfig.validate({ threshold: 1, owners: [{ owner: owner1, weight: 0 }], }), @@ -186,7 +180,7 @@ describe('assert / validate', () => { test('zero owner address', () => { expect( - ConfigurableAccount.validate({ + MultisigConfig.validate({ threshold: 1, owners: [ { @@ -200,7 +194,7 @@ describe('assert / validate', () => { test('unsorted owners', () => { expect( - ConfigurableAccount.validate({ + MultisigConfig.validate({ threshold: 1, owners: [ { owner: owner2, weight: 1 }, @@ -212,7 +206,7 @@ describe('assert / validate', () => { test('duplicate owners', () => { expect( - ConfigurableAccount.validate({ + MultisigConfig.validate({ threshold: 1, owners: [ { owner: owner1, weight: 1 }, @@ -224,7 +218,7 @@ describe('assert / validate', () => { test('invalid salt size', () => { expect( - ConfigurableAccount.validate({ + MultisigConfig.validate({ ...singleOwnerConfig, salt: '0x42', }), diff --git a/src/tempo/ConfigurableAccount.ts b/src/tempo/MultisigConfig.ts similarity index 86% rename from src/tempo/ConfigurableAccount.ts rename to src/tempo/MultisigConfig.ts index 4004773d..bc611c07 100644 --- a/src/tempo/ConfigurableAccount.ts +++ b/src/tempo/MultisigConfig.ts @@ -33,7 +33,7 @@ const signatureDomain = 'tempo:multisig:signature' export type Config = Compute<{ /** * Caller-chosen 32-byte salt mixed into the permanent config ID. Defaults to - * the {@link ox#ConfigurableAccount.zeroSalt} when omitted. + * the {@link ox#MultisigConfig.zeroSalt} when omitted. */ salt?: Hex.Hex | undefined /** Minimum total owner weight required to authorize a transaction. */ @@ -50,7 +50,7 @@ export type Owner = { weight: numberType } -/** RLP tuple representation of a {@link ox#ConfigurableAccount.Config}. */ +/** RLP tuple representation of a {@link ox#MultisigConfig.Config}. */ export type Tuple = readonly [ salt: Hex.Hex, threshold: Hex.Hex, @@ -58,7 +58,7 @@ export type Tuple = readonly [ ] /** - * Asserts that a native multisig {@link ox#ConfigurableAccount.Config} is valid. + * Asserts that a native multisig {@link ox#MultisigConfig.Config} is valid. * * Mirrors the Tempo `validate_multisig_config` rules: owners non-empty and * `<= maxOwners`, strictly ascending unique nonzero owner addresses, nonzero @@ -67,9 +67,9 @@ export type Tuple = readonly [ * * @example * ```ts twoslash - * import { ConfigurableAccount } from 'ox/tempo' + * import { MultisigConfig } from 'ox/tempo' * - * ConfigurableAccount.assert({ + * MultisigConfig.assert({ * threshold: 1, * owners: [ * { owner: '0x1111111111111111111111111111111111111111', weight: 1 }, @@ -124,16 +124,16 @@ export declare namespace assert { } /** - * Normalizes a native multisig {@link ox#ConfigurableAccount.Config}. + * Normalizes a native multisig {@link ox#MultisigConfig.Config}. * * Sorts owners into strictly ascending `owner` address order (the canonical * form required for config ID derivation) and asserts the config is valid. * * @example * ```ts twoslash - * import { ConfigurableAccount } from 'ox/tempo' + * import { MultisigConfig } from 'ox/tempo' * - * const config = ConfigurableAccount.from({ + * const config = MultisigConfig.from({ * threshold: 2, * owners: [ * { owner: '0x2222222222222222222222222222222222222222', weight: 1 }, @@ -162,14 +162,14 @@ export function from( } /** - * Converts an RLP {@link ox#ConfigurableAccount.Tuple} back to a - * {@link ox#ConfigurableAccount.Config}. + * Converts an RLP {@link ox#MultisigConfig.Tuple} back to a + * {@link ox#MultisigConfig.Config}. * * @example * ```ts twoslash - * import { ConfigurableAccount } from 'ox/tempo' + * import { MultisigConfig } from 'ox/tempo' * - * const config = ConfigurableAccount.fromTuple([ + * const config = MultisigConfig.fromTuple([ * `0x${'00'.repeat(32)}`, * '0x01', * [['0x1111111111111111111111111111111111111111', '0x01']], @@ -201,16 +201,16 @@ export function fromTuple(tuple: Tuple): Config { * * @example * ```ts twoslash - * import { ConfigurableAccount } from 'ox/tempo' + * import { MultisigConfig } from 'ox/tempo' * - * const config = ConfigurableAccount.from({ + * const config = MultisigConfig.from({ * threshold: 1, * owners: [ * { owner: '0x1111111111111111111111111111111111111111', weight: 1 }, * ], * }) * - * const address = ConfigurableAccount.getAddress({ config }) + * const address = MultisigConfig.getAddress({ config }) * ``` * * @param value - The config or config ID to derive the address from. @@ -243,23 +243,23 @@ export declare namespace getAddress { * * @example * ```ts twoslash - * import { ConfigurableAccount, TxEnvelopeTempo } from 'ox/tempo' + * import { MultisigConfig, TxEnvelopeTempo } from 'ox/tempo' * - * const config = ConfigurableAccount.from({ + * const config = MultisigConfig.from({ * threshold: 1, * owners: [ * { owner: '0x1111111111111111111111111111111111111111', weight: 1 }, * ], * }) - * const configId = ConfigurableAccount.toConfigId(config) - * const account = ConfigurableAccount.getAddress({ configId }) + * const configId = MultisigConfig.toConfigId(config) + * const account = MultisigConfig.getAddress({ configId }) * * const envelope = TxEnvelopeTempo.from({ * chainId: 1, * calls: [], * }) * - * const digest = ConfigurableAccount.getSignPayload({ + * const digest = MultisigConfig.getSignPayload({ * payload: TxEnvelopeTempo.getSignPayload(envelope), * account, * configId, @@ -300,7 +300,7 @@ export declare namespace getSignPayload { /** * Derives the permanent config ID for a native multisig - * {@link ox#ConfigurableAccount.Config}. + * {@link ox#MultisigConfig.Config}. * * Preimage (fixed-width big-endian, **not** RLP): * `keccak256("tempo:multisig:config" || salt || be_u32(threshold) || be_u32(owners.length) @@ -308,16 +308,16 @@ export declare namespace getSignPayload { * * @example * ```ts twoslash - * import { ConfigurableAccount } from 'ox/tempo' + * import { MultisigConfig } from 'ox/tempo' * - * const config = ConfigurableAccount.from({ + * const config = MultisigConfig.from({ * threshold: 1, * owners: [ * { owner: '0x1111111111111111111111111111111111111111', weight: 1 }, * ], * }) * - * const configId = ConfigurableAccount.toConfigId(config) + * const configId = MultisigConfig.toConfigId(config) * ``` * * @param config - The multisig config. @@ -353,7 +353,7 @@ export declare namespace toConfigId { } /** - * Converts a {@link ox#ConfigurableAccount.Config} to its RLP tuple form (carried + * Converts a {@link ox#MultisigConfig.Config} to its RLP tuple form (carried * by the multisig signature `init`). * * Tuple shape: `[salt, threshold, [[owner, weight], ...]]`. The @@ -362,9 +362,9 @@ export declare namespace toConfigId { * * @example * ```ts twoslash - * import { ConfigurableAccount } from 'ox/tempo' + * import { MultisigConfig } from 'ox/tempo' * - * const tuple = ConfigurableAccount.toTuple({ + * const tuple = MultisigConfig.toTuple({ * threshold: 1, * owners: [ * { owner: '0x1111111111111111111111111111111111111111', weight: 1 }, @@ -387,14 +387,14 @@ export function toTuple(config: Config): Tuple { } /** - * Validates a native multisig {@link ox#ConfigurableAccount.Config}. Returns `true` + * Validates a native multisig {@link ox#MultisigConfig.Config}. Returns `true` * if valid, `false` otherwise. * * @example * ```ts twoslash - * import { ConfigurableAccount } from 'ox/tempo' + * import { MultisigConfig } from 'ox/tempo' * - * const valid = ConfigurableAccount.validate({ + * const valid = MultisigConfig.validate({ * threshold: 1, * owners: [ * { owner: '0x1111111111111111111111111111111111111111', weight: 1 }, @@ -417,7 +417,7 @@ export function validate(config: Config): boolean { /** Thrown when a native multisig config is invalid. */ export class InvalidConfigError extends Errors.BaseError { - override readonly name = 'ConfigurableAccount.InvalidConfigError' + override readonly name = 'MultisigConfig.InvalidConfigError' constructor({ reason }: { reason: string }) { super(`Invalid native multisig config: ${reason}.`) } diff --git a/src/tempo/SignatureEnvelope.test.ts b/src/tempo/SignatureEnvelope.test.ts index da8f4853..41a69147 100644 --- a/src/tempo/SignatureEnvelope.test.ts +++ b/src/tempo/SignatureEnvelope.test.ts @@ -9,7 +9,7 @@ import { WebCryptoP256, } from 'ox' import { describe, expect, test } from 'vitest' -import * as ConfigurableAccount from './ConfigurableAccount.js' +import * as MultisigConfig from './MultisigConfig.js' import * as SignatureEnvelope from './SignatureEnvelope.js' const publicKey = PublicKey.from({ @@ -1701,7 +1701,7 @@ describe('sortMultisigApprovals', () => { const account = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as const const configId = `0x${'11'.repeat(32)}` as const const payload = `0x${'42'.repeat(32)}` as const - const digest = ConfigurableAccount.getSignPayload({ + const digest = MultisigConfig.getSignPayload({ account, configId, payload, diff --git a/src/tempo/SignatureEnvelope.ts b/src/tempo/SignatureEnvelope.ts index 62b3b35b..e1b7cf33 100644 --- a/src/tempo/SignatureEnvelope.ts +++ b/src/tempo/SignatureEnvelope.ts @@ -18,7 +18,7 @@ import * as ox_Secp256k1 from '../core/Secp256k1.js' import * as Signature from '../core/Signature.js' import type * as WebAuthnP256 from '../core/WebAuthnP256.js' import * as ox_WebAuthnP256 from '../core/WebAuthnP256.js' -import * as ConfigurableAccount from './ConfigurableAccount.js' +import * as MultisigConfig from './MultisigConfig.js' /** Signature type identifiers for encoding/decoding */ const serializedP256Type = '0x01' @@ -169,7 +169,7 @@ export type Multisig = { * the first (bootstrap) transaction from the derived account; absent on every * subsequent transaction. */ - init?: ConfigurableAccount.Config | undefined + init?: MultisigConfig.Config | undefined } export type MultisigRpc = { @@ -181,7 +181,7 @@ export type MultisigRpc = { * node's `Vec` representation. */ signatures: readonly Serialized[] - init?: ConfigurableAccount.Config | undefined + init?: MultisigConfig.Config | undefined } export type P256 = { @@ -333,7 +333,7 @@ export function assert(envelope: PartialBy): void { if (missing.length > 0) throw new MissingPropertiesError({ envelope, missing, type: 'multisig' }) for (const inner of multisig.signatures) assert(inner) - if (multisig.init) ConfigurableAccount.assert(multisig.init) + if (multisig.init) MultisigConfig.assert(multisig.init) return } } @@ -342,7 +342,7 @@ export declare namespace assert { type ErrorType = | CoercionError | MissingPropertiesError - | ConfigurableAccount.assert.ErrorType + | MultisigConfig.assert.ErrorType | Signature.assert.ErrorType | Errors.GlobalErrorType } @@ -620,7 +620,7 @@ export function deserialize(value: Serialized): SignatureEnvelope { Hex.Hex, Hex.Hex, readonly Hex.Hex[], - (Hex.Hex | ConfigurableAccount.Tuple)?, + (Hex.Hex | MultisigConfig.Tuple)?, ] return { type: 'multisig', @@ -629,9 +629,7 @@ export function deserialize(value: Serialized): SignatureEnvelope { signatures: signatures.map((signature) => deserialize(signature)), ...(init && init !== '0x' ? { - init: ConfigurableAccount.fromTuple( - init as ConfigurableAccount.Tuple, - ), + init: MultisigConfig.fromTuple(init as MultisigConfig.Tuple), } : {}), } satisfies Multisig @@ -782,9 +780,7 @@ export function from( signatures: multisig.signatures.map((signature) => from(signature)), // Normalize the bootstrap config (sorts owners, defaults the salt) so the // in-memory envelope matches what `deserialize` reconstructs. - ...(multisig.init - ? { init: ConfigurableAccount.from(multisig.init) } - : {}), + ...(multisig.init ? { init: MultisigConfig.from(multisig.init) } : {}), type, } as never } @@ -972,9 +968,7 @@ export function fromRpc(envelope: SignatureEnvelopeRpc): SignatureEnvelope { signatures: multisig.signatures.map((signature) => deserialize(signature), ), - ...(multisig.init - ? { init: ConfigurableAccount.from(multisig.init) } - : {}), + ...(multisig.init ? { init: MultisigConfig.from(multisig.init) } : {}), } } @@ -1167,7 +1161,7 @@ export function serialize( multisig.account, multisig.configId, multisig.signatures.map((signature) => serialize(signature)), - multisig.init ? ConfigurableAccount.toTuple(multisig.init) : '0x', + multisig.init ? MultisigConfig.toTuple(multisig.init) : '0x', ]), options.magic ? magicBytes : '0x', ) @@ -1192,7 +1186,7 @@ export declare namespace serialize { * array (the node enforces "recovered owners must be strictly ascending"). * * Each approval is signed over the multisig owner approval digest - * ({@link ox#ConfigurableAccount.(getSignPayload:function)}), so the signer of + * ({@link ox#MultisigConfig.(getSignPayload:function)}), so the signer of * every approval is recovered against that digest and the list is sorted by the * recovered owner address. Works for any owner key type (secp256k1, p256, * webAuthn, keychain). @@ -1200,21 +1194,21 @@ export declare namespace serialize { * @example * ```ts twoslash * import { Secp256k1 } from 'ox' - * import { ConfigurableAccount, SignatureEnvelope, TxEnvelopeTempo } from 'ox/tempo' + * import { MultisigConfig, SignatureEnvelope, TxEnvelopeTempo } from 'ox/tempo' * - * const config = ConfigurableAccount.from({ + * const config = MultisigConfig.from({ * threshold: 2, * owners: [ * { owner: '0x1111111111111111111111111111111111111111', weight: 1 }, * { owner: '0x2222222222222222222222222222222222222222', weight: 1 }, * ], * }) - * const configId = ConfigurableAccount.toConfigId(config) - * const account = ConfigurableAccount.getAddress({ configId }) + * const configId = MultisigConfig.toConfigId(config) + * const account = MultisigConfig.getAddress({ configId }) * * const tx = TxEnvelopeTempo.from({ chainId: 1, calls: [] }) * const payload = TxEnvelopeTempo.getSignPayload(tx) - * const digest = ConfigurableAccount.getSignPayload({ payload, account, configId }) + * const digest = MultisigConfig.getSignPayload({ payload, account, configId }) * * const signatures = ['0x...', '0x...'].map((privateKey) => * SignatureEnvelope.from(Secp256k1.sign({ payload: digest, privateKey })), @@ -1235,7 +1229,7 @@ export function sortMultisigApprovals( value: sortMultisigApprovals.Value, ): readonly SignatureEnvelope[] { const { account, configId, payload, signatures } = value - const digest = ConfigurableAccount.getSignPayload({ + const digest = MultisigConfig.getSignPayload({ account, configId, payload, @@ -1264,7 +1258,7 @@ export declare namespace sortMultisigApprovals { } type ErrorType = - | ConfigurableAccount.getSignPayload.ErrorType + | MultisigConfig.getSignPayload.ErrorType | extractAddress.ErrorType | Errors.GlobalErrorType } diff --git a/src/tempo/e2e.test.ts b/src/tempo/e2e.test.ts index 83408a2b..b9c5d8e8 100644 --- a/src/tempo/e2e.test.ts +++ b/src/tempo/e2e.test.ts @@ -13,8 +13,8 @@ import { beforeEach, describe, expect, test } from 'vitest' import { chain, client, fundAddress, nodeEnv } from '../../test/tempo/config.js' import { AuthorizationTempo, - ConfigurableAccount, KeyAuthorization, + MultisigConfig, Period, SignatureEnvelope, } from './index.js' @@ -3007,7 +3007,7 @@ describe('behavior: multisig (TIP-1061)', () => { return { address, privateKey } as const }) - const config = ConfigurableAccount.from({ + const config = MultisigConfig.from({ // A fresh random salt yields a distinct account each run, exercising the // salt-inclusive config-ID derivation against the node. salt: Hex.random(32), @@ -3017,8 +3017,8 @@ describe('behavior: multisig (TIP-1061)', () => { weight: 1, })), }) - const configId = ConfigurableAccount.toConfigId(config) - const account = ConfigurableAccount.getAddress({ configId }) + const configId = MultisigConfig.toConfigId(config) + const account = MultisigConfig.getAddress({ configId }) return { account, config, configId, ownerKeys } as const } @@ -3034,7 +3034,7 @@ describe('behavior: multisig (TIP-1061)', () => { signers: readonly { privateKey: Hex.Hex }[] }) { const { account, configId, payload, signers } = parameters - const digest = ConfigurableAccount.getSignPayload({ + const digest = MultisigConfig.getSignPayload({ account, configId, payload, diff --git a/src/tempo/index.ts b/src/tempo/index.ts index d336de2a..6a519442 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -62,32 +62,6 @@ export * as AuthorizationTempo from './AuthorizationTempo.js' * @category Reference */ export * as Channel from './Channel.js' -/** - * Native multisig account utilities (TIP-1061). - * - * Derives stable multisig account addresses and permanent config IDs from a weighted - * owner configuration, and computes the owner approval digest that owners sign. - * - * [TIP-1061](https://tips.sh/1061) - * - * @example - * ```ts twoslash - * import { ConfigurableAccount } from 'ox/tempo' - * - * const config = ConfigurableAccount.from({ - * threshold: 2, - * owners: [ - * { owner: '0x1111111111111111111111111111111111111111', weight: 1 }, - * { owner: '0x2222222222222222222222222222222222222222', weight: 1 }, - * ], - * }) - * - * const account = ConfigurableAccount.getAddress({ config }) - * ``` - * - * @category Reference - */ -export * as ConfigurableAccount from './ConfigurableAccount.js' /** * Tempo key authorization utilities for provisioning and signing access keys. * @@ -128,6 +102,32 @@ export * as ConfigurableAccount from './ConfigurableAccount.js' * @category Reference */ export * as KeyAuthorization from './KeyAuthorization.js' +/** + * Native multisig account utilities (TIP-1061). + * + * Derives stable multisig account addresses and permanent config IDs from a weighted + * owner configuration, and computes the owner approval digest that owners sign. + * + * [TIP-1061](https://tips.sh/1061) + * + * @example + * ```ts twoslash + * import { MultisigConfig } from 'ox/tempo' + * + * const config = MultisigConfig.from({ + * threshold: 2, + * owners: [ + * { owner: '0x1111111111111111111111111111111111111111', weight: 1 }, + * { owner: '0x2222222222222222222222222222222222222222', weight: 1 }, + * ], + * }) + * + * const account = MultisigConfig.getAddress({ config }) + * ``` + * + * @category Reference + */ +export * as MultisigConfig from './MultisigConfig.js' /** * Utilities for constructing period durations (in seconds) for recurring spending limits. * From 5340c058ca33c91c03e7d66d3c38342c0e149a02 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:47:57 +0200 Subject: [PATCH 3/8] refactor(tempo): rename MultisigConfig.toConfigId to toId Amp-Thread-ID: https://ampcode.com/threads/T-019e86f0-0dd6-71fc-8b67-efa5b7a4b9c5 --- src/tempo/MultisigConfig.test.ts | 20 ++++++++++---------- src/tempo/MultisigConfig.ts | 12 ++++++------ src/tempo/SignatureEnvelope.ts | 2 +- src/tempo/e2e.test.ts | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/tempo/MultisigConfig.test.ts b/src/tempo/MultisigConfig.test.ts index b121f00b..ba6520d2 100644 --- a/src/tempo/MultisigConfig.test.ts +++ b/src/tempo/MultisigConfig.test.ts @@ -32,20 +32,20 @@ describe('from', () => { describe('configId', () => { test('matches independent ground truth', () => { - expect(MultisigConfig.toConfigId(singleOwnerConfig)).toMatchInlineSnapshot( + expect(MultisigConfig.toId(singleOwnerConfig)).toMatchInlineSnapshot( `"0xd1f20e1a5bfdd89488f57f68db5bd1aae9a51b510f4a042b2604b57a0b7b471d"`, ) }) test('is stable across calls', () => { - expect(MultisigConfig.toConfigId(singleOwnerConfig)).toBe( - MultisigConfig.toConfigId(singleOwnerConfig), + expect(MultisigConfig.toId(singleOwnerConfig)).toBe( + MultisigConfig.toId(singleOwnerConfig), ) }) test('differs for a different salt', () => { - expect(MultisigConfig.toConfigId(singleOwnerConfig)).not.toBe( - MultisigConfig.toConfigId({ + expect(MultisigConfig.toId(singleOwnerConfig)).not.toBe( + MultisigConfig.toId({ ...singleOwnerConfig, salt: `0x${'42'.repeat(32)}`, }), @@ -54,7 +54,7 @@ describe('configId', () => { test('throws on invalid config', () => { expect(() => - MultisigConfig.toConfigId({ + MultisigConfig.toId({ threshold: 5, owners: singleOwnerConfig.owners, }), @@ -70,7 +70,7 @@ describe('getAddress', () => { }) test('derives from config or configId identically', () => { - const configId = MultisigConfig.toConfigId(singleOwnerConfig) + const configId = MultisigConfig.toId(singleOwnerConfig) expect(MultisigConfig.getAddress({ configId })).toBe( MultisigConfig.getAddress({ config: singleOwnerConfig }), ) @@ -78,15 +78,15 @@ describe('getAddress', () => { test('config ID and address are chain-independent', () => { // Derivation does not include chain ID; identical config → identical id/address. - const a = MultisigConfig.toConfigId(singleOwnerConfig) - const b = MultisigConfig.toConfigId(MultisigConfig.from(singleOwnerConfig)) + const a = MultisigConfig.toId(singleOwnerConfig) + const b = MultisigConfig.toId(MultisigConfig.from(singleOwnerConfig)) expect(a).toBe(b) }) }) describe('getSignPayload', () => { test('matches independent ground truth', () => { - const configId = MultisigConfig.toConfigId(singleOwnerConfig) + const configId = MultisigConfig.toId(singleOwnerConfig) const account = MultisigConfig.getAddress({ configId }) expect( MultisigConfig.getSignPayload({ diff --git a/src/tempo/MultisigConfig.ts b/src/tempo/MultisigConfig.ts index bc611c07..391061fb 100644 --- a/src/tempo/MultisigConfig.ts +++ b/src/tempo/MultisigConfig.ts @@ -217,7 +217,7 @@ export function fromTuple(tuple: Tuple): Config { * @returns The multisig account address. */ export function getAddress(value: getAddress.Value): Address.Address { - const id = 'configId' in value ? value.configId : toConfigId(value.config) + const id = 'configId' in value ? value.configId : toId(value.config) const hash = Hash.keccak256(Hex.concat(Hex.fromString(accountDomain), id)) return Address.from(Hex.slice(hash, 12, 32)) } @@ -226,7 +226,7 @@ export declare namespace getAddress { type Value = { config: Config } | { configId: Hex.Hex } type ErrorType = - | toConfigId.ErrorType + | toId.ErrorType | Address.from.ErrorType | Hash.keccak256.ErrorType | Hex.concat.ErrorType @@ -251,7 +251,7 @@ export declare namespace getAddress { * { owner: '0x1111111111111111111111111111111111111111', weight: 1 }, * ], * }) - * const configId = MultisigConfig.toConfigId(config) + * const configId = MultisigConfig.toId(config) * const account = MultisigConfig.getAddress({ configId }) * * const envelope = TxEnvelopeTempo.from({ @@ -317,13 +317,13 @@ export declare namespace getSignPayload { * ], * }) * - * const configId = MultisigConfig.toConfigId(config) + * const configId = MultisigConfig.toId(config) * ``` * * @param config - The multisig config. * @returns The 32-byte config ID. */ -export function toConfigId(config: Config): Hex.Hex { +export function toId(config: Config): Hex.Hex { assert(config) const id = Hash.keccak256( Hex.concat( @@ -342,7 +342,7 @@ export function toConfigId(config: Config): Hex.Hex { return id } -export declare namespace toConfigId { +export declare namespace toId { type ErrorType = | assert.ErrorType | Hash.keccak256.ErrorType diff --git a/src/tempo/SignatureEnvelope.ts b/src/tempo/SignatureEnvelope.ts index e1b7cf33..8079ff81 100644 --- a/src/tempo/SignatureEnvelope.ts +++ b/src/tempo/SignatureEnvelope.ts @@ -1203,7 +1203,7 @@ export declare namespace serialize { * { owner: '0x2222222222222222222222222222222222222222', weight: 1 }, * ], * }) - * const configId = MultisigConfig.toConfigId(config) + * const configId = MultisigConfig.toId(config) * const account = MultisigConfig.getAddress({ configId }) * * const tx = TxEnvelopeTempo.from({ chainId: 1, calls: [] }) diff --git a/src/tempo/e2e.test.ts b/src/tempo/e2e.test.ts index b9c5d8e8..9619a0b1 100644 --- a/src/tempo/e2e.test.ts +++ b/src/tempo/e2e.test.ts @@ -3017,7 +3017,7 @@ describe('behavior: multisig (TIP-1061)', () => { weight: 1, })), }) - const configId = MultisigConfig.toConfigId(config) + const configId = MultisigConfig.toId(config) const account = MultisigConfig.getAddress({ configId }) return { account, config, configId, ownerKeys } as const From b546d7bb2ad2029eb912caad9729e6ac89de732d Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:50:01 +0200 Subject: [PATCH 4/8] Update tempo-native-multisig.md --- .changeset/tempo-native-multisig.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/tempo-native-multisig.md b/.changeset/tempo-native-multisig.md index f77bc616..a3443c6d 100644 --- a/.changeset/tempo-native-multisig.md +++ b/.changeset/tempo-native-multisig.md @@ -1,5 +1,5 @@ --- -'ox': minor +'ox': patch --- -Added support for TIP-1061 native multisig accounts. A new `tempo/MultisigConfig` module derives stable multisig account addresses and permanent config IDs from a weighted owner configuration and computes the owner approval digest that owners sign. `tempo/SignatureEnvelope` gains a `multisig` signature variant (type `0x05`) that aggregates primitive owner approvals and carries the optional bootstrap config (`init`) on the first transaction from a derived account. +`viem/tempo`: Added support for TIP-1061 native multisig accounts. From d9b0222385112d0f40de99cbb2e5dc0928a08c84 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:52:49 +0200 Subject: [PATCH 5/8] docs(tempo): fix multi-line TSDoc code span in MultisigConfig Amp-Thread-ID: https://ampcode.com/threads/T-019e86f0-0dd6-71fc-8b67-efa5b7a4b9c5 --- src/tempo/MultisigConfig.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tempo/MultisigConfig.ts b/src/tempo/MultisigConfig.ts index 391061fb..86949d2e 100644 --- a/src/tempo/MultisigConfig.ts +++ b/src/tempo/MultisigConfig.ts @@ -303,8 +303,7 @@ export declare namespace getSignPayload { * {@link ox#MultisigConfig.Config}. * * Preimage (fixed-width big-endian, **not** RLP): - * `keccak256("tempo:multisig:config" || salt || be_u32(threshold) || be_u32(owners.length) - * || (owner || be_u32(weight)) for each owner)`. + * `keccak256("tempo:multisig:config" || salt || be_u32(threshold) || be_u32(owners.length) || (owner || be_u32(weight)) for each owner)`. * * @example * ```ts twoslash From af7927640ace537c26ff23ccafc1f562d67e4c89 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:54:08 +0200 Subject: [PATCH 6/8] test(tempo): skip multisig e2e until TIP-1061 deployed to standard nodes Amp-Thread-ID: https://ampcode.com/threads/T-019e86f0-0dd6-71fc-8b67-efa5b7a4b9c5 --- src/tempo/e2e.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tempo/e2e.test.ts b/src/tempo/e2e.test.ts index 9619a0b1..523016ed 100644 --- a/src/tempo/e2e.test.ts +++ b/src/tempo/e2e.test.ts @@ -2995,7 +2995,10 @@ describe('behavior: keyAuthorization', () => { ) }) -describe('behavior: multisig (TIP-1061)', () => { +// TODO: unskip once TIP-1061 native multisig is deployed to the standard +// localnet/testnet nodes. Until then these only pass against the dedicated +// PR-5178 devnet (run with VITE_TEMPO_RPC_URL pointed at it). +describe.skip('behavior: multisig (TIP-1061)', () => { // Helper: builds a fresh set of secp256k1 owners + the derived config. function setup(parameters: { count: number; threshold: number }) { const { count, threshold } = parameters From cc609b7aa3e76d0a9e470e1950997934c0ceb8db Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:57:29 +0200 Subject: [PATCH 7/8] docs(tempo): avoid docgen link to const (fixes docs build) Amp-Thread-ID: https://ampcode.com/threads/T-019e86f0-0dd6-71fc-8b67-efa5b7a4b9c5 --- src/tempo/MultisigConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tempo/MultisigConfig.ts b/src/tempo/MultisigConfig.ts index 86949d2e..fe712390 100644 --- a/src/tempo/MultisigConfig.ts +++ b/src/tempo/MultisigConfig.ts @@ -33,7 +33,7 @@ const signatureDomain = 'tempo:multisig:signature' export type Config = Compute<{ /** * Caller-chosen 32-byte salt mixed into the permanent config ID. Defaults to - * the {@link ox#MultisigConfig.zeroSalt} when omitted. + * the zero salt (`MultisigConfig.zeroSalt`) when omitted. */ salt?: Hex.Hex | undefined /** Minimum total owner weight required to authorize a transaction. */ From 3b2d46e5c5540f0efd166975d8b4098954b9fde6 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Tue, 2 Jun 2026 16:01:44 +0200 Subject: [PATCH 8/8] docs(tempo): fix twoslash type error in sortMultisigApprovals example Amp-Thread-ID: https://ampcode.com/threads/T-019e86f0-0dd6-71fc-8b67-efa5b7a4b9c5 --- src/tempo/SignatureEnvelope.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tempo/SignatureEnvelope.ts b/src/tempo/SignatureEnvelope.ts index 8079ff81..3dd545ee 100644 --- a/src/tempo/SignatureEnvelope.ts +++ b/src/tempo/SignatureEnvelope.ts @@ -1210,7 +1210,8 @@ export declare namespace serialize { * const payload = TxEnvelopeTempo.getSignPayload(tx) * const digest = MultisigConfig.getSignPayload({ payload, account, configId }) * - * const signatures = ['0x...', '0x...'].map((privateKey) => + * const privateKeys = [Secp256k1.randomPrivateKey(), Secp256k1.randomPrivateKey()] + * const signatures = privateKeys.map((privateKey) => * SignatureEnvelope.from(Secp256k1.sign({ payload: digest, privateKey })), * ) *