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:
parent
aca0e6b353
commit
f8ab716722
|
|
@ -642,9 +642,51 @@ app.get("/api/modules/:moduleId/landing", (c) => {
|
||||||
return c.json({ html, icon: mod.icon || "", name: mod.name || moduleId });
|
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";
|
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) => {
|
app.post("/api/x402-test", async (c) => {
|
||||||
if (x402Test) {
|
if (x402Test) {
|
||||||
const result = await new Promise<Response | null>((resolve) => {
|
const result = await new Promise<Response | null>((resolve) => {
|
||||||
|
|
@ -654,7 +696,8 @@ app.post("/api/x402-test", async (c) => {
|
||||||
});
|
});
|
||||||
if (result) return result;
|
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 ──
|
// ── Creative tools API endpoints ──
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ export interface LedgerEntry {
|
||||||
from: string;
|
from: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
issuedBy: string;
|
issuedBy: string;
|
||||||
|
txHash?: string;
|
||||||
|
onChainNetwork?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenDefinition {
|
export interface TokenDefinition {
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,7 @@ export function mintTokens(
|
||||||
memo: string,
|
memo: string,
|
||||||
issuedBy: string,
|
issuedBy: string,
|
||||||
timestamp?: number,
|
timestamp?: number,
|
||||||
|
extra?: { txHash?: string; onChainNetwork?: string },
|
||||||
): boolean {
|
): boolean {
|
||||||
const docId = tokenDocId(tokenId);
|
const docId = tokenDocId(tokenId);
|
||||||
ensureTokenDoc(tokenId);
|
ensureTokenDoc(tokenId);
|
||||||
|
|
@ -155,11 +156,56 @@ export function mintTokens(
|
||||||
timestamp: timestamp || Date.now(),
|
timestamp: timestamp || Date.now(),
|
||||||
issuedBy,
|
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;
|
d.token.totalSupply = (d.token.totalSupply || 0) + amount;
|
||||||
});
|
});
|
||||||
return result !== null;
|
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. */
|
/** List all token doc IDs. */
|
||||||
export function listTokenDocs(): string[] {
|
export function listTokenDocs(): string[] {
|
||||||
return _syncServer!.listDocs().filter((id) => id.startsWith('global:tokens:ledgers:'));
|
return _syncServer!.listDocs().filter((id) => id.startsWith('global:tokens:ledgers:'));
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,36 @@
|
||||||
/**
|
/**
|
||||||
* x402 Hono middleware — reusable payment gate for rSpace modules.
|
* 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 X402_PAY_TO env is set, protects routes with x402 micro-transactions.
|
||||||
* When not set, acts as a no-op passthrough.
|
* When not set, acts as a no-op passthrough.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Context, Next, MiddlewareHandler } from "hono";
|
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 {
|
export interface X402Config {
|
||||||
payTo: string;
|
payTo: string;
|
||||||
|
|
@ -14,37 +39,135 @@ export interface X402Config {
|
||||||
facilitatorUrl: string;
|
facilitatorUrl: string;
|
||||||
resource?: string;
|
resource?: string;
|
||||||
description?: 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.
|
* Create x402 payment middleware for Hono routes.
|
||||||
* Returns null if X402_PAY_TO is not configured (disabled).
|
|
||||||
*/
|
*/
|
||||||
export function createX402Middleware(config: X402Config): MiddlewareHandler {
|
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) => {
|
return async (c: Context, next: Next) => {
|
||||||
const paymentHeader = c.req.header("X-PAYMENT");
|
const paymentHeader = c.req.header("X-PAYMENT");
|
||||||
|
|
||||||
if (!paymentHeader) {
|
if (!paymentHeader) {
|
||||||
// Return 402 with payment requirements
|
// Build payment requirements array with available schemes
|
||||||
const requirements = {
|
const requirements: any[] = [
|
||||||
x402Version: 1,
|
{
|
||||||
scheme: "exact",
|
scheme: "exact",
|
||||||
network: config.network,
|
network: config.network,
|
||||||
maxAmountRequired: config.amount,
|
maxAmountRequired: config.amount,
|
||||||
resource: config.resource || c.req.url,
|
resource: config.resource || c.req.url,
|
||||||
description: config.description || "Payment required for upload",
|
description: config.description || "Payment required",
|
||||||
payTo: config.payTo,
|
payTo: config.payTo,
|
||||||
maxTimeoutSeconds: 300,
|
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(
|
return c.json(
|
||||||
{ error: "Payment Required", paymentRequirements: requirements },
|
{
|
||||||
|
error: "Payment Required",
|
||||||
|
x402Version: 1,
|
||||||
|
paymentRequirements: requirements,
|
||||||
|
},
|
||||||
402,
|
402,
|
||||||
{ "X-PAYMENT-REQUIREMENTS": JSON.stringify(requirements) }
|
{ "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 {
|
try {
|
||||||
const verifyRes = await fetch(`${config.facilitatorUrl}/verify`, {
|
const verifyRes = await fetch(`${config.facilitatorUrl}/verify`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -72,6 +195,17 @@ export function createX402Middleware(config: X402Config): MiddlewareHandler {
|
||||||
|
|
||||||
// Payment valid — store tx info for downstream handlers
|
// Payment valid — store tx info for downstream handlers
|
||||||
c.set("x402Payment", paymentHeader);
|
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();
|
await next();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[x402] Verification error:", e);
|
console.error("[x402] Verification error:", e);
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,7 @@ declare module 'hono' {
|
||||||
effectiveSpace: string;
|
effectiveSpace: string;
|
||||||
spaceRole: string;
|
spaceRole: string;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
|
x402Payment: string;
|
||||||
|
x402Scheme: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue