# Satori neuron (browser app) > The Satori neuron as a client-side web app. It registers with Satori's > central server, polls central for the latest observation of each data > stream, forecasts the next values with a simple model, and sends the > predictions back to central, the way the Docker neuron (Satori Lite) does. > It also follows Satori streams on Nostr relays and publishes a prediction > stream (`_pred`) for each one it subscribes to, and predicts on > Base: the vault delegates its prediction power to the identity, which sends > one batch of up/down predictions every UTC round on its own. > Everything runs in the browser tab: the keys, the database (DuckDB-WASM on > OPFS) and the engine. There is no server of our own. It is built to be > driven by people and by AI agents alike. Status: steps 1–6 of the roadmap are built (two keys, central's data flow, the engine, Nostr, Base), a finishing pass is done, a build installs as an app (PWA), and each central stream has its own evolving multivariate model per neuron (`bvar`) with history from central (2026-10-16). Served at https://neuron.satorinet.io (https://satori.satorinet.io redirects there; one static image; docs/design/deploy.md). ## Quick start (an agent) In the page's JS context (Playwright's `page.evaluate`, a browser console): 1. Wait until `document.body.dataset.screen` is not `loading` (DuckDB-WASM and the keys take a few seconds; it is kept current: `setup`, `home`, `elsewhere`, `fatal`). Then `window.satori` is ready. 2. On `setup`: `await satori.setup({ passphrase: 'at least 4 chars' })` → the status, `screen: 'home'`. The neuron now runs on its own. 3. `await satori.poll()`: central's observations in, predictions out. 4. `await satori.discover()`, then `await satori.subscribe(pubkey, stream_name)` for a free stream: each observation is forecast and published as `_pred`. 5. Base: fund both Base addresses (on the dev server's local fork: `node scripts/fork.mjs fund ` and `node scripts/fork.mjs fund --satori 100`), then `await satori.unlock(passphrase)`, `await satori.delegate()`, `await satori.predictBase()`. Every method is below; `await satori.status()` shows everything at once. ## Concepts - Identity key: the neuron's own key (Evrmore address, "E…"). Stored unencrypted in this browser (Docker's `wallet.yaml`) so the neuron runs from page load with nobody present. It signs central's login challenge. - Vault key: holds funds. Stored encrypted with the passphrase (Docker's `vault.yaml`). **Unlock** (enter the passphrase) decrypts it into memory; **Lock** wipes it. The neuron does not need the vault to run; its public key is read from `vault.yaml` while locked. - Central: `https://network.satorinet.io` in a build unless the build sets another (`VITE_CENTRAL_URL`; neuron.satorinet.io uses `https://central2.satorinet.io` for now); central2 (the dev central) on the dev server. Changeable (https only). - Register: on every page load, `POST /api/v1/peer/register` with the identity's and the vault's public keys. Central links the vault to the identity once; a different vault on central shows as a "vault mismatch". - Poll: `GET /api/v1/observations/batch?limit=100`, the latest observation of each stream (under 48 h old). The first poll is 5–30 min after the page loads (random), then every 11 h; the next time is stored, so a reload keeps it. `satori.poll()` (or "Poll now") runs one at once. - Stream: one of central's streams, by uuid, with its name. Observations are stored once per (stream, whole second). - History: a stream seen for the first time (and, once, every stream already known) gets its past from central: `GET /api/v1/observations/history?stream_uuid=…&limit=1000&since=` (an addition beyond the Docker neuron; central observes hourly, so ~41 days), at most 4 calls at a time, at load and in each poll before the forecasts. Its rows are stored only if older than the stream's newest polled row, so they never trigger a prediction; only a poll's new row does. A central without the route (404, central2 today) is asked again a day later; a failure never breaks the poll. - Prediction: made only for a stream that got a new observation, from all of its stored history. `t1` is 12 h ahead, `t2` 24 h ahead (the value central scores, against the first observation 24 h or more after it). Sent right after the poll in one batch (`POST /api/v1/predictions/batch`, after a login signed by the identity) as `{stream_uuid, stream_name, value: t2, t1_value: t1, observed_at, hash}`, with Python's float formatting. Status `pending` until central accepts the batch; a rejected or failed batch is resent with the next poll. Pending predictions older than 22 h (2 × 11 h) are dropped. Every call to central is resent once on a network error, a timeout or a 5xx. - Model (the engine, `src/engine/`, docs/design/engine.md): after the owner's `namaste_club` engine. History is cut into 12 h UTC buckets (the last value in each) and turned into returns r = v_k / v_(k-1) - 1 (changes v_k − v_(k-1) for a series with any value ≤ 0). A forecast predicts a stream one cadence ahead: 24 h for central (t2; t1 is 12 h). - `bvar` (central's streams): each target stream has its own **spec**, `{ companions: 1–3 other central streams, lambda1 ∈ {0.05, 0.1, 0.2, 0.5}, lambda2 ∈ {0.25, 0.5, 1} }`, first drawn by an RNG seeded from the identity's public key and the target's uuid, so every neuron starts with its own choice of which streams to read. The model: a Bayesian VAR(1) over the target and its companions (namaste_club's Minnesota prior: own lag variance lambda1^2, another series' (lambda1*lambda2)^2 times their variance ratio clipped to [0.01, 100]; noise from the OLS residuals), on the buckets where every series has three back-to-back values, iterated two 12 h steps over all equations (each step clamped), then faded toward persistence like bar1. Its **score** is the median error of its 24 h forecast over the last 28 backtest origins (fit only on data up to each origin). After each poll one **mutation** of each spec (swap, add or drop a companion; move lambda1 or lambda2 one grid step), seeded by identity + target + generation, is scored on the same origins and kept only if it beats the champion's score by 5 %. `generation` counts the mutations proposed, `accepted` the ones kept. A companion must stay aligned with the target (a row shared in the target's last 4 row buckets, 7 in all): one that stops, or one drawn before there was history, is replaced from the aligned streams before the spec is scored. Specs stay in this browser (`model_specs`). A spec that can't fit yet (fewer than 6 + companions aligned rows, or a companion with no history) falls back to `bar1`, then `naive`; the prediction's `model` says which. - `bar1`: a Bayesian AR(1) on the target's own returns, r_next = phi * r. phi is shrunk toward 0 ("tomorrow = today") with a N(0, 0.2^2) prior: phi = sum(x*y) / (sum(x*x) + sigma^2 / 0.2^2). Two 12 h steps give t1 and t2, each step's return clamped to +-50%. Then t = last * (1 + weight * (growth - 1)). `weight` in [0, 1] comes from a backtest on the stream's own buckets (of the last 28 origins, those with three back-to-back buckets): weight = clamp(2 - model error / persistence error, 0, 1), so a model that trails the last value is faded toward it (1 with fewer than 5 origins). The model for Nostr and Base streams. - `naive`: t1 = t2 = the last value. Used until a stream has 6 lag pairs. A pair is three back-to-back 12 h buckets, each with an observation, so without central's history bar1 needs an observation every 12 h (about 4 days of polls every 11 h). A stream central updates once a day stays naive: gaps are not bridged. - `bar1d`: bar1 on changes (additive steps clamped to the largest change seen, absolute backtest errors) for a series with any value ≤ 0, such as a named Base stream's signed counter. `phi` (bvar: the target's own-lag coefficient) and `weight` are stored with each prediction (null for `naive`). `node scripts/backtest.mjs` measures bar1, persistence and 5 simulated bvar neurons on real history: on liquid crypto pairs all three tie. - Changing the central URL drops the pending predictions, the stream list, the specs and the last poll/submission/history (another central has other stream uuids); the list refills with the next poll. - Nostr key: a third key, random (not derived from the identity or the vault), stored unencrypted like the identity (Docker's `nostr.yaml`, `{pubkey_hex, secret_hex}`). It signs the neuron's Nostr events; its 64-hex pubkey is sent to central on register (`nostr-pubkey`). - Relays: central's list (`GET /api/v1/peer/relays`, today `ws://satorian.satorinet.io:7777`) plus the user's (Settings or `setRelays`). On the dev server central's relays are read only (requests, never events) and the user's relays default to the test relay `ws://localhost:7778`; a build writes to central's relays too, and its user relays default to the build's `VITE_NOSTR_RELAYS` (neuron.satorinet.io: `wss://app.satorinet.io/relay`). An https page cannot open `ws://` relays (shown as blocked); it needs `wss://`. - Network streams: the relays' catalog of stream announcements (kind 34600), newest per (provider pubkey, stream name), deleted ones dropped. A provider is a Nostr pubkey (64 hex). Prediction streams end in `_pred`. - Subscribe: to a free, open stream (paid, encrypted or approval-gated ones come later), at most `maxStreams` at once: central's `max_streams` (60 today), or Lite's own 100 when central has not answered. It publishes a subscription event (34602) and announces `_pred` (34600). The first observation that arrives is the relays' stored latest one (an observation is replaceable: a relay keeps one per stream), so it may be old; then each new one. Each new observation (34601) of the stream is stored, forecast one cadence ahead (the engine with the stream's `cadence_seconds`, a day when it has none, as its step and its horizon; a forecast is one cadence ahead, the owner's rule) and published as `_pred` with `value` = the forecast as a string (`"82378.0"`), `seq_num` = our last + 1. A value with no number is echoed. Relay streams and predictions are kept apart from central's and never sent to central. Unsubscribe publishes the unsubscription and deletes the `_pred` announcement (a tombstone). - Hourly: the relay list again, dropped relays reconnected, `_pred` announcements again; daily: a NIP-65 relay list (10002). - Base (Base Sepolia, chain 84532, the D6 contracts; docs/design/base.md): each key's 32-byte secret is also its EVM key, so the identity and the vault each have a Base (0x) address (the vault's is read from vault.yaml's public key while locked). Addresses: bundled, with central's `/api/v1/base/config` over them (cached 1 h; prod answers 404, central2 serves it). RPC: Settings or `setBaseRpc`; empty = the default: on the dev server `/rpc`, a local anvil fork (`node scripts/fork.mjs start`); in a build central's (`https://sepolia.base.org`), then publicnode, unless the build sets `VITE_BASE_RPC`. An RPC on another chain than 84532 is refused (nothing is read or sent). - Delegate prediction power (needs Unlock): the identity sets its predictor fee to 0 (`rewards.setPredictorFee(0)`, set-once) and accepts only this vault (`hub.setAllowedDelegator(vault)`), then the vault calls `hub.delegatePredictionPower(identity)`. Each step only when needed. While delegated the vault's SATORI is transfer-locked; Stop delegating (`delegatePredictionPower(0x0)`) leaves it locked through the next round: the token refuses transfers while `transferLockedUntil >= round`, so "until round N" means free from round N + 1 at 00:00 UTC. ("Staking" in the Docker neuron.) - Automatic only: on-chain the neuron plays only through the identity's daily batch; manual play is Satori Daily (https://app.satorinet.io, the dapp). Per vault per round it is one or the other: the engine refuses a delegated vault's own predictions (`Delegated`). - Prediction power: the vault's SATORI, delegated to the identity. A vault with no SATORI, or one that has not delegated, gives the identity none, and the batch skips (`no prediction power delegated to the identity`). - The daily batch (the identity, no Unlock): once per UTC round (`block.timestamp / 86400`), at the first check of the round (10 s after load, after Delegate, at the round's start on the chain's clock, at least hourly), when the identity has delegated power and no batch this round. Every registry stream whose binary game has voting weight (the engine pays only those) is forecast by the engine one cadence ahead and at least the 24 h a round scores (horizon = max(24 h, the cadence), in n = max(1, floor(horizon / max(12 h, cadence))) equal steps: an hourly stream in two 12 h steps, a 13–24 h cadence and the daily default stream in one 24 h step; a named stream, a signed counter, as `bar1d`); direction UP (payload `0x01`) if that forecast > the latest value, else DOWN (`0x02`), as the Docker neuron. Each game is simulated alone first (the contract's own checks, named games' 24 clock hours included) and only the ones that pass are sent, in one `engine.predictMultipleWithPayloads`, if the max fee per gas is within the ceiling (`setBaseMaxFee`, 2 gwei by default; above it the next check tries again). Its hash is kept at once, so a check never sends twice; the batch is stored after its receipt, its round from its block. - Claims (needs Unlock), each on its own: `hub.claim()` when the rewards lens says > 0 (the vault's share of the identity's wins); the merkle drop (`GET /api/v1/merkle/proof/{vault}` on central, then `claimMerkle` when owed; the amount is what the reserve paid); the vested airdrop (`claimAirdrop`). - Gas: both addresses need a little Base ETH (the identity for the batch, the vault for its actions). Every contract call is simulated first (a revert costs nothing and names the contract's error) and sent with 500,000 gas over the estimate (unused gas is not charged); an ETH send uses its estimate. The tip is the node's `eth_maxPriorityFeePerGas`, the max fee 2 × the base fee + the tip. - The installed app (PWA; docs/design/pwa.md): a build (not the dev server) has a service worker that caches the page and the hashed `/assets/` (the app's code, DuckDB's wasm and worker), so it starts offline with its stored keys and data; it touches no other request (central, relays, RPC, `/llms.txt`). It never runs the neuron while the app is closed. The keys live in this origin's storage (OPFS), which a browser may clear unless it is persistent: the neuron asks for it (`navigator.storage.persist()`) after setup and on each load until it is kept. Installing helps keep it (Chrome grants it to installed apps; Safari keeps a Home Screen or Dock app's data). A Safari web app has its own storage, apart from Safari's: set it up there or import the Docker files. An app window and a tab share the one-tab lock. Times are unix seconds. ## Drive it from the page (window.satori) Open the app and wait until `document.body.dataset.screen` is not `loading` (synchronous, kept current; `await satori.status()` says the same). Then, in the page's JS context: - `await satori.status()` → `{ screen, identity, vault, unlocked, central, nostr, base, storage, app }`. `screen` is `setup` (no keys yet), `home` (running), `elsewhere` (another tab has it open; only one tab runs the neuron) or `fatal`. `central` (on `home`) is `{ url, registration, lastPoll, nextPoll, polling, lastSubmit, pending, history }`: - `registration`: null while registering; then `{ at, ok: true, peer_id, wallet_address, vault_address, message, vaultMismatch }` or `{ at, ok: false, error }` (central refused or is unreachable). - `lastPoll`: `{ at, streams, new, predicted, engine_ms }` or `{ at, error }` (central's error, or a local one such as a database write); `engine_ms`: how long the models took (present when a stream had a new row). - `history`: the last history run `{ at, url, streams, rows, left, error, retryAt }` (`retryAt`: the route 404'd, asked again then) and `needed`, how many streams have no history yet. - `lastSubmit`: `{ at, total_submitted, successful, failed, errors }` or `{ at, error }`; `pending`: how many predictions wait to be sent. `storage` and `app` are there on every screen. `storage`: `{ persisted, usage, quota }` (`persisted`: the browser keeps this origin's data, the keys included, under storage pressure; `usage` and `quota` in bytes, null if the browser does not say). `app`: `{ installed, canInstall, update }` (`installed`: running as an installed app, or installed from this page; `canInstall`: Chromium offered its install prompt; `update`: a new version is waiting, see `update()`). - `await satori.install()` → `'accepted' | 'dismissed' | 'unavailable'`: the browser's install prompt when `status().app.canInstall` (Chromium only; it needs a user gesture, which Playwright's `page.evaluate` provides; a person answers it). `'unavailable'` when there is none (installed, Safari, Firefox, an incognito window such as Playwright's default context, or not offered yet). - `satori.update()` → true, then the page reloads into the new version, when `status().app.update`; false when none is waiting. Never automatic. - `await satori.setup({ passphrase, vaultWords?, identityWords? })` → status. Creates the neuron on a fresh browser (random keys unless 24 words are given). Passphrase: at least 4 characters, Latin-1 only (no emoji or €), as Docker's vault encryption needs. The vault starts locked. One setup runs at a time: a call while another (or the UI's) is running throws `Busy (setup)…`, and a call once set up throws `Already set up`. - `await satori.poll()` → `{ poll, submit }`: poll central now, store, forecast the streams with a new row, and send everything pending. `submit` is null when nothing was pending or when the poll failed (`poll.error`). It never throws for central's errors. One poll runs at a time; a call during one returns it. The next poll is then 11 h later. - `await satori.streams()` → one row per stream: `uuid`, `name`, `meta` (central's stream meta: the parsed object when it is JSON, else its text, or null), `observations`, `obs_ts`, `obs_value`, and our latest `t1`, `t2`, `model` (`bvar`, `bar1`, `bar1d` or `naive`), `phi`, `weight`, `made_at`, `status` (`pending`/`sent`), `sent_at`; the stream's `spec` (`{ companions: [uuid], lambda1, lambda2 }` or null), `score`, `generation`, `accepted`; and `history_at` (when its history came from central; null: not yet). - `await satori.models()` → each target's champion spec: `[{ uuid, name, model, spec: { companions: [{ uuid, name }], lambda1, lambda2 }, score, generation, accepted }]` (`model`: what made its latest prediction; `score`: the median relative error of its 24 h forecast over its last 28 backtest origins, null below 5). - `await satori.predictions(stream, limit = 20)` → one stream's predictions (by uuid or name), newest first: `[{ id, stream_uuid, name, made_at, model, t1, t2, phi, weight, observed_at, hash, status, sent_at }]`. `observed_at` and `hash` are what was sent: as in Lite, `observed_at` is the time the prediction was made (Python's float text of unix seconds), not the observation's time; `hash` = sha256(value + observed_at)[:16]. - `await satori.setCentral(url)` → `status().central`: an https URL, or `''` for the default; registers there at once. A different URL drops the pending predictions and the stream list (see Concepts). - `await satori.unlock(passphrase)` → true (one at a time, as the UI's: another while one runs throws `Busy (unlock)…`); `satori.lock()` → true. - While unlocked: `satori.backup('identity' | 'vault')` → `{ words, wif }`; `satori.file('wallet.yaml' | 'vault.yaml' | 'nostr.yaml')` → the Docker file's text. Only these exact names; anything else throws. These need Unlock as Docker's dashboard needs login, `wallet.yaml` and `nostr.yaml` too, although they are stored unencrypted: they are private keys. - `await satori.nostr()` (also `status().nostr`) → `{ pubkey, npub, maxStreams, centralWrite, userRelays, relays, subscriptions }`. `relays`: `[{ url, from: ['central' | 'user'], write, state, error }]`, state `open` | `connecting` | `closed` (it retries) | `blocked`. `subscriptions`: `[{ uuid, stream, provider, name, cadence_seconds, subscribed_at, observations, observation: { seq, ts, value, raw, received_at } | null, prediction: { stream, seq, value, model, made_at, relays } | null }]` (`value` is the number taken from the observation or null; `raw` is what the provider sent; `prediction.relays` is how many relays took our latest `_pred`; `model` is `bar1`, `bar1d`, `naive` or `echo`). `maxStreams` is 100 until central's relay config answers, then central's (60). - `await satori.discover()` → the catalog: `[{ pubkey, stream_name, name, description, cadence_seconds, price_per_obs, encrypted, approval_required, tags, metadata, created_at, pred, subscribed, relays }]`, newest first; `relays` lists the relays that sent the announcement. Right after a load it waits up to 10 s for the relays still connecting, then throws if none is open. - `await satori.subscribe(pubkey, stream_name)` → the subscription (as in `nostr().subscriptions`). Throws for a `_pred` stream, one no connected relay announces, a paid/encrypted/gated one, or over `maxStreams`. Again on a subscribed stream: returns it. - `await satori.unsubscribe(pubkey, stream_name)` → `nostr()` (as `setRelays`). - `await satori.relayPredictions(pubkey, stream_name, limit = 20)` → our `_pred` history for that stream, newest first: `[{ seq, value, model, obs_seq, made_at, event_id, relays }]` (`relays`: how many relays took it; 0 until they answer). - `await satori.setRelays({ relays?: ['wss://…'], centralWrite?: boolean })` → `nostr()`: the user's relays (ws:// or wss://; they replace the list) and whether central's relays are written to. - `await satori.base()` (also `status().base`, without the fresh read) → `{ rpc: ['/rpc'], rpcSetting, localFork, explorer, maxFeeSetting, maxFeeGwei, config: 'central' | 'bundled', error, errors, round, chainTime, identity: { address, eth, power, fee, allowedDelegator, batchesLeft, lowEth }, vault: { address, eth, satori, delegate, delegated: 'identity' | 'none' | 'other', lock: { locked, indefinite, untilRound, freeAt } }, claimable: { rewards, airdrop }, lastBatch: { round, at, hash, games: [{ streamId, gameId, named, direction, model, latest, forecast, horizon }], dropped: [{ …, error }] } | null, lastCheck, nextCheck, checking }`. Amounts are decimal strings (ETH, SATORI); `fee` is in basis points (1000 = the 10 % default); `direction` 1 = UP, 2 = DOWN; a game's `latest` is the registry's last value (an `int232` in the stream's own units: the default stream, id 1, is the capital deployed in predictions ÷ the supply × 1e18) and `forecast` the engine's at `horizon` seconds ahead, in the same units, rounded, both as decimal strings (they pass 2^53; a batch stored before the horizon rule has `t2` instead); `lastCheck` is the last batch check: `{ at, round, sent }`, `{ at, round, skipped }` or `{ at, error }`. `rpcSetting` and `maxFeeSetting` are what Settings stored (`''` and null: the defaults); `maxFeeGwei` is the ceiling in force. Before the first chain read (a second or two after load) `status().base` has `rpc: []`, `config: null` and null chain values; `satori.base()` reads at once. - `await satori.delegate()` (needs Unlock) → `{ steps: [{ step, by: 'identity' | 'vault', hash }], settled }`. `steps` is empty when everything was in place. - `await satori.undelegate()` (needs Unlock) → the same shape. - `await satori.predictBase()` → the batch now, if due: `{ at, round, sent: { round, at, hash, games, dropped } }` or `{ …, skipped: 'already predicted this round' | 'no prediction power delegated to the identity' | 'gas above the ceiling: …' | 'waiting for the batch already sent (0x…)' | … }` or `{ at, error }`. - `await satori.claim()` (needs Unlock) → `{ rewards, merkle, airdrop }`, each `{ hash, amount }`, `{ skipped: why }` or `{ error }`. - `await satori.send({ asset: 'ETH' | 'SATORI', to: '0x…', amount: '1.5' })` (needs Unlock) → `{ hash }`, from the vault. SATORI while transfer-locked throws, saying until which round. - `await satori.setBaseRpc(url)` → `base()`: an https URL, a path on this site (such as `/rpc`), or `''` for the default. A saved URL replaces the default. - `await satori.setBaseMaxFee(gwei)` → `base()`: the batch's ceiling on the max fee per gas (a positive number; `''` or null = 2 gwei). Settings saves both at once and checks both before saving either. Errors are thrown as `Error` with a readable message (for example `Wrong passphrase`, `Unlock the vault first`). Central's errors are not thrown: they are in `status().central` (for example `central 500: …`, `central unreachable: Failed to fetch`, `central timed out (15 s)`). ## Drive it from the UI - Setup: "Create a new neuron" (shows the vault's 24 words, asks for three of them, then a passphrase), "Restore from words" (vault words, optional identity words), or "Import Docker files" (`wallet.yaml`, `vault.yaml`, optional `nostr.yaml`, the vault password). `wallet.yaml` is the unencrypted file and `vault.yaml` the encrypted one; files in the wrong slots, or a vault.yaml that is not encrypted, are refused with a message saying so. - Home, top to bottom. **Keys**: each key with its identifiers and Copy: the identity (Evrmore and Base addresses), the vault (the same, its Locked/Unlocked badge, the "Vault passphrase" field and Unlock), the Nostr pubkey, then the storage line ("Storage: kept", or "Storage: the browser may clear it; install Satori or back up your words", with the usage of the quota) and **Install Satori** when the browser offers it (Safari: how to add it to the Dock or Home Screen); the header has Lock while unlocked and "Update ready, reload" when a new version waits. Setup shows Install too. **Central**: the URL, Registered, Last poll, Next poll, Last submission, Pending, History (central's history for our streams), a vault mismatch if any, "Poll now", and a collapsed "Streams: N from central" (one row per stream: latest value and time, t1, t2, the model with its spec's companions, "bvar · +eth +gold", or "naive · spec +eth" while the spec waits for aligned rows, with λ1, λ2, phi, weight, generation and score in its hover title, Sent/Pending). **Nostr**: each relay with its state ("Connected · read only"), each subscribed stream (latest value and time, our `_pred` value, its seq, how many relays took it, Unsubscribe). **Network streams** (opening it discovers): a search box and the first 50 matching streams with Subscribe/Unsubscribe. **Base**: the round, the RPC, the identity's ETH (as daily batches of gas; a low warning once delegated), the vault's SATORI and ETH, Delegation (until delegated: what to do), Transfers (the lock), Claimable, Last batch, Batch check, and after Unlock "Delegate prediction power" or "Stop delegating", "Claim", and a send form (asset, Amount, "To (0x address)", "Send from the vault"). **Settings**: Central (URL, empty = default; Save), Nostr (relays one per line, "Publish to central's relays"; Save relays), Base (Base RPC, "Daily batch: max fee (gwei)"; Save Base settings). **Backup** (needs Unlock): Identity words, Vault words, Download wallet.yaml, vault.yaml, nostr.yaml. - Every screen is built once and shown or hidden (the `hidden` attribute), so Setup's labels stay in the DOM on Home: query visible elements, or use `data-test`. - Form fields have labels; elements carrying data or actions have `data-test` attributes: `identity`, `identity-base`, `vault`, `vault-base`, `vault-state`, `nostr-pubkey`, `unlock`, `lock`, `central-url`, `registered`, `last-poll`, `next-poll`, `last-submit`, `pending`, `history`, `mismatch`, `poll-now`, `streams` (the collapsed list) with `stream` rows and `obs`, `t1`, `t2`, `model`, `status` inside, `relay` (with `relay-state`), `sub` (a subscribed stream, with `sub-obs`, `sub-pred`, `sub-seq`, `sub-relays`, `unsubscribe`), `network`, `catalog-summary` and `catalog-row` (with `catalog-action`: Subscribe or Unsubscribe), on the Base card `base-refresh`, `base-rpc`, `base-error`, `base-identity`, `base-vault`, `base-delegation`, `base-play` (who plays: this neuron or Satori Daily), `base-lock`, `base-claimable`, `base-last-batch`, `base-check`, `base-delegate`, `base-undelegate`, `base-claim`, `base-result`, `base-txs` (the last action's transactions), `base-action-error`, under Backup `wif`, `download-wallet`, `download-vault`, `download-nostr`, and for the installed app `storage`, `install`, `install-hint` and `update`. ## Base on the dev server: the local fork The dev server's default Base RPC is `/rpc`, which it proxies to an anvil fork of Base Sepolia on 127.0.0.1:8545 (the real D6 contracts; nothing goes to a public chain). `node scripts/fork.mjs --help` lists: - `start` / `stop` (anvil in the background, 2 s blocks; needs Foundry's anvil), every command taking `--port 8545`; - `fund <0x…> [<0x…> …] [--eth 0.1] [--satori N]`: each address gets the ETH (0.1 by default) and, only with `--satori`, N SATORI from the deployer (~519 in all); - `airdrop <0x…> `, `merkle <0x…> ` (prints the proof a stub central serves), `advance [days]` (chain time; then `predictBase()`), `predict [up|down]` (moves the default stream), `stream [name]` (a game with no voting weight, which the batch leaves out). Recipe: `fund ` (gas for the batch) and `fund --satori 100` (gas and the power to delegate), then Unlock, `delegate()`, `predictBase()`. ## Docker neuron interop The keys are Satori Lite's (github.com/SatoriNetwork/satori-lite and github.com/SatoriNetwork/satorilib): one 32-byte secp256k1 secret per key = the private key = the BIP39 entropy of its 24 words. `wallet.yaml` / `vault.yaml` are read and written in Docker's format (vault fields: AES-256-CBC, PBKDF2-HMAC-SHA1 × 1,000,000 over the password's Latin-1 bytes), so a neuron moves between Docker and the browser by moving the two files (and `nostr.yaml` for the same Nostr key). Central sees the same identity either way. The Nostr events are satorilib's byte for byte (content serialized as Python's `json.dumps`). On Base the same secrets give the same 0x addresses as the Docker neuron's (`ethAddress`), so delegation and rewards follow the keys. ## Code Repository: https://github.com/lastmeta/Satori (this app is the repository root; branch `main`). Plain JavaScript ES modules, no build step beyond Vite, MIT licensed. - `src/main.js`: app wiring and `window.satori`; `src/config.js`: Settings' defaults, the dev server's or a build's, each replaced by a `VITE_*` variable at build time (`VITE_CENTRAL_URL`, `VITE_NOSTR_RELAYS`, `VITE_NOSTR_CENTRAL_WRITE`, `VITE_BASE_RPC`); `src/util.js`: `serial()` (one at a time, in order), `isHttps()` and `isRelayUrl()`; `src/py.js`: Python's `str(float)` and `json.dumps` texts, byte for byte. - `src/keys/`: identity and vault (`index.js`), derivations and Evrmore signing (`keys.js`), Docker files (`docker.js`). - `src/central/`: central's API (register, observations, login, predictions batch) and Python-identical payload strings. - `src/db/`: DuckDB-WASM on OPFS, schema migrations, small async API. - `src/engine/`: `forecast(points, { step, horizon }) → { model, t1, t2, value, horizon, phi?, weight? } | null`, pure: `step` is the bucket in seconds (12 h by default; a relay stream's cadence), `horizon` how far ahead `value` is (two steps by default; a forecast is one cadence ahead: 24 h for central, where value = t2; one cadence for a relay stream; max(24 h, cadence) on Base); t1 and t2 are one and two steps ahead; `{ weight }` forces the fade. `bvar.js`: the Bayesian VAR(1) of a target and its companions, its backtest and fallbacks; `evolve.js`: `runModels({ fresh, histories, specs, seed })`, each target's identity-seeded spec, one mutation per poll, the forecasts. - `src/nostr/`: NIP-01 events and the Nostr key over `@noble/curves` (`event.js`), Satori's events and parsing (`satori.js`), one relay over a WebSocket with reconnect (`relay.js`). - `src/neuron/`: register, the cycle (poll → store → history → forecast → submit), `history.js` (central's history for new streams), central's status, the schedule (`schedule.js`, shared with Base), the one-tab lock; `nostr.js`: relays, discover, subscribe, intake → `_pred`, reconcile; `base.js`: the daily batch and its schedule, delegate, claims, sends, the Base settings. - `src/chain/`: Base over viem: `config.js` (addresses, RPC), `abi.js`, `index.js` (`createChain`: status multicall, delegate, the batch's pieces, claims, sends). - `src/ui/`: ArrowJS screens. - `src/pwa/`: the installed app: `sw.js` (the service worker; a build writes it to `dist/sw.js` with the files to precache, `vite.config.js`) and `index.js` (register it in a build, Update, Install, persistent storage); `public/manifest.webmanifest` and the icons. - `deploy/`: the static image (nginx serving `dist/` behind central's nginx-proxy, which terminates TLS): `Dockerfile`, `nginx.conf` (the CSP from `csp.js` on every response, cache headers, the SPA fallback) and `build.env` (the deployment's `VITE_*`); `scripts/deploy-image.sh` builds and checks `satori-neuron:`. - `docs/design/`: what is built and why (`OVERVIEW.md`, `keys.md`, `central.md` with Satori Lite's file:line references, `engine.md` with namaste_club's, the deviations and the backtest numbers, `nostr.md` with Lite's and satorilib's, `base.md` with Lite's, the dapp's and the contracts', `storage.md`: tables, migrations, meta keys, `pwa.md`: the installed app, `deploy.md`: the image and the build variables); `docs/plans/`: the roadmap and what is left of each part; `docs/issues/`: open issues outside this repo; `docs/inbox/QUESTIONS.md`: open questions for the owner. - `scripts/`: parity-vector generators run against satorilib, the Docker image and namaste_club (Python; `bvar_parity.py` for bvar); `backtest.mjs` (node, read only: bar1, persistence and simulated bvar neurons); `test-relay.mjs` (an in-memory Nostr relay: `node scripts/test-relay.mjs 7778`) and `provider.mjs` (a throwaway stream provider for a test relay); `fork.mjs` (the local fork of Base Sepolia, above); `deploy-image.sh` (the deploy image). - `test/`: fixtures (parity vectors) and test doubles (a fake D6 behind JSON-RPC, an in-memory DuckDB); `e2e/`: Playwright specs and their stub central (`stub.js`). - Tests: `npm test` (vitest), `npm run e2e` (Playwright, Chrome, a stub central, two test relays, a fake chain behind `/rpc`), `npm run e2e:pwa` (a build on `vite preview` :5181: the manifest, the service worker, offline, Update, one neuron), `npm run e2e:deploy` (the deploy image at `DEPLOY_URL`: headers on every response, then the app in a fresh profile). ## Contribute Pull requests are welcome at https://github.com/lastmeta/Satori. Keep changes small, tested (vitest / Playwright) and in plain JS; follow the patterns of the Docker neuron (Satori Lite) and satorilib; update `docs/design/` and this file with what you change.