Wallets: one secret, shown as every account it unlocks (ML-DSA-65, ML-DSA-87, wormhole) #14
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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:m/44'/189189'/n'/0'/1'm/44'/189189'/n'/0'/0'm/44'/189189189'/n'/{0 receive, 1 change}'/k'The ML-DSA paths are the ones
quantus-cliand the mobile wallet derive, where the last index selects the scheme.quantus-cliwallet file) stays a standalone account with one tab.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).walletIdandaccountIndex, so signing, dapp injection and JSON export don't change.Stage A: this change
@quantus/crypto: derive wormhole addresses. The secret never leaves WASM; the reference vector is the chain node'sTEST_WORMHOLE_ADDRESS.Stage B: wormhole balance (read-only)
A wormhole address's
System.Accountbalance 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 nullifierN(secret, transfer_count)is not inWormhole.UsedNullifiers.Wormhole.TransferCount(address).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.Poseidon2(Poseidon2(salt ‖ secret ‖ count))inqp-wormhole-circuit. Port it ontoqp-poseidon-corein@quantus/cryptowith known-answer tests against the circuit crate, rather than pulling plonky2 into the WASM.Sending from a wormhole address is #13.
ZK tree sizes, read from
ZkTree.LeafCounton 2026-09-16:Each
ZkTree.Leavesvalue 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.Stage A is on
quantus-accounts:0b1cfe35, published as@quantus/crypto0.2.0:wormholeAddresses(). It is pinned to the chain node'sTEST_WORMHOLE_ADDRESSin 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:
TEST_MNEMONICpreviewsTEST_WORMHOLE_ADDRESSandTEST_ADDRESS_HD_0(the ML-DSA-87 account 0).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 B (read-only wormhole balance) is on
quantus-accounts. It differs from the plan above in three ways:GET /v1/chains/{chain}/wormhole/depositsis 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'sWormhole::TransferCount, so a gap shows as "N deposits not indexed yet" and never as a smaller balance.UsedNullifiersisBlake2_128Concat, so a key lookup hands the node the nullifier. The extension reads whole buckets by the first byte ofblake2_128(nullifier): at least 16, random decoys included, in shuffled order. Checked live: a spent nullifier from a publicProofVerifiedevent is found in its bucket.@quantus/crypto0.3.0 computes them, pinned toqp-wormhole-circuit'sNullifier::from_preimage.Commits:
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 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.
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.
Closing: stages A and B are on
main(22eb1c25to957db67f) 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:
wormholeBalanceorusedNullifiersthemselves;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.