Skip to content
Open
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
10 changes: 10 additions & 0 deletions .changeset/fix-settle-sender-separation.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions src/tempo/server/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@ export async function settle(
options?: {
escrowContract?: Address | undefined
feePayer?: viem_Account | undefined
account?: viem_Account | undefined
},
): Promise<Hex> {
const channel = await store.getChannel(channelId)
Expand All @@ -359,6 +360,7 @@ export async function settle(
resolvedEscrow,
channel.highestVoucher,
options?.feePayer,
options?.account,
)

await store.updateChannel(channelId, (current) => {
Expand Down
29 changes: 26 additions & 3 deletions src/tempo/session/Chain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Comment on lines +733 to +734
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test.todo() instead of comment?


test('settles with explicit account (no fee payer)', async () => {
const salt = nextSalt()
const deposit = 10_000_000n
const settleAmount = 5_000_000n
Expand All @@ -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,
Expand All @@ -760,6 +764,7 @@ describe.runIf(isLocalnet)('on-chain', () => {
cumulativeAmount: settleAmount,
signature,
},
undefined,
accounts[0],
)

Expand All @@ -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', () => {
Expand Down Expand Up @@ -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).
Comment on lines +829 to +830
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test.todo()


test('closes with explicit account (no fee payer)', async () => {
const salt = nextSalt()
const deposit = 10_000_000n
const closeAmount = 5_000_000n
Expand All @@ -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,
Expand All @@ -836,7 +860,6 @@ describe.runIf(isLocalnet)('on-chain', () => {
cumulativeAmount: closeAmount,
signature,
},
undefined,
accounts[0],
)

Expand Down
24 changes: 16 additions & 8 deletions src/tempo/session/Chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,15 +101,21 @@ export async function settleOnChain(
escrowContract: Address,
voucher: SignedVoucher,
feePayer?: Account | undefined,
account?: Account | undefined,
Comment on lines 103 to +104
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generic type parameter to require account if feePayer is defined?

): Promise<Hex> {
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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -167,20 +177,18 @@ 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 } : {}),
} as never)

const serialized = (await signTransaction(client, {
...prepared,
account: feePayer,
account,
feePayer,
} as never)) as Hex

Expand Down
Loading