Seedelf-Wallet

Launch review (2026-09-26)

A review of the whole web wallet before mainnet, after the crypto review. Lovejoin went live on mainnet on 2026-09-26 and Minswap is ready, so it also checks what mainnet needs. It was run on web-wallet/crypto-review at 3e79ebb. Nothing is fixed here: the fixes go on a launch-prep branch.

How it was done:

Verdict: the protocol side is ready for mainnet, and the wallet isn’t yet.

Status (2026-09-27)

Since: an independent review found more, and fixed it on the same branch: see independent-review.md.

Every item below is fixed on web-wallet/crypto-review, except what Still open lists. That’s M1–M6, H1–H9 and #10–#62.

A second adversarial review, of the fixes themselves, found 30 more issues, all now fixed. The worst five:

Checked on the branch (2026-09-27):

The owner’s calls (2026-09-26):

Beyond the table’s fixes:

Still open:

  1. By hand, on a mainnet build, with small amounts. The owner ran this on 2026-09-28 and reports the basic functionality working; what the chain shows is marked done, and the rest still stands.
    • Minswap:
      • that agg-api.minswap.org answers the extension’s origin with CORS done: a live swap quoted, ordered, filled and paid out from the extension;
      • that aggregator_fee is a lovelace string done: the same swap was funded on it and settled;
      • that exclude_protocols accepts VyFinance and MuesliSwap;
      • one swap through each protocol in MAINNET_PROTOCOLS: one route so far, so checkOrder is known to pass that one’s orders and no other’s;
      • Stop: not exercised, the order filled first.
    • Lovejoin: seed the pool from identities unrelated to any Seedelf key, then run one box at depth 1 done, deeper: the pool was seeded from a second wallet (39 boxes of 10 ₳, over the floor of 30) and a swap’s return ran three boxes two waves deep — a deposit, twelve mixes and the return, all confirmed. The mainnet mix_box hash is confirmed against real transactions for the first time, and a mix measured 0.8225 ₳, matching networks.ts’s mixCost.
    • Seedelf: a Seedelf funded from the private balance, and a return merged back into it, are done; the owner reports the other flows working, which the chain hasn’t been read for here.
  2. A validity interval on private spends: they still have no expiry, until a live check shows giveme.my accepts one.
  3. The store (web-wallet/release-prep):
    • a new listing, or an update of the unlisted preprod item a new listing;
    • screenshots from mainnet-shaped fixtures done: the five show mainnet, with no test-network strip;
    • the Location and Web history data-usage answers done: both checked at submission, reasoning in store/README.md;
    • no version bump, then the zip’s SHA-256 in the roadmap done: submitted as 1.0.0 on 2026-09-28, SHA-256 85c0fb09…714b, recorded in the roadmap’s handoff notes.
  4. Upstream: Lovejoin’s config and SDK swap Seedelf’s preprod and mainnet reference UTxOs. Its config/network.mainnet.json also still has mix_script_hash: null, though the hash the wallet uses is now confirmed on chain.
  5. Smaller items, left for later:
    • A certificate carries no network, so a site can still ask for a stake-key-only signature over the other network’s transaction.
    • #21: the order’s minimum and receiver fields aren’t decoded, so Minswap is trusted for them.
    • #14 and #15’s refusals reach only the site, not the user.
    • A chain’s last window isn’t watched until it lands.
    • Chain records live only on this device.
    • The Lovejoin page’s buttons aren’t disabled while a payment may still go through. The worker refuses them, and the page shows why.
    • One maybe-sent watch per network.
    • A resend’s “not on chain” count isn’t kept across pump calls.
    • The CLI-only advisories for h2 and spin.
    • The restore scan (known).
    • A swap’s return records no reason when its spare ADA pays for no box: chain returns nothing, and the reason is only kept for a mix (record?.mix), so the session screen says nothing and the return looks as though Lovejoin was skipped. Found on the owner’s mainnet run, 2026-09-28; the approval does say it, so nothing is hidden before the fact.
    • A swap’s review says “less than a box’s worth … comes back at once” without the amount, so the user can’t see what will not be mixed until it lands.
    • MIX_FEE_ESTIMATE is 0.95 ₳ and a mainnet mix measured 0.8225 ₳, so a chain over-reserves about 0.13 ₳ a mix. It only ever leaves a box unbought when the spare lands in that band.
  6. A privacy-by-default analysis, at the owner’s ask: done. See privacy-review.md and its Status, which lists what’s fixed, what the owner declined, and what’s still open.

