Skip to content

Add ML-DSA-65 signing, default stays ML-DSA-87 - #4

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

n13 merged 2 commits into
mainfrom
feat/ml-dsa-65

Conversation

@n13

@n13 n13 commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Stacked on #3 (signing context fix). Merge #3 first; this PR then retargets to main.

What

The runtime accepts two signature schemes since spec 139: Dilithium87 (variant 0) and Dilithium65 (variant 1). This adds ML-DSA-65 to the whole interface while keeping the existing API: every function takes an optional scheme ("ml-dsa-87" or "ml-dsa-65") in its options/params object, and omitting it is exactly the old ML-DSA-87 behaviour.

ML-DSA-87 (default) ML-DSA-65
public / secret / signature 2592 / 4896 / 4627 bytes 1952 / 4032 / 3309 bytes
wire variant 0 1
signed transfer size 7297 bytes 5339 bytes
  • account(seed, { scheme }), QuantusAccount.scheme
  • signTransfer(seed, { scheme, ... }), signCall(seed, call, { scheme, ... })
  • accountFromMnemonic / signTransferFromMnemonic / signCallFromMnemonic with MnemonicOptions.scheme. Default addressIndex is 0 for ML-DSA-87 and 1 for ML-DSA-65, matching quantus wallet import --scheme ml-dsa-65 and the mobile SDK, so the two schemes never derive from the same entropy.
  • Same QUANTUS_EXTRINSIC context and same Poseidon account derivation for both.

Rust: Scheme enum plus a scheme-agnostic Keypair wrapper, so derivation and extrinsic assembly stay written once.

Tests

Rust (9 pass), each oracle check runs for both schemes against qp-dilithium-crypto 0.7.0: account ids, signature bytes equal sp_core::Pair::sign, SCALE signature field, context binding (empty/other context rejected), and Verify on the decoded extrinsic signature field with a wrong-signer negative. Frozen golden digests for both schemes.

JS (14 pass): default remains ML-DSA-87; ML-DSA-65 sizes; accountFromMnemonic(MNEMONIC, { scheme: "ml-dsa-65" }) equals the wallet vector qzoyC4eRTrexYoutXABVsf61QJZxJim3iWvayRQwEjXWgA4mw; frozen ML-DSA-65 extrinsic with variant byte 1; signCall/signTransfer agreement; invalid scheme throws.

Live on a local quantus-node --dev (spec 148): funded an ML-DSA-65 account from crystal_alice, then signed a transfer from it with scheme: "ml-dsa-65". Accepted (0xf35b6075…), included, balance and nonce moved.

@n13 n13 added the bot-review label Sep 11, 2026

@n13 n13 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reviewer model: GPT 5.6 Sol

Verdict (advisory): Request changes

Blocking finding:

  • [P1] Use the effective scheme when choosing the default mnemonic HD index (js/index.ts:213-219, js/index.ts:242-249). Both mnemonic signing wrappers encode opts.scheme ?? params.scheme, but choose the default address index from only opts.scheme. Consequently, the supported params-only form signTransferFromMnemonic(mnemonic, { scheme: "ml-dsa-65", ... }) signs with ML-DSA-65 at index 0, while accountFromMnemonic(mnemonic, { scheme: "ml-dsa-65" }) and the documented wallet-compatible default use index 1. A built-package check confirmed the embedded signer equals the explicit index-0 account and differs from the default index-1 account; signCallFromMnemonic behaves the same way. Resolve the effective scheme once and use it for both encoding and defaultAddressIndex, then cover the params-only form for both functions. Otherwise callers can sign from an unexpected account rather than the account they derived or funded.

PR #3 is merged, but this PR still targets fix/extrinsic-signing-context; retarget it to main before merging.

Validation:

  • npm ci and npm test: pass (9 Rust tests, 14 JS tests, WASM build, TypeScript build).
  • cargo check --locked --no-default-features --target wasm32-unknown-unknown: pass.
  • cargo clippy --locked --all-targets --all-features -- -D warnings: pass.
  • git diff --check: pass; GitHub CI test: pass.
  • cargo fmt --all -- --check: fails with rustfmt diffs in src/ext.rs and src/mnemonic.rs (the output includes both changed and pre-existing lines).

No other blocking code findings found in the reviewed diff.

@n13 n13 removed the bot-review label Sep 11, 2026
Base automatically changed from fix/extrinsic-signing-context to main September 11, 2026 23:50
n13 added 2 commits September 12, 2026 13:54
The runtime accepts two signature schemes since spec 139:
DilithiumSignatureScheme::Dilithium87 (variant 0) and Dilithium65
(variant 1). Every entry point now takes an optional scheme
("ml-dsa-87" or "ml-dsa-65") in its options/params object. Omitting it
keeps the ML-DSA-87 behaviour, so existing callers are unchanged.

Rust: a Scheme enum and a scheme-agnostic Keypair wrapper so account
derivation and extrinsic assembly are written once. AccountKeys and the
JS Account expose the scheme. Mnemonic derivation uses the hdwallet
per-scheme modules; the JS default addressIndex is 0 for ML-DSA-87 and
1 for ML-DSA-65, matching the Quantus wallets.

Tests cover both schemes against qp-dilithium-crypto 0.7.0: account
ids, signature bytes equal to sp_core::Pair::sign, SCALE signature
field, context binding, and Verify on the decoded extrinsic. JS tests
add the wallet-known ML-DSA-65 mnemonic address and a frozen ML-DSA-65
extrinsic.

@n13 n13 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Reviewer model: GPT 5.6 Sol

Verdict (advisory): Approve

No blocking findings at c5ce3b2338512e544615a1258670874f66a09dc6.

The earlier mnemonic-signing issue is fixed: both wrappers now resolve opts.scheme ?? params.scheme once and use that effective scheme for key derivation, default HD index selection, and signing context encoding. The new params-only regression coverage exercises both transfer and generic-call signing. The ML-DSA-65 key sizes, Poseidon account derivation, wire variant, context-bound signature bytes, and decoded extrinsic verification also match the canonical chain crates, while omission of scheme preserves the existing ML-DSA-87 outputs.

Validation:

  • npm test: pass (9 Rust tests, release WASM build, TypeScript build, 16 Node tests, and package-content verification).
  • cargo clippy --all-targets --all-features -- -D warnings: pass.
  • cargo check --no-default-features: pass.
  • Focused built-package checks for mnemonic option/parameter precedence and invalid schemes: pass.
  • git diff --check: pass; GitHub CI test: pass at the reviewed head.
  • Cross-checked current chain enum ordering (Dilithium87 = 0, Dilithium65 = 1) and the CLI/app ML-DSA-65 default HD index (.../1').

Non-blocking: cargo fmt --all -- --check reports rustfmt-only layout differences in src/ext.rs and src/mnemonic.rs; formatting is not enforced by the current CI.

@n13 n13 removed the bot-review label Sep 12, 2026
@n13
n13 merged commit c459d40 into main Sep 12, 2026
1 check passed
@n13
n13 deleted the feat/ml-dsa-65 branch September 12, 2026 12:02
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.

1 participant