Seedelf-Wallet

seedelf-data

Seedelf Wallet’s data layer: the API in front of the home db-sync, Kupo and node. Mainnet only. The plan, and every decision behind it, is web wallet chunk 26, docs/archive/plans/chunk-26-data-layer.md.

Its own Cargo workspace, apart from seedelf-platform/, so nothing here moves the CLI’s Cargo.lock. It pins the same Rust (rust-toolchain.toml).

What’s built

Live at https://mainnet.seedelf.logicalmechanism.io (web wallet chunk 26c): a DigitalOcean droplet, deployed only through .github/workflows/data-layer-deploy.yml.

Run it locally

cp .env.example .env     # then fill it in; git ignores it
cargo run -p seedelf-data-api
curl http://127.0.0.1:8099/health

Listening: it listens on loopback (DATA_LISTEN, 127.0.0.1:8099 by default). On the VPS, Caddy is in front of it (deploy/README.md); never expose it without TLS.

Logging: it logs its start and failed queries only. It never logs a request’s address, path or body.

Reference code

_reference/ holds shallow clones to read and optimise against. Git ignores it, and its own README says what’s where. To recreate it:

mkdir -p _reference && cd _reference
for r in cardano-community/koios-artifacts blockfrost/blockfrost-backend-ryo \
         IntersectMBO/cardano-db-sync cardano-community/guild-operators \
         CardanoSolutions/kupo; do
  git clone --depth 1 "https://github.com/$r.git"
done

Token decimals are built in (api/data/token-decimals.json). Refreshing them needs a checkout of the token registry (about 500 MB), wherever it’s kept, then a build:

git clone --depth 1 https://github.com/cardano-foundation/cardano-token-registry.git
python3 scripts/token-decimals.py cardano-token-registry > api/data/token-decimals.json

Tests

cargo test                                    # offline
cargo test -- --ignored --test-threads=1      # against the real db-sync in .env
cargo clippy --all-targets -- -D warnings
cargo fmt --check

One test at a time: the database role allows 10 connections, and a running server holds some of them.

What the offline tests check, beyond each module’s own:

What the live tests check:

Checked end to end (2026-10-09): the server run once as is and once with db-sync unreachable gave byte-identical contract snapshots, since answers and names at the same tip. The Lovejoin answers differed only in made_by.inputs.

The edge

What stands between the internet and the routes, in src/edge.rs. Caddy in front does TLS and nothing else, so every limit is in code anyone can audit.

Setting What it does
DATA_ORIGINS the origins CORS lets read answers, comma-separated: the wallet’s chrome-extension://<id>. The wallet then needs no host permission. Retry-After is exposed, and a 429 carries CORS too, so the wallet can read why it waits.
DATA_TRUST_PROXY=true behind Caddy on loopback: the client is the last X-Forwarded-For address. From anywhere else the header is ignored.
DATA_EGRESS_GB_MONTH the API’s own traffic a calendar month (UTC), in GB, before every route but /health answers 503. Each day gets an equal share of what’s left, so a flood costs a day, not the rest of the month. It’s kept in egress.json in systemd’s StateDirectory (or DATA_STATE_DIR), so a restart doesn’t forget it. It counts response bodies alone: TLS and headers come on top, so watch the VPS’s own meter too.

A bucket per client: 300 units, refilled at 10 a second. IPv6 addresses are taken by their /64.

Request Costs
the private index, /health, and the shared public answers: tip, epoch_params, totals, pool_list, proposal_list 1
any other public route: one user’s live SQL 4
submittx, ogmios 10

Logs can’t be turned up to show a request. The database driver logs every query’s parameters at debug, and axum’s rejections quote bodies. Those libraries are held by a filter of their own, which every line must pass as well as RUST_LOG’s, so no directive there can raise them, however narrow its target. Checked with RUST_LOG=tokio_postgres::query=debug: no query parameters were logged.

The private index

All routes are under /seedelf/v1/mainnet/.

