Skip to content

fix(settlement): refuse a reverted receipt instead of recording it as a fill - #23

Merged
robertleifke merged 3 commits into
mainfrom
fix/settlement-receipt-verification
Sep 7, 2026
Merged

fix(settlement): refuse a reverted receipt instead of recording it as a fill#23
robertleifke merged 3 commits into
mainfrom
fix/settlement-receipt-verification

Conversation

@robertleifke

@robertleifke robertleifke commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Gate 1 of the Railway → AWS cutover. Three defects on one path, fixed together because each one widens the window the others open.

1. A reverted transaction was recorded as a fill

viem resolves normally on a reverted receipt — status is a field on the result, not a thrown error. executor.ts read that field, reported it, and returned accepted: true regardless. On the Go side ExecutorResponse had no receipt_status field, so json.Unmarshal silently discarded it. The matcher could not tell settlement from failure, called FinalizeMatchWithPrice, and wrote a fill the chain never made.

2. The matcher abandoned transactions still in flight

The executor client's timeout was a hardcoded 5 * time.Second while a receipt wait ran to viem's 180s default. The matcher gave up first. That timeout is not TM_FillLimitCrossed, so the pair was released and backoff retried it 2s later — against a state where filled[owner][nonce] had not moved, because the first transaction was still pending. The simulation passed and a second verifyAndMatch went out on the next nonce. Same EOA, one nonce sequence, two live transactions.

3. A receipt-wait timeout is not a failure

Bounding the wait does not by itself close hazard 2 — it only moves the threshold. viem rejects with WaitForTransactionReceiptTimeoutError, which reached the matcher as an ordinary error and got the ordinary treatment: release, back off, retry, broadcast again.

A timeout means the outcome is unknown: the transaction is on the wire and may yet mine. It is now reported as receipt_status: "timeout" rather than thrown, and the matcher declines to release the pair. Both orders stay reserved until someone resolves them against the chain.

That is a deliberate trade. A stranded pair is recoverable by hand; a duplicate settlement is not detectable at all.

Changes

  • executor.ts checks receipt.status, returning accepted: false on a revert. Extracted into buildReceiptResponse so the decision is testable without a chain.
  • RECEIPT_TIMEOUT_MS (default 60s) bounds the receipt wait instead of inheriting viem's 180s, and a timeout returns an unknown outcome rather than throwing.
  • EXECUTOR_TIMEOUT (default 5s — unchanged, so current behaviour is preserved) makes the Go client's timeout a decision. It must exceed RECEIPT_TIMEOUT_MS when WAIT_FOR_RECEIPT is on; ecs.tf sets 90s against 60s and documents it on both sides.
  • ExecutorResponse carries receipt_status and block_number.
  • Two distinct error types: notAcceptedError (definitely did not settle — safe to retry) and outcomeUnknownError (may still mine — must not retry). They demand opposite responses, so they are types rather than messages.
  • Both implement a classifiedOutcome marker, and shouldFinalizeAfterExecutorError rejects that interface before any substring matching. This matters more than it looks: a 32-byte transaction hash can begin with fea8fa6f, and these messages quote hashes. Earlier drafts of this PR hit that collision twice — first for a revert, then again for a pending transaction after only the first type was exempted — so the guard is structural rather than a list of types to remember. A var _ classifiedOutcome = (*T)(nil) assertion turns a forgotten marker into a compile error, and the regression test is table-driven over every outcome.

Verification

Eight new Go tests, two new TypeScript tests, full suites green, go vet/gofmt/terraform fmt/validate clean.

The revert guard was mutation-checked: removing it fails with got response {Accepted:false TxHash:0xdead ReceiptStatus:reverted BlockNumber:42} — precisely the response the old code finalized.

Not covered by tests: the TypeScript timeout branch. MatchExecutor builds its viem clients in its constructor, so there is no injection point for a stub without a wider refactor. The wire contract it produces is pinned by the Go tests.

Scope

WAIT_FOR_RECEIPT stays false. The correctness hazards are closed, but enabling it has an operational cost that is not yet paid: an unknown outcome strands two orders in matching and there is no tooling to resolve them, and no tx_hash is persisted on the fill row, so resolution is manual. Those want their own change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JN98Sjs2zHG8Z6jXkpHX7Q

robertleifke and others added 3 commits September 7, 2026 14:49
… a fill

