Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 179 additions & 58 deletions src/middleware/x402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import type {
PriceEstimate,
X402Context,
ChatCompletionRequest,
RelayRPC,
RelaySettleOptions,
} from "../types";
import {
validateTokenType,
Expand Down Expand Up @@ -172,6 +174,77 @@ function classifyPaymentError(error: unknown, settleResult?: Partial<SettlementR
return { code: X402_ERROR_CODES.UNEXPECTED_SETTLE_ERROR, message: "Payment processing error", httpStatus: 500, retryAfter: 5 };
}

// =============================================================================
// RPC Settlement Helper
// =============================================================================

/** RPC polling constants (mirrors landing-page lib/inbox/constants.ts) */
const RPC_POLL_INTERVAL_MS = 2_000;
const RPC_POLL_MAX_ATTEMPTS = 2;

/**
* Submit payment via X402_RELAY RPC service binding.
* 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,
txHex: string,
expectedRecipient: string,
minAmount: string,
asset: string,
log: Logger
): Promise<{ txid: string; payer: string }> {
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
// =============================================================================
Expand Down Expand Up @@ -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");
Expand All @@ -439,26 +548,38 @@ export function x402Middleware(
}

log.info("Payment verified successfully", {
txId: settleResult.transaction,
txId,
payerAddress,
asset,
network: networkV2,
amount: paymentRequirements.amount,
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();
Expand Down
42 changes: 42 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,47 @@ export interface LogsRPC {
error(appId: string, message: string, context?: Record<string, unknown>): Promise<unknown>;
}

// =============================================================================
// 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<RelaySubmitResult>;
checkPayment(paymentId: string): Promise<RelayCheckResult>;
}

export interface Logger {
debug(message: string, data?: Record<string, unknown>): void;
info(message: string, data?: Record<string, unknown>): void;
Expand All @@ -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;
Expand Down
10 changes: 7 additions & 3 deletions wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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"] },
Expand Down Expand Up @@ -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": {
Expand All @@ -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" }
]
}
}
Expand Down