From a13afcc11015265f5f10d563df073c1c8f3d6174 Mon Sep 17 00:00:00 2001 From: jxom <7336481+jxom@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:11:02 +1000 Subject: [PATCH] fix(tempo): support sponsored multisig transactions --- .changeset/bright-multisigs-pay.md | 5 ++ src/tempo/Account.test.ts | 36 +++++++++++++++ src/tempo/Transaction.test.ts | 32 ++++++------- src/tempo/Transaction.ts | 73 +++++++++++++----------------- src/tempo/Transport.test.ts | 51 ++++++++++++++++++++- src/tempo/chainConfig.test.ts | 55 ++++++++++++++++++++++ src/tempo/chainConfig.ts | 15 ++++++ 7 files changed, 208 insertions(+), 59 deletions(-) create mode 100644 .changeset/bright-multisigs-pay.md diff --git a/.changeset/bright-multisigs-pay.md b/.changeset/bright-multisigs-pay.md new file mode 100644 index 0000000000..7f8787327d --- /dev/null +++ b/.changeset/bright-multisigs-pay.md @@ -0,0 +1,5 @@ +--- +"viem": patch +--- + +`viem/tempo`: Fixed fee-payer sponsorship for native multisig transaction preparation and serialization. diff --git a/src/tempo/Account.test.ts b/src/tempo/Account.test.ts index 89a573f8c0..8683fd87a2 100644 --- a/src/tempo/Account.test.ts +++ b/src/tempo/Account.test.ts @@ -1533,4 +1533,40 @@ describe.runIf(import.meta.env.VITE_TEMPO_MULTISIG)('multisig', () => { expect(receipt.status).toBe('success') expect(receipt.from).toBe(account.address.toLowerCase()) }) + + test('fee payer sponsors bootstrap multisig', async () => { + const owner_1 = accounts[12] + const owner_2 = accounts[13] + const config = MultisigConfig.from({ + threshold: 2, + owners: [ + { owner: owner_1.address, weight: 1 }, + { owner: owner_2.address, weight: 1 }, + ], + }) + const account = Account.fromMultisig(config) + + const request = await prepareTransactionRequest(client, { + feePayer: accounts[0], + multisig: config, + to: account.address, + value: 0n, + }) + const signatures = await Promise.all( + [owner_1, owner_2].map((owner) => + signTransaction(client, { ...request, account: owner }), + ), + ) + const receipt = await sendTransactionSync(client, { + ...request, + account, + feePayer: accounts[0], + multisig: config, + signatures, + }) + + expect(receipt.status).toBe('success') + expect(receipt.from).toBe(account.address.toLowerCase()) + expect(receipt.feePayer).toBe(accounts[0].address.toLowerCase()) + }) }) diff --git a/src/tempo/Transaction.test.ts b/src/tempo/Transaction.test.ts index 0d6dd6a3f8..af95e6a489 100644 --- a/src/tempo/Transaction.test.ts +++ b/src/tempo/Transaction.test.ts @@ -293,22 +293,22 @@ describe('serialize', () => { expect(serialized.startsWith('0x76')).toBe(true) }) - test('behavior: throws when feePayer object but no from and non-secp256k1 sig', async () => { - await expect( - Transaction.serialize( - { - chainId: 1, - calls: [{ to: '0x0000000000000000000000000000000000000000' }], - feePayer: accounts.at(1)!, - }, - { - type: 'p256', - signature: { r: 1n, s: 1n }, - publicKey: { x: 1n, y: 1n, prefix: 4 }, - prehash: true, - }, - ), - ).rejects.toThrow('Unable to extract sender from transaction or signature.') + test('behavior: feePayer object derives sender from p256 signature', async () => { + const serialized = await Transaction.serialize( + { + chainId: 1, + calls: [{ to: '0x0000000000000000000000000000000000000000' }], + feePayer: accounts.at(1)!, + }, + { + type: 'p256', + signature: { r: 1n, s: 1n }, + publicKey: { x: 1n, y: 1n, prefix: 4 }, + prehash: true, + }, + ) + + expect(serialized.startsWith('0x76')).toBe(true) }) }) diff --git a/src/tempo/Transaction.ts b/src/tempo/Transaction.ts index a751653410..805b91748f 100644 --- a/src/tempo/Transaction.ts +++ b/src/tempo/Transaction.ts @@ -2,7 +2,6 @@ import type { Address } from 'abitype' import * as Hex from 'ox/Hex' -import * as Secp256k1 from 'ox/Secp256k1' import * as Signature from 'ox/Signature' import { type AuthorizationTempo, @@ -297,7 +296,8 @@ async function serializeTempo( }, sig?: OneOf | undefined, ) { - const signature = (() => { + // Track caller signatures separately from synthesized multisig approvals. + const signature_provided = (() => { if (transaction.signature) return transaction.signature if (sig && 'type' in sig) return sig as SignatureEnvelope.SignatureEnvelope if (sig) @@ -325,9 +325,14 @@ async function serializeTempo( const hasPrefilledFeePayerSignature = typeof transaction.feePayerSignature !== 'undefined' && transaction.feePayerSignature !== null + // Sponsorship sender signatures omit `feeToken`. const shouldStripFeeTokenForSponsorship = - (feePayer === true && (!signature || !feePayerSignature)) || - (!signature && hasPrefilledFeePayerSignature) + // Relay fills a partial sender envelope first. + (feePayer === true && (!signature_provided || !feePayerSignature)) || + // Local fee-payer accounts sign after sender serialization. + (typeof feePayer === 'object' && !signature_provided) || + // Filled transactions can already include fee-payer approval. + (!signature_provided && hasPrefilledFeePayerSignature) const transaction_ox = { ...rest, @@ -354,22 +359,15 @@ async function serializeTempo( // for the sender sign payload and the partial sponsorship handoff envelope. // Keep it only on the final broadcast envelope so the chain can verify // the fee payer. - if (shouldStripFeeTokenForSponsorship) delete transaction_ox.feeToken - - // Native multisig (TIP-1061): combine the collected owner approvals into the - // multisig signature envelope and serialize the broadcast transaction. The - // approvals are recovered against the multisig owner digest and ordered into - // the strictly-ascending owner address order the node requires. - // - // Bootstrap (`init`) is auto-detected from the nonce: the protocol requires - // (and consumes) nonce `0` for the first transaction from a derived account, - // so `nonce == 0` uniquely identifies a bootstrap. This matches the serialized - // nonce above (a falsy `nonce` serializes as `0`). The owner approval digest - // doesn't commit to `init`, so attaching it here (rather than at owner-signing) - // is safe. - // NOTE: fee-payer + multisig is handled in a later phase. - if (transaction.multisig && transaction.signatures && !feePayer) { - const payload = TxTempo.getSignPayload(TxTempo.from(transaction_ox)) + const transaction_sender_ox = { ...transaction_ox } + if (shouldStripFeeTokenForSponsorship) delete transaction_sender_ox.feeToken + + // Combine owner approvals before fee-payer handling. + const signature = (() => { + if (signature_provided) return signature_provided + if (!transaction.multisig || !transaction.signatures) return undefined + + const payload = TxTempo.getSignPayload(TxTempo.from(transaction_sender_ox)) const signatures = transaction.signatures.map((approval) => SignatureEnvelope.from(approval), ) @@ -378,31 +376,25 @@ async function serializeTempo( signatures, genesisConfig: transaction.multisig, }) - const signature = SignatureEnvelope.from({ + return SignatureEnvelope.from({ genesisConfig: transaction.multisig, signatures: sorted, ...(nonce ? {} : { init: true }), }) - return TxTempo.serialize(transaction_ox, { - feePayerSignature: undefined, - signature, - }) - } + })() if (signature && typeof transaction.feePayer === 'object') { const tx = TxTempo.from(transaction_ox, { signature, }) - const sender = (() => { - if (transaction.from) return transaction.from - if (signature.type === 'secp256k1') - return Secp256k1.recoverAddress({ - payload: TxTempo.getSignPayload(tx), - signature: signature.signature, - }) - throw new Error('Unable to extract sender from transaction or signature.') - })() + const sender = + transaction.from ?? + SignatureEnvelope.extractAddress({ + payload: TxTempo.getSignPayload(tx), + root: true, + signature, + }) const hash = TxTempo.getFeePayerSignPayload(tx, { sender, @@ -421,16 +413,16 @@ async function serializeTempo( // Fee payer signature was prefilled during `eth_fillTransaction` -- emit // a full envelope with both signatures to skip `eth_signRawTransaction`. if (signature && feePayerSignature) - return TxTempo.serialize(transaction_ox, { + return TxTempo.serialize(transaction_sender_ox, { signature, }) if (signature) - return TxTempo.serialize(transaction_ox, { + return TxTempo.serialize(transaction_sender_ox, { format: 'feePayer', sender: transaction.from, signature, }) - return TxTempo.serialize(transaction_ox, { + return TxTempo.serialize(transaction_sender_ox, { feePayerSignature: null, }) } @@ -439,10 +431,7 @@ async function serializeTempo( // If we have specified a fee payer, the user will not be signing over the fee token. // Defer the fee token signing to the fee payer. Once the fee payer has signed, // keep `feeToken` so the broadcast envelope carries the token the chain must charge. - { - ...transaction_ox, - ...(feePayer && !feePayerSignature ? { feeToken: undefined } : {}), - }, + transaction_sender_ox, { feePayerSignature: undefined, signature, diff --git a/src/tempo/Transport.test.ts b/src/tempo/Transport.test.ts index 435e26af43..39ec06dc98 100644 --- a/src/tempo/Transport.test.ts +++ b/src/tempo/Transport.test.ts @@ -1,15 +1,17 @@ import * as Http from 'node:http' import { createRequestListener } from '@remix-run/node-fetch-server' import { Hex, RpcRequest, RpcResponse } from 'ox' +import { MultisigConfig } from 'ox/tempo' import { privateKeyToAccount } from 'viem/accounts' import { getCallsStatus, + prepareTransactionRequest, sendCallsSync, sendTransaction, sendTransactionSync, signTransaction, } from 'viem/actions' -import { Transaction } from 'viem/tempo' +import { Account, Transaction } from 'viem/tempo' import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest' import { accounts, chain, getClient, http } from '~test/tempo/config.js' import { walletNamespaceCompat, withFeePayer, withRelay } from './Transport.js' @@ -242,6 +244,53 @@ describe('withRelay', () => { }) }) + test.runIf(import.meta.env.VITE_TEMPO_MULTISIG)( + 'behavior: sendTransactionSync sponsors multisig with feePayer: true', + async () => { + const owner_1 = accounts[14] + const owner_2 = accounts[15] + const config = MultisigConfig.from({ + threshold: 2, + owners: [ + { owner: owner_1.address, weight: 1 }, + { owner: owner_2.address, weight: 1 }, + ], + }) + const account = Account.fromMultisig(config) + + const request = await prepareTransactionRequest(client, { + feePayer: true, + multisig: config, + to: account.address, + value: 0n, + }) + const signatures = await Promise.all( + [owner_1, owner_2].map((owner) => + signTransaction(client, { ...request, account: owner }), + ), + ) + const receipt = await sendTransactionSync(client, { + ...request, + account, + feePayer: true, + multisig: config, + signatures, + }) + + expect(receipt.status).toBe('success') + expect(receipt.from).toBe(account.address.toLowerCase()) + expect(receipt.feePayer).toBe(accounts[0].address.toLowerCase()) + expect(relayRequests).toContainEqual({ + method: 'eth_fillTransaction', + params: expect.any(Array), + }) + expect(relayRequests).toContainEqual({ + method: 'eth_signRawTransaction', + params: expect.any(Array), + }) + }, + ) + test('behavior: non-sponsored transaction uses default transport', async () => { const receipt = await sendTransactionSync(client, { account: accounts[0], diff --git a/src/tempo/chainConfig.test.ts b/src/tempo/chainConfig.test.ts index e09460ade9..3750839c18 100644 --- a/src/tempo/chainConfig.test.ts +++ b/src/tempo/chainConfig.test.ts @@ -1,3 +1,4 @@ +import { MultisigConfig } from 'ox/tempo' import { describe, expect, test, vi } from 'vitest' import { accounts, feeToken, getClient } from '~test/tempo/config.js' import { generatePrivateKey } from '../accounts/generatePrivateKey.js' @@ -148,6 +149,60 @@ describe('prepareTransactionRequest', () => { expect(request.feeToken).toBe(feeToken) }) + test('behavior: multisigSignatureCount inferred from equal weights', async () => { + const config = MultisigConfig.from({ + threshold: 2, + owners: [ + { owner: accounts[1].address, weight: 1 }, + { owner: accounts[2].address, weight: 1 }, + { owner: accounts[3].address, weight: 1 }, + ], + }) + + const request = await prepareTransactionRequest(client, { + multisig: config, + parameters: ['chainId'], + }) + + expect(request.multisigSignatureCount).toBe(2) + }) + + test('behavior: multisigSignatureCount inferred from weights', async () => { + const config = MultisigConfig.from({ + threshold: 2, + owners: [ + { owner: accounts[1].address, weight: 2 }, + { owner: accounts[2].address, weight: 1 }, + ], + }) + + const request = await prepareTransactionRequest(client, { + multisig: config, + parameters: ['chainId'], + }) + + expect(request.multisigSignatureCount).toBe(1) + }) + + test('behavior: explicit multisigSignatureCount is preserved', async () => { + const config = MultisigConfig.from({ + threshold: 2, + owners: [ + { owner: accounts[1].address, weight: 1 }, + { owner: accounts[2].address, weight: 1 }, + { owner: accounts[3].address, weight: 1 }, + ], + }) + + const request = await prepareTransactionRequest(client, { + multisig: config, + multisigSignatureCount: 3, + parameters: ['chainId'], + }) + + expect(request.multisigSignatureCount).toBe(3) + }) + test('behavior: keyAuthorizationManager attaches pending key authorization', async () => { const rootAccount = accounts.at(0)! const keyAuthorizationManager = KeyAuthorizationManager.memory() diff --git a/src/tempo/chainConfig.ts b/src/tempo/chainConfig.ts index fd17cabcee..90d60a85e8 100644 --- a/src/tempo/chainConfig.ts +++ b/src/tempo/chainConfig.ts @@ -94,6 +94,7 @@ export const chainConfig = { weight: Number(owner.weight), })), } + request.multisigSignatureCount ??= inferMultisigSignatureCount(config) // A non-multisig `account` (e.g. the client's default) isn't the sender, // so drop it: core then fills nonce/gas/fees for the multisig sender via // `request.from`. A multisig account *is* the sender — keep it so the @@ -147,6 +148,7 @@ export const chainConfig = { // validBefore timestamp. const useExpiringNonce = await (async () => { if (request.nonceKey === 'expiring') return true + if (multisig) return false if (request.feePayer && typeof request.nonceKey === 'undefined') return true const address = request.account?.address @@ -261,3 +263,16 @@ export const chainConfig = { } as const satisfies viem_ChainConfig & { blockTime: number } export type ChainConfig = typeof chainConfig + +function inferMultisigSignatureCount(config: MultisigConfig.Config) { + const threshold = Number(config.threshold) + const weights = config.owners + .map((owner) => Number(owner.weight)) + .sort((a, b) => b - a) + let total = 0 + for (const [index, weight] of weights.entries()) { + total += weight + if (total >= threshold) return index + 1 + } + return weights.length +}