Seedelf-Wallet

Chunk 16: Lovejoin

Lovejoin is a Cardano mixer. You lock fixed 10 ₳ boxes in a shared pool, anyone can mix boxes together, and the owner later takes a box out with a proof. This chunk builds it into the wallet in two places:

Why (the user, 2026-09-25): a private session is a one-time account, and today its return lands in Seedelf linked to it. If several returned UTxOs are later spent together, those sessions are tied together on chain, which is the very thing private sessions exist to prevent. Lovejoin breaks the link between a session and the money it brings back. Transaction chaining matters here, and the user expects it to be “worth it”. Branch: web-wallet/lovejoin, taken from seedelf-web-wallet after PR #262 merged.

Decided (the user, 2026-09-25)

  1. No fee shards and no giveme.my in the chain. The session pays every deposit and mix fee, and its own 5 ₳ UTxO is their collateral. giveme.my can’t witness a chained transaction (chunk 15, Private interactions).
  2. The chain is built locally and optimistically. Each transaction is built on the outputs of the one before it, without waiting for the chain. Script budgets come from an evaluator running in WebAssembly. Build for a healthy pool of boxes.
  3. Fan-out 3 wide. Depth is a setting from 1 to 3, default 2.
    • Every output of each wave is mixed again in the next wave, as Lovejoin’s own strategy/fanout.ts does. So which leaf is yours stays hidden whoever pays.
    • Depth 2 is 4 mixes per box, about 3.5 ₳, and 9 leaves (a 1 in 9 chance of linking, at best). That assumes the leaves’ ways out look alike. The wallet’s go into the Seedelf contract under a register, and Lovejoin’s own app writes no datum, so a box hides only among the leaves whose owners also bring them back into a Seedelf, and fewer while few do (privacy review §2.6, §5.3). The review’s issue for Lovejoin’s app: write a fresh register when it withdraws to a Seedelf.
  4. Each box is withdrawn on its own, after its own random delay. The range is a setting, default 1–6 hours. Kept (the owner, 2026-09-27): the privacy review (§3.5) proposed 2–12 hours by default; the owner kept 1–6.
    • The withdraw runs at the first unlock after the delay, since the proof needs the key.
    • Changed (the user, 2026-09-27, the privacy review §3.1): never the moment the wallet unlocks. A box that came due while it was locked waits a fresh draw inside the stretch the unlock keeps it open (2 minutes on, at most 2 before the auto-lock, never past 20), once; nor in a run that sent anything else, nor within 5 minutes of the wallet’s own send (pushed 3 to 10 minutes, 3 times at most). A swap’s step found at unlock waits the same way.
    • giveme.my’s collateral, fee paid from the box, into a fresh Seedelf register for each box.
    • The session is never on it.
  5. The boxes are owned by the Seedelf key. A Lovejoin box’s datum {a, b} with b = x·a has the same shape and encoding as a Register. So the scan the wallet already runs finds its boxes anywhere in the pool, after other people’s mixes too, and after a restore.
  6. Tokens don’t go through Lovejoin; it takes ADA only, in 10 ₳ boxes. A session mixes as many 10 ₳ boxes as its spare ADA pays for.
    • The tokens and the leftover ADA come back directly, merged into the session’s own funding change: the Seedelf UTxO its funding made, already linked to it on chain.
    • If that change has been spent, they come back in a new UTxO, as today.
  7. Which returns go through it:
    • a swap that runs itself (after a fill or Stop; its approval says so)
    • hand-run Bring it back
    • site sessions
    • Bring everything back (each session gets its own chain)
  8. The standalone tile takes money from either side.
    • Private balance goes through a one-time account.
    • Public account goes straight in, paying with the account and using its collateral.
    • Both end in the private balance, unlinked.
  9. Lovejoin’s website is out of scope. It’s an unfinished idea; the feature belongs in the wallet. Ask the user if a Lovejoin doc looks wrong.
  10. One collateral for the whole chain. The session’s 5 ₳ UTxO is the collateral of every transaction in its chain, even within one block, as long as nothing spends it before the chain ends. The last transaction spends it back into Seedelf. Koios may not like chained submits; we’ll see what happens.

Found before planning (2026-09-25)

The protocol (contracts in _reference/Lovejoin/contracts/validators/):

Measured on preprod:

  Fee Steps (limit 10 billion)
3-box mix, wallet-paid 0.877 ₳ 5.73 billion
3-box mix, shard-paid 0.893 ₳ 5.73 billion
4-box mix, wallet-paid 1.27 ₳ 9.78 billion (why the width stays 3)
4-box withdraw 0.463 ₳  

