BASIS
Documentation

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.

  1. 01Configure a live inference upstream (BASIS_INFERENCE_UPSTREAM_URL/_KEY/_MODEL).
  2. 02Configure a durable DB (BASIS_STORE_DRIVER=postgres + BASIS_DATABASE_URL + migrations).
  3. 03Launch $BASIS on Bankr (operator action — planned, not live).
  4. 04Seed liquidity for $BASIS so quotes are meaningful.
  5. 05Set BASIS_TOKEN_ADDRESS to the deployed token address.
  6. 06Set ZEROX_API_KEY (server-only) for the quote provider.
  7. 07Enable the router (BASIS_PAYMENT_ROUTER_ENABLED=true).
  8. 08Deploy the credit vault on Base Sepolia; set BASIS_CREDIT_VAULT_ADDRESS.
  9. 09Verify a direct BASIS deposit credits a balance.
  10. 10Verify a USDC→BASIS quote returns a real, bounded quote.
  11. 11Verify payment confirmation final-credits only after on-chain proof.
  12. 12Deploy the reward distributor; set BASIS_REWARD_DISTRIBUTOR_ADDRESS.
  13. 13Run the worker canary (pnpm canary).
  14. 14Run the settlement dry-run (pnpm settle:rewards).
  15. 15Run a small live settlement on Sepolia and confirm idempotency.
  16. 16Promote to Base mainnet (explicit operator decision — see docs/CONTRACTS.md).

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).

Phase 1

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).
Phase 2

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.
Phase 3

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.

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.

  1. 01Rotate the pasted Privy App Secret (it was shown in plaintext at creation — treat the old one as compromised).
  2. 02Store the new PRIVY_APP_SECRET in the Vercel environment only — never NEXT_PUBLIC, never committed to the repo.
  3. 03Set NEXT_PUBLIC_PRIVY_APP_ID (public — the browser SDK uses it to start login).
  4. 04Set PRIVY_APP_ID (server-side audience for access-token verification).
  5. 05Set PRIVY_JWKS_URL (the public JWKS the server verifies tokens against — no secret in this path).
  6. 06Set BASIS_API_KEY_PEPPER (server-only strong random string — required to create/verify sk-basis-... keys).
  7. 07Enable login methods (wallet and email) in the Privy dashboard.
  8. 08Configure basis.watch as the production domain in the Privy dashboard.
  9. 09Upgrade the Privy app from development to production before launch (development mode is capped at <=150 users).
  10. 10(Optional) Enable HTTP-only cookies for production (PRIVY_AUTH_MODE=cookie + PRIVY_COOKIE_DOMAIN=basis.watch).
  11. 11If cookies are enabled, use separate dev/prod Privy apps so cookie domains and origins do not collide.
  12. 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).
  13. 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.
bash
# 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

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.