Mainnet

Lovejoin’s contracts. Mainnet runs the same logic the wallet was built against (Lovejoin fa5b605), recompiled with Aiken 1.1.24 and stdlib v4.0.0, so the hashes are new.

# What Fix
M1 Lovejoin is off end to end in a mainnet build. There are five gates:
• Protocol::of bails on mainnet (seedelf-core/src/lovejoin.rs:82);
• LOVEJOIN_MIX_BOX lists preprod only (background/lovejoin.ts:179);
• three UI gates test network === "preprod" (Dapps.tsx:162, Settings.tsx:104, Swaps.tsx:490).
Opening some but not all breaks every mainnet session return, or sends returns through Lovejoin with its settings and notice hidden.
Open them in one change, driven by one source (a lovejoin entry in networks.ts).
Mainnet values (_reference/Lovejoin/artifacts/mainnet/addresses.json):
• mix_box c145c10f…1fad, mix_logic 0dad3046…499e, denom 10 ₳;
• references f89cb43a…175c#0 (the protocol datum and NFT), 7d21335f…416a#0 (mix_box, 629 B) and 2452c3e6…c7b6#0 (mix_logic, 3,161 B).
Script size: derive script_bytes from the bundled outputs. Preprod’s 3,156 underprices every mainnet mix and withdraw by 75 lovelace, and the node refuses them.
Rebuild the outputs from Koios utxo_info, and check (160 + size) × 4,310 against the locked lovelace.
Tests: flip lovejoin_is_not_on_mainnet (core tests/lovejoin_test.rs:384) and the wasm funding("mainnet") assertion, and add mainnet evaluation tests.
M2 npm run package builds a preprod zip (extension/package.json:15). Nothing sets VITE_ENABLE_MAINNET, and scripts/package.mjs doesn’t check the network. build:store sets the flag, and a build:store:preprod stays for tests. package.mjs takes the network, refuses a manifest whose hosts don’t match it, and puts the network in the zip’s name.
M3 A mainnet build still asks for preprod’s hosts (networks.ts:41, enabledNetworks(true) is ["mainnet","preprod"]). Nothing in it can use them: the network is fixed per build, and the switch architecture.md promises doesn’t exist. Mainnet builds are mainnet only (recommended), or build the switch. Update manifest.test.ts and architecture.md’s Networks.
M4 The store listing is preprod only (docs/store/README.md:3-7, :39, :109): “a test build… test ADA only”. The test instructions and the host justifications cover only preprod. A mainnet listing: real-money wording, justifications for api.koios.rest and api.coingecko.com, new test instructions, and Lovejoin in the services list.
M5 Mainnet’s Lovejoin pool is empty, and the wallet can’t seed it: every mix needs two boxes that aren’t the wallet’s. Depth 2 needs 8 per box, and depth 3 needs 26. Until then the tile refuses, and returns come back directly with a warning. That part is safe. An owner’s decision (see Decisions): seed it, set a pool floor, and choose the default.
M6 The end-to-end suite only runs against preprod (e2e/support.ts:233). The release checklist runs it on the store build, and on a mainnet build 5 of 6 tests fail. tests/tokens.test.ts:52 also assumes preprod. Run the full suite on the preprod dev build. Give the mainnet store build a small smoke project. Make the token test build-aware, and run Vitest once with the flag in CI.

Checked against the chain and correct:

High

These are all confirmed by two independent checks. Each has one fix, and several merge findings that share a cause.

