Documentation
Guides · Operator
Operator guide
Everything an operator needs to stand up and run the Basis inference network: which environment variables exist and what each one gates, how the web app deploys on Vercel, where the orchestrator runs, the persistence caveat, how to wire an inference backend and Base RPC, internal-route secrets, the settlement keeper, and production smoke tests. Unset configuration renders honestly as pending — never invent a value, and never print a real secret.
Activation checklist
The ordered sequence to take a fresh deployment to a live network. Every step is an explicit operator action, and several are gated or pending — this is the order, not a claim that any of it is already done. Contract deployment, the Base Sepolia → mainnet checklist, and the canary runbook live in docs/CONTRACTS.md — followed here, not duplicated.
- 01Configure a live inference upstream (BASIS_INFERENCE_UPSTREAM_URL/_KEY/_MODEL).
- 02Configure a durable DB (BASIS_STORE_DRIVER=postgres + BASIS_DATABASE_URL + migrations).
- 03Launch $BASIS on Bankr (operator action — DONE: $BASIS is live on Base).
- 04Seed liquidity for $BASIS so quotes are meaningful.
- 05Set BASIS_TOKEN_ADDRESS to the deployed token address.
- 06Set ZEROX_API_KEY (server-only) for the quote provider.
- 07Enable the router (BASIS_PAYMENT_ROUTER_ENABLED=true).
- 08Deploy the credit vault on Base Sepolia; set BASIS_CREDIT_VAULT_ADDRESS.
- 09Verify a direct BASIS deposit credits a balance.
- 10Verify a USDC→BASIS quote returns a real, bounded quote.
- 11Verify payment confirmation final-credits only after on-chain proof.
- 12Deploy the reward distributor; set BASIS_REWARD_DISTRIBUTOR_ADDRESS.
- 13Run the worker canary (pnpm canary).
- 14Run the settlement dry-run (pnpm settle:rewards).
- 15Run a small live settlement on Sepolia and confirm idempotency.
- 16Promote to Base mainnet (explicit operator decision — see docs/CONTRACTS.md).
Honest sequencing
Token launch, liquidity, contract deployment, and the mainnet promotion are operator actions only — code can be ready, execution is the operator's. Enable the router only after the token and liquidity exist, deploy to Base Sepolia first, and treat the mainnet promotion as a deliberate decision. Persistence (docs/PERSISTENCE.md) and the payment-verification model (security) must both be in place before real funds flow.
Backend activation (Phase 1 → 3)
The website, docs, dashboard, auth, and most REST routes already run as a backend on Vercel serverless — that part is live. Real inference, durable data, and reward settlement each need an additional piece that is not yet provisioned. The phases below are the honest floor each configuration can stand on — not a claim that any service is running. The live GET /api/launch-status backend block reports which phase the current env supports, derived from env. Full detail lives in docs/backend-architecture.md and the per-piece deploy guides (docs/deploy-inference-upstream.md, docs/deploy-postgres.md, docs/deploy-orchestrator.md, docs/deploy-keeper.md).
Minimal live inference + durable data
Needs: Hosted OpenAI-compatible backend + managed Postgres
- Set BASIS_INFERENCE_UPSTREAM_URL (+ _KEY/_MODEL) to an OpenAI-compatible backend — the gateway switches from runtime_pending (503) to a streaming proxy. See docs/deploy-inference-upstream.md.
- Provision managed Postgres (pooled), set BASIS_STORE_DRIVER=postgres + BASIS_DATABASE_URL, and run migrations/0001_init.sql so receipts/ledgers/batches survive a cold start — the async-store adapter is already wired, so this is an operator migration, not a code change. See docs/deploy-postgres.md (proxied jobs record a metered receipt but pay no $BASIS reward).
Worker canary
Needs: A long-lived orchestrator
- Stand up the Socket.io orchestrator (runtime/orchestrator) on an always-on host — it holds persistent connections and CANNOT run on Vercel. BASIS_ORCHESTRATOR_URL is the WORKER's websocket connection target for the self-hosted mesh, NOT a backend the serverless gateway dials — a fetch-based Vercel function can't hold the long-lived socket the mesh needs, so the /api/v1/chat/completions route only ever proxies the hosted upstream (precedence below). Setting it is informational for launch-status (backend.orchestrator); the worker mesh runs separately. See docs/deploy-orchestrator.md (the orchestrator ships a template Dockerfile).
- Run the worker canary (pnpm canary) to exercise job → receipt → reward → dry-run batch end to end.
Contributor worker network
Needs: Orchestrator + durable store + reward distributor
- Run one or more contributor workers (runtime/worker, GPU-bound, BASIS_WORKER_WALLET required) against the orchestrator — only orchestrator-routed jobs with a valid EVM wallet earn $BASIS. The worker also ships a template Dockerfile.
- Deploy the reward distributor and set BASIS_REWARD_DISTRIBUTOR_ADDRESS, then schedule the keeper (Vercel Cron → the secret-protected /api/internal/settlement/run, which only prepares; on-chain submission is the operator-signed pnpm settle:rewards). See docs/deploy-keeper.md.
No service claim; no key on a hosted runtime
The orchestrator and worker (runtime/) are standalone, outside the pnpm workspace, and CANNOT run on Vercel — host them as always-on services (each ships a template Dockerfile). The settlement keeper stays dry-run until BASIS_REWARD_DISTRIBUTOR_ADDRESS is set; on-chain submission is the operator-signed pnpm settle:rewards — the signing key is never given to a hosted runtime and there are no on-chain writes in the inference hot path.
Privy activation
Stand up sign-in and API keys. Login is via Privy (wallet or email); keys are sk-basis-... bearer tokens minted in the dashboard. The server verifies a Privy access token against the public JWKS — so no app secret is in the verify path — but the app secret and the API-key pepper are still server-only secrets that must be rotated and kept out of the client bundle. Work this list in order; step 1 is non-negotiable.
- 01Rotate the pasted Privy App Secret (it was shown in plaintext at creation — treat the old one as compromised).
- 02Store the new PRIVY_APP_SECRET in the Vercel environment only — never NEXT_PUBLIC, never committed to the repo.
- 03Set NEXT_PUBLIC_PRIVY_APP_ID (public — the browser SDK uses it to start login).
- 04Set PRIVY_APP_ID (server-side audience for access-token verification).
- 05Set PRIVY_JWKS_URL (the public JWKS the server verifies tokens against — no secret in this path).
- 06Set BASIS_API_KEY_PEPPER (server-only strong random string — required to create/verify sk-basis-... keys).
- 07Enable login methods (wallet and email) in the Privy dashboard.
- 08Configure basis.watch as the production domain in the Privy dashboard.
- 09Upgrade the Privy app from development to production before launch (development mode is capped at <=150 users).
- 10(Optional) Enable HTTP-only cookies for production (PRIVY_AUTH_MODE=cookie + PRIVY_COOKIE_DOMAIN=basis.watch).
- 11If cookies are enabled, use separate dev/prod Privy apps so cookie domains and origins do not collide.
- 12Run production auth smoke tests: sign in, mint a key, call a protected route, hit an unauthenticated route (expect 401 auth_required when BASIS_AUTH_REQUIRED=true).
- 13Verify no secrets appear in the client bundle: neither PRIVY_APP_SECRET nor BASIS_API_KEY_PEPPER is shipped to the browser, and the app secret is never given a NEXT_PUBLIC_ prefix.
# Privy authentication. App id + JWKS URL are PUBLIC; the app SECRET and the # API-key pepper are SERVER-ONLY. NEVER set a secret as NEXT_PUBLIC, never commit # a real value, and ROTATE the development app secret before production use. NEXT_PUBLIC_PRIVY_APP_ID= # PUBLIC — browser SDK uses it to start login PRIVY_APP_ID= # server-side audience for token verification PRIVY_APP_SECRET= # SERVER-ONLY — ROTATE before prod; never NEXT_PUBLIC PRIVY_JWKS_URL= # public JWKS — token verify needs NO secret (jose) PRIVY_AUTH_MODE=local_storage # local_storage (default) | cookie (prod preference) PRIVY_COOKIE_DOMAIN= # e.g. basis.watch, when PRIVY_AUTH_MODE=cookie # API keys + auth enforcement. BASIS_API_KEY_PEPPER= # SERVER-ONLY — required to mint/verify sk-basis-... keys BASIS_AUTH_REQUIRED=false # true → protected routes require a token or API key BASIS_DASHBOARD_ENABLED=true # master switch for the /dashboard app
Rotate the secret; keep it out of the client
The development Privy App Secret was displayed in plaintext at creation — Rotate it before any production use and store only the new PRIVY_APP_SECRET in the Vercel environment. Never give it (or BASIS_API_KEY_PEPPER) a NEXT_PUBLIC_ prefix — a NEXT_PUBLIC_ app secret would ship to every browser and must never exist. The Privy app is in development mode (capped at 150 users) — upgrade it to production and configure basis.watch as the domain before launch, and use separate dev/prod apps if you enable HTTP-only cookies. See security.
Environment variables
The full operator configuration surface. Values are set per-environment (never committed); an unset variable renders the corresponding feature as pending. The right column notes what each variable gates.
| Variable | Purpose | Gates |
|---|---|---|
| NEXT_PUBLIC_SITE_URL | Canonical public site URL used in metadata, links, and OpenGraph. | Correct absolute URLs / SEO |
| BASE_RPC_URL | Base JSON-RPC endpoint for read-only chain access and settlement. | On-chain reads / settlement |
| BASE_CHAIN_ID | Expected chain id. Base mainnet is 8453; validated before any chain action. | Chain-id validation |
| BASE_BLOCK_EXPLORER_URL | Explorer base (https://basescan.org) for address and tx links. | Explorer links |
| BASIS_TOKEN_ADDRESS | Deployed $BASIS contract address. Empty → token reads pending. | Token shown as deployed |
| BASIS_CREDIT_VAULT_ADDRESS | Credit vault contract address. Empty → credit vault pending. | Deposits (depositsEnabled) |
| BASIS_REWARD_DISTRIBUTOR_ADDRESS | Reward distributor address. Empty → distributor pending. | Worker reward settlement (rewardSettlementEnabled) |
| BASIS_TREASURY_ADDRESS | Network treasury holdings address referenced in accounting and the treasury surface. Empty → treasury holdings pending. | Treasury references / /treasury |
| BASIS_BANKR_FEE_RECIPIENT_ADDRESS | Bankr/Doppler creator-fee beneficiary wallet (defaults to the deployer; redirectable; only the current beneficiary can claim). Empty → Bankr creator-fee reads pending. NEVER claimed server-side. | Bankr creator-fee reads |
| BASIS_BANKR_FEES_API_URL | Base URL for Bankr's UNAUTHENTICATED creator-fee read API (default https://api.bankr.bot). Sanitised to http(s); a malformed value falls back to the default. Read-only — never used to claim. | Bankr fee-read endpoint |
| BASIS_BURN_ENABLED | Draft burn gate. Even when true, the burn policy stays DRAFT / not_implemented until an on-chain burn exists and is verified — this flag never promotes the policy on its own. | Burn policy note only (never live) |
| BANKR_LAUNCH_URL | Public Bankr launch page URL. Empty → Bankr launch pending. | Bankr launch shown as configured |
| BANKR_TOKEN_URL | Bankr token page URL for the $BASIS listing. | Bankr token link |
| BASIS_OPERATOR_ADDRESS | Public address of the operator/keeper wallet used for settlement. | Keeper identity |
| BASIS_OPERATOR_PRIVATE_KEY | Keeper signing key. Read only at keeper execution time, in memory. NEVER logged or committed. | Keeper signing (out-of-app) |
| INTERNAL_API_SECRET | Shared secret authorizing /api/internal/* routes. | Internal route auth (401/403) |
| ADMIN_SECRET | Admin secret that also authorizes /api/internal/* routes. | Internal route auth (401/403) |
| CRON_SECRET | Vercel Cron bearer for the scheduled settlement route. Vercel sends Authorization: Bearer ${CRON_SECRET} on scheduled invocations; the route accepts it only when this is set and matches (else falls back to INTERNAL_API_SECRET/ADMIN_SECRET). SERVER-ONLY — never echoed or logged. | Vercel Cron auth on /api/internal/settlement/run |
| BASIS_SETTLEMENT_MODE | dry_run (default) | live. Only the exact string live opts into on-chain submission, and only when ALL five live gates pass. Any other/unset value resolves to dry_run. | Live-settlement gate #5 (explicit opt-in) |
| BASIS_SETTLEMENT_CRON_ENABLED | Operator intent switch for the scheduled trigger (default false). Surfaced as cronEnabled in the response; it does NOT itself authorize any on-chain send — the live gate governs that. | Cron intent flag (advisory only) |
| BASIS_SETTLEMENT_MIN_BATCH_RAW | Optional minimum total batch amount (raw base-unit string) worth settling; parsed as BigInt, no floats. Unset/invalid → no minimum. | Minimum batch size before settling |
| BASIS_SETTLEMENT_MAX_BATCH_SIZE | Optional maximum receipts per batch (positive integer). Unset/invalid → no cap (advisory until batch chunking lands). | Maximum receipts per batch |
| BASIS_INFERENCE_UPSTREAM_URL | OpenAI-compatible upstream base URL. Set → proxy mode; unset → runtime_pending. | Inference runtime (proxy) |
| BASIS_INFERENCE_UPSTREAM_KEY | Bearer key for the upstream inference backend, if it requires auth. | Upstream auth |
| BASIS_INFERENCE_UPSTREAM_MODEL | Model id to send upstream, overriding the requested id. | Upstream model mapping |
| BASIS_INFERENCE_UPSTREAM_TIMEOUT_MS | Per-request upstream timeout in ms (default 60000). On timeout the fetch aborts, the request is metered FAILED, and the caller gets a structured error / graceful stream end — a hung backend never hangs the serverless function. | Upstream request timeout |
| BASIS_INFERENCE_DISABLED | Emergency kill switch. When exactly true, the chat route returns a structured inference_disabled (503) BEFORE any upstream call — the managed upstream is never dialed, so upstream spend drops to zero. The OpenAI-compatible contract stays live. Any other/unset value leaves inference enabled. | Inference kill switch (zero-spend off) |
| BASIS_INFERENCE_MAX_BODY_BYTES | Max raw request body size in bytes (default 262144 = 256 KiB). An oversized body is rejected (413) before parsing. Absent/malformed → default; never throws. | Per-request body-size cap |
| BASIS_INFERENCE_MAX_MESSAGES | Max chat messages per request (default 64). Over the limit → 400. A coarse prompt-shape guard. | Per-request message-count cap |
| BASIS_INFERENCE_MAX_PROMPT_CHARS | Max total prompt characters across all messages (default 200000) — the primary INPUT spend guard. Over the limit → 400. | Per-request prompt-size cap (input cost) |
| BASIS_INFERENCE_MAX_OUTPUT_TOKENS | HARD cap on completion tokens forwarded upstream (default 2048) — the primary OUTPUT spend ceiling. The forwarded max_tokens is always clamped to this, so one request can never generate unbounded billable output. | Per-request output-token cap (output cost) |
| BASIS_INFERENCE_DEFAULT_OUTPUT_TOKENS | Completion-token budget when the caller omits max_tokens (default 1024). Itself clamped to the hard cap above. | Default output budget |
| BASIS_RATE_LIMIT_ENABLED | Master switch for the per-caller rate limiter. Only the literal false disables it; any other/unset value leaves it ON. Best-effort and PER-INSTANCE (see Cost guardrails) — not a global guarantee. | Rate limiter on/off |
| BASIS_RATE_ANON_PER_MIN | Anonymous requests per minute (default 10). The low caps that bound anonymous spend in the public demo. | Anonymous per-minute cap |
| BASIS_RATE_ANON_PER_DAY | Anonymous requests per UTC day (default 200). | Anonymous per-day cap |
| BASIS_RATE_AUTH_PER_MIN | Authenticated requests per minute (default 60). An identity / API key gets the higher caps. | Authenticated per-minute cap |
| BASIS_RATE_AUTH_PER_DAY | Authenticated requests per UTC day (default 5000). | Authenticated per-day cap |
| BASIS_ORCHESTRATOR_URL | The WORKER's websocket connection target for the self-hosted contributor mesh (default ws://localhost:8787) — NOT a backend the serverless route dials. Informational for launch-status (backend.orchestrator); the upstream proxy always wins for /api/v1/chat/completions. | Worker ↔ orchestrator link (mesh, not the gateway route) |
| BASIS_STORE_DRIVER | Store backend: memory (default — process-local, NON-DURABLE) or postgres (durable, wired). memory resets on a cold start; postgres needs a pooled BASIS_DATABASE_URL + migrations/0001_init.sql — the async-store adapter is already wired (store() returns PostgresStore), so it is an operator migration, not a code change. | Durable persistence (memory vs postgres) |
| BASIS_DATABASE_URL | Postgres connection string for the durable store. Required when BASIS_STORE_DRIVER=postgres. SERVER-ONLY — read in memory, never returned to the client. | Postgres store connection |
| NEXT_PUBLIC_PRIVY_APP_ID | Privy app id. PUBLIC — used by the browser SDK to start login. Empty → login pending (privy_config_pending). | Privy login availability |
| PRIVY_APP_ID | Privy app id used server-side as the access-token audience during JWKS verification. | Token audience check |
| PRIVY_APP_SECRET | Privy app secret. SERVER-ONLY — never NEXT_PUBLIC, never committed, ROTATE before production. NOT in the token-verify path; only for advanced Privy server API calls. | Privy server API calls |
| PRIVY_JWKS_URL | Public JWKS endpoint the server verifies Privy access tokens against with jose — no app secret needed. | Token signature verification |
| PRIVY_AUTH_MODE | Token transport: local_storage (default) or cookie. HTTP-only cookies are the production preference once the domain is set. | Auth token transport |
| PRIVY_COOKIE_DOMAIN | Cookie domain (e.g. basis.watch) when PRIVY_AUTH_MODE uses HTTP-only cookies. | Cookie-mode domain |
| BASIS_API_KEY_PEPPER | Server-only strong random string. Required to create/verify sk-basis-... API keys (only a peppered hash is stored; the raw key is shown once). Never NEXT_PUBLIC, never committed. | API-key issuance/verification |
| BASIS_AUTH_REQUIRED | When true, /api/v1/chat/completions and user routes require a Privy token or API key. Default false (public API open today). Flip true before a broad or paid launch. | Auth enforcement on protected routes |
| BASIS_DASHBOARD_ENABLED | Master switch for the /dashboard app (DID, linked wallet, credits, keys, receipts, worker). | Dashboard availability |
Secret handling
Never print real values for any secret. The operator private key (BASIS_OPERATOR_PRIVATE_KEY) is read only at keeper execution time, in memory, and is never logged or committed. Internal secrets and upstream keys are configured in the deployment environment, not the repo.
Vercel deployment
The web app deploys on Vercel with the project Root Directory set to apps/web. A push to main auto-deploys to production; pushes to a branch get a preview URL. Environment variables are configured per-environment in the Vercel dashboard.
# Vercel project settings (apps/web is the web app) Root Directory: apps/web # Deploy model: # push to main → production (basis.watch) # push to a branch → preview URL # Environment variables are set per-environment in the Vercel dashboard, # never committed to the repo. # Scheduled jobs live in apps/web/vercel.json (Vercel reads it from the Root # Directory; a repo-root vercel.json is IGNORED). The committed cron prepares a # settlement batch daily via /api/internal/settlement/run — it never signs or # submits. See the Settlement keeper section + docs/deploy-keeper.md.
Orchestrator hosting
The orchestrator (runtime/orchestrator) is a separate, long-lived Socket.io process that holds persistent websocket connections to workers. It does not run on Vercel — host it where a long-lived process can accept connections. Workers reach it via BASIS_ORCHESTRATOR_URL. A hosted orchestrator is running in production (a private backend); you can also run your own.
# The orchestrator is a separate, long-lived process — NOT on Vercel. # Run it where it can hold persistent websocket connections to workers. PORT=8787 pnpm --filter runtime/orchestrator start # Workers connect to it via BASIS_ORCHESTRATOR_URL (default ws://localhost:8787). # A hosted orchestrator is running in production (a private backend). Public worker # routing for /api/v1 stays OFF until BASIS_WORKER_ROUTING_ENABLED=true.
A hosted orchestrator is running, but public worker routing for /api/v1 stays OFF until BASIS_WORKER_ROUTING_ENABLED=true and a verified worker is online (the backend route stays upstream_proxy until then).
Persistence
The store backend is selected by BASIS_STORE_DRIVER. The default is memory — process-local and non-durable: worker registrations, the credit and reward ledgers, receipts, quotes, and the /data state live in process memory and reset on a cold start. That is fine for local testing and a single long-lived process, but it must not be treated as a system of record — payments recorded under memory are not durably persisted.
The honest posture is reported by persistenceStatus() as exactly one of three states, surfaced on GET /api/launch-status (persistence.status):
- memory_non_durable — the default (and production today); the process-local memory store (durable: false).
- postgres_unavailable — postgres driver selected but no BASIS_DATABASE_URL, so no database can be reached; records fall back to the non-durable memory store (durable: false).
- postgres_configured — driver + connection string set and the durable adapter is wired (the STORE_WIRED constant, which is true). The only state with durable: true.
The async adapter (apps/web/lib/store/postgres.ts) and schema (migrations/0001_init.sql) are complete and wired: InferenceStore is asynchronous, STORE_WIRED is true, and store() returns the durable PostgresStore (over a lazy postgres driver — pooled, prepare:false, imported on first query, never at build) whenever the postgres driver and a connection string are set. So moving off memory is an operator migration, not a code change: provision pooled managed Postgres, set BASIS_STORE_DRIVER=postgres + BASIS_DATABASE_URL, run migrations/0001_init.sql (idempotent; there is no 0002), deploy, and confirm persistence.status: "postgres_configured" / durable: true. STORE_WIRED is a hard-coded constant (never an env flag) so durability is reported from the same code path that returns PostgresStore — and storeHealth() additionally probes the DB (SELECT 1) and downgrades to postgres_unavailable if it is unreachable, so a surface never claims durability against a database it cannot reach. The full runbook is in docs/PERSISTENCE.md (deployment side: docs/deploy-postgres.md); see also architecture.
⚠ Do not enable live deposits, worker payouts, or settlement while BASIS_STORE_DRIVER=memory — the in-memory store resets on cold start and is not shared across instances, so money-path records would be lost. Durable persistence (durable: true) is gate g4 of the five live-settlement gates; verify it before turning on payment finalization or the live keeper.
# Store backend. memory is the repo DEFAULT (local/preview) and is process-local + # NON-DURABLE: receipts, credits, rewards, quotes, and /data reset on a cold start. # PRODUCTION runs durable Postgres (Neon): persistence.status postgres_configured, # durable true. A no-DB-env deploy stays memory_non_durable. BASIS_STORE_DRIVER=postgres # memory (default) | postgres (durable, wired; prod) # Durable Postgres (wired — operator migration, no code change; DONE in prod): # 1. BASIS_STORE_DRIVER=postgres # 2. BASIS_DATABASE_URL=postgres://... # SERVER-ONLY, POOLED endpoint; never client-side # 3. run migrations/0001_init.sql # idempotent (0002/0003 also applied in prod) # -> persistence.status: postgres_configured, durable: true BASIS_DATABASE_URL= # required when BASIS_STORE_DRIVER=postgres
Durable Postgres is active (postgres_configured): records persist across restarts and instances. Confirm reachability with storeHealth() before relying on it for the money path.
Upstream inference config
Set BASIS_INFERENCE_UPSTREAM_URL to an OpenAI-compatible backend to put the gateway in proxy mode: requests forward upstream, tokens stream back, and a receipt is recorded (server-counted output tokens). With no upstream configured, the API returns a structured runtime_pending response — the OpenAI-compatible contract stays live either way.
# Proxy mode: forward to an OpenAI-compatible backend and record receipts. BASIS_INFERENCE_UPSTREAM_URL=https://your-openai-compatible-backend/v1 BASIS_INFERENCE_UPSTREAM_KEY=sk-... # SERVER-ONLY, if the backend needs auth BASIS_INFERENCE_UPSTREAM_MODEL=basis-default # optional model override BASIS_INFERENCE_UPSTREAM_TIMEOUT_MS=60000 # optional per-request timeout (default 60s) # Unset BASIS_INFERENCE_UPSTREAM_URL → the API returns a structured # runtime_pending (503). The OpenAI-compatible contract stays live either way. # # PRECEDENCE: this hosted upstream proxy is the ONLY backend the serverless route # (/api/v1/chat/completions) dials. BASIS_ORCHESTRATOR_URL is the WORKER mesh's # socket target, NOT a backend this route calls — if both are set, the upstream # proxy still wins (the route never silently routes to the orchestrator).
Live — inference is routed to the contributor worker mesh; completed, verified worker jobs accrue $BASIS rewards, and requests fall back to a hosted upstream when no eligible worker is available. On-chain reward settlement is pending the $BASIS token and the reward distributor.
Backend precedence (upstream vs orchestrator)
The serverless route resolves exactly one backend (resolvedBackend() in lib/inference/adapter.ts): the hosted OpenAI-compatible upstream proxy. It always wins. BASIS_ORCHESTRATOR_URL is the worker mesh's websocket connection target, not a backend this Vercel route dials — a fetch-based serverless function cannot hold the long-lived Socket.io connection the mesh requires. So with neither set the route is runtime_pending (503); with the upstream URL set it is a proxy; with BASIS_ORCHESTRATOR_URL only it is still runtime_pending (the route never dials the mesh); and with both set the upstream proxy still wins (the orchestrator is reported via backend.orchestrator, never silently chosen). The contributor mesh is a separate, self-hosted path — see docs/deploy-inference-upstream.md and docs/deploy-orchestrator.md.
Cost guardrails
In proxy mode the gateway forwards to a managed, per-token-billed upstream model, so every completion costs real money per input and output token. The gateway bounds spend before any upstream call with three independent controls — a per-request size/output ceiling, a per-caller throttle, and an instant kill switch — plus an in-app usage signal. These are configured by the BASIS_INFERENCE_* and BASIS_RATE_* env vars in the table above; each reads from env with a safe default and never throws.
Kill switch — instant zero-spend off
BASIS_INFERENCE_DISABLED=true is the emergency cost cap. When set, the chat route returns a structured inference_disabled (503) before any upstream call — the managed upstream is never dialed, so upstream spend drops to zero — while the OpenAI-compatible API contract stays live (callers get a clean error envelope, not a hang). To engage: set it in the deployment environment, redeploy so new invocations pick it up, and verify a POST /api/v1/chat/completions returns 503 inference_disabled. To restore, unset it (or set it to anything other than the literal true) and redeploy. It is faster and more total than tuning the caps — pull it on a cost spike, a provider incident, or abuse you cannot throttle in time.
Per-request spend ceiling
A single request can never become an unbounded spend faucet. BASIS_INFERENCE_MAX_PROMPT_CHARS (default 200000) bounds the input size, and BASIS_INFERENCE_MAX_OUTPUT_TOKENS (default 2048) is the hard cap on completion tokens forwarded upstream — the forwarded budget is always clamped to it, even if the caller asks for more or omits max_tokens, so the worst-case billable output of any one call is fixed. MAX_BODY_BYTES / MAX_MESSAGES reject oversized or malformed shapes early. Lower the output cap to tighten per-call cost.
Per-caller throttle
The rate limiter gives anonymous callers low caps (10/min, 200/day by default) and an authenticated identity or API key higher caps (60/min, 5000/day). BASIS_RATE_LIMIT_ENABLED is on unless set to the literal false.
# Emergency kill switch — instant, zero-spend off. When exactly "true", the chat # route returns inference_disabled (503) BEFORE any upstream call; the managed # upstream is never dialed. The OpenAI-compatible contract stays live. BASIS_INFERENCE_DISABLED=false # set true to halt all upstream spend now # Per-request spend ceiling — caps request SIZE + OUTPUT length before any # (billable) upstream call. Absent/malformed → the default; never throws. BASIS_INFERENCE_MAX_BODY_BYTES=262144 # raw body bytes (256 KiB) → 413 over BASIS_INFERENCE_MAX_MESSAGES=64 # messages per request → 400 over BASIS_INFERENCE_MAX_PROMPT_CHARS=200000 # total prompt chars (INPUT guard) BASIS_INFERENCE_MAX_OUTPUT_TOKENS=2048 # HARD output cap (OUTPUT spend ceiling) BASIS_INFERENCE_DEFAULT_OUTPUT_TOKENS=1024 # budget when caller omits max_tokens # Per-caller throttle — low caps for anonymous, higher for an identity / API key. # BEST-EFFORT and PER-INSTANCE (no shared store): each serverless instance keeps # its own counters, so the effective ceiling is per-instance, not global. BASIS_RATE_LIMIT_ENABLED=true # only the literal "false" disables it BASIS_RATE_ANON_PER_MIN=10 BASIS_RATE_ANON_PER_DAY=200 BASIS_RATE_AUTH_PER_MIN=60 BASIS_RATE_AUTH_PER_DAY=5000
The rate limiter is per-instance, best-effort — not a global cap
Counters live in process memory, so they reset on every cold start and each serverless instance keeps its own counters. There is no shared, globally-consistent view — a caller spread across N warm instances can do up to N× a single instance's cap. It is also fail-open: a limiter bug allows the request rather than blocking legitimate traffic. Treat it as basic abuse friction, not a hard spend wall — the per-request token caps and the kill switch are the real bounds, and the upstream provider's own quota is the backstop. A strong global limit would require a shared store.
In-app usage signal
Daily receipt volume is the primary in-app cost signal — every served job records a receipt, so receipt count ≈ billable request count. GET /api/inference/receipts lists the receipts (each gateway-served job is workerRewardRaw: "0" — metered, not paid; contributor-worker rewards require the separate worker mesh) and GET /api/inference/stats reports the accounting totals and runtime mode. On the non-durable memory store these counts are process-local and reset on a cold start; durable cross-instance totals need the Postgres store. Pair this with the upstream provider's own cost dashboard and per-minute token quota as the authoritative, out-of-band spend controls.
Auth posture
BASIS_AUTH_REQUIRED decides whether the protected routes (/api/v1/*, /api/user/*, payment prepare/confirm, worker register) accept anonymous callers. When off, anonymous requests still work under the low anonymous rate caps; when on, they fail closed (401/403) and usage is attributable to an identity or API key. This deployment currently has BASIS_AUTH_REQUIRED=false — protected routes are open (the public demo posture).
Protected routes accept anonymous callers, bounded by the cost guardrails above.
Recommendation (explicit operator decision — do not flip during the demo)
Keep BASIS_AUTH_REQUIRED=false for the public demo because the low anonymous rate caps, the per-request token caps, and the kill switch bound the spend. Flip BASIS_AUTH_REQUIRED=true before a broad or paid launch so anonymous callers are rejected and every request is attributable. This is a deliberate launch decision gated by the cost guardrails — not a step to take while the demo is open. See the auth reference for the verification model.
Base RPC config
Configure the Base RPC endpoint and chain identity. The chain id is validated before any chain action — Base mainnet is 8453 — so a misconfigured network is caught rather than acted on. The explorer URL drives address and transaction links.
# Base RPC + chain identity. Chain id is validated before any chain action. BASE_RPC_URL=https://mainnet.base.org # or your provider endpoint BASE_CHAIN_ID=8453 # Base mainnet BASE_BLOCK_EXPLORER_URL=https://basescan.org
Token / contract env config
The token and contract addresses are each empty until deployed and configured. Empty values render honestly as pending — addresses are never invented in source. Two of these gate features: BASIS_CREDIT_VAULT_ADDRESS gates deposits, and BASIS_REWARD_DISTRIBUTOR_ADDRESS gates worker reward settlement.
# Token + contract addresses. Each is empty until deployed/configured; # empty values render honestly as "pending" — never invent an address. BASIS_TOKEN_ADDRESS= # $BASIS contract → token "deployed" when set BASIS_CREDIT_VAULT_ADDRESS= # gates deposits (depositsEnabled) BASIS_REWARD_DISTRIBUTOR_ADDRESS= # gates rewards (rewardSettlementEnabled) BASIS_TREASURY_ADDRESS= # treasury reference
Deposits enabled by configuration.
Reward settlement enabled by configuration.
Pricing-epoch operations
BASIS-per-credit is a dynamic, versioned value, not a forever constant. It is configured entirely by the BASIS_PRICING_* env vars in the table above, and every value is honest-pending unless set: an unconfigured deployment stays in the placeholder epoch and never fabricates a live price.
- BASIS_PRICING_MODE selects the source: placeholder (default), manual (operator-asserted BASIS/USD via BASIS_PRICING_MANUAL_BASIS_USD_RAW + BASIS_CREDIT_TARGET_USD_RAW), or quote / twap (live derivation, pending until a token + liquidity + a price source exist).
- BASIS_PRICING_MAX_EPOCH_CHANGE_BPS caps each epoch-to-epoch move (default 2500 = 25%); BASIS_PRICE_TTL_SECONDS bounds staleness; the MIN/MAX_BASIS_PER_CREDIT_RAW bounds are hard sanity rails.
- A new pricing epoch applies prospectively — to new quotes and reservations only. Existing receipts are never rewritten and accepted jobs are never silently repriced; a worker's reward is drawn from the snapshot already locked on its receipt.
Read the resolved rate at any time with GET /api/pricing and the epoch table with GET /api/pricing/epochs. To re-read the price source and re-derive BASIS-per-credit now, call the secret-protected POST /api/internal/pricing/refresh (it accepts INTERNAL_API_SECRET / ADMIN_SECRET or the dedicated BASIS_PRICING_REFRESH_SECRET, and never signs or writes on-chain).
No retroactive repricing
Refreshing or changing an epoch changes the price for new quotes and reservations only. It never reprices an accepted job, never rewrites a receipt, and never mutates a worker reward after the fact.
Payment router env config
The payment router lets a payer route ETH, WETH, or USDC into $BASIS at payment time to fund credits. It is a deliberate opt-in via BASIS_PAYMENT_ROUTER_ENABLED and is reported as configured only when the token, a quote provider, and the enable flag are all set. Quote providers are wired by env; provider API keys are read server-side only and are never returned to the client.
| Variable | Purpose | Gates |
|---|---|---|
| BASIS_PAYMENT_ROUTER_ENABLED | Master opt-in for the payment router. Must be true (with token + a provider) for the router to be configured. | Payment router (paymentRouterStatus) |
| BASIS_DEFAULT_SLIPPAGE_BPS | Default slippage in bps applied to a quote when the caller omits one (default 100 = 1%). | Default quote slippage |
| BASIS_MAX_SLIPPAGE_BPS | Hard ceiling on caller-supplied slippage (default 300 = 3%). A request above it is rejected. | Slippage ceiling (slippage_too_high) |
| BASIS_QUOTE_TTL_SECONDS | Quote lifetime, clamped 30–300s (default 90). After it, a quote is expired and refused. | Quote TTL / expiry |
| BASIS_PAYMENT_ROUTER_ADDRESS | On-chain payment-router contract address, if the deployment uses one. | Router contract reference |
| ZEROX_API_KEY | 0x Swap API key. SERVER-ONLY — read in memory, NEVER returned to the client or embedded in a page. | 0x quote provider |
| ZEROX_SWAP_API_URL | 0x Swap API base URL (default https://base.api.0x.org). | 0x endpoint |
| UNISWAP_UNIVERSAL_ROUTER_ADDRESS | Uniswap Universal Router address. Research provider — set with Permit2 to wire it. | Uniswap quote provider |
| UNISWAP_PERMIT2_ADDRESS | Permit2 contract address used by the Uniswap path. | Uniswap quote provider |
| AERODROME_ROUTER_ADDRESS | Aerodrome router address. Research Base-native provider. | Aerodrome quote provider |
| BASIS_PAYMENT_RECEIVER_ADDRESS | Address a payment must pay into to credit. Defaults to the credit vault → router when unset; on-chain verification checks the tx paid this receiver. | Receiver checked at verification |
| BASIS_PAYMENT_CONFIRMATIONS | Block confirmations required before a payment final-credits (default 1). Higher values trade latency for finality. | Confirmations before credit |
| BASIS_PAYMENT_VERIFICATION_MODE | strict | provisional | disabled. strict (default when the router is enabled) only final-credits after on-chain proof; provisional credits before proof (dev/test only, clearly labelled); disabled when the router is off. | On-chain payment verification |
| BASIS_PRICING_MODE | placeholder | manual | quote | twap. Default placeholder (the only honest rate until a price source is configured). manual uses an operator-asserted BASIS/USD price; quote/twap derive the live rate from a real price reading. An unset/unknown value falls back to placeholder. | Pricing source (BASIS-per-credit derivation) |
| BASIS_CREDIT_TARGET_USD_RAW | Target USD value of one credit, as a micro-USD raw integer (e.g. 10000 = $0.01). Used to derive BASIS-per-credit in live/manual modes. Empty → null (placeholder rate). | Credit USD target for live derivation |
| BASIS_PRICING_MANUAL_BASIS_USD_RAW | Operator-asserted micro-USD-per-BASIS price, used only in manual mode. NOT a live market price. Empty/invalid → manual mode stays pending. | Manual-epoch BASIS price |
| BASIS_PRICE_TTL_SECONDS | Price-reading TTL in seconds, clamped 5–3600 (default 60). A reading older than its TTL is treated as stale and never prices new credits. | Price staleness window |
| BASIS_PRICING_MAX_EPOCH_CHANGE_BPS | Cap on epoch-to-epoch change in BASIS-per-credit, in bps (default 2500 = 25%). The circuit breaker that stops a volatile reading from repricing the network in one step. | Epoch-to-epoch change cap |
| BASIS_PRICING_MIN_BASIS_PER_CREDIT_RAW | Hard lower sanity bound on a computed BASIS-per-credit (raw BASIS). Empty → no lower bound. Rejects absurd/stale-driven low rates. | Pricing sanity floor |
| BASIS_PRICING_MAX_BASIS_PER_CREDIT_RAW | Hard upper sanity bound on a computed BASIS-per-credit (raw BASIS). Empty → no upper bound. Rejects absurd/stale-driven high rates. | Pricing sanity ceiling |
| BASIS_PRICING_REFRESH_SECRET | Dedicated secret for POST /api/internal/pricing/refresh, accepted in addition to INTERNAL_API_SECRET / ADMIN_SECRET. SERVER-ONLY — never echoed or logged. | Pricing-refresh route auth |
# Payment router: route ETH/WETH/USDC into $BASIS at payment time.
# Enable ONLY after the $BASIS token AND on-chain liquidity exist — quoting a
# token with no market is meaningless. Until then the router stays pending.
BASIS_PAYMENT_ROUTER_ENABLED=false # master opt-in (true to enable)
BASIS_DEFAULT_SLIPPAGE_BPS=100 # default slippage when caller omits one (1%)
BASIS_MAX_SLIPPAGE_BPS=300 # hard ceiling on caller slippage (3%)
BASIS_QUOTE_TTL_SECONDS=90 # quote lifetime, clamped 30–300s
BASIS_PAYMENT_ROUTER_ADDRESS= # router contract, if used
# Quote providers. 0x is the configured path; Uniswap/Aerodrome are in research.
# Provider keys are SERVER-ONLY — never expose them client-side.
ZEROX_API_KEY= # SERVER-ONLY — never sent to the browser
ZEROX_SWAP_API_URL=https://base.api.0x.org
UNISWAP_UNIVERSAL_ROUTER_ADDRESS= # research
UNISWAP_PERMIT2_ADDRESS= # research
AERODROME_ROUTER_ADDRESS= # research
# Payment receiver + on-chain verification. A payment final-credits ONLY after
# the tx is proven on Base (success, chain 8453, correct token, correct
# receiver, enough BASIS out, enough confirmations, quote unexpired + unreplayed).
BASIS_PAYMENT_RECEIVER_ADDRESS= # defaults to credit vault → router when unset
BASIS_PAYMENT_CONFIRMATIONS=1 # confirmations required before crediting
BASIS_PAYMENT_VERIFICATION_MODE= # strict | provisional | disabled
# strict → default when router enabled
# provisional → dev/test only (credits early)
# disabled → when the router is offEnable order & key discipline
Enable the router only after the $BASIS token and on-chain liquidity exist — quoting a token with no market is meaningless, and the router stays pending until then. Never expose provider API keys: ZEROX_API_KEY is server-only, read in memory, never embedded in a page or returned by any route. Smoke-test the surface with /api/payments/supported-tokens and a /api/payments/quote POST — both are safe and surface only booleans for providers.
Treasury & tokenomics
How value accrues to the network now that $BASIS is live — fee streams, a draft treasury allocation, and Bankr creator fees. None of those flows is live yet: every fee, treasury balance, and accrual reads pending until addresses are configured and reads are verified on Base. The public surface is /treasury (also in the treasury block of /api/data); the design source of truth is docs/tokenomics.md + docs/treasury-policy.md + docs/bankr-fees.md.
- WETH-side and $BASIS-side are separate ledgers — never blended. The Bankr/Doppler creator trading fee accrues in both WETH and $BASIS together in the Uniswap-V4 pool; each side is accounted on its own. WETH is a liquid external reserve that funds real opex; $BASIS is native inventory. No automatic buyback is promised; any WETH→$BASIS market buy is an explicit operator-approved action.
- Tokenomics splits. The per-job split is 90% worker / 10% burn (BASIS_WORKER_REWARD_BPS / BASIS_NETWORK_MARGIN_BPS), configurable. The 10% per-job share routes to BURN via the on-chain BasisInferenceFeeRouter (deployed + configured; CreditVault spendSink wired to it). The 10% burn / 90% worker split has been CANARY-PROVEN by a controlled mainnet tx (totalBurned 1000 / totalAllocated 9000 $BASIS) — proven, not yet a continuous live feed. This is a SEPARATE flow from the Bankr $BASIS-side split. The Bankr creator-fee $BASIS-side splits 50% burn / 50% staker rewards (BASIS_SIDE_BURN_BPS / BASIS_SIDE_STAKER_REWARD_BPS), realized per claim (each $BASIS-side claim is burned 50% on-chain + funded 50% to the staking vault); the CONTINUOUS automatic feed is not yet wired. There is no fixed allocation matrix; the treasury is accounted as assets vs obligations.
- Reads are unauthenticated; claiming is operator-signed. Once BASIS_TOKEN_ADDRESS and BASIS_BANKR_FEE_RECIPIENT_ADDRESS are set, the read-only surface MAY poll Bankr's public fee endpoints (Base, chainId 8453; 0.7% swap fee; the Bankr protocol split — creator 95% / Doppler 5% — is a source-tagged assumption, and Basis's exact beneficiary share is read from the launch output / fee API). Claiming creator fees is an operator/keeper signed action and is never performed server-side.
# Treasury + Bankr creator-fee accounting. Everything stays PENDING until the # addresses are set and a verified production read succeeds. WETH-side # and $BASIS-side are accounted SEPARATELY and NEVER blended. Reads are # UNAUTHENTICATED; CLAIMING Bankr fees is an operator/keeper signed action and is # NEVER performed server-side. BASIS_TREASURY_ADDRESS= # treasury holdings address (empty → pending) BASIS_BANKR_FEE_RECIPIENT_ADDRESS= # Bankr creator-fee beneficiary (only it can claim) BASIS_BANKR_FEES_API_URL= # default https://api.bankr.bot (read-only) BASIS_BURN_ENABLED=false # DRAFT gate only — never makes the burn live # Read accrued creator fees (no key) once token + wallet are configured: # GET https://api.bankr.bot/token-launches/<tokenAddress>/fees?days=30 # GET https://api.bankr.bot/public/doppler/creator-fees/<walletAddress>
Honest accounting
Fee, treasury, and accrual amounts are null (rendered as pending) until a verified production read of the live pool succeeds — they are never fabricated and never shown as 0 as a fact. WETH-side and $BASIS-side are reported separately. See docs/backend-architecture.md for how the read fits the topology, and docs/treasury-policy.md for the full draft policy and risks.
Burn policy status
The Proposed Burn Policy is a DRAFT and is NOT IMPLEMENTED. The proposal is to burn 10% of the $BASIS inference protocol fee (deflationary as usage grows). It is not active, and BASIS_BURN_ENABLED does not make it live — even when set, the policy stays a draft until an on-chain burn path exists and is verified.
No burn occurs and no burn figure is claimed on any public page.
Activation requires all of: an on-chain burn path, accounting support, operator approval, and legal review — each verified. Burned amounts are tracked separately from treasury balances and are never blended (burned-to-date is "0" until live). No burn claim may appear on a public surface until it is verifiable on-chain. See docs/treasury-policy.md (§4) and docs/tokenomics.md for the full-burn vs partial-burn vs route-to-treasury analysis.
No public burn claim until verifiable on-chain
The burn is a proposal under review, not a mechanism. It is never described as live, active, or burning, and BASIS_BURN_ENABLED is a draft gate only — it surfaces a note, never a live burn.
Staking activation
The BasisStakingVault (contracts/src/BasisStakingVault.sol) is DEPLOYED + BaseScan-verified + configured in production (address read from env, never hardcoded). The public surface is /stake ("Stake $BASIS"), enabled in production. On a no-env deploy every staking action stays disabled until both addresses are configured and an on-chain read succeeds.
The staker-reward tranche is funded and streaming. fundRewards has been called: a funded $BASIS reward tranche streams over 7 days into the redeemable value of staked $BASIS (gated behind BASIS_STAKING_LIVE=true; unset → honestly not-active). Staking receives the staker-reward side — 50% of $BASIS-side fee generation — through a staking contract on Base; reward amounts are read live on-chain. This is NOT yield, passive income, or a guaranteed return. WETH-side fees are separate and never enter the vault, and the per-job 90% worker / 10% burn split is a separate flow again. Rewards accrue as vault share-value (ERC4626), not a per-user APY, and are not guaranteed.
- 01Launch the $BASIS token (operator action — DONE: $BASIS is live on Base) and set BASIS_TOKEN_ADDRESS.
- 02Deploy and verify the BasisStakingVault on Base (DONE — contracts/src/BasisStakingVault.sol; runbook docs/DEPLOY_CONTRACTS.md). No address lives in source.
- 03Set the vault address in BOTH BASIS_STAKING_VAULT_ADDRESS (server) and NEXT_PUBLIC_BASIS_STAKING_VAULT_ADDRESS (client) — the same deployed address.
- 04(Optional) set NEXT_PUBLIC_BASE_RPC_URL so browser reads use your endpoint instead of the public default.
- 05Confirm GET /api/staking/status reports vault.readVerified true (a real on-chain read succeeded) before exposing any action.
- 06Fund the staker-reward tranche on-chain (fundRewards) — DONE: rewards stream linearly into staked value over the reward duration; vault.live stays false from an env address alone.
- 07Set BASIS_STAKING_LIVE=true (the staking-policy `implemented` gate) so vault.live opens — it needs a successful on-chain read AND this affirmative policy env.
- 08Confirm the /api/launch-status staking block reports vaultConfigured + vaultLive true; the /stake surface then enables actions and shows the Live chip.
- 09Smoke-test on /stake with a wallet: a deposit, a start-cooldown, and a redeem in the withdraw window after the cooldown matures — the wallet signs every transaction; Basis never signs server-side.
# Staking vault (BasisStakingVault — ERC4626 + cooldown). DEPLOYED + BaseScan-verified # + configured in prod (0x8E06...a2f6); no address lives in source. The staker-reward # tranche is funded + streaming: fundRewards has been called and a funded $BASIS # tranche streams into the redeemable value of staked $BASIS (gated behind # BASIS_STAKING_LIVE=true; unset -> # honestly not-active). Every /stake action stays disabled on a no-env deploy until # both addresses are set to the SAME deployed address and an on-chain read succeeds. # Staking receives the staker-reward side (50% of $BASIS-side fee generation). These # rewards are not yield, not passive income, not a guaranteed return; they accrue as # ERC4626 share-value, not a per-user APY, and are not guaranteed. BASIS_TOKEN_ADDRESS= # the staked asset; empty -> staking pending BASIS_STAKING_LIVE=true # operator assertion the reward tranche is funded BASIS_STAKING_VAULT_ADDRESS= # server-side vault address (empty -> pending) NEXT_PUBLIC_BASIS_STAKING_VAULT_ADDRESS= # PUBLIC — SAME address; the browser reads vault state NEXT_PUBLIC_BASE_RPC_URL= # optional public Base RPC for browser reads # Verify the read-only surface once configured (no secret needed): # GET /api/staking/status # vault.readVerified true after a real on-chain read # GET /api/launch-status # staking block: vaultConfigured / vaultLive # Unstaking is two steps: start a cooldown, then redeem in the withdraw window after # it matures (miss it -> restart). Defaults 7-day cooldown / 7-day window, owner-set. # The wallet signs every transaction; Basis never holds keys or signs server-side.
The vault is deployed, verified, and configured in production, and the staker-reward tranche is funded and streaming (fundRewards called). /stake is enabled and /api/staking/status reads live on-chain figures. On a no-env deploy actions stay disabled and the status route reads honest nulls; no reward figure is shown unless verifiable on-chain. Rewards are not guaranteed.
Unstaking is a two-step cooldown; the wallet signs everything
Exiting is two steps: a staker starts a cooldown, then redeems during the withdraw window after the cooldown matures — miss the window and the cooldown must be restarted. Defaults are a 7-day cooldown and a 7-day withdraw window (owner-settable; pure state machine in apps/web/lib/staking/cooldown.ts). Staking is non-custodial: the staker's wallet signs every transaction (deposit, start-cooldown, redeem) and Basis never holds keys or signs server-side. vault.live is affirmative only from a real on-chain read plus an affirmative staking policy — never from an address alone. Design spec: docs/STAKING_VAULT.md; economics: docs/tokenomics.md §7 and docs/treasury-policy.md §4.2.
Internal secrets
The internal routes are authorized by a shared secret. Each /api/internal/* route requires a valid INTERNAL_API_SECRET or ADMIN_SECRET; without one the route returns 401/403. Set distinct, high-entropy values in the deployment environment.
# Internal route auth. /api/internal/* require ONE of these; without a valid # secret the route returns 401/403. Set distinct, high-entropy values. INTERNAL_API_SECRET=... ADMIN_SECRET=... # Protected internal routes: # POST /api/internal/settlement/run # POST /api/internal/workers/probe # POST /api/internal/inference/reconcile
Settlement keeper
Worker-reward settlement runs on a schedule, off the inference hot path, and is dry-run by default. Two pieces, deliberately separated by trust: a scheduled trigger that only prepares a batch (safe on Vercel — it holds no signer), and an operator-run keeper (pnpm settle:rewards) that submits with the signer in a controlled environment. There are no on-chain writes in the inference hot path. Settlement is idempotent — a batchHash settles once.
# Settle accrued worker rewards to Base. Idempotent: a batchHash settles once. pnpm settle:rewards # Seed example rewards first (local testing only): pnpm settle:rewards --demo # Dry-run until BASIS_REWARD_DISTRIBUTOR_ADDRESS is configured # (rewardSettlementEnabled() is false until then). The keeper never signs in # the app or the inference hot path; the operator key is read in memory only at # execution time and is never logged or committed.
Scheduled trigger (Vercel Cron). The cron is already wired in apps/web/vercel.json and deploys with the app. It issues a GET to the secret-protected /api/internal/settlement/run, which builds a deterministic, idempotent batch and — only when the live gate passes — prepares the unsigned distributor call. It never submits. Set CRON_SECRET in the Vercel project to authenticate the scheduled call.
# apps/web/vercel.json — Vercel reads vercel.json from the project Root
# Directory, which for this project is apps/web (a repo-root vercel.json is
# IGNORED). The scheduled trigger only PREPARES a batch; it never signs/submits.
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"crons": [
{ "path": "/api/internal/settlement/run", "schedule": "0 0 * * *" }
]
}
# Authenticate the cron: set CRON_SECRET in the Vercel project. Vercel then sends
# Authorization: Bearer ${CRON_SECRET} on each scheduled (GET) call and the route
# verifies it (else it falls back to INTERNAL_API_SECRET / ADMIN_SECRET; 503 if
# none is set). CRON_SECRET is server-only and is never echoed or logged.
# Operator intent (advisory; does NOT authorize any send):
BASIS_SETTLEMENT_MODE=dry_run # dry_run (default) | live (live = gate #5)
BASIS_SETTLEMENT_CRON_ENABLED=false # surfaced as cronEnabled; never authorizes a send
BASIS_SETTLEMENT_MIN_BATCH_RAW= # optional BigInt min total (no floats)
BASIS_SETTLEMENT_MAX_BATCH_SIZE= # optional max receipts per batchThe five live-settlement gates (ALL required). evaluateSettlementGate() (lib/settlement/gate.ts) returns mode: "live" only when every one of these holds; otherwise it is dry_run with the unmet gates and an honest reason:
- 1. BASIS_REWARD_DISTRIBUTOR_ADDRESS configured.
- 2. BASIS_OPERATOR_PRIVATE_KEY present (presence only — the value is never read into any log or response).
- 3. Base mainnet (BASE_CHAIN_ID === 8453).
- 4. Persistence is durable (persistenceStatus().durable === true) — a hard blocker until durable Postgres is wired (see Persistence above; it is false today).
- 5. BASIS_SETTLEMENT_MODE=live explicitly set (opt-in, never the default).
Signing discipline — the signer is never on Vercel
The keeper never signs in the application or the inference hot path. The operator private key (BASIS_OPERATOR_PRIVATE_KEY) is never given to Vercel or any hosted runtime, read in memory only at keeper execution time, and never logged or committed. Even when all five gates pass, neither the route nor the keeper sends a transaction — they only prepare the unsigned settleBatch call; the operator submits it out-of-band with a reviewed sending path. A failed submission never erases rewards (they roll back to pending under the same deterministic batchHash). Full runbook: docs/deploy-keeper.md.
Production smoke tests
After deploying, verify the public surface responds correctly. These reads are safe and need no secrets.
# Models — advertised model list (available reflects backend config).
curl -s https://basis.watch/api/v1/models
# Launch status — what is configured vs pending, as JSON.
curl -s https://basis.watch/api/launch-status
# Inference stats — runtime mode and accounting totals.
curl -s https://basis.watch/api/inference/stats
# Payments — supported tokens + router status (no secrets exposed).
curl -s https://basis.watch/api/payments/supported-tokens
# Payments — request a quote (returns a structured pending response until the
# token + a provider are configured; never a fabricated quote).
curl -s https://basis.watch/api/payments/quote \
-H "Content-Type: application/json" \
-d '{"sell_token":"USDC","payer_wallet":"0x0000000000000000000000000000000000000000","credit_amount":"100","slippage_bps":100}'- GET /api/v1/modelsThe advertised model list. available reflects whether an inference backend is configured.
- GET /api/launch-statusWhat is configured vs pending, as JSON — the same source the status panels read.
- GET /api/inference/statsRuntime mode and accounting totals — confirm the gateway is in the expected mode.
Live vs pending
The panel below is derived from real configuration. A fresh deployment with no addresses set reads honestly as pending across the board.
- deployed
0x660FceEe54d71997F8f2b675Ee78EAb2d48DbBA3
- pending
Launch URL pending
- configured
0xb6cfb9354C0e8e5EfD6887E0Ee096fBAcDe5Fddc
- configured
0xC1c036da4C6b31f2E69FaE5E652005135b5A2d6b
- configured
Upstream backend configured
- process-local
Process-local (non-durable) until a durable store is configured