Facilitator 502 → clean 402: fix status

2026-09-07 ~06:00 UTC. Branch fix/cdp-verify-invalid-verdict in sudzikcoin/suverse-pay, built and tested in an isolated worktree (/home/govhub/suverse-pay-wt). DEPLOYED 2026-09-07 06:06 UTC on owner go. Status: LIVE, verified, no rollback. Root-cause report: funnel-report-20260907.html.

1. The two code fixes

CommitWhatFiles
84945e6FIX 1 — CDP 4xx verdict → proper 402. httpJson gains an opt-in acceptStatus (a JSON 4xx body is returned as data instead of thrown). The CDP adapter's verify() accepts 4xx and, when the body is a verdict with isValid:false, returns valid:false with the mapped code and CDP's own text; any other 4xx body re-throws with exactly the code and message it had before. invalid_payload is now mapped (→ invalid_authorization), refined to insufficient_funds when the message shows the transfer simulation reverted (the 0x8a1A… body). Settle path untouched.packages/provider-sdk/src/http-json.ts · packages/adapters/coinbase-cdp/src/{adapter,error-map}.ts + tests
f474c8cFIX 2 — nested error read, logged text, no retry on verdicts. readFacilitatorError() reads both the flat {errorCode,errorMessage} and our facilitator's nested {error:{code,message,details}}. Every transient / exhausted / rejected log line now carries the (truncated) provider message. Payment-verdict codes (insufficient_funds, invalid_signature, invalid_authorization, invalid_payload, nonce_already_used, expired_authorization, insufficient_grant, unsupported_scheme, route_unsupported) are never retried and always yield a 402 with the reason, whatever HTTP status wrapped them.packages/x402-server-node/src/core.ts + tests
 packages/adapters/coinbase-cdp/src/adapter.test.ts |  62 ++++++++++++
 packages/adapters/coinbase-cdp/src/adapter.ts      |  50 +++++++++-
 .../adapters/coinbase-cdp/src/error-map.test.ts    |  40 +++++++-
 packages/adapters/coinbase-cdp/src/error-map.ts    |  45 +++++++++
 packages/provider-sdk/src/http-json.test.ts        |  34 +++++++
 packages/provider-sdk/src/http-json.ts             |  34 +++++++
 .../x402-server-node/src/__tests__/core.test.ts    | 101 +++++++++++++++++++
 packages/x402-server-node/src/core.ts              | 110 +++++++++++++++++----
 8 files changed, 452 insertions(+), 24 deletions(-)
Branch base: 628ba93 (feat/no-wallet-hint = what is live on the proxy since 07:10 CET today; api is live at main + c92e960, both contained in this base). Commits carry no trailer, per the repo's convention. Not pushed.

2. Build / test status (worktree, turbo cache disabled)

StepResult
pnpm turbo run build --force (whole monorepo)25/25 tasks OK, 0 cached, 1m48s (post-rebase run)
pnpm turbo run typecheck test --force (whole monorepo, first run)63/68 tasks OK; the 5 failures are apps/proxy tests carrier-risk-verdict (4) and road-conditions (1) — pre-existing: identical 5 failures on the untouched live tree (503 vs 404 from the FMCSA mirror preflight). Every other package green.
Changed packages + api, post-rebasex402-server 52/52 · provider-sdk 42/42 · adapter-coinbase-cdp 67/67 · api 83/83 · proxy handler.test 32/32
New tests13 added: httpJson acceptStatus ×3, mapCdpVerifyRejection ×3, adapter 4xx-verdict ×5, middleware nested-error / verdict-no-retry / 200-verdict ×3
Artifact check (grep the built dist, not the exit code)provider-sdk/dist/http-json.js acceptStatus=1 · coinbase-cdp/dist/adapter.js mapCdpVerifyRejection=2 · x402-server-node/dist/core.js readFacilitatorError=2, PAYMENT_VERDICT_CODES=2 · apps/proxy/dist/handler.js no_wallet=4 (live feature preserved). apps resolve these packages through workspace symlinks to dist/, not bundled.

3. Sample: invalid payment, before vs after

Before (live now, measured 2026-09-07 05:49 UTC with a fresh random wallet holding 0 USDC, X-PAYMENT for $0.001 on bazaar-test):
HTTP 502 in 1146 ms
body.error = "facilitator verify returned HTTP 502"        ← 3 facilitator round-trips, no reason
After (new dist, replaying the exact CDP HTTP 400 body captured for payer 0x8a1A…):
CDP adapter        → {"valid":false,"providerId":"coinbase-cdp","payer":"0x8a1A037b…","errorCode":"insufficient_funds",
                       "errorMessage":"contract call failed: unable to call contract: execution reverted"}
