fix(examples): batch-settlement lifecycle demo invariant + post-tx readback race#2203
Closed
ryanRfox wants to merge 1 commit intox402-foundation:mainfrom
Closed
fix(examples): batch-settlement lifecycle demo invariant + post-tx readback race#2203ryanRfox wants to merge 1 commit intox402-foundation:mainfrom
ryanRfox wants to merge 1 commit intox402-foundation:mainfrom
Conversation
|
@ryanRfox is attempting to deploy a commit to the Coinbase Team on Vercel. A member of the Team first needs to authorize it. |
351b813 to
4f8b44f
Compare
4f8b44f to
dd11ebe
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:onchain.balance === 0nafter refund/withdraw, but the contract'sbalancefield doesn't go to zero on a full drain — it retains the cumulativetotalClaimedfor replay protection. Correct invariant:balance - totalClaimed === 0n(available escrow).publicClientimmediately 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 servelatestfrom a node that hasn't yet ingested the new block, returning pre-tx state. Empirically, even addingpublicClient.waitForTransactionReceipt(...)is insufficient — viem's wait returns when any node returns the receipt, but a subsequentreadContractcall can be load-balanced to a different node still one block behind. The deterministic fix is to pin the readback to the receipt'sblockNumber.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
examples/typescript/clients/batch-settlement-lifecycle/index.tsexamples/typescript/clients/batch-settlement-lifecycle/src/contract.tsreadContractWithBlockSync()helper (block-pinned read with retry on RPC-32001"block not found")examples/typescript/clients/batch-settlement-lifecycle-mezo/index.tsAssertion fix (defect 1):
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 });readOnchainChannelStateandreadErc20Balanceaccept an optional{ blockNumber }and forward it to the underlying calls via the newreadContractWithBlockSync()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'sinitiateWithdrawandfinalizeWithdrawreadbacks.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'spublicClientis 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'sblockNumberis 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 typecheckcleanpnpm -C typescript -r lintcleanpnpm -C typescript -r testcleanWAIT_FULL_WITHDRAW=true: 5/5 phases PASS — including Phase 4 cooperative refund and Phase 5 unilateral withdrawAI disclosure
This PR was prepared with the assistance of a coding agent and reviewed by Ryan R. Fox (an actual human) before submission.