Skip to content

[HOLD] x402 USDC credits top-up - #112

Open
Deesmo wants to merge 1 commit into
mainfrom
feat/x402-usdc-topup
Open

[HOLD] x402 USDC credits top-up#112
Deesmo wants to merge 1 commit into
mainfrom
feat/x402-usdc-topup

Conversation

@Deesmo

@Deesmo Deesmo commented Jul 31, 2026

Copy link
Copy Markdown
Owner

⛔ DO NOT MERGE — build-only PR (GROWTH_50 item #8), held for conductor review

USDC → credits top-up: the crypto crowd is the only segment that has ever paid, and until now they had no card-free path to credits. This gives them one, on the exact x402 rails that already move money in production.

What it does

POST /v1/billing/topup-x402/:tier — tiers 5 / 20 / 50 ($5, $20, $50):

  1. Agent calls with Authorization: Bearer <api_key> and no payment header → 402 challenge priced at the tier (new requirePayment middleware option: on this route an API key does not bypass the gate — the payment IS the point; auth only establishes whose credits).
  2. Agent signs and retries with PAYMENT-SIGNATURE (or legacy X-PAYMENT) + the same API key. requireAuth identifies the account, then the existing x402 middleware verifies + settles via the CDP facilitator — zero hand-rolled payment verification.
  3. On settle success===true the handler grants credits atomically with a Purchase record, idempotent on the settlement id (Purchase.stripeId = "x402:<tx-or-nonce>", unique) — the same dedupe discipline as the Stripe webhook. Replays/races can never double-credit (unit-tested).

GET /v1/billing/topup-x402 — public tier catalog + how-to.

Rates — CREDIT_PACKS is the authority (never our own numbers)

Derived at startup from routes/billing.ts CREDIT_PACKS (now exported), integer math, ceil'd ≤1 credit in the buyer's favor:

Tier Credits $/credit Rule
$5 1,667 0.0030 worst-case starter rate ($0.003/credit or cheaper — spec floor)
$20 6,667 0.0030 starter rate (pro pack not affordable at $20)
$50 25,511 0.00196 pro-pack rate ($49 pack eligible) — never undercuts the best pack

Tier credits are pinned in tests so a pack price change fails loud (mirrors the intent-funnel pack pin).

Money-safety properties

  • Fail-closed: no grant without req.x402Paid === true set by THIS request's settle (wallet-unconfigured/Stripe-only mode → 503, never a free grant).
  • Allow-list guard on grantable amounts (webhook ALLOWED_ONETIME_CREDITS parity).
  • Settled-but-uncredited is impossible to lose silently: loudest admin alert + "do NOT pay again" response carrying the tx hash.
  • Same-settlement-different-account → 409 + alert, credits nothing.
  • Webhook/email/admin-alert parity with card purchases.

What deliberately did NOT change

  • X402_PRICES and every advertised price surface (openapi.json, tools.json, llms*.txt, discovery.ts, mcp/server.ts) — tier prices ride in as middleware options, so scripts/check-price-drift.mjs needs no changes and passes unchanged (verified: OK — 89 charged tools, 88 x402 prices, 3 packs + 4 subscriptions).
  • Tool-route x402 behavior — options are opt-in; defaults are byte-identical (regression-tested: API-credential bypass, X402_PRICES pricing, default resource URLs).
  • /webhooks alias — the new router mounts on /v1/billing only.

Files

  • api/src/middleware/x402.ts — optional X402MiddlewareOptions (price, resourceUrl, requirePayment) + req.x402Settlement after successful settle + real agent attribution in the ApiRequest log when auth ran first
  • api/src/lib/x402Topup.ts — new: pure tier math + injectable idempotent grant core
  • api/src/routes/topupX402.ts — new: catalog + requireAuth → x402 gate → grant
  • api/src/routes/billing.tsexport const CREDIT_PACKS (+2 comment lines, nothing else)
  • api/src/index.ts — mount
  • api/tests/x402-topup.test.mjs — new: 19 fixtures (tier math, challenge overrides, requirePayment, grant idempotency/races) — modeled on the PR fix(x402): pass V1 payment payloads through to CDP unchanged (payment_invalid on all real x402 payments) #62 fixture pattern
  • api/package.json — test added to the npm test chain

Verification (all run locally in the worktree)

  • npx tsc --noEmit → clean
  • node tests/x402-topup.test.mjs19/19 pass
  • x402-v1-passthrough / x402-sell-copy / x402-v2 / intent-funnel / critical-regressions / signup-firstcall → all pass
  • node scripts/check-price-drift.mjs → OK, unchanged
  • node api/scripts/security-gate.cjs → passed
  • No dist/node_modules churn committed (restored before commit)

Held-back items for the conductor (before/at merge)

  • Live end-to-end paid test on Base with a real wallet (facilitator settle → credits land) — not executable from CI.
  • Optional follow-up: advertise the top-up in openapi.json / docs (would then require a check-price-drift.mjs update in that same PR).

🤖 Generated with Claude Code


Note

High Risk
New money path that mutates balances and Purchase rows; mitigations (fail-closed grant, allow-list, unique dedupe, alerts) are strong but this is still payment/credit infrastructure worth careful review and a live settle test before merge.

Overview
Adds a USDC → credits path for authenticated agents: GET/POST /v1/billing/topup-x402/:tier ($5 / $20 / $50) mounted on /v1/billing only (not the /webhooks billing alias).

Flow is auth first (requireAuth), then existing x402 middleware with new options: requirePayment (API keys no longer bypass the 402), price, and resourceUrl so tier amounts never enter X402_PRICES. After settle success===true, credits grant in one Prisma transaction with a Purchase row; dedupe uses Purchase.stripeId = "x402:<tx-or-nonce>" (same pattern as Stripe). req.x402Settlement is set on successful settle for the grant handler; ApiRequest logging can attribute to req.agent when auth ran before the gate.

lib/x402Topup.ts holds pack-equivalent tier math from exported CREDIT_PACKS and injectable grantTopupCredits. tests/x402-topup.test.mjs pins tier credits and idempotency; package.json test chain includes it.

Default tool x402 behavior stays opt-in unchanged when middleware options are omitted.

Reviewed by Cursor Bugbot for commit b1d8a10. Configure here.

…not merge]