H1. A site’s signing prompt leaves out the account’s rewards and deposit refunds (wasm/src/cip30.rs:1054).

H2. Lovejoin withdraws boxes that were never mixed (sessions.ts:1395, lovejoin.ts:570, lovejoin.ts:651-695).

H3. Every key “owns” the register (identity, identity) (seedelf-crypto/src/register.rs:175).

H4. One UTxO with deep JSON makes the WebAssembly refuse a whole request (seedelf-koios/src/koios.rs:134, :150).

H5. The contract index can outgrow session storage (contract-scan.ts:153, the write at :96).

H6. Token totals past u64 freeze a session’s return (seedelf-core/src/utxos.rs:342).

H7. Disconnect closes a private session while money is on its way (sessions.ts:629).

H8. Junk at Lovejoin’s address turns a skip into a stuck return (lovejoin.ts:414, and :343, :355).

H9. Chains collide over the same pool boxes (lovejoin.ts:324, sessions.ts:852).

Medium

# What Where Fix
10 A submit Koios didn’t answer is treated as not sent, and the retry the wallet asks for pays twice.
• A KoiosBusyError (a timeout, 429 or 5xx) “may or may not have gone through” (koios.ts’s own words).
• The user presses Send again and gets “already spent… review it again”. The review picks other UTxOs and pays a second time.
• A Lovejoin withdraw that times out keeps its due time, so a second box goes a minute later, undoing crypto-review #4.
script-spend.ts:175, move-in.ts:85, lovejoin.ts:691 A maybe sent state:
• remember the inputs as spent;
• keep a pending entry;
• resolve with tx_status before another build;
• on “already spent”, check tx_status and finish as a success if it landed.
11 Sessions treat a maybe-sent funding as failed.
• An ambiguous submit marks it unsent, so the stage is “failed” at once, and Forget or Disconnect drop a session whose funding then lands.
• auto.failed is never cleared: a swap whose funding lands late shows “Running” forever, and no button moves the money.
• A timed-out step rebuilt after 2 minutes can leave the session unable to close.
sessions.ts:1536, :1004, :990 Tell unsent from maybe-sent. Check the account before declaring a funding failed, and clear failed when the funding shows up. Keep replaced copies in the tx_status check.
12 UTxOs carrying a reference script are counted and chosen, but can’t be spent.
• Private side: the evaluator refuses them (eval.rs:56). One sent to a session’s address blocks every return, and anyone can send one. One paid into a Seedelf breaks Max withdraw.
• Public side: the fee leaves out Conway’s reference-script fee for spent inputs, so the node refuses with FeeTooSmallUTxO.
• The per-byte fee is hard-coded at 15 (lovejoin.rs:59, build.rs:860).
eval.rs:56, build.rs:748, build.rs:1745, wasm/src/lib.rs:580 Treat them as unspendable on both sides, and show them on UTxOs. A session return with one takes the plain sweep. Price the reference-script fee from min_fee_ref_script_cost_per_byte, tiered, wherever inputs carry scripts.
13 dApp: an input the wallet can’t resolve is still signed by the account’s key, with partialSign, and the prompt calls it someone else’s. A site can take a pending output (a Send’s change, a top-up) once it lands. Reproduced. cip30.rs:753 Refuse a payment-key signature while any input or collateral input is unresolved.
14 dApp: a site’s transaction can spend locked UTxOs and the collateral, and the prompt doesn’t say so. resolve() uses the unfiltered view. dapp.ts:643 Compare the inputs and collateral with coin control, then refuse or name them.
15 dApp: a private session’s stake key can be registered and delegated. Bring it back and Disconnect then strand the 2 ₳ deposit and every reward. privacy.md says this key is never registered. dapp.ts signTx, sessions.ts:629 For a session, refuse own register, delegate and vote certificates.
16 dApp: a pool registration in the same transaction makes the stake key a pool owner. The pool takes the user’s rewards, and the prompt shows only “A stake pool’s certificate.” cip30.rs:958 Read pool_owners, and refuse when the account’s stake key is one of them.
17 dApp: a ~3 KB signTx overflows the WebAssembly’s stack. The instance is then dead: Lock errors out without clearing session storage, and auto-lock fails while the site keeps pinging. cip30.rs:688, wallet.ts:368-380, wasm.ts Bound CBOR nesting before any decode. Clear session storage first in wipe(), free each key in its own try, and replace a trapped instance.
18 A token named like a listed one reads exactly like it. SNEK, USDM, or even “₳” (which prints “1,000 ₳”) look the same in signTx, every review and Activity. No token is marked unlisted, and no fingerprint is shown. DappApprovals.tsx:428 One token-text helper for every text view: mark unlisted tokens and show the fingerprint. Swaps must decide ADA by id, not by label (Swaps.tsx:100).
19 A long token name shrinks a Pays address to its 6-character checksum, and pushes the output’s ADA off-screen. A ground address with the same checksum then reads as the real one. DappApprovals.tsx:471, styles.css:548 Give the address its own wrapped, full-width line, and cap the amount column.
20 The swap’s You receive offers unverified held tokens under their own names, above Minswap’s verified list. Anyone can put tokens in a private balance. (Plausible.) Swaps.tsx:1024 Check tokenOut in the worker, and list unverified tokens apart.
21 The swap runner signs Minswap’s transaction on a value cap alone. The order’s destination, receiver and minimum output are never read, though the comments and architecture.md say the minimum is checked. sessions.ts:1705, :22-24 An owner’s decision: a structural check of the order, or reworded copy with the risk accepted.
22 freshIndex’s 20-address probe lets Koios link every session the wallet opens. Successive windows overlap, and each session’s stake key goes on chain in its funding. sessions.ts:1628 Probe next alone, and widen only when it’s used.
23 A stale pool read makes the network check skip Lovejoin. A swap that runs itself then comes back directly without a word. lovejoin.ts:454 Treat an unknown input as stale: re-read and retry. Tell the user when a return skips Lovejoin.
24 One failed tx_status read ends a chain. A public mix stops for good, and a session drops its pending chain. lovejoin.ts:583 Treat a KoiosError in onChain as nothing seen yet.
25 A swap that runs itself takes any stranger’s UTxO as its fill when Minswap lags. It returns early and closes, and the real fill lands in a closed session. (Low confidence: it needs the lag.) sessions.ts:1048 Decide a fill from the order’s outpoints.
26 A token→ADA swap’s proceeds go through Lovejoin, at about 27% in mix fees at depth 2, yet the approval says “the proceeds… at once”. Swaps.tsx:952 Owner’s call (chunk 16 chose this). At least fix the copy and show the cost.
27 Contracts CI pins Aiken v1.1.23, but 3e79ebb moved aiken.toml to 1.1.24 and stdlib v4. aiken check -D fails on every PR, the launch-prep PR included. .github/workflows/continuous-integration.yml:15 Set v1.1.24. It changes no hash.

