No scheme choice anywhere. Create and restore no longer ask for ML-DSA-65 or ML-DSA-87, and the commands behind them take no scheme. A wallet that carries a phrase opens with an account under each scheme at its conventional path, ML-DSA-65 first and ML-DSA-87 labelled legacy, plus the file's own keypair when it sits at another path, de-duplicated by account id. A raw key such as crystal_alice keeps its one account. The default follows Quantus's own clients: quantus-cli's --scheme default, the mobile SDK's DilithiumSchemeExtension.current and the extension's DEFAULT_TYPE are all ML-DSA-65, and the extension already shows both accounts with no key type to choose. The keystore file stays quantus-cli's and records the ML-DSA-65 keypair; the rest is derived on every open. Any number of wallets open at once. The session holds a list of open wallets, each with its accounts and seed; unlocking adds one, locking takes a wallet name or locks all, signing finds the account in whichever wallet holds it, and one idle clock locks them all. Session status lists the wallets and a flat account list where every account names its wallet. The sidebar lists open wallets with a lock each, an "open another" page and "lock all"; accounts groups rows by wallet; send, receive and history pick from every account of every open wallet, grouped by wallet and labelled by scheme; the wormhole panel is per wallet. Driven on the dev node: crystal_alice open, the public dev phrase restored beside it with no scheme field, and its two accounts appeared, qzq29m9… (ML-DSA-65) and qzjrYTUnn… (ML-DSA-87, legacy), the addresses quantus-cli lists for that phrase. 5 DEV from crystal_alice to the legacy account, 2 DEV from it to its ML-DSA-65 sibling, both in blocks. Locking devphrase left crystal_alice signing while a transfer from devphrase's account was refused; lock all returned to the lock screen. Unit tests cover both-schemes derivation for every CLI fixture, side-by-side wallets, per-wallet lock and the shared idle rule; the secret-leak test passes with both signers held. Closes #65 Closes #66 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
4.6 KiB
CLAUDE.md
Guidance for Claude Code working in this repository.
Read readme.md first: it carries the shape, the security model and the
reasoning. Conventions are ~/git/architecture (generic.md for the
workspace shape and §14 for how epics are executed, gitea-runners.md for
runs-on: labels). This file is the things that will bite you.
Work is filed before it is done
Epics (epic label) and their children (child label) live on
blackbeard/wallet at git.lair.cafe. The commit that implements a child closes
it with Closes #N. When an investigation contradicts an issue, comment on the
issue rather than quietly building the right thing; the correction is the
valuable half of the history.
The boundary is the security model
- The webview never receives a secret, a seed, a mnemonic, or a private key, and never decodes a transaction itself. If a command needs to show the user what they are signing, Rust decodes it and returns a summary.
- Every plugin permission goes into
crates/wallet-app/capabilities/*.jsondeliberately, with a reason in the description. Noallow-all. - Amounts are
wallet_entities::Amount: integer base units plus decimals, serialised as strings. Nof64touches a balance, ever. - Ramp providers open in the system browser, never in the wallet's webview.
Things that will bite you
- Quantus mainnet is spec 152 and verifies extrinsic signatures under the
FIPS 204 context
QUANTUS_EXTRINSIC(from spec 148). A signature made with the empty context is cryptographically valid and rejected by the chain. - The account id is Poseidon2 of the public key, so the signature carries the public key on the wire. ML-DSA-65 is the default for new accounts; ML-DSA-87 must stay decodable because dev-genesis, legacy and MPC accounts use it.
- Derivation is
m/44'/189189'/account'/change'/index', every level hardened. Convention in the other Quantus wallets: index0'is ML-DSA-87,1'is ML-DSA-65. Match it or an imported mnemonic shows an empty wallet. - There is no scheme choice anywhere (#65). A wallet that carries a phrase
opens with both schemes' accounts, ML-DSA-65 first (what quantus-cli, the
mobile wallet and the extension call current) and ML-DSA-87 labelled
legacy, plus the file's own keypair if it sits at another path. The file
records the ML-DSA-65 keypair (
paths::CURRENT_SCHEME); the rest is derived on every open (keystore::accounts_for_phrase). - Any number of wallets are open at once (#66). Nothing may read "the"
wallet: session status lists
wallets, each account names its wallet,locktakes an optional wallet,with_seedandwormhole_summarytake one. The idle rule locks them all. - The keystore file format is quantus-cli's (Argon2id + AES-256-GCM). It is shared with the CLI and the browser extension; do not invent a second one.
cargo testregeneratesui/src/api/generatedvia ts-rs; commit the result with the entities change or CI fails on drift.
Running it
script/dev-node.sh # a throwaway quantus-node --dev on :9944
cd crates/wallet-app && WALLET_DATA_DIR=/tmp/wallet-dev cargo tauri dev
WALLET_DATA_DIR relocates wallets, settings, profile overrides and the
history cache (history.sqlite) together; put quantus-cli wallet files (crystal_alice.json from the test
fixtures opens with an empty password) under <dir>/wallets/ and a
settings.json with "network": "dev". History comes from
blackbeard.observer's /v1/chains/{chain}/accounts/{address} route (the
indexer field of a profile); a profiles.toml override in the data dir
pointing indexer at a dead address is how the offline path is exercised. tauri.conf.json carries a
separate devCsp: Vite's dev page injects an inline React Refresh
preamble, and under the production CSP the page is blank white with no
error anywhere you can see.
To verify without eyes, start with WEBKIT_INSPECTOR_HTTP_SERVER=127.0.0.1:9223
and use script/webview-inspect.py to read the DOM and click through it;
GNOME refuses D-Bus screenshots and ImageMagick captures WebKit's window as
a flat grey rectangle, so neither is evidence of anything. Two more limits:
the inspector cannot await a promise (park the result on window and
poll), and a native file chooser is a Wayland window nothing here can
type into, so exercise walletImportFile through import('/src/api/wallet.ts')
and treat a pickWalletFile() promise that stays pending, rather than
rejecting, as the permission check passing.
Gates
cargo fmt --check --all
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
pnpm --dir ui typecheck && pnpm --dir ui lint && pnpm --dir ui build