Seedelf-Wallet

Chunk 26 · The data layer

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:

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:

The owner’s calls (2026-10-08)

Still holding from 2026-10-02:

Choices made here (the owner can overturn any)

  1. The private index reads db-sync first (the owner, 2026-10-08): “the db sync queries will be more than enough for now”. Its contract queries take 1–13 ms (Order of work).
    • Kupo is the second source (built 2026-10-09). It’s synced, and it was checked equal to db-sync on every output of both contracts. The private index switches to it when db-sync is down or behind.
      • db-sync does its heaviest work at epoch boundaries, and an upgrade sometimes means a resync of several days. Kupo needs only the node.
      • It’s tiny: for two script hashes its database is MBs.
    • The freshness log waits until it’s needed. It would sample the node’s tip, Kupo’s checkpoint and db-sync’s newest block across an epoch boundary.
  2. The public side copies Koios’s paths and JSON. Falling back is then a change of base URL, and the WebAssembly’s UtxoResponse parser and the e2e fixtures stay valid.
    • Underneath sit Koios’s own grest SQL functions (koios-artifacts, CC-BY-4.0, credited), on a db-sync set up with Koios’s own insert options.
    • “Optimised for us” means:
      • only the parameter shapes the wallet sends;
      • our indexes;
      • cached shared answers;
      • our own SQL wherever a Koios function measures slow.
    • PostgREST is never run or exposed.
  3. Every line of custom code lives on the VPS. Home runs stock software plus SQL: the node, db-sync, Postgres, Kupo, Ogmios and submit-api.
  4. Once synced, home reaches the internet only through the tunnel. Otherwise the node’s P2P traffic, the propagation of submits and db-sync’s off-chain metadata fetches would each show strangers the home IP. A peer watching where Seedelf transactions are first seen could pin it down.
    • Dropped in 26c (2026-10-09): home’s node is a stake pool’s relay. It must keep its inbound peers, and its registration publishes the home IP already, so the tunnel carries only the API’s calls to home (runbook §3).
  5. Kupo and Ogmios must support the node’s version. Kupo 2.12 and Ogmios v7.0.0 are tested against node 11.0.1, and the current node is 11.1.3. With no preprod to try an upgrade on first, check both projects’ compatibility tables before each node upgrade.

The shape

 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.

The private index (Seedelf and Lovejoin)

What it serves. The current unspent set at two script credentials, and what changed since any point:

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.

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.

The compact row carries what the wallet uses, and nothing else:

Dropped:

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.

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:

What it takes off the per-user side

The feed gives a watch everything it needs to stop asking about one transaction:

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.

What the browser keeps

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:

  1. Decrypt the private state.
  2. Call since(cursor).
  3. Check only the new rows, in the WebAssembly.
  4. Drop the rows that were spent.

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:

  1. Fetch the shards.
  2. Check every row as it streams in: keep yours, drop the rest.
  3. Call 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.

Submits

cardano-submit-api runs at home, on the mainnet node’s socket.

The public side: Koios-equivalent, optimised for us

As built (2026-10-09), it differs from what follows in these ways. The seedelf-data README has the details.

The 22 routes, under /api/v1/.

db-sync’s insert_options are Koios’s own (guild-operators’ files/configs/mainnet/db-sync-config.json):

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.

The API’s Postgres role:

Postgres runs with log_statement = none, so no one’s credentials land on disk.

Home

Tunnel and VPS

WireGuard, dialled out from home.

The VPS:

Caddy terminates TLS, with no access log.

Limits:

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

No logs:

The wallet’s side

Its own chunk. This contract is what it builds to.

What the service learns

This is for the policy, in the roadmap’s words.

Cost

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:

