Files
wallet/CLAUDE.md
rob thijssen cb0fe536cd
All checks were successful
ci / gate (push) Successful in 12m43s
feat: both ML-DSA accounts from every phrase, and several wallets open at once
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
2026-09-16 18:35:04 +03:00

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/*.json deliberately, with a reason in the description. No allow-all.
  • Amounts are wallet_entities::Amount: integer base units plus decimals, serialised as strings. No f64 touches 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: index 0' 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, lock takes an optional wallet, with_seed and wormhole_summary take 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 test regenerates ui/src/api/generated via 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