Skip to content

fix(examples): batch-settlement lifecycle demo invariant + post-tx readback race#2203

Closed
ryanRfox wants to merge 1 commit intox402-foundation:mainfrom
ryanRfox:fix/balance-drain-assertion
Closed

fix(examples): batch-settlement lifecycle demo invariant + post-tx readback race#2203
ryanRfox wants to merge 1 commit intox402-foundation:mainfrom
ryanRfox:fix/balance-drain-assertion

Conversation

@ryanRfox
Copy link
Copy Markdown
Contributor

@ryanRfox ryanRfox commented May 5, 2026

Summary

Fix two defects in the batch-settlement lifecycle demos that together prevent Phase 4 (and Phase 5 with WAIT_FULL_WITHDRAW=true) from passing on a load-balanced public RPC such as Base Sepolia's default endpoint:

  1. Wrong invariant: assertions check onchain.balance === 0n after refund/withdraw, but the contract's balance field doesn't go to zero on a full drain — it retains the cumulative totalClaimed for replay protection. Correct invariant: balance - totalClaimed === 0n (available escrow).
  2. Post-tx readback race: the demo reads on-chain state via its own publicClient immediately after a mutating SDK call. The on-chain effect is already confirmed (the facilitator awaits the receipt), but the demo's separate viem client may serve latest from a node that hasn't yet ingested the new block, returning pre-tx state. Empirically, even adding publicClient.waitForTransactionReceipt(...) is insufficient — viem's wait returns when any node returns the receipt, but a subsequent readContract call can be load-balanced to a different node still one block behind. The deterministic fix is to pin the readback to the receipt's blockNumber.

Each defect alone breaks Phase 4 on Base Sepolia for a different reason. Both are fixed here. Demo-only — no SDK, contract, or spec changes.

Closes #2201, closes #2202

What changed

File Sites
examples/typescript/clients/batch-settlement-lifecycle/index.ts Phase 4 (cooperative refund) + Phase 5 (initiateWithdraw + finalizeWithdraw) — assertion fix at 2 sites + block-pinned readback at 3 sites
examples/typescript/clients/batch-settlement-lifecycle/src/contract.ts New readContractWithBlockSync() helper (block-pinned read with retry on RPC -32001 "block not found")
examples/typescript/clients/batch-settlement-lifecycle-mezo/index.ts Phase 5 (finalize-after-delay) — assertion fix

Assertion fix (defect 1):

+      const availableAfterRefund = onchainAfterRefund.balance - onchainAfterRefund.totalClaimed;
       assertInvariant(
-        "channel balance drained",
-        onchainAfterRefund.balance === 0n,
-        `balance=${onchainAfterRefund.balance}`,
+        "available escrow drained (balance - totalClaimed === 0)",
+        availableAfterRefund === 0n,
+        `balance=${onchainAfterRefund.balance}, totalClaimed=${onchainAfterRefund.totalClaimed}`,
       );

Block-pinned readback (defect 2):

   const refundSettle = await client1.scheme.refund(url1);
   console.log(`  refund tx: ${refundSettle.transaction ?? "(none)"}`);
+  let receiptBlock: bigint | undefined;
+  if (refundSettle.transaction) {
+    const receipt = await publicClient.waitForTransactionReceipt({
+      hash: refundSettle.transaction as `0x${string}`,
+    });
+    receiptBlock = receipt.blockNumber;
+  }

-  const onchainAfterRefund = await readOnchainChannelState(publicClient, channel1Id);
+  const onchainAfterRefund = await readOnchainChannelState(publicClient, channel1Id, { blockNumber: receiptBlock });

readOnchainChannelState and readErc20Balance accept an optional { blockNumber } and forward it to the underlying calls via the new readContractWithBlockSync() helper. The helper retries on RPC -32001 "block not found" with exponential backoff (200ms..2000ms, 14 attempts, ~16s total) — block-pinning otherwise trades stale-read for transient block-not-found when an LB node hasn't yet ingested the pinned block. Same pattern at Phase 5's initiateWithdraw and finalizeWithdraw readbacks.

Scoped to the Base Sepolia demo since Mezo's RPC serves consistent reads (both the wait and the block-pin would be defensive no-ops there).

Why

Defect 1 — The SDK's own unit tests already model the right invariant. See typescript/packages/mechanisms/evm/test/unit/batch-settlement/server.test.ts:1399 (comment: amount = balance - totalClaimed) and the "fully drained" channel fixture at :1422 (balance: "61800", totalClaimed: "61800"). The previous assertion was checking a state the contract doesn't produce.

Defect 2 — The SDK's facilitator already calls signer.waitForTransactionReceipt(...) before returning, which guarantees the facilitator's RPC connection has confirmed the tx. The demo's publicClient is a separate viem client; behind a load-balanced public endpoint it is not guaranteed to be on the same physical node. Pinning the readback to the receipt's blockNumber is the deterministic fix — it forces the RPC to serve from a node that has ingested at least that block (or fail, hence the retry).

Test plan

  • pnpm -C typescript -r typecheck clean
  • pnpm -C typescript -r lint clean
  • pnpm -C typescript -r test clean
  • Base Sepolia end-to-end with WAIT_FULL_WITHDRAW=true: 5/5 phases PASS — including Phase 4 cooperative refund and Phase 5 unilateral withdraw

AI disclosure

This PR was prepared with the assistance of a coding agent and reviewed by Ryan R. Fox (an actual human) before submission.

@vercel
Copy link
Copy Markdown

vercel Bot commented May 5, 2026

@ryanRfox is attempting to deploy a commit to the Coinbase Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions Bot added the examples Changes to examples label May 5, 2026
@ryanRfox ryanRfox force-pushed the fix/balance-drain-assertion branch from 351b813 to 4f8b44f Compare May 5, 2026 21:09
@ryanRfox ryanRfox force-pushed the fix/balance-drain-assertion branch from 4f8b44f to dd11ebe Compare May 6, 2026 14:49
@github-actions github-actions Bot added docs and removed examples Changes to examples labels May 6, 2026
@ryanRfox ryanRfox closed this May 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

(Batch Settlement) demo readback races load-balanced RPC after mutating tx (Batch Settlement) demos assert wrong invariant after refund/withdraw

1 participant