Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-multisigs-pay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"viem": patch
---

`viem/tempo`: Fixed fee-payer sponsorship for native multisig transaction preparation and serialization.
36 changes: 36 additions & 0 deletions src/tempo/Account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
})
})
32 changes: 16 additions & 16 deletions src/tempo/Transaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})

Expand Down
73 changes: 31 additions & 42 deletions src/tempo/Transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -297,7 +296,8 @@ async function serializeTempo(
},
sig?: OneOf<SignatureEnvelope.SignatureEnvelope | viem_Signature> | 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)
Expand Down Expand Up @@ -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,
Expand All @@ -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),
)
Expand All @@ -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,
Expand All @@ -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,
})
}
Expand All @@ -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,
Expand Down
51 changes: 50 additions & 1 deletion src/tempo/Transport.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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],
Expand Down
55 changes: 55 additions & 0 deletions src/tempo/chainConfig.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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()
Expand Down
15 changes: 15 additions & 0 deletions src/tempo/chainConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Loading