facilitator /verify → HTTP 200 {"isValid":false,"invalidReason":"insufficient_funds","payer":"0x8a1A037b…"}
proxy → buyer      → HTTP 402, ONE facilitator call, body:
{"x402Version":2,
 "resource":{"url":"https://proxy.suverse.io/v1/proxy/reskey_1166628d/bazaar-test","description":"bazaar-test","mimeType":"application/json"},
 "accepts":[{"scheme":"exact","network":"eip155:8453","asset":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
             "payTo":"0x260fbe1ec46968ee02e5b972507d7bb7f09f82b0","amount":"1000","maxTimeoutSeconds":60,"extra":{"name":"USD Coin","version":"2"}}],
 "error":"insufficient_funds"}
proxy_request_logs → outcome=settle_failed, error_code=insufficient_funds (was facilitator_error)
Defence in depth (FIX 2 alone, e.g. an old facilitator still answering 502): the buyer still gets the 502 for an invalid_request-coded failure (kept retryable on purpose, Jun-2026 CDP outage class), but the log line now reads … code=invalid_request status=502 message=POST https://api.cdp.coinbase.com/…/verify -> HTTP 400: {"invalidMessage":"contract call failed: … execution reverted","invalidReason":"invalid_payload",…} instead of a bare status. A verdict code wrapped in a 5xx is answered 402 in one call.

4. Valid payments: unchanged

Check (old live dist vs new dist, same mocked CDP replies)Result
CDP adapter verify() output for {"isValid":true,"payer":…}identical {"valid":true,"providerId":"coinbase-cdp","payer":"0x3869…"}
Middleware outbound /verify + /settle request envelopes (URL, method, headers, body)byte-identical
Middleware accepted receipt (payer, network, asset, amount, txHash)byte-identical
Code path2xx branch of httpJson untouched; acceptStatus is consulted only after the 2xx return. Adapter 200-verdict path unchanged (one console.log line now prefixes the HTTP status). Middleware response.ok path unchanged. Settle path unchanged.

