Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
141 changes: 141 additions & 0 deletions api/src/lib/x402Topup.ts
Original file line number Diff line number Diff line change
@@ -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<TopupTier, "credits" | "amountCents">,
dedupeId: string,
): Promise<TopupGrantResult> {
// 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) };
}
}
67 changes: 56 additions & 11 deletions api/src/middleware/x402.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,15 +201,17 @@ 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"
const baseNetwork = network === "base-sepolia" ? "base-sepolia" : "base";
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/<name> default.
const resource = resourceUrl ?? `${process.env.PUBLIC_SITE_URL ?? "https://archtools.dev"}/v1/tools/${toolName}`;

const evmWallet = config.x402.walletAddress;
const accepts: object[] = [];
Expand Down Expand Up @@ -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));
}

/**
Expand Down Expand Up @@ -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/<toolName>). */
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<void> => {
const requestStartMs = Date.now();

Expand Down Expand Up @@ -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;
Expand All @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion api/src/routes/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? "" },
Expand Down
Loading
Loading