beihaili/sats4tokens:用比特币闪电网络支付换取 AI API key
beihaili/sats4tokens
beihaili/sats4tokens 让用户用 Lightning 或 Cashu ecash 付款直接换取 AI API key,无需账户、KYC 或银行卡,额度严格等于付款金额。
Pay sats, get an AI API key. Customers pay with Lightning or Cashu ecash — no account, no KYC, no card — and the key is capped at exactly what they paid. Every payment is credited exactly once, even if the gateway is killed mid-payment.
Built at bitcoin++ Berlin 2026 (payments edition) for a real shop: our AI API relay (310 users, ~12 billion tokens a month), which runs new-api.
Live (mainnet, real sats): key shop https://sats4tokens.bhbtc.xyz/ · console of our EU relay https://btc.bhbtc.xyz/
Slides: docs/slides.pdf
Docs: user guide · self-hosting · HTTP API · how it works
Real money, verified. 2026-10-02, mainnet (Coinos mint, priced in USD back then), paid from a phone wallet:
- Key shop: $1 (1161 sat) → API key → a real model call.
- Top-up: $1 (1158 sat) → credited to a relay account → withdrawn from the gateway as one Cashu token → received back in a phone wallet.
No Lightning node on our side at any point.
Why
An AI API relay pools accounts at OpenAI / Anthropic / Google upstream and resells access downstream through one OpenAI-compatible endpoint. The AI companies see the relay, not the user — so a relay is already a privacy layer. Payment breaks it: relays in China take Alipay / WeChat Pay, both need a real-name account, and every API call ends up tied to an ID card.
Open /, pick €1 / €2 / €5 / €10 (amounts and currency are configurable), pay with ⚡ or 🥜, and the checkout page shows an API key with its
endpoint, capped at exactly what you paid. No signup, no email, no password — the key is the account.
- The key is a new-api token of one pool user, with its own hard quota: €1 buys exactly the quota €1 buys in the relay's console. The gateway holds only that user's personal access token, not an admin key.
- Exactly once: the token name comes from the order id; the gateway looks it up before creating, so a crash between "created" and "saved" finds the same key instead of making a second one.
- The order id is 128 random bits and is the receipt: whoever has the
/pay/…link can see the key — and its usage: balance left and the latest calls (time, model, tokens, cost), straight from new-api. - Top up the same key: "Top up this key" on the key page (or
POST /api/buy {"money": 2, "key": "sk-…"}) makes a normal order that adds the money to that key exactly once. The top-up page never shows the key, so anyone can pay it. - Auto top-up from your own wallet (Nostr Wallet Connect): paste an NWC connection on the key page, pick an amount, a threshold and a daily cap. When the key runs low, the gateway sends your wallet one invoice for one top-up. Each invoice is sent at most once, and a lost answer never turns into a second payment.
- Bonus on larger amounts (
KEY_BONUS, ours: €5 +5%, €10 +10%), for new keys and top-ups, Lightning or Cashu. - The shop sells only what the pool user's quota can cover (minus a reserve), so a sold key is never left without credit.
- Both pages list every model the key can call with its price (per 1M tokens in the shop's currency, from new-api's pricing).
- Claude Code works too: the key page has a copy-paste command that points Claude Code at the relay
(
ANTHROPIC_BASE_URL+ANTHROPIC_AUTH_TOKEN). It caps the output tokens because new-api reserves quota formax_tokensup front, and Claude Code's default would need more than a small key holds.
For bots and agents the same flow is three HTTP calls, no account and no browser:
POST /api/buy → pay (POST /api/order/:id/token with a Cashu token, or POST /api/order/:id/invoice and pay the
bolt11) → poll GET /api/order/:id until apiKey appears. Every step is safe to retry: the same token sent again
for the order it paid gets the same 200. A complete script with no dependencies:
examples/agent-buy-key.ts (node examples/agent-buy-key.ts 1 cashuB…); the
endpoints are in docs/api.md.
Users who already have a relay account can also top up through the same checkout (see Top-ups below).
How it works
customer gateway Cashu mint
│ buy a €1 key ──────► order, BTC price locked
│ ◄───────────────────── /pay/:id checkout page
│ │
│ ⚡ pay invoice ───────────┼──── mint quote (bolt11) ─────────────► receives the sats
│ 🥜 or paste token ────────┼──── swap (NUT-03) ───────────────────► blind-signs new proofs
│ │ ◄── "quote PAID" push (NUT-17) ───────
│ │ ──► mint proofs to our seed (NUT-04/13)
│ │ ──► new-api: create the capped key (find-or-create)
│ 🔑 key + endpoint ◄──────┤
operator: one click → whole balance as one Cashu token
- The mint is our Lightning node. For ⚡ the gateway asks the mint for an invoice; when it's paid the mint signs ecash for us. For 🥜 the customer pastes a token and we swap it for proofs of our own.
- Private. The mint signs blinded messages; it can't link the payer to the shop.
- Price. Fiat → BTC is locked when the order is created (CoinGecko → Coinbase → mempool.space fallback, a ≤10-min-old price if all three are down). Sats are rounded up.
Exactly once: write first, then ask the mint
The dangerous moment: the mint signs our outputs, and the gateway dies before saving them. The money exists, but nobody holds it — or worse, a naive retry credits the order twice.
- Every output comes from a seed and a counter (NUT-13), so it is deterministic.
- Before every mint call, the gateway writes the counter range it will use into the order and saves the ledger (write-ahead). Only then does it call the mint.
- After a crash, the order is in
SETTLING. The gateway asks the mint "did you sign these exact outputs?" (NUT-09 restore) and follows one table:
| # | facts | action |
|---|---|---|
| 1 | restore finds our outputs | finish — the mint signed, we only lost the reply |
| 2 | lightning, quote PAID | retry with the same counter → identical outputs, signable only once |
| 3 | lightning, quote ISSUED / UNPAID | conflict → a human looks |
| 4 | cashu, customer's proofs UNSPENT | retry |
| 5 | cashu, proofs PENDING | wait |
| 6 | cashu, proofs SPENT | abort (the token was spent elsewhere) |
npm run crash-demo kills the gateway with kill -9 at both crash points, for both payment paths.
All four cases end the same way: credited once, replaying the token is rejected.
Crediting is idempotent too: a key is find-or-create by order id, and an account top-up is retried with backoff
until new-api confirms it, which credits each order once on its side. A key top-up uses the fact that new-api's
remain_quota + used_quota of a key changes only when quota is added: the gateway writes that total to its ledger
before it adds anything, and after a crash adds only what is still missing.
Polite to public mints
Public mints rate-limit and firewall IPs that poll hard. (We learned this during the hackathon: one mint refused
our server after hours of polling 4 quotes every 2 s; another answered 429 at ~24 quote checks/min.)
- NUT-17 push is the primary signal: one WebSocket, the mint pushes "quote PAID", then a single HTTP check confirms it and we mint.
- HTTP polling is only a safety net with a hard budget: ≤ 1 quote check per 8 s across all orders,
60 s per order while subscribed, open checkout pages first, and backoff (5 s → 60 s) on network errors or
429. - On startup the gateway refuses any mint without NUT-04 (bolt11, sat), NUT-07 and NUT-09.
Cashu NUTs used
NUT-03 swap · NUT-04 mint from Lightning · NUT-06 mint info check · NUT-07 proof states · NUT-09 restore · NUT-13 deterministic secrets · NUT-16 animated QR (camera scan) · NUT-17 WebSocket subscriptions
Run it
Node ≥ 22 (runs TypeScript directly, no build step).
npm install EPAY_KEY=demo-key PORT=8091 npm run gateway # testnut by default EPAY_KEY=demo-key GATEWAY=http://127.0.0.1:8091 npm run cli balance # or: withdraw
| env | meaning |
|---|---|
NEWAPI_URL, NEWAPI_USER_ID, NEWAPI_TOKEN, KEY_BASE_URL |
turn on the key shop: new-api URL, pool user id + its personal access token, endpoint shown with the key (default new-api's ServerAddress + /v1). Key prices use new-api's top-up Price, which must be in FIAT |
POOL_RESERVE, POOL_ALERT |
key shop: quota (in FIAT) never sold from the pool, default 10; warn in the log below this much, default 30 |
MINT_URL |
Cashu mint (default https://testnut.cashu.space) |
FIAT, BTC_PRICE |
order currency (default cny; our EU relay uses eur); fixed price for offline tests |
ADMIN_KEY |
operator page key |
UPSTREAMS_FILE |
turn on the /network page: snapshot from scripts/export-upstreams.ts (optional) |
MODEL_ALIAS_SUFFIX |
regex of alias suffixes (e.g. -alt$): extra routes of one model stay callable but are left out of /api/models and /network (optional) |
EPAY_KEY (required), EPAY_PID |
signing key / merchant id shared with new-api for top-ups (see below). Keep ADMIN_KEY different: this one can sign "paid" callbacks |
DATA_DIR, ORDER_TTL_MIN, CHECKOUT_TAB=ln|cashu, WITHDRAW_TOKEN_OVER_HTTP=1 |
storage, order lifetime, default tab, show withdrawn token on the page (https only) |
Operator — /admin.html#key=ADMIN_KEY: balance, orders, one-click withdraw of the whole balance as
one Cashu token (always written to DATA_DIR/withdrawals/ before the proofs leave the ledger).
Top-ups
The gateway also plugs into new-api's built-in online-payment settings, so existing users get a Bitcoin
button on the top-up page. In new-api → 支付设置: PayAddress = gateway URL, EpayId = EPAY_PID,
EpayKey = EPAY_KEY, PayMethods = [{"name":"Bitcoin","color":"#f7931a","type":"bitcoin"}].
new-api redirects the customer to /submit.php with a signed form; after payment the gateway sends a
signed callback, retried until new-api confirms. scripts/fake-merchant.ts stands in for new-api locally.
Tests
npm test && npm run typecheck # 62 unit tests, offline: signatures, idempotent orders, write-ahead, decideSettle, retry backoff, mint checks, upstream anonymization, model aliases, token retries, key top-ups, NWC (NIP-44 vectors, fake relay + wallet), auto top-up npm run edge-checks # 15 end-to-end checks against testnut (~40 s): bad signatures, underpaid / spent tokens, callback retries, withdraw npm run crash-demo -- lightning after-mint # or: cashu | after-writeahead
Layout
src/server.ts HTTP: key shop (/ , /api/buy), checkout, order API, top-up form (/submit.php), operator API, callback loop
src/keyshop.ts paid key order → capped new-api token (find-or-create by name), usage
src/gateway.ts the engine: seed-backed cashu-ts wallet, write-ahead settle, NUT-09 recovery, NUT-17 push
src/ledger.ts orders, durable JSON ledger, pure decision logic (no network)
src/epay.ts MD5 sign / verify for the top-up form and callback
src/nwc.ts Nostr Wallet Connect client (pay_invoice, NIP-44 v2 / NIP-04)
src/autotopup.ts auto top-up of low keys from the customer's wallet (send-once, daily cap, pause)
src/price.ts fiat → BTC with fallbacks
src/upstreams.ts anonymized upstream network for the /network page (no provider names or hosts)
web/ key shop, mobile checkout page, upstream network page, operator page
examples/ agent-buy-key.ts: buy and pay for a key from a script
deploy/demo/ our live EU relay stack (own new-api + gateway, channels copied from the main relay)
docs/ user guide, self-hosting, HTTP API, internals; slides (PDF) and screenshots (mock orders)
About 4,000 lines for the gateway and pages, 1,400 for tests and scripts. Dependencies: @cashu/cashu-ts, qrcode,
and @noble/curves + @noble/hashes (already part of cashu-ts) for Nostr Wallet Connect.
Limits and next steps
- One mint per gateway. A token from another mint is rejected with a clear message (pay with ⚡ instead). Next: a list of trusted mints, or melting foreign tokens to pay our invoice.
- Overpayment is kept (a pasted token can't be split here; the page shows the exact amount).
- The mint is custodial until you withdraw — so withdraw often, or run your own mint.
- No signed receipt yet. The order id is the receipt; a client can check its Cashu proofs are spent (NUT-07) or keep the Lightning preimage, but nothing binds payment and key cryptographically.
- Next: signed receipts, NUT-18 payment requests (the wallet pays the request, no copy-paste), pay-per-request with Cashu tokens in the API header (NUT-24), automatic melt to the operator's Lightning wallet.
Safety
data/ holds the wallet seed and ecash proofs — bearer money. It is git-ignored; back up seed.hex.
License
MIT, see LICENSE. Vendored browser libraries keep their own licenses (web/vendor/README.md).
来源:GitHub · github.com
