Documentation
Launch & safety · Security
Security & limitations
The security properties the Basis inference network is built on — how keys and secrets are handled, how addresses and the chain id are validated, how internal routes are authorized, why on-chain writes stay out of the inference hot path, and how settlement avoids double-paying — followed by an honest account of what is still limited or pending. The posture throughout is to state what is true and mark what is not yet done.
Authentication & secrets — JWKS verification, no secret in the verify path
Sign-in is via Privy (wallet or email); agents authenticate with sk-basis-... API keys. The server verifies a Privy access token against Privy's PUBLIC JWKS with jose — checking issuer privy.io, the app-id audience, and the DID subject — so NO app secret is in the token-verification path. The Privy app secret (PRIVY_APP_SECRET) is server-only and must be rotated before production (see Secret rotation below); it is never given a public prefix. API keys are stored only as a hash peppered with a server-only secret (BASIS_API_KEY_PEPPER); the raw key is shown exactly once and a peppered hash, never the raw value, is persisted. Tokens and keys are never logged. When auth is missing or pending configuration, protected routes return a structured response — 401 (auth_required / invalid_privy_token), 403 (wallet_not_linked), or 503 (privy_config_pending) — never a crash, and the dashboard shows an honest sign-in-pending-configuration state. Privy authentication is NOT sufficient for /api/internal/* — those routes still require INTERNAL_API_SECRET (or ADMIN_SECRET); a signed-in user cannot reach an internal route. No app secret and no API-key pepper is ever embedded in the client bundle.
No private-key logging, no .env commits
Private keys are never logged. The operator/keeper signing key is read in memory only at keeper execution time and is never written to logs or committed. Environment files are not committed — secrets live only in the deployment environment.
EVM address & Base chain-id validation
Worker reward wallets must be valid EVM 0x… addresses; registration validates and rejects malformed input. Before any chain action the expected chain id (Base mainnet 8453) is validated, so a request on the wrong network is caught rather than acted on.
Internal routes require a secret
Every /api/internal/* route — settlement/run, workers/probe, inference/reconcile — requires a valid INTERNAL_API_SECRET or ADMIN_SECRET. Without one the route returns 401/403. These routes are not reachable by unauthenticated callers.
No on-chain writes in the inference hot path
Serving an inference request performs no on-chain writes. Settlement to Base happens out-of-band, driven by the keeper — never inside the request path — so a user's request never depends on, or is blocked by, a chain transaction.
Settlement idempotency — no double-pay
Reward settlement is idempotent: a batch is keyed by its batch hash and settles exactly once. Re-running the keeper over an already-settled batch is a no-op, so the same rewards cannot be paid twice.
Duplicate receipt & job rejection
Jobs and receipts are de-duplicated: a job id or receipt that has already been recorded is rejected rather than re-counted. Replays do not inflate accounting or rewards.
Failed jobs produce no payable reward
A failed or rejected job earns 0. Reward only accrues for work that was actually completed and passed quality checks, and only to a valid EVM reward address.
Worker-reported metrics are not blindly trusted
Server-counted output tokens are authoritative. Worker-reported token counts and heartbeat throughput fields are advisory only — they cannot move accounting or rewards. Coherence checks and a throughput sanity cap further guard against gamed output.
Authentication & secret rotation — operator runbook
The Privy app secret was pasted into a local environment template during setup and then scrubbed; the development app secret is shown in plaintext when a Privy app is created. Before any production use the operator must rotate it and keep it server-only. These steps embed no secret value — the secret never appears in source, docs, the client bundle, or a NEXT_PUBLIC_-prefixed variable. Token verification itself uses only the public JWKS and never reads the secret.
1 · Rotate the Privy app secret
Rotate the pasted Privy app secret in the Privy dashboard. It was shown in plaintext when the app was created, so treat the original as compromised — generate a fresh secret before any production use.
2 · Delete the old secret
Revoke and delete the previous app secret in the Privy dashboard so the compromised value can no longer authenticate. Confirm it does not survive in any local file, shell history, or note.
3 · Store the new secret server-only
Place the new value ONLY in the Vercel server environment as PRIVY_APP_SECRET. It is never committed to the repo, never echoed by a route, and is never given a public NEXT_PUBLIC_ prefix — a public-exposed app secret is forbidden. Token verification needs no secret at all (it uses the public JWKS), so the secret is required only for advanced Privy server API calls.
4 · Set the app id (public)
Set NEXT_PUBLIC_PRIVY_APP_ID (the browser SDK reads it to start login) and PRIVY_APP_ID (the server-side audience checked during access-token verification). The app id is PUBLIC — unlike the secret, it is safe to ship to the client.
5 · Configure the production domain
Add basis.watch as the production domain in the Privy dashboard so logins originating from the live site are accepted.
6 · Upgrade to production before launch
Upgrade the Privy app from development to production before public launch. Development mode is capped at ~150 users; production is required for an open launch.
7 · Separate dev / prod apps if cookies are enabled
If HTTP-only cookie auth is enabled (PRIVY_AUTH_MODE=cookie with PRIVY_COOKIE_DOMAIN=basis.watch), use separate development and production Privy apps so cookie domains and origins never collide. The local_storage default needs no separate app.
Server-only, never public
PRIVY_APP_SECRET and the API-key pepper (BASIS_API_KEY_PEPPER) are server-only: they live in the deployment environment, are never committed, are never given a public prefix, and are never shipped to the browser. Only the app id and JWKS URL are public. The full step-by-step activation checklist lives in the operator guide.
Pricing integrity
BASIS-per-credit is dynamic and versioned, so pricing integrity rests on one rule: a price, once reserved, is immutable. When a job is reserved, the active epoch's rate and multipliers are frozen into a pricing snapshot that is hashed (the same canonical-JSON + SHA-256 used for receipts) and recorded inside the receipt's hashed economic core. Anyone can re-derive the snapshot hash and the receipt hash and confirm neither the price nor the charge was altered after the fact — totalChargedRaw equals the snapshot's basisRequiredRaw by construction.
No retroactive repricing
A new pricing epoch applies prospectively — to new quotes and reservations only. Accepted jobs are never silently repriced and existing receipts are never rewritten. The snapshot on a receipt is the single source of truth for what that job cost.
No silent reward mutation
A worker's reward is drawn from the same locked snapshot recorded on the receipt, never recomputed at a later (different epoch) price. A subsequent epoch change cannot raise or lower the reward for a completed job.
Snapshot hashed into the receipt
The epoch id, usage credits, BASIS-per-credit rate, BASIS required, and the multiplier block are part of the hashed receipt core. Tampering with any of them changes the receipt hash, so an altered price fails verification.
Pending, by design
Pre-launch the rate runs in an honest placeholder epoch — the integrity properties above are real today; the live market price is simply absent until a token, liquidity, and a price source exist. A pending price never becomes a fabricated one.
Payment security model
The payment router routes ETH, WETH, or USDC into $BASIS at payment time. Its security properties keep a payer in control: bounded slippage, short-lived quotes, single-use confirmation, and no custody. The router is pending until the $BASIS token and a quote provider are configured; these properties hold by construction once it is enabled.
Slippage ceiling
Every routed payment carries a min_basis_out_raw floor derived from slippage_bps, and caller-supplied slippage is capped by a hard ceiling (BASIS_MAX_SLIPPAGE_BPS). A request above the ceiling is rejected (slippage_too_high) rather than executed, and a swap that would deliver less than the floor reverts — a payer cannot be silently under-filled.
Short quote TTL
Quotes expire on a short, clamped TTL (BASIS_QUOTE_TTL_SECONDS, 30–300s). A stale quote is never honored — after expiry it is refused — so a price obtained at one moment cannot be executed against a moved market much later.
Quote replay / double-confirm rejection
A quote is single-use and bound to its payer and amount. Re-confirming an already-consumed quote_id is rejected (409 duplicate_payment), so the same quote cannot be replayed to credit a balance twice.
Payment confirmation idempotency
Confirmation is idempotent: a quote consumes exactly once and the deposit it links cannot be double-counted. On-chain verification of the submitted transaction is pending, so a credited balance is provisional until verification lands — and the idempotency holds regardless.
No server custody — user-signed transactions
Basis prepares a transaction (to / calldata / value); the user or agent wallet signs and submits it. Basis never signs for the payer, never executes the swap server-side, and never takes custody of funds or keys at any step of the payment flow.
Provider API keys are server-only
Swap-quote provider credentials (ZEROX_API_KEY and any provider key) are read server-side only and are never returned by a route or embedded in a page. Public payment surfaces expose only booleans for which providers are configured — never the key values.
No custody, on either path
Funding credits is non-custodial end to end: Basis returns calldata for the payer's own wallet to sign, never holds the swap, and never holds the resulting $BASIS. This mirrors the settlement side — reward addresses are destinations only — so the network takes custody nowhere.
On-chain payment verification
A payment final-credits a balance only after on-chain proof on Base — never on a user-submitted transaction hash alone. The mode is set by BASIS_PAYMENT_VERIFICATION_MODE: strict (the default when the router is enabled) enforces every check below before crediting; provisional is dev/test only — it credits before proof and is clearly labelled as unverified; disabled applies when the router is off. A failed, wrong-chain, wrong-token, wrong-receiver, under-filled, expired, or replayed payment is never credited.
Transaction succeeded
The transaction must be mined and successful on Base. A reverted or failed tx never credits — a hash that points at a failed transaction is rejected, not honored.
Correct chain
The proof must be on Base mainnet (chain id 8453). A tx on any other chain — including a testnet or a look-alike — is not accepted.
Correct $BASIS token & receiver
The transfer must move the configured $BASIS token into the configured receiver (BASIS_PAYMENT_RECEIVER_ADDRESS, which defaults to the credit vault → router). A payment to the wrong token or the wrong address does not credit.
Output ≥ expected / min-out
The $BASIS the receiver actually received must be at least the quote's expected amount and its slippage-derived min-out floor. An under-filled swap does not credit the full balance — it is treated as not meeting the quote.
Sufficient confirmations
The transaction must have at least BASIS_PAYMENT_CONFIRMATIONS confirmations (default 1) before credits are made final — a not-yet-final tx is not yet creditable.
Quote not expired, not replayed
The quote must still be within its TTL and must not have been consumed before. Verification is idempotent: a quote credits exactly once, so the same proof cannot be replayed to double-credit a balance (409 duplicate_payment).
Strict by default; never trust a hash alone
In strict mode a quote is verified against the receipt the chain returns and credited exactly once — replay and double-confirm are rejected (409 duplicate_payment), and a stale quote past its TTL is refused. The async verifier wiring that performs the on-chain lookup is the remaining launch task; until it lands, treat a provisional credit as unverified, never as proven.
Current limitations
What is not yet production-grade. These are stated plainly so nothing reads as more finished than it is. Each renders its real state.
- process-local
Process-local, non-durable persistence
The store lives in process memory and resets on a cold start. A durable database is required for production and is pending.
- pending
Hosted orchestrator pending
The long-lived Socket.io orchestrator runs locally or self-hosted today; a hosted deployment is pending.
- pending
Contracts pending
The $BASIS token, the credit vault, and the reward distributor are not yet deployed/configured. Addresses render pending and reward settlement runs dry-run until the distributor is set.
- pending
Inference backend / API keys pending
No upstream inference backend is configured; requests return a structured runtime_pending response. API keys for an upstream backend are pending.
- pending
Payment router / on-chain verification pending
The payment router is pending until the $BASIS token and a quote provider are configured. On-chain verification of a submitted payment transaction is not yet implemented, so a credited balance from a confirm is provisional. Quote replay and double-confirm are already rejected.
No custody
Basis never holds a user's or worker's funds or keys. Reward addresses are settlement destinations only; the network never takes custody.