Seedelf-Wallet

Chunk 26c · The VPS, then 1.4.0

Steps 5 and 6 of chunk 26, its last. The data layer goes public on a DigitalOcean droplet, home’s services are reached through a tunnel, and the drills run. Then the store build gets the API’s address, and that’s 1.4.0.

Status: ✅ done, in 1.4.0: the VPS, the deploy workflow, CI and the drills (#291–#293), then the 1.4.0 PR (#294), deployed and packaged (After the merge) and recorded (#295). Archived at 1.4.1, its open items carried into post-release-roadmap.md’s Kept in mind.

Done so far (2026-10-09):

Then the 1.4.0 PR.

Home’s traffic stays home’s (owner, 2026-10-09). The home box, logicalmechanism-relay, runs a stake pool’s relay, which must keep its inbound peers: there’s no money for a second mainnet node. So the node’s and db-sync’s egress through the tunnel (the old runbook §3) is dropped, and with it the VPS’s forwarding and NAT, the routes unit, the kill switch and DNS over TLS. A registered relay publishes the home IP already. A user still meets only the VPS. See runbook §3.

The 1.4.0 PR

The brief it started from, kept as written; what was done follows it.

The branch is web-wallet/release-1.4.0, from main at #293’s merge (56696f2). It’s the release PR, shaped like 1.3.0’s (#286): a review, its fixes, the lists and the bump. After the upload comes a release-1.4.0-record PR with the package’s commit and SHA-256, as #287 was. No tag: the handoff note is the record (post-release-roadmap.md). If the owner wants one, it needs a slash: web-wallet/1.4.0.

Read first:

Since 1.3.0: #288–#293, which are the data layer’s plan, the API (#289), the wallet’s side (#290, reviewed on its own) and the VPS (#291–#293, no wallet code). What ships is #290’s code in a store build that finally has an origin.

The work:

  1. The release review of #288–#293’s wallet-facing code, as for 1.2.0 and 1.3.0: by area, then where the areas meet, with every finding verified. It’s new in that #290 runs with the origin set, in the store build.
  2. The origin:
    • networks.ts’s mainnet data is https://mainnet.seedelf.logicalmechanism.io;
    • tests/manifest.test.ts’s store CSP gains it in connect-src, still with no host permission;
    • scripts/package.mjs’s checks pass.
  3. The docs, in the same PR: item 8’s list, plus every line that says the store build has no origin yet: development.md’s The data layer, locally, architecture.md’s chain data, and CLAUDE.md’s seedelf-koios line.
  4. The facts the privacy docs state, all checked in this chunk:
    • a mainnet user’s requests go to mainnet.seedelf.logicalmechanism.io, a DigitalOcean droplet in New York;
    • Caddy and the API log no address and no path (runbook §5);
    • the rate buckets per IP live in memory alone;
    • the API reads home’s node, db-sync and Kupo through WireGuard, so home never sees a user;
    • a submitted transaction’s first hop is Logical Mechanism’s stake pool relay, whose registration publishes its IP;
    • each part falls back to Koios on its own, and Settings → Network’s Read Cardano through Koios only turns the API off;
    • preprod reads Koios alone.
  5. The house rules:
    • the privacy policy gets a dated Changes entry in this PR, and there are never release notes;
    • docs don’t date releases or describe the store;
    • before recommending any change to a store declaration, say what it publishes on the listing;
    • the screens’ words that name Koios where the data layer may answer change in en, es and ja, through i18n: no t() at module level, and critical keys named .warn. or .privacy.;
    • always Seedelf, which words.test.ts enforces.
  6. The release: development.md §1–§3.
    • npm version 1.4.0, then npm run tokens and npm run dreps. dreps asks again and refuses a lossy list, and the outcome goes in the handoff note.
    • Then the checks, and the merge.
    • Then the package on main, the upload (the owner) and the record PR.

The owner’s calls, still open:

On the server: the running API is #291’s build (e737c6f1…), and nothing since has changed its code. If the review changes seedelf-data/, deploy through the workflow (Actions → Data layer deploy, approved) and check status after.

What the PR did (2026-10-09)

The owner’s calls, asked at the start:

The origin: networks.ts’s mainnet data is https://mainnet.seedelf.logicalmechanism.io, so every build without VITE_DATA_ORIGIN reads it, dev builds included (the dev ID is in DATA_ORIGINS). tests/manifest.test.ts pins it in the store CSP’s connect-src, and in no host permission. The unit suite passes with no network at all (unshare -rn), so no test reaches the live API.

The release review of #288–#293’s wallet-facing code: five area reviewers (the fallback and submits; the private index and its sealed state; watches, Lovejoin and sessions; the public side’s parity with Koios, checked against Koios’s own SQL; the API’s edge and privacy), and one reviewer walking a mainnet user’s whole life across them. No high finding. Each fix has a test that fails without it.

Found Severity Fixed
The API kept the account’s own DRep standing and profile 10 minutes, shared: after a retirement, Staking still offered Retire and votes, and the node refused what was built on it medium the own DRep’s drep_info and drep_metadata (deposit,meta_url,meta_hash and is_valid selected) are read live and never kept (public/governance.rs, a live test)
A lost connection to the data layer, then Koios’s “spent”, made a public-account payment maybe sent, and its slot held every payment back about 2.5 hours though another transaction had spent its input low one a resend finds can’t land (inMempool false) goes after its 20 minutes unseen, as a private one does (pending.ts), and Home’s Details say so (PendingBanner.tsx)
A submit’s 429 held the data layer’s whole limit, so Home’s reads and the private index waited it out instead of going to Koios low only Koios’s own submit holds the limit; the data layer’s submit part goes down alone
cache: "default" covered the data layer’s GETs too, which gain nothing, so an ADA Handle’s or a DRep ID’s address stayed off the disk only by the server’s word (a rollback to #291’s build loses it) low postCache: a POST alone asks default
A refused write of a feed cursor (session storage full) failed the whole private reading and Lovejoin’s pool read low noteCursor never fails its reading; a watch with no cursor asks tx_status
A private row keeps the decimals the API gave it until it’s spent, and the API’s come from the registry as of its build low the registry is refreshed at each release with the lists (development.md); it was unchanged for 1.4.0 (1,172 tokens, the registry’s last commit 2026-10-01)
A Lovejoin return’s last step was described as on the pool’s feed nit (latent) the comment: it’s never asked there, and landed watches it on the contract’s feed
Docs: the preflight cache is keyed by the full URL, query included; the server’s name is seen on the user’s network (DNS, TLS); stale lines in seedelf-data’s README, edge.rs and CI nit said in the policy, privacy.md and architecture.md; the stale lines fixed

The store’s own ID, end to end (the owner asked for certainty that CORS holds once live, 2026-10-09). The published package (fetched from the store’s update URL) carries the ID dkefopeefhophfkjkkdebhoklagjdmcp in its signed header, and its public key. A store build (VITE_ENABLE_MAINNET=true VITE_STORE_BUILD=true) given that key as its manifest’s key loads unpacked under the store’s ID. In Playwright’s Chromium, it restored the public BIP39 test phrase on mainnet and read the balances against the live server:

Build Balances Requests Junk submit from the worker
the store’s ID 1.6 s 13, all to mainnet.seedelf.logicalmechanism.io, preflights included, none to Koios; a second POST to a route reused its preflight 400, readable
the store’s ID, with https://*/* granted (the connector on) 1.4 s 8, all to the server (no preflights with the grant) 400, readable
control: no key, so an ID the server doesn’t know 27.8 s 6 refused by CORS, then 5 to Koios Failed to fetch

So the test sees a wrong ID, and the store’s passes it: CORS holds for the listing’s own ID in the browser, not only in curl. The server side, by curl with that Origin: a preflight for a JSON POST (200, content-type allowed, max-age 86400), a GET, and a CBOR submit’s 400 all carry Access-Control-Allow-Origin for it; another ID gets a 403 with none. The deploy workflow checks the store ID’s CORS line after every deploy.

Checked and sound: every request koios.ts makes is one the API accepts, inside its caps, with Koios’s shapes (rule for rule against Koios’s grest SQL); CORS for both IDs on every answer the API makes, 429s and 503s included; the bucket’s IP can’t be spoofed through Caddy; ordinary use of one wallet can’t trip a 429; Caddy and the API log no client address and no path; the sealed record stays consistent across lock, restart, account and network switches and the Koios-only switch; a 1.3.0 wallet updating carries nothing over that a feed watch could misread.

Left as found:

The lists: npm run tokens left both token lists as they were; npm run dreps named 459 mainnet DReps (454 committed, 5 new, none lost) and preprod’s 62 unchanged. The API’s token decimals, refreshed from the registry, were byte-identical.

The docs: the privacy policy (a dated Changes entry, In short, the data table, The services the extension talks to), privacy.md’s Network, store/README.md (the description, the host and remote-code text, the data-usage answers; no box changes, so the listing’s privacy label stays as it is), the root README’s De-Anonymizing Via IP Tracking and Data Layer Reliance, architecture.md, development.md, flows.md, CLAUDE.md, seedelf-data’s README, and 26b’s Left for later.

After the merge

Done for 1.4.0: PR #294 merged as b5ad15a; the API deployed from it through the workflow, and the live API then answered the store’s ID with cache-control: no-store; the package built from it, SHA-256 b1ba594f3d4f2705c075e51c3ca561613db12528bbd76405ff045bcf74571c0a (roadmap.md’s handoff note has the rest). The steps, for the record:

  1. Deploy the API from the merge commit: Actions → Data layer deploy, deploy, approved, then status. Then check from anywhere: curl -sI -H 'Origin: chrome-extension://dkefopeefhophfkjkkdebhoklagjdmcp' https://mainnet.seedelf.logicalmechanism.io/api/v1/tip says cache-control: no-store. The package isn’t uploaded before this.
  2. The package, on main at the merge commit (development.md): npm run package, npm run e2e on it, the SHA-256.
  3. The upload (the owner), and the release-1.4.0-record PR with the commit and the hash, as #287 was.
  4. Rolling back the API after 1.4.0 ships brings back #291’s build, which sends no no-store; the wallet’s GETs stay no-store themselves, so nothing private reaches the disk, but deploy forward rather than leave it there.

Where 26c started

  1. Branch web-wallet/data-layer-vps from main, after #290 is merged.
  2. Read:
  3. The owner runs everything on home and in the DigitalOcean account. Claude prepares the files and the commands, and checks the results. Never SSH into home unasked: every server change so far has been the owner’s.
  4. Get the decisions settled before the first droplet exists.

The owner’s calls this builds on

Decisions before anything is built

1. The address the wallet calls

It’s compiled into every installed wallet and its CSP, so it’s picked once. A new address means a new release, with every wallet on Koios until it updates: slower, never broken.

  A domain An IP address
What the wallet calls https://mainnet.<domain> https://<reserved IP>
Cost a subdomain of one already owned is free; a new one on Namecheap is about $10 a year nothing: DigitalOcean’s reserved IPs are free while attached to a droplet
TLS Caddy’s automatic certificate, 90 days Let’s Encrypt’s IP certificates, generally available since 2026-01-15: 6-day certificates only (profile shortlived in Caddy), renewed by Caddy
Moving the server change DNS; no wallet release the IP stays only inside DigitalOcean (a reserved IP moves between droplets); leaving DigitalOcean needs a release
What a user’s network sees a DNS lookup naming the API, and its name in the TLS handshake only the IP
Certificate transparency the name is public the IP is public

The recommendation is a domain, for the freedom to move. A subdomain of a domain the owner already has costs nothing.

Settled (owner, 2026-10-09): https://mainnet.seedelf.logicalmechanism.io, a subdomain of the owner’s business domain, whose DNS is on DigitalOcean.

No reserved IP. The wallet calls the name, so the droplet’s IP lives only in the DNS record and home’s wg0.conf (Endpoint), each a minute to change. A droplet keeps its IP when it’s resized or rebuilt; only destroying it loses the IP. One can still be added at any time, with no release.

2. DigitalOcean’s terms

The acceptable use policy forbids two things that come near what this is, checked 2026-10-09:

Ask DigitalOcean’s support to confirm in writing. Describe it as it is: a Cardano node at home, with its peer traffic leaving through a private WireGuard tunnel to the droplet, and an API on it. Keep the reply with this file’s record.

Settled: DigitalOcean said it’s fine (the owner, 2026-10-09). The owner holds the reply.

3. The droplet

The work, in order

  1. The droplet, not public yet (runbook §2): packages, WireGuard, nftables, the API as a service, and the checks of home from the VPS. Home’s side first (runbook §1): the tunnel and its firewall, then Postgres’s listen address and pg_hba. Kupo, Ogmios and submit-api already listen on every address, so they stay as they are.
  2. Home’s traffic into the tunnel (runbook §3): dropped, since home’s node is a stake pool’s relay.
  3. Going public (runbook §4): the DNS record, or the IP certificate, and Caddy.
  4. The checks (runbook §5). The CORS check runs for both IDs DATA_ORIGINS holds:
    • the store’s, dkefopeefhophfkjkkdebhoklagjdmcp (the listing’s own, unchanged since 1.0.0);
    • the dev build’s, jfekiogplaamnceifeehipmomhojngcb, so a dev build can be pointed at the VPS.

    A wrong ID is silent: every wallet falls back to Koios, and nothing says why. So this check is also the deploy workflow’s.

  5. The deploy workflow, .github/workflows/data-layer-deploy.yml, run by hand only (workflow_dispatch):
    • build: cargo build --release -p seedelf-data-api with the pinned toolchain (seedelf-data/rust-toolchain.toml) on ubuntu-24.04. Its glibc (2.39) is older than the droplet’s (2.43), which is the safe direction; the binary needs 2.34. The token decimals are built in (api/data/token-decimals.json), so refreshing them is a deploy too;
    • ship: over SSH, as a deploy user. Its key in authorized_keys may only run one fixed script (command=), which takes the binary on stdin;
      • the key and the droplet’s host key are secrets of a GitHub environment that asks the owner to approve each run;
      • as built: the host key and the host are the environment’s variables, not secrets: neither is secret, and a variable can be read back to check. A status action joins deploy and rollback;
    • install, check, roll back: the script keeps the running binary as .prev, puts the new one in place and restarts the service. It then checks /health and the store ID’s CORS line, and puts .prev back and restarts if either fails;
    • roll back by hand: the same workflow with a rollback input swaps .prev back;
    • what’s on the droplet: the script, the deploy user, and a sudoers line for that script alone. All three are templates in deploy/edge/.
  6. CI for seedelf-data/, which has none today: on a PR touching it, cargo fmt --check, cargo clippy --all-targets and cargo test with the pinned toolchain. The live tests stay #[ignore]d.
  7. The drills (runbook §5’s table): Kupo, Postgres, db-sync alone, the node, the tunnel, home’s power, and the VPS, each stopped while a dev build points at the VPS. Each part should move to Koios on its own and come back. Use the 26b driver’s approach: a Playwright script over the worker’s UI port, logging every request, as described in 26b’s Verification.
  8. The 1.4.0 PR, once the drills pass:
    • the origin: networks.ts’s mainnet data gets the API’s https:// address. tests/manifest.test.ts’s store strings gain it in connect-src, and still no host permission;
    • the user-facing docs, in the same PR, before it ships (26b’s list):
      • the privacy policy: a dated Changes entry, the In short, the data table, The services the extension talks to;
      • privacy.md;
      • store/README.md: the descriptions, and the remote-code and data-usage answers;
      • the root README’s De-Anonymizing Via IP Tracking and Data Layer Reliance;
      • the screens’ words that name Koios for answers the data layer may give now (“Koios has no details for this pool”, “Koios returned no tip”);
    • before recommending any change to a store declaration, say what it publishes on the listing;
    • the release itself: npm run tokens and npm run dreps, the version to 1.4.0, the package, and the record PR with its commit and SHA-256, as development.md says. There’s no tag unless the owner wants one, which then needs a slash: web-wallet/1.4.0.

The drills (2026-10-09)

A dev build pointed at the VPS ran a fresh mainnet wallet (no funds), driven over the worker’s UI port as in 26b. It read balances and UTxOs every 30 s, and governance every fourth time, and logged which host answered each request. A poller read /health every 3 s. The owner stopped each part on the home server, or the API on the VPS. Times are UTC.

Stopped The API The wallet Back
Kupo (22:51) nothing changed: 200, source db-sync, Kupo’s age growing every read on the API caught up in 4 minutes; nothing to come back to
db-sync alone (22:56:25) private from Kupo at 22:57:28, once db-sync was 60 slots behind; public 503 at 22:59:21, its tip 183 s old public reads to Koios at 22:59:33; private stayed on the API API 200 2 minutes after db-sync started; wallet at its first reading after the 5-minute hold
Postgres, db-sync first (23:06:15) 503 within 4 s: private from Kupo, public down public to Koios at the next cycle; private stayed API 200 once db-sync caught up (2 minutes). The wallet’s retry at the hold’s end landed before that and failed, so the hold began again: back 4 minutes after the API
the tunnel (23:18:20) every part 503 within 5 s; a junk submit got 503 unreachable with Retry-After: 5 in 3.3 s seen from home, the API’s 3 s connect limit and the trip to New York (with the tunnel up: the node’s 400 in 0.5 s) everything to Koios at the next cycle (that one took 10 s, the rest 3 s); the relay ran on API 200 within 2 s of the tunnel; Postgres answered on 10.88.0.2 with no restart; wallet after its hold
the API on the VPS (23:43:33) Caddy’s 502 within a second, with no CORS headers everything to Koios at the next cycle, the 502s and the CORS-blocked requests alike API 200 within 2 s; wallet after its hold

Not run: the node, which is a stake pool’s relay, and home’s power. What the API sees of them is drills 1, 2 and 4.

Learned:

Gotchas known now

Verification