5. FIX 3 — QA wallet top-up (you send, I don't)

WalletAddressBalance nowBurnSend
QA bot Base (internal_wallets #7, Base swap-quote QA from the AgentOS host)0x0145ee0B440300928291668eDC5557f4B07790870.000455 USDC (+0.00358 ETH, gas is not needed: facilitator pays)~8 settles/day × $0.001 = ~$0.24/month (measured: 7 settles in the 18 h it still worked; 105 attempts blocked since)5 USDC on Base (eip155:8453), native USDC 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 ≈ 20 months. 10 USDC if you want to forget about it.
QA bot Solana (internal_wallets #6) — heads-up, same cliff in ~20 daysGEytKjbGUTnBH2M55sRNMibim2LgLMamHBRnXXofdDQk0.163992 USDC111 settles / 14 d = ~8/day = ~$0.24/month5 USDC (SPL USDC EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v) whenever convenient
Verified on-chain 2026-09-07 ~05:45 UTC via base-rpc.publicnode.com and api.mainnet-beta.solana.com. Minimum that unblocks Base QA immediately: anything ≥ 0.001 USDC; 5 USDC is the sensible amount.

6. Deploy + rollback (deploy RUN 06:04–06:07 UTC; rollback commands still valid)

# Pre-deploy snapshot ALREADY TAKEN (662 files):
#   /home/govhub/backups/dist-pre-cdp-verify-fix-20260907-0747.tar.gz   (live tree HEAD = 628ba93)

# DEPLOY (main tree; services load /home/govhub/suverse-pay/{apps/*,packages/*}/dist via workspace symlinks)
cd /home/govhub/suverse-pay
git status --short            # expect only the 4 known untracked files
git checkout fix/cdp-verify-invalid-verdict          # fast-forward from 628ba93 (+2 commits)
pnpm turbo run build --force                           # full, cache bypassed
grep -c acceptStatus packages/provider-sdk/dist/http-json.js            # expect 1
grep -c mapCdpVerifyRejection packages/adapters/coinbase-cdp/dist/adapter.js  # expect 2
grep -c readFacilitatorError packages/x402-server-node/dist/core.js     # expect 2
grep -c no_wallet apps/proxy/dist/handler.js                            # expect 4 (live feature kept)
kill -TERM $(systemctl show -p MainPID --value suverse-pay-api.service)    # Restart=always brings it back (~5 s)
journalctl -u suverse-pay-api.service -n 20 --no-pager | grep -i listening
kill -TERM $(systemctl show -p MainPID --value suverse-pay-proxy.service)
journalctl -u suverse-pay-proxy.service -n 20 --no-pager | grep -i listening
# order matters: api (facilitator) first, then proxy; between the two restarts the old proxy talks to the new facilitator, which is
# a strictly-better combination (200 isValid:false → old middleware already maps it to 402).

# LIVE VERIFICATION (scripts ready in the session scratchpad)
node live-check.mjs invalid   # random empty wallet → expect HTTP 402, body.error "insufficient_funds", 1 facilitator call
node live-check.mjs verify    # read-only /facilitator/verify with 0x3869…, $0.001 → expect 200 {"isValid":true}
node live-check.mjs settle    # REAL $0.001 from 0x3869… to bazaar-test → expect 200 + x-payment-response, row in facilitator_payments
journalctl -u suverse-pay-proxy.service --since "-10 min" | grep "facilitator verify rejected"   # nested text now in logs
# plus: watch the next 0x8a1A… attempt (~every 4 h) turn into settle_failed/insufficient_funds instead of facilitator_error

# ROLLBACK (≤ 1 min, no rebuild)
cd /home/govhub/suverse-pay
tar xzf /home/govhub/backups/dist-pre-cdp-verify-fix-20260907-0747.tar.gz      # restores the 5 dist dirs
git checkout feat/no-wallet-hint                                              # source back to 628ba93
kill -TERM $(systemctl show -p MainPID --value suverse-pay-api.service); sleep 6
kill -TERM $(systemctl show -p MainPID --value suverse-pay-proxy.service)
node live-check.mjs verify                                                    # confirm valid path again
Trigger for rollback: any change in the valid path — live-check.mjs verify not returning isValid:true, or live-check.mjs settle not settling, or a settled row missing from facilitator_payments.

7. Live verification (2026-09-07 06:06–06:08 UTC)

StepResult
Deploylive tree git checkout fix/cdp-verify-invalid-verdict (f474c8c), pnpm turbo run build --force 25/25 · 0 cached · 2m04s; dist grep in the live tree: acceptStatus=1, mapCdpVerifyRejection=2, readFacilitatorError=2, PAYMENT_VERDICT_CODES=2, no_wallet=4. First attempt was refused by git because the branch was still attached to the worktree, and a pipe hid the failure, so one build ran on the old commit (dist byte-identical, nothing restarted). Worktree removed, second attempt clean.
Restartssuverse-pay-api pid 681562 → 3146172, /facilitator/health ok; then suverse-pay-proxy pid 3090530 → 3146261, "proxy listening", 402 probe ok. Restart gap per service ≈ 5 s.
Invalid payment (fresh random wallet 0x8a6115…, 0 USDC, X-PAYMENT $0.001 on bazaar-test)HTTP 402 in 673 ms, body "error":"insufficient_funds", challenge body otherwise normal. proxy_request_logs: settle_failed / insufficient_funds (the same call 20 min earlier, pre-deploy: HTTP 502 / facilitator_error in 1146 ms after 3 facilitator round-trips).
Valid verify (read-only, 0x3869…, $0.001)200 {"isValid":true,"payer":"0x3869dE75…"}
Valid settle (real $0.001, 0x3869… → bazaar-test)HTTP 200 in 1386 ms, upstream body served, x-payment-response = {"success":true,"transaction":"0x7863bb54e156116c77d1f35d7000c78e72b2f7fa5e693d4710642d54de1f7c72","network":"eip155:8453","payer":"0x3869dE75…","amount":"1000"}; facilitator_payments fpay_01M1X7MZHZ8WR567T0E8RWGPW9 settled; proxy_request_logs settled with the tx hash.
Logsapi journal now records the provider text for the invalid case: [CDP-VERIFY-BODY] HTTP 400 {"invalidMessage":"contract call failed: unable to call contract: execution reverted","invalidReason":"invalid_payload","isValid":false,"payer":"0x8a6115…"}. The proxy-side nested-error line ("facilitator verify rejected … message=…") fires only when the facilitator itself answers non-2xx; with FIX 1 live that path was not exercised (the verdict now arrives as 200), it is covered by the unit tests and the dist harness.
RegressionsNone seen. No rollback. External attempts after deploy so far: none yet (the 0x8a1A… bot is due ~09:00 UTC; expect settle_failed / insufficient_funds instead of facilitator_error).
Noticed, pre-existing, not caused by this deployapi startup logs ERROR admin api_key row 'apikey_admin_default' hash does not match ADMIN_API_KEY env — a startup sanity check in apps/api/src/index.ts that only logs and continues; the DB row and the env value have drifted (the service had not restarted since 07-28, journald starts 08-12, so the earlier startup line is not retrievable). Payment path unaffected (verified above). Fix when convenient: pnpm db:bootstrap or align the env key.
Rollback remains available: /home/govhub/backups/dist-pre-cdp-verify-fix-20260907-0747.tar.gz + git checkout feat/no-wallet-hint + the two restarts (section 6).