Merge branch 'dev'
This commit is contained in:
commit
f7c41594e4
|
|
@ -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 ──
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ export interface LedgerEntry {
|
|||
from: string;
|
||||
timestamp: number;
|
||||
issuedBy: string;
|
||||
txHash?: string;
|
||||
onChainNetwork?: string;
|
||||
}
|
||||
|
||||
export interface TokenDefinition {
|
||||
|
|
|
|||
|
|
@ -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:'));
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
* 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);
|
||||
|
|
|
|||
|
|
@ -5,5 +5,7 @@ declare module 'hono' {
|
|||
effectiveSpace: string;
|
||||
spaceRole: string;
|
||||
isOwner: boolean;
|
||||
x402Payment: string;
|
||||
x402Scheme: string;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue