How it works

Deposit USDC, mint a position, call one endpoint with any model you want, already paid for by the deposit's yield. Everything below is the actual deployed state on Base. Addresses link to BaseScan, not a guess.

Contracts (Base mainnet)

Your position is a plain ERC-721, but it's soulbound: it can't be transferred or sold, only held by the wallet that created it. That's deliberate: principal-withdrawal rights and the yield-funded allowance both follow whoever holds this NFT, and a position that could move on its own would let those split apart (a buyer controlling the allowance while the original depositor kept indefinite control over withdrawing the principal). No proxy contracts, no upgradeability: what's deployed is what runs.

YieldFundedPosition (the ERC-721, holds your allowance)0x8d6f96f0AD8A8C69f7596a3784D72a0FD5B1f171
LendingYieldAdapter (deposit/withdraw/harvest)0xee93162570fF5Edd72C49683613ad7608A55Db98
Vault (Steakhouse steakUSDC, ERC-4626)0xbeeF010f9cb27031ad51e3333f9aF9C6B1228183

Functions you'll actually call, beyond the proxy:

  • LendingYieldAdapter.createVault(modelTier, dailySmoothingBps, vaultAddress, harvestIntervalMinutes): mints your position and registers a vault for it, in one call. harvestIntervalMinutes(30 to 360, i.e. 30 minutes to 6 hours) is stored on-chain and changeable later via setHarvestInterval(tokenId, newIntervalMinutes), but the keeper currently runs once daily regardless of this value. See How it's funded below.
  • LendingYieldAdapter.deposit(tokenId, assets, minSharesOut): deposits USDC into the vault on your position's behalf. Requires a prior USDC.approve. minSharesOutis your own slippage floor against the vault's share price moving between when you quote it and when the transaction lands. Pass 0 to skip the check.
  • LendingYieldAdapter.withdrawPrincipal(tokenId): returns your original deposit, independent of anything you've spent on inference. No lock-up, callable anytime, but capped at the vault's real-time liquidity, so a fully-utilized vault may only return part of it immediately (call again later for the rest). Falls back to redeeming your exact remaining shares (rather than reverting) if the vault has lost value since you deposited, so you get the real current worth, not a stuck transaction.
  • YieldFundedPosition.withdrawUnspentYield(tokenId): cashes out harvested-yield credit you haven't spent on inference, as plain USDC.
  • YieldFundedPosition.bumpKeyEpoch(tokenId): invalidates every API key ever issued for this position. See Key revocation below.

How it's funded

Deposit USDC into a real, audited ERC-4626 vault. Your principal stays yours and is withdrawable anytime, subject to the vault's real-time liquidity and vault/protocol risk; the vault's yield funds your inference allowance. Harvests run automatically once a day, or anyone can trigger one early. Harvesting can only ever credit you more, never less, so it's permissionless, anytime, by anyone.

How a request is settled

Usage is reserve-then-settle, not a single debit after the fact. Before calling the inference provider, the proxy reserves a conservative ceiling against your allowance, sized from the request's max_tokens(capped at 4096 regardless of what you ask for). That reservation is what closes the allowance the instant it's made, before any completion comes back. A concurrent request against an already-thin allowance sees the reservation immediately, not a stale "there's room" read. Once the real response comes back, the proxy settles the reservation against the real cost and refunds the unused difference. If a request fails outright, the reservation is refunded immediately, not left to expire.

Key revocation

API keys are tied to your position's current on-chain keyEpoch, not just its tokenId. If you suspect a key leaked, call bumpKeyEpoch(exposed in the app as "Revoke all issued API keys") to invalidate every key ever issued for that position, then sign a new one. Positions are soulbound, so there's no transfer-triggered revocation case to worry about. The position never changes hands in the first place.

1. Get an API key

Your position is an ERC-721. Proving you own it means signing a message with the wallet that holds it. No password, no account, no database row tying a key to an email.

curl -X POST https://your-deployment.example/api/current/keys \
-d '{"tokenId":"0","signature":"0x...","timestamp":1700000000000}'

signature is an EIP-191 personal-sign over the exact string Issue Current API key for token <tokenId> at <timestamp>. timestampis milliseconds since epoch and must be within 5 minutes of the server's clock. Smart-contract wallets (Safe, Coinbase Smart Wallet) work via ERC-1271, not just plain EOAs. The response's apiKeyis a bearer token tied to the position's current key epoch. See Key revocation above for how to invalidate it later.

The key works immediately, but can't spend anything until real yield has been harvested. A fresh deposit has earned ~$0 in the first moments after depositing regardless of harvest timing, so the very first request can return a 429 even though nothing is wrong. Check remainingAllowance(or the app's balance display) before assuming it's broken.

Models

Any model Surplus offers. There's no tier restriction on model choice (the on-chain modelTier a position is minted with no longer gates which models it can request). Pull the live catalog yourself:

curl https://your-deployment.example/api/current/models

Returns {"models": [...]}, fetched live from Surplus, not a hardcoded list. The app's model picker calls this same endpoint. If Surplus is unreachable this falls back to a small known-good list and adds "fallback": true to the response so that degraded state is visible, not silently indistinguishable from the real catalog.

2. Call the proxy

curl https://your-deployment.example/api/current/v1/chat/completions \
-H "Authorization: Bearer current_<tokenId>_<epoch>_<sig>" \
-d '{"model":"gpt-4o","messages":[...]}'

Pin any model from the catalog above, or pass "model":"cheapest" to route to whichever model a small reference table prices lowest (not a live Surplus price comparison, see lib/modelPricing.ts if you're running this yourself). Pass "min_discount_pct":N to only route to a marketplace seller if it beats the reference price by at least N%, otherwise you get a clear error instead of an overpriced response. max_tokensis capped at 4096 regardless of what you request, to bound the reservation sized for the request below. Streaming responses aren't supported. The proxy always waits for the full completion before settling and replying.

Errors you might see

  • 401: invalid, expired, or revoked API key (see Key revocation above).
  • 404: the position doesn't exist.
  • 429: no spendable allowance right now. The error message distinguishes why: never harvested any yield yet (likely a fresh deposit, not a bug), fully spent what had been harvested, or today's daily cap reached (resets at the next UTC day boundary). A concurrent request that already reserved what was left looks the same as fully spent.
  • 502: the completion was generated upstream but settling the reservation failed; the response is withheld rather than given away for free.