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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-21 14:23:24 -07:00
parent aca0e6b353
commit f8ab716722
7 changed files with 454 additions and 17 deletions

View File

@ -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<Response | null>((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 ──

View File

@ -19,6 +19,8 @@ export interface LedgerEntry {
from: string;
timestamp: number;
issuedBy: string;
txHash?: string;
onChainNetwork?: string;
}
export interface TokenDefinition {

View File

@ -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:'));

107
shared/x402/crdt-client.ts Normal file
View File

@ -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<Response>;
export function wrapFetchWithCrdtPayment(
baseFetch: FetchFn,
auth: AuthProvider,
): FetchFn {
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
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,
},
});
};
}

103
shared/x402/crdt-scheme.ts Normal file
View File

@ -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<string, number>();
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<CrdtVerifyResult> {
// 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}` };
}
}

View File

@ -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<void>;
/** 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);

2
types/hono.d.ts vendored
View File

@ -5,5 +5,7 @@ declare module 'hono' {
effectiveSpace: string;
spaceRole: string;
isOwner: boolean;
x402Payment: string;
x402Scheme: string;
}
}