diff --git a/.changeset/tempo-native-multisig.md b/.changeset/tempo-native-multisig.md new file mode 100644 index 00000000..a3443c6d --- /dev/null +++ b/.changeset/tempo-native-multisig.md @@ -0,0 +1,5 @@ +--- +'ox': patch +--- + +`viem/tempo`: Added support for TIP-1061 native multisig accounts. diff --git a/src/tempo/MultisigConfig.test.ts b/src/tempo/MultisigConfig.test.ts new file mode 100644 index 00000000..ba6520d2 --- /dev/null +++ b/src/tempo/MultisigConfig.test.ts @@ -0,0 +1,227 @@ +import { MultisigConfig } 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 = MultisigConfig.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(() => + MultisigConfig.from({ threshold: 0, owners: [] }), + ).toThrowError() + }) +}) + +describe('configId', () => { + test('matches independent ground truth', () => { + expect(MultisigConfig.toId(singleOwnerConfig)).toMatchInlineSnapshot( + `"0xd1f20e1a5bfdd89488f57f68db5bd1aae9a51b510f4a042b2604b57a0b7b471d"`, + ) + }) + + test('is stable across calls', () => { + expect(MultisigConfig.toId(singleOwnerConfig)).toBe( + MultisigConfig.toId(singleOwnerConfig), + ) + }) + + test('differs for a different salt', () => { + expect(MultisigConfig.toId(singleOwnerConfig)).not.toBe( + MultisigConfig.toId({ + ...singleOwnerConfig, + salt: `0x${'42'.repeat(32)}`, + }), + ) + }) + + test('throws on invalid config', () => { + expect(() => + MultisigConfig.toId({ + threshold: 5, + owners: singleOwnerConfig.owners, + }), + ).toThrowError() + }) +}) + +describe('getAddress', () => { + test('matches independent ground truth', () => { + expect( + MultisigConfig.getAddress({ config: singleOwnerConfig }), + ).toMatchInlineSnapshot(`"0x6ca655065b1de473d903eebd50e5cb4996e10468"`) + }) + + test('derives from config or configId identically', () => { + const configId = MultisigConfig.toId(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 = MultisigConfig.toId(singleOwnerConfig) + const b = MultisigConfig.toId(MultisigConfig.from(singleOwnerConfig)) + expect(a).toBe(b) + }) +}) + +describe('getSignPayload', () => { + test('matches independent ground truth', () => { + const configId = MultisigConfig.toId(singleOwnerConfig) + const account = MultisigConfig.getAddress({ configId }) + expect( + MultisigConfig.getSignPayload({ + payload: `0x${'42'.repeat(32)}`, + account, + configId, + }), + ).toMatchInlineSnapshot( + `"0xe3d66f6118b89a67c71c8137c46abf0c829056a46ee6a038a1b42c84529fc17e"`, + ) + }) +}) + +describe('toTuple / fromTuple', () => { + test('round-trips', () => { + const config = MultisigConfig.from({ + threshold: 3, + owners: [ + { owner: owner1, weight: 1 }, + { owner: owner2, weight: 2 }, + ], + }) + const tuple = MultisigConfig.toTuple(config) + expect(MultisigConfig.fromTuple(tuple)).toEqual(config) + }) + + test('encodes each owner as `[owner, weight]`', () => { + const [, , owners] = MultisigConfig.toTuple(singleOwnerConfig) + expect(owners[0]).toEqual([owner1, '0x1']) + }) + + test('encodes salt as a full 32-byte string (first element)', () => { + const [salt] = MultisigConfig.toTuple(singleOwnerConfig) + expect(salt).toBe(MultisigConfig.zeroSalt) + }) + + test('round-trips a non-zero salt', () => { + const config = MultisigConfig.from({ + ...singleOwnerConfig, + salt: `0x${'42'.repeat(32)}`, + }) + const tuple = MultisigConfig.toTuple(config) + expect(tuple[0]).toBe(`0x${'42'.repeat(32)}`) + expect(MultisigConfig.fromTuple(tuple)).toEqual(config) + }) +}) + +describe('assert / validate', () => { + test('valid config', () => { + expect(MultisigConfig.validate(singleOwnerConfig)).toBe(true) + }) + + test('empty owners', () => { + expect(MultisigConfig.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(MultisigConfig.validate({ threshold: 1, owners })).toBe(false) + }) + + test('zero threshold', () => { + expect( + MultisigConfig.validate({ + threshold: 0, + owners: singleOwnerConfig.owners, + }), + ).toBe(false) + }) + + test('threshold exceeds total weight', () => { + expect( + MultisigConfig.validate({ + threshold: 2, + owners: singleOwnerConfig.owners, + }), + ).toBe(false) + }) + + test('zero owner weight', () => { + expect( + MultisigConfig.validate({ + threshold: 1, + owners: [{ owner: owner1, weight: 0 }], + }), + ).toBe(false) + }) + + test('zero owner address', () => { + expect( + MultisigConfig.validate({ + threshold: 1, + owners: [ + { + owner: '0x0000000000000000000000000000000000000000', + weight: 1, + }, + ], + }), + ).toBe(false) + }) + + test('unsorted owners', () => { + expect( + MultisigConfig.validate({ + threshold: 1, + owners: [ + { owner: owner2, weight: 1 }, + { owner: owner1, weight: 1 }, + ], + }), + ).toBe(false) + }) + + test('duplicate owners', () => { + expect( + MultisigConfig.validate({ + threshold: 1, + owners: [ + { owner: owner1, weight: 1 }, + { owner: owner1, weight: 1 }, + ], + }), + ).toBe(false) + }) + + test('invalid salt size', () => { + expect( + MultisigConfig.validate({ + ...singleOwnerConfig, + salt: '0x42', + }), + ).toBe(false) + }) +}) diff --git a/src/tempo/MultisigConfig.ts b/src/tempo/MultisigConfig.ts new file mode 100644 index 00000000..fe712390 --- /dev/null +++ b/src/tempo/MultisigConfig.ts @@ -0,0 +1,423 @@ +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 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[] +}> + +/** 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#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 `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 { MultisigConfig } from 'ox/tempo' + * + * MultisigConfig.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#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 { 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( + 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#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. + * + * `keccak256("tempo:multisig:account" || config_id)[12:32]`. + * + * @example + * ```ts twoslash + * import { MultisigConfig } from 'ox/tempo' + * + * const config = MultisigConfig.from({ + * threshold: 1, + * owners: [ + * { owner: '0x1111111111111111111111111111111111111111', weight: 1 }, + * ], + * }) + * + * const address = MultisigConfig.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 : toId(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 = + | toId.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 { MultisigConfig, TxEnvelopeTempo } from 'ox/tempo' + * + * const config = MultisigConfig.from({ + * threshold: 1, + * owners: [ + * { owner: '0x1111111111111111111111111111111111111111', weight: 1 }, + * ], + * }) + * const configId = MultisigConfig.toId(config) + * const account = MultisigConfig.getAddress({ configId }) + * + * const envelope = TxEnvelopeTempo.from({ + * chainId: 1, + * calls: [], + * }) + * + * const digest = MultisigConfig.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#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)`. + * + * @example + * ```ts twoslash + * import { MultisigConfig } from 'ox/tempo' + * + * const config = MultisigConfig.from({ + * threshold: 1, + * owners: [ + * { owner: '0x1111111111111111111111111111111111111111', weight: 1 }, + * ], + * }) + * + * const configId = MultisigConfig.toId(config) + * ``` + * + * @param config - The multisig config. + * @returns The 32-byte config ID. + */ +export function toId(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 toId { + type ErrorType = + | assert.ErrorType + | Hash.keccak256.ErrorType + | Hex.concat.ErrorType + | Hex.fromNumber.ErrorType + | Hex.fromString.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}.`) + } +} diff --git a/src/tempo/SignatureEnvelope.test.ts b/src/tempo/SignatureEnvelope.test.ts index 4bb4ebed..41a69147 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 MultisigConfig from './MultisigConfig.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 = MultisigConfig.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..3dd545ee 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 MultisigConfig from './MultisigConfig.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?: MultisigConfig.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?: MultisigConfig.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) MultisigConfig.assert(multisig.init) + return + } } export declare namespace assert { type ErrorType = | CoercionError | MissingPropertiesError + | MultisigConfig.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,31 @@ 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 | MultisigConfig.Tuple)?, + ] + return { + type: 'multisig', + account, + configId, + signatures: signatures.map((signature) => deserialize(signature)), + ...(init && init !== '0x' + ? { + init: MultisigConfig.fromTuple(init as MultisigConfig.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 +772,19 @@ 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: MultisigConfig.from(multisig.init) } : {}), + type, + } as never + } + return { ...value, ...(type === 'p256' ? { prehash: value.prehash } : {}), @@ -837,14 +942,35 @@ 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: MultisigConfig.from(multisig.init) } : {}), + } + } throw new CoercionError({ envelope }) } @@ -922,6 +1048,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 +1149,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 ? MultisigConfig.toTuple(multisig.init) : '0x', + ]), + options.magic ? magicBytes : '0x', + ) + } + throw new CoercionError({ envelope }) } @@ -1028,6 +1180,90 @@ 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#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). + * + * @example + * ```ts twoslash + * import { Secp256k1 } from 'ox' + * import { MultisigConfig, SignatureEnvelope, TxEnvelopeTempo } from 'ox/tempo' + * + * const config = MultisigConfig.from({ + * threshold: 2, + * owners: [ + * { owner: '0x1111111111111111111111111111111111111111', weight: 1 }, + * { owner: '0x2222222222222222222222222222222222222222', weight: 1 }, + * ], + * }) + * const configId = MultisigConfig.toId(config) + * const account = MultisigConfig.getAddress({ configId }) + * + * const tx = TxEnvelopeTempo.from({ chainId: 1, calls: [] }) + * const payload = TxEnvelopeTempo.getSignPayload(tx) + * const digest = MultisigConfig.getSignPayload({ payload, account, configId }) + * + * const privateKeys = [Secp256k1.randomPrivateKey(), Secp256k1.randomPrivateKey()] + * const signatures = privateKeys.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 = MultisigConfig.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 = + | MultisigConfig.getSignPayload.ErrorType + | extractAddress.ErrorType + | Errors.GlobalErrorType +} + /** * Converts a signature envelope to RPC format. * @@ -1095,6 +1331,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 +1580,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..523016ed 100644 --- a/src/tempo/e2e.test.ts +++ b/src/tempo/e2e.test.ts @@ -14,6 +14,7 @@ import { chain, client, fundAddress, nodeEnv } from '../../test/tempo/config.js' import { AuthorizationTempo, KeyAuthorization, + MultisigConfig, Period, SignatureEnvelope, } from './index.js' @@ -2993,3 +2994,214 @@ describe('behavior: keyAuthorization', () => { }, ) }) + +// 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 + const ownerKeys = Array.from({ length: count }, () => { + const privateKey = Secp256k1.randomPrivateKey() + const address = Address.fromPublicKey( + Secp256k1.getPublicKey({ privateKey }), + ) + return { address, privateKey } as const + }) + + 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), + threshold, + owners: ownerKeys.map((key) => ({ + owner: key.address, + weight: 1, + })), + }) + const configId = MultisigConfig.toId(config) + const account = MultisigConfig.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 = MultisigConfig.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..6a519442 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -102,6 +102,32 @@ export * as Channel from './Channel.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. * 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')