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. 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 ea4fb7e3..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' @@ -456,6 +457,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 !== zeroAddress) { + throw new ChannelClosedError({ reason: 'channel deposit is zero (settled)' }) + } if (voucher.cumulativeAmount <= onChain.settled) { throw new VerificationFailedError({