Route Answer
contract/snapshot the contract’s rows unspent as of the stable cursor
contract/since/{cursor} what changed after cursor: created (each with its spent, if it’s gone since) and spent (older rows spent after it), plus the new stable cursor
names every Seedelf name unspent at the tip, with the row that holds it
lovejoin/pool and lovejoin/since/{cursor} the same for the mix box. Each box carries made_by: whether its transaction spent a box itself, and each input’s payment and stake credentials
/health each part’s state (private, public, submit), the private index’s source, db-sync’s and Kupo’s tips and ages, and the node’s as Ogmios says (node, with connected): 200 while the private index and the public routes answer, 503 when either can’t. The submit part’s state is said but not counted, since the deploy compares the status with the one before

How a wallet reads it:

  1. A snapshot gives the settled view as of a stable cursor C.
  2. since/C gives the changes after it. The settled view plus created minus every spend is the view at the tip.
  3. The answer’s cursor is the new settled point C'. Apply only the entries at or below C' to the settled view. Everything above it is recomputed from the next answer, so a fork above a cursor never needs undoing.
  4. reset: true means the cursor’s own block was rolled back: start again from a snapshot. A reset carries no cursor, so none can be taken by mistake.

An answer’s cursor never goes back. A source a little behind the one that handed out from answers with from itself as the cursor.

What a cursor is: <slot>.<block hash>: a slot that’s a multiple of 200, at least 200 slots below the tip (about 10 blocks), and the hash of the newest block at or before it. The server refuses any other slot. Deep cursors almost never roll back. Shared cursors mean a request shows only roughly when its wallet last read, and that one answer serves everyone at that point.

Two sources, one answer. db-sync answers first (the owner, 2026-10-08). Kupo answers instead while db-sync is down, its tip over 3 minutes old, or more than 60 slots (about 3 blocks) behind Kupo’s, and whenever a db-sync read fails.

Caching: each source’s answers are kept until its tip moves. Both tips are read every 2 s, so one set of reads per block serves every request.

The public routes

Koios’s paths and JSON, so a wallet falls back by changing its base URL. The code is src/public/.

Route What the wallet asks Kept
credential_utxos up to 75 payment credentials’ unspent outputs, 1,000 a page by outpoint, optionally only blocks after a height; at most 20,000 in all (but the contract’s and mix box’s) a block, for the contract’s or mix box’s; otherwise never
address_utxos up to 20 addresses’ unspent outputs, paged the same way; at most 20,000 in all never
utxo_info up to 60 outputs, spent or not never
datum_info up to 60 datums’ CBOR never
tip the newest block read every 2 s
tx_status up to 100 transactions’ confirmations never
epoch_params the newest epoch’s parameters a block
totals the newest epoch’s supply a block
account_addresses up to 20 stake keys’ addresses, empty ones too never
account_info one stake key’s standing never
account_reward_history one stake key’s rewards, oldest first, 1,000 a page: Staking’s Rewards history (web wallet chunk 30), read only when it’s opened never
account_txs one stake key’s transactions: a page of 20, at most 10,000 in, or up to 1,000 from a block on; for a key with at most 200,000 outputs. The restore scan, and Public activity up to wallet 1.4.1 never
credential_txs up to 75 payment credentials’ transactions, whatever their addresses’ staking part, paged as account_txs is; for at most 200,000 outputs in all. Public activity, by the account’s payment keys (web wallet chunk 28) never
tx_info up to 20 transactions, in Activity’s shape or inputs alone never
pool_list the registered pools, 1,000 a page an hour, within its epoch
pool_info up to 5 pools’ details and live stake 10 minutes, within its epoch
drep_info up to 5 DReps’ standing 10 minutes, within its epoch; the account’s own (deposit,meta_url,meta_hash selected) never
drep_metadata up to 5 DReps’ profile names 10 minutes; the account’s own profile (is_valid selected) never
proposal_list the live governance actions 5 minutes, within its epoch
vote_list one DRep’s votes on up to 40 actions never
asset_nft_address the address holding one NFT (an ADA Handle) never
asset_info one token’s CIP-25 and CIP-68 metadata never

What one request may cost is bounded. Any credential, address or stake key may be asked about, and some on mainnet hold hundreds of thousands of outputs: one listing of a credential with 220,000 read 2.4 GB from disk for every page. So a listing gathers at most 20,000 unspent outputs, and account_txs and credential_txs at most 200,000 outputs, with a page starting at most 10,000 transactions in. Past any of these, the answer is a 503, {"error":"too large for this server"}, with no Retry-After: it would be as large again, so the wallet reads Koios, for that request alone. No wallet comes near any of them. Requests are also held to the wallet’s own sizes (one account’s standing, 20 keys probed).

