Wallets: one secret, shown as every account it unlocks (ML-DSA-65, ML-DSA-87, wormhole) #14

Closed
opened 2026-09-16 12:56:29 +00:00 by grenade · 5 comments
Owner

Part of #1. Supersedes the per-key-type dropdown added in 4a052de4, which made one recovery phrase look like two unrelated accounts.

Model

A wallet is one secret: a recovery phrase, or a raw 32-byte seed for dev accounts. It is shown as the accounts that secret unlocks, one tab per kind, for each account index n:

Tab Path Can sign Offered to dapps
ML-DSA-65 m/44'/189189'/n'/0'/1' yes yes
ML-DSA-87 m/44'/189189'/n'/0'/0' yes yes
Wormhole m/44'/189189189'/n'/{0 receive, 1 change}'/k' no (ZK proof) no

The ML-DSA paths are the ones quantus-cli and the mobile wallet derive, where the last index selects the scheme.

  • A raw seed unlocks both ML-DSA tabs at one index, and has no wormhole tab: wormhole derivation starts from the 64-byte BIP39 seed.
  • A single-key import (JSON backup, quantus-cli wallet file) stays a standalone account with one tab.
  • Existing accounts are unchanged and appear as standalone accounts.

Storage

  • wallet:<random id> holds the name, the secret encrypted with the wallet password (the keyring's scrypt + xsalsa20-poly1305 JSON encryption), the account indices present, and the derived wormhole addresses (public).
  • ML-DSA keys stay ordinary keyring pairs, tagged with walletId and accountIndex, so signing, dapp injection and JSON export don't change.
  • Adding an account index, or deriving further wormhole addresses, needs the password. Showing anything doesn't.

Stage A: this change

  • @quantus/crypto: derive wormhole addresses. The secret never leaves WASM; the reference vector is the chain node's TEST_WORMHOLE_ADDRESS.
  • Background: create a wallet from a phrase or seed, preview its three account-0 addresses, add an account index, rename, forget.
  • Create/import: one flow per secret, with no key-type choice. The preview shows all three addresses, so the user can recognise their wallet by any of them.
  • Account list: wallets as cards with tabs and an account-index selector. The wormhole tab shows the receive address; its balance comes in stage B.

Stage B: wormhole balance (read-only)

A wormhole address's System.Account balance is everything it has ever received. An exit credits the exit account (increase_balance) and marks a nullifier used, and nothing debits the wormhole address. Spendable is the sum of transfers whose nullifier N(secret, transfer_count) is not in Wormhole.UsedNullifiers.

  • Transfer count comes from Wormhole.TransferCount(address).
  • Per-transfer amounts live in ZkTree.Leaves(index), which has no reverse index by recipient. Mainnet has 110,576 leaves and Heisenberg 2,042,328 (2026-09-16), so a full scan is workable on mainnet but not on the testnet.
  • Deposit discovery will therefore use the indexer, as the mobile wallet does, from a user-settable endpoint in the same privacy posture as balances.
  • Nullifiers need the secret. Precompute them when the password is available, for a window of transfer counts, and store them alongside the addresses. They can link a user to their exits but can't spend. This is stated here because it is a trade-off.
  • The nullifier is Poseidon2(Poseidon2(salt ‖ secret ‖ count)) in qp-wormhole-circuit. Port it onto qp-poseidon-core in @quantus/crypto with known-answer tests against the circuit crate, rather than pulling plonky2 into the WASM.

Sending from a wormhole address is #13.

Part of #1. Supersedes the per-key-type dropdown added in `4a052de4`, which made one recovery phrase look like two unrelated accounts. ## Model A **wallet** is one secret: a recovery phrase, or a raw 32-byte seed for dev accounts. It is shown as the accounts that secret unlocks, one tab per kind, for each account index `n`: | Tab | Path | Can sign | Offered to dapps | |---|---|---|---| | ML-DSA-65 | `m/44'/189189'/n'/0'/1'` | yes | yes | | ML-DSA-87 | `m/44'/189189'/n'/0'/0'` | yes | yes | | Wormhole | `m/44'/189189189'/n'/{0 receive, 1 change}'/k'` | no (ZK proof) | no | The ML-DSA paths are the ones `quantus-cli` and the mobile wallet derive, where the last index selects the scheme. - A **raw seed** unlocks both ML-DSA tabs at one index, and has no wormhole tab: wormhole derivation starts from the 64-byte BIP39 seed. - A **single-key import** (JSON backup, `quantus-cli` wallet file) stays a standalone account with one tab. - Existing accounts are unchanged and appear as standalone accounts. ## Storage - `wallet:<random id>` holds the name, the secret **encrypted with the wallet password** (the keyring's scrypt + xsalsa20-poly1305 JSON encryption), the account indices present, and the derived wormhole addresses (public). - ML-DSA keys stay ordinary keyring pairs, tagged with `walletId` and `accountIndex`, so signing, dapp injection and JSON export don't change. - Adding an account index, or deriving further wormhole addresses, needs the password. Showing anything doesn't. ## Stage A: this change - `@quantus/crypto`: derive wormhole addresses. The secret never leaves WASM; the reference vector is the chain node's `TEST_WORMHOLE_ADDRESS`. - Background: create a wallet from a phrase or seed, preview its three account-0 addresses, add an account index, rename, forget. - Create/import: one flow per secret, with no key-type choice. The preview shows all three addresses, so the user can recognise their wallet by any of them. - Account list: wallets as cards with tabs and an account-index selector. The wormhole tab shows the receive address; its balance comes in stage B. ## Stage B: wormhole balance (read-only) A wormhole address's `System.Account` balance is **everything it has ever received**. An exit credits the exit account (`increase_balance`) and marks a nullifier used, and nothing debits the wormhole address. Spendable is the sum of transfers whose nullifier `N(secret, transfer_count)` is not in `Wormhole.UsedNullifiers`. - **Transfer count** comes from `Wormhole.TransferCount(address)`. - **Per-transfer amounts** live in `ZkTree.Leaves(index)`, which has no reverse index by recipient. Mainnet has 110,576 leaves and Heisenberg 2,042,328 (2026-09-16), so a full scan is workable on mainnet but not on the testnet. - **Deposit discovery** will therefore use the indexer, as the mobile wallet does, from a user-settable endpoint in the same privacy posture as balances. - **Nullifiers** need the secret. Precompute them when the password is available, for a window of transfer counts, and store them alongside the addresses. They can link a user to their exits but can't spend. This is stated here because it is a trade-off. - The nullifier is `Poseidon2(Poseidon2(salt ‖ secret ‖ count))` in `qp-wormhole-circuit`. Port it onto `qp-poseidon-core` in `@quantus/crypto` with known-answer tests against the circuit crate, rather than pulling plonky2 into the WASM. Sending from a wormhole address is #13.
Author
Owner

ZK tree sizes, read from ZkTree.LeafCount on 2026-09-16:

Chain Leaves Depth
Mainnet 110,576 9
Heisenberg 2,042,328 11
Planck 2,414,744 11

Each ZkTree.Leaves value is 60 bytes. Planck is the newer testnet, but its tree is already bigger than Heisenberg's, so a full leaf scan (download every leaf and keep the recipient's) is workable only on mainnet today. Stage B needs a discovery method that works on the testnets, whether indexer queries or something else, and can treat a full scan as the more private option where the tree is small enough.