Check 1: local evaluation with Aiken’s uplc.

Size:

Check 2: Pallas.

Lovejoin’s own UI has a trap: its withdraw output has no datum, and it tells users to withdraw to “a Seedelf address”. Seedelf’s wallet.ak lets anyone spend a datum-less UTxO (None -> True). This is worth an issue on the Lovejoin repo; it doesn’t affect us, since we write the register.

Decided in the design (mine, for the user to overrule)

Design

Rust: seedelf-crypto

Rust: seedelf-core

WebAssembly (wasm/src/lib.rs, a new lovejoin.rs):

Worker

UI

Koios requests

Build order

  1. Pallas 0.35 across the workspace. Every test green, the module rebuilt, the size noted.
  2. Local evaluation:
    • uplc 1.1.23 in seedelf-core.
    • ScriptSpend evaluates locally.
    • The five recorded preprod transactions become fixtures that must match exactly.
    • The Koios cross-check.
  3. The script-withdrawal patch. Rebuild the script data hash of the recorded Lovejoin withdraw and mix and compare it with their bodies.
  4. The lovejoin crypto, with Lovejoin’s test vectors: Schnorr, the sigma-OR at N 2, 3, 4 and 6, the negatives, and encoding parity.
  5. The builders: deposit, mix and withdraw, each evaluated offline against the deployed validators (recorded pool boxes and the bundled scripts). A wrong context or proof fails here, not on chain.
  6. The chain runner and the session returns, with the merge into the funding change, and the collateral-as-input question settled.
  7. The withdraw schedule and the pool scan.
  8. The tile, the settings and the review screens.
  9. A live preprod run, on the user’s go-ahead:
    • a session return with two boxes
    • a standalone from each side
    • the delayed withdraws
    • a chain rebuilt after a collision

Tests

Risks and things to check

Built (2026-09-25, first session)

Rust:

Worker:

UI:

Tests:

Where New
seedelf-core eval_test 5, withdraw_zero_test 5, lovejoin_test 11
seedelf-crypto lovejoin_test 6
seedelf-wasm lovejoin_test 4
Vitest lovejoin.test.ts 6: a 40 ₳ session’s ten-transaction chain built, measured, sent in order and scheduled, against 20 recorded preprod pool boxes

Totals: Rust 312, Vitest 273, Playwright 47 (unchanged).

Departed from the design:

Not done yet (after the first session; the second session built items 1 to 6, below):

  1. The merge of the leftover and tokens into the session’s funding change.
  2. The tile’s own mixing, from the private balance through a one-time account and from the public account.
  3. The swap approval’s words about Lovejoin. A swap that runs itself already goes through Lovejoin on its way back.
  4. Home’s In Lovejoin row (the dApps tile shows it instead).
  5. The Koios cross-check.
  6. End-to-end tests for the tile and the review.
  7. A live preprod run (on the user’s go-ahead): a session through Lovejoin, then its withdraws.

Built (2026-09-25, second session)

The merge into the funding change.

Mixing from the tile.

The Koios cross-check. Before a chain is used, Koios’s Ogmios measures its first mix, given the unsent deposit as additionalUtxo.

The swap approval says that spare ADA goes through Lovejoin on the way back, with the depth and the wait from Settings.

Home’s In Lovejoin row (lovejoin-held) is read from this device’s schedule alone, with no Koios request: the boxes on their way back, what they hold, and when the next is due. It opens Lovejoin’s page.

A request left out of rpc.ts’s list is now a type error. The worker drops an unlisted request as unknown, silently, which is how the first build of the funding preview failed.

Tests:

Where New
seedelf-core merge_test 2 (the merged return passes the deployed wallet contract; the refusals), eval_test +2 (the cross-check on every recorded transaction; the Ogmios UTxOs), lovejoin_test +2 (funding for k boxes; the change floor)
seedelf-wasm session_test +1 (the merged return), lovejoin_test +3 (the merged chain; the funding call; the public account’s chain, one and two signing keys)
Vitest sessions.test.ts +2 (merged, or new UTxOs when the change is gone); lovejoin.test.ts +5 (the cross-check’s request, a disagreement, a refused script, a mix session end to end, the pool and box-count checks, a public mix) and held
Playwright the Lovejoin page (both sides, the cost, a public mix sent, Home’s row); a site session’s return through Lovejoin, then directly

Totals: Rust 322, WebAssembly (Node) 33, Vitest 280, Playwright 49. The module is 760 KB gzipped.

Departed from the design:

Found in the user’s first run on preprod (2026-09-25):

