From 0c8e0399b604c937a0c84464f84c13d4d1d74348 Mon Sep 17 00:00:00 2001 From: tfibtcagent Date: Mon, 30 Mar 2026 11:53:06 +0000 Subject: [PATCH 1/2] feat(x402): migrate payment middleware to X402_RELAY RPC service binding (closes #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 --- src/middleware/x402.ts | 226 ++++++++++++++++++++++++++++++----------- src/types.ts | 42 ++++++++ wrangler.jsonc | 9 +- 3 files changed, 216 insertions(+), 61 deletions(-) diff --git a/src/middleware/x402.ts b/src/middleware/x402.ts index 4722f82..39c654e 100644 --- a/src/middleware/x402.ts +++ b/src/middleware/x402.ts @@ -29,6 +29,8 @@ import type { PriceEstimate, X402Context, ChatCompletionRequest, + RelayRPC, + RelaySettleOptions, } from "../types"; import { validateTokenType, @@ -172,6 +174,70 @@ function classifyPaymentError(error: unknown, settleResult?: Partial { + const settle: RelaySettleOptions = { + expectedRecipient, + minAmount, + tokenType: asset, + maxTimeoutSeconds: 10, + }; + + const submitResult = await rpc.submitPayment(txHex, settle); + + if (!submitResult.accepted || !submitResult.paymentId) { + const err = submitResult.error || "RPC submit rejected"; + log.warn("RPC payment submit rejected", { error: err, code: submitResult.code }); + throw Object.assign(new Error(err), { + code: submitResult.code, + retryable: submitResult.retryable, + }); + } + + const { paymentId } = submitResult; + + // Poll for confirmation (up to RPC_POLL_MAX_ATTEMPTS, RPC_POLL_INTERVAL_MS apart) + for (let i = 0; i < RPC_POLL_MAX_ATTEMPTS; i++) { + if (i > 0) await new Promise(resolve => setTimeout(resolve, RPC_POLL_INTERVAL_MS)); + + const check = await rpc.checkPayment(paymentId); + + if (check.status === "confirmed" && check.txid) { + return { txid: check.txid, payer: check.payer || "", pending: false }; + } + + if (check.status === "failed") { + throw Object.assign(new Error(check.error || "RPC settlement failed"), { + code: check.errorCode, + retryable: check.retryable, + }); + } + // status: pending/mempool → continue polling + } + + // Poll exhausted — relay accepted and broadcast, tx not yet confirmed + log.info("RPC settlement poll exhausted, returning pending", { paymentId }); + return { txid: paymentId, payer: "", pending: true }; +} + // ============================================================================= // Middleware Factory // ============================================================================= @@ -361,74 +427,111 @@ export function x402Middleware( }, 400); } - // Verify payment with settlement relay using v2 API - const verifier = new X402PaymentVerifier(c.env.X402_FACILITATOR_URL); - - log.debug("Settling payment via settlement relay", { - relayUrl: c.env.X402_FACILITATOR_URL, - expectedRecipient: c.env.X402_SERVER_ADDRESS, - minAmount: paymentRequirements.amount, - asset, - network: networkV2, - }); + // --- Settlement: prefer RPC binding, fall back to HTTP path --- + let txId: string; + let payerAddress: string; + let settlePending = false; + let settleResult: SettlementResponseV2 | undefined; + + if (c.env.X402_RELAY) { + // RPC path: queue-backed, retry-aware, avoids direct nonce conflicts + const txHex = paymentPayload.payload?.transaction; + if (!txHex) { + log.error("Missing transaction hex in payment payload"); + return c.json({ + error: "Invalid payment payload: missing transaction", + code: X402_ERROR_CODES.INVALID_PAYLOAD, + }, 400); + } - let settleResult: SettlementResponseV2; - try { - settleResult = await verifier.settle(paymentPayload, { - paymentRequirements, + log.debug("Settling payment via X402_RELAY RPC binding", { + expectedRecipient: c.env.X402_SERVER_ADDRESS, + minAmount: paymentRequirements.amount, + asset, + network: networkV2, }); - log.debug("Settle result", { ...settleResult }); - } catch (error) { - const errorStr = String(error); - log.error("Payment settlement exception", { error: errorStr }); - - const classified = classifyPaymentError(error); - if (classified.retryAfter) { - c.header("Retry-After", String(classified.retryAfter)); - } - - return c.json( - { + try { + const rpcResult = await settleViaRPC( + c.env.X402_RELAY, + txHex, + c.env.X402_SERVER_ADDRESS, + paymentRequirements.amount, + asset, + log + ); + txId = rpcResult.txid; + payerAddress = rpcResult.payer; + settlePending = rpcResult.pending; + } catch (error) { + const errorStr = String(error); + const code = (error as { code?: string }).code; + log.error("RPC payment settlement failed", { error: errorStr, code }); + const classified = classifyPaymentError(error); + if (classified.retryAfter) c.header("Retry-After", String(classified.retryAfter)); + return c.json({ error: classified.message, code: classified.code, asset, network: networkV2, resource: c.req.path, - details: { - exceptionMessage: errorStr, - }, - }, - classified.httpStatus as 400 | 402 | 500 | 502 | 503 - ); - } - - if (!settleResult.success) { - log.error("Payment settlement failed", { ...settleResult }); + }, classified.httpStatus as 400 | 402 | 500 | 502 | 503); + } + } else { + // HTTP fallback path (existing logic) + const verifier = new X402PaymentVerifier(c.env.X402_FACILITATOR_URL); - const classified = classifyPaymentError(settleResult.errorReason || "settlement_failed", settleResult); + log.debug("Settling payment via HTTP path (X402_RELAY not bound)", { + relayUrl: c.env.X402_FACILITATOR_URL, + expectedRecipient: c.env.X402_SERVER_ADDRESS, + minAmount: paymentRequirements.amount, + asset, + network: networkV2, + }); - if (classified.retryAfter) { - c.header("Retry-After", String(classified.retryAfter)); + try { + settleResult = await verifier.settle(paymentPayload, { paymentRequirements }); + log.debug("Settle result", { ...settleResult }); + } catch (error) { + const errorStr = String(error); + log.error("Payment settlement exception", { error: errorStr }); + const classified = classifyPaymentError(error); + if (classified.retryAfter) c.header("Retry-After", String(classified.retryAfter)); + return c.json( + { + error: classified.message, + code: classified.code, + asset, + network: networkV2, + resource: c.req.path, + details: { exceptionMessage: errorStr }, + }, + classified.httpStatus as 400 | 402 | 500 | 502 | 503 + ); } - return c.json( - { - error: classified.message, - code: classified.code, - asset, - network: networkV2, - resource: c.req.path, - details: { - errorReason: settleResult.errorReason, + if (!settleResult.success) { + log.error("Payment settlement failed", { ...settleResult }); + const classified = classifyPaymentError(settleResult.errorReason || "settlement_failed", settleResult); + if (classified.retryAfter) c.header("Retry-After", String(classified.retryAfter)); + return c.json( + { + error: classified.message, + code: classified.code, + asset, + network: networkV2, + resource: c.req.path, + details: { errorReason: settleResult.errorReason }, }, - }, - classified.httpStatus as 400 | 402 | 500 | 502 | 503 - ); - } + classified.httpStatus as 400 | 402 | 500 | 502 | 503 + ); + } - // Extract payer address from settle result - const payerAddress = settleResult.payer; + txId = settleResult.transaction; + payerAddress = settleResult.payer || ""; + // Set payment-response header for HTTP path (RPC path omits this since no SettlementResponseV2) + c.header(X402_HEADERS.PAYMENT_RESPONSE, encodeBase64Json(settleResult)); + } if (!payerAddress) { log.error("Could not extract payer address from valid payment"); @@ -439,26 +542,33 @@ export function x402Middleware( } log.info("Payment verified successfully", { - txId: settleResult.transaction, + txId, payerAddress, asset, network: networkV2, amount: paymentRequirements.amount, tier: dynamic ? "dynamic" : tier, + pending: settlePending, }); + // Build a minimal settleResult for the X402Context (required by the type) + const contextSettleResult: SettlementResponseV2 = settleResult ?? { + success: true, + transaction: txId, + network: networkV2, + payer: payerAddress, + }; + // Store payment context for downstream use c.set("x402", { payerAddress, - settleResult, + settleResult: contextSettleResult, paymentPayload, paymentRequirements, priceEstimate, parsedBody, } as X402Context); - // Add v2 response headers (base64 encoded) - c.header(X402_HEADERS.PAYMENT_RESPONSE, encodeBase64Json(settleResult)); c.header("X-PAYER-ADDRESS", payerAddress); return next(); diff --git a/src/types.ts b/src/types.ts index 4b3156c..8a724f5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,6 +20,47 @@ export interface LogsRPC { error(appId: string, message: string, context?: Record): Promise; } +// ============================================================================= +// Relay RPC Types (matching x402-sponsor-relay RelayRPC entrypoint) +// ============================================================================= + +export interface RelaySettleOptions { + expectedRecipient: string; + minAmount: string; + tokenType?: string; + expectedSender?: string; + maxTimeoutSeconds?: number; +} + +export interface RelaySubmitResult { + accepted: boolean; + paymentId?: string; + status?: string; + error?: string; + code?: string; + retryable?: boolean; + help?: string; + action?: string; +} + +export interface RelayCheckResult { + paymentId: string; + status: string; + txid?: string; + blockHeight?: number; + confirmedAt?: string; + /** Payer address if relay resolved it from the transaction */ + payer?: string; + error?: string; + errorCode?: string; + retryable?: boolean; +} + +export interface RelayRPC { + submitPayment(txHex: string, settle?: RelaySettleOptions): Promise; + checkPayment(paymentId: string): Promise; +} + export interface Logger { debug(message: string, data?: Record): void; info(message: string, data?: Record): void; @@ -41,6 +82,7 @@ export interface Env { AI: Ai; // Service bindings (optional - uncomment in wrangler.jsonc if available) LOGS?: LogsRPC; + X402_RELAY?: RelayRPC; // Secrets (set via wrangler secret put) OPENROUTER_API_KEY: string; HIRO_API_KEY?: string; diff --git a/wrangler.jsonc b/wrangler.jsonc index 2767307..ce24435 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -23,7 +23,8 @@ ] }, "services": [ - { "binding": "LOGS", "service": "worker-logs-staging", "entrypoint": "LogsRPC" } + { "binding": "LOGS", "service": "worker-logs-staging", "entrypoint": "LogsRPC" }, + { "binding": "X402_RELAY", "service": "x402-sponsor-relay-staging", "entrypoint": "RelayRPC" } ], "migrations": [ { "tag": "v1", "new_sqlite_classes": ["UsageDO", "StorageDO"] }, @@ -67,7 +68,8 @@ ] }, "services": [ - { "binding": "LOGS", "service": "worker-logs-staging", "entrypoint": "LogsRPC" } + { "binding": "LOGS", "service": "worker-logs-staging", "entrypoint": "LogsRPC" }, + { "binding": "X402_RELAY", "service": "x402-sponsor-relay-staging", "entrypoint": "RelayRPC" } ] }, "production": { @@ -91,7 +93,8 @@ ] }, "services": [ - { "binding": "LOGS", "service": "worker-logs-production", "entrypoint": "LogsRPC" } + { "binding": "LOGS", "service": "worker-logs-production", "entrypoint": "LogsRPC" }, + { "binding": "X402_RELAY", "service": "x402-sponsor-relay-production", "entrypoint": "RelayRPC" } ] } } From f18b7ed8ea992fd66c1b8bffff6de156c58d9445 Mon Sep 17 00:00:00 2001 From: tfibtcagent Date: Mon, 30 Mar 2026 13:01:49 +0000 Subject: [PATCH 2/2] fix(x402): return 402 TRANSACTION_PENDING on RPC poll exhaustion, add PAYMENT_RESPONSE header, fix poll delay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [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 --- src/middleware/x402.ts | 35 +++++++++++++++++++++++------------ wrangler.jsonc | 1 + 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/middleware/x402.ts b/src/middleware/x402.ts index 39c654e..15175c2 100644 --- a/src/middleware/x402.ts +++ b/src/middleware/x402.ts @@ -184,7 +184,8 @@ const RPC_POLL_MAX_ATTEMPTS = 2; /** * Submit payment via X402_RELAY RPC service binding. - * Returns { txid, payer, pending } on accepted settlement, throws on failure. + * Returns { txid, payer } on confirmed settlement, throws on failure or poll exhaustion. + * Poll exhaustion throws with code TRANSACTION_PENDING so the caller returns 402 + Retry-After. */ async function settleViaRPC( rpc: RelayRPC, @@ -193,7 +194,7 @@ async function settleViaRPC( minAmount: string, asset: string, log: Logger -): Promise<{ txid: string; payer: string; pending: boolean }> { +): Promise<{ txid: string; payer: string }> { const settle: RelaySettleOptions = { expectedRecipient, minAmount, @@ -214,14 +215,15 @@ async function settleViaRPC( const { paymentId } = submitResult; - // Poll for confirmation (up to RPC_POLL_MAX_ATTEMPTS, RPC_POLL_INTERVAL_MS apart) + // Poll for confirmation (up to RPC_POLL_MAX_ATTEMPTS, RPC_POLL_INTERVAL_MS apart). + // Always wait before each check: tx was just submitted so attempt 0 is a near-certain miss. for (let i = 0; i < RPC_POLL_MAX_ATTEMPTS; i++) { - if (i > 0) await new Promise(resolve => setTimeout(resolve, RPC_POLL_INTERVAL_MS)); + await new Promise(resolve => setTimeout(resolve, RPC_POLL_INTERVAL_MS)); const check = await rpc.checkPayment(paymentId); if (check.status === "confirmed" && check.txid) { - return { txid: check.txid, payer: check.payer || "", pending: false }; + return { txid: check.txid, payer: check.payer || "" }; } if (check.status === "failed") { @@ -233,9 +235,14 @@ async function settleViaRPC( // status: pending/mempool → continue polling } - // Poll exhausted — relay accepted and broadcast, tx not yet confirmed - log.info("RPC settlement poll exhausted, returning pending", { paymentId }); - return { txid: paymentId, payer: "", pending: true }; + // Poll exhausted — relay accepted and broadcast but tx is not yet confirmed. + // Throw so the caller returns 402 + Retry-After, letting the client retry. + log.info("RPC settlement poll exhausted, returning TRANSACTION_PENDING", { paymentId }); + throw Object.assign(new Error("Transaction pending in settlement relay"), { + code: X402_ERROR_CODES.TRANSACTION_PENDING, + retryable: true, + retryAfter: 5, + }); } // ============================================================================= @@ -430,7 +437,6 @@ export function x402Middleware( // --- Settlement: prefer RPC binding, fall back to HTTP path --- let txId: string; let payerAddress: string; - let settlePending = false; let settleResult: SettlementResponseV2 | undefined; if (c.env.X402_RELAY) { @@ -462,7 +468,6 @@ export function x402Middleware( ); txId = rpcResult.txid; payerAddress = rpcResult.payer; - settlePending = rpcResult.pending; } catch (error) { const errorStr = String(error); const code = (error as { code?: string }).code; @@ -529,7 +534,8 @@ export function x402Middleware( txId = settleResult.transaction; payerAddress = settleResult.payer || ""; - // Set payment-response header for HTTP path (RPC path omits this since no SettlementResponseV2) + // Set payment-response header for HTTP path. RPC path sets it below after + // contextSettleResult is constructed (no native SettlementResponseV2 from RPC). c.header(X402_HEADERS.PAYMENT_RESPONSE, encodeBase64Json(settleResult)); } @@ -548,7 +554,6 @@ export function x402Middleware( network: networkV2, amount: paymentRequirements.amount, tier: dynamic ? "dynamic" : tier, - pending: settlePending, }); // Build a minimal settleResult for the X402Context (required by the type) @@ -559,6 +564,12 @@ export function x402Middleware( payer: payerAddress, }; + // Set payment-response header on the RPC path (HTTP path sets it above via settleResult). + // Uses the constructed contextSettleResult so clients on both paths receive the header. + if (c.env.X402_RELAY) { + c.header(X402_HEADERS.PAYMENT_RESPONSE, encodeBase64Json(contextSettleResult)); + } + // Store payment context for downstream use c.set("x402", { payerAddress, diff --git a/wrangler.jsonc b/wrangler.jsonc index ce24435..9fad4d6 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -24,6 +24,7 @@ }, "services": [ { "binding": "LOGS", "service": "worker-logs-staging", "entrypoint": "LogsRPC" }, + // Dev shares staging relay — no separate dev relay; ghost entries are transient { "binding": "X402_RELAY", "service": "x402-sponsor-relay-staging", "entrypoint": "RelayRPC" } ], "migrations": [