Repository navigation
OPL-5278: wait out x402 202 pending answers and never pay a challenge twice - #2
Conversation
… twice
Root cause: the server (bananablocks OPL-5253) now answers a payment proof
the network has not accepted yet with 202 + Retry-After and
{"status":"pending"}. bb treated any 2xx as settled, failed with "upgrade
response contained no settlement details", and kept no proof, so a rerun
built and paid a second transaction for the same challenge. Only one
payment is credited. The server re-serves only an unexpired challenge
(default TTL 600s), so after expiry a rerun would even see a new challenge
id.
Fix:
- submitProof handles 202: settleProof resubmits the identical X402-Proof
after Retry-After (integer seconds, default 10, clamped to 1-60) until
200 or a terminal answer. The overall wait is capped by a new --wait
(default 10m) that is separate from --timeout, which still bounds each
request. 429 is waited out the same way. One progress line per pending
answer goes to stderr, and Ctrl-C stops the wait.
- The proof is saved to <user config dir>/bb/pending-upgrades.json (0600,
atomic write, keyed by a host+API-key fingerprint and challenge id)
before the first submit. If it cannot be saved, nothing is submitted.
Any saved entry for the key is resumed instead of building a new
payment, whatever challenge the server serves now, and
ensureNotExpired does not apply to a resume. The rate-limit inline
offer refuses to pay while an entry is saved.
- The entry is deleted on 200 and on answers after which the proof can
never settle (400/402/404/409/410/422). On 409 "challenge already
consumed" the key's tier (GET /api/v1/key/usage) decides: at or above
the target reports success and deletes the entry, otherwise the entry is
kept, since other nodes can report the old tier for a while.
- A malformed store file fails closed and is never overwritten.
- The 409 message no longer claims the tx was not broadcast: the server
raises "payment txid already used" after broadcasting.
- README and the command help document the pending wait, --wait and
resume.
…stale consumed entries, match saved proofs by challenge id - A 404 drops a saved proof only with the upgrade handler's own "challenge not found" body; a bare 404 from a node without the route keeps it and points at the resume. - "challenge already consumed" with the key below the saved tier keeps the entry only within an hour of the proof's previous submit (now recorded as last_submit_at); older entries can never settle and are removed, so the key is no longer blocked from upgrading. - A fetched challenge id that already has a saved proof (under any key fingerprint) is resumed by `bb key upgrade` and refused by the rate-limit offer instead of being paid again. Key fingerprints now ignore host case, default ports, trailing slashes and whitespace around the key. - Test that the inline offer's pending waits do not run under the --timeout deadline.
Review round 1: what happened to each finding (head
|
… grace, test dry-run resume guards and 400/402 terminal answers
|
Review round 2 dispositions (commit 56bd42b):
README: added a note that a submit answered "consumed" does not restart the hour. |
…der --wait, never report a long-settled saved payment as this run's upgrade
Review round 3: dispositions (066bf08)1. A stale at-tier entry was reported as this run's settlement (key.go confirmConsumed): fixed. 2. settleProof gave up on the first 5xx or transport failure: fixed.
3. No test showed that only the consumed arm un-records a submit: fixed (test added). Mutation battery (scratch copy of key.go, restored with
Gates: |
…shes a stale saved entry's last submit time The upgrade limiter answers 429 before the server reads the proof, so a rate-limited resubmit settled nothing. It now restores LastSubmitAt like the consumed arm does, so a following "challenge already consumed" on a long-settled entry is still refused instead of reported as this run's upgrade. Transport failures and 5xx stay recorded: those may be the submit that settled.
Review round 4 — dispositions (0b92895)Fixed — 429 arm of Regression test The transient and 5xx arms still keep the submit recorded, because a lost response or an app-side 5xx may be the submit that settled. Gates: |
…expired-challenge resume and the --wait flag
|
Review round 1 (resumed run) dispositions, head
I ran the mutants against a scratch copy of key.go, restored it and confirmed the restore with |
Problem
The BananaBlocks server (OPL-5253, bananablocks
4ced125, not yet deployed) now answers an x402 payment proof that the network has not accepted yet with 202 Accepted,Retry-After: 10and{"status":"pending","error":"..."}. A 202 grants nothing. The client should resubmit the same proof until it gets 200, and never pay the challenge again.bbtreated any 2xx as settled, failed withupgrade response contained no settlement details, and kept no proof. A rerun then built and paid a second transaction for the same challenge, and only one payment is credited. x402 is live on production, so this has to ship before or with the server deploy.Root cause
submitProofmade one POST and decoded any 2xx as anUpgradeResult.store.CreateOrReuseX402Challenge:consumed_at IS NULL AND expires_at > NOW()). The default TTL is 600s. So a proof still pending past expiry gets a new challenge id on rerun, and matching saved proofs by challenge id alone would still pay twice.Fix
settleProofresubmits the identicalX402-Proofafter eachRetry-Afteruntil it gets 200 or a terminal answer.Retry-Afteris read as integer seconds. Missing or invalid values wait 10s, and the result is clamped to 1-60s.--waitflag (default10m) caps the total wait. It is separate from--timeout, which still bounds each request through the client'shttp.Client.Timeout.payChallenge) uses the same loop. Its submits are no longer cut off by the old whole-flowflagTimeoutcontext; only the payment build keeps that timeout.os.UserConfigDir()/bb/pending-upgrades.json. The write is atomic (temp file + rename), the file is 0600 and its directory 0700.ensureNotExpireddoes not apply to a resume.--dry-runonly reports the saved entry.bb key upgrade.--waitruns out, on network or 5xx errors and on Ctrl-C, the entry is kept. The error names the txid and the file and saysrerun \bb key upgrade` to resume it — do not pay again`.409 challenge already consumed, bb readsGET /api/v1/key/usage.payment txid already used, and a settle-time downgrade, after broadcasting (settleAndGrant), and this path now also deletes the saved proof.bb key upgrade --helpcover the pending wait,--waitand resume.Tests
All tests run against
httptestfakes of the server contract, with an injected clock and sleep, so nothing really sleeps. No payment is made and no real key is used.TestUpgradePendingThenSettles: 202 (Retry-After 7), 202 (no header), 200 → success. Exactly 3 submits, all with the identical proof. Waits are [7s, 10s], one payment is built, the proof is on disk before every submit, and the entry is gone afterwards.TestUpgradePendingBudgetThenResume: always 202 with--wait 25s→ submits at 0s, 10s and 20s, then an error that names the txid and says to resume, not pay again. The entry is kept. The rerun comes after the server has switched to a new challenge id; it resubmits the saved proof, builds no second payment and fetches no challenge.TestUpgradeResumesSavedProofForSameChallenge: saved entry plus the same, already expired challenge id re-served → the saved proof is resubmitted.BuildPaymentis never called (the seam fails the test if it is).TestUpgradeConsumedChecksTier: 409 consumed with tier pro or enterprise → success and entry deleted. With tier free or empty → error and entry kept.TestUpgradeTerminalAndTransientAnswers: 422, 409 txid-used and 410 → error and entry deleted. 500, and a 200 with no settlement → error and entry kept. None of these is resubmitted.TestUpgradeRetriesAfter429,TestUpgradeCancelWhilePending,TestSleepCtxCancels,TestParseRetryAfter(missing, non-numeric, HTTP-date, 0, negative, 61, 86400, int64 overflow).TestPayChallengeRefusesWhileSaved,TestPayChallengeInlineWaitsForPending(auto-upgrade path).internal/x402/pending_test.go: file 0600 and dir 0700, a loose-permission file tightened to 0600, per-key filtering, replace-on-put, delete, file removed when empty, missing and empty files read as no entries, a malformed file fails closed and is left unmodified, and the fingerprint differs by host and key without containing the key.Red without the fix (mutation battery): each source file was copied into
mktemp -d, one mutant applied at a time (match count checked,go vetgate), the targeted tests run, and the file copied back (verified byte-identical). All 24/24 mutants were killed. They include:sleepCtxignores the contextGates
make build✅go vet ./...✅go test ./...✅ (also-race -count=3)golangci-lint run(v2.12.2) ✅ 0 issuesLinear: https://linear.app/openprotocollabs/issue/OPL-5278
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.