Seedelf-Wallet

Keys and accounts

One phrase, two key trees

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:

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).

Seedelf key derivation

v1, frozen on 2026-09-23. Once wallets exist this derivation can never change, or their phrases stop restoring funds.

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:

One-time spend keys

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

Accounts

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

The Cardano account

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.

Rules:

  1. One account at a time, and all of it. Several accounts since chunk 18.
    • Discovery scans both the receive (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.
    • Everything under the account’s payment keys is the account’s, whatever the address’s staking part: enterprise addresses, and our key with someone else’s stake key, are found and spent too (chunk 12).
    • Which account the wallet works on is one integer in 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.
    • Any CIP-1852 index is an account but one. The path component is hardened, so 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.
    • A custom or non-sequential account is reached by number (the owner, 2026-10-02). Sequential discovery stops at the first unused account, so it can never find account 1337; and an account that has never been used can’t be discovered at all. So Settings → Public accounts has an account-number entry with two actions: Check it, one 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).
    • Discovery looks for accounts in order, stopping at the first never used (BIP44’s rule), one 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).
    • The Seedelf key stays on account 0. One private balance for the whole phrase, whichever public account is active. See Several accounts and the private balance.
    • Each account has its own staking (its own 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.
    • One account is the dApp account (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.
    • A switch is refused while something of the account’s is on its way — a payment Koios didn’t answer, a mix from the public account stopped at a transaction that may have gone through, or a Lovejoin chain still being sent — since a watch must not lose its account halfway through.
  2. One collateral, set aside, as in Lace (chunk 12).
    • Lace’s collateral is just a pure-ADA UTxO of exactly 5 ADA, which Lace marks as reserved in its own local storage; nothing on-chain says so. The web wallet does the same, in Settings → Collateral.
    • With none chosen, the wallet takes the oldest pure 5 ADA UTxO the account holds (no transaction), so another wallet’s collateral on the same phrase stays put. Reclaiming it stops that; setting one with none to take pays 5 ADA to 0/0.
    • It’s kept out of every payment, and put up only by what the account signs that runs a script: the account-paid mint, a Lovejoin mix from the public account (chunk 16), and a connected site’s transaction (CIP-30’s getCollateral, chunk 15). Move-in and send run no script, and Seedelf spends use giveme.my.
    • Until chunk 12, move-in and send never spent any pure 5 ADA UTxO, as the CLI’s collect_address_utxos doesn’t. Now only the collateral, and the UTxOs the user locked, stay put.
  3. Tokens and NFTs are shown. Move-in moves ADA by default, and tokens only when the user picks them. Each Seedelf UTxO can only hold so many tokens (see the root README’s Wallet Limitations).
  4. The account stakes, with its own stake key 2/0 (chunk 13).
    • Delegation to one pool, the vote’s delegation, and the rewards are the account’s, as in Lace: a restored phrase shows the same pool and DRep, and a change made here shows there.
    • The stake key signs inside WebAssembly, only for a certificate or a withdrawal, and never leaves it. See flows.md.
    • Moving ADA into Seedelf lowers the stake behind that delegation, because Seedelf addresses have no staking part.
  5. The account can be its own DRep, with its DRep key 3/0 (chunk 21).
    • CIP-105’s role 3, index 0, as Lace derives it (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.
    • Each account has its own: several accounts are several DReps.
    • The DRep key signs inside WebAssembly, only for a DRep certificate or a vote, and never leaves it. Its public key and ID can leave (drepOf), for Koios and for CIP-95. See flows.md.
  6. Using it alongside another wallet is fine. Both wallets can spend the same UTxOs. If both try at once, one transaction simply fails.
  7. It is not private. For a restored wallet, this account is the user’s public identity, and the UI never suggests otherwise.

The Seedelf key is separate. It stays on its own account 0 whichever Cardano account is used.

Several accounts and the private balance

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.

Password and vault

Built in chunk 5. The code is in extension/src/background/: vault.ts, wallet.ts and secret-box/.