fix(errors): stop claiming payment was taken when nothing settled#25
Conversation
"API error after payment" reads as *your money is gone*, and on almost every
failure that is false. Gateways settle on success: settlePaymentWithRetry sits
after the upstream work, so a failed paid request normally moves no funds at
all. The message said otherwise on all 26 error paths.
This is not theoretical. A 500 from an image edit was read as a lost payment by
two separate readers and reported as real spend, before anyone checked the
gateway's settle ordering. The wording alone manufactured the false alarm, and
cost real time chasing money that never left the wallet.
The SDK can do better than guess: X-PAYMENT-RESPONSE carries the on-chain
settlement, and its absence is the ordinary shape of a failure that cost
nothing. paid_request_error_prefix() reads it and reports what is known:
settled -> "API error after settlement (payment SETTLED, tx 0xabc...)"
not settled -> "API error on the paid request (no settlement recorded —
payment likely not taken)"
Absence isn't proof — a gateway could settle and omit the header — so the
wording stays hedged rather than promising a refund that isn't ours to give.
Applied across all 13 modules that had the phrase; the helper lives in tx_log
next to decode_settlement_header, which it uses. Error paths must never raise,
so a malformed or missing header degrades to the unsettled wording.
…t taken" (1.7.1) Two bugs in the first cut, both found by checking the gateways instead of the PR's own reasoning. 1. The header name was wrong. Both gateways send PAYMENT-RESPONSE (x402 v2) — blockrun 36 sites, blockrun-sol 25 — and neither ever sends X-PAYMENT-RESPONSE. The SDK read only the legacy name in four hand-rolled places, so settlement decoded to nothing against production and the "SETTLED" branch was dead code. The tests passed because they built the legacy name themselves, mirroring the bug. Now one helper reads both. 2. "payment likely not taken" is a false all-clear. Solana's paid chat path settles in parallel and re-raises at once (logChargedButFailed(...); throw primaryError), so a request the gateway logs as CHARGED BUT REQUEST FAILED — refund manually answers before settle lands and carries no header. Absence and "you were charged" co-occur systematically there. Base does settle after upstream, but headers don't say which gateway you hit. The wording names the usual case without asserting it. Also fixes settlement capture in client.py/solana_client.py, which had the same wrong name — pre-existing, and the reason _last_settlement never populated on real calls.
|
Reviewed this against the two gateways rather than the reasoning in the description, and the thesis holds on Base but breaks on Solana. Pushed 1. The header name meant the mechanism never ran
They emit the x402 v2 spec name. So the The 345 passing tests couldn't catch it because they construct This is a known bug with a paper trail: the sidecar hit it and fixed it in blockrun-litellm 0.6.0 ("the Base gateway emits the settlement header under its x402 v2 spec name It's also worse than this PR. 2. "payment likely not taken" is a false all-clearThe justification — settle sits after upstream, so failures move no funds — is true for Base and false for Solana.
logChargedButFailed(settlementPromise, verification.payer, modelId, pricing.withMargin);
throw primaryError; // Re-throw original errorSettle runs in parallel with the upstream call, and the error re-raises immediately. The gateway's own comment ( The response is on the wire before settlement lands, so those requests carry no header at all. Absence and "you were charged" don't just coexist — on the one path where this costs money, they co-occur systematically. So absence can't be sold as "likely not taken". That swaps a false alarm for a false all-clear, and the all-clear lands on exactly the requests needing a manual refund — the worse direction for anyone reconciling spend. Headers also don't tell the SDK which gateway produced them, so it can't hedge per-chain at error-construction time. New wording names the usual case without asserting it:
Still kills the "your money is gone" false alarm this PR is about; just doesn't replace it with the opposite lie. What was already rightGating on Tests357 passed, 15 skipped; ruff clean. Mutation-checked both fixes rather than trusting green: reverting to the legacy-only header name turns 5 red, and restoring "likely not taken" turns 11 red. Both names are parametrized everywhere it matters. Not addressedTwo things the gateway survey turned up that are out of scope here but worth tracking: |
The wallet PR branched from a stale main and bumped 1.7.0 -> 1.7.1, but 1.7.1 was already released on 2026-07-16 (the settlement-header and paid-request wording fixes, #25) and is live on PyPI. Publishing that version again is impossible — PyPI does not allow overwriting a release — so this lands as 1.7.2 across pyproject.toml, __init__.py and VERSION. The PR's CHANGELOG entry also claimed the 1.7.1 heading, which would have buried the real 1.7.1 notes. Its content now sits under 1.7.2, above an intact 1.7.1. Note for anyone auditing the timeline: the published 1.7.1 still selects wallets via scan_wallets(), so the hijack is live on PyPI until 1.7.2 ships.
"API error after payment"reads as your money is gone. On almost every failure that is false — and the wording cost real time.The bug is the sentence
Gateways settle on success:
settlePaymentWithRetrysits after the upstream work, so a failed paid request normally moves no funds at all. The SDK said "after payment" on all 26 error paths regardless.Not theoretical: a 500 from an image edit was read as a lost payment by two separate readers and reported as real spend, before anyone checked the gateway's settle ordering. Nothing had been charged. The phrase manufactured the false alarm.
The SDK can just look
X-PAYMENT-RESPONSEcarries the on-chain settlement, and its absence is the ordinary shape of a costless failure.paid_request_error_prefix()reads it:API error after settlement (payment SETTLED, tx 0xabc...)API error on the paid request (no settlement recorded — payment likely not taken)Absence isn't proof — a gateway could settle and omit the header — so the wording stays hedged rather than promising a refund that isn't ours to give. When it did settle, the tx hash is right there, which is what makes it actionable.
Scope
All 13 modules that carried the phrase. The helper lives in
tx_logbesidedecode_settlement_header, which it uses. Error paths must never raise, so a malformed or missing header degrades to the unsettled wording.345 tests pass (9 new). Companion gateway fixes make the underlying failure honest too: BlockRunAI/blockrun and BlockRunAI/blockrun-sol now validate image bytes before the 402, so garbage input gets a 400 instead of the 500 that started this.