diff --git a/src/middleware/x402.ts b/src/middleware/x402.ts index 4722f82..15175c2 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,77 @@ 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). + // 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++) { + 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 || "" }; + } + + 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 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, + }); +} + // ============================================================================= // Middleware Factory // ============================================================================= @@ -361,74 +434,110 @@ 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 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; + } 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 sets it below after + // contextSettleResult is constructed (no native SettlementResponseV2 from RPC). + c.header(X402_HEADERS.PAYMENT_RESPONSE, encodeBase64Json(settleResult)); + } if (!payerAddress) { log.error("Could not extract payer address from valid payment"); @@ -439,7 +548,7 @@ export function x402Middleware( } log.info("Payment verified successfully", { - txId: settleResult.transaction, + txId, payerAddress, asset, network: networkV2, @@ -447,18 +556,30 @@ export function x402Middleware( tier: dynamic ? "dynamic" : tier, }); + // Build a minimal settleResult for the X402Context (required by the type) + const contextSettleResult: SettlementResponseV2 = settleResult ?? { + success: true, + transaction: txId, + network: networkV2, + 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, - 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..9fad4d6 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -23,7 +23,9 @@ ] }, "services": [ - { "binding": "LOGS", "service": "worker-logs-staging", "entrypoint": "LogsRPC" } + { "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": [ { "tag": "v1", "new_sqlite_classes": ["UsageDO", "StorageDO"] }, @@ -67,7 +69,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 +94,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" } ] } }