Skip to content

feat(accounts): add ML-DSA-65 support for transparent accounts - #628

Merged
n13 merged 3 commits into
mainfrom
feat/ml-dsa-65
Sep 3, 2026
Merged

feat(accounts): add ML-DSA-65 support for transparent accounts#628
n13 merged 3 commits into
mainfrom
feat/ml-dsa-65

Conversation

@n13

@n13 n13 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

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-crystals crates 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)

  • Transparent derivation path: 87 → m/44'/189189'/{account}'/0'/0', 65 → .../0'/1'. The trailing hardened index encodes the scheme.
  • Chain signature-type byte: Dilithium87 = 0, Dilithium65 = 1. Account id is poseidon(pubkey) for both.
  • A stored account with no scheme reads as ML-DSA-87 (historic wallets).

How

  • SDK / Rust bridge: DilithiumScheme enum exposed from Rust; one dispatch! macro binds the ml_dsa_65 / ml_dsa_87 modules so each function has a single body. The Keypair carries its scheme, so signing, verification, sizes and the wire byte follow the keypair rather than a hardcoded 7219 constant. New DilithiumSchemeExtension is the single home for scheme constants.
  • Storage: Account and ColdAccount gain a scheme (and the transparent Account a stored derivationPath). Index is unique per (walletIndex, scheme). Legacy rows without a scheme default to 87.
  • Mobile: create and import produce 65 at index 0. Import scans the chain for both schemes, keeps a returning user's funded account active, and falls back to the 87 root if the indexer is unreachable. A new account follows the wallet's scheme (65 if the wallet already holds any 65 account, else 87).
  • Cold wallet (air-gapped): create is 65; import holds both schemes for an index and respects the scheme a full path names. The external signature travels as a single signature ++ publicKey blob whose length identifies the scheme.
  • Miner stays ML-DSA-87.

Tests

  • Rust unit tests for ML-DSA-65 (sign/verify, context binding, sizes, address uniqueness).
  • Dart native vectors for ML-DSA-65 at index 0 and 1, cross-checked against quantus wallet import --scheme ml-dsa-65.
  • Discovery across both schemes, account/cold-account scheme round-trips, per-scheme next-free-index, fee sizing for both schemes, UR frames for both payload sizes.
  • Full suites pass: quantus_sdk (499), mobile-app (424), cold-wallet-app (277); melos analyze clean.

Note: miner-app/test/widget_test.dart (a boilerplate stub with no main) fails to load; it is pre-existing and untouched here.

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 n13 added the bot-review Request automated review from review-bot label Sep 3, 2026

@n13 n13 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 --check clean.
  • 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 Analyze check is green.
  • cargo clippy --locked --all-targets -- -D warnings reaches an existing needless_range_loop warning in unchanged quantus_sdk/rust/src/api/wormhole.rs:376; this PR does not introduce it.

@n13 n13 removed the bot-review Request automated review from review-bot label Sep 3, 2026
final fee = balancesService.transferFee(
ed,
dispatchWeight: await balancesService.transferDispatchWeight(),
scheme: account.scheme ?? DilithiumSchemeExtension.legacy,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we want to fail instead of fallback here? Like why would account.scheme empty?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really confusing, I thought it's the index of the scheme.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the runtime metadata doesn't contain this bytes detail of the signatures? if we can, we should avoid hard coding shared value.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

metadata should have this, actually this should come from rusty crystals crate

@dewabisma dewabisma left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Just few nits.

…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.
@n13

n13 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

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.
@n13
n13 merged commit c124160 into main Sep 3, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants