Branch web-wallet/data-layer, from main.
Status: ✅ done, in 1.4.0 with 26b (the wallet’s side) and 26c (the server). Archived at 1.4.1, its open items carried into post-release-roadmap.md’s Kept in mind.
For the API as built, seedelf-data/README.md is the reference. This plan says why; that README says what the routes return.
1.4.0’s focus (the owner, 2026-10-08):
This plan is the data layer’s side. The wallet’s side, the client that uses it, is chunk 26b. Its contract is fixed in The wallet’s side.
Why now. On mainnet the Koios public tier is slow and its limits bite:
TIMEOUT_MS at 45 s, because one credential_utxos over an account with real history “can take tens of seconds”.The shape recorded on 2026-10-02 in post-release-roadmap.md’s The data layer still holds, and this plan builds on it.
No cloud node, no cloud db-sync: there’s no money for it. So:
tx_out is enable, there’s no address table and no off-chain data. Matching mainnet would need a full resync.seedelf-data/ with its own Cargo workspace, so the CLI’s Cargo.lock never moves. It’s open source, because the no-log promise rests on people being able to audit it.Still holding from 2026-10-02:
UtxoResponse parser and the e2e fixtures stay valid.
grest SQL functions (koios-artifacts, CC-BY-4.0, credited), on a db-sync set up with Koios’s own insert options. Chrome extension ──HTTPS──▶ VPS (public IP, ours) Home (no inbound ports, own VLAN)
Koios on 503 / Caddy: TLS, no access log cardano-node (mainnet)
timeout, per part; seedelf-data-api (Rust): Kupo (Seedelf + Lovejoin patterns)
preprod: always Koios /seedelf/v1 private index (in memory) db-sync 13.7 → Postgres (grest schema)
/api/v1 Koios-equivalent, 22 routes cardano-submit-api, Ogmios v7
buckets, caches, breakers, /health timers: Koios cache jobs, host_status
WireGuard server ◀══ tunnel, dialled out ══ WireGuard client (default route once synced)
The home box never sees a user’s IP: every request reaches it from the VPS. The VPS keeps IPs only in its rate limiter’s memory.
What it serves. The current unspent set at two script credentials, and what changed since any point:
94bca9c0…a469;c145c10f… (networks.ts).Every answer is the same for every user. Each is cached once and costs home nothing per request. No request ever names a UTxO, a register or an owner.
The source: a Seedelf-only Kupo at home (kupo_seedelf.service, Kupo 2.11, port 1443), on the mainnet server, set up 2026-10-08.
94bca9c0…a469/* (the contract) and c145c10f…1fad/* (the mixBox).--since 144386185.af5f8c02581c857a39218fcfab037817d470e8f4ca347ae04ffb328c16e7d11b: block 11,305,804, checked against both db-sync and Koios.
1e491b15…, epoch 531).StateDirectory=kupo-seedelf must match --workdir /var/lib/kupo-seedelf, or Kupo can’t write its database.ExecStart line but the last needs a trailing \, or the --match lines are silently dropped.--defer-db-indexes.coin-control.ts orders them by block_height.As built (2026-10-09), there’s no builder holding rows in memory. Each answer is read from its source when first asked for at a tip, then kept until that source’s tip moves. The reads take milliseconds, and one set per block serves everyone, so a copy of the rows would only add a second truth to keep right. See the seedelf-data README.
The builder lives in seedelf-data-api, in memory.
created_after=S&resolve_hashes and spent_after=S for each pattern./checkpoints/{slot} no longer holds the header hash.
The compact row carries what the wallet uses, and nothing else:
ref.address, the exact bech32.
addr…w…) and staked (addr…z…).lovelace, and assets as [policy, name, quantity].Constr 0 [G1, G1], 103 or 104 bytes; both encodings are on chain.script, when the output carries a reference script. The wallet never spends one.Dropped:
stake_address and epoch_no, never read for contract rows;Size: a row is about 400 bytes of JSON, against about 1 KB for a trimmed Koios row today. It compresses only to about 60%, because a register’s points are random.
Routes, under /seedelf/v1/{network}/. All are shared and all are cacheable.
As built (2026-10-09), three things differ from the table below. The seedelf-data README has the details.
<slot>.<hash>, a slot that’s a multiple of 200, at least 200 slots (about 10 blocks) below the tip, with the hash of the newest block at or before it. There’s no separate day-long grid for old ones.
reset.created row carries its own spent.reset comes only when the cursor’s own block was rolled back.contract/snapshot instead of shards, and names instead of names/{bucket}. No client exists yet, so adding them needs no compatibility work.created as {slot, time}, with no height: Kupo has none, and both sources give the same row. A slot’s time is exact from Shelley on.made_by.inputs is null.| Route | Answer | Notes |
|---|---|---|
contract/since/{cursor} |
{cursor, created: [rows], spent: [{ref, by}], reset?} |
The workhorse: every unlock and refresh. Cursors are quantised: to 10 blocks within the last day, to a day before that. Answers are shared per window, and a request shows the last unlock only roughly. reset comes with a rollback point if the cursor’s block was rolled back. |
contract/shards, then contract/shard/{id} |
the manifest {point, shards: [{id, rows, version}]}, then each shard’s rows at its own point |
A restore only. The set is sharded by the outref’s transaction-hash prefix, and a shard is re-cached only when one of its rows changes. v1 serves one shard (mainnet’s contract is 27 UTxOs, preprod’s about 1,000), but the format allows 16 or 256 from the start, so growth needs no wallet change. Afterwards the wallet calls since(oldest point) to catch up; duplicates are harmless. |
names/{bucket} |
the rows of the Seedelfs whose name hash starts with bucket |
Paying a Seedelf, by its exact full name only (5eed0e1f and 56 hex characters), never a prefix search: tags aren’t unique. Today a name resolves from the kept view with no request at all. Without a full set kept, a bucket keeps that close: the service learns a bucket, never the payee. v1 has one bucket, the whole list, which is tiny today. |
lovejoin/pool and lovejoin/since/{cursor} |
the same row and delta shapes, and per box its creating transaction’s provenance: mixed, and inputs: [[payment_cred, stake_cred]] |
The pool is small, so one snapshot is enough. It replaces the full mixBox credential_utxos listing on every look. Both come from db-sync. While it’s down, mixed comes from Kupo (the transaction spent a mixBox output) and inputs are null. A box’s created point gives the time that waitedAt, backOrder and ripeAt use. |
Provenance replaces madeBy’s tx_info (lovejoin.ts). That tx_info is the one request that today tells the service which pool boxes are yours. The wallet now matches input credentials against its own account keys locally.
The server sends every row at the two credentials. The wallet’s own filters stay the authority:
registerOf;PoolBox::from_row: exactly 10 ₳, no assets, a ≠ b, points of prime order.The feed gives a watch everything it needs to stop asking about one transaction:
spent entry carries the spending transaction;created row carries the creating one;A watch polls the same shared since everyone calls.
| Today, per user, through Koios | With the feed |
|---|---|
tx_status of each private spend, every 15 s (pending.ts) |
its inputs appear in spent, by its transaction |
tip, for an expiry |
the feed’s tip |
utxo_info on a maybe-sent spend’s own contract inputs |
answered locally |
tx_status of a Lovejoin withdraw, and of deposit, mix and back chains |
the boxes and contract rows they spend and create |
tx_status and utxo_info for a public mix that may have gone through |
mostly, through the boxes it created |
a session’s out and back tx_status (sessions.ts) |
the contract rows they spend and create |
madeBy’s tx_info |
the pool feed’s provenance |
Still per user, by nature:
The blind spot closes. Spends made with the same phrase on another device now show up, which today’s catch-up read can’t see.
No chain database, because it doesn’t need one. A contract UTxO’s register never changes. So once a row has been checked and isn’t yours, the wallet never has to look at it again. The server keeps the index.
The wallet keeps a few KB, sealed with the vault the way contacts are, in chrome.storage.local:
There’s no IndexedDB and no unlimitedStorage. Nothing else is kept: Seedelf names come from a bucket when needed, and the Lovejoin pool from its snapshot.
An unlock:
since(cursor).Home shows the private balance at once, marked stale until the delta lands. That fixes the private half of “Couldn’t read your balances”.
A restore from the phrase:
since(oldest point).At scale, the restore is the cost, not the storage: one G1 scalar multiplication per row. Measure the WebAssembly’s time per row, which sets how long a restore takes on a large contract, and give the restore a progress bar.
The trade, said plainly: your own rows are kept at rest, encrypted. That is the second candidate in Kept in mind’s balances item, chosen now for the private side. The public side’s last reading can follow it in the wallet’s chunk.
cardano-submit-api runs at home, on the mainnet node’s socket.
POST /api/v1/submittx: Koios’s path, Content-Type: application/cbor, and a 16 KiB body cap (the ledger’s max_tx_size).BadInputsUTxO, OutsideValidityIntervalUTxO, FeeTooSmallUTxO and the rest.
"All inputs are spent. Transaction has probably already been included", a string the wallet already classifies.TraceOptions, as in upstream’s cardano-submit-api/config/tx-submit-mainnet-config.yaml). The old logging format makes it crash-loop with key "Options" not found, and on the server it had been doing that for about three weeks.
Notice. Info logs every submitted transaction.evaluateTransaction only) shares this part and its health check, with at most 2 in flight.As built (2026-10-09), it differs from what follows in these ways. The seedelf-data README has the details.
grest at home. The API carries its own SQL, written after Koios’s functions rule for rule and read with the read-only role, so nothing at home is installed or changed. Koios’s caches are replaced:
ma_tx_mint.address.stake_address_id: 2 ms, against 6.7 s through tx_out.
epoch_params and totals are kept for a block, not an epoch: a millisecond each, and never last epoch’s fees at a boundary;drep_metadata is kept 10 minutes, like drep_info;pool_info waits at most 3 s for live figures, then answers with the epoch’s snapshot while they’re finished and kept. A large pool’s first look from a cold disk took 12 s.address_utxos, 10 stake keys for account_info, 5 pools or DReps, one token, a page of 20 or up to 1,000 for account_txs.tx_info’s collateral, scripts, mints and proposals.jit = off: JIT took 76 of a UTxO query’s 80 ms.The 22 routes, under /api/v1/.
grest function of the same name.Shared answers are cached:
| Answer | Kept |
|---|---|
tip |
2 s |
epoch_params, totals |
an epoch |
pool_list |
1 h |
proposal_list |
5 min |
pool_info, drep_info, drep_metadata |
10 min, by ID |
the contract’s or mixBox’s credential_utxos (the fallback’s shape) |
a block |
tx_info, and 100 for tx_status (which the wallet must start chunking);db-sync’s insert_options are Koios’s own (guild-operators’ files/configs/mainnet/db-sync-config.json):
tx_out: {value: "consumed", use_address_table: true}, and ledger: "enable";offchain_pool_data and offchain_vote_data, each its own option since 13.7.0.1) and pool_stat set to "enable";json_type: "text";tx_cbor: "disable".consumed and the address table can only be set on an empty database. If the box already has a db-sync synced with other options, it has to resync.
Koios’s SQL.
grest schema and indexes.The API’s Postgres role:
statement_timeout of 5 s;Postgres runs with log_statement = none, so no one’s credentials land on disk.
tx_out is consumed: consumed_by_tx_id is filled, and tx_in is empty;tx_cbor is on;"offchain_vote_data" option, which defaults to "disable"; before, "governance" covered it. The config never got the new option, so fetching stopped at the upgrade, on 2026-03-17, with no error. The owner enabled it on 2026-10-09 and fetching resumed: 49 anchors in its first minute, with the backlog since March to work through. Koios’s own config sets it too.Postgres must wait for the network (found 2026-10-09). After a reboot it started before the server had its LAN address, couldn’t bind it, and listened on localhost alone, with only a warning. A restart fixes it once. After=network-online.target in a drop-in for its unit fixes it for good, and it applies to wg0 later too.
No grest schema is installed yet. The node and Kupo run there too.
ledger_backend: "lsm" cuts db-sync to about 2–3 GB.wg0 only;wg0 lets in the VPS alone.seedelf.host_status table gives /health the home’s state without another channel. A timer fills it with free disk and each service’s state.WireGuard, dialled out from home.
wg0. Measure the node’s P2P traffic first: the docs say about 1 GB an hour for a relay, and an outbound-only node uses less.wg0, loopback or the LAN, IPv6 included. With the tunnel down they reach nothing, never anyone from the home IP. Lookups go over TLS to a resolver that passes on no client subnet, since anyone can make db-sync look up a name. See deploy/README.md.The VPS:
Caddy terminates TLS, with no access log.
https://mainnet.<domain>, the domain being the owner’s pick.connect-src entry and no new host permission: no install warning, nothing disabled on update, and the store’s nearly full host-permission box unchanged.Limits:
Retry-After, as Koios does.Health and breakers, one per part:
| Part | Healthy when |
|---|---|
| private | the source in use (db-sync, or Kupo when db-sync is down) is within 3 blocks of the node’s tip, and that tip is under 2 min old. As built: a source’s tip under 3 min old, and db-sync within 60 slots of Kupo’s |
| public | db-sync’s newest block is under 3 min old |
| submit | the node’s tip is under 2 min old |
/health reports all three.No logs:
Its own chunk. This contract is what it builds to.
networks.ts gets a data origin beside koios, on mainnet only. Preprod has none, so it reads and submits through Koios as it does today, and a preprod-only build adds nothing to connect-src.credential_utxos scan, which gives the same view, only slower./submittx.KOIOS_LIMIT stays for Koios, and the data layer gets a limiter of its own.poolRef asks pool_info only for a ticker, and Home’s reading waits for it. A pool nobody has looked at in 10 minutes makes the server work out its live stake first, for up to 3 s: 1.6 s cold for a pool of 2,000 delegators, and the full 3 s for the largest. The ticker is in pool_list, which every wallet shares and the server keeps for an hour.seedelfs map;tx_status;madeBy reading the pool’s provenance;connect-src, which tests/manifest.test.ts pins.connect-src only);This is for the policy, in the roadmap’s words.
It never learns a UTxO, a register, a box, a transaction or an owner.
Monthly and rough; check prices when buying.
| Item | Cost |
|---|---|
| VPS: 2 vCPU, 4 GB, 1–2 TB traffic, DDoS filtering, terms that allow nodes | $6–15 |
| Domain | $0–1 |
TLS, monitoring (a free uptime check of /health only) |
$0 |
| Home power, 100–200 W around the clock | $10–30 |
| A UPS (about $150–300, once), and an NVMe replacement (about $150 every few years) | $5–10 amortised |
| Cash in all | about $20–55 |
The real cost is time:
seedelf-data/ exists, with its .env ignored by git.seedelf_reader, in place of cexplorer, db-sync’s own writer. It has SELECT only, default_transaction_read_only, a 60 s timeout and 10 connections. pg_hba admits it from the dev machine only.
pg_hba lines that let cexplorer in from the whole LAN. Narrow them once nothing else on the network needs them.grest and its cache jobs✅ db-sync’s speed (2026-10-08). On the contract, which has 2 addresses, 32 unspent outputs and 117 ever:
| Query | Warm (first run) |
|---|---|
| the snapshot, with datum, assets and point | 13 ms (96 ms) |
| created in the last ~day, still unspent | 1.2 ms |
| spent in the last ~day, with the spending tx | 8 ms |
The indexes it needs are already in place: idx_address_payment_cred, idx_tx_out_address_id_unspent and idx_tx_out_consumed_by_tx_id. Speed is no reason to keep Kupo: the builder reads once a block and answers from memory.
✅ Kupo equals db-sync (2026-10-08), once synced:
| Ever | Unspent now | Same spending tx | |
|---|---|---|---|
| contract | 117 | 32, the same set | 117 of 117 |
| mixBox | 454 | 41, the same set | 454 of 454 |
Kupo’s snapshot, with datums resolved, takes 4 ms over the LAN, and a day’s spends take 2 ms.
Deferred, “if we even need it”: freshness. That means the node’s tip, Kupo’s checkpoint and db-sync’s newest block, every 30 s across an epoch boundary. The builder reads db-sync first and switches to Kupo, so this would only tune when it switches.
seedelf-data/, built and run locally:
snapshot, since, names, the Lovejoin pool with made_by), and ✅ submittx and ogmios.
reset.MATERIALIZED CTE. Left alone, the planner took 3.5 s instead of 9.5 ms.koios.ts’s requests.
epoch_params parse with seedelf-koios’s own types;tx_info balances;asset_info equals Koios’s recorded answer, and the other routes match the recorded fixtures’ shapes.offchain_vote_data option, off by default (Home). It’s on since 2026-10-09, and the backlog is being fetched.deploy/home, deploy/edge and a runbook, seedelf-data/deploy/README.md (2026-10-09). Secrets are never committed.
src/edge.rs), so Caddy stays stock: buckets per IP, weighted 1 for a shared answer, 4 for live SQL and 10 for a submit or evaluation; the month’s egress ceiling, kept across restarts; CORS for the wallet’s origins only, a 429 included.wg0 decides what reaches it. (Dropped in 26c: the node is a stake pool’s relay.)Upstreams are config (localhost now, wg0 later). The API keeps to loopback or the LAN until the VPS layer exists.
account_txs is 4× faster, a delegators clause Koios doesn’t have is gone (some pools timed out on every look), and IDs have one spelling.query_typed for one round trip a statement; one pass for every DRep’s delegators; single-flight for answers kept a while; live tests over the heaviest keys.EXPLAIN ANALYZE on the home db-sync, for a fresh wallet, two typical ones and a busy key.
tx_out are 100–1,000× low, so the planner chose parallel workers and whole-table reads for small lookups. credential_utxos and tx_info’s outputs now read each address’s or transaction’s own outputs, with the same rows. A wallet’s own UTxOs went from 4–6 ms to 0.3–0.5 ms, a credential at 95,000 addresses from 4.1 s to 160 ms, and a page of Activity’s outputs from 5 ms to 0.6 ms.account_info went from 16–19 ms to 0.1–0.4 ms, and tx_info’s certificates and votes from 15 ms and 1.7 ms to 0.3–0.6 ms and 0.03 ms.account_addresses (under 0.2 ms), account_txs (1 ms; 16 ms for the busy key), the private index (under 2 ms warm, and read once a block).drep_info gives a retired DRep’s status as "deregistered", but Staking passed it through and Voting checked for "retired". So a picked DRep that had retired, or that never registered ("not_registered"), wasn’t blocked. It got the inactive warning instead, which says delegating to it still lets you withdraw rewards. The wallet now carries Koios’s three words throughout, the account’s own DRep included (the owner: “follow what koios is doing”). Voting blocks both, each with its own reason; the screens still say “Retired” and “Not registered”./health. Then home’s egress moves to the tunnel (dropped in 26c: home’s node is a stake pool’s relay, which keeps its inbound peers). Planned in chunk 26c, on DigitalOcean (the owner, 2026-10-09).No preprod, and no real-money tests, so verification leans on what’s free:
The private index is read-only: a bug shows a wrong balance or gets a transaction refused, but it can’t lose funds.
since from many cursors matches a fresh snapshot.tests/scripted_kupo.rs). Then real rollbacks are watched on mainnet for a day.since sizes after a day and after a month.koios.ts’s request shapes against Koios and against ours, and compares the fields the wallet reads. It runs from the VPS, on that machine’s own Koios allowance, never the owner’s.wg0;credential_utxos ever measures slow;