feat(accounts): add ML-DSA-65 support for transparent accounts - #628
Conversation
New wallets and accounts now use ML-DSA-65; existing ML-DSA-87 accounts keep working unchanged and seed import finds both. No UX change. The Rust bridge keypair carries its scheme, so signing, verification, sizes and the extrinsic signature-type byte all follow the keypair instead of a hardcoded ML-DSA-87 constant. Derivation matches quantus-cli: 65 uses the `.../1'` path, 87 uses `.../0'`; the account id is poseidon(pubkey) for both. - SDK: DilithiumScheme enum exposed from Rust (single dispatch macro binds the ml_dsa_65/87 modules). New DilithiumSchemeExtension holds the scheme constants (wire byte, path index, storage name, signature+pubkey size). Keypair, Account and ColdAccount carry a scheme; accounts with no stored scheme read as ML-DSA-87. Account index is unique per (walletIndex, scheme). - Mobile: create and import produce ML-DSA-65 at index 0. Import discovers both schemes on chain, keeps a returning user's funded account active, and falls back to the ML-DSA-87 root if the indexer is unreachable. New accounts follow the wallet's scheme. - Cold wallet: create is ML-DSA-65; import holds both schemes for an index and respects the scheme a full path names. The external signature is carried as a single blob whose length identifies the scheme. - Encrypted (wormhole) accounts and derivation are untouched. Miner stays 87. Cross-checked ML-DSA-65 addresses against quantus-cli and pinned them as test vectors.
n13
left a comment
There was a problem hiding this comment.
Reviewer model: GPT Sol
Verdict (advisory): Approve
No blocking findings. I reviewed exact head 580dae79cbaea19f37d5c0e073624a8ecf8712c6 and traced scheme selection through derivation, persisted account migration, discovery, local/cold/hardware signing, fee sizing, and SCALE extrinsic encoding. The legacy default preserves the existing ML-DSA-87 path, while the new path/storage values match quantus-cli and discriminator bytes 0/1 match the chain's Dilithium87/Dilithium65 enum order.
Non-blocking cleanup: cargo fmt --all -- --check asks for two line-wrap-only changes in quantus_sdk/rust/src/api/crypto.rs:58 and :448.
Validation:
cargo test --locked: 54 passed.- SDK Flutter tests: 470 non-native and 29 native passed.
- Mobile Flutter tests: 424 passed.
- Cold-wallet Flutter tests: 277 passed.
- Dart format dry-run: 714 files checked, 0 changed;
git diff --checkclean. - Local fatal-info analysis completed cleanly for cold-wallet, miner, and SDK; mobile analysis was stopped at the required 10-second hang cap. GitHub's exact-head
Analyzecheck is green. cargo clippy --locked --all-targets -- -D warningsreaches an existingneedless_range_loopwarning in unchangedquantus_sdk/rust/src/api/wormhole.rs:376; this PR does not introduce it.
| final fee = balancesService.transferFee( | ||
| ed, | ||
| dispatchWeight: await balancesService.transferDispatchWeight(), | ||
| scheme: account.scheme ?? DilithiumSchemeExtension.legacy, |
There was a problem hiding this comment.
Don't we want to fail instead of fallback here? Like why would account.scheme empty?
There was a problem hiding this comment.
Hmm yeah.. i told it to fall back for accounts that don't have a scheme but it would be nicer to give them a scheme when loading them, not do the fallback here!
| /// The account [text] names as an index, or null when it is not one. The | ||
| /// label follows the index, so the wallet's own numbering stays predictable. | ||
| static ColdAccount? atIndexText(String text) { | ||
| static int _schemeRank(DilithiumScheme scheme) => scheme == DilithiumSchemeExtension.current ? 0 : 1; |
There was a problem hiding this comment.
This is really confusing, I thought it's the index of the scheme.
There was a problem hiding this comment.
Scheme Rank may be the wrong word... but also not bad... let me look into this
|
|
||
| /// Bytes of an ML-DSA signature. FIPS 204 fixed constants, cross-checked | ||
| /// against the Rust `signatureBytes` in tests. | ||
| int get signatureByteLength => switch (this) { |
There was a problem hiding this comment.
the runtime metadata doesn't contain this bytes detail of the signatures? if we can, we should avoid hard coding shared value.
There was a problem hiding this comment.
metadata should have this, actually this should come from rusty crystals crate
…centralize fee scheme - The signature/public-key byte sizes now come from the rusty-crystals crate via the Rust bridge instead of hardcoded Dart constants, so the value lives in one place. transaction_fee_test is now a native test since sizing calls the bridge. - Rename the sort helper _schemeRank to _schemeSortOrder (Account and ColdAccount) and note it is an ordering key, not the derivation path index (0 for 87, 1 for 65). - Replace the scattered `account.scheme ?? legacy` fee fallback with a documented Account.feeSizingScheme getter: hardware accounts have no local scheme until the device signs, so fee sizing uses the larger ML-DSA-87 to never understate.
|
All comments addressed |
…ust bridge
polkadart already generates the fixed-size codec for the signature-with-public
types, so signatureWithPublicKeyBytes now reads 5261 / 7219 from
Dilithium{65,87}SignatureWithPublic.codec instead of summing the Rust bridge
sizes. The value tracks the chain's wire format directly and needs no native
call, so transaction_fee_test is a plain (non-native) test again. The native
key test still cross-checks the metadata size against the crate.
What
New wallets and accounts now generate ML-DSA-65 keys. Existing ML-DSA-87 accounts keep working unchanged, seed import finds both, and there is no UX change. Encrypted (wormhole) accounts and derivation are untouched.
The chain, quantus-cli, and the
qp-rusty-crystalscrates already support ML-DSA-65 (5261 bytes of signature+pubkey per extrinsic vs 7219). This wires it into the Flutter code, which was ML-DSA-87 only.Conventions (match quantus-cli)
m/44'/189189'/{account}'/0'/0', 65 →.../0'/1'. The trailing hardened index encodes the scheme.Dilithium87 = 0,Dilithium65 = 1. Account id isposeidon(pubkey)for both.How
DilithiumSchemeenum exposed from Rust; onedispatch!macro binds theml_dsa_65/ml_dsa_87modules so each function has a single body. TheKeypaircarries its scheme, so signing, verification, sizes and the wire byte follow the keypair rather than a hardcoded 7219 constant. NewDilithiumSchemeExtensionis the single home for scheme constants.AccountandColdAccountgain ascheme(and the transparentAccounta storedderivationPath). Index is unique per(walletIndex, scheme). Legacy rows without a scheme default to 87.signature ++ publicKeyblob whose length identifies the scheme.Tests
quantus wallet import --scheme ml-dsa-65.melos analyzeclean.Note:
miner-app/test/widget_test.dart(a boilerplate stub with nomain) fails to load; it is pre-existing and untouched here.