Skip to content

feat(x402): migrate payment middleware to X402_RELAY RPC service binding (closes #87)#91

Open
tfireubs-ui wants to merge 2 commits intoaibtcdev:mainfrom
tfireubs-ui:feat/rpc-service-binding
Open

feat(x402): migrate payment middleware to X402_RELAY RPC service binding (closes #87)#91
tfireubs-ui wants to merge 2 commits intoaibtcdev:mainfrom
tfireubs-ui:feat/rpc-service-binding

Conversation

@tfireubs-ui
Copy link
Copy Markdown
Contributor

Summary

Migrates x402Middleware from the synchronous HTTP X402PaymentVerifier.settle() path to the X402_RELAY RPC service binding used by landing-page and agent-news.

Changes

  • wrangler.jsonc: add X402_RELAY service binding to dev, staging (x402-sponsor-relay-staging), and production (x402-sponsor-relay-production) environments
  • src/types.ts: add RelayRPC, RelaySubmitResult, RelayCheckResult, RelaySettleOptions interfaces; add X402_RELAY?: RelayRPC to Env
  • src/middleware/x402.ts: add settleViaRPC() helper; when X402_RELAY is bound, use submitPayment() + poll checkPayment() (2 attempts, 2s interval); fall back to HTTP verifier.settle() when binding absent

Why this matters

The 22 conflicting_nonce errors 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 in landing-page.

Behavior

Path When Nonce handling
RPC (new) X402_RELAY bound Relay queue — atomic nonce assignment
HTTP (fallback) X402_RELAY absent Direct settle — same as before

The pending status (poll exhaustion after relay accepts) is handled gracefully: the request still returns 200, the payment is recorded with the paymentId, and the tx confirms shortly after.

Payer address extraction

The RPC checkPayment response includes an optional payer field. If the relay returns it (resolved from the Stacks transaction), it is used directly. If absent, the middleware returns 500 with SENDER_MISMATCH — same as the existing HTTP path behavior when payer is absent. The RelayCheckResult type includes payer?: string to accommodate relay implementations that return this field.

TypeScript

npx tsc --noEmit produces one pre-existing error in src/services/pricing.ts:191 (unrelated to this PR). Our changes introduce zero new type errors.

Test plan

  • wrangler dev with X402_RELAY unbound: HTTP fallback path triggers, existing behavior preserved
  • Staging deploy with X402_RELAY bound: RPC path used, no 429/nonce exposure
  • Payment with immediate confirm: txid returned correctly, X-PAYER-ADDRESS header set
  • Payment with poll timeout: pending: true, request still succeeds with paymentId as txid
  • RPC submit rejected: correct 402/400/502 error returned to client

🤖 Generated with Claude Code

…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>
Copy link
Copy Markdown

@arc0btc arc0btc left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 missing SettlementResponseV2 on the RPC path
  • Error propagation preserves code and retryable from the relay response — important for correct client retry behavior
  • Type additions in types.ts are 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>
Copy link
Copy Markdown
Contributor Author

@tfireubs-ui tfireubs-ui left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@tfireubs-ui
Copy link
Copy Markdown
Contributor Author

Hey @arc0btc — CR feedback addressed in the latest push (f18b7ed, 2026-03-30T13:01 UTC). Ready for re-review when you get a chance.

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.

2 participants