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 — planned, not live).
- 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_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 (pre-launch open). Becomes required when live. | 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 deploy is pending.
# 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 deploy is pending.
Hosted orchestrator deploy is pending. Run it yourself for now (default PORT 8787).
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 default (and prod today) and is process-local + # NON-DURABLE: receipts, credits, rewards, quotes, and /data reset on a cold start. BASIS_STORE_DRIVER=memory # memory (default) | postgres (durable, wired) # Durable Postgres (wired — operator migration, no code change): # 1. BASIS_STORE_DRIVER=postgres # 2. BASIS_DATABASE_URL=postgres://... # SERVER-ONLY, POOLED endpoint; never client-side # 3. run migrations/0001_init.sql # idempotent; no 0002 needed # -> 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).
No upstream backend is configured — the gateway returns runtime_pending (503).
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.
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 disabled until the credit vault is configured.
Reward settlement in dry-run until the distributor is configured.
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. Planned 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. Planned 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 pre-launch rate). 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 planned.
# 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= # planned
UNISWAP_PERMIT2_ADDRESS= # planned
AERODROME_ROUTER_ADDRESS= # planned
# 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 would accrue to the network once $BASIS launches — fee streams, a draft treasury allocation, and Bankr creator fees. Nothing here is live: every fee, treasury balance, and accrual reads pending until the token launches, 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 a v0.1 reference proposal — 70% worker / 30% network margin (BASIS_WORKER_REWARD_BPS / BASIS_NETWORK_MARGIN_BPS), configurable, pending operator approval. $BASIS-side fee generation is designed to split 50% burn / 50% staker rewards (BASIS_SIDE_BURN_BPS / BASIS_SIDE_STAKER_REWARD_BPS) — both NOT IMPLEMENTED until contracts exist. 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 $BASIS # launches, 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 the token launches and a verified production read 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: a token launch, on-chain contract support, 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.
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.
- pending
Address pending deployment
- pending
Launch URL pending
- pending
Pending deployment / configuration
- pending
Pending deployment / configuration
- pending
No backend configured
- process-local
Process-local (non-durable) until a durable store is configured