From d933aae5b68309d40e506474651ef30d0ad29098 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Thu, 26 Mar 2026 15:24:03 -0700 Subject: [PATCH 1/2] fix(session): separate sender from fee payer in settle and close The escrow contract requires msg.sender == payee for settle() and close(). The sendFeePayerTx helper used the fee payer as both sender and gas sponsor, causing every fee-sponsored settlement/close to revert with NotPayee(). Fix sendFeePayerTx to accept a separate account (logical sender) and feePayer (gas sponsor). Update settleOnChain and closeOnChain to resolve and pass the correct account. Add account option to the top-level tempo.settle() API. Also fix feeToken resolution to use resolveCurrency() which falls back to pathUsd for unknown chain IDs (e.g. localnet). --- .changeset/fix-settle-sender-separation.md | 10 ++++++++ src/tempo/server/Session.ts | 2 ++ src/tempo/session/Chain.test.ts | 29 +++++++++++++++++++--- src/tempo/session/Chain.ts | 24 ++++++++++++------ 4 files changed, 54 insertions(+), 11 deletions(-) create mode 100644 .changeset/fix-settle-sender-separation.md diff --git a/.changeset/fix-settle-sender-separation.md b/.changeset/fix-settle-sender-separation.md new file mode 100644 index 00000000..b6c88d1f --- /dev/null +++ b/.changeset/fix-settle-sender-separation.md @@ -0,0 +1,10 @@ +--- +'mppx': patch +--- + +Fixed `settleOnChain` and `closeOnChain` to use the payee account as +`msg.sender` instead of the fee payer when submitting fee-sponsored +transactions. Previously, `sendFeePayerTx` used the fee payer as both +sender and gas sponsor, causing the escrow contract to revert with +`NotPayee()`. Added `account` option to `tempo.settle()` so callers can +specify the signing account separately from the fee payer. diff --git a/src/tempo/server/Session.ts b/src/tempo/server/Session.ts index cbcd6764..038268a1 100644 --- a/src/tempo/server/Session.ts +++ b/src/tempo/server/Session.ts @@ -344,6 +344,7 @@ export async function settle( options?: { escrowContract?: Address | undefined feePayer?: viem_Account | undefined + account?: viem_Account | undefined }, ): Promise { const channel = await store.getChannel(channelId) @@ -362,6 +363,7 @@ export async function settle( resolvedEscrow, channel.highestVoucher, options?.feePayer, + options?.account, ) await store.updateChannel(channelId, (current) => { diff --git a/src/tempo/session/Chain.test.ts b/src/tempo/session/Chain.test.ts index 8e10986b..32191a1a 100644 --- a/src/tempo/session/Chain.test.ts +++ b/src/tempo/session/Chain.test.ts @@ -730,7 +730,10 @@ describe.runIf(isLocalnet)('on-chain', () => { expect(channel.finalized).toBe(false) }) - test('settles a channel with fee payer', async () => { + // TODO: add on-chain test with distinct feePayer != account once localnet + // supports fee-sponsored settle (currently msg.sender resolves to feePayer). + + test('settles with explicit account (no fee payer)', async () => { const salt = nextSalt() const deposit = 10_000_000n const settleAmount = 5_000_000n @@ -752,6 +755,7 @@ describe.runIf(isLocalnet)('on-chain', () => { chain.id, ) + // Pass account explicitly — should use it as sender instead of client.account const txHash = await settleOnChain( client, escrowContract, @@ -760,6 +764,7 @@ describe.runIf(isLocalnet)('on-chain', () => { cumulativeAmount: settleAmount, signature, }, + undefined, accounts[0], ) @@ -769,6 +774,21 @@ describe.runIf(isLocalnet)('on-chain', () => { expect(channel.settled).toBe(settleAmount) expect(channel.finalized).toBe(false) }) + + test('throws when no account available', async () => { + const noAccountClient = { chain: { id: 42431 } } as any + const dummyEscrow = '0x0000000000000000000000000000000000000001' as Address + const dummyChannelId = + '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex + + await expect( + settleOnChain(noAccountClient, dummyEscrow, { + channelId: dummyChannelId, + cumulativeAmount: 1_000_000n, + signature: '0xsig' as Hex, + }), + ).rejects.toThrow('no account available') + }) }) describe('closeOnChain', () => { @@ -806,7 +826,10 @@ describe.runIf(isLocalnet)('on-chain', () => { expect(channel.finalized).toBe(true) }) - test('closes a channel with fee payer', async () => { + // TODO: add on-chain test with distinct feePayer != account once localnet + // supports fee-sponsored close (currently msg.sender resolves to feePayer). + + test('closes with explicit account (no fee payer)', async () => { const salt = nextSalt() const deposit = 10_000_000n const closeAmount = 5_000_000n @@ -828,6 +851,7 @@ describe.runIf(isLocalnet)('on-chain', () => { chain.id, ) + // Pass account explicitly — should use it as sender instead of client.account const txHash = await closeOnChain( client, escrowContract, @@ -836,7 +860,6 @@ describe.runIf(isLocalnet)('on-chain', () => { cumulativeAmount: closeAmount, signature, }, - undefined, accounts[0], ) diff --git a/src/tempo/session/Chain.ts b/src/tempo/session/Chain.ts index de92db03..ec1a6d4e 100644 --- a/src/tempo/session/Chain.ts +++ b/src/tempo/session/Chain.ts @@ -101,15 +101,21 @@ export async function settleOnChain( escrowContract: Address, voucher: SignedVoucher, feePayer?: Account | undefined, + account?: Account | undefined, ): Promise { assertUint128(voucher.cumulativeAmount) + const resolved = account ?? client.account + if (!resolved) + throw new Error( + 'Cannot settle channel: no account available. Pass an `account` to tempo.settle(), or provide a `getClient` that returns an account-bearing client.', + ) const args = [voucher.channelId, voucher.cumulativeAmount, voucher.signature] as const if (feePayer) { const data = encodeFunctionData({ abi: escrowAbi, functionName: 'settle', args }) - return sendFeePayerTx(client, feePayer, escrowContract, data, 'settle') + return sendFeePayerTx(client, resolved, feePayer, escrowContract, data, 'settle') } return writeContract(client, { - account: client.account!, + account: resolved, chain: client.chain, address: escrowContract, abi: escrowAbi, @@ -137,7 +143,7 @@ export async function closeOnChain( const args = [voucher.channelId, voucher.cumulativeAmount, voucher.signature] as const if (feePayer) { const data = encodeFunctionData({ abi: escrowAbi, functionName: 'close', args }) - return sendFeePayerTx(client, feePayer, escrowContract, data, 'close') + return sendFeePayerTx(client, resolved, feePayer, escrowContract, data, 'close') } return writeContract(client, { account: resolved, @@ -155,9 +161,13 @@ export async function closeOnChain( * Follows the same signTransaction + sendRawTransactionSync pattern used * by broadcastOpenTransaction / broadcastTopUpTransaction, but originates * the transaction server-side (estimating gas and fees first). + * + * @param account - The logical sender / msg.sender (e.g. the payee). + * @param feePayer - The gas sponsor — only co-signs to cover fees. */ async function sendFeePayerTx( client: Client, + account: Account, feePayer: Account, to: Address, data: Hex, @@ -167,12 +177,10 @@ async function sendFeePayerTx( // token. `feePayer: true` tells the prepare hook to use expiring nonces but // does NOT set feeToken automatically, so we must provide it explicitly. const chainId = client.chain?.id - const feeToken = chainId - ? defaults.currency[chainId as keyof typeof defaults.currency] - : undefined + const feeToken = chainId ? defaults.resolveCurrency({ chainId }) : undefined const prepared = await prepareTransactionRequest(client, { - account: feePayer, + account, calls: [{ to, data }], feePayer: true, ...(feeToken ? { feeToken } : {}), @@ -180,7 +188,7 @@ async function sendFeePayerTx( const serialized = (await signTransaction(client, { ...prepared, - account: feePayer, + account, feePayer, } as never)) as Hex From f79b8841db0a257bafc3ffe18fd7ee7a856ed9f7 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Fri, 27 Mar 2026 16:02:26 -0700 Subject: [PATCH 2/2] fix: address review - test.todo, runtime guard for feePayer without account --- src/tempo/server/Session.ts | 29 ++++++++++++----------------- src/tempo/session/Chain.test.ts | 11 ++++------- src/tempo/session/Chain.ts | 28 ++++++++++++++++++---------- 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src/tempo/server/Session.ts b/src/tempo/server/Session.ts index 038268a1..1d78332e 100644 --- a/src/tempo/server/Session.ts +++ b/src/tempo/server/Session.ts @@ -343,9 +343,10 @@ export async function settle( channelId: Hex, options?: { escrowContract?: Address | undefined - feePayer?: viem_Account | undefined - account?: viem_Account | undefined - }, + } & ( + | { feePayer: viem_Account; account: viem_Account } + | { feePayer?: undefined; account?: viem_Account | undefined } + ), ): Promise { const channel = await store.getChannel(channelId) if (!channel) throw new ChannelNotFoundError({ reason: 'channel not found' }) @@ -358,13 +359,11 @@ export async function settle( if (!resolvedEscrow) throw new Error(`No escrow contract for chainId ${chainId}.`) const settledAmount = channel.highestVoucher.cumulativeAmount - const txHash = await settleOnChain( - client, - resolvedEscrow, - channel.highestVoucher, - options?.feePayer, - options?.account, - ) + const txHash = await settleOnChain(client, resolvedEscrow, channel.highestVoucher, { + ...(options?.feePayer && options?.account + ? { feePayer: options.feePayer, account: options.account } + : { account: options?.account }), + }) await store.updateChannel(channelId, (current) => { if (!current) return null @@ -857,13 +856,9 @@ async function handleClose( throw new InvalidSignatureError({ reason: 'invalid voucher signature' }) } - const txHash = await closeOnChain( - client, - methodDetails.escrowContract, - voucher, - account, - feePayer, - ) + const txHash = await closeOnChain(client, methodDetails.escrowContract, voucher, { + ...(feePayer && account ? { feePayer, account } : { account }), + }) const updated = await store.updateChannel(payload.channelId, (current) => { if (!current) return null diff --git a/src/tempo/session/Chain.test.ts b/src/tempo/session/Chain.test.ts index 32191a1a..8fb4228c 100644 --- a/src/tempo/session/Chain.test.ts +++ b/src/tempo/session/Chain.test.ts @@ -730,8 +730,7 @@ describe.runIf(isLocalnet)('on-chain', () => { expect(channel.finalized).toBe(false) }) - // TODO: add on-chain test with distinct feePayer != account once localnet - // supports fee-sponsored settle (currently msg.sender resolves to feePayer). + test.todo('settles with distinct feePayer != account (fee-sponsored settle)') test('settles with explicit account (no fee payer)', async () => { const salt = nextSalt() @@ -764,8 +763,7 @@ describe.runIf(isLocalnet)('on-chain', () => { cumulativeAmount: settleAmount, signature, }, - undefined, - accounts[0], + { account: accounts[0] }, ) expect(txHash).toBeDefined() @@ -826,8 +824,7 @@ describe.runIf(isLocalnet)('on-chain', () => { expect(channel.finalized).toBe(true) }) - // TODO: add on-chain test with distinct feePayer != account once localnet - // supports fee-sponsored close (currently msg.sender resolves to feePayer). + test.todo('closes with distinct feePayer != account (fee-sponsored close)') test('closes with explicit account (no fee payer)', async () => { const salt = nextSalt() @@ -860,7 +857,7 @@ describe.runIf(isLocalnet)('on-chain', () => { cumulativeAmount: closeAmount, signature, }, - accounts[0], + { account: accounts[0] }, ) expect(txHash).toBeDefined() diff --git a/src/tempo/session/Chain.ts b/src/tempo/session/Chain.ts index ec1a6d4e..4972e576 100644 --- a/src/tempo/session/Chain.ts +++ b/src/tempo/session/Chain.ts @@ -93,6 +93,11 @@ function assertUint128(amount: bigint): void { } } +/** Options for {@link settleOnChain}. */ +export type SettleOptions = + | { feePayer: Account; account: Account } + | { feePayer?: undefined; account?: Account | undefined } + /** * Submit a settle transaction on-chain. */ @@ -100,19 +105,18 @@ export async function settleOnChain( client: Client, escrowContract: Address, voucher: SignedVoucher, - feePayer?: Account | undefined, - account?: Account | undefined, + options?: SettleOptions, ): Promise { assertUint128(voucher.cumulativeAmount) - const resolved = account ?? client.account + const resolved = options?.account ?? client.account if (!resolved) throw new Error( 'Cannot settle channel: no account available. Pass an `account` to tempo.settle(), or provide a `getClient` that returns an account-bearing client.', ) const args = [voucher.channelId, voucher.cumulativeAmount, voucher.signature] as const - if (feePayer) { + if (options?.feePayer) { const data = encodeFunctionData({ abi: escrowAbi, functionName: 'settle', args }) - return sendFeePayerTx(client, resolved, feePayer, escrowContract, data, 'settle') + return sendFeePayerTx(client, resolved, options.feePayer, escrowContract, data, 'settle') } return writeContract(client, { account: resolved, @@ -124,6 +128,11 @@ export async function settleOnChain( }) } +/** Options for {@link closeOnChain}. */ +export type CloseOptions = + | { feePayer: Account; account: Account } + | { feePayer?: undefined; account?: Account | undefined } + /** * Submit a close transaction on-chain. */ @@ -131,19 +140,18 @@ export async function closeOnChain( client: Client, escrowContract: Address, voucher: SignedVoucher, - account?: Account, - feePayer?: Account | undefined, + options?: CloseOptions, ): Promise { assertUint128(voucher.cumulativeAmount) - const resolved = account ?? client.account + const resolved = options?.account ?? client.account if (!resolved) throw new Error( 'Cannot close channel: no account available. Pass an `account` (viem Account, e.g. privateKeyToAccount("0x...")) to tempo.session(), or provide a `getClient` that returns an account-bearing client.', ) const args = [voucher.channelId, voucher.cumulativeAmount, voucher.signature] as const - if (feePayer) { + if (options?.feePayer) { const data = encodeFunctionData({ abi: escrowAbi, functionName: 'close', args }) - return sendFeePayerTx(client, resolved, feePayer, escrowContract, data, 'close') + return sendFeePayerTx(client, resolved, options.feePayer, escrowContract, data, 'close') } return writeContract(client, { account: resolved,