Order of work

  1. ✅ The mainnet server runs the node, db-sync (with Koios’s options) and Kupo. seedelf-data/ exists, with its .env ignored by git.
  2. Make it reachable and safe from this machine:
    • ✅ a read-only Postgres role, 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.
      • Still open: the pg_hba lines that let cexplorer in from the whole LAN. Narrow them once nothing else on the network needs them.
    • ✅ the Seedelf Kupo on 1443, synced to the tip;
    • ✅ Ogmios is already there: v6.14.0 on 1337, synced. That release is tested with node 10.5.1, so the node is likely 10.5.x.
    • ✅ submit-api on 8090, once its config was in the new format (Submits);
    • install grest and its cache jobs: not needed. The API carries its own SQL (The public side).
    • ✅ find both contracts’ first-output points.
  3. Measure.
    • ✅ 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.

  4. seedelf-data/, built and run locally:
    1. ✅ the private index, read from db-sync (snapshot, since, names, the Lovejoin pool with made_by), and ✅ submittx and ogmios.
      • ✅ Kupo as its second source (2026-10-09). Both sources give the same rows in the same order, checked live from a day, two days, a month and Kupo’s first block back. With db-sync unreachable, the server’s contract answers were byte-identical to db-sync’s. A scripted Kupo forces the rollback reset.
      • Checked live: the view rebuilt from the API equals Kupo’s unspent set, and every cursor back to before the contract existed replays to the tip. The submit part passes answers through untouched, and the tests use only sends that can’t land.
      • The SQL lesson: every query gathers the credential’s own outputs first, in a MATERIALIZED CTE. Left alone, the planner took 3.5 s instead of 9.5 ms.
    2. ✅ the 20 public routes (2026-10-09), in Koios’s paths and JSON, each taking only koios.ts’s requests.
      • Checked live:
        • UTxO rows and epoch_params parse with seedelf-koios’s own types;
        • the contract’s and mix box’s listings equal the private index;
        • every tx_info balances;
        • an NFT’s asset_info equals Koios’s recorded answer, and the other routes match the recorded fixtures’ shapes.
      • Found at home: db-sync has fetched no off-chain vote data since 2026-03-17. Anchors since then (4,886) have no data, so newer governance actions have no title or abstract and newer DRep profiles no name, from this server.
        • The cause is db-sync 13.7.0.1’s new offchain_vote_data option, off by default (Home). It’s on since 2026-10-09, and the backlog is being fetched.
    3. ✅ alongside: deploy/home, deploy/edge and a runbook, seedelf-data/deploy/README.md (2026-10-09). Secrets are never committed.
      • The edge is the API’s own code (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.
      • A source is down after two failed reads of its tip (4 s). Its part answers 503 at once, rather than each request waiting on a timeout. Dropping each link mid-run checked it: the private index moved to Kupo within 5 s, and with both gone, every route answered 503 at once.
      • Only the node’s and db-sync’s traffic goes through the tunnel, routed by user. The mainnet box runs another project, which a moved default route would move too. If the tunnel drops, the node loses its peers rather than reaching them directly. Ogmios serves that project too, so its bind stays, and nftables on 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.

    1. ✅ The optimisation and review pass (2026-10-09, the owner’s order: once building was done). Four reviewers covered the private index, the public routes (in two halves), and the edge with the deploy files; a fifth then reviewed the fixes together. The fixes are in three commits, each checked live.
      • Private: a tunnel that drops packets no longer hangs the tip read, the tip has its own connection, misses share one read, caches are bounded by bytes, and cursors never go back.
      • Edge: the tunnel’s routing fails closed; a web page can’t spend the month’s traffic or reach Ogmios; answers charge their bytes; the logs can’t be turned up to show a request.
      • Public: what one request may gather is capped, so a heavy credential costs a 503, not gigabytes. 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.
      • Left for later, measured and small: 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.
    2. The first-load pass (2026-10-09, the owner’s order: before the wallet’s plan). Every query a wallet makes from create or restore to Home and Activity was put through EXPLAIN ANALYZE on the home db-sync, for a fresh wallet, two typical ones and a busy key.
      • The queries: db-sync’s statistics on 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.
      • The indexes: three of Koios’s, built at home by the owner the same day. 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.
      • Already fast: 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).
      • Left as it is: a pool’s first look, up to 3 s on Home’s path (The wallet’s side).
  5. ✅ The wallet’s chunk (built 2026-10-09), against the local API from a dev build only: chunk 26b. The store build never carries a localhost origin.
    • Found in the review, and fixed on this branch (2026-10-09), a wallet bug with Koios too: 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”.
  6. The VPS, at the end: WireGuard, nftables, DNS, Caddy and /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).
  7. The drills, then 1.4.0 (chunk 26c).

Verification

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.

Left for later