feat(spend): one-way payment-channel spend rail + OpenAI gateway#12
Merged
Conversation
Replace the bearer-key spend model with a one-way payment channel so an
operator can never bill more than the consumer signed — over-billing is
impossible by construction, not merely detectable.
- Contract: SpendKeyAuth -> SpendPermit{lotId,sessionKey,maxTokens,expiry}
(holder delegates a session key with ONE signature) + SpendVoucher
{lotId,sessionKey,servedCumulative} (session key acks cumulative served).
settleSpend verifies BOTH the holder permit (binds to the lot's current
holder) and a session-signed voucher, settling only up to what the session
key acknowledged. Cumulative + monotone, permissionless, revocable.
- Operator: spend.rs serves request N+1 only once the voucher acks everything
through N (exposure bounded to one request) and settles the latest voucher.
New /v1/spend/ack records a trailing voucher with no serve, so the LAST
request of a channel is settleable too (closes a serve-for-free gap).
- Gateway: new `surplus-gateway` bin holds the session key, signs vouchers
invisibly, and exposes a vanilla OpenAI surface (base_url + dummy api_key) —
run locally for zero trust. Sends the trailing ack after each response.
- App: ApiKeyMint delegates a fresh session key and shows the one-time gateway
run command; SPEND_PERMIT_TYPES replaces the old key-hash typed data.
- Cross-stack EIP-712 parity pinned (Rust digest test == contract pins).
- e2e rewritten to the channel model and proven end-to-end on anvil: forged
(non-session) voucher refused; lot debits exactly by served tokens.
- Design memo: docs/specs/spend-rail.md.
…nism # Conflicts: # operator/src/spend.rs
The branch merge silently dropped main's CI setup (forge soldeer install in the contracts and parity jobs, protoc install in the zk job). This work does not touch CI; restore main's workflow verbatim so CI is unchanged by this PR.
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 & why
Replaces the bearer-key spend model with a one-way payment channel so an operator can never bill more than the consumer signed — over-billing is impossible by construction, not merely detectable. The consumer keeps a vanilla OpenAI experience (base_url + api_key); a local gateway signs the crypto invisibly.
Design memo:
docs/specs/spend-rail.md.How it works
SpendPermit{lotId, sessionKey, maxTokens, expiry}, delegating a fresh ephemeral session key to draw down one lot (capped, expiring, revocable).SpendVoucher{lotId, sessionKey, servedCumulative}acknowledging cumulative tokens served. The operator serves request N+1 only once the voucher acks everything through N, so its exposure is bounded to a single unacknowledged request.settleSpend(permit, holderSig, servedCumulative, voucherSig)verifies both the holder permit (bound to the lot's current holder, so a resold lot's channels die at transfer) and a session-signed voucher, settling only up to what the session key acknowledged. Cumulative + monotone → flush at any cadence, replays no-op, keeper-friendly.Changes
SurplusSettlement.sol):SpendKeyAuth→SpendPermit/SpendVoucher;settleSpendrewrite;spendPermitDigest/spendVoucherDigestviews;spendSettled/spendRevokedkeyed by permit digest;revokeSpendKey. (Also: renamed a localbookFeeto clear a shadowing warning against main'sbookFee().)spend.rs): channel-model serve + settle; newPOST /v1/spend/ackrecords a trailing voucher with no serve, so the last request of a channel is settleable too (closes a serve-for-free gap). Metrics from main preserved.surplus-gatewaybin): holds the session key, signs vouchers, exposes a vanilla OpenAI surface; sends the trailing ack after each response. Run locally for zero trust.Portfolio.tsx):ApiKeyMintdelegates a fresh session key and shows the one-time gateway run command;SPEND_PERMIT_TYPESreplaces the old key-hash typed data.spend-e2e.mjs/spend-live.mjs) rewritten to the channel model.Verification
forge test: 100 passed (incl. the Solvency invariant exercising the channel-modelspend(); a temporary isolation test confirmedspendsSettled == 1).spend-e2e.shagainst anvil: proven end-to-end — one signature → session key, two vanilla OpenAI calls served, a forged (non-session) voucher refused, both requests settled via the trailing ack, lot debited exactly by served tokens on-chain.chain+meshfeatures build; operator tests pass; app typechecks + builds; router-bridge 26 tests pass.Out of scope / follow-ups (documented in the memo)
TEE-attested inference for authenticity trust; full Tor deploy; per-holder session limits to harden the residual one-request griefing bound.