Step 4 of chunk 26: Seedelf Wallet reads mainnet through seedelf-data, falling back to Koios one part at a time. Preprod doesn’t change.
Status: ✅ done, in 1.4.0 (#290; built, reviewed and checked 2026-10-09 on web-wallet/data-layer-wallet, from main after #289). Archived at 1.4.1, its open items carried into post-release-roadmap.md’s Kept in mind. Steps 1 to 7 and the developer docs are done; what changed from the plan is at the end. The free checks and the review are in Verification; the owner’s own-wallet parity and everyday spends are what’s left.
Read first:
The owner’s call (2026-10-09): the VPS comes last, on DigitalOcean. It gets a write-up of its own, with a manual deploy action.
cargo run -p seedelf-data-api on this machine, against the home server.https:// origin;From chunk 26 (2026-10-08):
From 2026-10-09:
pool_info for a ticker.All paths in this section are under extension/src/.
background/sw.ts:173, a closure handed to every service as koios: (network) => Koios. Nothing else reaches Koios:
seedelf-koios’s types;fetches are giveme.my, Minswap, CoinGecko and the IPFS gateway.networks.ts:
networkOrigins() feeds both the manifest’s host_permissions and the CSP’s connect-src;corsOrigins() (Minswap) feeds connect-src only, with no host permission, because those services answer with CORS headers.DATA_ORIGINS), so its origin belongs with corsOrigins: no new host permission, and nothing more asked at install.tests/manifest.test.ts pins the exact connect-src and host lists for each build kind. With no origin set they stay as they are.vite.config.ts reads only VITE_ENABLE_MAINNET and VITE_STORE_BUILD. There’s no dev-only origin mechanism yet.background/contract-scan.ts):
chrome.storage.session (seedelf.contract.<network>), unsealed and wiped on lock. It holds your rows, a seedelfs name map, the highest block seen, and when the last full read was.forgetContractView. In between, a catch-up reads from block_height − 2.UtxoResponse (seedelf-koios/src/koios.rs:181).
payment_cred, epoch_no, block_height, block_time and is_spent, and each asset’s decimals and fingerprint.payment_cred (the contract and mix-box checks, the signer count), reference_script’s presence, datum_hash, and is_spent for DEX orders.ui/screens/Home.tsx, WATCH_EVERY_MS). Elsewhere:
background/private-store.ts): XChaCha20-Poly1305 under a key HKDF’d from the vault’s entropy, padded to 1 KiB or the next power of two, named in PRIVATE_RECORDS.networks.ts gets data?: string on mainnet only, left empty until the VPS chunk.VITE_DATA_ORIGIN overrides it in dev and e2e builds, through a define.scripts/package.mjs refuses to package a store build made with the override, as it refuses one without Koios.sw.ts:173 becomes a factory that returns today’s Koios on preprod, with no origin, or with the switch on.chrome.storage.session as Koios’s 429 hold is.UtxoResponses in TypeScript, so no builder changes. A new adapter fills:
payment_cred from the route (contract or mix box);epoch_no from the slot (Shelley’s 432,000-slot epochs);block_time from created.time;block_height as 0, since nothing on the private side reads it once the catch-up cursor is gone;is_spent as false;inline_datum.bytes from datum;reference_script when script is set;fingerprint, worked out locally (CIP-14, as ui/tokens.ts does when one is missing).asset_info per token, would tell the server which tokens a wallet holds.readStake gives the pool by ID when no ticker is known on the device, and asks pool_info behind the reading. The next render shows the ticker.pool_list for the ticker. Its three pages come to about 600 KB, too much for one ticker on a fresh restore.networks.ts: data on mainnet, unset;vite.config.ts: VITE_DATA_ORIGIN → __DATA_ORIGIN__, which wins over the constant only outside a store build;corsOrigins() takes the data origin when there is one;package.mjs refuses the override.tests/manifest.test.ts: today’s strings stand with no origin. New cases cover an origin set: it’s in connect-src, never in host_permissions, and a store build refuses the override.koiosOnly preference, default false, in shared/preferences.ts and checked in background/preferences.ts’s get and set;en, es and ja.prices.ts reads currency.DATA_ORIGINS, and build with VITE_DATA_ORIGIN=http://127.0.0.1:8099.new Koios(<origin>/api/v1, …, DATA_LIMIT) and today’s Koios;DATA_LIMIT is a RateLimit sized under the edge’s bucket (300 units, 10 a second; 1 for shared answers, 4 for live SQL, 10 for a submit), with Koios’s KOIOS_LIMIT untouched;public down for 5 minutes):
Retry-After becomes the part’s hold, if longer;not a request Seedelf Wallet makes, which is a bug between koios.ts and the API, logged in dev builds.Retry-After) goes to Koios for that call only, without marking the part down.pending.ts. Its resend goes through the factory, so through Koios if the part is down, which is safe: it’s the same signed transaction.ogmios only reads, so it falls back to Koios on any failure.staking.ts’s readStake stops awaiting poolRef, as choice 6 says. Its tests and Home’s staking card show the ID until the ticker arrives.
background/private-index.ts:
contract/snapshot, contract/since/{cursor}, names, lovejoin/pool and lovejoin/since/{cursor};private), with the same fallback rules and DATA_LIMIT.UtxoResponse.contract.mainnet in PRIVATE_RECORDS;since(cursor).created rows not yet checked, a batch of 200 a queue turn as today.cursor into the record. Everything above is recomputed from the next answer.
reset: snapshot, check every row, then since(snapshot's cursor).readContractView keeps its signature, so its callers don’t change: balances, transfer, script-spend, send and sessions.
since(cursor) catches up.forgetContractView becomes “read the feed again”.block_height catch-up stays only for the Koios path.seedelfs map comes from the names route (shared, kept a block) instead of from a full read’s rows.transfer.lookup asks it, and withdraw.ts’s Remove keeps finding your own Seedelf in the map.lovejoin.ts’s listing reads lovejoin/pool instead of credential_utxos(mixBox);payment_cred with the mix box, which PoolBox::from_row checks.madeBy reads made_by from the row instead of tx_info;mixed maps directly;inputs are matched against your accounts’ keys as today;inputs: null means Kupo answered. The box stays in asking, as one Koios didn’t answer does today, and is looked up again on the next look.backOrder, ripeAt and waitedAt use created.time where they use block_time today.| Watch | Today | On the feed |
|---|---|---|
| A private spend (Home) | tx_status every 15 s |
its inputs in spent, by its hash |
| A move-in, an account-paid mint, an account send to a Seedelf, a session’s way back | tx_status |
its rows in created |
| A session’s way out | tx_status |
the contract rows it spends, in spent |
| A Lovejoin chain, a withdraw, a box coming back | tx_status every 5 s |
mix-box rows in lovejoin/since |
| An expiry | tip |
the answer’s tip.slot |
tx_status, now on the public side: anything that touches only key addresses. That’s account sends, staking and DRep transactions, collateral, swap legs, a session’s funding reaching its key address, and a public mix’s deposit.tx_status as today. Maybe-sent logic doesn’t change.since, including entries above the cursor, a reset, and the Koios scan leaving the cursor alone;names;made_by with inputs: null;tests/fakes.ts dispatches on a path’s last segment, so it can serve the new routes beside Koios’s.credential_utxos counts included.test.use({ network: "mainnet" })), on a build made with VITE_DATA_ORIGIN set to a test host.e2e/support.ts (it holds the test vectors’ secrets)..github/workflows/web-wallet.yml.DATA_ORIGINS for local runs.architecture.md (Chain data, Networks, the CSP);development.md (the dev build against the local API; the network-panel checklist);CLAUDE.md’s “Koios is the sole data layer”.privacy.md;store/README.md (descriptions, the remote-code and data-usage answers);All of it is free. There’s no preprod for the data layer, and no real-money tests.
DATA_ORIGINS (403s);since request, not a full read.since’s size after a day and a month.A dev build against the local API, on home’s mainnet db-sync and Kupo. A Playwright script drove a fresh mainnet wallet through the worker’s UI port and logged every request the extension made.
| Check | What the extension did |
|---|---|
| A restore | contract/snapshot, one contract/since, names; the account on /api/v1/…; nothing to Koios |
| A lock, then an unlock | one contract/since and names: no snapshot, no row checked again |
| The Koios-only switch on | nothing to the API; the contract from Koios’s scan |
| The API stopped | each part’s first request failed, and that part went to Koios; held 5 minutes, then back on the API at the next reading |
A wrong DATA_ORIGINS |
the browser sees a CORS failure, not a 403, and the wallet reads it as no answer: the same as stopped |
| db-sync unreachable | the private index answered from Kupo; only the public side went to Koios, on its 503s |
| A 429, the bucket emptied | both parts to Koios, held 5 minutes, longer than the 4 s Retry-After |
| Parity, with no wallet needed | the index’s contract at the tip against Koios’s credential_utxos: the same 33 rows, field by field (decimals and times too), and the same 10 names |
Scale (wasm/scale.mjs, its numbers):
isOwned.since on mainnet, measured against the API: 3.7 KB after a day (6 rows made), 50 KB after a week, 65 KB after a month (99 made, 7 older ones spent). Lovejoin’s pool after a month: 397 KB (454 boxes).Left for the owner:
Three reviewers read the branch by area (the fallback and submits; the private index, its sealed state and Lovejoin; the watches), then one more read the fixes together. Every fix but Home’s has a test that fails without it.
| Found | Fixed |
|---|---|
Watches compared a cursor’s slot time with the send time less 10 minutes: a send in the first minutes after a reading asked tx_status, a clock running fast could pick a cursor from after the landing, and one stale transaction sent its whole group to tx_status |
dated by when the wallet had each cursor, against a first try stamped before the submit; the sealed cursor read straight from its record; one with none in hand asks alone |
| A WebAssembly trap in the ownership check read as “not ours”, which the sealed record then kept for good | it stops the read and locks the wallet, as everywhere else |
| A sealed cursor the server refuses left that wallet on Koios’s scan for good | a snapshot again; a refused watch cursor forgotten |
| After a lost connection, Koios being busy or its node down read as “never sent” | maybe sent; “spent” gets the mempool check |
| The API’s plain-text 413 reached the screen as the network’s refusal | the data layer’s own refusal, by status |
| The API answered 502 (maybe sent) when home never answered the connection | 503: Koios at once |
| Home showed a pool’s ID until the next reading | the kept reading asked again 3 s later, asking no one |
| Both feeds’ cursor notes could overwrite each other | one at a time |
Left as they are:
koios.ts);| Today, through Koios | With the data layer | |
|---|---|---|
| An unlock | a full contract scan, every row checked | one shared since, only new rows checked |
| Every 30 minutes | another full scan | nothing more |
| Paying a Seedelf by name | the kept view, or a catch-up scan | one shared names |
| A Lovejoin look | the whole mix box, plus tx_info per box of unknown origin |
one shared lovejoin/pool |
| A private spend’s confirmation | tx_status every 15 s |
the shared feed every 5 s |
Planned in chunk 26c.
/health, from seedelf-data/deploy/README.md;/health, roll back;seedelf-data/, which has none today;Found while checking; none blocks the merge.
Cache-Control: no-store on every answer, and the data layer’s client asks a POST with default (a GET stays no-store), with the policy’s Changes saying so. Chrome skips its preflight cache for a cache: "no-store" request, which every service request was (SERVICE_FETCH, chunk 25), and only cache: "default" reuses one (measured).since keeps failing (a 503 every time, not a 400): the wallet reads Koios each time and keeps the cursor. A 400 already starts again from a snapshot.names and since can come from different blocks for one reading. A Seedelf of this wallet’s that moved in between shows as another’s for that reading, and Remove can’t build until the next one.names (contract-scan.ts).Built 2026-10-09. Where the build departs from the steps above, and why:
lovejoin/pool, then lovejoin/since its cursor, both shared answers. The snapshot alone is at the stable cursor, ten blocks back, and a chain drawing from it would take boxes spent since.feed.ts keeps them in session storage, one every 5 minutes for 6 hours, each with when the wallet had it by the device’s clock, and reads the sealed record’s from before a lock.
triedAt, a chain’s firstTried by transaction, a stopped public mix’s from. The stamps they had are taken after a submit’s retries, or again at each resend, which is what their own timing needs.tx_status alone, as one whose part is down does, and holds up nothing else on its feed: a Lovejoin chain that started before a lock, say.contract: true so it stays on the feed.tx_status once.utxos.made) for a private coin with no block height, beside Block, rather than changing Block’s key.tx_status or utxo_info, rare paths, for a follow-up:
sentAlready);utxo_info names the wallet’s own contract UTxOs to the public side, as it did to Koios;