Handoff to the third session (2026-09-25)

The branch. web-wallet/lovejoin, pushed through 18b4847: the second session 7db8117, then the live-test fixes db35e24, 8e65bc0 and 18b4847 (the stopped return, a submit Koios didn’t answer, the rate limit, and this handoff). Nothing was left uncommitted. No PR yet.

The user’s live testing on preprod, where it stood:

Decided for next (the user, 2026-09-25). Both built in the third session (below).

  1. Mix my boxes again, on the Lovejoin page: every box of the wallet’s in the pool, fanned out again at the Settings depth.
    • Paid from the private balance through a fresh one-time account, like Mix from the private balance: one review, then it runs by itself, and what’s left merges back.
    • The user chose this over the public account, which would tie it to boxes that may trace back to private sessions.
    • That covers boxes from private sessions only (the privacy review §2.10, 2026-09-27). A box a mix from the public account put in is the account’s, which paid for that mix in the open: the private balance paying to mix it again would tie itself to the account. So Mix my boxes again leaves those out, Mix again from my public account mixes them paid by the account (no deposit, no new tie), and Pay from my private balance anyway takes them all after a warning.
    • A sketch:
      • core: a chain that starts from the wallet’s own PoolBoxes, with no deposit;
      • WebAssembly: a call for it;
      • worker: a mix session that carries the boxes’ outrefs, funded with their mixes’ fees and the reserve, and whose runner builds that chain;
      • keep the due withdraws off those boxes while it runs, or the chain meets a spent box.
    • The user’s reason: a chain cut short, or boxes someone else was mixing; a button to try again.
  2. A warning before auto-lock: a countdown in the wallet only. In the last 2 minutes, a banner on every screen (“Locking in 1:30”) with Stay unlocked.
    • The user turned down the toolbar badge, holding the lock while a chain is sent, and counting mouse movement as activity.
    • The deadline is the last activity (seedelf.lastActivity) plus the lock time; the auto-lock alarm checks it every minute.

Offered, not decided (ask before building):

Not done:

Gotchas:

Built (2026-09-25, third session)

Mix my boxes again (the Lovejoin page, beside Bring one back now): every box of the wallet’s in the pool, 10 at most, fanned out again at the Settings depth, paid from the private balance through a fresh one-time account.

The countdown before auto-lock. In the last 2 minutes before the lock, every screen shows a banner, Locking in 1:30, with Stay unlocked (LockCountdown.tsx, in the app shell).

Progress while a chain goes (the user asked, 2026-09-25; offered in the second session’s handoff).

Found in the user’s preprod run (2026-09-25): a 49-transaction chain stopped at its 7th, which had gone in. It was Mix my boxes again with 12 boxes at depth 2 (47.1 ₳ of funding). On chain, all 7 of its first mixes landed, the 7th 15 s after the 6th’s block, yet the wallet counted 6 and stopped.

Then: “still getting random Koios timeouts” (the user, 2026-09-25). Not the number of requests: a 49-transaction chain is about 50 submits and a few reads, under the wallet’s 60 every 10 s. The chain was sent as fast as it could go, and the node couldn’t take it that fast.

Also fixed: the pool counts that fits and a return’s chain checked against included the wallet’s own boxes, which a mix never takes. They’re counted out now. A return that stopped partway also counts a recorded mix, not only a deposit, as the chain having started.

Tests:

Where New
seedelf-core lovejoin_test +3 (our boxes fanned out again, measured against the scripts; the refusals; the funding for the mixes alone)
seedelf-wasm lovejoin_test +2 (a mix session’s again chain, signed, its return taking the ADA it left alone; its funding)
Vitest lovejoin.test.ts +5 (mixing again end to end, with the withdraws held and the due times drawn again; boxes gone before the funding landed; no box to mix; past ten boxes, as the pool allows; a chain’s progress, one that stopped partway, and a finished one letting the next return through; the public mix’s count); wallet.test.ts +1 (the deadline, and locking when asked past it)
Playwright mixing again’s review (2 of 3 boxes), and the mix listed; the countdown on Settings, Stay unlocked, then the lock at 0:00; a site session’s return counting its chain on Send, then how much is on chain

Totals: Rust 327, WebAssembly (Node) 33, Vitest 295, Playwright 52. The module is 762 KB gzipped.

Handoff to the fourth session (2026-09-25)

Out of scope

Start here

  1. git fetch origin && git checkout web-wallet/lovejoin.
  2. Read, in order:
  3. The checks’ scratch code isn’t kept. The measurements and results are above; build order step 2 records the five transactions again as fixtures.