Answers kept for a time are kept within their epoch, since a pool’s status, a DRep’s activity and the live actions turn at an epoch’s start. Not-found answers aren’t kept: anyone can ask about made-up IDs. When the store is full, the answer due to expire soonest makes room.

An ID has one spelling: the bech32 the wallet sends, re-encoded exactly. The decoder also takes a Bech32m checksum and stray padding bits, which would let one pool be asked for, cached and worked out under several names.

Only the wallet’s requests. Each route takes exactly the query string and body koios.ts sends: its parameters, in its order, at its sizes. Anything else is a 400, {"error":"not a request Seedelf Wallet makes"}, before any query runs, so no caller can compose an expensive one.

Answers about one user are never cached. Answers that are the same for everyone are kept for the time above. A request waits at most 5 s for a shared answer, its queue included, then gets a 503.

Koios’s names and types, for every field the wallet reads. What it never reads is left out:

The SQL is the API’s own. The home Postgres has no grest schema and no pg_cardano, and the API reads with the read-only role, so nothing at home is installed. Its indexes beyond db-sync’s own are recorded in deploy/home/db-sync-indexes.sql, with the command that lists what’s there.

db-sync’s statistics are far off, so the hot queries don’t lean on them.

What stands in for Koios’s caches:

Every connection runs with jit = off. JIT compiled plans whose estimates db-sync’s ma_tx_out inflates: 76 of a UTxO query’s 80 ms.

The public routes have their own connections: 6, beside the private index’s 3, so a burst of them never holds the private index up.

account_txs and credential_txs never join a whole history. A transaction’s id rises with its block, so a page takes the newest ids it needs, its own and every newer one, widened to the whole of the oldest block they reach, and joins only those: 0.5 s a first page for a key with 221,000 transactions, against 2.3 s before, and the same rows. A deeper page needs more of them, and one past the end of a long list would need them all, so no page starts more than 10,000 in (Activity’s 500th Load more): deeper, the answer is a 503, too large, and the wallet reads that page from Koios.

account_reward_history is Koios’s, from reward and reward_rest, the pool’s bech32 from pool_hash.view, sorted in SQL by the order the wallet asks for (spendable_epoch.asc,earned_epoch.asc,type.asc,pool_id_bech32.asc,amount.asc), oldest first by every column. The next epoch’s rewards are listed before they’re paid, as Koios’s are.

tx_info reads its parts at once, pipelined on one connection: two round trips to home, not six.

Known slow cases:

The submit part

Route Upstream The server’s own checks
POST /api/v1/submittx cardano-submit-api, POST /api/submit/tx the body is application/cbor (415 if not) and at most 16 KiB, the ledger’s max_tx_size (413 if more); at most 4 in flight (503 past it)
POST /api/v1/ogmios Ogmios, POST / application/json (415 if not: a text/plain POST needs no CORS preflight, so a web page could send one); a JSON-RPC 2.0 evaluateTransaction and no other method (400); at most 64 KiB; at most 2 in flight

A row

{
  "ref": "<tx hash>#<index>",
  "address": "addr1…",
  "lovelace": "1749860",
  "assets": [["<policy>", "<name>", "1", 0]],
  "datum": "d8799f5830…",
  "script": true,
  "created": { "slot": 144889937, "time": 1736456228 },
  "spent": { "slot": 199943000, "time": 1791509291, "by": "<tx hash>" },
  "made_by": { "mixed": true, "inputs": [["<payment cred>", "<stake cred or null>"]] }
}

The SQL

Every query starts from the credential’s own outputs, in a MATERIALIZED CTE. Left to itself, the planner can start from every transaction since a cursor instead: for two days of mainnet that’s 880,000 transactions and 3.5 s, against 9.5 ms this way.

Every value is cast or encoded to a plain type in SQL, so db-sync’s domain types never reach the driver.

The role it reads with is read-only: SELECT only, default_transaction_read_only, a 60 s timeout.