fix(settlement): refuse a reverted receipt instead of recording it as a fill - #23
Merged
Merged
Conversation
… 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
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.
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 —
statusis a field on the result, not a thrown error.executor.tsread that field, reported it, and returnedaccepted: trueregardless. On the Go sideExecutorResponsehad noreceipt_statusfield, sojson.Unmarshalsilently discarded it. The matcher could not tell settlement from failure, calledFinalizeMatchWithPrice, 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.Secondwhile a receipt wait ran to viem's 180s default. The matcher gave up first. That timeout is notTM_FillLimitCrossed, so the pair was released and backoff retried it 2s later — against a state wherefilled[owner][nonce]had not moved, because the first transaction was still pending. The simulation passed and a secondverifyAndMatchwent 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.tschecksreceipt.status, returningaccepted: falseon a revert. Extracted intobuildReceiptResponseso 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 exceedRECEIPT_TIMEOUT_MSwhenWAIT_FOR_RECEIPTis on;ecs.tfsets 90s against 60s and documents it on both sides.ExecutorResponsecarriesreceipt_statusandblock_number.notAcceptedError(definitely did not settle — safe to retry) andoutcomeUnknownError(may still mine — must not retry). They demand opposite responses, so they are types rather than messages.classifiedOutcomemarker, andshouldFinalizeAfterExecutorErrorrejects that interface before any substring matching. This matters more than it looks: a 32-byte transaction hash can begin withfea8fa6f, 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. Avar _ 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/validateclean.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.
MatchExecutorbuilds 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_RECEIPTstaysfalse. The correctness hazards are closed, but enabling it has an operational cost that is not yet paid: an unknown outcome strands two orders inmatchingand there is no tooling to resolve them, and notx_hashis persisted on the fill row, so resolution is manual. Those want their own change.🤖 Generated with Claude Code
https://claude.ai/code/session_01JN98Sjs2zHG8Z6jXkpHX7Q