Repository navigation
Priority: P1 — ambiguous-outcome class at the TollGate layer. Independent of gonuts fixes. #498
Description
Priority: P1 — ambiguous-outcome class at the TollGate layer. Independent of gonuts fixes.
Problem
PurchaseSession wraps Receive in a 30s timeout, but the goroutine keeps running. When Receive completes after the timeout response was sent: funds land in the operator wallet, no session is granted, no record exists, and the customer was told "timed out, try again" — so they pay again. Duplicate value consumed for one session; every timeout has this tail risk.
Why it matters
Timeouts are routine (mobile-network mints). The current code converts each one into a potential silent confiscation with a lying error message. Same family as #403 but via the timeout path, and invisible in logs beyond a Receive-completed line nobody correlates.
Current behavior (source refs)
src/merchant/merchant.go:506-538— goroutine +selecttimeout; on timeout the notice returns immediately, goroutine abandoned.src/merchant/merchant.go:519-529— late result (success or error) is logged and dropped.- No pending-payment record anywhere (sessions are created only post-timeout-window).
Desired invariant
No Receive outcome is discarded: a late success is reconciled into a granted session (or refund) durably; a late failure clears the pending record; the customer-visible timeout message states that the payment may still complete and how to check (
GET /usage).
Proposed scope
- Durable pending-payment record keyed by token Ys hash, written before launching Receive (this is also the idempotency key — coordinate with the payment-saga design for Rust parity, Rust issues Refs/heads/x1860 #5/Build/Deployment improvements #6).
- Late-completion handler: on late success → same grant path as normal completion (session + gate + record COMPLETE); on late failure → record FAILED.
- Startup + periodic reconciliation: records in RECEIVE state older than timeout → query mint proof state (NUT-07) → resolve.
- Timeout notice copy updated to reflect "may complete; check usage" (wire-compatible text change only).
Areas / files
src/merchant/merchant.go, new small store (JSONL or bbolt sub-bucket; flash-aware), startup reconciliation in merchant init.
Acceptance criteria
- Fault test: Receive completes at t=35s, timeout returned at t=30s → session granted at t=35s,
/usagereflects it, record COMPLETE. - Kill between receive-start and completion → restart reconciliation resolves by mint state.
Required tests
- Unit: late-completion grant path; reconciliation decision table.
- Integration (cloud-lab): slow-mint proxy lane.
Failure-injection tests
- Proxy delays swap response 35s (payment completes late).
- Proxy drops response entirely; mint did consume inputs (mint-side state says spent) → reconciliation grants session.
Compatibility
Notice text change only.
Dependencies
Conceptually pairs with the Rust payment-saga issue (Amperstrand/tollgate-module-basic-rust#5) — implement to the same state names for test parity.
Out of scope
- Refund issuance on late success + unreachable client (session grant suffices; refund policy lives in Payment consumed with no session when gate-open fails after successful Receive (no refund path) #403's resolution).
Activity
Amperstrand commentedon Sep 22, 2026
Verification of PR #511 (late-Receive reconciliation) is up: #511 (comment)
Short version: the in-process fix is correct and race-verified — a late Receive success now grants the session through the same path as an inline success, exactly-once per completion, with the #507 reclaimer interplay confirmed clean. Two stack-level blockers (missing
src/cligo.mod tidy inherited from the dep-bump base, missing CHANGELOG entry) hold the PR, and note the PR's "Fixes #498" would auto-close this issue while the durable-record/crash-restart criteria (durable payment record, startup reconciliation, slow-mint cloud-lab lane) remain open — tracked in #502. Suggest keeping this issue open (or re-scoping it) until the durable saga lands.docs(changelog): late-Receive reconciliation entry (#511)
fix(merchant): reconcile late Receive completions after the response …
docs(changelog): late-Receive reconciliation entry (#511)
fix(merchant): reconcile late Receive completions after the response …
docs(changelog): late-Receive reconciliation entry (#511)
fix(merchant): reconcile late Receive completions after the response …
docs(changelog): late-Receive reconciliation entry (#511)
fix(payment): hedge the late-Receive record when the error is not a r…
fix(payment): record the outcome of a Receive that outlived its deadl…
Amperstrand commentedon Oct 7, 2026
Pointer: #703's incident map folds this class in (Class C rows, with #640 as the measured evidence on the wallet side) and names the pattern the TollGate layer should adopt from CDK for ambiguous outcomes: replay-first reconciliation.
Reference:
crates/cdk/src/wallet/receive/saga/resume.rs— for an ambiguous swap, re-issue the original request first (NUT-19-cached responses make replay idempotent at compliant mints, so a mint that already processed it returns the signatures instead of a duplicate-output error), and only on replay failure query mint-side state (checkstate / quote state) before regenerating anything. Regenerating fresh outputs on timeout is precisely the move that re-exposes derivation ranges.