From ba624d3f5e90676776a2da3f90156e49cbc9b6a2 Mon Sep 17 00:00:00 2001 From: grandizzy Date: Thu, 26 Mar 2026 12:22:45 +0200 Subject: [PATCH 1/3] fix: return 410 ChannelClosed for zero-deposit settled channels During settlement the escrow contract may zero the deposit before setting the finalized flag. This creates a window where finalized=false but deposit=0. Without this guard, vouchers against such channels return 402 AmountExceedsDepositError instead of 410 ChannelClosedError. The 410 response tells clients to clear their local channel state and open a fresh channel on the next request, which is the correct recovery path. --- src/tempo/server/Session.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/tempo/server/Session.ts b/src/tempo/server/Session.ts index ea4fb7e3..eb3c5065 100644 --- a/src/tempo/server/Session.ts +++ b/src/tempo/server/Session.ts @@ -456,6 +456,15 @@ async function verifyAndAcceptVoucher(parameters: { if (onChain.closeRequestedAt !== 0n) { throw new ChannelClosedError({ reason: 'channel has a pending close request' }) } + // Treat a zero deposit on an existing channel as settled/closed. + // During settlement the escrow contract may zero the deposit before + // setting the finalized flag, creating a brief window where + // finalized=false but deposit=0. Without this guard the voucher + // check below would return a 402 (AmountExceedsDepositError) instead + // of the correct 410 (ChannelClosedError). + if (onChain.deposit === 0n && onChain.payer !== '0x0000000000000000000000000000000000000000') { + throw new ChannelClosedError({ reason: 'channel deposit is zero (settled)' }) + } if (voucher.cumulativeAmount <= onChain.settled) { throw new VerificationFailedError({ From 8df704834ae352ca87da750024d92f1574bbc48c Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Fri, 27 Mar 2026 14:39:28 -0700 Subject: [PATCH 2/3] refactor: use zeroAddress from viem, add zero-deposit race window test --- src/tempo/server/Session.test.ts | 28 ++++++++++++++++++++++++++++ src/tempo/server/Session.ts | 3 ++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/tempo/server/Session.test.ts b/src/tempo/server/Session.test.ts index 4d4de693..c904ac7f 100644 --- a/src/tempo/server/Session.test.ts +++ b/src/tempo/server/Session.test.ts @@ -885,6 +885,34 @@ describe.runIf(isLocalnet)('session', () => { }), ).rejects.toThrow(ChannelClosedError) }) + + test('rejects voucher when deposit is zero (settled race window)', async () => { + const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n) + // Use a large TTL so the voucher path uses the cached store state + // instead of reading on-chain. This lets us simulate the settlement + // race where deposit=0 but finalized=false by manipulating the store. + const server = createServer({ channelStateTtl: 60_000 }) + await openServerChannel(server, channelId, serializedTransaction) + + // Simulate the escrow contract zeroing the deposit before setting + // finalized (the race window this PR guards against). + await store.updateChannel(channelId, (ch) => (ch ? { ...ch, deposit: 0n } : null)) + + await expect( + server.verify({ + credential: { + challenge: makeChallenge({ id: 'challenge-after-settle', channelId }), + payload: { + action: 'voucher' as const, + channelId, + cumulativeAmount: '2000000', + signature: await signTestVoucher(channelId, 2000000n), + }, + }, + request: makeRequest(), + }), + ).rejects.toThrow(ChannelClosedError) + }) }) describe('topUp', () => { diff --git a/src/tempo/server/Session.ts b/src/tempo/server/Session.ts index eb3c5065..ce9cb2d9 100644 --- a/src/tempo/server/Session.ts +++ b/src/tempo/server/Session.ts @@ -14,6 +14,7 @@ import { type Address, type Hex, parseUnits, + zeroAddress, type Account as viem_Account, type Client as viem_Client, } from 'viem' @@ -462,7 +463,7 @@ async function verifyAndAcceptVoucher(parameters: { // finalized=false but deposit=0. Without this guard the voucher // check below would return a 402 (AmountExceedsDepositError) instead // of the correct 410 (ChannelClosedError). - if (onChain.deposit === 0n && onChain.payer !== '0x0000000000000000000000000000000000000000') { + if (onChain.deposit === 0n && onChain.payer !== zeroAddress) { throw new ChannelClosedError({ reason: 'channel deposit is zero (settled)' }) } From ece5c8ec22f65fd94083624859f9e27a8a6c2e99 Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Fri, 27 Mar 2026 14:45:54 -0700 Subject: [PATCH 3/3] changeset: zero-deposit channel closed guard --- .changeset/zero-deposit-channel-closed.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/zero-deposit-channel-closed.md diff --git a/.changeset/zero-deposit-channel-closed.md b/.changeset/zero-deposit-channel-closed.md new file mode 100644 index 00000000..178fcef3 --- /dev/null +++ b/.changeset/zero-deposit-channel-closed.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Return `410 ChannelClosedError` instead of `402 AmountExceedsDepositError` when a channel's on-chain deposit is zero but the channel still exists (payer is non-zero). This handles a race window during settlement where the escrow contract zeros the deposit before setting the finalized flag.