A wallet is one BIP39 recovery phrase. New wallets get 24 words (256 bits of entropy). Restore also accepts 12 or 15 words, as Lace does. Two independent key trees come from the phrase:
m/1852'/1815'/account'/role/index) from the usual Icarus master key.
x, derived from the same phrase by a separate, Seedelf-specific derivation.
A web-wallet phrase is a Seedelf phrase. CLI wallets use a random scalar stored in a file, and they are a separate product with no import path (see the README).
v1, frozen on 2026-09-23. Once wallets exist this derivation can never change, or their phrases stop restoring funds.
seedelf-crypto/src/derivation.rs.seedelf-crypto/tests/vectors/seedelf_key_v1.json.
x → base register.hashlib/hmac for the seed, HKDF and the reduction, and py_ecc for the public value. The BIP39 layer also reproduces the official Trezor vector.seed = BIP39 seed of the phrase PBKDF2-HMAC-SHA512, 2048 rounds, empty passphrase → 64 bytes
okm = HKDF-SHA-256(
ikm = seed,
salt = "seedelf-wallet-v1",
info = "seedelf-key" || u32_be(account),
L = 64)
x = int_be(okm) mod r r = BLS12-381 scalar field order
if x == 0: derivation error probability ≈ 2^-255
Notes:
account is 0 for v1. The index would allow several Seedelf keys per phrase, and the owner decided against one per public account on 2026-10-02: see Several accounts and the private balance.r (a 255-bit prime) leaves negligible bias.generatePhrase and validatePhrase in the WebAssembly module apply these rules, and validatePhrase says what is wrong.seedelf-crypto), and the extension uses it through WebAssembly (SeedelfKey.fromPhrase).Every Seedelf spend is signed by a key of its own (privacy rule 1). Each proof’s challenge binds that key’s hash (vkh), so a proof can’t be replayed in another transaction, and no two spends share a signer. The web wallet derives the key from the Seedelf key rather than drawing it:
okm = HKDF-SHA-256(ikm = x, 32 bytes big-endian,
salt = "seedelf-one-time-key-v1",
info = seed, 32 fresh random bytes, one per spend
L = 32)
key = the Ed25519 key whose secret is okm
one_time_key in wasm/src/lib.rs.x it gives nothing, and the key itself never leaves WebAssembly.api::merged_return, and ScriptSpend::with_account refuses the account’s key as the one-time key).| Account | What it is | Address | Lifetime |
|---|---|---|---|
| Seedelf | The scalar x. The base register is (G1, G1^x). Each Seedelf’s root UTxO holds a re-randomized copy that senders use. |
Wallet contract (script address, no staking part) | Permanent |
| Cardano | CIP-1852 account n', one at a time (chunk 18; 0' until then): receive keys 0/i, change keys 1/i, staking key 2/0, DRep key 3/0 (chunk 21) |
Standard base addresses | Permanent. See The Cardano account. |
| One-time (private sessions, chunk 15) | Reserved CIP-1852 account 24301' (0x5EED, ONE_TIME_ACCOUNT), payment 0/i and staking 2/i: session i, counting from 0 in order |
Base address with the session’s own stake key 2/i, which the wallet never registers (since chunk 15b; sessions from before keep the shared Seedelf staking part). See privacy.md. |
One session, then retired |
24301' indices hold money. The scan (SessionService.scan):
account_addresses on their stake keys, 2/i, one request a batch), until a batch shows none used. Sessions take their index from one sequence (freshIndex) and only a funding never sent leaves one unused, so the gap is BIP44’s. Every used index moves the book’s next past it, so freshIndex needn’t look.credential_utxos, 75 keys a request), each one’s transactions (account_txs by its stake key, one request each, up to 1,000), and what those spent and paid (tx_info with inputs alone, 20 a request, whose outputs carry their index and payment credential). The oldest its key didn’t sign is the funding (a swap can share its block, which the server sorts by hash); every one that spent from the account is what its key signed.tx_info row, means a backend behind the one that answered first, as Koios’s can be. The scan then fails rather than find nothing: Find leftovers says the server hasn’t caught up, to try again in a minute, and a restore’s own scan leaves its network marked (A scan that doesn’t finish, below).orders.json pins, read (utxo_info, datum_info for a datum kept by hash) by readDexOrder as the account’s own. A SundaeSwap V3 order sits under Minswap’s staking part, not the account’s, and is found the same way. Never from Minswap: its pending-orders doesn’t list every order, and the privacy policy says Minswap isn’t asked for an account’s orders (chunk 24). Each order counts while utxo_info shows it unspent, whatever came after it, since a return from before chunk 24 checked only Minswap’s list; and what a Splash fill in parts left at its order’s address counts as liveOrders counts it, since the scan hands its record, with the rows it read, to liveOrders, which asks utxo_info nothing more (1.5.0’s release review, C15).recovered): its funding as out, with what the scan read at the account as its outs (1.5.0’s release review, C02), its swaps as swap with their orders, and what its key signed (recovered.own), whose outputs its return takes as the session’s own. What it was for (a swap, a site, a mix) isn’t known, so it has no swap, site or mix, never runs itself, and comes back by hand: Cancel the order, Bring it back, or Bring everything back. Its page warns that a swap or site using the phrase in another browser may still use the account, and so does Bring everything back’s review when it takes one; neither its return’s review nor its page once it’s over says the account is never used again (C18; its page, 1.5.0’s cross-area review, X09). Its return goes through Lovejoin as Settings has it, even where another browser’s chain stopped partway at its account, which that browser would have sent back directly: its key’s deposits and mixes are in recovered.own alone, so its chain is one of its own, which never draws the wallet’s boxes, and its review’s switch brings it back directly (C51). One holding nothing, with no order open, isn’t added. The book’s own sessions are skipped, and the merge is into the book as it is once everything is read, so a session started meanwhile keeps its index.outs, so Koios must show each spent, or still there and left behind, before it ends: an empty read proves nothing alone (independent review M4). A session found by its open order alone has no outs, though, and an order’s end (a fill, a refund, another browser’s cancel) pays the account in the very transaction that spends the order, which a listing read just before it, or by a Koios backend behind the one that read the order, doesn’t show. So one whose record holds a swap ends only at a later refresh that finds it over too, 15 minutes or more after the first that did: the first marks the sealed record (recovered.quietAt) and keeps the session listed, and any refresh that doesn’t find it over (something there to come back, an order open, a lookup that fails) clears the mark (1.5.0’s cross-area review, X01). Meanwhile its page says it ends by itself unless something comes in, and Home’s restore note doesn’t count it (1.5.0’s last review, L4). The wait asks nothing of its own. The list’s refresh checks it once its account holds nothing a return takes, at the cost of one utxo_info for what the scan read there, and its orders as closing a swap reads them when it has any (architecture.md, What each costs): one utxo_info while one is open, two and a Splash order’s remnant look once they’re spent. With nothing left its record goes, as a site’s does at Disconnect, so the book no longer skips it, and Find leftovers finds the account again if anything reaches it. With something left behind it stays, closed, and Remove wallet names it as left behind. Once this device has sent a cancel, a top-up or a return for it (one Koios turned away doesn’t count), only its own return ends it, as any session’s.credential_utxos per 75, one account_txs each, one tx_info per 20 transactions, and for the outputs those paid to an order script one utxo_info per 60 (every account’s together, which also says which are still open) and one datum_info per 60 kept by hash; a spent Splash order of an account holding nothing adds its remnant look (address_utxos at its order’s address, then, for what it finds there, a tx_info a hop back through its fills, up to 8) (C35).orders.json doesn’t pin, a loan). The scan can’t see that, and a restore can’t connect a site to that session again, so nothing in the wallet can close it; Remove wallet’s and Forgot password’s notes say so, and Remove wallet’s says to close it there first (1.5.0’s release review, C17). What it pays the account later, Find leftovers finds while the book doesn’t know that session; one this device brought back stays in the book, closed, and isn’t read or found again. Lovejoin’s boxes need none of it: the Seedelf key finds them in the pool after a restore.unscanned), and the first scan that finishes on a network clears it, the restore’s own or Find leftovers’. A restore’s scan that didn’t finish, and the network a restore doesn’t look at, stay marked until Find leftovers runs there: starting a swap, a site’s private session or a mix there meanwhile leaves the mark as it is (1.5.0’s cross-area review, X02: its funding wrote the record without it). While a network is marked and no scan runs there, the dApps page’s After a restore says “The restore didn’t look for private sessions’ money on Preprod, or didn’t finish looking: press Find leftovers.”, naming the network; it asks as the page shows, every 3 s while a scan runs, and once Find leftovers has looked, and the line is hidden while Find leftovers looks. The Home a restore opens adds “The restore didn’t finish looking for private sessions’ money: press Find leftovers on the dApps page.” to its note once the restore’s own scan has ended unfinished on its network. Both read the device’s sealed record alone (session-scan-due): no Koios request. A restore’s scan counts as under way from before the wallet exists, since the wallet’s creation opens Home at once, before the restore has marked the networks or started it: a Home that asked in between heard none due and none running, and never asked again (the 1.5.0 PR’s e2e); and a Home whose ask the worker didn’t answer asks again.2/i) has been used by any address (account_addresses, that one stake address, which the funding puts on chain anyway), and only when it has, which of the 20 after it have, in one request, taking the first that none has (SessionService.freshIndex; the launch review, #22). Asking about 20 at once tells Koios they’re one wallet’s, so freshIndex does it only when the next one was used where this device’s record doesn’t know it: in another browser, or before a restore whose scan hasn’t looked on this network. The restore scan asks 20 at a time at every restore and at Find leftovers, though, and the sessions after the last used index take indices its batches named, so the server can tie them to the scan once they’re funded (privacy.md; 1.5.0’s release review, C06). Every session since chunk 15b has its own stake key, so any payment to one shows there. The few sessions from before then, with the shared staking part, were on preprod only.
SessionService.stillUnused asks about that one index: its stake key (account_addresses), then, if that shows nothing, its payment key (credential_utxos). If either shows it used, nothing is sent: the record’s next index moves past it, and the error says to review it again, which takes the next unused one. A top-up doesn’t ask: its account is already the session’s.seedelf-cli/src/commands/external/), a normal address tied to the Seedelf key. The web wallet reaches it through HD derivation instead.The Cardano account is CIP-1852 account 0' of the phrase, or since chunk 18 whichever account n' the wallet is working on (rule 1 below): an ordinary Cardano wallet account, and the wallet’s non-private side. What it holds depends on where the phrase came from:
Implementation: seedelf-crypto/src/cardano.rs.
seedelf-crypto makes it from the entropy as pallas-wallet’s from_bip39_mnenomic does, without the phrase string that one takes (launch review #32), and pallas-wallet derives the keys below it. cardano_test.rs checks each role’s key 0 against pallas’s own, for every vector.m/1852'/1815'/account'/role/index.seedelf-crypto/tests/vectors/cardano_account.json.
@cardano-sdk/key-management, the library Lace uses: every value. The file holds eight vectors, each with the account key and, on both networks, two receive addresses, a change address and the stake address: 72 values.Rules:
0/i) and change (1/i) chains with the standard gap limit of 20, so a restored wallet shows its full balance. Built in chunk 6; see architecture.md.chrome.storage.local (seedelf.account, unsealed, beside the network and for the same reason: Wallet reads it while deriving the keys, before anything is unlocked). Wallet reads it at every key use, so a switch writes the choice and nothing else — the next use re-derives, and no request can sign with the account the user just left. Which accounts the phrase has used, and what they are called, are sealed with the other private records (accounts): how many accounts someone runs is about them.m/1852'/1815'/n' runs to 2^31 - 1 (seedelf-crypto’s check_account). The exception is 24301', the private sessions’ one-time accounts: its 0/i and 2/0 keys are session i’s and session 0’s, so as a public account it would read, and spend, their money. The worker refuses it however it’s reached (ONE_TIME_ACCOUNT in shared/preferences.ts; the screens call it Account 24302, counting from 1), and a stored one reads as account 0. The wallet keeps up to 100 of them in its list — a list length, never a limit on which numbers.account_addresses request about that one account, which adds it if it has been used; and Add it, which asks nobody anything and adds it whether or not it has ever been used — the way to start a custom-numbered account. It exists in the phrase either way and holds nothing until something is put there. A restore never finds such an account by itself, and Remove wallet deletes the list. So when an add, a check or the look puts in an account past the first one not found used, Settings says to note its number, and Remove wallet’s and Forgot password’s notes promise only the accounts up to the first unused one (release review C09).account_addresses request at a time. It carries on from the first gap in the run up from 0, not from the highest known, so adding a custom account doesn’t stop it ever reaching account 2. Koios.usedStakeAddresses would answer for twenty in one request, and that request would tell Koios those twenty stake addresses are one wallet’s — which works against the habit several accounts serve. It runs on a restore (in the background: a restored phrase may hold funds past account 0) and on demand from Settings → Public accounts, which probes exactly one. Never on unlock. What a look finds is merged into the list as it is then, in one queue with every other change to the list, so an account added or named while it asked Koios stays (release review C32).2/0), its own collateral and its own locked UTxOs; contacts, the Seedelf history and the private sessions are the wallet’s, not an account’s.Preferences.dappAccount, Settings → Sites), and connected sites always use it, whichever account the wallet is working on. Eternl’s model, on the owner’s call (2026-10-02): switching accounts can then never hand a site a second account’s addresses, and a site is never refused for being on the “wrong” one. Changing which account it is, is a deliberate act, and Settings says it shows every connected site the new account. Account 0 by default — the one account every wallet from before this had, so nothing a site already sees changes.0/0.getCollateral, chunk 15). Move-in and send run no script, and Seedelf spends use giveme.my.collect_address_utxos doesn’t. Now only the collateral, and the UTxOs the user locked, stay put.2/0 (chunk 13).
3/0 (chunk 21).
KeyRole.DRep): a phrase restored in Lace, Eternl or Yoroi is the same DRep, and one registered there shows here. CIP-105’s test vector 1 pins it in seedelf-crypto’s cardano_test.rs. Adding the role changed nothing derived before it, and the Seedelf key’s derivation is untouched.drepOf), for Koios and for CIP-95. See flows.md.The Seedelf key is separate. It stays on its own account 0 whichever Cardano account is used.
Decided by the owner, 2026-10-02: the Seedelf key stays on account 0 — one private balance for the whole phrase. The derivation would allow one key per account (info = "seedelf-key" || u32_be(account)), and it isn’t needed: stealth addressing already unlinks the move-ins. Two public accounts paying the same Seedelf create re-randomized registers (g^d1, u^d1) and (g^d2, u^d2), which can’t be tied to each other or back to (g, u) without d. Account 0 is, in effect, the nonce.
What stealth addressing does not cover is co-spending. A contract UTxO’s creating transaction is public. The registers hid who the money went to, not where an input came from — so one later private spend that takes a UTxO originating from account 0 together with one from account 1 ties those two accounts to one owner, in the open.
So each account’s money made private is its own history class: public:<n> in shared/histories.ts. Coin selection keeps different classes apart where a choice that doesn’t merge them pays (seedelf-core’s build::Histories), the review says what a merge ties together and names the accounts, and the UTxOs screen tags each private UTxO with the account its money came from. See privacy.md, Several public accounts.
public of a wallet from before chunk 18 reads as public:0, canonicalized on the way in. Two classes there would have the wallet claim a spend ties two accounts together when both are account 0 — a privacy note that is simply false.Built in chunk 5. The code is in extension/src/background/: vault.ts, wallet.ts and secret-box/.
chrome.storage.local under seedelf.vault, as { version: 1, blob: <base64> }. A vault sealed before the privacy review (§3.13) also carries createdAt, in the clear and never read; the next password change drops it.seedelf_crypto::derivation::{phrase_to_entropy, entropy_to_phrase}), with the same rules as parse_phrase.SeedelfKey.fromEntropy, CardanoAccount.fromEntropy), so the phrase never becomes a JavaScript string after onboarding, unless the user asks to see it (Settings, below).seedelf_key_v1_from_entropy, CardanoAccount::from_entropy), and they’re the keys the phrase gives. Tests check that against the frozen vectors and against pallas’s own Cardano master key.SBV1, adapted from Lace (packages/lib/core/src/secret-box/) into secret-box/. Those files stay under Apache-2.0, with Lace’s notice and our changes listed in that folder’s README.
@noble/hashes).
kdf.ts); the owner kept them to match Lace (crypto review, 2026-09-25). The crypto review put a stolen profile at about 10⁴ guesses a second per GPU, so length is what protects the password. Stronger parameters mean SBV2 and a reseal at the next unlock.TextEncoder), not Unicode-normalized, so the same password composed differently on another keyboard won’t open the vault.@noble/ciphers).SBV1, a 32-byte salt and a 12-byte nonce. It is authenticated as associated data.SBV2), never a silent change.SBV1.extension/tests/vectors/secret_box_sbv1.json was made with Python’s argon2-cffi (the reference Argon2) and cryptography’s ChaCha20Poly1305. The TypeScript code reproduces its keys and blobs byte for byte.createdAt. The same back-off applies.delete wallet), as Forgot password does, and every private record with it but two: a payment that may still go through, and, from Remove wallet only, a mix from the public account that may have gone through, stay sealed until a wallet is next made or restored here, which looks for them again if it’s the same phrase and deletes them if not (independent review M2, final review F1). While something is still open (that payment or mix, a private session, a Lovejoin chain being sent), it lists it first and asks a second time (independent review M5).PRIVATE_RECORDS in private-store.ts) are sealed under a second key: HKDF-SHA-256 of the entropy with its own salt (seedelf-web-wallet-private-store-v1), so it has nothing to do with the Seedelf or Cardano keys. See architecture.md.setArgon2idImplementation hook is kept in case that changes.x and the Cardano account key before releasing them, and clears chrome.storage.session, all but when the wallet last sent something (seedelf.sends, for Lovejoin’s quiet rule; independent review M10) and the sites the user declined, with their waits (seedelf.dapp.refused: origins, waits and counts, never a title), which Remove wallet doesn’t keep (architecture.md).