VariablePurposeGates
NEXT_PUBLIC_SITE_URLCanonical public site URL used in metadata, links, and OpenGraph.Correct absolute URLs / SEO
BASE_RPC_URLBase JSON-RPC endpoint for read-only chain access and settlement.On-chain reads / settlement
BASE_CHAIN_IDExpected chain id. Base mainnet is 8453; validated before any chain action.Chain-id validation
BASE_BLOCK_EXPLORER_URLExplorer base (https://basescan.org) for address and tx links.Explorer links
BASIS_TOKEN_ADDRESSDeployed $BASIS contract address. Empty → token reads pending.Token shown as deployed
BASIS_CREDIT_VAULT_ADDRESSCredit vault contract address. Empty → credit vault pending.Deposits (depositsEnabled)
BASIS_REWARD_DISTRIBUTOR_ADDRESSReward distributor address. Empty → distributor pending.Worker reward settlement (rewardSettlementEnabled)
BASIS_TREASURY_ADDRESSNetwork treasury holdings address referenced in accounting and the treasury surface. Empty → treasury holdings pending.Treasury references / /treasury
BASIS_BANKR_FEE_RECIPIENT_ADDRESSBankr/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_URLBase 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_ENABLEDDraft 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_URLPublic Bankr launch page URL. Empty → Bankr launch pending.Bankr launch shown as configured
BANKR_TOKEN_URLBankr token page URL for the $BASIS listing.Bankr token link
BASIS_OPERATOR_ADDRESSPublic address of the operator/keeper wallet used for settlement.Keeper identity
BASIS_OPERATOR_PRIVATE_KEYKeeper signing key. Read only at keeper execution time, in memory. NEVER logged or committed.Keeper signing (out-of-app)
INTERNAL_API_SECRETShared secret authorizing /api/internal/* routes.Internal route auth (401/403)
ADMIN_SECRETAdmin secret that also authorizes /api/internal/* routes.Internal route auth (401/403)
CRON_SECRETVercel 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_MODEdry_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_ENABLEDOperator 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_RAWOptional 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_SIZEOptional maximum receipts per batch (positive integer). Unset/invalid → no cap (advisory until batch chunking lands).Maximum receipts per batch
BASIS_INFERENCE_UPSTREAM_URLOpenAI-compatible upstream base URL. Set → proxy mode; unset → runtime_pending.Inference runtime (proxy)
BASIS_INFERENCE_UPSTREAM_KEYBearer key for the upstream inference backend, if it requires auth.Upstream auth
BASIS_INFERENCE_UPSTREAM_MODELModel id to send upstream, overriding the requested id.Upstream model mapping
BASIS_INFERENCE_UPSTREAM_TIMEOUT_MSPer-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_URLThe 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_DRIVERStore 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_URLPostgres 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_IDPrivy app id. PUBLIC — used by the browser SDK to start login. Empty → login pending (privy_config_pending).Privy login availability
PRIVY_APP_IDPrivy app id used server-side as the access-token audience during JWKS verification.Token audience check
PRIVY_APP_SECRETPrivy 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_URLPublic JWKS endpoint the server verifies Privy access tokens against with jose — no app secret needed.Token signature verification
PRIVY_AUTH_MODEToken transport: local_storage (default) or cookie. HTTP-only cookies are the production preference once the domain is set.Auth token transport
PRIVY_COOKIE_DOMAINCookie domain (e.g. basis.watch) when PRIVY_AUTH_MODE uses HTTP-only cookies.Cookie-mode domain
BASIS_API_KEY_PEPPERServer-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_REQUIREDWhen 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_ENABLEDMaster switch for the /dashboard app (DID, linked wallet, credits, keys, receipts, worker).Dashboard availability

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.

text
# 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.

bash
# 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.
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.

bash
# 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

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.

bash
# 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).
pending

No upstream backend is configured — the gateway returns runtime_pending (503).

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.

bash
# 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.

bash
# 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
pending

Deposits disabled until the credit vault is configured.

pending

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).

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.

VariablePurposeGates
BASIS_PAYMENT_ROUTER_ENABLEDMaster 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_BPSDefault slippage in bps applied to a quote when the caller omits one (default 100 = 1%).Default quote slippage
BASIS_MAX_SLIPPAGE_BPSHard ceiling on caller-supplied slippage (default 300 = 3%). A request above it is rejected.Slippage ceiling (slippage_too_high)
BASIS_QUOTE_TTL_SECONDSQuote lifetime, clamped 30–300s (default 90). After it, a quote is expired and refused.Quote TTL / expiry
BASIS_PAYMENT_ROUTER_ADDRESSOn-chain payment-router contract address, if the deployment uses one.Router contract reference
ZEROX_API_KEY0x Swap API key. SERVER-ONLY — read in memory, NEVER returned to the client or embedded in a page.0x quote provider
ZEROX_SWAP_API_URL0x Swap API base URL (default https://base.api.0x.org).0x endpoint
UNISWAP_UNIVERSAL_ROUTER_ADDRESSUniswap Universal Router address. Planned provider — set with Permit2 to wire it.Uniswap quote provider
UNISWAP_PERMIT2_ADDRESSPermit2 contract address used by the Uniswap path.Uniswap quote provider
AERODROME_ROUTER_ADDRESSAerodrome router address. Planned Base-native provider.Aerodrome quote provider
BASIS_PAYMENT_RECEIVER_ADDRESSAddress 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_CONFIRMATIONSBlock confirmations required before a payment final-credits (default 1). Higher values trade latency for finality.Confirmations before credit
BASIS_PAYMENT_VERIFICATION_MODEstrict | 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_MODEplaceholder | 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_RAWTarget 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_RAWOperator-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_SECONDSPrice-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_BPSCap 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_RAWHard 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_RAWHard 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_SECRETDedicated 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
bash
# 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 off

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.
bash
# 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>

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.

Draft · not implemented

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.

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.

bash
# 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.

bash
# 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.

json
# 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 batch

The 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. 1. BASIS_REWARD_DISTRIBUTOR_ADDRESS configured.
  2. 2. BASIS_OPERATOR_PRIVATE_KEY present (presence only — the value is never read into any log or response).
  3. 3. Base mainnet (BASE_CHAIN_ID === 8453).
  4. 4. Persistence is durable (persistenceStatus().durable === true) — a hard blocker until durable Postgres is wired (see Persistence above; it is false today).
  5. 5. BASIS_SETTLEMENT_MODE=live explicitly set (opt-in, never the default).

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.

bash
# 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.

Live vs pending — derived from configuration