ZK tree sizes, read from `ZkTree.LeafCount` on 2026-09-16: | Chain | Leaves | Depth | |---|---|---| | Mainnet | 110,576 | 9 | | Heisenberg | 2,042,328 | 11 | | Planck | 2,414,744 | 11 | Each `ZkTree.Leaves` value is 60 bytes. Planck is the newer testnet, but its tree is already bigger than Heisenberg's, so a full leaf scan (download every leaf and keep the recipient's) is workable only on mainnet today. Stage B needs a discovery method that works on the testnets, whether indexer queries or something else, and can treat a full scan as the more private option where the tree is small enough.
Author
Owner

Stage A is on quantus-accounts:

  • quantus/wasm 0b1cfe35, published as @quantus/crypto 0.2.0: wormholeAddresses(). It is pinned to the chain node's TEST_WORMHOLE_ADDRESS in Rust and through the packed package.
  • 22eb1c25: background wallets (create, preview, add account, rename, forget) and 9 specs against independent vectors.
  • c378b36a: wallet cards with ML-DSA-65 / ML-DSA-87 / Wormhole tabs and an account-index selector. Import and create take a phrase or seed with no key type or path, and show a three-address preview.

Checked in Firefox against the built extension:

  • The node's TEST_MNEMONIC previews TEST_WORMHOLE_ADDRESS and TEST_ADDRESS_HD_0 (the ML-DSA-87 account 0).
  • The zero seed previews crystal_alice with no wormhole tab, and a second import of it is refused.
  • The secret is stored only encrypted.
  • Add account refuses a wrong password, then adds account 1.
  • Forget removes the wallet and its pairs.