Low

# What Where Fix
28 No transaction has a validity interval: a stuck one stays valid forever while the wallet lets the user pay again. build.rs:105, :1674 Set invalid_from_slot (tip + about 2 h) before settling and patching.
29 Fees and minimums trust Koios’s protocol parameters with no bound. koios.rs:840 Refuse parameters outside generous bounds, and cap an account fee.
30 Public Activity judges “ours” by base address: strangers’ transactions show as Sent. activity.ts:358 Match by payment credential.
31 Offset paging over a changing UTxO set can skip or duplicate a row. koios.ts:473 Keyset paging, and dedupe by outpoint.
32 Fix #12 is incomplete: the phrase and entropy stay in WebAssembly memory after lock (bip39 and pallas Strings). cardano.rs:61, derivation.rs:55 Zeroizing buffers where possible, and docs that say “best effort”.
33 UI requests (passwords, the phrase) reach every open wallet page, because runtime.sendMessage fires every page’s listener. ui/background.ts:10 Send requests over a port only the worker listens on.
34 SetPassword’s Show makes a spell-checked text field. SetPassword.tsx:52 spellCheck={false}, autoCapitalize="off".
35 The auto-lock alarm outlives a browser restart and loads the WebAssembly every minute while locked. wallet.ts:334 Stop it when there’s no entropy.
36 A clock that moved backwards keeps an idle wallet unlocked longer. wallet.ts:321 Treat last > now as expired.
37 Two overlapping runSessions can send two withdraws seconds apart. (Plausible.) sw.ts:81 One in-flight run, or a queue for withdraws.
38 The approval window swaps in a new request in place, with Sign live at once. DappApprovals.tsx:54 Hold the buttons for about 1 s, and say it changed.
39 The window’s 800 ms self-close can decline a request that just arrived, unseen. DappApprovals.tsx:50 The worker decides the close.
40 A site can make the worker read Koios and allocate without limit, with no approval. dapp.ts:496 Share in-flight reads, rate-limit per origin, and cap the hex size.
41 A signTx of outputs with register datums can freeze the worker for tens of seconds. cip30.rs:860 Refuse over 64 KiB before parsing.
42 An output counts as the account’s by payment key alone, so a franken address shows as change. cip30.rs:838 Also match the stake part.
43 Fix #10 has a race: a public connect between the check and the write detaches a funded session. (Plausible.) dapp.ts:409 Serialize dapps writes.
44 The runner’s checks ignore a treasury donation and never bound a cancel’s fee. sessions.ts:1087 Refuse donations, and cap a cancel’s fee.
45 An unreadable sealed session record reads as empty and is overwritten. (Plausible.) private-store.ts:54 Throw on a failed decrypt, and never write over it.
46 The runner’s automatic return deletes a return the user is reviewing for another session. sessions.ts:1094 Clear a kept slot only if it holds this transaction.
47 An in-flight chain’s next input (the 0/0 change, the collateral) is offered to other spends and to sites. account.ts:95 Reserve a live chain’s inputs.
48 A worker restart during a busy back-off loses tries, so a resent deposit stops the chain. sessions.ts:1376 Persist a maybe-sent mark.
49 A chain transaction Koios took but that never lands stalls the chain until the next lock. lovejoin.ts:152 Resubmit the oldest after about 3 minutes.
50 One chain overflows spentSet’s 500 entries and evicts every other feature’s recent spends. spent.ts:26 Keep entries by time (about 2 h), not by count.
51 Toggling a lock drops every other lock missing from the last reading. coin-control.ts:180 Don’t prune on toggle.
52 A full contract read runs isOwned on every row inside the keys queue. At mainnet scale, Lock and the UI wait. contract-scan.ts:150 Batches of about 200, yielding between them.
53 After giveme.my refuses a spent input, “review it again” rebuilds from the same view for up to 30 min. script-spend.ts:162 Forget the contract view on that refusal.
54 A decimal comma is read as a thousands separator: “0,5” becomes 5 ₳, and “12,5” becomes 125 ₳. format.ts:129 Accept a comma only as a well-formed separator.
55 Send forms use Koios’s token decimals, while the list uses the registry’s. TokenAmounts.tsx:25 One tokenDecimals helper.
56 Hide balances leaves Lovejoin’s and the dApps page’s holdings visible. Lovejoin.tsx:259 useAmounts() there too.
57 A session’s return moves an ADA Handle into the private balance with no warning. ClaimAll.tsx:172 HandleWarning on return reviews.
58 On mainnet the To field’s placeholder asks for a testnet address. Destination.tsx:160 Take the prefix from the network.
59 Two mainnet DReps named “8Ball” share the 10 ID characters shown. Pool tickers are squattable. (The DRep one is plausible.) Staking.tsx:323-326 Flag shared names and tickers, and show more of the ID.
60 On a Chrome that refuses storage.local.setAccessLevel, the connector still injects everywhere. (Plausible.) sw.ts:46 Record the result, and refuse the connector if it failed.
61 Supply chain: Cargo.lock is gitignored and never enforced, so the shipped WebAssembly’s crates are recorded nowhere, CI tests a different graph, and build.sh fails on a clean checkout. The shipped module also carries 146 absolute paths under /home/logic/. seedelf-platform/.gitignore:30, wasm/build.sh:23-32 Commit the lock that built 1.0.0 (!Cargo.lock), then clear its advisories (bytes, slab). Build with --locked, pin the toolchain, and pass --remap-path-prefix.
62 Docs traps: seedelf-contracts/hashes/ and contracts/ hold a later, undeployed revision, and AUDIT.md scopes that revision, yet both CLAUDE.md files say to copy hashes into the Rust constants. seedelf-platform/CLAUDE.md:47 also has get_config’s flag inverted (true is preprod). seedelf-contracts/CLAUDE.md:17, root CLAUDE.md:12 Freeze v1: it’s 5b82530 with Aiken 1.1.9, 94bca9c0 / 84967d91. A changed hash is a new variant, never an edit to v1. Fix the flag’s wording, and add a core test that pins mainnet’s config.

