diff --git a/api/package.json b/api/package.json index 3cfdc408..7debf448 100644 --- a/api/package.json +++ b/api/package.json @@ -16,7 +16,7 @@ "db:cleanup": "node dist/cron/dbCleanup.js", "funnel-report": "node dist/scripts/funnelReport.js", "npm:audit": "npm audit --json > /tmp/audit.json && echo \"Audit complete\"", - "test": "TS_NODE_TRANSPILE_ONLY=1 node --loader ts-node/esm tests/wallet-provisioning.test.js && node --loader ts-node/esm tests/ssrf.test.js && node tests/integration.test.js && node tests/pages.test.js && node tests/x402-v1-passthrough.test.mjs && node tests/model-cost.test.mjs && node tests/session-pricing.test.mjs && node tests/prompt-moderation.test.mjs && node tests/critical-regressions.test.mjs && node tests/unsubscribe.test.mjs && node tests/reactivation-render.test.mjs && node tests/credit-alert-dedup.test.mjs && node tests/verify-activation.test.mjs && node tests/signup-firstcall.test.mjs && node tests/oauth-signup-cta.test.mjs && node tests/x402-sell-copy.test.mjs && node tests/verify-resend.test.mjs && node tests/intent-funnel.test.mjs", + "test": "TS_NODE_TRANSPILE_ONLY=1 node --loader ts-node/esm tests/wallet-provisioning.test.js && node --loader ts-node/esm tests/ssrf.test.js && node tests/integration.test.js && node tests/pages.test.js && node tests/x402-v1-passthrough.test.mjs && node tests/model-cost.test.mjs && node tests/session-pricing.test.mjs && node tests/prompt-moderation.test.mjs && node tests/critical-regressions.test.mjs && node tests/unsubscribe.test.mjs && node tests/reactivation-render.test.mjs && node tests/credit-alert-dedup.test.mjs && node tests/verify-activation.test.mjs && node tests/signup-firstcall.test.mjs && node tests/oauth-signup-cta.test.mjs && node tests/x402-sell-copy.test.mjs && node tests/verify-resend.test.mjs && node tests/intent-funnel.test.mjs && node tests/x402-topup.test.mjs", "test:integration": "node tests/integration.test.js", "test:verify-activation": "node tests/verify-activation.test.mjs", "test:verify-resend": "node tests/verify-resend.test.mjs", diff --git a/api/src/index.ts b/api/src/index.ts index 9814d7be..ab4cb7a5 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -26,6 +26,7 @@ import agentRouter from "./routes/agent.js"; import { requireAuth, AuthedRequest } from "./middleware/auth.js"; import toolsRouter from "./routes/tools/index.js"; import billingRouter from "./routes/billing.js"; +import topupX402Router from "./routes/topupX402.js"; import adminRouter from "./routes/admin.js"; import workflowsRouter from "./routes/workflows.js"; import seoRouter from "./routes/seo.js"; @@ -311,6 +312,8 @@ app.use("/v1/tools", toolsRouter); // Billing app.use("/v1/billing", billingRouter); app.use("/webhooks", billingRouter); +// x402 USDC → credits top-up — /v1/billing ONLY (never on the /webhooks alias) +app.use("/v1/billing", topupX402Router); // Admin app.use("/v1/admin", adminRouter); diff --git a/api/src/lib/x402Topup.ts b/api/src/lib/x402Topup.ts new file mode 100644 index 00000000..6e478a82 --- /dev/null +++ b/api/src/lib/x402Topup.ts @@ -0,0 +1,141 @@ +/** + * x402 USDC → credits top-up — tier math + idempotent grant core (GROWTH_50 #8). + * + * Pure/dependency-free by design — unit-tested from dist without a DB (same + * pattern as lib/x402V1.ts / lib/x402V2.ts). The route (routes/topupX402.ts) + * injects the real Prisma calls through TopupGrantDeps. + * + * RATE AUTHORITY: the one-time CREDIT_PACKS catalog in routes/billing.ts — + * the route passes it in; this module never declares its own pack economics. + * Pack-equivalent fairness rule: + * - A top-up gets the CHEAPEST per-credit rate among packs priced at or + * under the top-up amount (the best deal the same money buys today). + * - A top-up below every pack price gets the WORST pack rate (starter: + * $9 / 3,000 credits = $0.003/credit) — never a better rate than the + * best pack, never a worse rate than the smallest pack. + * - Integer math with ceil ROUNDED IN THE BUYER'S FAVOR (≤1 extra credit, + * ≤$0.003) so the effective rate is never worse than the promised pack + * rate ("$0.003/credit or cheaper"). + */ + +export interface PackRateSource { + /** Credits granted by the pack. */ + credits: number; + /** Pack price in USD cents (routes/billing.ts CREDIT_PACKS `amount`). */ + amount: number; +} + +export interface TopupTier { + /** Path segment: POST /v1/billing/topup-x402/:tier */ + id: string; + /** x402 USD price string (same decimal format as X402_PRICES values). */ + usd: string; + /** Top-up price in USD cents. */ + amountCents: number; + /** Credits granted when the x402 payment settles. */ + credits: number; +} + +/** Fixed top-up price points in USD cents: $5 / $20 / $50. */ +export const TOPUP_TIER_CENTS: readonly number[] = [500, 2000, 5000]; + +/** x402 "tool" name for a tier's payment gate + X402Payment/ApiRequest rows. + * Deliberately NOT in X402_PRICES (the price rides in as a middleware option), + * so the advertised-tool price map check-price-drift.mjs anchors on is untouched. */ +export function topupToolName(tierId: string): string { + return `credits-topup-${tierId}`; +} + +/** + * Credits for a top-up of `amountCents`, derived from the pack catalog. + * ceil(amountCents * credits / amount) per pack — integer-exact, rounded in + * the buyer's favor by at most one credit. + */ +export function creditsForTopupCents(amountCents: number, packs: readonly PackRateSource[]): number { + if (!Number.isFinite(amountCents) || amountCents <= 0) return 0; + const valid = packs.filter( + (p) => Number.isFinite(p.amount) && p.amount > 0 && Number.isFinite(p.credits) && p.credits > 0, + ); + if (valid.length === 0) return 0; + const creditsAt = (p: PackRateSource): number => Math.ceil((amountCents * p.credits) / p.amount); + const eligible = valid.filter((p) => p.amount <= amountCents); + return eligible.length > 0 + ? Math.max(...eligible.map(creditsAt)) // best rate among packs the same money buys + : Math.min(...valid.map(creditsAt)); // below every pack price → worst pack rate +} + +/** Build the fixed tier catalog from the pack authority. */ +export function buildTopupTiers(packs: readonly PackRateSource[]): TopupTier[] { + return TOPUP_TIER_CENTS.map((cents) => ({ + id: String(cents / 100), + usd: (cents / 100).toFixed(3), + amountCents: cents, + credits: creditsForTopupCents(cents, packs), + })); +} + +// ─── Idempotent grant core ─────────────────────────────────────────────────── + +export interface TopupGrantDeps { + /** Look up a prior grant by its dedupe id (Purchase.stripeId). Null when none. */ + findPurchase(dedupeId: string): Promise<{ agentId: string; credits: number } | null>; + /** + * Create the payment record AND increment the agent's credits in ONE atomic + * transaction. MUST fail (throw) on a duplicate dedupe id — Purchase.stripeId + * is unique, which is what makes the grant idempotent under races. + */ + createPurchaseAndCredit(args: { + agentId: string; + dedupeId: string; + credits: number; + amountCents: number; + }): Promise<{ balance: number }>; +} + +export type TopupGrantResult = + | { status: "granted"; creditsAdded: number; balance: number } + | { status: "already_credited"; creditsAdded: 0 } + | { status: "conflict" } // same settlement id credited to a DIFFERENT agent — never expected + | { status: "failed"; reason: string }; + +/** + * Grant credits for ONE settled x402 payment — exactly once per settlement. + * Dedupe key = the settlement id (tx hash / EIP-3009 nonce), mirroring how the + * Stripe webhook dedupes on Purchase.stripeId. Safe under concurrent replays: + * the unique-constraint loser re-reads and reports already_credited instead of + * double-crediting. + */ +export async function grantTopupCredits( + deps: TopupGrantDeps, + agentId: string, + tier: Pick, + dedupeId: string, +): Promise { + // Cheap idempotency answer for clean replays (same pattern as the webhook's + // findUnique-first); the unique constraint below covers the race window. + const existing = await deps.findPurchase(dedupeId).catch(() => null); + if (existing) { + return existing.agentId === agentId + ? { status: "already_credited", creditsAdded: 0 } + : { status: "conflict" }; + } + try { + const { balance } = await deps.createPurchaseAndCredit({ + agentId, + dedupeId, + credits: tier.credits, + amountCents: tier.amountCents, + }); + return { status: "granted", creditsAdded: tier.credits, balance }; + } catch (e) { + // Lost a race on the unique dedupe id? Then the other insert credited it — + // idempotent success, NOT an error. Anything else is a real failure. + const raced = await deps.findPurchase(dedupeId).catch(() => null); + if (raced) { + return raced.agentId === agentId + ? { status: "already_credited", creditsAdded: 0 } + : { status: "conflict" }; + } + return { status: "failed", reason: e instanceof Error ? e.message : String(e) }; + } +} diff --git a/api/src/middleware/x402.ts b/api/src/middleware/x402.ts index 67778ad4..b335ed00 100644 --- a/api/src/middleware/x402.ts +++ b/api/src/middleware/x402.ts @@ -201,7 +201,7 @@ export function isX402AnonymousTool(toolName: string): boolean { * translation path (lib/x402V1.ts), but it is NO LONGER what we serve on the wire — * 402 challenges are emitted via buildPaymentRequiredV2() below. */ -export function buildPaymentRequired(toolName: string, price: string): object { +export function buildPaymentRequired(toolName: string, price: string, resourceUrl?: string): object { const network = config.x402.network; // Use x402 named network format (required by client SDK schema validation) // "base" / "polygon" / "solana" NOT "eip155:8453" / "eip155:137" @@ -209,7 +209,9 @@ export function buildPaymentRequired(toolName: string, price: string): object { const usdcContract = USDC_CONTRACTS[network] ?? USDC_CONTRACTS["base"]; // Convert price to USDC atomic units (6 decimals) const amountAtomic = Math.round(parseFloat(price) * 1_000_000).toString(); - const resource = `${process.env.PUBLIC_SITE_URL ?? "https://archtools.dev"}/v1/tools/${toolName}`; + // resourceUrl override: non-tool x402 resources (e.g. /v1/billing/topup-x402/:tier) + // advertise their real endpoint instead of the /v1/tools/ default. + const resource = resourceUrl ?? `${process.env.PUBLIC_SITE_URL ?? "https://archtools.dev"}/v1/tools/${toolName}`; const evmWallet = config.x402.walletAddress; const accepts: object[] = []; @@ -788,8 +790,8 @@ export function buildPaymentRequired(toolName: string, price: string): object { * extensions.bazaar). Served as BOTH the base64 PAYMENT-REQUIRED header and the * JSON body on every 402. */ -export function buildPaymentRequiredV2(toolName: string, price: string): object { - return toV2PaymentRequired(buildPaymentRequired(toolName, price)); +export function buildPaymentRequiredV2(toolName: string, price: string, resourceUrl?: string): object { + return toV2PaymentRequired(buildPaymentRequired(toolName, price, resourceUrl)); } /** @@ -1229,12 +1231,44 @@ async function settlePayment(paymentHeader: string, toolName: string, paymentReq } } +/** + * Settlement facts attached to the request after a successful x402 settle — + * downstream handlers (e.g. the credits top-up grant) key idempotent side + * effects on `transaction`. Set ONLY when settle returned success===true. + */ +export interface X402SettlementInfo { + transaction: string | null; + network: string | null; + payer: string | null; + /** USD price the payment was verified/settled against (decimal string). */ + amountUsd: string; +} + +export interface X402MiddlewareOptions { + /** + * Fixed USD price (decimal string, same format as X402_PRICES values). + * Defaults to X402_PRICES[toolName] ?? "0.010". Lets non-tool resources + * (credit top-ups) price their 402 without entering the advertised tool + * price map that scripts/check-price-drift.mjs anchors on. + */ + price?: string; + /** Exact resource URL for the 402 challenge (default /v1/tools/). */ + resourceUrl?: string; + /** + * When true, an API credential does NOT bypass the payment gate — every + * request without a payment header gets the 402 challenge. Used by routes + * where the payment IS the point (credits top-up), which authenticate + * separately BEFORE this middleware. + */ + requirePayment?: boolean; +} + /** * x402 middleware — attach to any tool route. * Checks for X-Payment header; if missing + no valid API key, returns 402. * If X-Payment present, verifies with facilitator and logs payment. */ -export function x402Middleware(toolName: string) { +export function x402Middleware(toolName: string, opts?: X402MiddlewareOptions) { return async (req: Request, res: Response, next: NextFunction): Promise => { const requestStartMs = Date.now(); @@ -1284,7 +1318,7 @@ export function x402Middleware(toolName: string) { // No payment header — check if they have a valid API key with credits if (!paymentHeader) { - if (hasApiCredential) { + if (hasApiCredential && !opts?.requirePayment) { // Let auth middleware handle it (API key or Bearer token) next(); return; @@ -1296,8 +1330,8 @@ export function x402Middleware(toolName: string) { // same PaymentRequired plus a namespaced `links` object with free-signup // discovery hints (bodies are a server implementation concern — // specs/transports-v2/http.md). - const price = X402_PRICES[toolName] ?? "0.010"; - const paymentRequired = buildPaymentRequiredV2(toolName, price); + const price = opts?.price ?? X402_PRICES[toolName] ?? "0.010"; + const paymentRequired = buildPaymentRequiredV2(toolName, price, opts?.resourceUrl); const paymentRequiredB64 = Buffer.from(JSON.stringify(paymentRequired)).toString("base64"); res.status(402) .header("Content-Type", "application/json") @@ -1342,8 +1376,8 @@ export function x402Middleware(toolName: string) { // CRITICAL FIX: paymentRequirements sent to the facilitator MUST match the // accepts[] entry the agent selected. We rebuild the full accepts[] and // find the matching entry from the payment payload's network/asset. - const price = X402_PRICES[toolName] ?? "0.010"; - const fullPaymentDetails = buildPaymentRequired(toolName, price) as { accepts: any[] }; + const price = opts?.price ?? X402_PRICES[toolName] ?? "0.010"; + const fullPaymentDetails = buildPaymentRequired(toolName, price, opts?.resourceUrl) as { accepts: any[] }; const { network: paymentNetwork, asset: paymentAsset } = extractPaymentNetwork(paymentHeader); // Find matching accepts[] entry — network match normalizes v1 named networks and @@ -1443,6 +1477,14 @@ export function x402Middleware(toolName: string) { if (payer) { (req as Request & { x402Payer?: string }).x402Payer = payer; } + // Settlement facts for downstream handlers (idempotent side effects key on + // the transaction hash). Set ONLY on this success===true path. + (req as Request & { x402Settlement?: X402SettlementInfo }).x402Settlement = { + transaction: settleResult?.transaction ?? null, + network: settleResult?.network ?? (paymentRequirements as any).network ?? config.x402.network ?? null, + payer: payer ?? null, + amountUsd: price, + }; // PAYMENT-RESPONSE header: Base64-encoded SettleResponse per x402 v2 spec §5.3.2 // ("network: Blockchain network identifier in CAIP-2 format") — normalize the @@ -1473,7 +1515,10 @@ export function x402Middleware(toolName: string) { try { await prisma.apiRequest.create({ data: { - agentId: "x402_anonymous", + // Routes that authenticate BEFORE this middleware (credits top-up) + // attribute the call to the real account; tool routes run x402 + // first, so req.agent is unset there and behavior is unchanged. + agentId: (req as Request & { agent?: { id?: string } }).agent?.id ?? "x402_anonymous", toolName, creditsUsed: 0, // Three-way: SUCCESS (<400) | CLIENT_ERROR (4xx) | ERROR (5xx) diff --git a/api/src/routes/billing.ts b/api/src/routes/billing.ts index 0d8fb48a..b339c14b 100644 --- a/api/src/routes/billing.ts +++ b/api/src/routes/billing.ts @@ -44,7 +44,9 @@ async function requireAuthOrSession(req: AuthedRequest, res: Response, next: Nex } // ─── One-time credit packs ────────────────────────────────────────────────── -const CREDIT_PACKS = [ +// Exported as the RATE AUTHORITY for the x402 USDC top-up (routes/topupX402.ts +// derives its credits-per-dollar from these packs — never from its own numbers). +export const CREDIT_PACKS = [ { id: "starter", credits: 3000, amount: 900, label: "Starter Pack", priceId: process.env.STRIPE_PRICE_STARTER ?? "" }, { id: "pro", credits: 25000, amount: 4900, label: "Pro Pack", priceId: process.env.STRIPE_PRICE_PRO ?? "" }, { id: "business", credits: 125000, amount: 19900, label: "Business Pack", priceId: process.env.STRIPE_PRICE_BUSINESS ?? "" }, diff --git a/api/src/routes/topupX402.ts b/api/src/routes/topupX402.ts new file mode 100644 index 00000000..bffacb54 --- /dev/null +++ b/api/src/routes/topupX402.ts @@ -0,0 +1,281 @@ +/** + * x402 USDC → credits top-up (GROWTH_50 #8) — the card-free path to credits. + * + * POST /v1/billing/topup-x402/:tier ($5 / $20 / $50) + * 1. Agent calls with Authorization: Bearer and NO payment header + * → 402 + PAYMENT-REQUIRED challenge priced at the tier (requirePayment: + * on this route an API key does NOT bypass the gate — the payment IS the + * point; auth only establishes WHO the credits belong to). + * 2. Agent signs and retries with PAYMENT-SIGNATURE (or legacy X-PAYMENT) + * plus the same API key. requireAuth identifies the account, then the + * EXISTING x402 middleware verifies + settles through the facilitator — + * no payment verification is hand-rolled here. + * 3. On settle success the handler grants credits at a pack-equivalent rate + * (lib/x402Topup.ts — routes/billing.ts CREDIT_PACKS is the rate + * authority) atomically with a Purchase record, idempotent on the + * settlement id (Purchase.stripeId = "x402:", unique) — + * the same dedupe discipline as the Stripe webhook. A replayed or + * duplicate settlement can never double-credit. + * + * Mounted at /v1/billing ONLY (deliberately NOT on the /webhooks alias that + * routes/billing.ts also serves). Tier prices ride in as middleware options — + * X402_PRICES and every advertised price surface are untouched, so + * scripts/check-price-drift.mjs needs no changes. + */ + +import { Router, Request, Response, NextFunction } from "express"; +import { randomUUID } from "crypto"; +import { prisma } from "../lib/prisma.js"; +import { requireAuth, AuthedRequest } from "../middleware/auth.js"; +import { x402Middleware, extractNonce, X402SettlementInfo } from "../middleware/x402.js"; +import { CREDIT_PACKS } from "./billing.js"; +import { + buildTopupTiers, + topupToolName, + grantTopupCredits, + TopupTier, + TopupGrantDeps, +} from "../lib/x402Topup.js"; +import { reqId } from "../utils/credits.js"; +import { sendPurchaseConfirmation, sendAdminAlert } from "../services/email.js"; +import { fireWebhookEvent } from "../services/webhooks.js"; + +const router = Router(); + +const SITE = process.env.PUBLIC_SITE_URL ?? "https://archtools.dev"; + +/** Tier catalog — derived from the billing pack authority at startup. */ +export const TOPUP_TIERS: readonly TopupTier[] = buildTopupTiers(CREDIT_PACKS); + +// Paranoid allow-list, mirroring ALLOWED_ONETIME_CREDITS in the Stripe webhook: +// a grant may only ever be one of the amounts this catalog computed at startup. +const ALLOWED_TOPUP_CREDITS = new Set(TOPUP_TIERS.map((t) => t.credits)); + +// One x402 payment gate per tier, created once at startup (same lifecycle as +// toolMiddleware). Each gate advertises its REAL endpoint as the resource and +// enforces payment even for API-key holders. +const tierGates = new Map>( + TOPUP_TIERS.map((t) => [ + t.id, + x402Middleware(topupToolName(t.id), { + price: t.usd, + requirePayment: true, + resourceUrl: `${SITE}/v1/billing/topup-x402/${t.id}`, + }), + ]), +); + +type TopupRequest = AuthedRequest & { + x402Paid?: boolean; + x402Settlement?: X402SettlementInfo; +}; + +function tierCatalog(): object[] { + return TOPUP_TIERS.map((t) => ({ + tier: t.id, + price_usd: t.amountCents / 100, + credits: t.credits, + usd_per_credit: Number((t.amountCents / 100 / t.credits).toFixed(6)), + endpoint: `/v1/billing/topup-x402/${t.id}`, + })); +} + +function invalidTier(res: Response): void { + res.status(400).json({ + ok: false, + error: "invalid_request", + message: `tier must be one of: ${TOPUP_TIERS.map((t) => t.id).join(", ")}. POST /v1/billing/topup-x402/ with your API key (e.g. /v1/billing/topup-x402/20 for a $20 USDC top-up).`, + tiers: tierCatalog(), + request_id: reqId(), + }); +} + +// GET /v1/billing/topup-x402 — public tier catalog + how-to (no auth, no payment) +router.get("/topup-x402", (_req: Request, res: Response): void => { + res.json({ + ok: true, + description: + "Top up credits with USDC via x402 — no card needed. POST /v1/billing/topup-x402/:tier with your API key, pay the 402 challenge, credits land on settlement at a pack-equivalent rate.", + tiers: tierCatalog(), + auth: "Authorization: Bearer (or x-api-key) is required — the credits must land on YOUR account.", + payment: + "x402 v2 (PAYMENT-SIGNATURE) or v1 (X-PAYMENT) — USDC on the networks listed in the 402 challenge accepts[].", + request_id: reqId(), + }); +}); + +// POST without a tier — answer with the exact corrective call (lost-sale guard, +// same philosophy as billing.ts checkout's pack/plan mixup handling). +router.post("/topup-x402", requireAuth, (_req: Request, res: Response): void => { + invalidTier(res); +}); + +/** :tier path param as a plain string ("" when absent/array-shaped). */ +function tierParam(req: Request): string { + const t = (req.params as Record).tier; + return typeof t === "string" ? t : ""; +} + +// Per-request tier dispatch to the pre-built payment gates. +function topupGate(req: Request, res: Response, next: NextFunction): void { + const gate = tierGates.get(tierParam(req)); + if (!gate) { + invalidTier(res); + return; + } + void gate(req, res, next); +} + +// Real Prisma wiring for the grant core: payment record + credit increment in +// ONE transaction; Purchase.stripeId's unique constraint enforces idempotency. +const grantDeps: TopupGrantDeps = { + findPurchase: async (dedupeId) => + prisma.purchase.findUnique({ + where: { stripeId: dedupeId }, + select: { agentId: true, credits: true }, + }), + createPurchaseAndCredit: async ({ agentId, dedupeId, credits, amountCents }) => { + const [, updated] = await prisma.$transaction([ + prisma.purchase.create({ + data: { agentId, stripeId: dedupeId, credits, amountCents, status: "completed" }, + }), + prisma.agent.update({ + where: { id: agentId }, + data: { credits: { increment: credits } }, + }), + ]); + return { balance: updated.credits }; + }, +}; + +// POST /v1/billing/topup-x402/:tier — auth first (WHO), then the x402 gate +// (PAYMENT), then the grant. Order matters: requireAuth must run before the +// gate so a paid request is always attributed to a verified account. +router.post( + "/topup-x402/:tier", + requireAuth, + topupGate, + async (req: Request, res: Response): Promise => { + const r = req as TopupRequest; + const agent = r.agent; + const tier = TOPUP_TIERS.find((t) => t.id === tierParam(req)); + if (!agent || !tier) { + // requireAuth + topupGate guarantee both — reaching here is a wiring bug. + res.status(500).json({ ok: false, error: "internal_error", message: "Top-up context missing after payment gate.", request_id: reqId() }); + return; + } + + // NEVER grant without a settled payment on THIS request. x402Paid is set by + // the middleware only after facilitator settle success===true; if the gate + // was skipped (no WALLET_ADDRESS → Stripe-only mode) we refuse, fail-closed. + if (r.x402Paid !== true) { + res.status(503).json({ + ok: false, + error: "x402_not_configured", + message: "USDC top-ups are unavailable right now (payment gate inactive). Use card checkout instead: POST /v1/billing/checkout.", + request_id: reqId(), + }); + return; + } + + // Allow-list guard (webhook parity): only startup-computed amounts may grant. + if (!ALLOWED_TOPUP_CREDITS.has(tier.credits) || tier.credits <= 0) { + console.error(`[topup-x402] REJECTED grant: credits=${tier.credits} not in allowed set (agent ${agent.id})`); + sendAdminAlert("⚠️ x402 top-up credit mismatch", `A top-up grant requested an out-of-range credit amount and was NOT credited.\nagent=${agent.id} tier=$${tier.amountCents / 100} credits=${tier.credits}`).catch(() => {}); + res.status(500).json({ ok: false, error: "internal_error", message: "Top-up misconfigured — you were NOT credited. Support has been alerted.", request_id: reqId() }); + return; + } + + // Idempotency key: on-chain tx hash, else the EIP-3009 nonce (unique per + // authorization, consumed at settlement). A key-less settle (not expected + // from CDP) still credits — exactly once for this request — under a random + // key, with an admin alert for reconciliation. + const settlement = r.x402Settlement; + const txHash = settlement?.transaction && settlement.transaction.length > 0 ? settlement.transaction : null; + const paymentHeader = (req.headers["payment-signature"] ?? req.headers["x-payment"]) as string | undefined; + const nonce = paymentHeader ? extractNonce(paymentHeader) : null; + const settlementKey = txHash ?? (nonce ? `nonce:${nonce}` : `unkeyed:${randomUUID()}`); + if (!txHash && !nonce) { + sendAdminAlert("⚠️ x402 top-up settled without a dedupe key", `Settle succeeded but returned no transaction hash and the payment carried no nonce.\nagent=${agent.id} tier=$${tier.amountCents / 100} key=${settlementKey}`).catch(() => {}); + } + const dedupeId = `x402:${settlementKey}`; + + const result = await grantTopupCredits(grantDeps, agent.id, tier, dedupeId); + + if (result.status === "already_credited") { + res.json({ + ok: true, + already_credited: true, + message: "This x402 settlement was already credited to your account — no double-credit.", + credits_added: 0, + tx: txHash, + request_id: reqId(), + }); + return; + } + + if (result.status === "conflict") { + // Same settlement id on a different account — cannot legitimately happen + // (on-chain nonce consumption blocks replays). Alert loudly, credit nothing. + console.error(`[topup-x402] CONFLICT: settlement ${dedupeId} already credited to a different agent (caller ${agent.id})`); + sendAdminAlert("🚨 x402 top-up settlement conflict", `Settlement ${dedupeId} is already credited to a DIFFERENT account than the caller.\ncaller=${agent.id} tx=${txHash ?? "-"} — investigate immediately.`).catch(() => {}); + res.status(409).json({ ok: false, error: "settlement_conflict", message: "This payment is already associated with another account. Contact support.", request_id: reqId() }); + return; + } + + if (result.status === "failed") { + // Money moved on-chain but crediting failed — loudest possible alert; + // tell the agent NOT to pay again. + console.error(`[topup-x402] CRITICAL: settled but NOT credited — agent=${agent.id} tx=${txHash ?? "-"} dedupe=${dedupeId}: ${result.reason}`); + sendAdminAlert( + "🚨 x402 top-up SETTLED but NOT credited", + `A USDC top-up settled on-chain but the credit grant failed — reconcile manually.\n\nAgent: ${agent.id} (${agent.email})\nTier: $${tier.amountCents / 100} → ${tier.credits.toLocaleString()} credits\nTx: ${txHash ?? "-"}\nDedupe: ${dedupeId}\nError: ${result.reason}`, + ).catch(() => {}); + res.status(500).json({ + ok: false, + error: "credit_grant_failed", + message: `Your payment settled (tx ${txHash ?? "recorded"}) but crediting hit an error. Do NOT pay again — support has been alerted and your credits will be applied.`, + tx: txHash, + request_id: reqId(), + }); + return; + } + + // Granted. Non-fatal extras: attribute the middleware's X402Payment row to + // this account, fire the payment webhook, send confirmations. + if (txHash) { + prisma.x402Payment + .updateMany({ where: { txHash, toolName: topupToolName(tier.id), agentId: null }, data: { agentId: agent.id } }) + .catch(() => {}); + } + fireWebhookEvent("payment.received", agent.id, { + type: "x402_topup", + credits_added: result.creditsAdded, + amount_usd: (tier.amountCents / 100).toFixed(2), + tx: txHash, + network: settlement?.network ?? null, + }).catch(() => {}); + if (agent.email) { + sendPurchaseConfirmation(agent.email, result.creditsAdded, `USDC Top-Up ($${tier.amountCents / 100})`, result.balance).catch(() => {}); + } + sendAdminAlert( + `💰 New Arch Tools sale — $${(tier.amountCents / 100).toFixed(2)} (USDC x402 top-up)`, + `USDC top-up settled and credited!\n\nCustomer: ${agent.email || agent.id}\nTier: $${tier.amountCents / 100}\nCredits: ${result.creditsAdded.toLocaleString()}\nTx: ${txHash ?? "-"}\nNetwork: ${settlement?.network ?? "-"}\nPayer: ${settlement?.payer ?? "-"}`, + ).catch(() => {}); + console.log(`[topup-x402] +${result.creditsAdded} credits to agent ${agent.id} ($${tier.amountCents / 100} USDC, tx ${txHash ?? "-"})`); + + res.json({ + ok: true, + credits_added: result.creditsAdded, + balance: result.balance, + amount_usd: tier.amountCents / 100, + usd_per_credit: Number((tier.amountCents / 100 / tier.credits).toFixed(6)), + tx: txHash, + network: settlement?.network ?? null, + payer: settlement?.payer ?? null, + request_id: reqId(), + }); + }, +); + +export default router; diff --git a/api/tests/x402-topup.test.mjs b/api/tests/x402-topup.test.mjs new file mode 100644 index 00000000..d2cf5b99 --- /dev/null +++ b/api/tests/x402-topup.test.mjs @@ -0,0 +1,271 @@ +/** + * x402 USDC → credits top-up — unit fixtures (GROWTH_50 #8). + * + * Covers the money-critical units without a DB or facilitator: + * - pack-equivalent tier math (CREDIT_PACKS is the rate authority; numbers + * are PINNED so a pack price change fails loud here, mirroring the + * intent-funnel pack pin — update deliberately, never silently) + * - buildPaymentRequired/V2 resourceUrl override (and default unchanged) + * - x402Middleware options: requirePayment (API key must NOT bypass the 402 + * on top-up routes), price override, and unchanged default tool behavior + * - grantTopupCredits idempotency: grant / replay / race / conflict / failure + * + * Requires a build first (imports the compiled module): + * cd api && npm run build && node tests/x402-topup.test.mjs + */ +import assert from "assert"; + +process.env.WALLET_ADDRESS = process.env.WALLET_ADDRESS || "0x2583aAc89f58a63D9CCbeDaa5e3BaF2196Aa967e"; +process.env.SOLANA_WALLET_ADDRESS = process.env.SOLANA_WALLET_ADDRESS || "D6ZhtNQ5nT9ZnTHUbqXZsTx5MH2rPFiBBggX4hY1WePM"; + +const { creditsForTopupCents, buildTopupTiers, TOPUP_TIER_CENTS, topupToolName, grantTopupCredits } = + await import("../dist/lib/x402Topup.js"); +const { RECOMMENDABLE_PACKS } = await import("../dist/lib/creditPacks.js"); +const { buildPaymentRequired, buildPaymentRequiredV2, x402Middleware, X402_PRICES } = + await import("../dist/middleware/x402.js"); + +let failures = 0; +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + } catch (e) { + failures++; + console.error(` ✗ ${name}: ${e.message}`); + } +} +async function testAsync(name, fn) { + try { + await fn(); + console.log(` ✓ ${name}`); + } catch (e) { + failures++; + console.error(` ✗ ${name}: ${e.message}`); + } +} + +// billing.ts CREDIT_PACKS shape (amount in cents), sourced from the guarded +// pack mirror lib/creditPacks.ts (intent-funnel pins those sizes; the price +// drift guard pins billing.ts itself). +const PACKS = RECOMMENDABLE_PACKS.map((p) => ({ credits: p.credits, amount: p.priceUsd * 100 })); + +console.log("x402 top-up tier math (pack-equivalent rates):"); + +test("$5 top-up = worst-case starter rate ($0.003/credit or cheaper) — 1667 credits", () => { + const credits = creditsForTopupCents(500, PACKS); + assert.strictEqual(credits, 1667); + // effective USD/credit must be <= 0.003 (the starter pack rate) + assert.ok(5 / credits <= 9 / 3000); +}); + +test("$20 top-up stays on the starter rate (pro pack not affordable at $20) — 6667 credits", () => { + const credits = creditsForTopupCents(2000, PACKS); + assert.strictEqual(credits, 6667); + assert.ok(20 / credits <= 9 / 3000); +}); + +test("$50 top-up earns the pro pack rate ($49 pack eligible) — 25511 credits", () => { + const credits = creditsForTopupCents(5000, PACKS); + assert.strictEqual(credits, 25511); + assert.ok(50 / credits <= 49 / 25000 + 1e-9); // pro rate or cheaper (ceil ≤ 1 credit) +}); + +test("every tier rate sits between the best pack rate and the starter rate (± 1-credit ceil)", () => { + const bestRate = Math.min(...PACKS.map((p) => p.amount / p.credits)); // cents/credit + for (const t of buildTopupTiers(PACKS)) { + const rate = t.amountCents / t.credits; + assert.ok(rate <= 900 / 3000 + 1e-9, `${t.id}: worse than starter (${rate})`); + assert.ok(t.amountCents / (t.credits - 1) >= bestRate, `${t.id}: undercuts the best pack by more than the 1-credit rounding`); + } +}); + +test("below every pack price → worst pack rate, still ceil'd in the buyer's favor", () => { + assert.strictEqual(creditsForTopupCents(100, PACKS), Math.ceil((100 * 3000) / 900)); // 334 +}); + +test("degenerate inputs grant ZERO credits (0 / negative / NaN / empty or invalid packs)", () => { + assert.strictEqual(creditsForTopupCents(0, PACKS), 0); + assert.strictEqual(creditsForTopupCents(-500, PACKS), 0); + assert.strictEqual(creditsForTopupCents(NaN, PACKS), 0); + assert.strictEqual(creditsForTopupCents(500, []), 0); + assert.strictEqual(creditsForTopupCents(500, [{ credits: 0, amount: 900 }]), 0); + assert.strictEqual(creditsForTopupCents(500, [{ credits: 3000, amount: 0 }]), 0); +}); + +test("buildTopupTiers: pinned catalog — ids 5/20/50, x402 price strings, credits", () => { + const tiers = buildTopupTiers(PACKS); + assert.deepStrictEqual(tiers.map((t) => t.id), ["5", "20", "50"]); + assert.deepStrictEqual(tiers.map((t) => t.usd), ["5.000", "20.000", "50.000"]); + assert.deepStrictEqual(tiers.map((t) => t.amountCents), [...TOPUP_TIER_CENTS]); + assert.deepStrictEqual(tiers.map((t) => t.credits), [1667, 6667, 25511]); +}); + +test("top-up tool names never collide with X402_PRICES (drift guard stays untouched)", () => { + for (const cents of TOPUP_TIER_CENTS) { + const name = topupToolName(String(cents / 100)); + assert.match(name, /^credits-topup-\d+$/); + assert.strictEqual(X402_PRICES[name], undefined); + } +}); + +console.log("\nbuildPaymentRequired resourceUrl override:"); + +const SITE = process.env.PUBLIC_SITE_URL ?? "https://archtools.dev"; + +test("default resource unchanged: /v1/tools/", () => { + const body = buildPaymentRequired("generate-hash", "0.010"); + assert.strictEqual(body.resource.url, `${SITE}/v1/tools/generate-hash`); + for (const a of body.accepts) assert.strictEqual(a.resource, `${SITE}/v1/tools/generate-hash`); +}); + +test("override: every accepts[] entry + resource carry the real top-up endpoint and the tier amount", () => { + const url = `${SITE}/v1/billing/topup-x402/20`; + const body = buildPaymentRequired("credits-topup-20", "20.000", url); + assert.strictEqual(body.resource.url, url); + assert.ok(body.accepts.length > 0); + for (const a of body.accepts) { + assert.strictEqual(a.resource, url); + if (a.extra?.name === "USD Coin" || a.extra?.name === "Tether USD") { + assert.strictEqual(a.maxAmountRequired, "20000000"); // $20 in 6-decimal atomic units + } + } +}); + +test("buildPaymentRequiredV2 override: v2 wire body carries the endpoint + amount", () => { + const url = `${SITE}/v1/billing/topup-x402/5`; + const v2 = buildPaymentRequiredV2("credits-topup-5", "5.000", url); + assert.strictEqual(v2.x402Version, 2); + assert.strictEqual(v2.resource.url, url); + assert.ok(v2.accepts.some((a) => a.amount === "5000000")); +}); + +console.log("\nx402Middleware options (fake req/res — no facilitator calls):"); + +function fakeRes() { + return { + statusCode: 200, + headers: {}, + body: undefined, + status(c) { this.statusCode = c; return this; }, + header(k, v) { this.headers[String(k).toLowerCase()] = v; return this; }, + setHeader(k, v) { this.headers[String(k).toLowerCase()] = v; }, + json(b) { this.body = b; return this; }, + once() {}, + }; +} + +await testAsync("requirePayment: an API key does NOT bypass — 402 challenge at the tier price + real endpoint", async () => { + const gate = x402Middleware("credits-topup-20", { + price: "20.000", + requirePayment: true, + resourceUrl: `${SITE}/v1/billing/topup-x402/20`, + }); + const res = fakeRes(); + let nextCalled = false; + await gate({ headers: { authorization: "Bearer at_test_key" } }, res, () => { nextCalled = true; }); + assert.strictEqual(nextCalled, false, "must not fall through to the handler"); + assert.strictEqual(res.statusCode, 402); + assert.ok(res.headers["payment-required"], "PAYMENT-REQUIRED header missing"); + const challenge = JSON.parse(Buffer.from(res.headers["payment-required"], "base64").toString("utf-8")); + assert.strictEqual(challenge.resource.url, `${SITE}/v1/billing/topup-x402/20`); + assert.ok(challenge.accepts.some((a) => a.amount === "20000000")); +}); + +await testAsync("default tool behavior unchanged: API credential still bypasses to next()", async () => { + const gate = x402Middleware("generate-hash"); + const res = fakeRes(); + let nextCalled = false; + await gate({ headers: { authorization: "Bearer at_test_key" } }, res, () => { nextCalled = true; }); + assert.strictEqual(nextCalled, true); + assert.strictEqual(res.statusCode, 200); +}); + +await testAsync("no credential + no payment: tool 402 still priced from X402_PRICES (override never leaks)", async () => { + const gate = x402Middleware("generate-hash"); + const res = fakeRes(); + await gate({ headers: {} }, res, () => {}); + assert.strictEqual(res.statusCode, 402); + const challenge = JSON.parse(Buffer.from(res.headers["payment-required"], "base64").toString("utf-8")); + assert.ok(challenge.accepts.some((a) => a.amount === "10000")); // $0.010 +}); + +console.log("\ngrantTopupCredits idempotency (injected fake store):"); + +const TIER = { credits: 6667, amountCents: 2000 }; + +function fakeStore() { + const purchases = new Map(); // dedupeId -> { agentId, credits } + const balances = new Map(); // agentId -> credits + return { + purchases, + balances, + deps: { + findPurchase: async (id) => purchases.get(id) ?? null, + createPurchaseAndCredit: async ({ agentId, dedupeId, credits }) => { + if (purchases.has(dedupeId)) throw new Error("unique constraint (P2002)"); + purchases.set(dedupeId, { agentId, credits }); + balances.set(agentId, (balances.get(agentId) ?? 0) + credits); + return { balance: balances.get(agentId) }; + }, + }, + }; +} + +await testAsync("fresh settlement grants exactly the tier credits", async () => { + const s = fakeStore(); + const r = await grantTopupCredits(s.deps, "agent-1", TIER, "x402:0xtx1"); + assert.deepStrictEqual(r, { status: "granted", creditsAdded: 6667, balance: 6667 }); +}); + +await testAsync("replaying the SAME settlement never double-credits (already_credited)", async () => { + const s = fakeStore(); + await grantTopupCredits(s.deps, "agent-1", TIER, "x402:0xtx1"); + const r = await grantTopupCredits(s.deps, "agent-1", TIER, "x402:0xtx1"); + assert.strictEqual(r.status, "already_credited"); + assert.strictEqual(s.balances.get("agent-1"), 6667); // unchanged +}); + +await testAsync("race: create loses the unique insert → idempotent already_credited, no double-credit", async () => { + const s = fakeStore(); + let first = true; + const racingDeps = { + findPurchase: async (id) => (first ? null : s.deps.findPurchase(id)), + createPurchaseAndCredit: async (args) => { + if (first) { + // Concurrent request wins the insert between our pre-check and create. + first = false; + await s.deps.createPurchaseAndCredit(args); + throw new Error("unique constraint (P2002)"); + } + return s.deps.createPurchaseAndCredit(args); + }, + }; + const r = await grantTopupCredits(racingDeps, "agent-1", TIER, "x402:0xtx1"); + assert.strictEqual(r.status, "already_credited"); + assert.strictEqual(s.balances.get("agent-1"), 6667); +}); + +await testAsync("same settlement id on a DIFFERENT agent → conflict, credits nothing", async () => { + const s = fakeStore(); + await grantTopupCredits(s.deps, "agent-1", TIER, "x402:0xtx1"); + const r = await grantTopupCredits(s.deps, "agent-2", TIER, "x402:0xtx1"); + assert.strictEqual(r.status, "conflict"); + assert.strictEqual(s.balances.get("agent-2"), undefined); +}); + +await testAsync("store failure (not a duplicate) → failed with the reason, nothing credited", async () => { + const s = fakeStore(); + const brokenDeps = { + findPurchase: async () => null, + createPurchaseAndCredit: async () => { throw new Error("db down"); }, + }; + const r = await grantTopupCredits(brokenDeps, "agent-1", TIER, "x402:0xtx1"); + assert.deepStrictEqual(r, { status: "failed", reason: "db down" }); + assert.strictEqual(s.balances.get("agent-1"), undefined); +}); + +if (failures) { + console.error(`\n${failures} failure(s)`); + process.exit(1); +} +console.log("\nAll x402 top-up tests passed.");