Still to do for stage A: nothing is migrated automatically. Accounts imported before wallets stay standalone; re-importing their phrase as a wallet adopts the same pairs.

Stage B, the wormhole balance, is next.

Stage A is on `quantus-accounts`: - quantus/wasm `0b1cfe35`, published as `@quantus/crypto` 0.2.0: `wormholeAddresses()`. It is pinned to the chain node's `TEST_WORMHOLE_ADDRESS` in Rust and through the packed package. - `22eb1c25`: background wallets (create, preview, add account, rename, forget) and 9 specs against independent vectors. - `c378b36a`: wallet cards with ML-DSA-65 / ML-DSA-87 / Wormhole tabs and an account-index selector. Import and create take a phrase or seed with no key type or path, and show a three-address preview. Checked in Firefox against the built extension: - The node's `TEST_MNEMONIC` previews `TEST_WORMHOLE_ADDRESS` and `TEST_ADDRESS_HD_0` (the ML-DSA-87 account 0). - The zero seed previews crystal_alice with no wormhole tab, and a second import of it is refused. - The secret is stored only encrypted. - Add account refuses a wrong password, then adds account 1. - Forget removes the wallet and its pairs. Still to do for stage A: nothing is migrated automatically. Accounts imported before wallets stay standalone; re-importing their phrase as a wallet adopts the same pairs. Stage B, the wormhole balance, is next.
Author
Owner

Stage B (read-only wormhole balance) is on quantus-accounts. It differs from the plan above in three ways:

  • Deposits come from blackbeard.observer, not Quantus's Subsquid. The new GET /v1/chains/{chain}/wormhole/deposits is blackbeard/observer#22 and is deployed. While building on it, observer turned out to be missing every event kind before its 2026-09-10 policy change (mainnet below 18510); that is blackbeard/observer#23, fixed and deployed, and history is being re-read. The extension checks each address's deposits against the chain's Wormhole::TransferCount, so a gap shows as "N deposits not indexed yet" and never as a smaller balance.
  • Spent status is read in buckets, not by key. UsedNullifiers is Blake2_128Concat, so a key lookup hands the node the nullifier. The extension reads whole buckets by the first byte of blake2_128(nullifier): at least 16, random decoys included, in shuffled order. Checked live: a spent nullifier from a public ProofVerified event is found in its bucket.
  • Nullifiers are precomputed and stored, the user's choice: 128 transfer counts per address when a phrase wallet or account is made. "Unlock to check" extends only the addresses whose deposits outgrew what is stored. Forgetting a wallet removes them for every account index. @quantus/crypto 0.3.0 computes them, pinned to qp-wormhole-circuit's Nullifier::from_preimage.

Commits:

  • quantus/wasm quantus-crypto: nullifiers, published as 0.3.0.
  • a4e19a4b: background (balance, unlock, storage and removal) with specs.
  • 957db67f: wormhole tab, unlock page, and the "look up wormhole deposits with" setting.

Not yet exercised against a wallet that holds wormhole deposits: nothing on hand has both deposits and a known phrase. The unchecked, unlock and spent paths are covered by specs and by the live bucket check, not by an end-to-end run.

