rspace-online/shared/x402/hono-middleware.ts

239 lines
7.3 KiB
TypeScript

/**
* x402 Hono middleware — reusable payment gate for rSpace modules.
*
* Supports two payment schemes:
* - "exact": On-chain USDC via x402 facilitator (original)
* - "crdt": Local cUSDC balance via CRDT token ledger (bridge)
*
* When X402_PAY_TO env is set, protects routes with x402 micro-transactions.
* When not set, acts as a no-op passthrough.
*/
import type { Context, Next, MiddlewareHandler } from "hono";
import type { CrdtPaymentPayload } from "./crdt-scheme";
export interface CrdtPaymentConfig {
tokenId: string;
amount: number;
payToDid: string;
payToLabel: string;
// Injected to avoid shared/ → server/ import
getBalance: (doc: any, did: string) => number;
getTokenDoc: (tokenId: string) => any;
transferTokens: (
tokenId: string,
fromDid: string,
fromLabel: string,
toDid: string,
toLabel: string,
amount: number,
memo: string,
issuedBy: string,
) => boolean;
}
export interface X402Config {
payTo: string;
network: string;
amount: string;
facilitatorUrl: string;
resource?: string;
description?: string;
/** Called after on-chain payment is verified (Direction 1: on-chain → CRDT mint) */
onPaymentSettled?: (params: { paymentHeader: string; context: Context }) => Promise<void>;
/** CRDT payment config (Direction 2: CRDT balance as payment) */
crdtPayment?: CrdtPaymentConfig;
}
/**
* Create x402 payment middleware for Hono routes.
*/
export function createX402Middleware(config: X402Config): MiddlewareHandler {
// Lazy-load crdt-scheme to avoid circular imports
let verifyCrdtPayment: typeof import("./crdt-scheme").verifyCrdtPayment | null = null;
return async (c: Context, next: Next) => {
const paymentHeader = c.req.header("X-PAYMENT");
if (!paymentHeader) {
// Build payment requirements array with available schemes
const requirements: any[] = [
{
scheme: "exact",
network: config.network,
maxAmountRequired: config.amount,
resource: config.resource || c.req.url,
description: config.description || "Payment required",
payTo: config.payTo,
maxTimeoutSeconds: 300,
},
];
// Add CRDT option if configured
if (config.crdtPayment) {
requirements.push({
scheme: "crdt",
network: "rspace:local",
tokenId: config.crdtPayment.tokenId,
maxAmountRequired: String(config.crdtPayment.amount),
resource: config.resource || c.req.url,
description: config.description || "Payment required",
payTo: config.crdtPayment.payToDid,
});
}
return c.json(
{
error: "Payment Required",
x402Version: 1,
paymentRequirements: requirements,
},
402,
{ "X-PAYMENT-REQUIREMENTS": JSON.stringify(requirements) }
);
}
// Decode payment header to detect scheme
let decoded: any;
try {
decoded = JSON.parse(
typeof atob === "function"
? atob(paymentHeader)
: Buffer.from(paymentHeader, "base64").toString("utf-8")
);
} catch {
// Not base64 JSON — treat as legacy exact scheme
decoded = { scheme: "exact" };
}
// ── CRDT scheme ──
if (decoded.scheme === "crdt" && config.crdtPayment) {
try {
// Lazy-load verifier
if (!verifyCrdtPayment) {
const mod = await import("./crdt-scheme");
verifyCrdtPayment = mod.verifyCrdtPayment;
}
const payload = decoded as CrdtPaymentPayload;
const result = await verifyCrdtPayment(
payload,
config.crdtPayment.amount,
config.crdtPayment.tokenId,
);
if (!result.valid) {
return c.json({ error: "CRDT payment invalid", details: result.error }, 402);
}
// Check balance
const doc = config.crdtPayment.getTokenDoc(config.crdtPayment.tokenId);
if (!doc) {
return c.json({ error: "Token ledger not found" }, 500);
}
const balance = config.crdtPayment.getBalance(doc, result.fromDid!);
if (balance < config.crdtPayment.amount) {
return c.json({
error: "Insufficient cUSDC balance",
required: config.crdtPayment.amount,
available: balance,
}, 402);
}
// Execute transfer: payer → treasury
const transferred = config.crdtPayment.transferTokens(
config.crdtPayment.tokenId,
result.fromDid!,
result.fromLabel || result.fromDid!,
config.crdtPayment.payToDid,
config.crdtPayment.payToLabel,
config.crdtPayment.amount,
`x402 CRDT payment for ${config.resource || c.req.url}`,
'x402-crdt',
);
if (!transferred) {
return c.json({ error: "CRDT transfer failed" }, 500);
}
c.set("x402Payment", paymentHeader);
c.set("x402Scheme", "crdt");
await next();
return;
} catch (e) {
console.error("[x402] CRDT verification error:", e);
return c.json({ error: "CRDT payment verification failed" }, 500);
}
}
// ── Exact (on-chain) scheme ──
try {
const verifyRes = await fetch(`${config.facilitatorUrl}/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
payment: paymentHeader,
requirements: {
scheme: "exact",
network: config.network,
maxAmountRequired: config.amount,
payTo: config.payTo,
},
}),
});
if (!verifyRes.ok) {
const err = await verifyRes.text();
return c.json({ error: "Payment verification failed", details: err }, 402);
}
const result = await verifyRes.json() as { valid?: boolean };
if (!result.valid) {
return c.json({ error: "Payment invalid or insufficient" }, 402);
}
// Payment valid — store tx info for downstream handlers
c.set("x402Payment", paymentHeader);
c.set("x402Scheme", "exact");
// Direction 1: fire onPaymentSettled hook (mint cUSDC from on-chain)
if (config.onPaymentSettled) {
try {
await config.onPaymentSettled({ paymentHeader, context: c });
} catch (e) {
console.warn("[x402] onPaymentSettled hook error (non-fatal):", e);
}
}
await next();
} catch (e) {
console.error("[x402] Verification error:", e);
return c.json({ error: "Payment verification service unavailable" }, 503);
}
};
}
/**
* Initialize x402 from environment variables.
* Returns middleware or null if disabled.
*/
export function setupX402FromEnv(overrides?: Partial<X402Config>): MiddlewareHandler | null {
const payTo = process.env.X402_PAY_TO;
if (!payTo) {
console.log("[x402] Disabled — X402_PAY_TO not set");
return null;
}
const config: X402Config = {
payTo,
network: process.env.X402_NETWORK || "eip155:84532",
amount: process.env.X402_UPLOAD_PRICE || "0.01",
facilitatorUrl: process.env.X402_FACILITATOR_URL || "https://www.x402.org/facilitator",
...overrides,
};
console.log(`[x402] Enabled — payTo=${payTo}, network=${config.network}, amount=${config.amount}`);
return createX402Middleware(config);
}