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
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.
Comment on lines +5 to +10
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

line wrapping for some reason

27 changes: 12 additions & 15 deletions src/tempo/server/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,8 +343,10 @@ export async function settle(
channelId: Hex,
options?: {
escrowContract?: Address | undefined
feePayer?: viem_Account | undefined
},
} & (
| { feePayer: viem_Account; account: viem_Account }
| { feePayer?: undefined; account?: viem_Account | undefined }
),
): Promise<Hex> {
const channel = await store.getChannel(channelId)
if (!channel) throw new ChannelNotFoundError({ reason: 'channel not found' })
Expand All @@ -357,12 +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,
)
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
Expand Down Expand Up @@ -855,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
Expand Down
30 changes: 25 additions & 5 deletions src/tempo/session/Chain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -730,7 +730,9 @@ describe.runIf(isLocalnet)('on-chain', () => {
expect(channel.finalized).toBe(false)
})

test('settles a channel with fee payer', async () => {
test.todo('settles with distinct feePayer != account (fee-sponsored settle)')

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 +754,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,7 +763,7 @@ describe.runIf(isLocalnet)('on-chain', () => {
cumulativeAmount: settleAmount,
signature,
},
accounts[0],
{ account: accounts[0] },
)

expect(txHash).toBeDefined()
Expand All @@ -769,6 +772,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 +824,9 @@ describe.runIf(isLocalnet)('on-chain', () => {
expect(channel.finalized).toBe(true)
})

test('closes a channel with fee payer', async () => {
test.todo('closes with distinct feePayer != account (fee-sponsored close)')

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 +848,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,8 +857,7 @@ describe.runIf(isLocalnet)('on-chain', () => {
cumulativeAmount: closeAmount,
signature,
},
undefined,
accounts[0],
{ account: accounts[0] },
)

expect(txHash).toBeDefined()
Expand Down
44 changes: 30 additions & 14 deletions src/tempo/session/Chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,23 +93,33 @@ 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.
*/
export async function settleOnChain(
client: Client,
escrowContract: Address,
voucher: SignedVoucher,
feePayer?: Account | undefined,
options?: SettleOptions,
): Promise<Hex> {
assertUint128(voucher.cumulativeAmount)
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, feePayer, escrowContract, data, 'settle')
return sendFeePayerTx(client, resolved, options.feePayer, escrowContract, data, 'settle')
}
return writeContract(client, {
account: client.account!,
account: resolved,
chain: client.chain,
address: escrowContract,
abi: escrowAbi,
Expand All @@ -118,26 +128,30 @@ 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.
*/
export async function closeOnChain(
client: Client,
escrowContract: Address,
voucher: SignedVoucher,
account?: Account,
feePayer?: Account | undefined,
options?: CloseOptions,
): Promise<Hex> {
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, feePayer, escrowContract, data, 'close')
return sendFeePayerTx(client, resolved, options.feePayer, escrowContract, data, 'close')
}
return writeContract(client, {
account: resolved,
Expand All @@ -155,9 +169,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 +185,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