feat(spend): bearer API keys for credits — one signature, then any OpenAI client#2
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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'sbase_url+api_keyin any vanilla OpenAI SDK — no wallet, no per-request signatures, no client shim.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./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.docs/examples/api-key-spend.mdwith 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-detectsk-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