From f8ab716722b21c6436186ed5eedd009e260b77fd Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 21 Mar 2026 14:23:24 -0700 Subject: [PATCH] feat(x402): bridge on-chain USDC payments with CRDT token ledger Connects x402 (on-chain USDC via Base) and CRDT token system (Automerge cUSDC) in both directions: on-chain payments auto-mint cUSDC to payer's DID, and users can pay with cUSDC balance via new "crdt" payment scheme. 402 responses now return both exact and crdt payment options. Co-Authored-By: Claude Opus 4.6 --- server/index.ts | 49 +++++++++- server/token-schemas.ts | 2 + server/token-service.ts | 46 ++++++++++ shared/x402/crdt-client.ts | 107 ++++++++++++++++++++++ shared/x402/crdt-scheme.ts | 103 +++++++++++++++++++++ shared/x402/hono-middleware.ts | 162 ++++++++++++++++++++++++++++++--- types/hono.d.ts | 2 + 7 files changed, 454 insertions(+), 17 deletions(-) create mode 100644 shared/x402/crdt-client.ts create mode 100644 shared/x402/crdt-scheme.ts diff --git a/server/index.ts b/server/index.ts index e62e28b..0a8ed84 100644 --- a/server/index.ts +++ b/server/index.ts @@ -642,9 +642,51 @@ app.get("/api/modules/:moduleId/landing", (c) => { return c.json({ html, icon: mod.icon || "", name: mod.name || moduleId }); }); -// ── x402 test endpoint (no auth, payment-gated only) ── +// ── x402 test endpoint (payment-gated, supports on-chain + CRDT) ── import { setupX402FromEnv } from "../shared/x402/hono-middleware"; -const x402Test = setupX402FromEnv({ description: "x402 test endpoint", resource: "/api/x402-test" }); +import { setTokenVerifier } from "../shared/x402/crdt-scheme"; +import { getBalance, getTokenDoc, transferTokens, mintFromOnChain } from "./token-service"; + +// Wire EncryptID JWT verifier into CRDT scheme +setTokenVerifier(async (token: string) => { + const claims = await verifyEncryptIDToken(token); + return { sub: claims.sub, did: claims.did as string | undefined, username: claims.username }; +}); + +const x402Test = setupX402FromEnv({ + description: "x402 test endpoint", + resource: "/api/x402-test", + crdtPayment: { + tokenId: "cusdc", + amount: 10_000, // 0.01 cUSDC (6 decimals) + payToDid: "did:key:treasury-main-rspace-dao-2026", + payToLabel: "DAO Treasury", + getBalance, + getTokenDoc, + transferTokens, + }, + onPaymentSettled: async ({ paymentHeader, context }) => { + // Direction 1: mint cUSDC after on-chain USDC payment + try { + const token = extractToken(context.req.raw.headers); + if (!token) { + console.warn("[x402 bridge] No JWT — skipping cUSDC mint (on-chain payment still valid)"); + return; + } + const claims = await verifyEncryptIDToken(token); + const did = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`; + const label = claims.username || did; + const amount = process.env.X402_UPLOAD_PRICE || "0.01"; + const network = process.env.X402_NETWORK || "eip155:84532"; + // Use payment header hash as pseudo-txHash for idempotency + const txHash = `x402-${Buffer.from(paymentHeader).toString("base64url").slice(0, 40)}`; + mintFromOnChain(did, label, amount, txHash, network); + } catch (e) { + console.warn("[x402 bridge] cUSDC mint failed (non-fatal):", e); + } + }, +}); + app.post("/api/x402-test", async (c) => { if (x402Test) { const result = await new Promise((resolve) => { @@ -654,7 +696,8 @@ app.post("/api/x402-test", async (c) => { }); if (result) return result; } - return c.json({ ok: true, message: "Payment received!", timestamp: new Date().toISOString() }); + const scheme = c.get("x402Scheme") || "none"; + return c.json({ ok: true, scheme, message: "Payment received!", timestamp: new Date().toISOString() }); }); // ── Creative tools API endpoints ── diff --git a/server/token-schemas.ts b/server/token-schemas.ts index 0402221..07a27ad 100644 --- a/server/token-schemas.ts +++ b/server/token-schemas.ts @@ -19,6 +19,8 @@ export interface LedgerEntry { from: string; timestamp: number; issuedBy: string; + txHash?: string; + onChainNetwork?: string; } export interface TokenDefinition { diff --git a/server/token-service.ts b/server/token-service.ts index 22bfa94..e9472ec 100644 --- a/server/token-service.ts +++ b/server/token-service.ts @@ -138,6 +138,7 @@ export function mintTokens( memo: string, issuedBy: string, timestamp?: number, + extra?: { txHash?: string; onChainNetwork?: string }, ): boolean { const docId = tokenDocId(tokenId); ensureTokenDoc(tokenId); @@ -155,11 +156,56 @@ export function mintTokens( timestamp: timestamp || Date.now(), issuedBy, }; + if (extra?.txHash) d.entries[entryId].txHash = extra.txHash; + if (extra?.onChainNetwork) d.entries[entryId].onChainNetwork = extra.onChainNetwork; d.token.totalSupply = (d.token.totalSupply || 0) + amount; }); return result !== null; } +/** + * Mint cUSDC from an on-chain USDC payment (Direction 1: x402 → CRDT). + * Idempotent — skips if an entry with the same txHash already exists. + */ +export function mintFromOnChain( + did: string, + label: string, + amountDecimal: string, + txHash: string, + network: string, +): boolean { + const tokenId = 'cusdc'; + const doc = ensureTokenDoc(tokenId); + + // Idempotency: check if txHash already minted + for (const entry of Object.values(doc.entries)) { + if (entry.txHash === txHash) { + console.log(`[TokenService] x402 bridge: txHash ${txHash} already minted, skipping`); + return false; + } + } + + // Convert decimal USDC string to cUSDC base units (6 decimals) + const amount = Math.round(parseFloat(amountDecimal) * 1_000_000); + if (amount <= 0 || isNaN(amount)) { + console.warn(`[TokenService] x402 bridge: invalid amount "${amountDecimal}"`); + return false; + } + + const success = mintTokens( + tokenId, did, label, amount, + `x402 bridge: on-chain USDC → cUSDC (tx: ${txHash.slice(0, 10)}...)`, + 'x402-bridge', + undefined, + { txHash, onChainNetwork: network }, + ); + + if (success) { + console.log(`[TokenService] x402 bridge: minted ${amount} cUSDC to ${label} (${did}) from tx ${txHash.slice(0, 10)}...`); + } + return success; +} + /** List all token doc IDs. */ export function listTokenDocs(): string[] { return _syncServer!.listDocs().filter((id) => id.startsWith('global:tokens:ledgers:')); diff --git a/shared/x402/crdt-client.ts b/shared/x402/crdt-client.ts new file mode 100644 index 0000000..4743881 --- /dev/null +++ b/shared/x402/crdt-client.ts @@ -0,0 +1,107 @@ +/** + * x402 CRDT payment — client-side helpers. + * + * Creates CRDT payment headers and wraps fetch to auto-handle + * 402 responses with CRDT scheme when available. + */ + +export interface CrdtPaymentParams { + jwtToken: string; + fromDid: string; + fromLabel?: string; + amount: number; + tokenId: string; +} + +/** + * Create a base64-encoded X-PAYMENT header for CRDT scheme. + */ +export function createCrdtPaymentHeader(params: CrdtPaymentParams): string { + const payload = { + scheme: "crdt" as const, + jwtToken: params.jwtToken, + fromDid: params.fromDid, + fromLabel: params.fromLabel, + amount: params.amount, + tokenId: params.tokenId, + nonce: crypto.randomUUID(), + timestamp: Date.now(), + }; + + const json = JSON.stringify(payload); + return typeof btoa === "function" + ? btoa(json) + : Buffer.from(json).toString("base64"); +} + +interface AuthProvider { + getToken: () => string | null; + getDid: () => string | null; + getLabel?: () => string | null; + getBalance?: (tokenId: string) => number; +} + +/** + * Wrap fetch to auto-handle 402 responses with CRDT payment. + * + * On 402: checks if "crdt" scheme is offered and user has + * sufficient balance → auto-retries with CRDT payment header. + * Falls through if no CRDT option or insufficient balance. + */ +type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +export function wrapFetchWithCrdtPayment( + baseFetch: FetchFn, + auth: AuthProvider, +): FetchFn { + return async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const response = await baseFetch(input, init); + + if (response.status !== 402) return response; + + // Check if we have auth + const token = auth.getToken(); + const did = auth.getDid(); + if (!token || !did) return response; + + // Parse 402 response for CRDT option + let body: any; + try { + body = await response.clone().json(); + } catch { + return response; + } + + const requirements = body.paymentRequirements; + if (!Array.isArray(requirements)) return response; + + const crdtReq = requirements.find((r: any) => r.scheme === "crdt"); + if (!crdtReq) return response; + + const amount = parseInt(crdtReq.maxAmountRequired, 10); + const tokenId = crdtReq.tokenId; + + // Check balance if provider available + if (auth.getBalance) { + const balance = auth.getBalance(tokenId); + if (balance < amount) return response; // Insufficient — return original 402 + } + + // Create CRDT payment header and retry + const paymentHeader = createCrdtPaymentHeader({ + jwtToken: token, + fromDid: did, + fromLabel: auth.getLabel?.() || undefined, + amount, + tokenId, + }); + + return baseFetch(input, { + ...init, + headers: { + ...(init?.headers || {}), + "X-PAYMENT": paymentHeader, + }, + }); + }; +} diff --git a/shared/x402/crdt-scheme.ts b/shared/x402/crdt-scheme.ts new file mode 100644 index 0000000..6d235b1 --- /dev/null +++ b/shared/x402/crdt-scheme.ts @@ -0,0 +1,103 @@ +/** + * x402 CRDT payment scheme — server-side verifier. + * + * Verifies CRDT payment payloads: JWT signature, DID match, + * timestamp freshness, and nonce replay protection. + */ + +// Lazy-import to avoid pulling EncryptID SDK at module level in shared/ +let _verifyToken: ((token: string) => Promise<{ sub: string; did?: string; username?: string }>) | null = null; + +export function setTokenVerifier(fn: (token: string) => Promise<{ sub: string; did?: string; username?: string }>) { + _verifyToken = fn; +} + +export interface CrdtPaymentPayload { + scheme: "crdt"; + jwtToken: string; + fromDid: string; + fromLabel?: string; + amount: number; + tokenId: string; + nonce: string; + timestamp: number; +} + +export interface CrdtVerifyResult { + valid: boolean; + error?: string; + fromDid?: string; + fromLabel?: string; +} + +// In-memory nonce store with TTL (sufficient for single-instance deployment) +const usedNonces = new Map(); +const NONCE_TTL_MS = 10 * 60 * 1000; // 10 minutes +const TIMESTAMP_WINDOW_MS = 5 * 60 * 1000; // 5 minutes + +// Cleanup expired nonces every 5 minutes +setInterval(() => { + const now = Date.now(); + for (const [nonce, ts] of usedNonces) { + if (now - ts > NONCE_TTL_MS) usedNonces.delete(nonce); + } +}, 5 * 60 * 1000).unref?.(); + +/** + * Verify a CRDT payment payload. + * Does NOT check balance or execute transfer — that's the middleware's job. + */ +export async function verifyCrdtPayment( + payload: CrdtPaymentPayload, + requiredAmount: number, + requiredTokenId: string, +): Promise { + // 1. Basic field validation + if (!payload.jwtToken || !payload.fromDid || !payload.nonce || !payload.timestamp) { + return { valid: false, error: "Missing required fields" }; + } + + if (payload.tokenId !== requiredTokenId) { + return { valid: false, error: `Wrong token: expected ${requiredTokenId}, got ${payload.tokenId}` }; + } + + if (payload.amount < requiredAmount) { + return { valid: false, error: `Insufficient amount: need ${requiredAmount}, got ${payload.amount}` }; + } + + // 2. Timestamp freshness + const age = Math.abs(Date.now() - payload.timestamp); + if (age > TIMESTAMP_WINDOW_MS) { + return { valid: false, error: "Payment timestamp too old or too far in future" }; + } + + // 3. Nonce replay protection + if (usedNonces.has(payload.nonce)) { + return { valid: false, error: "Nonce already used (replay detected)" }; + } + + // 4. JWT verification + if (!_verifyToken) { + return { valid: false, error: "Token verifier not configured" }; + } + + try { + const claims = await _verifyToken(payload.jwtToken); + const claimsDid = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`; + + if (claimsDid !== payload.fromDid && claims.sub !== payload.fromDid) { + return { valid: false, error: "JWT DID does not match payment fromDid" }; + } + + // All checks passed — mark nonce as used + usedNonces.set(payload.nonce, Date.now()); + + return { + valid: true, + fromDid: claimsDid, + fromLabel: payload.fromLabel || claims.username || claimsDid, + }; + } catch (e) { + return { valid: false, error: `JWT verification failed: ${(e as Error).message}` }; + } +} diff --git a/shared/x402/hono-middleware.ts b/shared/x402/hono-middleware.ts index 2fafaf5..a4b8840 100644 --- a/shared/x402/hono-middleware.ts +++ b/shared/x402/hono-middleware.ts @@ -1,11 +1,36 @@ /** * 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; @@ -14,37 +39,135 @@ export interface X402Config { 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; + /** CRDT payment config (Direction 2: CRDT balance as payment) */ + crdtPayment?: CrdtPaymentConfig; } /** * Create x402 payment middleware for Hono routes. - * Returns null if X402_PAY_TO is not configured (disabled). */ 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) { - // Return 402 with payment requirements - const requirements = { - x402Version: 1, - scheme: "exact", - network: config.network, - maxAmountRequired: config.amount, - resource: config.resource || c.req.url, - description: config.description || "Payment required for upload", - payTo: config.payTo, - maxTimeoutSeconds: 300, - }; + // 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", paymentRequirements: requirements }, + { + error: "Payment Required", + x402Version: 1, + paymentRequirements: requirements, + }, 402, { "X-PAYMENT-REQUIREMENTS": JSON.stringify(requirements) } ); } - // Verify payment via facilitator + // 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", @@ -72,6 +195,17 @@ export function createX402Middleware(config: X402Config): MiddlewareHandler { // 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); diff --git a/types/hono.d.ts b/types/hono.d.ts index 42d68e2..fa5b728 100644 --- a/types/hono.d.ts +++ b/types/hono.d.ts @@ -5,5 +5,7 @@ declare module 'hono' { effectiveSpace: string; spaceRole: string; isOwner: boolean; + x402Payment: string; + x402Scheme: string; } }