Stage B (read-only wormhole balance) is on `quantus-accounts`. It differs from the plan above in three ways: - **Deposits come from blackbeard.observer, not Quantus's Subsquid.** The new `GET /v1/chains/{chain}/wormhole/deposits` is blackbeard/observer#22 and is deployed. While building on it, observer turned out to be missing every event kind before its 2026-09-10 policy change (mainnet below 18510); that is blackbeard/observer#23, fixed and deployed, and history is being re-read. The extension checks each address's deposits against the chain's `Wormhole::TransferCount`, so a gap shows as "N deposits not indexed yet" and never as a smaller balance. - **Spent status is read in buckets, not by key.** `UsedNullifiers` is `Blake2_128Concat`, so a key lookup hands the node the nullifier. The extension reads whole buckets by the first byte of `blake2_128(nullifier)`: at least 16, random decoys included, in shuffled order. Checked live: a spent nullifier from a public `ProofVerified` event is found in its bucket. - **Nullifiers are precomputed and stored,** the user's choice: 128 transfer counts per address when a phrase wallet or account is made. "Unlock to check" extends only the addresses whose deposits outgrew what is stored. Forgetting a wallet removes them for every account index. `@quantus/crypto` 0.3.0 computes them, pinned to `qp-wormhole-circuit`'s `Nullifier::from_preimage`. Commits: - quantus/wasm `quantus-crypto`: nullifiers, published as 0.3.0. - `a4e19a4b`: background (balance, unlock, storage and removal) with specs. - `957db67f`: wormhole tab, unlock page, and the "look up wormhole deposits with" setting. Not yet exercised against a wallet that holds wormhole deposits: nothing on hand has both deposits and a known phrase. The unchecked, unlock and spent paths are covered by specs and by the live bucket check, not by an end-to-end run.
Author
Owner

Stage B has now been checked end to end against a real mainnet wallet, the operator's own, whose wormhole receive address 0 is where its mining rewards go.

  • During the rescan: before blackbeard/observer#23's rescan reached genesis, the tab showed "At least 3.81 QTC spendable, 18 deposits are not indexed by the observer yet". The 13 deposits it had (transfer counts 18–30) add to exactly 3.81 QTC. The shortfall was stated rather than hidden.
  • After the rescan: once mainnet was re-read from block 1, the observer returned all 31 deposits (counts 0–30, 9.34 QTC), and Refresh showed the full 9.34 QTC balance. That matches the observer's own account page: 9.33 in mining rewards plus a 0.01 volume-fee share.

Still unexercised: a deposit that has been spent. This wallet has exited nothing, so "already exited" is 0 and the bucket lookup found none of its nullifiers used.

Stage B has now been checked end to end against a real mainnet wallet, the operator's own, whose wormhole receive address 0 is where its mining rewards go. - **During the rescan:** before blackbeard/observer#23's rescan reached genesis, the tab showed "At least 3.81 QTC spendable, 18 deposits are not indexed by the observer yet". The 13 deposits it had (transfer counts 18–30) add to exactly 3.81 QTC. The shortfall was stated rather than hidden. - **After the rescan:** once mainnet was re-read from block 1, the observer returned all 31 deposits (counts 0–30, 9.34 QTC), and Refresh showed the full 9.34 QTC balance. That matches the observer's own account page: 9.33 in mining rewards plus a 0.01 volume-fee share. Still unexercised: a deposit that has been spent. This wallet has exited nothing, so "already exited" is 0 and the bucket lookup found none of its nullifiers used.
Author
Owner

Closing: stages A and B are on main (22eb1c25 to 957db67f) and have been checked against a real mainnet wallet (31 deposits, 9.34 QTC).

Correction to my stage B comment. It said the unchecked, unlock and spent paths were "covered by specs". That overstates it:

  • Specs cover: nullifier storage, windowing, extension on unlock, and removal on forget.
  • No spec covers:
    • wormholeBalance or usedNullifiers themselves;
    • the arithmetic that sorts deposits into spent, unchecked and not-yet-indexed.
  • Live checks cover: reading a known spent nullifier from its bucket, and the missing-deposit report against the mainnet rescan gap.

A spent deposit has never been exercised end to end. The first real exit from a wallet this extension holds is the test.

If that logic changes, it needs unit tests with a fake provider and observer first. Sending from a wormhole address is #13.

Closing: stages A and B are on `main` (`22eb1c25` to `957db67f`) and have been checked against a real mainnet wallet (31 deposits, 9.34 QTC). **Correction to my stage B comment.** It said the unchecked, unlock and spent paths were "covered by specs". That overstates it: - **Specs cover:** nullifier storage, windowing, extension on unlock, and removal on forget. - **No spec covers:** - `wormholeBalance` or `usedNullifiers` themselves; - the arithmetic that sorts deposits into spent, unchecked and not-yet-indexed. - **Live checks cover:** reading a known spent nullifier from its bucket, and the missing-deposit report against the mainnet rescan gap. A spent deposit has never been exercised end to end. The first real exit from a wallet this extension holds is the test. If that logic changes, it needs unit tests with a fake provider and observer first. Sending from a wormhole address is #13.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: quantus/extension#14