Skip to content

fix(errors): stop claiming payment was taken when nothing settled#25

Merged
VickyXAI merged 2 commits into
mainfrom
fix/paid-request-error-wording
Jul 16, 2026
Merged

fix(errors): stop claiming payment was taken when nothing settled#25
VickyXAI merged 2 commits into
mainfrom
fix/paid-request-error-wording

Conversation

@VickyXAI

Copy link
Copy Markdown
Contributor

"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: settlePaymentWithRetry sits 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-RESPONSE carries the on-chain settlement, and its absence is the ordinary shape of a costless failure. paid_request_error_prefix() reads it:

message
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. 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_log beside decode_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.

"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.
@VickyXAI

Copy link
Copy Markdown
Contributor Author

Reviewed this against the two gateways rather than the reasoning in the description, and the thesis holds on Base but breaks on Solana. Pushed 705fb29 fixing both problems; version is now 1.7.1.

1. The header name meant the mechanism never ran

paid_request_error_prefix read headers.get("X-PAYMENT-RESPONSE"). Neither gateway sends that name — ever:

repo PAYMENT-RESPONSE X-PAYMENT-RESPONSE
blockrun 36 sites 0
blockrun-sol 25 sites 0

They emit the x402 v2 spec name. So the SETTLED branch was dead code in production: every failure — including ones that really did settle — reported "payment likely not taken". That inverts the PR's goal on the exact requests where money moved.

The 345 passing tests couldn't catch it because they construct httpx.Headers({"X-PAYMENT-RESPONSE": ...}) themselves, so they encode the same wrong assumption the code does. Note this isn't a case-sensitivity thing that httpx would paper over: PAYMENT-RESPONSE and X-PAYMENT-RESPONSE are different names.

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 PAYMENT-RESPONSE — the sidecar only checked the legacy X-PAYMENT-RESPONSE, so it decoded nothing"), live-verified against a real paid call. The SDK half was never done.

It's also worse than this PR. client.py and solana_client.py had the same wrong name in four hand-rolled read sites, so _last_settlement has been decoding to None on every real paid call — no tx hash, no settlement, SDK-wide. Fixed here too, behind one tx_log.read_settlement_header so the name can't drift apart again.

2. "payment likely not taken" is a false all-clear

The justification — settle sits after upstream, so failures move no funds — is true for Base and false for Solana.

blockrun-sol/src/app/api/v1/chat/completions/route.ts:2426:

logChargedButFailed(settlementPromise, verification.payer, modelId, pricing.withMargin);
throw primaryError; // Re-throw original error

Settle runs in parallel with the upstream call, and the error re-raises immediately. The gateway's own comment (:143) says it plainly: "already have landed and cannot be recalled — the user paid for a 5xx... loud enough to trigger a manual refund", logging CHARGED BUT REQUEST FAILED — refund manually.

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:

API error on the paid request (no settlement reported — a failed call usually moves no funds, but settlement can land after the error; check your wallet history before assuming nothing was charged)

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 right

Gating on tx_hash rather than the header's success field is correct, and worth keeping deliberately: the gateways hard-code success: true even when settle didn't land (so older clients don't surface a spurious error), which makes it useless as a signal. A tx hash is the only field meaning money moved — and it's the same field the gateways gate their own revenue accounting on (price_usdc: settlement.txHash ? ... : 0). Silent agreement, but it's load-bearing, so there's now a test pinning it.

Tests

357 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 addressed

Two things the gateway survey turned up that are out of scope here but worth tracking: polymarket/fund settles the deposit before the fee, so a failed fund request can leave deposit funds moved; and Base's portrait/enroll eats served-but-unsettled cost with only a console.error and no unpaid-ledger write, so those losses are invisible to reconciliation. Happy to file either.

@VickyXAI
VickyXAI merged commit 128455e into main Jul 16, 2026
3 checks passed
@VickyXAI
VickyXAI deleted the fix/paid-request-error-wording branch July 16, 2026 05:52
VickyXAI pushed a commit that referenced this pull request Jul 19, 2026
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.
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