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:
VITE_ENABLE_MAINNET=true build.cargo fmt, clippy, every Rust test, tsc, Vitest (302 tests) and npm audit all pass.Verdict: the protocol side is ready for mainnet, and the wallet isn’t yet.
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):
cargo test --workspace --locked, 424 passed (the 8 live Koios tests are #[ignore]d); clippy and fmt clean.The owner’s calls (2026-09-26):
MAINNET_PROTOCOLS); VyFinance is excluded.hashes/ is a later, undeployed build (see its README).Beyond the table’s fixes:
sw.js, so every wallet page ran a second copy of the worker. A build now fails if a page imports sw.js.account_info.Cargo.lock is tracked, and Rust is pinned to 1.98.1.bytes, slab, anyhow and keccak are cleared.Still open:
agg-api.minswap.org answers the extension’s origin with CORSaggregator_fee is a lovelace stringexclude_protocols accepts VyFinance and MuesliSwap;MAINNET_PROTOCOLS: one route so far, so checkOrder is known to pass that one’s orders and no other’s;mix_box hash is confirmed against real transactions for the first time, and a mix measured 0.8225 ₳, matching networks.ts’s mixCost.web-wallet/release-prep):
85c0fb09…714b, recorded in the roadmap’s handoff notes.config/network.mainnet.json also still has mix_script_hash: null, though the hash the wallet uses is now confirmed on chain.h2 and spin.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.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.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.
Value → Assets rename, checked mechanically. No known-answer vector changed.PayMixFee enforces the cap, and the wallet pays every mix from a key account.MIX_FEE_ESTIMATE (0.95 ₳) stays safe.| # | 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:
get_config(1, false) (mainnet) and its two reference UTxOs (51f12c1a…#1, f3955f42…#1). Their bundled outputs match the chain byte for byte.1c2fbce4…#0 holds 5 ₳, and blake2b-224 of COLLATERAL_PUBLIC_KEY matches.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).
inspect() nets the account’s own outputs against its own inputs only. An own withdrawal only adds the stake key as a signer, and an UnReg refund only becomes a certificate line.inspect.UnReg refunds as money the account puts in, with checked adds.StakeDeregistration, whose refund isn’t in the certificate.cip30_test for rewards paid to someone else.H2. Lovejoin withdraws boxes that were never mixed (sessions.ts:1395, lovejoin.ts:570, lovejoin.ts:651-695).
chrome.storage.session, which a lock, a browser restart or an extension update wipes. A send moves only the first window (4 transactions), and the alarm sends the rest while unlocked.withdrawDue takes owned[0], the owned box with the lowest tx hash. It has no idea whether that box was mixed, or whether a chain still being sent is about to mix it.block_time), not the first.H3. Every key “owns” the register (identity, identity) (seedelf-crypto/src/register.rs:175).
is_owned multiplies the generator by the key, and the identity stays the identity.Ok(false) when either point is the identity, and add a test.
Ok(false) and not an error: the CLI’s scan (seedelf-core/src/utxos.rs:42, :124) aborts on an error, so one such UTxO would break every CLI balance.H4. One UTxO with deep JSON makes the WebAssembly refuse a whole request (seedelf-koios/src/koios.rs:134, :150).
InlineDatum.value and reference_script are serde_json::Value, and serde_json stops at 128 levels for the whole request. That takes a datum about 65 levels deep, or a native reference script about 62 levels deep: about 200 bytes on a 1–2 ₳ output.sw.ts:89 swallows the error.value as RawValue and parse it leniently.reference_script a struct holding only its hash and size.bytes.H5. The contract index can outgrow session storage (contract-scan.ts:153, the write at :96).
_extended Koios row goes into chrome.storage.session: the asset list, the datum twice, the reference script. The quota is 10 MB for the whole extension.rememberSpent fails after a good submit, so a sent payment shows as failed. This was reproduced in the built extension.{txHash, txIndex, generator, publicValue} for other people’s Seedelfs, or keep the index in worker memory and persist only what’s owned.H6. Token totals past u64 freeze a session’s return (seedelf-core/src/utxos.rs:342).
assets_of then overflows. buildSessionReturn and the Lovejoin chain fail forever.utxos::fitting helper) and leave the rest behind, with a notice. u128 alone doesn’t help, because no output may hold more than u64 of one token.H7. Disconnect closes a private session while money is on its way (sessions.ts:629).
disconnect judges “empty” from one Koios read, minus the spent set. It never looks at the session’s own transactions.tx_status, as act does.H8. Junk at Lovejoin’s address turns a skip into a stuck return (lovejoin.ts:414, and :343, :355).
PoolBox::from_row). Lovejoin allows junk there, and anyone can add it.LovejoinSkipped:
H9. Chains collide over the same pool boxes (lovejoin.ts:324, sessions.ts:852).
| # | 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. |
| # | 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. |
cip30.rs.agg-api.minswap.org answers the extension’s origin with CORS; this was only ever checked on preprod’s aggregator. Then check:
aggregator_fee is a lovelace string;DIRECT_PROTOCOLS;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.
onMessage takes only the extension’s own pages.Wallet.load().is_payable.freshIndex fails closed.dangerouslySetInnerHTML.config/network.{mainnet,preprod}.json (lines 26–27) and offchain/src/seedelf/addresses.ts have Seedelf’s reference UTxOs swapped between the networks.
96fbddac…#1 and f620a4e9…#1 are preprod’s.