Decisions for the owner

  1. Lovejoin at launch. The pool is empty, and the wallet can’t seed it. My suggestions:
    • Seed dozens of boxes from identities unrelated to any Seedelf key, through Lovejoin’s own deposit-only flow.
    • Set a mainnet pool floor (60–100 other boxes).
    • Ship mainnet with returns-through-Lovejoin off by default, behind a new setting.
    • Say plainly that Lovejoin has had no third-party audit: its CLAUDE.md asks that no copy contradict that.
    • Say too that boxes seeded only by the operator hide nothing from the operator.
    • Make the first live run small: one box at depth 1.
  2. A token→ADA swap’s proceeds through Lovejoin (#26): keep chunk 16’s choice with honest copy, or leave the proceeds out.
  3. The swap runner’s order check (#21): add a structural check, or accept the risk with reworded copy.
  4. Mainnet-only builds (M3, recommended), or build the network switch.
  5. The store listing: a new listing.
  6. freshIndex’s probe (#22): the privacy gain against one extra request when an index is used.

Launch-prep order

  1. Core and crypto (Rust):
    • H3, H4 and H6.
    • #12: reference-script UTxOs and their fee.
    • H1, #13 and #16 in cip30.rs.
    • #17: the depth bound.
    • #28 (validity interval) and #29 (parameter bounds).
    • M1’s core half.
  2. Worker:
    • #10 and #11: maybe sent.
    • H5 and H7.
    • H2, H8 and H9: chain records, reservations and valid counts.
    • #14, #15, #23, #24 and #25.
  3. Mainnet wiring and release:
    • M1’s single source of truth and tests.
    • M2 and M3.
    • M6, and the Vitest run with the flag in CI.
    • #27 and #61.
    • M4, the listing.
    • #62’s docs.
  4. UI: #18–#20, then the lows.
  5. By hand, on a mainnet build, with small amounts:
    • Minswap: check that agg-api.minswap.org answers the extension’s origin with CORS; this was only ever checked on preprod’s aggregator. Then check:
      • that aggregator_fee is a lovelace string;
      • the route protocols against DIRECT_PROTOCOLS;
      • one ADA→token and one token→ADA swap;
      • Stop.
    • Lovejoin: once the pool holds enough boxes, one box at depth 1.
    • Seedelf: every flow.

A head start for M1 from this review is in the session’s scratch directory, which isn’t kept: scratchpad/mainnet/lj-parity/mainnet-lovejoin-scratch.patch. It has the core Protocol mainnet branch, the three reference outputs encoded from the chain, and 16 mainnet evaluation tests (core and wasm). Its script_bytes is hard-coded, so derive it as M1 says.

Checked and fine

Refuted or superseded

For Lovejoin (upstream)