Skip to content

feat(spend): bearer API keys for credits — one signature, then any OpenAI client#2

Merged
drewstone merged 4 commits into
mainfrom
feat/spend-keys
Jun 12, 2026
Merged

feat(spend): bearer API keys for credits — one signature, then any OpenAI client#2
drewstone merged 4 commits into
mainfrom
feat/spend-keys

Conversation

@drewstone

Copy link
Copy Markdown
Contributor

What

The consumption rail (audit P1): a credit lot becomes a plain bearer API key. The holder signs one EIP-712 SpendKeyAuth { lotId, keyHash, maxTokens, expiry }; after that it's base_url + api_key in any vanilla OpenAI SDK — no wallet, no per-request signatures, no client shim.

  • Contract: settleSpend (cumulative, monotone, idempotent at any flush cadence) + revokeSpendKey. The issuer settles served amounts by presenting the holder's own authorization. 12 new Foundry tests + a cross-stack digest pin.
  • Operator: real /v1/chat/completions + /v1/models — bearer → keccak lookup → serve via the inference backend → meter → journaled cumulative counter (restart-safe) → background on-chain flush. OpenAI-style error envelopes; wrong key → clean 401.
  • App: Portfolio "Create API key" per lot — secret generated client-side (only its hash reaches the operator), key shown once with a drop-in snippet. The per-message trustless chat stays underneath.
  • Docs: docs/examples/api-key-spend.md with the trust model stated plainly.

Why no per-request signatures or receipts

They were protecting against an attack the system never prevented: metering is issuer-asserted (audit M2), so holder-countersigned receipts were signature theater. The single irreducible signature is the one that lets the issuer debit the lot unattended — without it, a holder could consume the lot via API and then claim a default refund + penalty on the untouched on-chain balance. Protections that remain (each test-pinned): the cap, on-chain revocation, binding to the current holder (resale kills keys), both expiries, the redemption lock, and the issuer's collateral + slashable bond.

Routing

The key embeds lotId ‖ issuer ‖ secret, so anything that can read it can route it: the Portfolio/docs hand the dev the issuer URL directly (no router required), and a future tangle-router middleware can prefix-detect sk-surplus- and proxy — outside the platform billing surface by construction.

Proof

scripts/spend-e2e.sh (anvil, re-run green after merging main): mint lot → one signature → two vanilla OpenAI calls (137+137 tokens, zero request-path crypto) → cumulative on-chain debit, lot 1,000,000 → 999,726, spendSettled = 274.

77 forge + 47 Rust green, merged against main's v3 receipt hardening.

Deploy note: needs the next contract redeploy (rides along with v3); fleet + app ship after merge.

🤖 Generated with Claude Code

…r API key

The consumption rail's on-chain half. The holder signs ONE EIP-712
SpendKeyAuth { lotId, keyHash, maxTokens, expiry }; the issuer then serves
plain bearer-authenticated requests off-chain and settles what it served by
presenting the authorization itself — no per-request signatures, no receipts.

Why no receipts: metering was already issuer-asserted (audit M2) — the holder
countersigning a number the issuer made up was signature theater. The real
protections, now explicit: the cap (maxTokens), on-chain revocation
(revokeSpendKey — immediate; unsettled service is the issuer's loss), expiry
on both auth and lot (an issuer that serves without flushing past lot expiry
eats it — reclaim wins), binding to the CURRENT holder (reselling the lot
kills every outstanding key), the redemption lock (locked tokens are not
spendable), and the issuer's collateral + slashable bond underneath it all.

settleSpend is cumulative and monotone — idempotent at any flush cadence,
replay of an old total reverts. Full draw deletes the lot and releases the
issuer's full liability, mirroring _settleRedemption's accounting.

12 new tests (cap, revoke, resale-invalidation, both expiries, lock interplay,
cumulative replay, forged auth, full draw, pro-rata math) + a cross-stack
digest pin mirrored by the operator's Rust side. 77 forge green.
…le API keys

POST /v1/spend-keys registers a holder-signed SpendKeyAuth (lot ownership and
issuer verified on-chain; the raw key never arrives — only its keccak). The
dev then uses ANY OpenAI SDK: POST /v1/chat/completions with Authorization:
Bearer sk-surplus-… serves through the venue's InferenceBackend, meters the
lot's token kind, and accumulates a journaled cumulative served counter
(restart-safe — served quota is money-shaped state). GET /v1/models for SDK
compat. OpenAI-style error envelopes so clients surface messages verbatim.

A background flush presents settleSpend(auth, servedCumulative, holderSig)
on-chain at the venue's flush cadence; cumulative semantics make retries free
and failures self-requeue (served > settled persists until a flush lands).

The hand-rolled spend digest is pinned byte-for-byte against the contract's
spendAuthDigest (tests::digest_matches_contract_pin ↔ Spend.t.sol pin).
/v1/chat/completions costs 10 rate-limit tokens like /redeem.
…l rail

Portfolio: 'Create API key' per lot — the secret is generated client-side,
ONE wallet signature authorizes it, the key is shown once with the issuer's
base_url and a drop-in OpenAI snippet. The per-message chat spend remains as
the trustless 'try it' path beneath it.

scripts/spend-e2e.sh proves the whole loop on anvil: settleFills mints a lot;
one signature registers a bearer key; two VANILLA OpenAI-style calls (no
wallet, no shim, wrong key → clean 401) serve 137+137 tokens through a stub
upstream; the flush settles cumulatively on-chain — lot 1,000,000 → 999,726,
spendSettled(digest) == 274. docs/examples/api-key-spend.md is the dev-facing
walkthrough with the trust model stated plainly.
@drewstone drewstone merged commit b099b7d into main Jun 12, 2026
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.

1 participant