POST /v1/billing/topup-x402/:tier ($5/$20/$50): an API-key-authenticated
agent pays the 402 in USDC through the EXISTING x402 middleware and gets
credits at a pack-equivalent rate (CREDIT_PACKS is the rate authority;
never cheaper than the best pack, never worse than the starter pack's
$0.003/credit).

- middleware/x402.ts: optional X402MiddlewareOptions (price override,
  resourceUrl override, requirePayment) + req.x402Settlement facts after
  a success===true settle. Tool-route behavior unchanged (regression-tested).
- lib/x402Topup.ts: pure tier math (integer, ceil in buyer's favor) +
  injectable idempotent grant core (dedupe on settlement tx/nonce via
  Purchase.stripeId unique - same discipline as the Stripe webhook;
  grant + payment record in ONE transaction).
- routes/topupX402.ts: requireAuth -> per-tier x402 gate -> grant.
  Fail-closed when the gate is inactive; webhook/email/admin-alert parity
  with card purchases; loud alert if a settle ever lands uncredited.
- X402_PRICES and all advertised price surfaces untouched; price drift
  guard passes unchanged.
- tests: 19 fixtures in api/tests/x402-topup.test.mjs (tier math pinned,
  challenge overrides, requirePayment, grant idempotency/races).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is ON. A cloud agent has been kicked off to fix the reported issue.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit b1d8a10. Configure here.

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}`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Random dedupe key allows double credit

Medium Severity

When settlement returns no transaction hash and extractNonce finds no nonce, each top-up request builds a new unkeyed: dedupe id via randomUUID(). Repeat successful settles on the same payment (or retries that pass the gate again) can insert distinct Purchase.stripeId rows and grant credits more than once, contrary to settlement-based idempotency.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b1d8a10. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants