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).
/api/v1/submittx and /api/v1/ogmios paths, passed to the home cardano-submit-api and Ogmios./api/v1/ paths and JSON, taking only the requests koios.ts makes.Live at https://mainnet.seedelf.logicalmechanism.io (web wallet chunk 26c): a DigitalOcean droplet, deployed only through .github/workflows/data-layer-deploy.yml.
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/ 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
docs/api/v2.11.0.yaml is the HTTP API the private index’s second source reads. Its filters are inclusive.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
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:
tests/scripted_kupo.rs): a fork under a cursor answers reset, and with no source fresh the index answers 503;tests/edge.rs): CORS for the wallet’s origin alone, a 429 the wallet can read, a bucket per client Caddy names, the month’s ceiling, and no-store on every answer but a preflight.What the live tests check:
credential_utxos equal the private index, each row’s tokens and decimals included;tx_info balances: inputs and withdrawals against outputs, fee, deposit and donation;credential_txs lists only transactions under them, and every one of its stake key’s newest that is; its next page and its answer from a block carry on from it;asset_info equals Koios’s recorded answer;account_info’s rewards available plus withdrawals.tests/live_private.rs):
made_by.inputs;mixed is the same from both.All inputs are spent), and Ogmios’s error for evaluating it, each passed through as the service gave it. It also covers what the server refuses itself.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.
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 |
Retry-After. A wallet paces itself under this: its data layer client keeps to 80 units every 10 s, by these weights (DATA_LIMIT in the wallet’s data-layer.ts).no-cors fetch, a page load), or one from an origin that isn’t the wallet’s. The browser would hide the answer from the page but still download it, so any site’s visitors could spend the month’s traffic. A request with neither header (curl, a monitor) passes, and /health always does.Cache-Control: no-store, refusals and 429s included. The wallet asks with cache: "default" (web wallet 1.4.0), so Chrome reuses a preflight for as long as its Access-Control-Max-Age allows (24 h here, which Chrome caps at 2), instead of sending one before every POST.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.
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:
C.since/C gives the changes after it. The settled view plus created minus every spend is the view at the tip.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.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.
made_by.inputs is null. Kupo indexes outputs at our two credentials, not who paid for a transaction.Retry-After, and the wallet goes to Koios.X-Most-Recent-Checkpoint), so it’s always of one chain state.reset from Kupo.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.
since answer from an old cursor can be hundreds of KB, and anyone can ask for one. The answers every wallet reads (the snapshots, the names, epoch_params, totals) are kept whatever the budget holds.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:
value is null): trimmed() drops both, registers come from the bytes, and anyone can nest a datum thousands of levels deep;tx_info: collateral, reference inputs, mints, scripts and proposals, and every certificate but the seven an account’s own transactions make.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.
grest functions (koios-artifacts, CC-BY-4.0) rule for rule: an account’s standing, a pool’s state and live stake, a DRep’s activity and delegators, a token’s minting metadata.address.stake_address_id: 2 ms, against 6.7 s through tx_out.stake_address_id, which db-sync doesn’t index. An address is found by its bytes (address.raw), not its text.
The exceptions are three of Koios’s own, on small tables: delegation_vote by account and by transaction, and voting_procedure by transaction. Together they’re 25 MB, and nothing else reads those tables faster. Built at home on 2026-10-09:
| Query | Before (the table read whole) | After |
|---|---|---|
account_info |
16–19 ms | 0.1–0.4 ms |
tx_info’s certificates, a page of Activity |
15 ms | 0.3–0.6 ms |
tx_info’s votes, a page of Activity |
1.7 ms | 0.03 ms |
src/ids.rs).db-sync’s statistics are far off, so the hot queries don’t lean on them.
tx_out and counts 44,000 distinct addresses (there are 54 million) and a million distinct transactions (there are 125 million). It then expects 243 unspent outputs at every address and 343 outputs on every transaction.The fix: each address’s, transaction’s or token’s outputs are read on their own, behind offset 0, so the planner can’t swap the per-key lookups for something its estimates make look cheaper. Measured on 2026-10-09, with the same rows before and after:
| Query | Before | After |
|---|---|---|
credential_utxos, a wallet’s own 75 credentials |
4–6 ms (parallel workers for 3 rows) | 0.3–0.5 ms |
credential_utxos, a credential at 95,000 addresses |
4.1 s (it read every unspent output on the chain: 26 GB a page) | 160 ms |
tx_info’s outputs for a page of Activity |
5 ms (parallel workers for 40 rows) | 0.5–0.8 ms |
asset_nft_address, an ADA Handle last moved five months ago |
2.5–3.5 s, and past the 10 s cap for some (it walked every output, newest first, to the handle’s) | 0.4–22 ms |
parallel_setup_cost does nothing for these plans.What stands in for Koios’s caches:
api/data/token-decimals.json, made by scripts/token-decimals.py from a checkout of it and built into the API. Refresh it the way the wallet refreshes its token list: the script, a commit, a build.pool_delegators_list counts them. Their reward sums change only at an epoch’s start, so each account’s is kept in memory for the epoch.
pool_stat) while they’re finished in the background and kept.ma_tx_mint, which db-sync indexes by token.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.
credential_txs gathers nothing past the cap: a count from tx_out’s index alone gates the gathering, in the same query, so Minswap V2’s pool script is refused in 0.1 s, against 4.9 s gathering its outputs to find out. account_txs is as it was.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:
drep_info reads delegation_vote whole, for every account’s latest vote delegation: 260 ms for the DRep with the most delegators, then kept.account_txs or credential_txs joins every newer transaction too, so the deeper it starts, the more it costs, which is why none starts more than 10,000 in: for a busy key, 60 ms for that deepest page against 30 ms for its first (warm, at home, 2026-10-12).drep_info comes back in under a millisecond instead of 200–400 ms, so a prober could tell someone asked about that DRep in the last 10 minutes. Sharing answers is what keeps home’s load flat, so this stands, as it does for every kept answer.| 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 |
Retry-After: nothing reached the node, so the wallet sends through Koios at once./health gates both paths (below), so an Ogmios restart sends submits to Koios for its few seconds./health is read every 2 s, as db-sync’s and Kupo’s tips are, and while the node’s tip is more than 3 minutes old, Ogmios can’t reach the node (it replays its ledger after a restart), or /health has failed two reads in a row, both paths answer 503 behind with Retry-After, and the wallet goes to Koios at once. It counts the tip’s own slot, not networkSynchronization (a step of its last decimal is about 48 minutes) or lastTipUpdate (a node catching up moves its tip every moment). Without an Ogmios configured, the node’s sync is unknown, and the submit part answers 503.{
"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>"]] }
}
assets, datum, script, spent and made_by (Lovejoin only).[policy, name, quantity, decimals]: hex, hex, a decimal string, and the token registry’s decimals, 0 for a token it doesn’t list, as Koios gives them in an asset_list. The wallet shows a token its own list lacks by these, without asking about the token. Both sources fill them from the same file, so their rows stay the same.datum is the inline datum’s raw CBOR. The wallet’s own parser decides what it means.made_by.inputs is null when Kupo answered.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.