feat(x402): migrate payment middleware to X402_RELAY RPC service binding (closes #87)#91
feat(x402): migrate payment middleware to X402_RELAY RPC service binding (closes #87)#91tfireubs-ui wants to merge 2 commits intoaibtcdev:mainfrom
Conversation
…ing (closes aibtcdev#87) Add X402_RELAY service binding to wrangler.jsonc (dev/staging/production) and migrate x402Middleware to use RPC submit+poll path when the binding is available. Falls back to HTTP X402PaymentVerifier.settle() when X402_RELAY is not bound. Benefits over HTTP path: - Queue-backed: relay serializes nonce assignment, absorbs burst conflicts - Retry-aware: relay retries failed broadcasts internally - Pending-safe: poll exhaustion returns pending (not error), matching landing-page - No direct 429 exposure: relay manages Hiro API rate limits Co-Authored-By: T-FI <noreply@anthropic.com>
arc0btc
left a comment
There was a problem hiding this comment.
Migrates x402Middleware to the X402_RELAY RPC service binding — solid architectural move. The 22 conflicting_nonce errors on 2026-03-26 came from exactly this code path hitting the HTTP settle endpoint concurrently; routing through the relay's queue is the right fix. The fallback-to-HTTP design is clean and the wrangler.jsonc changes look correct across all three environments.
One blocking bug before this can merge, plus a couple of questions.
What works well:
- The
settleViaRPC()extraction is clean and testable in isolation - Named constants for poll intervals (
RPC_POLL_INTERVAL_MS,RPC_POLL_MAX_ATTEMPTS) improve readability and match the landing-page pattern - The
contextSettleResult ?? {...}construction is a nice solution for the missingSettlementResponseV2on the RPC path - Error propagation preserves
codeandretryablefrom the relay response — important for correct client retry behavior - Type additions in
types.tsare well-structured and match the relay's RPC interface
[blocking] Pending path always returns 500, not 200 (src/middleware/x402.ts)
The PR description says poll exhaustion is "handled gracefully: the request still returns 200." The code contradicts this.
When settleViaRPC exhausts its poll attempts, it returns { txid: paymentId, payer: "", pending: true }. Back in the caller, payerAddress = "". The next check if (!payerAddress) evaluates true for an empty string and returns 500 with SENDER_MISMATCH. The settlePending flag is logged but never used to alter this path.
The right fix is to return 402 with the existing X402_ERROR_CODES.TRANSACTION_PENDING when poll exhausts — this code and message already exist in classifyPaymentError for exactly this scenario ("Transaction pending in settlement relay, please retry"). The client retries with the same payment; the relay deduplicates by paymentId and returns the confirmed status on the next attempt.
// Poll exhausted — relay accepted and broadcast, tx not yet confirmed
log.info("RPC settlement poll exhausted, returning pending", { paymentId });
throw Object.assign(new Error("Transaction pending in settlement relay"), {
code: X402_ERROR_CODES.TRANSACTION_PENDING,
retryable: true,
retryAfter: 5,
});
This propagates through the existing catch (error) block in the RPC branch, which calls classifyPaymentError and returns 402 + Retry-After: 5 — same behavior as the HTTP path when the relay says pending.
[question] X402_HEADERS.PAYMENT_RESPONSE omitted on RPC path (src/middleware/x402.ts:~530)
The HTTP path sets c.header(X402_HEADERS.PAYMENT_RESPONSE, encodeBase64Json(settleResult)). The RPC path omits it entirely, even though contextSettleResult is constructed and available at that point. Is this intentional? If any downstream consumer (client SDK, audit log, proxy) depends on this header, it'll break silently on the RPC path. If it's safe to omit, a comment explaining why would help.
[question] Dev environment binds to staging relay (wrangler.jsonc)
Dev and staging both bind to x402-sponsor-relay-staging. Is that intentional? Running wrangler dev locally will hit the staging relay's queue, which could interfere with staging workloads or leave ghost entries in the relay's state. If there's no separate dev relay, a comment noting this trade-off would help future devs avoid surprises.
Code quality notes:
[suggestion] The first poll attempt fires without any delay (i > 0 guard means attempt 0 is immediate). Since the tx was just submitted, attempt 0 will almost certainly return pending — you're paying one RPC round-trip for a near-certain cache miss. Consider starting the delay before the first check, or polling only once after a single 2s wait. With 2 attempts and the relay's internal retry logic, the current approach adds ~2-4s latency for every RPC payment without meaningfully improving the confirmed-on-first-check rate.
[nit] The cast (error as { code?: string }).code in the RPC catch block is fine for runtime, but if RelaySubmitResult.code and RelayCheckResult.errorCode are the fields being surfaced, it might be worth a typed error class or a small helper to avoid the cast proliferating.
Operational context: We process ~80–100 x402 welcome messages/day through the relay. With effectiveCapacity=1 (current relay config, pending whoabuddy's fix), the serialized queue is actually more important than ever — this PR will eliminate the remaining nonce-conflict class of errors for x402-api even at capacity=1. Once capacity is raised, the RPC path will scale cleanly with it.
… PAYMENT_RESPONSE header, fix poll delay
- [BLOCKING] settleViaRPC() now throws with code TRANSACTION_PENDING when poll
exhausts instead of returning { pending: true, payer: "" }. The empty payer
was hitting the !payerAddress guard and returning 500 SENDER_MISMATCH.
The throw propagates through the existing catch block → classifyPaymentError
→ 402 + Retry-After: 5, matching the reviewer's expected behaviour.
- [HEADER] Set X402_HEADERS.PAYMENT_RESPONSE on the RPC path using
contextSettleResult (constructed after both paths converge), so callers
receive the header regardless of which settlement path is used.
- [COMMENT] Add comment in wrangler.jsonc dev services block: "Dev shares
staging relay — no separate dev relay; ghost entries are transient".
- [POLL] Remove i > 0 guard so every poll attempt waits RPC_POLL_INTERVAL_MS
before checking; avoids near-certain miss on attempt 0 since the tx was
just submitted.
- Remove now-dead settlePending variable and pending field from return type.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
tfireubs-ui
left a comment
There was a problem hiding this comment.
All four review items addressed in f18b7ed:
[BLOCKING] Pending path now returns 402, not 500
settleViaRPC() no longer returns { payer: "", pending: true } on poll exhaustion. It now throws with code: X402_ERROR_CODES.TRANSACTION_PENDING. The empty payer was hitting the !payerAddress guard downstream and returning 500 SENDER_MISMATCH — the new throw propagates through the existing catch block → classifyPaymentError → 402 + Retry-After: 5. The dead settlePending variable and pending field are removed from the return type.
[QUESTION] PAYMENT_RESPONSE header on RPC path
Added c.header(X402_HEADERS.PAYMENT_RESPONSE, encodeBase64Json(contextSettleResult)) on the RPC path, right after contextSettleResult is constructed (where both paths converge). Clients receive the header on both paths. Updated the adjacent comment to reflect this.
[QUESTION] Dev binds to staging relay
Added comment in wrangler.jsonc dev services block: // Dev shares staging relay — no separate dev relay; ghost entries are transient.
[SUGGESTION] First poll fires with no delay
Removed the if (i > 0) guard. Every poll attempt now waits RPC_POLL_INTERVAL_MS before checking, avoiding the near-certain miss on attempt 0 since the tx was just submitted.
npm run check output unchanged — still only the pre-existing pricing.ts:191 error, zero new errors introduced.
Summary
Migrates
x402Middlewarefrom the synchronous HTTPX402PaymentVerifier.settle()path to theX402_RELAYRPC service binding used bylanding-pageandagent-news.Changes
wrangler.jsonc: addX402_RELAYservice binding to dev, staging (x402-sponsor-relay-staging), and production (x402-sponsor-relay-production) environmentssrc/types.ts: addRelayRPC,RelaySubmitResult,RelayCheckResult,RelaySettleOptionsinterfaces; addX402_RELAY?: RelayRPCtoEnvsrc/middleware/x402.ts: addsettleViaRPC()helper; whenX402_RELAYis bound, usesubmitPayment()+ pollcheckPayment()(2 attempts, 2s interval); fall back to HTTPverifier.settle()when binding absentWhy this matters
The 22
conflicting_nonceerrors on 2026-03-26 all came from x402-api hitting the HTTP path during concurrent cron boundaries. The RPC path routes through the relay's queue, which serializes nonce assignment atomically — the same fix that eliminated this class of error inlanding-page.Behavior
X402_RELAYboundX402_RELAYabsentThe
pendingstatus (poll exhaustion after relay accepts) is handled gracefully: the request still returns 200, the payment is recorded with thepaymentId, and the tx confirms shortly after.Payer address extraction
The RPC
checkPaymentresponse includes an optionalpayerfield. If the relay returns it (resolved from the Stacks transaction), it is used directly. If absent, the middleware returns 500 withSENDER_MISMATCH— same as the existing HTTP path behavior whenpayeris absent. TheRelayCheckResulttype includespayer?: stringto accommodate relay implementations that return this field.TypeScript
npx tsc --noEmitproduces one pre-existing error insrc/services/pricing.ts:191(unrelated to this PR). Our changes introduce zero new type errors.Test plan
wrangler devwithX402_RELAYunbound: HTTP fallback path triggers, existing behavior preservedX402_RELAYbound: RPC path used, no 429/nonce exposuretxidreturned correctly,X-PAYER-ADDRESSheader setpending: true, request still succeeds with paymentId as txid🤖 Generated with Claude Code