Two defects, one path. Both had to go together: fixing either alone widens
the window the other one opens.

1. A reverted transaction was an accepted fill. viem resolves normally on a
   reverted receipt -- `status` is a field on the result, not a throw --
   and executor.ts returned `accepted: true` regardless. The Go
   ExecutorResponse had no receipt_status field, so json.Unmarshal dropped
   it, leaving the matcher unable to tell settlement from failure. It then
   called FinalizeMatchWithPrice and wrote a fill the chain never made.

2. The matcher abandoned transactions that were still in flight. The
   executor client's 5s timeout was a constant, while a receipt wait ran to
   viem's 180s default. The matcher gave up first; the timeout is not
   TM_FillLimitCrossed, so shouldFinalizeAfterExecutorError returned false,
   the pair was released, and backoff retried it 2s later -- against a state
   where filled[owner][nonce] had not moved, because the first transaction
   was still pending. The simulation passed and a second verifyAndMatch went
   out on the next nonce for a fill already on the wire.

Changes:

- executor.ts checks receipt.status and returns accepted: false on a revert.
  The decision is extracted into buildReceiptResponse so it is testable
  without a chain.
- RECEIPT_TIMEOUT_MS (default 60s) bounds the receipt wait rather than
  inheriting viem's 180s.
- EXECUTOR_TIMEOUT (default 5s, unchanged) makes the Go client's timeout a
  decision. It must exceed RECEIPT_TIMEOUT_MS whenever WAIT_FOR_RECEIPT is
  on; ecs.tf now sets 90s against 60s and says why.
- ExecutorResponse carries receipt_status and block_number.
- Any non-acceptance becomes an error inside SubmitMatchForMarket, so it
  joins the existing backoff-and-release path and cannot reach
  FinalizeMatchWithPrice. The engine checks Accepted anyway.

WAIT_FOR_RECEIPT stays false. It is now a production decision rather than a
blocked one. It still does not persist tx_hash on the fill row, so
traceability from a fill back to a transaction remains missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JN98Sjs2zHG8Z6jXkpHX7Q
Review of the previous commit found two defects in it.

1. The refusal error embedded the transaction hash, and
   shouldFinalizeAfterExecutorError classifies by substring. A hash is 32
   bytes of hex, so one beginning with fea8fa6f makes the message contain
   "0xfea8fa6f" -- the TM_FillLimitCrossed selector -- and the engine would
   reconcile a reverted transaction as an already-settled fill. That is the
   exact outcome the commit set out to prevent. Refusals are now a distinct
   type that the classifier rejects before any substring match runs.

2. Bounding the receipt wait did not close the double-broadcast window, it
   only moved it from 5s to 60s. viem rejects with
   WaitForTransactionReceiptTimeoutError on timeout, which reached the
   matcher as a plain error, released the pair, and let backoff retry it
   while the first transaction was still pending.

   A timeout is not a failure: the transaction is broadcast and may yet
   mine. execution-service now reports receipt_status "timeout" instead of
   throwing, and the matcher treats it as an unknown outcome and declines to
   release the pair. Both orders stay reserved until someone resolves them
   against the chain.

   That is a deliberate trade: a stranded pair is recoverable by hand, a
   duplicate settlement is not detectable at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JN98Sjs2zHG8Z6jXkpHX7Q
…not just one

The previous commit fixed the selector collision for notAcceptedError and left
outcomeUnknownError exposed to it. A pending transaction whose hash begins with
fea8fa6f was still finalized as a completed fill -- a worse case than the one
that was fixed, because that transaction may yet mine.

The shape was wrong, not the instance. shouldFinalizeAfterExecutorError was
carrying a denylist of types to skip, so every structured error added later has
to remember to join it, and forgetting is silent.

Both structured outcomes now implement a classifiedOutcome marker, and the
classifier rejects the interface rather than naming types. A `var _
classifiedOutcome = (*T)(nil)` assertion makes a forgotten marker a compile
error instead of a production bug -- verified by removing one:

    cannot use (*outcomeUnknownError)(nil) ... missing method alreadyClassified

The regression test is table-driven over both outcomes for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JN98Sjs2zHG8Z6jXkpHX7Q
@robertleifke
robertleifke merged commit 52a10ba into main Sep 7, 2026
2 checks passed
@robertleifke
robertleifke deleted the fix/settlement-receipt-verification branch September 7, 2026 19:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant