From 580dae79cbaea19f37d5c0e073624a8ecf8712c6 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 3 Sep 2026 11:58:17 +0800 Subject: [PATCH 1/3] feat(accounts): add ML-DSA-65 support for transparent accounts 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. --- .../lib/components/derivation_field.dart | 7 +- cold-wallet-app/lib/models/cold_account.dart | 87 +++- .../lib/providers/wallet_providers.dart | 11 +- .../lib/screens/add_account_screen.dart | 32 +- .../lib/screens/create_wallet_screen.dart | 2 +- .../lib/screens/import_wallet_screen.dart | 17 +- .../lib/screens/sign_transaction_screen.dart | 4 +- .../lib/services/vault_service.dart | 3 +- cold-wallet-app/test/add_account_test.dart | 41 +- .../test/audit_regressions_test.dart | 4 +- cold-wallet-app/test/call_display_test.dart | 4 +- .../test/change_password_test.dart | 7 +- .../test/cold_account_scheme_test.dart | 69 +++ .../test/every_call_renders_test.dart | 4 +- cold-wallet-app/test/multi_account_test.dart | 48 +- .../test/set_password_screen_test.dart | 2 +- .../test/show_secret_phrase_test.dart | 4 +- .../src/services/miner_wallet_service.dart | 2 +- .../transaction_submission_service.dart | 24 +- .../lib/services/wallet_creation_service.dart | 11 +- .../lib/shared/utils/accounts_grouping.dart | 9 +- .../screens/import/import_wallet_screen.dart | 58 ++- .../multisig_action_confirm_sheet.dart | 8 +- .../multisig_approve_confirm_sheet.dart | 20 +- .../multisig_cancel_confirm_sheet.dart | 22 +- .../multisig_execute_confirm_sheet.dart | 22 +- .../send/keystone_signature_scan_screen.dart | 14 +- .../send/keystone_signing_session.dart | 3 +- .../screens/send/regular_send_strategy.dart | 24 +- .../settings/redeem_address_screen.dart | 2 +- .../v2/screens/welcome/welcome_screen.dart | 6 +- .../patrol_test/support/send_preflight.dart | 6 +- mobile-app/test/fakes.dart | 7 +- .../test/unit/send_amount_layout_test.dart | 2 +- .../unit/wallet_creation_service_test.dart | 4 + .../wallet_creation_service_test.mocks.dart | 453 ++++++++++++------ quantus_sdk/lib/quantus_sdk.dart | 1 + .../lib/src/extensions/account_extension.dart | 10 +- .../dilithium_scheme_extension.dart | 58 +++ quantus_sdk/lib/src/models/account.dart | 71 ++- .../lib/src/resonance_extrinsic_payload.dart | 15 +- quantus_sdk/lib/src/rust/api/crypto.dart | 30 +- quantus_sdk/lib/src/rust/frb_generated.dart | 106 ++-- .../lib/src/rust/frb_generated.io.dart | 24 +- .../lib/src/rust/frb_generated.web.dart | 24 +- .../services/account_discovery_service.dart | 31 +- .../lib/src/services/accounts_service.dart | 22 +- .../lib/src/services/balances_service.dart | 4 +- .../lib/src/services/hd_wallet_service.dart | 27 +- .../lib/src/services/settings_service.dart | 48 +- .../lib/src/services/substrate_service.dart | 43 +- quantus_sdk/rust/Cargo.toml | 4 +- quantus_sdk/rust/src/api/crypto.rs | 227 +++++++-- quantus_sdk/rust/src/frb_generated.rs | 101 +++- quantus_sdk/test/generate_keys_test.dart | 47 +- .../test/models/account_scheme_test.dart | 72 +++ .../account_discovery_scheme_test.dart | 72 +++ .../recovery_proxy_encoding_test.dart | 12 +- .../test/services/settings_service_test.dart | 24 +- .../test/services/transaction_fee_test.dart | 15 +- quantus_sdk/test/ur_qr_frame_test.dart | 37 +- 61 files changed, 1603 insertions(+), 565 deletions(-) create mode 100644 cold-wallet-app/test/cold_account_scheme_test.dart create mode 100644 quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart create mode 100644 quantus_sdk/test/models/account_scheme_test.dart create mode 100644 quantus_sdk/test/services/account_discovery_scheme_test.dart diff --git a/cold-wallet-app/lib/components/derivation_field.dart b/cold-wallet-app/lib/components/derivation_field.dart index ac3519316..96dd8dc9a 100644 --- a/cold-wallet-app/lib/components/derivation_field.dart +++ b/cold-wallet-app/lib/components/derivation_field.dart @@ -16,7 +16,7 @@ class DerivationField extends StatefulWidget { class _DerivationFieldState extends State { final _index = TextEditingController(text: '0'); - final _path = TextEditingController(text: HdWalletService.pathForIndex(0)); + final _path = TextEditingController(text: HdWalletService.pathForIndex(0, DilithiumSchemeExtension.current)); bool _expanded = false; bool _useFullPath = false; @@ -34,8 +34,9 @@ class _DerivationFieldState extends State { super.dispose(); } - ColdAccount? get _account => - _useFullPath ? ColdAccount.atPath(_path.text, label: 'Account 1') : ColdAccount.atIndexText(_index.text); + ColdAccount? get _account => _useFullPath + ? ColdAccount.atPath(_path.text, label: 'Account 1', defaultScheme: DilithiumSchemeExtension.current) + : ColdAccount.atIndexText(_index.text, scheme: DilithiumSchemeExtension.current); void _emit() => widget.onChanged(_account); diff --git a/cold-wallet-app/lib/models/cold_account.dart b/cold-wallet-app/lib/models/cold_account.dart index de4bfb346..aade08225 100644 --- a/cold-wallet-app/lib/models/cold_account.dart +++ b/cold-wallet-app/lib/models/cold_account.dart @@ -3,13 +3,16 @@ import 'package:quantus_sdk/quantus_sdk.dart'; /// One account derived from the vault's single seed phrase. /// /// Exactly one of [index] and [path] is set: an index fills the wallet's own -/// template, a path is taken verbatim so a seed created elsewhere can be used. +/// template for [scheme], a path is taken verbatim so a seed created elsewhere +/// can be used. [scheme] is the ML-DSA parameter set the key uses; accounts +/// stored before it was recorded are ML-DSA-87. class ColdAccount { final String label; final int? index; final String? path; + final DilithiumScheme scheme; - ColdAccount({required this.label, this.index, this.path}) { + ColdAccount({required this.label, this.index, this.path, required this.scheme}) { if ((index == null) == (path == null)) { throw ArgumentError('ColdAccount needs exactly one of index or path, got index: $index, path: $path'); } @@ -19,11 +22,12 @@ class ColdAccount { } } - String get derivationPath => path ?? HdWalletService.pathForIndex(index!); + String get derivationPath => path ?? HdWalletService.pathForIndex(index!, scheme); /// The slot this account derives from: its index, or the index its path - /// names when that path follows the wallet's own template. Null for a path - /// from somewhere else, which the wallet's numbering says nothing about. + /// names when that path follows the wallet's own template for [scheme]. Null + /// for a path from somewhere else, which the wallet's numbering says nothing + /// about. /// /// Read by rebuilding the template rather than matching a pattern, so the /// template stays defined in exactly one place. @@ -31,42 +35,77 @@ class ColdAccount { if (index != null) return index; for (final segment in derivationPath.split('/')) { final candidate = int.tryParse(segment.replaceAll("'", '')); - if (candidate != null && HdWalletService.pathForIndex(candidate) == derivationPath) return candidate; + if (candidate != null && HdWalletService.pathForIndex(candidate, scheme) == derivationPath) return candidate; } return null; } - /// Orders accounts by the slot they derive from, so a list reads as the seed's - /// own sequence rather than the order the accounts happened to be added. A - /// path this wallet does not number claims no slot, and sorts after the ones - /// that do. + /// Orders accounts by the slot they derive from, then scheme (current first), + /// so a list reads as the seed's own sequence rather than the order the + /// accounts happened to be added. A path this wallet does not number claims + /// no slot, and sorts after the ones that do. static int compareByDerivation(ColdAccount a, ColdAccount b) { final left = a.templateIndex; final right = b.templateIndex; - if (left != null && right != null) return left.compareTo(right); + if (left != null && right != null) { + final byIndex = left.compareTo(right); + if (byIndex != 0) return byIndex; + return _schemeRank(a.scheme).compareTo(_schemeRank(b.scheme)); + } if (left != null) return -1; if (right != null) return 1; return a.derivationPath.compareTo(b.derivationPath); } - /// 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; + + /// Scheme new accounts of this wallet use: the current scheme once the wallet + /// holds any account of it, otherwise the legacy one, so pre-existing wallets + /// stay uniform. + static DilithiumScheme walletScheme(Iterable accounts) => + accounts.any((a) => a.scheme == DilithiumSchemeExtension.current) + ? DilithiumSchemeExtension.current + : DilithiumSchemeExtension.legacy; + + /// The account [text] names as an index at [scheme], or null when it is not an + /// index. The label follows the index, so the wallet's own numbering stays + /// predictable. + static ColdAccount? atIndexText(String text, {required DilithiumScheme scheme}) { final index = int.tryParse(text.trim()); if (index == null || index < 0) return null; - return ColdAccount(label: 'Account ${index + 1}', index: index); + return ColdAccount(label: 'Account ${index + 1}', index: index, scheme: scheme); } - /// The account at [path], or null when [path] is not a derivation path. Used - /// for a seed created elsewhere, whose paths follow no template this wallet - /// can number, so the label is supplied. - static ColdAccount? atPath(String path, {required String label}) { - if (!HdWalletService.isValidPath(path)) return null; - return ColdAccount(label: label, path: path.trim()); + /// The account at [path], or null when [path] is not a derivation path. The + /// scheme is read from the path when it follows a template ([`.../1'`] for + /// ML-DSA-65, [`.../0'`] for ML-DSA-87), otherwise [defaultScheme]. + static ColdAccount? atPath(String path, {required String label, required DilithiumScheme defaultScheme}) { + final trimmed = path.trim(); + if (!HdWalletService.isValidPath(trimmed)) return null; + return ColdAccount(label: label, path: trimmed, scheme: _schemeForPath(trimmed, defaultScheme)); + } + + static DilithiumScheme _schemeForPath(String path, DilithiumScheme fallback) { + for (final scheme in DilithiumScheme.values) { + for (final segment in path.split('/')) { + final candidate = int.tryParse(segment.replaceAll("'", '')); + if (candidate != null && HdWalletService.pathForIndex(candidate, scheme) == path) return scheme; + } + } + return fallback; } - factory ColdAccount.fromJson(Map json) => - ColdAccount(label: json['label'] as String, index: json['index'] as int?, path: json['path'] as String?); + factory ColdAccount.fromJson(Map json) => ColdAccount( + label: json['label'] as String, + index: json['index'] as int?, + path: json['path'] as String?, + scheme: DilithiumSchemeExtension.fromStorageName(json['scheme'] as String?), + ); - Map toJson() => {'label': label, if (index != null) 'index': index, if (path != null) 'path': path}; + Map toJson() => { + 'label': label, + if (index != null) 'index': index, + if (path != null) 'path': path, + 'scheme': scheme.storageName, + }; } diff --git a/cold-wallet-app/lib/providers/wallet_providers.dart b/cold-wallet-app/lib/providers/wallet_providers.dart index 82aabbce9..dbe3ea49b 100644 --- a/cold-wallet-app/lib/providers/wallet_providers.dart +++ b/cold-wallet-app/lib/providers/wallet_providers.dart @@ -215,7 +215,7 @@ final addressesProvider = Provider>((ref) { final service = HdWalletService(); return { for (final account in ref.watch(accountsProvider)) - service.keyPairAtPath(mnemonic, account.derivationPath).ss58Address: account, + service.keyPairAtPath(mnemonic, account.derivationPath, account.scheme).ss58Address: account, }; }); @@ -223,7 +223,7 @@ Keypair? keypairFor(WidgetRef ref, String address) { final mnemonic = ref.read(walletControllerProvider).mnemonic; final account = ref.read(addressesProvider)[address]; if (mnemonic == null || account == null) return null; - return HdWalletService().keyPairAtPath(mnemonic, account.derivationPath); + return HdWalletService().keyPairAtPath(mnemonic, account.derivationPath, account.scheme); } /// The first account, which the home screen leads with. @@ -231,10 +231,13 @@ final addressProvider = Provider((ref) => ref.watch(addressesProvider). /// The address a derivation path produces, so an account can be checked before /// it is added. Auto-disposed: a path still being typed is not worth keeping. -final derivedAddressProvider = FutureProvider.autoDispose.family((ref, path) async { +final derivedAddressProvider = FutureProvider.autoDispose.family(( + ref, + key, +) async { final mnemonic = ref.watch(walletControllerProvider).mnemonic; if (mnemonic == null) throw StateError('Wallet is locked'); - return HdWalletService().keyPairAtPath(mnemonic, path).ss58Address; + return HdWalletService().keyPairAtPath(mnemonic, key.path, key.scheme).ss58Address; }); /// Resolves human checkphrases for every address on screen, not just the diff --git a/cold-wallet-app/lib/screens/add_account_screen.dart b/cold-wallet-app/lib/screens/add_account_screen.dart index 80aa5be40..4478ceffd 100644 --- a/cold-wallet-app/lib/screens/add_account_screen.dart +++ b/cold-wallet-app/lib/screens/add_account_screen.dart @@ -62,10 +62,18 @@ class _AddAccountScreenState extends ConsumerState { super.dispose(); } - /// The lowest index this wallet does not already hold, so the field opens on - /// an account that can actually be added. + /// Scheme new accounts of this wallet use, so an added account matches the + /// ones already there. + DilithiumScheme get _scheme => ColdAccount.walletScheme(ref.read(accountsProvider)); + + /// The lowest index this wallet does not already hold at [_scheme], so the + /// field opens on an account that can actually be added. int _firstFreeIndex() { - final taken = {for (final account in ref.read(accountsProvider)) account.index}; + final scheme = _scheme; + final taken = { + for (final account in ref.read(accountsProvider)) + if (account.scheme == scheme) account.templateIndex, + }; for (var index = 0; ; index++) { if (!taken.contains(index)) return index; } @@ -81,17 +89,17 @@ class _AddAccountScreenState extends ConsumerState { } ColdAccount? get _account => switch (_mode) { - _Derivation.accountIndex => ColdAccount.atIndexText(_index.text), - _Derivation.fullPath => ColdAccount.atPath(_path.text, label: _nextFreeLabel()), + _Derivation.accountIndex => ColdAccount.atIndexText(_index.text, scheme: _scheme), + _Derivation.fullPath => ColdAccount.atPath(_path.text, label: _nextFreeLabel(), defaultScheme: _scheme), }; /// The account already holding this derivation, if any. Adding it twice would /// put two rows with one address in the list. ColdAccount? get _duplicate { - final path = _account?.derivationPath; - if (path == null) return null; + final target = _account; + if (target == null) return null; for (final account in ref.read(accountsProvider)) { - if (account.derivationPath == path) return account; + if (account.derivationPath == target.derivationPath && account.scheme == target.scheme) return account; } return null; } @@ -111,7 +119,7 @@ class _AddAccountScreenState extends ConsumerState { // disagree about which account is being added. A path typed by hand follows // no template and is left alone. if (mode == _Derivation.fullPath && !_userChangedPath) { - _path.text = ColdAccount.atIndexText(_index.text)?.derivationPath ?? ''; + _path.text = ColdAccount.atIndexText(_index.text, scheme: _scheme)?.derivationPath ?? ''; } setState(() { _mode = mode; @@ -275,7 +283,7 @@ class _AddAccountScreenState extends ConsumerState { decoration: InputDecoration( border: InputBorder.none, isCollapsed: true, - hintText: HdWalletService.pathForIndex(0), + hintText: HdWalletService.pathForIndex(0, DilithiumSchemeExtension.current), hintStyle: text.dataAddressLarge.copyWith( color: colors.textMuted, fontFamily: AppTextThemeV3.fontFamilySecondary, @@ -306,7 +314,9 @@ class _AddAccountScreenState extends ConsumerState { } final path = account.derivationPath; - final address = _previewPath == path ? ref.watch(derivedAddressProvider(path)) : const AsyncValue.loading(); + final address = _previewPath == path + ? ref.watch(derivedAddressProvider((path: path, scheme: account.scheme))) + : const AsyncValue.loading(); return Container( padding: const EdgeInsets.all(14), diff --git a/cold-wallet-app/lib/screens/create_wallet_screen.dart b/cold-wallet-app/lib/screens/create_wallet_screen.dart index ff0e0a9b7..a4b0098cb 100644 --- a/cold-wallet-app/lib/screens/create_wallet_screen.dart +++ b/cold-wallet-app/lib/screens/create_wallet_screen.dart @@ -33,7 +33,7 @@ class _CreateWalletScreenState extends State { MaterialPageRoute( builder: (_) => SetPasswordScreen( mnemonic: words.join(' '), - accounts: [ColdAccount(label: 'Account 1', index: 0)], + accounts: [ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.current)], ), ), ); diff --git a/cold-wallet-app/lib/screens/import_wallet_screen.dart b/cold-wallet-app/lib/screens/import_wallet_screen.dart index 7ae85ec9b..e4d9e0661 100644 --- a/cold-wallet-app/lib/screens/import_wallet_screen.dart +++ b/cold-wallet-app/lib/screens/import_wallet_screen.dart @@ -34,7 +34,7 @@ class _ImportWalletScreenState extends State { final _buttonKey = GlobalKey(); bool _isLoading = false; String? _error; - ColdAccount? _account = ColdAccount(label: 'Account 1', index: 0); + ColdAccount? _account = ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.current); @override void initState() { @@ -79,14 +79,25 @@ class _ImportWalletScreenState extends State { }); try { + // An index (the default) could be either scheme, and this air-gapped + // wallet cannot check the chain, so hold both. A path names one scheme. + final accounts = account.index != null + ? [ + ColdAccount(label: 'Account 1', index: account.index, scheme: DilithiumSchemeExtension.current), + ColdAccount(label: 'Account 2', index: account.index, scheme: DilithiumSchemeExtension.legacy), + ] + : [account]; + // Throws on an invalid phrase. - HdWalletService().keyPairAtPath(mnemonic, account.derivationPath); + for (final a in accounts) { + HdWalletService().keyPairAtPath(mnemonic, a.derivationPath, a.scheme); + } if (!mounted) return; Navigator.push( context, MaterialPageRoute( - builder: (_) => SetPasswordScreen(mnemonic: mnemonic, accounts: [account]), + builder: (_) => SetPasswordScreen(mnemonic: mnemonic, accounts: accounts), ), ); } catch (e) { diff --git a/cold-wallet-app/lib/screens/sign_transaction_screen.dart b/cold-wallet-app/lib/screens/sign_transaction_screen.dart index 1373acfcd..60d60e6bd 100644 --- a/cold-wallet-app/lib/screens/sign_transaction_screen.dart +++ b/cold-wallet-app/lib/screens/sign_transaction_screen.dart @@ -66,8 +66,8 @@ class _SignTransactionScreenState extends ConsumerState { }); return; } - // Returns signature ++ publicKey; the hot wallet splits it and rebuilds the - // extrinsic via submitExtrinsicWithExternalSignature. + // Returns signature ++ publicKey; the hot wallet reads the scheme off its + // length and rebuilds the extrinsic via submitExtrinsicWithExternalSignature. final signed = signMessageWithPubkey( keypair: keypair, message: QuantusSigningPayload.signablePayload(widget.request.payload), diff --git a/cold-wallet-app/lib/services/vault_service.dart b/cold-wallet-app/lib/services/vault_service.dart index 26527b803..d89d54740 100644 --- a/cold-wallet-app/lib/services/vault_service.dart +++ b/cold-wallet-app/lib/services/vault_service.dart @@ -4,6 +4,7 @@ import 'dart:math'; import 'package:cryptography/cryptography.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:quantus_cold_wallet/models/cold_account.dart'; /// Decrypted result of a successful unlock: the vault contents plus the derived @@ -32,7 +33,7 @@ class VaultContents { if (!plaintext.startsWith('{')) { return VaultContents( mnemonic: plaintext, - accounts: [ColdAccount(label: 'Account 1', index: 0)], + accounts: [ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy)], ); } final m = jsonDecode(plaintext) as Map; diff --git a/cold-wallet-app/test/add_account_test.dart b/cold-wallet-app/test/add_account_test.dart index d9815e479..68e65315d 100644 --- a/cold-wallet-app/test/add_account_test.dart +++ b/cold-wallet-app/test/add_account_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:quantus_cold_wallet/models/cold_account.dart'; /// Where an account sits in the wallet's own numbering: what the account list @@ -6,31 +7,42 @@ import 'package:quantus_cold_wallet/models/cold_account.dart'; void main() { group('the slot an account derives from', () { test('an indexed account sits at its index', () { - expect(ColdAccount(label: 'Account 1', index: 0).templateIndex, 0); - expect(ColdAccount(label: 'Account 13', index: 12).templateIndex, 12); + expect(ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy).templateIndex, 0); + expect(ColdAccount(label: 'Account 13', index: 12, scheme: DilithiumSchemeExtension.legacy).templateIndex, 12); }); test('a path following the wallet template counts as the index it names', () { final typed = ColdAccount( label: 'Typed', - path: ColdAccount(label: 'x', index: 9).derivationPath, + path: ColdAccount(label: 'x', index: 9, scheme: DilithiumSchemeExtension.legacy).derivationPath, + scheme: DilithiumSchemeExtension.legacy, ); expect(typed.templateIndex, 9); }); test('a path from another wallet claims no slot', () { - expect(ColdAccount(label: 'Elsewhere', path: "m/44'/1'/0'").templateIndex, isNull); - expect(ColdAccount(label: 'Deeper', path: "m/44'/189189'/7'/1'/2'").templateIndex, isNull); + expect( + ColdAccount(label: 'Elsewhere', path: "m/44'/1'/0'", scheme: DilithiumSchemeExtension.legacy).templateIndex, + isNull, + ); + expect( + ColdAccount( + label: 'Deeper', + path: "m/44'/189189'/7'/1'/2'", + scheme: DilithiumSchemeExtension.legacy, + ).templateIndex, + isNull, + ); }); }); group('the order accounts are listed in', () { test('follows the derivation, not the order they were added', () { final accounts = [ - ColdAccount(label: 'Account 5', index: 4), - ColdAccount(label: 'Account 1', index: 0), - ColdAccount(label: 'Account 3', index: 2), + ColdAccount(label: 'Account 5', index: 4, scheme: DilithiumSchemeExtension.legacy), + ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy), + ColdAccount(label: 'Account 3', index: 2, scheme: DilithiumSchemeExtension.legacy), ]..sort(ColdAccount.compareByDerivation); expect(accounts.map((a) => a.label), ['Account 1', 'Account 3', 'Account 5']); @@ -38,12 +50,13 @@ void main() { test('a typed template path takes the slot it names, among the indexed ones', () { final accounts = [ - ColdAccount(label: 'Account 5', index: 4), + ColdAccount(label: 'Account 5', index: 4, scheme: DilithiumSchemeExtension.legacy), ColdAccount( label: 'Typed', - path: ColdAccount(label: 'x', index: 1).derivationPath, + path: ColdAccount(label: 'x', index: 1, scheme: DilithiumSchemeExtension.legacy).derivationPath, + scheme: DilithiumSchemeExtension.legacy, ), - ColdAccount(label: 'Account 1', index: 0), + ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy), ]..sort(ColdAccount.compareByDerivation); expect(accounts.map((a) => a.label), ['Account 1', 'Typed', 'Account 5']); @@ -51,9 +64,9 @@ void main() { test('a path this wallet does not number sorts last, and stably', () { final accounts = [ - ColdAccount(label: 'Zed', path: "m/44'/2'/0'"), - ColdAccount(label: 'Elsewhere', path: "m/44'/1'/0'"), - ColdAccount(label: 'Account 1', index: 0), + ColdAccount(label: 'Zed', path: "m/44'/2'/0'", scheme: DilithiumSchemeExtension.legacy), + ColdAccount(label: 'Elsewhere', path: "m/44'/1'/0'", scheme: DilithiumSchemeExtension.legacy), + ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy), ]..sort(ColdAccount.compareByDerivation); expect(accounts.map((a) => a.label), ['Account 1', 'Elsewhere', 'Zed']); diff --git a/cold-wallet-app/test/audit_regressions_test.dart b/cold-wallet-app/test/audit_regressions_test.dart index 8d1519637..c87eef877 100644 --- a/cold-wallet-app/test/audit_regressions_test.dart +++ b/cold-wallet-app/test/audit_regressions_test.dart @@ -33,7 +33,9 @@ Future pumpRequest(WidgetTester tester, SigningRequest request) async { await tester.pumpWidget( ProviderScope( overrides: [ - addressesProvider.overrideWith((ref) => {wallet: ColdAccount(label: 'Account 1', index: 0)}), + addressesProvider.overrideWith( + (ref) => {wallet: ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy)}, + ), checksumNameProvider.overrideWith((ref, address) async => 'check phrase'), ], child: MaterialApp( diff --git a/cold-wallet-app/test/call_display_test.dart b/cold-wallet-app/test/call_display_test.dart index daafce02d..556f22499 100644 --- a/cold-wallet-app/test/call_display_test.dart +++ b/cold-wallet-app/test/call_display_test.dart @@ -49,7 +49,9 @@ Future pumpSignScreen(WidgetTester tester, Uint8List payload) async { await tester.pumpWidget( ProviderScope( overrides: [ - addressesProvider.overrideWith((ref) => {signerAddress: ColdAccount(label: 'Account 1', index: 0)}), + addressesProvider.overrideWith( + (ref) => {signerAddress: ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy)}, + ), checksumNameProvider.overrideWith((ref, address) async => 'check phrase'), ], child: MaterialApp( diff --git a/cold-wallet-app/test/change_password_test.dart b/cold-wallet-app/test/change_password_test.dart index fcd2e81d7..d54614420 100644 --- a/cold-wallet-app/test/change_password_test.dart +++ b/cold-wallet-app/test/change_password_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/test/test_flutter_secure_storage_platform.dart'; import 'package:flutter_secure_storage_platform_interface/flutter_secure_storage_platform_interface.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; import 'package:quantus_cold_wallet/providers/wallet_providers.dart'; import 'package:quantus_cold_wallet/models/cold_account.dart'; import 'package:quantus_cold_wallet/services/vault_service.dart'; @@ -23,7 +24,7 @@ class _FaultInjectingStorage extends TestFlutterSecureStoragePlatform { } } -final _accounts = [ColdAccount(label: 'Account 1', index: 0)]; +final _accounts = [ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy)]; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -100,7 +101,7 @@ void main() { await vault.createVault( mnemonic: _mnemonic, password: 'beta', - accounts: [ColdAccount(label: 'Account 1', index: 0)], + accounts: [ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy)], ); final fresh = ProviderContainer(); @@ -127,7 +128,7 @@ void main() { await vault.createVault( mnemonic: _mnemonic, password: 'beta', - accounts: [ColdAccount(label: 'Account 1', index: 0)], + accounts: [ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy)], ); expect(await vault.isBiometricEnabled(), isTrue, reason: 'a bare key carries no pairing to check at startup'); await expectLater(vault.unlockWithBiometricKey(), throwsA(isA())); diff --git a/cold-wallet-app/test/cold_account_scheme_test.dart b/cold-wallet-app/test/cold_account_scheme_test.dart new file mode 100644 index 000000000..592663b5a --- /dev/null +++ b/cold-wallet-app/test/cold_account_scheme_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:quantus_cold_wallet/models/cold_account.dart'; + +void main() { + group('ColdAccount scheme', () { + test('JSON without a scheme reads as ML-DSA-87', () { + final account = ColdAccount.fromJson({'label': 'Account 1', 'index': 0}); + expect(account.scheme, DilithiumScheme.mlDsa87); + expect(account.derivationPath, HdWalletService.pathForIndex(0, DilithiumScheme.mlDsa87)); + }); + + test('scheme round-trips through JSON', () { + final account = ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumScheme.mlDsa65); + final restored = ColdAccount.fromJson(account.toJson()); + expect(restored.scheme, DilithiumScheme.mlDsa65); + expect(restored.derivationPath, account.derivationPath); + }); + + test('the same index derives different paths per scheme', () { + final a65 = ColdAccount(label: 'a', index: 0, scheme: DilithiumScheme.mlDsa65); + final a87 = ColdAccount(label: 'a', index: 0, scheme: DilithiumScheme.mlDsa87); + expect(a65.derivationPath, endsWith("/1'")); + expect(a87.derivationPath, endsWith("/0'")); + expect(a65.derivationPath, isNot(a87.derivationPath)); + }); + + test('atPath infers the scheme from a template path', () { + final p65 = HdWalletService.pathForIndex(3, DilithiumScheme.mlDsa65); + final p87 = HdWalletService.pathForIndex(3, DilithiumScheme.mlDsa87); + expect( + ColdAccount.atPath(p65, label: 'x', defaultScheme: DilithiumScheme.mlDsa87)!.scheme, + DilithiumScheme.mlDsa65, + ); + expect( + ColdAccount.atPath(p87, label: 'x', defaultScheme: DilithiumScheme.mlDsa65)!.scheme, + DilithiumScheme.mlDsa87, + ); + }); + + test('atPath falls back to the default scheme for a foreign path', () { + final account = ColdAccount.atPath("m/44'/1'/0'", label: 'x', defaultScheme: DilithiumScheme.mlDsa65); + expect(account!.scheme, DilithiumScheme.mlDsa65); + expect(account.templateIndex, isNull); + }); + + test('at one index, current scheme sorts before legacy', () { + final accounts = [ + ColdAccount(label: 'legacy', index: 0, scheme: DilithiumScheme.mlDsa87), + ColdAccount(label: 'current', index: 0, scheme: DilithiumScheme.mlDsa65), + ]..sort(ColdAccount.compareByDerivation); + expect(accounts.map((a) => a.label), ['current', 'legacy']); + }); + + test('walletScheme grows current once any current account is held', () { + expect( + ColdAccount.walletScheme([ColdAccount(label: 'a', index: 0, scheme: DilithiumScheme.mlDsa87)]), + DilithiumScheme.mlDsa87, + ); + expect( + ColdAccount.walletScheme([ + ColdAccount(label: 'a', index: 0, scheme: DilithiumScheme.mlDsa87), + ColdAccount(label: 'b', index: 0, scheme: DilithiumScheme.mlDsa65), + ]), + DilithiumScheme.mlDsa65, + ); + }); + }); +} diff --git a/cold-wallet-app/test/every_call_renders_test.dart b/cold-wallet-app/test/every_call_renders_test.dart index f30fc7508..f59f84e04 100644 --- a/cold-wallet-app/test/every_call_renders_test.dart +++ b/cold-wallet-app/test/every_call_renders_test.dart @@ -32,7 +32,9 @@ Future pumpAt(WidgetTester tester, Uint8List payload, Size size, double sc await tester.pumpWidget( ProviderScope( overrides: [ - addressesProvider.overrideWith((ref) => {signerAddress: ColdAccount(label: 'Account 1', index: 0)}), + addressesProvider.overrideWith( + (ref) => {signerAddress: ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy)}, + ), checksumNameProvider.overrideWith((ref, address) async => 'check phrase'), ], child: MediaQuery( diff --git a/cold-wallet-app/test/multi_account_test.dart b/cold-wallet-app/test/multi_account_test.dart index de992d558..12609a526 100644 --- a/cold-wallet-app/test/multi_account_test.dart +++ b/cold-wallet-app/test/multi_account_test.dart @@ -64,14 +64,14 @@ void main() { mnemonic: mnemonic, password: 'alpha', enableBiometric: false, - accounts: [ColdAccount(label: 'One', index: 0)], + accounts: [ColdAccount(label: 'One', index: 0, scheme: DilithiumSchemeExtension.legacy)], ); expect( await controller.changePassword(currentPassword: 'alpha', newPassword: 'beta'), PasswordChangeResult.changed, ); - await controller.addAccount(ColdAccount(label: 'Two', index: 1)); + await controller.addAccount(ColdAccount(label: 'Two', index: 1, scheme: DilithiumSchemeExtension.legacy)); final reopened = await VaultService().unlockWithPassword('beta'); expect(reopened.mnemonic, mnemonic); @@ -89,12 +89,12 @@ void main() { mnemonic: mnemonic, password: 'alpha', enableBiometric: true, - accounts: [ColdAccount(label: 'One', index: 0)], + accounts: [ColdAccount(label: 'One', index: 0, scheme: DilithiumSchemeExtension.legacy)], ); biometric.lock(); expect(await biometric.unlockWithBiometric(), isTrue); - await biometric.addAccount(ColdAccount(label: 'Two', index: 1)); + await biometric.addAccount(ColdAccount(label: 'Two', index: 1, scheme: DilithiumSchemeExtension.legacy)); expect(container.read(accountsProvider), hasLength(2)); expect((await VaultService().unlockWithPassword('alpha')).accounts, hasLength(2)); @@ -103,18 +103,30 @@ void main() { group('ColdAccount', () { test('an index fills the wallet template', () { - expect(ColdAccount(label: 'a', index: 3).derivationPath, HdWalletService.pathForIndex(3)); + expect( + ColdAccount(label: 'a', index: 3, scheme: DilithiumSchemeExtension.legacy).derivationPath, + HdWalletService.pathForIndex(3, DilithiumSchemeExtension.legacy), + ); }); test('a path is taken verbatim', () { - expect(ColdAccount(label: 'a', path: "m/44'/189189'/9'/0'/0'").derivationPath, "m/44'/189189'/9'/0'/0'"); + expect( + ColdAccount(label: 'a', path: "m/44'/189189'/9'/0'/0'", scheme: DilithiumSchemeExtension.legacy).derivationPath, + "m/44'/189189'/9'/0'/0'", + ); }); test('needs exactly one of index or path', () { - expect(() => ColdAccount(label: 'a'), throwsArgumentError); - expect(() => ColdAccount(label: 'a', index: 0, path: "m/44'"), throwsArgumentError); - expect(() => ColdAccount(label: 'a', index: -1), throwsArgumentError); - expect(() => ColdAccount(label: 'a', path: 'not a path'), throwsArgumentError); + expect(() => ColdAccount(label: 'a', scheme: DilithiumSchemeExtension.legacy), throwsArgumentError); + expect( + () => ColdAccount(label: 'a', index: 0, path: "m/44'", scheme: DilithiumSchemeExtension.legacy), + throwsArgumentError, + ); + expect(() => ColdAccount(label: 'a', index: -1, scheme: DilithiumSchemeExtension.legacy), throwsArgumentError); + expect( + () => ColdAccount(label: 'a', path: 'not a path', scheme: DilithiumSchemeExtension.legacy), + throwsArgumentError, + ); }); }); @@ -123,8 +135,8 @@ void main() { final contents = VaultContents( mnemonic: mnemonic, accounts: [ - ColdAccount(label: 'One', index: 0), - ColdAccount(label: 'Two', path: "m/44'/189189'/7'/0'/0'"), + ColdAccount(label: 'One', index: 0, scheme: DilithiumSchemeExtension.legacy), + ColdAccount(label: 'Two', path: "m/44'/189189'/7'/0'/0'", scheme: DilithiumSchemeExtension.legacy), ], ); final decoded = VaultContents.decode(contents.encode()); @@ -144,14 +156,22 @@ void main() { group('the signing screen matches the request to an account', () { testWidgets('signs when the wallet holds the signer', (tester) async { - await pumpFor(tester, signerAddress, held: {signerAddress: ColdAccount(label: 'One', index: 0)}); + await pumpFor( + tester, + signerAddress, + held: {signerAddress: ColdAccount(label: 'One', index: 0, scheme: DilithiumSchemeExtension.legacy)}, + ); expect(find.text('Sign'), findsOneWidget); expect(find.textContaining('does not hold'), findsNothing); }); testWidgets('refuses when the wallet does not hold the signer', (tester) async { - await pumpFor(tester, otherAddress, held: {signerAddress: ColdAccount(label: 'One', index: 0)}); + await pumpFor( + tester, + otherAddress, + held: {signerAddress: ColdAccount(label: 'One', index: 0, scheme: DilithiumSchemeExtension.legacy)}, + ); expect(find.textContaining('does not hold'), findsOneWidget); expect(find.text('Sign'), findsNothing); diff --git a/cold-wallet-app/test/set_password_screen_test.dart b/cold-wallet-app/test/set_password_screen_test.dart index 1c30ecd40..04af141f9 100644 --- a/cold-wallet-app/test/set_password_screen_test.dart +++ b/cold-wallet-app/test/set_password_screen_test.dart @@ -21,7 +21,7 @@ void main() { theme: AppTheme.darkTheme(context), home: SetPasswordScreen( mnemonic: 'test mnemonic', - accounts: [ColdAccount(label: 'Account 1', index: 0)], + accounts: [ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy)], ), ), ), diff --git a/cold-wallet-app/test/show_secret_phrase_test.dart b/cold-wallet-app/test/show_secret_phrase_test.dart index 562c1c209..fb61931e8 100644 --- a/cold-wallet-app/test/show_secret_phrase_test.dart +++ b/cold-wallet-app/test/show_secret_phrase_test.dart @@ -37,7 +37,7 @@ void main() { mnemonic: _mnemonic, password: 'alpha', enableBiometric: false, - accounts: [ColdAccount(label: 'Account 1', index: 0)], + accounts: [ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy)], ), ); @@ -94,7 +94,7 @@ void main() { mnemonic: _mnemonic, password: 'alpha', enableBiometric: false, - accounts: [ColdAccount(label: 'Account 1', index: 0)], + accounts: [ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.legacy)], ), ); diff --git a/miner-app/lib/src/services/miner_wallet_service.dart b/miner-app/lib/src/services/miner_wallet_service.dart index 4a7e811fc..89e977375 100644 --- a/miner-app/lib/src/services/miner_wallet_service.dart +++ b/miner-app/lib/src/services/miner_wallet_service.dart @@ -115,7 +115,7 @@ class MinerWalletService { Future getDefaultAccountAddress() async { final mnemonic = await getMnemonic(); if (mnemonic == null || mnemonic.isEmpty) return null; - return _hdWallet.keyPairAtIndex(mnemonic, _minerWalletIndex).ss58Address; + return _hdWallet.keyPairAtIndex(mnemonic, _minerWalletIndex, DilithiumScheme.mlDsa87).ss58Address; } Future hasRewardsPreimageFile() async { diff --git a/mobile-app/lib/services/transaction_submission_service.dart b/mobile-app/lib/services/transaction_submission_service.dart index c83f4bb1c..c3afaa497 100644 --- a/mobile-app/lib/services/transaction_submission_service.dart +++ b/mobile-app/lib/services/transaction_submission_service.dart @@ -51,7 +51,7 @@ class TransactionSubmissionService { /// Broadcasts a transfer whose signature was produced off-device (e.g. by a /// Keystone hardware wallet). The [unsignedData] is rebuilt into an extrinsic - /// using the externally provided [signature] and [publicKey]. + /// using the externally provided [signatureWithPublicKey] (`signature ++ publicKey`). Future submitExternallySignedTransfer({ required Account account, required String targetAddress, @@ -59,8 +59,7 @@ class TransactionSubmissionService { required BigInt fee, required int blockHeight, required UnsignedTransactionData unsignedData, - required Uint8List signature, - required Uint8List publicKey, + required Uint8List signatureWithPublicKey, }) async { final pendingTx = createPendingTransaction( from: account.accountId, @@ -75,7 +74,7 @@ class TransactionSubmissionService { TelemetryService().sendEvent('send_transfer_hardware'); return submitAndTrackTransaction( - () => SubstrateService().submitExtrinsicWithExternalSignature(unsignedData, signature, publicKey), + () => SubstrateService().submitExtrinsicWithExternalSignature(unsignedData, signatureWithPublicKey), pendingTx, ); } @@ -227,15 +226,14 @@ class TransactionSubmissionService { required Account signer, required MultisigProposal proposal, required UnsignedTransactionData unsignedData, - required Uint8List signature, - required Uint8List publicKey, + required Uint8List signatureWithPublicKey, }) async { return _submitAndTrackApproval( msig: msig, proposal: proposal, approverId: signer.accountId, telemetryEvent: 'multisig_approve_hardware', - submit: () => SubstrateService().submitExtrinsicWithExternalSignature(unsignedData, signature, publicKey), + submit: () => SubstrateService().submitExtrinsicWithExternalSignature(unsignedData, signatureWithPublicKey), ); } @@ -331,8 +329,7 @@ class TransactionSubmissionService { required Account signer, required MultisigProposal proposal, required UnsignedTransactionData unsignedData, - required Uint8List signature, - required Uint8List publicKey, + required Uint8List signatureWithPublicKey, BigInt? fee, }) async { final pending = PendingMultisigExecutionEvent.fromProposal( @@ -348,8 +345,7 @@ class TransactionSubmissionService { try { final hashBytes = await SubstrateService().submitExtrinsicWithExternalSignature( unsignedData, - signature, - publicKey, + signatureWithPublicKey, ); final extrinsicHash = '0x${hex.encode(hashBytes)}'; quantusPrint('[Execute] hardware submitted: $extrinsicHash'); @@ -419,8 +415,7 @@ class TransactionSubmissionService { required Account proposer, required MultisigProposal proposal, required UnsignedTransactionData unsignedData, - required Uint8List signature, - required Uint8List publicKey, + required Uint8List signatureWithPublicKey, BigInt? fee, }) async { final pending = PendingMultisigCancellationEvent.fromProposal( @@ -436,8 +431,7 @@ class TransactionSubmissionService { try { final hashBytes = await SubstrateService().submitExtrinsicWithExternalSignature( unsignedData, - signature, - publicKey, + signatureWithPublicKey, ); final extrinsicHash = '0x${hex.encode(hashBytes)}'; quantusPrint('[Cancel] hardware submitted: $extrinsicHash'); diff --git a/mobile-app/lib/services/wallet_creation_service.dart b/mobile-app/lib/services/wallet_creation_service.dart index 51aa7a7da..632b6aff5 100644 --- a/mobile-app/lib/services/wallet_creation_service.dart +++ b/mobile-app/lib/services/wallet_creation_service.dart @@ -20,6 +20,8 @@ class WalletCreationService { required String mnemonic, required int walletIndex, required String accountId, + required DilithiumScheme scheme, + required String derivationPath, required List existingAccounts, }) async { await _settings.setMnemonic(mnemonic, walletIndex); @@ -27,7 +29,14 @@ class WalletCreationService { final hasRoot = existingAccounts.any((a) => a.walletIndex == walletIndex && a.index == 0); if (!hasRoot) { _settings.setWalletOrigin(walletIndex, WalletOrigin.created); - final account = Account(walletIndex: walletIndex, index: 0, name: name, accountId: accountId); + final account = Account( + walletIndex: walletIndex, + index: 0, + name: name, + accountId: accountId, + scheme: scheme, + derivationPath: derivationPath, + ); await _accounts.addAccount(account); return account; } diff --git a/mobile-app/lib/shared/utils/accounts_grouping.dart b/mobile-app/lib/shared/utils/accounts_grouping.dart index c463d2258..890c6b856 100644 --- a/mobile-app/lib/shared/utils/accounts_grouping.dart +++ b/mobile-app/lib/shared/utils/accounts_grouping.dart @@ -80,8 +80,8 @@ WalletsGrouping groupWallets({ WalletGroup buildGroup(WalletKind kind, int number, int walletIndex) { final group = byWallet[walletIndex] ?? []; - final regular = group.where((a) => a.accountType != AccountType.encrypted).toList()..sort(_compareAccounts); - final encrypted = group.where((a) => a.accountType == AccountType.encrypted).toList()..sort(_compareAccounts); + final regular = group.where((a) => a.accountType != AccountType.encrypted).toList()..sort(Account.compare); + final encrypted = group.where((a) => a.accountType == AccountType.encrypted).toList()..sort(Account.compare); final msigs = [...?multisigsByWallet[walletIndex]]..sort(_compareMultisigs); return WalletGroup( walletIndex: walletIndex, @@ -127,11 +127,6 @@ int? softwareWalletNumber(List accounts, int walletIndex) { return pos == -1 ? null : pos + 1; } -int _compareAccounts(Account a, Account b) { - final w = a.walletIndex.compareTo(b.walletIndex); - return w != 0 ? w : a.index.compareTo(b.index); -} - int _compareMultisigs(MultisigAccount a, MultisigAccount b) { final n = a.name.toLowerCase().compareTo(b.name.toLowerCase()); return n != 0 ? n : a.accountId.compareTo(b.accountId); diff --git a/mobile-app/lib/v2/screens/import/import_wallet_screen.dart b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart index f682125f8..185ae6d44 100644 --- a/mobile-app/lib/v2/screens/import/import_wallet_screen.dart +++ b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart @@ -59,19 +59,22 @@ class _ImportWalletScreenV2State extends ConsumerState { } } - final key = HdWalletService().keyPairAtIndex(mnemonic, 0); + const scheme = DilithiumSchemeExtension.current; + final path = HdWalletService.pathForIndex(0, scheme); + final key = HdWalletService().keyPairAtPath(mnemonic, path, scheme); await _settingsService.setMnemonic(mnemonic, widget.walletIndex); await _accountsService.addAccount( - Account( + Account.derived( walletIndex: widget.walletIndex, index: 0, name: 'Account ${accounts.length + 1}', - accountId: key.ss58Address, + keypair: key, + derivationPath: path, ), ); if (!HdWalletService.isDevAccount(mnemonic)) { - await _discoverAccounts(mnemonic); + await _discoverAccounts(mnemonic, defaultAccountId: key.ss58Address); } invalidateAccountProviders(ref); _settingsService.setReferralCheckCompleted(); @@ -101,22 +104,55 @@ class _ImportWalletScreenV2State extends ConsumerState { } } - /// Discovers on-chain HD accounts only. Multisigs are added manually via - /// Add Account → Discover Multisig. - Future _discoverAccounts(String mnemonic) async { + /// Discovers on-chain HD accounts across both signature schemes. Multisigs + /// are added manually via Add Account → Discover Multisig. + /// + /// [defaultAccountId] is the current-scheme account 0 added before discovery. + /// When it has no on-chain history but discovery finds funded accounts, the + /// first funded one is made active so a returning user lands on it. + Future _discoverAccounts(String mnemonic, {required String defaultAccountId}) async { try { final discovered = await _discoveryService.discoverAccounts(mnemonic: mnemonic, walletIndex: widget.walletIndex); - final existing = (await _accountsService.getAccounts()).map((e) => e.accountId).toSet(); + final current = await _accountsService.getAccounts(); + final existing = current.map((e) => e.accountId).toSet(); + var count = current.length; for (final account in discovered) { - if (!existing.contains(account.accountId)) { - await _accountsService.addAccount(account); - } + if (existing.contains(account.accountId)) continue; + await _accountsService.addAccount(account.copyWith(name: 'Account ${++count}')); + } + if (!discovered.any((a) => a.accountId == defaultAccountId) && discovered.isNotEmpty) { + await _settingsService.setActiveAccount(RegularAccount(discovered.first)); } invalidateAccountProviders(ref); unawaited(_discoverEncryptedAccount()); } catch (e) { quantusPrint('error discovering accounts: $e'); TelemetryService().sendError('Error discovering accounts', error: e); + // Discovery is best-effort, but an old ML-DSA-87 seed must still yield its + // funded root account even when the indexer is unreachable. + await _addLegacyRootFallback(mnemonic); + } + } + + Future _addLegacyRootFallback(String mnemonic) async { + try { + const legacy = DilithiumSchemeExtension.legacy; + final path = HdWalletService.pathForIndex(0, legacy); + final key = HdWalletService().keyPairAtPath(mnemonic, path, legacy); + final existing = (await _accountsService.getAccounts()).map((e) => e.accountId).toSet(); + if (existing.contains(key.ss58Address)) return; + await _accountsService.addAccount( + Account.derived( + walletIndex: widget.walletIndex, + index: 0, + name: 'Account ${existing.length + 1}', + keypair: key, + derivationPath: path, + ), + ); + invalidateAccountProviders(ref); + } catch (e) { + quantusPrint('legacy root fallback failed: $e'); } } diff --git a/mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart b/mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart index 58d92b44e..ff503c2d8 100644 --- a/mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart +++ b/mobile-app/lib/v2/screens/multisig/multisig_action_confirm_sheet.dart @@ -54,8 +54,7 @@ typedef MultisigConfirmExternalSubmitter = WidgetRef ref, { required Account signer, required UnsignedTransactionData unsignedData, - required Uint8List signature, - required Uint8List publicKey, + required Uint8List signatureWithPublicKey, BigInt? fee, }); @@ -310,13 +309,12 @@ class _MultisigActionConfirmSheetState extends ConsumerState ref .read(transactionSubmissionServiceProvider) .approveProposal(msig: msig, signer: resolvedSigner, proposal: proposal, callBytes: callBytes), - submitExternal: (ref, {required signer, required unsignedData, required signature, required publicKey, fee}) => - ref - .read(transactionSubmissionServiceProvider) - .approveProposalWithExternalSignature( - msig: msig, - signer: signer, - proposal: proposal, - unsignedData: unsignedData, - signature: signature, - publicKey: publicKey, - ), + submitExternal: (ref, {required signer, required unsignedData, required signatureWithPublicKey, fee}) => ref + .read(transactionSubmissionServiceProvider) + .approveProposalWithExternalSignature( + msig: msig, + signer: signer, + proposal: proposal, + unsignedData: unsignedData, + signatureWithPublicKey: signatureWithPublicKey, + ), ), ); } diff --git a/mobile-app/lib/v2/screens/multisig/multisig_cancel_confirm_sheet.dart b/mobile-app/lib/v2/screens/multisig/multisig_cancel_confirm_sheet.dart index f15fd9157..659a9c80d 100644 --- a/mobile-app/lib/v2/screens/multisig/multisig_cancel_confirm_sheet.dart +++ b/mobile-app/lib/v2/screens/multisig/multisig_cancel_confirm_sheet.dart @@ -40,18 +40,16 @@ void showMultisigCancelConfirmSheet( submit: (ref, signer, fee, callBytes) => ref .read(transactionSubmissionServiceProvider) .cancelProposal(msig: msig, proposer: signer, proposal: proposal, fee: fee), - submitExternal: (ref, {required signer, required unsignedData, required signature, required publicKey, fee}) => - ref - .read(transactionSubmissionServiceProvider) - .cancelProposalWithExternalSignature( - msig: msig, - proposer: signer, - proposal: proposal, - unsignedData: unsignedData, - signature: signature, - publicKey: publicKey, - fee: fee, - ), + submitExternal: (ref, {required signer, required unsignedData, required signatureWithPublicKey, fee}) => ref + .read(transactionSubmissionServiceProvider) + .cancelProposalWithExternalSignature( + msig: msig, + proposer: signer, + proposal: proposal, + unsignedData: unsignedData, + signatureWithPublicKey: signatureWithPublicKey, + fee: fee, + ), ), ); } diff --git a/mobile-app/lib/v2/screens/multisig/multisig_execute_confirm_sheet.dart b/mobile-app/lib/v2/screens/multisig/multisig_execute_confirm_sheet.dart index 095eb5501..8b0b451aa 100644 --- a/mobile-app/lib/v2/screens/multisig/multisig_execute_confirm_sheet.dart +++ b/mobile-app/lib/v2/screens/multisig/multisig_execute_confirm_sheet.dart @@ -45,18 +45,16 @@ void showMultisigExecuteConfirmSheet( submit: (ref, signer, fee, callBytes) => ref .read(transactionSubmissionServiceProvider) .executeProposal(msig: msig, signer: signer, proposal: proposal, callBytes: callBytes, fee: fee), - submitExternal: (ref, {required signer, required unsignedData, required signature, required publicKey, fee}) => - ref - .read(transactionSubmissionServiceProvider) - .executeProposalWithExternalSignature( - msig: msig, - signer: signer, - proposal: proposal, - unsignedData: unsignedData, - signature: signature, - publicKey: publicKey, - fee: fee, - ), + submitExternal: (ref, {required signer, required unsignedData, required signatureWithPublicKey, fee}) => ref + .read(transactionSubmissionServiceProvider) + .executeProposalWithExternalSignature( + msig: msig, + signer: signer, + proposal: proposal, + unsignedData: unsignedData, + signatureWithPublicKey: signatureWithPublicKey, + fee: fee, + ), ), ); } diff --git a/mobile-app/lib/v2/screens/send/keystone_signature_scan_screen.dart b/mobile-app/lib/v2/screens/send/keystone_signature_scan_screen.dart index 882beeac3..33ac410be 100644 --- a/mobile-app/lib/v2/screens/send/keystone_signature_scan_screen.dart +++ b/mobile-app/lib/v2/screens/send/keystone_signature_scan_screen.dart @@ -45,19 +45,15 @@ class _KeystoneSignatureScanScreenState extends ConsumerState _submit(List parts) async { final bytes = decodeUr(urParts: parts); - final signatureSize = signatureBytes().toInt(); - final expectedSize = signatureSize + publicKeyBytes().toInt(); - if (bytes.length != expectedSize) { - throw Exception('Invalid signature length: expected $expectedSize bytes, got ${bytes.length}'); - } + // Validates the length and identifies the signature scheme; the SDK reads it back off the blob. + DilithiumSchemeExtension.forSignatureWithPublicKeyLength(bytes.length); await _ensureEraNotExpired(); final hash = await widget.session.submitSigned( ref, unsignedData: widget.unsignedData, - signature: bytes.sublist(0, signatureSize), - publicKey: bytes.sublist(signatureSize), + signatureWithPublicKey: bytes, ); ref.read(keystoneSignCacheProvider.notifier).reset(); @@ -76,7 +72,9 @@ class _KeystoneSignatureScanScreenState extends ConsumerState> _simulateSignature() async { - final keypair = await widget.session.account.getKeypair(); + final account = widget.session.account; + final mnemonic = (await account.getMnemonic())!; + final keypair = HdWalletService().keyPairAtIndex(mnemonic, account.index, DilithiumScheme.mlDsa87); final signed = signMessageWithPubkey( keypair: keypair, message: widget.unsignedData.encodedPayloadToSign, diff --git a/mobile-app/lib/v2/screens/send/keystone_signing_session.dart b/mobile-app/lib/v2/screens/send/keystone_signing_session.dart index 824790524..f7c854dcc 100644 --- a/mobile-app/lib/v2/screens/send/keystone_signing_session.dart +++ b/mobile-app/lib/v2/screens/send/keystone_signing_session.dart @@ -13,8 +13,7 @@ typedef KeystoneSignatureSubmitter = Future Function( WidgetRef ref, { required UnsignedTransactionData unsignedData, - required Uint8List signature, - required Uint8List publicKey, + required Uint8List signatureWithPublicKey, }); /// Describes one Keystone hardware-signing flow. diff --git a/mobile-app/lib/v2/screens/send/regular_send_strategy.dart b/mobile-app/lib/v2/screens/send/regular_send_strategy.dart index d6f936020..3a6a5d558 100644 --- a/mobile-app/lib/v2/screens/send/regular_send_strategy.dart +++ b/mobile-app/lib/v2/screens/send/regular_send_strategy.dart @@ -29,12 +29,17 @@ final transferDispatchWeightProvider = FutureProvider.autoDispose((ref) /// Transfer fee for an amount: base and length fee from the shipped metadata, /// dispatch weight from [transferDispatchWeightProvider]. Address-independent. -final regularSendFeeProvider = Provider.autoDispose.family, BigInt>((ref, amount) { - final balances = ref.watch(balancesServiceProvider); - return ref - .watch(transferDispatchWeightProvider) - .whenData((weight) => RegularFee(networkFee: balances.transferFee(amount, dispatchWeight: weight))); -}); +final regularSendFeeProvider = Provider.autoDispose + .family, ({BigInt amount, DilithiumScheme scheme})>((ref, key) { + final balances = ref.watch(balancesServiceProvider); + return ref + .watch(transferDispatchWeightProvider) + .whenData( + (weight) => RegularFee( + networkFee: balances.transferFee(key.amount, dispatchWeight: weight, scheme: key.scheme), + ), + ); + }); /// Standard single-signer transfer from the active account. Signs locally, or /// hands off to the Keystone QR flow for hardware accounts. @@ -74,7 +79,7 @@ class RegularSendStrategy extends SendStrategy { @override ProviderListenable> feeProvider({required String recipient, required BigInt amount}) => - regularSendFeeProvider(amount); + regularSendFeeProvider((amount: amount, scheme: account.scheme ?? DilithiumSchemeExtension.legacy)); @override void retryFee(WidgetRef ref, {required String recipient, required BigInt amount}) => @@ -172,7 +177,7 @@ class RegularSendStrategy extends SendStrategy { tertiaryDetail: recipientChecksum, cacheKey: _hardwareCacheKey(recipient, amount), telemetryPrefix: 'send_transfer_hardware', - submitSigned: (ref, {required unsignedData, required signature, required publicKey}) async { + submitSigned: (ref, {required unsignedData, required signatureWithPublicKey}) async { final hash = await ref .read(transactionSubmissionServiceProvider) .submitExternallySignedTransfer( @@ -182,8 +187,7 @@ class RegularSendStrategy extends SendStrategy { fee: regularFee.networkFee, blockHeight: unsignedData.payloadToSign.blockNumber, unsignedData: unsignedData, - signature: signature, - publicKey: publicKey, + signatureWithPublicKey: signatureWithPublicKey, ); unawaited( RecentAddressesService() diff --git a/mobile-app/lib/v2/screens/settings/redeem_address_screen.dart b/mobile-app/lib/v2/screens/settings/redeem_address_screen.dart index d757a51d2..d968bb941 100644 --- a/mobile-app/lib/v2/screens/settings/redeem_address_screen.dart +++ b/mobile-app/lib/v2/screens/settings/redeem_address_screen.dart @@ -43,7 +43,7 @@ class _RedeemAddressScreenState extends ConsumerState { Future _prefillPrimaryAccount() async { final settings = ref.read(settingsServiceProvider); - final primary = await settings.getAccount(walletIndex: 0, index: 0); + final primary = await settings.getPrimaryAccount(); if (!mounted || primary == null) return; _recipientController.text = primary.accountId; } diff --git a/mobile-app/lib/v2/screens/welcome/welcome_screen.dart b/mobile-app/lib/v2/screens/welcome/welcome_screen.dart index ec85f138f..1a86224d3 100644 --- a/mobile-app/lib/v2/screens/welcome/welcome_screen.dart +++ b/mobile-app/lib/v2/screens/welcome/welcome_screen.dart @@ -35,7 +35,9 @@ class _WelcomeScreenV2State extends ConsumerState { final mnemonic = await SubstrateService().generateMnemonic(); if (mnemonic.isEmpty) throw Exception('Mnemonic generation returned empty.'); - final address = HdWalletService().keyPairAtIndex(mnemonic, 0).ss58Address; + const scheme = DilithiumSchemeExtension.current; + final path = HdWalletService.pathForIndex(0, scheme); + final address = HdWalletService().keyPairAtPath(mnemonic, path, scheme).ss58Address; final accounts = ref.read(accountsProvider).value ?? []; await _walletCreationService.createNewWallet( @@ -43,6 +45,8 @@ class _WelcomeScreenV2State extends ConsumerState { mnemonic: mnemonic, walletIndex: _walletIndex, accountId: address, + scheme: scheme, + derivationPath: path, existingAccounts: accounts, ); diff --git a/mobile-app/patrol_test/support/send_preflight.dart b/mobile-app/patrol_test/support/send_preflight.dart index d8cbae78b..e236267c3 100644 --- a/mobile-app/patrol_test/support/send_preflight.dart +++ b/mobile-app/patrol_test/support/send_preflight.dart @@ -15,7 +15,11 @@ class SendPreflight { final balancesService = BalancesService(); final balance = await substrateService.queryBalance(account.accountId); - final fee = balancesService.transferFee(ed, dispatchWeight: await balancesService.transferDispatchWeight()); + final fee = balancesService.transferFee( + ed, + dispatchWeight: await balancesService.transferDispatchWeight(), + scheme: account.scheme ?? DilithiumSchemeExtension.legacy, + ); final required = ed + fee; if (balance < required) { diff --git a/mobile-app/test/fakes.dart b/mobile-app/test/fakes.dart index 1491112e5..976975f6b 100644 --- a/mobile-app/test/fakes.dart +++ b/mobile-app/test/fakes.dart @@ -80,7 +80,8 @@ class FakeBalancesService extends Fake implements BalancesService { } @override - BigInt transferFee(BigInt amount, {required BigInt dispatchWeight}) => amount + dispatchWeight; + BigInt transferFee(BigInt amount, {required BigInt dispatchWeight, required DilithiumScheme scheme}) => + amount + dispatchWeight; } Account makeAccount(int index, {AccountType accountType = AccountType.local}) => Account( @@ -89,6 +90,10 @@ Account makeAccount(int index, {AccountType accountType = AccountType.local}) => name: 'Account $index', accountId: 'qzaccount$index${'x' * 40}', accountType: accountType, + scheme: accountType == AccountType.local ? DilithiumSchemeExtension.current : null, + derivationPath: accountType == AccountType.local + ? HdWalletService.pathForIndex(index, DilithiumSchemeExtension.current) + : null, ); MultisigAccount makeMultisigAccount() => MultisigAccount( diff --git a/mobile-app/test/unit/send_amount_layout_test.dart b/mobile-app/test/unit/send_amount_layout_test.dart index 97fbe45c5..d9b210857 100644 --- a/mobile-app/test/unit/send_amount_layout_test.dart +++ b/mobile-app/test/unit/send_amount_layout_test.dart @@ -42,7 +42,7 @@ void main() { (ref, accountId) => AsyncValue.data(BigInt.from(5000000000000)), ), regularSendFeeProvider.overrideWith( - (ref, amount) => AsyncValue.data(RegularFee(networkFee: BigInt.from(12964885))), + (ref, key) => AsyncValue.data(RegularFee(networkFee: BigInt.from(12964885))), ), ], child: Builder( diff --git a/mobile-app/test/unit/wallet_creation_service_test.dart b/mobile-app/test/unit/wallet_creation_service_test.dart index b3404f00a..379c541e9 100644 --- a/mobile-app/test/unit/wallet_creation_service_test.dart +++ b/mobile-app/test/unit/wallet_creation_service_test.dart @@ -24,6 +24,8 @@ void main() { mnemonic: mnemonic, walletIndex: 0, accountId: accountId, + scheme: DilithiumSchemeExtension.current, + derivationPath: HdWalletService.pathForIndex(0, DilithiumSchemeExtension.current), existingAccounts: const [], ); @@ -47,6 +49,8 @@ void main() { mnemonic: 'word ' * 12, walletIndex: 0, accountId: 'new_derived_addr', + scheme: DilithiumSchemeExtension.current, + derivationPath: HdWalletService.pathForIndex(0, DilithiumSchemeExtension.current), existingAccounts: const [existing], ); diff --git a/mobile-app/test/unit/wallet_creation_service_test.mocks.dart b/mobile-app/test/unit/wallet_creation_service_test.mocks.dart index 3489f825f..a05d48b1e 100644 --- a/mobile-app/test/unit/wallet_creation_service_test.mocks.dart +++ b/mobile-app/test/unit/wallet_creation_service_test.mocks.dart @@ -3,13 +3,15 @@ // Do not manually edit this file. // ignore_for_file: no_leading_underscores_for_library_prefixes -import 'dart:async' as _i5; +import 'dart:async' as _i4; import 'package:mockito/mockito.dart' as _i1; -import 'package:mockito/src/dummies.dart' as _i7; -import 'package:quantus_sdk/quantus_sdk.dart' as _i4; +import 'package:mockito/src/dummies.dart' as _i8; +import 'package:quantus_sdk/quantus_sdk.dart' as _i3; import 'package:quantus_sdk/src/models/account.dart' as _i2; -import 'package:quantus_sdk/src/models/display_account.dart' as _i6; +import 'package:quantus_sdk/src/models/display_account.dart' as _i5; +import 'package:quantus_sdk/src/models/multisig_account.dart' as _i7; +import 'package:quantus_sdk/src/rust/api/crypto.dart' as _i6; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -24,6 +26,7 @@ import 'package:quantus_sdk/src/models/display_account.dart' as _i6; // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member class _FakeAccount_0 extends _i1.SmartFake implements _i2.Account { _FakeAccount_0(Object parent, Invocation parentInvocation) : super(parent, parentInvocation); @@ -32,204 +35,258 @@ class _FakeAccount_0 extends _i1.SmartFake implements _i2.Account { /// A class which mocks [SettingsService]. /// /// See the documentation for Mockito's code generation for more information. -class MockSettingsService extends _i1.Mock implements _i4.SettingsService { +class MockSettingsService extends _i1.Mock implements _i3.SettingsService { @override - _i5.Future initialize() => + _i4.Future initialize() => (super.noSuchMethod( Invocation.method(#initialize, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future> getAccounts() => + _i4.Future> getAccounts() => (super.noSuchMethod( Invocation.method(#getAccounts, []), - returnValue: _i5.Future>.value(<_i2.Account>[]), - returnValueForMissingStub: _i5.Future>.value(<_i2.Account>[]), + returnValue: _i4.Future>.value(<_i2.Account>[]), + returnValueForMissingStub: _i4.Future>.value(<_i2.Account>[]), ) - as _i5.Future>); + as _i4.Future>); @override - _i5.Future saveAccounts(List<_i2.Account>? accounts) => + _i4.Future saveAccounts(List<_i2.Account>? accounts) => (super.noSuchMethod( Invocation.method(#saveAccounts, [accounts]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future addAccount(_i2.Account? account) => + _i4.Future addAccount(_i2.Account? account) => (super.noSuchMethod( Invocation.method(#addAccount, [account]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future updateAccount(_i2.Account? account) => + _i4.Future updateAccount(_i2.Account? account) => (super.noSuchMethod( Invocation.method(#updateAccount, [account]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future removeAccount(_i2.Account? account) => + _i4.Future removeAccount(_i2.Account? account) => (super.noSuchMethod( Invocation.method(#removeAccount, [account]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future setActiveAccount(_i6.DisplayAccount? account) => + _i4.Future removeWallet(int? walletIndex) => + (super.noSuchMethod( + Invocation.method(#removeWallet, [walletIndex]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setActiveAccount(_i5.DisplayAccount? account) => (super.noSuchMethod( Invocation.method(#setActiveAccount, [account]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future<_i6.DisplayAccount?> getActiveAccount() => + _i4.Future<_i5.DisplayAccount?> getActiveAccount() => (super.noSuchMethod( Invocation.method(#getActiveAccount, []), - returnValue: _i5.Future<_i6.DisplayAccount?>.value(), - returnValueForMissingStub: _i5.Future<_i6.DisplayAccount?>.value(), + returnValue: _i4.Future<_i5.DisplayAccount?>.value(), + returnValueForMissingStub: _i4.Future<_i5.DisplayAccount?>.value(), ) - as _i5.Future<_i6.DisplayAccount?>); + as _i4.Future<_i5.DisplayAccount?>); @override - _i5.Future<_i2.Account?> getActiveRegularAccount() => + _i4.Future<_i2.Account?> getActiveRegularAccount() => (super.noSuchMethod( Invocation.method(#getActiveRegularAccount, []), - returnValue: _i5.Future<_i2.Account?>.value(), - returnValueForMissingStub: _i5.Future<_i2.Account?>.value(), + returnValue: _i4.Future<_i2.Account?>.value(), + returnValueForMissingStub: _i4.Future<_i2.Account?>.value(), + ) + as _i4.Future<_i2.Account?>); + + @override + _i4.Future<_i2.Account?> getPrimaryAccount() => + (super.noSuchMethod( + Invocation.method(#getPrimaryAccount, []), + returnValue: _i4.Future<_i2.Account?>.value(), + returnValueForMissingStub: _i4.Future<_i2.Account?>.value(), + ) + as _i4.Future<_i2.Account?>); + + @override + _i4.Future getNextFreeAccountIndex(int? walletIndex, {_i6.DilithiumScheme? scheme}) => + (super.noSuchMethod( + Invocation.method(#getNextFreeAccountIndex, [walletIndex], {#scheme: scheme}), + returnValue: _i4.Future.value(0), + returnValueForMissingStub: _i4.Future.value(0), ) - as _i5.Future<_i2.Account?>); + as _i4.Future); @override - _i5.Future<_i2.Account?> getAccount({required int? walletIndex, required int? index}) => + _i4.Future> getMultisigAccounts() => (super.noSuchMethod( - Invocation.method(#getAccount, [], {#walletIndex: walletIndex, #index: index}), - returnValue: _i5.Future<_i2.Account?>.value(), - returnValueForMissingStub: _i5.Future<_i2.Account?>.value(), + Invocation.method(#getMultisigAccounts, []), + returnValue: _i4.Future>.value(<_i7.MultisigAccount>[]), + returnValueForMissingStub: _i4.Future>.value(<_i7.MultisigAccount>[]), ) - as _i5.Future<_i2.Account?>); + as _i4.Future>); @override - _i5.Future getNextFreeAccountIndex(int? walletIndex) => + _i4.Future addMultisigAccount(_i7.MultisigAccount? account) => (super.noSuchMethod( - Invocation.method(#getNextFreeAccountIndex, [walletIndex]), - returnValue: _i5.Future.value(0), - returnValueForMissingStub: _i5.Future.value(0), + Invocation.method(#addMultisigAccount, [account]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future> getAddressBook() => + _i4.Future updateMultisigAccount(_i7.MultisigAccount? account) => + (super.noSuchMethod( + Invocation.method(#updateMultisigAccount, [account]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future removeMultisigAccount(String? accountId) => + (super.noSuchMethod( + Invocation.method(#removeMultisigAccount, [accountId]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future> getAddressBook() => (super.noSuchMethod( Invocation.method(#getAddressBook, []), - returnValue: _i5.Future>.value({}), - returnValueForMissingStub: _i5.Future>.value({}), + returnValue: _i4.Future>.value({}), + returnValueForMissingStub: _i4.Future>.value({}), ) - as _i5.Future>); + as _i4.Future>); @override - _i5.Future saveAddressBook(Map? addressBook) => + _i4.Future saveAddressBook(Map? addressBook) => (super.noSuchMethod( Invocation.method(#saveAddressBook, [addressBook]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future setAddressName(String? address, String? name) => + _i4.Future setAddressName(String? address, String? name) => (super.noSuchMethod( Invocation.method(#setAddressName, [address, name]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future getAddressName(String? address) => + _i4.Future getAddressName(String? address) => (super.noSuchMethod( Invocation.method(#getAddressName, [address]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future removeAddressName(String? address) => + _i4.Future removeAddressName(String? address) => (super.noSuchMethod( Invocation.method(#removeAddressName, [address]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future getHasWallet() => + _i4.Future getHasWallet() => (super.noSuchMethod( Invocation.method(#getHasWallet, []), - returnValue: _i5.Future.value(false), - returnValueForMissingStub: _i5.Future.value(false), + returnValue: _i4.Future.value(false), + returnValueForMissingStub: _i4.Future.value(false), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future isWalletLoggedOut() => + _i4.Future isWalletLoggedOut() => (super.noSuchMethod( Invocation.method(#isWalletLoggedOut, []), - returnValue: _i5.Future.value(false), - returnValueForMissingStub: _i5.Future.value(false), + returnValue: _i4.Future.value(false), + returnValueForMissingStub: _i4.Future.value(false), ) - as _i5.Future); + as _i4.Future); @override String getMnemonicKey(int? walletIndex) => (super.noSuchMethod( Invocation.method(#getMnemonicKey, [walletIndex]), - returnValue: _i7.dummyValue(this, Invocation.method(#getMnemonicKey, [walletIndex])), - returnValueForMissingStub: _i7.dummyValue(this, Invocation.method(#getMnemonicKey, [walletIndex])), + returnValue: _i8.dummyValue(this, Invocation.method(#getMnemonicKey, [walletIndex])), + returnValueForMissingStub: _i8.dummyValue(this, Invocation.method(#getMnemonicKey, [walletIndex])), ) as String); @override - _i5.Future setMnemonic(String? mnemonic, int? walletIndex) => + _i4.Future setMnemonic(String? mnemonic, int? walletIndex) => (super.noSuchMethod( Invocation.method(#setMnemonic, [mnemonic, walletIndex]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future getMnemonic(int? walletIndex) => + _i4.Future getMnemonic(int? walletIndex) => (super.noSuchMethod( Invocation.method(#getMnemonic, [walletIndex]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future setReversibleEnabled(bool? enabled) => + _i4.Future deleteMnemonic(int? walletIndex) => + (super.noSuchMethod( + Invocation.method(#deleteMnemonic, [walletIndex]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setReversibleEnabled(bool? enabled) => (super.noSuchMethod( Invocation.method(#setReversibleEnabled, [enabled]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override bool isReversibleEnabled() => @@ -241,31 +298,31 @@ class MockSettingsService extends _i1.Mock implements _i4.SettingsService { as bool); @override - _i5.Future setReversibleTimeSeconds(int? seconds) => + _i4.Future setReversibleTimeSeconds(int? seconds) => (super.noSuchMethod( Invocation.method(#setReversibleTimeSeconds, [seconds]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future getReversibleTimeSeconds() => + _i4.Future getReversibleTimeSeconds() => (super.noSuchMethod( Invocation.method(#getReversibleTimeSeconds, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future setBalanceHidden(bool? hidden) => + _i4.Future setBalanceHidden(bool? hidden) => (super.noSuchMethod( Invocation.method(#setBalanceHidden, [hidden]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override bool isBalanceHidden() => @@ -273,13 +330,13 @@ class MockSettingsService extends _i1.Mock implements _i4.SettingsService { as bool); @override - _i5.Future setCurrencyFlipped(bool? flipped) => + _i4.Future setCurrencyFlipped(bool? flipped) => (super.noSuchMethod( Invocation.method(#setCurrencyFlipped, [flipped]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override bool isCurrencyFlipped() => @@ -291,51 +348,78 @@ class MockSettingsService extends _i1.Mock implements _i4.SettingsService { as bool); @override - _i5.Future setSelectedFiatCurrency(String? currencyCode) => + _i4.Future setSelectedFiatCurrency(String? currencyCode) => (super.noSuchMethod( Invocation.method(#setSelectedFiatCurrency, [currencyCode]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); + + @override + _i4.Future clearSelectedFiatCurrency() => + (super.noSuchMethod( + Invocation.method(#clearSelectedFiatCurrency, []), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future setSelectedAppLocale(String? languageCode) => + (super.noSuchMethod( + Invocation.method(#setSelectedAppLocale, [languageCode]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future clearSelectedAppLocale() => + (super.noSuchMethod( + Invocation.method(#clearSelectedAppLocale, []), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); @override bool? getBool(String? key) => (super.noSuchMethod(Invocation.method(#getBool, [key]), returnValueForMissingStub: null) as bool?); @override - _i5.Future setBool(String? key, bool? value) => + _i4.Future setBool(String? key, bool? value) => (super.noSuchMethod( Invocation.method(#setBool, [key, value]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override String? getString(String? key) => (super.noSuchMethod(Invocation.method(#getString, [key]), returnValueForMissingStub: null) as String?); @override - _i5.Future setString(String? key, String? value) => + _i4.Future setString(String? key, String? value) => (super.noSuchMethod( Invocation.method(#setString, [key, value]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override void resetForTest() => super.noSuchMethod(Invocation.method(#resetForTest, []), returnValueForMissingStub: null); @override - _i5.Future clearAll() => + _i4.Future clearAll() => (super.noSuchMethod( Invocation.method(#clearAll, []), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override bool referralCheckCompleted() => @@ -375,6 +459,42 @@ class MockSettingsService extends _i1.Mock implements _i4.SettingsService { void clearQuestsPromoWatchedFlag() => super.noSuchMethod(Invocation.method(#clearQuestsPromoWatchedFlag, []), returnValueForMissingStub: null); + @override + bool recoveryPhraseViewed(int? walletIndex) => + (super.noSuchMethod( + Invocation.method(#recoveryPhraseViewed, [walletIndex]), + returnValue: false, + returnValueForMissingStub: false, + ) + as bool); + + @override + void setRecoveryPhraseViewed(int? walletIndex) => + super.noSuchMethod(Invocation.method(#setRecoveryPhraseViewed, [walletIndex]), returnValueForMissingStub: null); + + @override + _i2.WalletOrigin? getWalletOrigin(int? walletIndex) => + (super.noSuchMethod(Invocation.method(#getWalletOrigin, [walletIndex]), returnValueForMissingStub: null) + as _i2.WalletOrigin?); + + @override + void setWalletOrigin(int? walletIndex, _i2.WalletOrigin? origin) => + super.noSuchMethod(Invocation.method(#setWalletOrigin, [walletIndex, origin]), returnValueForMissingStub: null); + + @override + String? getWalletName(int? walletIndex) => + (super.noSuchMethod(Invocation.method(#getWalletName, [walletIndex]), returnValueForMissingStub: null) + as String?); + + @override + _i4.Future setWalletName(int? walletIndex, String? name) => + (super.noSuchMethod( + Invocation.method(#setWalletName, [walletIndex, name]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + @override bool existingUserSeenPromoVideo() => (super.noSuchMethod( @@ -396,57 +516,94 @@ class MockSettingsService extends _i1.Mock implements _i4.SettingsService { /// A class which mocks [AccountsService]. /// /// See the documentation for Mockito's code generation for more information. -class MockAccountsService extends _i1.Mock implements _i4.AccountsService { +class MockAccountsService extends _i1.Mock implements _i3.AccountsService { @override - set onAccountsChanged(void Function()? _onAccountsChanged) => - super.noSuchMethod(Invocation.setter(#onAccountsChanged, _onAccountsChanged), returnValueForMissingStub: null); + set onAccountsChanged(void Function()? value) => + super.noSuchMethod(Invocation.setter(#onAccountsChanged, value), returnValueForMissingStub: null); @override - _i5.Future<_i2.Account> createNewAccount({required int? walletIndex}) => + _i4.Future<_i2.Account> createNewAccount({required int? walletIndex}) => (super.noSuchMethod( Invocation.method(#createNewAccount, [], {#walletIndex: walletIndex}), - returnValue: _i5.Future<_i2.Account>.value( + returnValue: _i4.Future<_i2.Account>.value( _FakeAccount_0(this, Invocation.method(#createNewAccount, [], {#walletIndex: walletIndex})), ), - returnValueForMissingStub: _i5.Future<_i2.Account>.value( + returnValueForMissingStub: _i4.Future<_i2.Account>.value( _FakeAccount_0(this, Invocation.method(#createNewAccount, [], {#walletIndex: walletIndex})), ), ) - as _i5.Future<_i2.Account>); + as _i4.Future<_i2.Account>); + + @override + _i4.Future<_i2.Account> createEncryptedAccount({required int? walletIndex, required String? name}) => + (super.noSuchMethod( + Invocation.method(#createEncryptedAccount, [], {#walletIndex: walletIndex, #name: name}), + returnValue: _i4.Future<_i2.Account>.value( + _FakeAccount_0( + this, + Invocation.method(#createEncryptedAccount, [], {#walletIndex: walletIndex, #name: name}), + ), + ), + returnValueForMissingStub: _i4.Future<_i2.Account>.value( + _FakeAccount_0( + this, + Invocation.method(#createEncryptedAccount, [], {#walletIndex: walletIndex, #name: name}), + ), + ), + ) + as _i4.Future<_i2.Account>); @override - _i5.Future updateAccountName(_i2.Account? account, String? name) => + _i4.Future ensureEncryptedAccountsForSoftwareWallets({required String? name}) => + (super.noSuchMethod( + Invocation.method(#ensureEncryptedAccountsForSoftwareWallets, [], {#name: name}), + returnValue: _i4.Future.value(false), + returnValueForMissingStub: _i4.Future.value(false), + ) + as _i4.Future); + + @override + _i4.Future updateAccountName(_i2.Account? account, String? name) => (super.noSuchMethod( Invocation.method(#updateAccountName, [account, name]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future addAccount(_i2.Account? newAccount) => + _i4.Future addAccount(_i2.Account? newAccount) => (super.noSuchMethod( Invocation.method(#addAccount, [newAccount]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); @override - _i5.Future> getAccounts() => + _i4.Future> getAccounts() => (super.noSuchMethod( Invocation.method(#getAccounts, []), - returnValue: _i5.Future>.value(<_i2.Account>[]), - returnValueForMissingStub: _i5.Future>.value(<_i2.Account>[]), + returnValue: _i4.Future>.value(<_i2.Account>[]), + returnValueForMissingStub: _i4.Future>.value(<_i2.Account>[]), ) - as _i5.Future>); + as _i4.Future>); @override - _i5.Future removeAccount(_i2.Account? account) => + _i4.Future removeAccount(_i2.Account? account) => (super.noSuchMethod( Invocation.method(#removeAccount, [account]), - returnValue: _i5.Future.value(), - returnValueForMissingStub: _i5.Future.value(), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future removeWallet(int? walletIndex) => + (super.noSuchMethod( + Invocation.method(#removeWallet, [walletIndex]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), ) - as _i5.Future); + as _i4.Future); } diff --git a/quantus_sdk/lib/quantus_sdk.dart b/quantus_sdk/lib/quantus_sdk.dart index ec3699952..6167f4f53 100644 --- a/quantus_sdk/lib/quantus_sdk.dart +++ b/quantus_sdk/lib/quantus_sdk.dart @@ -15,6 +15,7 @@ export 'src/extensions/address_extension.dart'; export 'src/extensions/color_extensions.dart'; export 'src/extensions/context_extension.dart'; export 'src/extensions/decimal_input_filter.dart'; +export 'src/extensions/dilithium_scheme_extension.dart'; export 'src/extensions/keypair_extensions.dart'; export 'src/extensions/media_query_data_extension.dart'; export 'src/extensions/string_extensions.dart'; diff --git a/quantus_sdk/lib/src/extensions/account_extension.dart b/quantus_sdk/lib/src/extensions/account_extension.dart index b743ab4af..796833853 100644 --- a/quantus_sdk/lib/src/extensions/account_extension.dart +++ b/quantus_sdk/lib/src/extensions/account_extension.dart @@ -2,11 +2,17 @@ import 'package:quantus_sdk/quantus_sdk.dart'; extension HDWalletAccount on Account { Future getKeypair() async { + final path = derivationPath; + final keyScheme = scheme; + if (path == null || keyScheme == null) { + throw StateError('Account $accountId (${accountType.name}) holds no local key'); + } final mnemonic = await getMnemonic(); - return HdWalletService().keyPairAtIndex(mnemonic!, index); + if (mnemonic == null) throw StateError('Mnemonic not found for wallet $walletIndex'); + return HdWalletService().keyPairAtPath(mnemonic, path, keyScheme); } - Future getMnemonic() async { + Future getMnemonic() { return SettingsService().getMnemonic(walletIndex); } } diff --git a/quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart b/quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart new file mode 100644 index 000000000..7d58f9131 --- /dev/null +++ b/quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart @@ -0,0 +1,58 @@ +import 'package:quantus_sdk/src/rust/api/crypto.dart'; + +/// Scheme-dependent constants, in one place. Conventions match quantus-cli. +extension DilithiumSchemeExtension on DilithiumScheme { + /// Scheme new wallets and accounts use. + static const DilithiumScheme current = DilithiumScheme.mlDsa65; + + /// Scheme of accounts stored before the scheme was recorded. + static const DilithiumScheme legacy = DilithiumScheme.mlDsa87; + + /// Variant index of the chain's `DilithiumSignatureScheme`, written into every signed extrinsic. + int get signatureTypeByte => switch (this) { + DilithiumScheme.mlDsa87 => 0, + DilithiumScheme.mlDsa65 => 1, + }; + + /// Trailing hardened index of the transparent derivation path (`.../0'` for 87, `.../1'` for 65). + int get derivationAddressIndex => switch (this) { + DilithiumScheme.mlDsa87 => 0, + DilithiumScheme.mlDsa65 => 1, + }; + + /// Name persisted in account storage, same as quantus-cli's `scheme` field. + String get storageName => switch (this) { + DilithiumScheme.mlDsa65 => 'ml-dsa-65', + DilithiumScheme.mlDsa87 => 'ml-dsa-87', + }; + + static DilithiumScheme fromStorageName(String? name) { + if (name == null) return legacy; + return DilithiumScheme.values.firstWhere( + (s) => s.storageName == name, + orElse: () => throw FormatException('Unknown signature scheme: $name'), + ); + } + + /// Bytes of an ML-DSA signature. FIPS 204 fixed constants, cross-checked + /// against the Rust `signatureBytes` in tests. + int get signatureByteLength => switch (this) { + DilithiumScheme.mlDsa65 => 3309, + DilithiumScheme.mlDsa87 => 4627, + }; + + /// Bytes of an ML-DSA public key. + int get publicKeyByteLength => switch (this) { + DilithiumScheme.mlDsa65 => 1952, + DilithiumScheme.mlDsa87 => 2592, + }; + + /// Bytes of `signature ++ publicKey`, the payload every signed extrinsic carries. + int get signatureWithPublicKeyBytes => signatureByteLength + publicKeyByteLength; + + /// The scheme whose `signature ++ publicKey` is [length] bytes long. + static DilithiumScheme forSignatureWithPublicKeyLength(int length) => DilithiumScheme.values.firstWhere( + (s) => s.signatureWithPublicKeyBytes == length, + orElse: () => throw FormatException('No ML-DSA scheme has a $length-byte signature with public key'), + ); +} diff --git a/quantus_sdk/lib/src/models/account.dart b/quantus_sdk/lib/src/models/account.dart index 796716813..ea2ef3f2b 100644 --- a/quantus_sdk/lib/src/models/account.dart +++ b/quantus_sdk/lib/src/models/account.dart @@ -1,5 +1,9 @@ import 'package:flutter/foundation.dart'; +import 'package:quantus_sdk/src/extensions/dilithium_scheme_extension.dart'; +import 'package:quantus_sdk/src/extensions/keypair_extensions.dart'; import 'package:quantus_sdk/src/models/base_account.dart'; +import 'package:quantus_sdk/src/rust/api/crypto.dart'; +import 'package:quantus_sdk/src/services/hd_wallet_service.dart'; enum AccountType { local, keystone, external, encrypted } @@ -10,27 +14,60 @@ enum WalletOrigin { created, imported } @immutable class Account implements BaseAccount { final int walletIndex; - final int index; // derivation index + final int index; // derivation index, unique per (walletIndex, scheme) @override final String name; @override final String accountId; // address final AccountType accountType; + + /// Signature scheme and derivation path of the key behind a local account. + /// Null for accounts that hold no key here (keystone, encrypted). + final DilithiumScheme? scheme; + final String? derivationPath; + const Account({ required this.walletIndex, required this.index, required this.name, required this.accountId, this.accountType = AccountType.local, + this.scheme, + this.derivationPath, }); + /// A local account for [keypair], derived at [derivationPath]. + Account.derived({ + required int walletIndex, + required int index, + required String name, + required Keypair keypair, + required String derivationPath, + }) : this( + walletIndex: walletIndex, + index: index, + name: name, + accountId: keypair.ss58Address, + scheme: keypair.scheme, + derivationPath: derivationPath, + ); + + /// Local accounts stored before the scheme was recorded are ML-DSA-87 at the legacy path. factory Account.fromJson(Map json) { + final accountType = AccountType.values.byName(json['accountType'] as String? ?? AccountType.local.name); + final index = json['index'] as int; + final isLocal = accountType == AccountType.local; + final scheme = isLocal ? DilithiumSchemeExtension.fromStorageName(json['scheme'] as String?) : null; return Account( walletIndex: (json['walletIndex'] ?? 0) as int, - index: json['index'] as int, + index: index, name: json['name'] as String, accountId: json['accountId'] as String, - accountType: AccountType.values.byName(json['accountType'] as String? ?? AccountType.local.name), + accountType: accountType, + scheme: scheme, + derivationPath: isLocal + ? (json['derivationPath'] as String? ?? HdWalletService.pathForIndex(index, scheme!)) + : null, ); } @@ -41,16 +78,42 @@ class Account implements BaseAccount { 'name': name, 'accountId': accountId, 'accountType': accountType.name, + if (scheme != null) 'scheme': scheme!.storageName, + if (derivationPath != null) 'derivationPath': derivationPath, }; } - Account copyWith({int? walletIndex, int? index, String? name, String? accountId, AccountType? accountType}) { + Account copyWith({ + int? walletIndex, + int? index, + String? name, + String? accountId, + AccountType? accountType, + DilithiumScheme? scheme, + String? derivationPath, + }) { return Account( walletIndex: walletIndex ?? this.walletIndex, index: index ?? this.index, name: name ?? this.name, accountId: accountId ?? this.accountId, accountType: accountType ?? this.accountType, + scheme: scheme ?? this.scheme, + derivationPath: derivationPath ?? this.derivationPath, ); } + + /// Wallet, then scheme (current first, keyless accounts last), then derivation index. + static int compare(Account a, Account b) { + final w = a.walletIndex.compareTo(b.walletIndex); + if (w != 0) return w; + final s = _schemeRank(a.scheme).compareTo(_schemeRank(b.scheme)); + return s != 0 ? s : a.index.compareTo(b.index); + } + + static int _schemeRank(DilithiumScheme? scheme) => switch (scheme) { + DilithiumSchemeExtension.current => 0, + DilithiumSchemeExtension.legacy => 1, + null => 2, + }; } diff --git a/quantus_sdk/lib/src/resonance_extrinsic_payload.dart b/quantus_sdk/lib/src/resonance_extrinsic_payload.dart index 780fea54b..a0b0a142f 100644 --- a/quantus_sdk/lib/src/resonance_extrinsic_payload.dart +++ b/quantus_sdk/lib/src/resonance_extrinsic_payload.dart @@ -5,6 +5,8 @@ import 'package:polkadart/extrinsic/signed_extensions/signed_extensions_abstract import 'package:polkadart/polkadart.dart'; import 'package:polkadart/scale_codec.dart'; import 'package:polkadart/substrate/era.dart'; +import 'package:quantus_sdk/src/extensions/dilithium_scheme_extension.dart'; +import 'package:quantus_sdk/src/rust/api/crypto.dart'; /// This is a modified version of the ExtrinsicPayload class from polkadart /// It adds a method to encode the extrinsic payload with all our signature types @@ -13,13 +15,6 @@ import 'package:polkadart/substrate/era.dart'; /// The reason we need this is that vanilla polkadart is not using the chain metadata to encode /// the signature type. Instead, it is redefining the original sig type. -enum ResonanceSignatureType { - resonance(0); - - final int type; - const ResonanceSignatureType(this.type); -} - class ResonanceExtrinsicPayload extends ExtrinsicPayload { ResonanceExtrinsicPayload({ required super.signer, @@ -52,9 +47,7 @@ class ResonanceExtrinsicPayload extends ExtrinsicPayload { /// Encode the extrinsic payload with all our signature types /// This replaces the original method 'encode' in the parent class - // Uint8List encodeResonance(dynamic registry, ResonanceSignatureType signatureType) { - - Uint8List encodeResonance(dynamic registry, ResonanceSignatureType signatureType) { + Uint8List encodeResonance(dynamic registry, DilithiumScheme scheme) { if (customSignedExtensions.isNotEmpty && registry is! Registry) { throw Exception( 'Custom signed extensions are not supported on this registry. Please use registry from `runtimeMetadata.chainInfo.scaleCodec.registry`.', @@ -78,7 +71,7 @@ class ResonanceExtrinsicPayload extends ExtrinsicPayload { // Push Signer Address ..write(signer) // Push signature type byte - ..pushByte(signatureType.type) + ..pushByte(scheme.signatureTypeByte) // Push signature ..write(signature); diff --git a/quantus_sdk/lib/src/rust/api/crypto.dart b/quantus_sdk/lib/src/rust/api/crypto.dart index 48d3482d5..502c92c0f 100644 --- a/quantus_sdk/lib/src/rust/api/crypto.dart +++ b/quantus_sdk/lib/src/rust/api/crypto.dart @@ -7,7 +7,8 @@ import '../frb_generated.dart'; import 'package:collection/collection.dart'; import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart'; -// These functions are ignored because they are not marked as `pub`: `from_ml_dsa`, `to_ml_dsa` +// These functions are ignored because they are not marked as `pub`: `ml_dsa_87_from_entropy`, `new` +// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `assert_receiver_is_total_eq`, `clone`, `eq`, `fmt` void setDefaultSs58Prefix({required int prefix}) => RustLib.instance.api.crateApiCryptoSetDefaultSs58Prefix(prefix: prefix); @@ -18,11 +19,12 @@ String toAccountId({required Keypair obj}) => RustLib.instance.api.crateApiCrypt /// Convert key in ss58check format to accountId32 Uint8List ss58ToAccountId({required String s}) => RustLib.instance.api.crateApiCryptoSs58ToAccountId(s: s); +/// Legacy non-HD ML-DSA-87 keypair straight from the mnemonic seed (early CLI and miner accounts). Keypair generateKeypair({required String mnemonicStr}) => RustLib.instance.api.crateApiCryptoGenerateKeypair(mnemonicStr: mnemonicStr); -Keypair generateDerivedKeypair({required String mnemonicStr, required String path}) => - RustLib.instance.api.crateApiCryptoGenerateDerivedKeypair(mnemonicStr: mnemonicStr, path: path); +Keypair generateDerivedKeypair({required String mnemonicStr, required String path, required DilithiumScheme scheme}) => + RustLib.instance.api.crateApiCryptoGenerateDerivedKeypair(mnemonicStr: mnemonicStr, path: path, scheme: scheme); WormholeResult deriveWormhole({required String mnemonicStr, required String path}) => RustLib.instance.api.crateApiCryptoDeriveWormhole(mnemonicStr: mnemonicStr, path: path); @@ -35,6 +37,7 @@ WormholeResult deriveWormhole({required String mnemonicStr, required String path String firstHashToAddress({required String firstHashHex}) => RustLib.instance.api.crateApiCryptoFirstHashToAddress(firstHashHex: firstHashHex); +/// ML-DSA-87 keypair from a raw 32-byte seed (dev accounts). Keypair generateKeypairFromSeed({required List seed}) => RustLib.instance.api.crateApiCryptoGenerateKeypairFromSeed(seed: seed); @@ -82,23 +85,31 @@ Keypair crystalBob() => RustLib.instance.api.crateApiCryptoCrystalBob(); Keypair crystalCharlie() => RustLib.instance.api.crateApiCryptoCrystalCharlie(); -BigInt publicKeyBytes() => RustLib.instance.api.crateApiCryptoPublicKeyBytes(); +int publicKeyBytes({required DilithiumScheme scheme}) => + RustLib.instance.api.crateApiCryptoPublicKeyBytes(scheme: scheme); -BigInt secretKeyBytes() => RustLib.instance.api.crateApiCryptoSecretKeyBytes(); +int secretKeyBytes({required DilithiumScheme scheme}) => + RustLib.instance.api.crateApiCryptoSecretKeyBytes(scheme: scheme); -BigInt signatureBytes() => RustLib.instance.api.crateApiCryptoSignatureBytes(); +int signatureBytes({required DilithiumScheme scheme}) => + RustLib.instance.api.crateApiCryptoSignatureBytes(scheme: scheme); // Rust type: RustOpaqueMoi> abstract class HdLatticeError implements RustOpaqueInterface {} +/// ML-DSA parameter set of a keypair. Mirrors quantus-cli's `DilithiumScheme`; +/// accounts stored before this existed are ML-DSA-87. +enum DilithiumScheme { mlDsa65, mlDsa87 } + class Keypair { final Uint8List publicKey; final Uint8List secretKey; + final DilithiumScheme scheme; - const Keypair({required this.publicKey, required this.secretKey}); + const Keypair({required this.publicKey, required this.secretKey, required this.scheme}); @override - int get hashCode => publicKey.hashCode ^ secretKey.hashCode; + int get hashCode => publicKey.hashCode ^ secretKey.hashCode ^ scheme.hashCode; @override bool operator ==(Object other) => @@ -106,7 +117,8 @@ class Keypair { other is Keypair && runtimeType == other.runtimeType && publicKey == other.publicKey && - secretKey == other.secretKey; + secretKey == other.secretKey && + scheme == other.scheme; } class U8Array32 extends NonGrowableListView { diff --git a/quantus_sdk/lib/src/rust/frb_generated.dart b/quantus_sdk/lib/src/rust/frb_generated.dart index 05c6a0049..d8c26a019 100644 --- a/quantus_sdk/lib/src/rust/frb_generated.dart +++ b/quantus_sdk/lib/src/rust/frb_generated.dart @@ -112,7 +112,11 @@ abstract class RustLibApi extends BaseApi { String crateApiCryptoFirstHashToAddress({required String firstHashHex}); - Keypair crateApiCryptoGenerateDerivedKeypair({required String mnemonicStr, required String path}); + Keypair crateApiCryptoGenerateDerivedKeypair({ + required String mnemonicStr, + required String path, + required DilithiumScheme scheme, + }); Keypair crateApiCryptoGenerateKeypair({required String mnemonicStr}); @@ -138,9 +142,9 @@ abstract class RustLibApi extends BaseApi { required BigInt nonce, }); - BigInt crateApiCryptoPublicKeyBytes(); + int crateApiCryptoPublicKeyBytes({required DilithiumScheme scheme}); - BigInt crateApiCryptoSecretKeyBytes(); + int crateApiCryptoSecretKeyBytes({required DilithiumScheme scheme}); void crateApiCryptoSetDefaultSs58Prefix({required int prefix}); @@ -158,7 +162,7 @@ abstract class RustLibApi extends BaseApi { required int specVersion, }); - BigInt crateApiCryptoSignatureBytes(); + int crateApiCryptoSignatureBytes({required DilithiumScheme scheme}); Uint8List crateApiCryptoSs58ToAccountId({required String s}); @@ -525,13 +529,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { const TaskConstMeta(debugName: 'first_hash_to_address', argNames: ['firstHashHex']); @override - Keypair crateApiCryptoGenerateDerivedKeypair({required String mnemonicStr, required String path}) { + Keypair crateApiCryptoGenerateDerivedKeypair({ + required String mnemonicStr, + required String path, + required DilithiumScheme scheme, + }) { return handler.executeSync( SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); sse_encode_String(mnemonicStr, serializer); sse_encode_String(path, serializer); + sse_encode_dilithium_scheme(scheme, serializer); return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 17)!; }, codec: SseCodec( @@ -540,14 +549,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_decode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerHDLatticeError, ), constMeta: kCrateApiCryptoGenerateDerivedKeypairConstMeta, - argValues: [mnemonicStr, path], + argValues: [mnemonicStr, path, scheme], apiImpl: this, ), ); } TaskConstMeta get kCrateApiCryptoGenerateDerivedKeypairConstMeta => - const TaskConstMeta(debugName: 'generate_derived_keypair', argNames: ['mnemonicStr', 'path']); + const TaskConstMeta(debugName: 'generate_derived_keypair', argNames: ['mnemonicStr', 'path', 'scheme']); @override Keypair crateApiCryptoGenerateKeypair({required String mnemonicStr}) { @@ -721,42 +730,44 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { const TaskConstMeta(debugName: 'predict_multisig_address', argNames: ['signers', 'threshold', 'nonce']); @override - BigInt crateApiCryptoPublicKeyBytes() { + int crateApiCryptoPublicKeyBytes({required DilithiumScheme scheme}) { return handler.executeSync( SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_dilithium_scheme(scheme, serializer); return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 26)!; }, - codec: SseCodec(decodeSuccessData: sse_decode_usize, decodeErrorData: null), + codec: SseCodec(decodeSuccessData: sse_decode_u_32, decodeErrorData: null), constMeta: kCrateApiCryptoPublicKeyBytesConstMeta, - argValues: [], + argValues: [scheme], apiImpl: this, ), ); } TaskConstMeta get kCrateApiCryptoPublicKeyBytesConstMeta => - const TaskConstMeta(debugName: 'public_key_bytes', argNames: []); + const TaskConstMeta(debugName: 'public_key_bytes', argNames: ['scheme']); @override - BigInt crateApiCryptoSecretKeyBytes() { + int crateApiCryptoSecretKeyBytes({required DilithiumScheme scheme}) { return handler.executeSync( SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_dilithium_scheme(scheme, serializer); return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 27)!; }, - codec: SseCodec(decodeSuccessData: sse_decode_usize, decodeErrorData: null), + codec: SseCodec(decodeSuccessData: sse_decode_u_32, decodeErrorData: null), constMeta: kCrateApiCryptoSecretKeyBytesConstMeta, - argValues: [], + argValues: [scheme], apiImpl: this, ), ); } TaskConstMeta get kCrateApiCryptoSecretKeyBytesConstMeta => - const TaskConstMeta(debugName: 'secret_key_bytes', argNames: []); + const TaskConstMeta(debugName: 'secret_key_bytes', argNames: ['scheme']); @override void crateApiCryptoSetDefaultSs58Prefix({required int prefix}) { @@ -837,23 +848,24 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { ); @override - BigInt crateApiCryptoSignatureBytes() { + int crateApiCryptoSignatureBytes({required DilithiumScheme scheme}) { return handler.executeSync( SyncTask( callFfi: () { final serializer = SseSerializer(generalizedFrbRustBinding); + sse_encode_dilithium_scheme(scheme, serializer); return pdeCallFfi(generalizedFrbRustBinding, serializer, funcId: 31)!; }, - codec: SseCodec(decodeSuccessData: sse_decode_usize, decodeErrorData: null), + codec: SseCodec(decodeSuccessData: sse_decode_u_32, decodeErrorData: null), constMeta: kCrateApiCryptoSignatureBytesConstMeta, - argValues: [], + argValues: [scheme], apiImpl: this, ), ); } TaskConstMeta get kCrateApiCryptoSignatureBytesConstMeta => - const TaskConstMeta(debugName: 'signature_bytes', argNames: []); + const TaskConstMeta(debugName: 'signature_bytes', argNames: ['scheme']); @override Uint8List crateApiCryptoSs58ToAccountId({required String s}) { @@ -1013,14 +1025,27 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return raw as int; } + @protected + DilithiumScheme dco_decode_dilithium_scheme(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return DilithiumScheme.values[raw as int]; + } + + @protected + int dco_decode_i_32(dynamic raw) { + // Codec=Dco (DartCObject based), see doc to use other codecs + return raw as int; + } + @protected Keypair dco_decode_keypair(dynamic raw) { // Codec=Dco (DartCObject based), see doc to use other codecs final arr = raw as List; - if (arr.length != 2) throw Exception('unexpected arr length: expect 2 but see ${arr.length}'); + if (arr.length != 3) throw Exception('unexpected arr length: expect 3 but see ${arr.length}'); return Keypair( publicKey: dco_decode_list_prim_u_8_strict(arr[0]), secretKey: dco_decode_list_prim_u_8_strict(arr[1]), + scheme: dco_decode_dilithium_scheme(arr[2]), ); } @@ -1211,12 +1236,26 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return (sse_decode_u_32(deserializer)); } + @protected + DilithiumScheme sse_decode_dilithium_scheme(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + var inner = sse_decode_i_32(deserializer); + return DilithiumScheme.values[inner]; + } + + @protected + int sse_decode_i_32(SseDeserializer deserializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + return deserializer.buffer.getInt32(); + } + @protected Keypair sse_decode_keypair(SseDeserializer deserializer) { // Codec=Sse (Serialization based), see doc to use other codecs var var_publicKey = sse_decode_list_prim_u_8_strict(deserializer); var var_secretKey = sse_decode_list_prim_u_8_strict(deserializer); - return Keypair(publicKey: var_publicKey, secretKey: var_secretKey); + var var_scheme = sse_decode_dilithium_scheme(deserializer); + return Keypair(publicKey: var_publicKey, secretKey: var_secretKey, scheme: var_scheme); } @protected @@ -1391,12 +1430,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { return WormholeResult(address: var_address, firstHash: var_firstHash, secret: var_secret); } - @protected - int sse_decode_i_32(SseDeserializer deserializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - return deserializer.buffer.getInt32(); - } - @protected void sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerHDLatticeError( HdLatticeError self, @@ -1445,11 +1478,24 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_u_32(self, serializer); } + @protected + void sse_encode_dilithium_scheme(DilithiumScheme self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + sse_encode_i_32(self.index, serializer); + } + + @protected + void sse_encode_i_32(int self, SseSerializer serializer) { + // Codec=Sse (Serialization based), see doc to use other codecs + serializer.buffer.putInt32(self); + } + @protected void sse_encode_keypair(Keypair self, SseSerializer serializer) { // Codec=Sse (Serialization based), see doc to use other codecs sse_encode_list_prim_u_8_strict(self.publicKey, serializer); sse_encode_list_prim_u_8_strict(self.secretKey, serializer); + sse_encode_dilithium_scheme(self.scheme, serializer); } @protected @@ -1590,12 +1636,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi { sse_encode_list_prim_u_8_strict(self.firstHash, serializer); sse_encode_list_prim_u_8_strict(self.secret, serializer); } - - @protected - void sse_encode_i_32(int self, SseSerializer serializer) { - // Codec=Sse (Serialization based), see doc to use other codecs - serializer.buffer.putInt32(self); - } } @sealed diff --git a/quantus_sdk/lib/src/rust/frb_generated.io.dart b/quantus_sdk/lib/src/rust/frb_generated.io.dart index 130c3e175..c8cd57473 100644 --- a/quantus_sdk/lib/src/rust/frb_generated.io.dart +++ b/quantus_sdk/lib/src/rust/frb_generated.io.dart @@ -47,6 +47,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected int dco_decode_box_autoadd_u_32(dynamic raw); + @protected + DilithiumScheme dco_decode_dilithium_scheme(dynamic raw); + + @protected + int dco_decode_i_32(dynamic raw); + @protected Keypair dco_decode_keypair(dynamic raw); @@ -126,6 +132,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected int sse_decode_box_autoadd_u_32(SseDeserializer deserializer); + @protected + DilithiumScheme sse_decode_dilithium_scheme(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + @protected Keypair sse_decode_keypair(SseDeserializer deserializer); @@ -180,9 +192,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected WormholeResult sse_decode_wormhole_result(SseDeserializer deserializer); - @protected - int sse_decode_i_32(SseDeserializer deserializer); - @protected void sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerHDLatticeError( HdLatticeError self, @@ -210,6 +219,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_box_autoadd_u_32(int self, SseSerializer serializer); + @protected + void sse_encode_dilithium_scheme(DilithiumScheme self, SseSerializer serializer); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); + @protected void sse_encode_keypair(Keypair self, SseSerializer serializer); @@ -263,9 +278,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_wormhole_result(WormholeResult self, SseSerializer serializer); - - @protected - void sse_encode_i_32(int self, SseSerializer serializer); } // Section: wire_class diff --git a/quantus_sdk/lib/src/rust/frb_generated.web.dart b/quantus_sdk/lib/src/rust/frb_generated.web.dart index 980032129..9dbc5b342 100644 --- a/quantus_sdk/lib/src/rust/frb_generated.web.dart +++ b/quantus_sdk/lib/src/rust/frb_generated.web.dart @@ -49,6 +49,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected int dco_decode_box_autoadd_u_32(dynamic raw); + @protected + DilithiumScheme dco_decode_dilithium_scheme(dynamic raw); + + @protected + int dco_decode_i_32(dynamic raw); + @protected Keypair dco_decode_keypair(dynamic raw); @@ -128,6 +134,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected int sse_decode_box_autoadd_u_32(SseDeserializer deserializer); + @protected + DilithiumScheme sse_decode_dilithium_scheme(SseDeserializer deserializer); + + @protected + int sse_decode_i_32(SseDeserializer deserializer); + @protected Keypair sse_decode_keypair(SseDeserializer deserializer); @@ -182,9 +194,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected WormholeResult sse_decode_wormhole_result(SseDeserializer deserializer); - @protected - int sse_decode_i_32(SseDeserializer deserializer); - @protected void sse_encode_Auto_Owned_RustOpaque_flutter_rust_bridgefor_generatedRustAutoOpaqueInnerHDLatticeError( HdLatticeError self, @@ -212,6 +221,12 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_box_autoadd_u_32(int self, SseSerializer serializer); + @protected + void sse_encode_dilithium_scheme(DilithiumScheme self, SseSerializer serializer); + + @protected + void sse_encode_i_32(int self, SseSerializer serializer); + @protected void sse_encode_keypair(Keypair self, SseSerializer serializer); @@ -265,9 +280,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl { @protected void sse_encode_wormhole_result(WormholeResult self, SseSerializer serializer); - - @protected - void sse_encode_i_32(int self, SseSerializer serializer); } // Section: wire_class diff --git a/quantus_sdk/lib/src/services/account_discovery_service.dart b/quantus_sdk/lib/src/services/account_discovery_service.dart index fa7bc89ae..b619bc719 100644 --- a/quantus_sdk/lib/src/services/account_discovery_service.dart +++ b/quantus_sdk/lib/src/services/account_discovery_service.dart @@ -15,22 +15,43 @@ class AccountDiscoveryService { } '''; - /// Discovers on-chain HD accounts using the BIP-44 gap-limit algorithm: - /// scan HD indices in batches and keep going as long as accounts exist, - /// stopping once [gapLimit] consecutive indices have no on-chain account. + /// Discovers on-chain HD accounts of every signature scheme using the BIP-44 + /// gap-limit algorithm per scheme: scan HD indices in batches and keep going + /// as long as accounts exist, stopping once [gapLimit] consecutive indices + /// have no on-chain account. Current-scheme accounts come first, by index. Future> discoverAccounts({ required String mnemonic, required int walletIndex, int gapLimit = 20, + }) async { + final perScheme = await Future.wait([ + for (final scheme in [DilithiumSchemeExtension.current, DilithiumSchemeExtension.legacy]) + _discoverScheme(mnemonic: mnemonic, walletIndex: walletIndex, scheme: scheme, gapLimit: gapLimit), + ]); + return perScheme.expand((accounts) => accounts).toList(); + } + + Future> _discoverScheme({ + required String mnemonic, + required int walletIndex, + required DilithiumScheme scheme, + required int gapLimit, }) async { final addressByIndex = {}; final used = await discoverUsedIndices( - addressAt: (i) => addressByIndex[i] ??= _hdWalletService.keyPairAtIndex(mnemonic, i).ss58Address, + addressAt: (i) => addressByIndex[i] ??= _hdWalletService.keyPairAtIndex(mnemonic, i, scheme).ss58Address, gapLimit: gapLimit, ); return [ for (final i in used.toList()..sort()) - Account(walletIndex: walletIndex, index: i, name: 'Account ${i + 1}', accountId: addressByIndex[i]!), + Account( + walletIndex: walletIndex, + index: i, + name: 'Account ${i + 1}', + accountId: addressByIndex[i]!, + scheme: scheme, + derivationPath: HdWalletService.pathForIndex(i, scheme), + ), ]; } diff --git a/quantus_sdk/lib/src/services/accounts_service.dart b/quantus_sdk/lib/src/services/accounts_service.dart index 748c2c956..bc615114d 100644 --- a/quantus_sdk/lib/src/services/accounts_service.dart +++ b/quantus_sdk/lib/src/services/accounts_service.dart @@ -18,20 +18,30 @@ class AccountsService { final SettingsService _settingsService = SettingsService(); void Function()? onAccountsChanged; + /// Scheme new accounts of [walletIndex] use: the current scheme once the + /// wallet holds any account of it, otherwise the legacy one, so pre-existing + /// wallets stay uniform. + static DilithiumScheme walletScheme(Iterable accounts, int walletIndex) => + accounts.any((a) => a.walletIndex == walletIndex && a.scheme == DilithiumSchemeExtension.current) + ? DilithiumSchemeExtension.current + : DilithiumSchemeExtension.legacy; + Future createNewAccount({required int walletIndex}) async { final mnemonic = await _settingsService.getMnemonic(walletIndex); if (mnemonic == null) { throw Exception('Mnemonic not found. Cannot create new account.'); } - final nextIndex = await _settingsService.getNextFreeAccountIndex(walletIndex); - final keypair = HdWalletService().keyPairAtIndex(mnemonic, nextIndex); - final newAccount = Account( + final accounts = await getAccounts(); + final scheme = walletScheme(accounts, walletIndex); + final nextIndex = await _settingsService.getNextFreeAccountIndex(walletIndex, scheme: scheme); + final path = HdWalletService.pathForIndex(nextIndex, scheme); + return Account.derived( walletIndex: walletIndex, index: nextIndex, - name: 'Account ${nextIndex + 1}', // Default name - accountId: keypair.ss58Address, + name: 'Account ${accounts.length + 1}', + keypair: HdWalletService().keyPairAtPath(mnemonic, path, scheme), + derivationPath: path, ); - return newAccount; } Future createEncryptedAccount({required int walletIndex, required String name}) async { diff --git a/quantus_sdk/lib/src/services/balances_service.dart b/quantus_sdk/lib/src/services/balances_service.dart index cd5783fd3..edb93a55c 100644 --- a/quantus_sdk/lib/src/services/balances_service.dart +++ b/quantus_sdk/lib/src/services/balances_service.dart @@ -30,8 +30,8 @@ class BalancesService { /// Fee of a transfer of [amount], computed locally: base and length fee from /// the shipped metadata, [dispatchWeight] from [transferDispatchWeight]. /// Only the compact-encoded amount varies the length. - BigInt transferFee(BigInt amount, {required BigInt dispatchWeight}) => inclusionFee( - length: _substrateService.signedExtrinsicLength(_transferCall(_anyDest, amount)), + BigInt transferFee(BigInt amount, {required BigInt dispatchWeight, required DilithiumScheme scheme}) => inclusionFee( + length: _substrateService.signedExtrinsicLength(_transferCall(_anyDest, amount), scheme), dispatchWeight: dispatchWeight, ); diff --git a/quantus_sdk/lib/src/services/hd_wallet_service.dart b/quantus_sdk/lib/src/services/hd_wallet_service.dart index 17e0b7131..cc39049b8 100644 --- a/quantus_sdk/lib/src/services/hd_wallet_service.dart +++ b/quantus_sdk/lib/src/services/hd_wallet_service.dart @@ -50,33 +50,28 @@ class HdWalletService { static bool isDevAccount(String mnemonic) => kDebugMode && _devAccounts.containsKey(mnemonic); - Keypair _deriveHDWallet({required String mnemonic, int account = 0, int change = 0, int addressIndex = 0}) { - return crypto.generateDerivedKeypair(mnemonicStr: mnemonic, path: pathForIndex(account, change, addressIndex)); - } - - /// The transparent-account derivation path this wallet uses. - static String pathForIndex(int account, [int change = 0, int addressIndex = 0]) => - "m/44'/189189'/$account'/$change'/$addressIndex'"; + /// Transparent-account path. The account level carries the index; the trailing + /// address index carries the scheme (`0'` for ML-DSA-87, `1'` for ML-DSA-65), as in quantus-cli. + static String pathForIndex(int account, DilithiumScheme scheme) => + "m/44'/189189'/$account'/0'/${scheme.derivationAddressIndex}'"; static final RegExp _pathPattern = RegExp(r"^m(/\d+'?)+$"); static bool isValidPath(String path) => _pathPattern.hasMatch(path.trim()); - Keypair keyPairAtPath(String mnemonic, String path) { - final trimmed = path.trim(); - if (!isValidPath(trimmed)) throw FormatException('Not a derivation path: $path'); - return crypto.generateDerivedKeypair(mnemonicStr: mnemonic, path: trimmed); - } - - Keypair keyPairAtIndex(String mnemonic, int index) { + Keypair keyPairAtPath(String mnemonic, String path, DilithiumScheme scheme) { if (kDebugMode) { final devKeypair = _devAccounts[mnemonic]; if (devKeypair != null) return devKeypair(); } - if (index == -1) return crypto.generateKeypair(mnemonicStr: mnemonic); - return _deriveHDWallet(mnemonic: mnemonic, account: index); + final trimmed = path.trim(); + if (!isValidPath(trimmed)) throw FormatException('Not a derivation path: $path'); + return crypto.generateDerivedKeypair(mnemonicStr: mnemonic, path: trimmed, scheme: scheme); } + Keypair keyPairAtIndex(String mnemonic, int index, DilithiumScheme scheme) => + keyPairAtPath(mnemonic, pathForIndex(index, scheme), scheme); + crypto.WormholeResult _deriveWormhole(String mnemonic, {int account = 0, int change = 0, int addressIndex = 0}) { final path = "m/44'/189189189'/$account'/$change'/$addressIndex'"; return crypto.deriveWormhole(mnemonicStr: mnemonic, path: path); diff --git a/quantus_sdk/lib/src/services/settings_service.dart b/quantus_sdk/lib/src/services/settings_service.dart index a1a985c67..99bb28b3d 100644 --- a/quantus_sdk/lib/src/services/settings_service.dart +++ b/quantus_sdk/lib/src/services/settings_service.dart @@ -1,9 +1,12 @@ import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:quantus_sdk/src/extensions/dilithium_scheme_extension.dart'; import 'package:quantus_sdk/src/models/account.dart'; import 'package:quantus_sdk/src/models/display_account.dart'; import 'package:quantus_sdk/src/models/multisig_account.dart'; +import 'package:quantus_sdk/src/rust/api/crypto.dart'; +import 'package:quantus_sdk/src/services/hd_wallet_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; class SettingsService { @@ -67,15 +70,21 @@ class SettingsService { final accountsJson = _prefs.getString(_accountsKey); if (accountsJson != null) { final decoded = jsonDecode(accountsJson) as List; - return decoded.map((e) => Account.fromJson(e)).toList()..sort( - (a, b) => a.walletIndex != b.walletIndex ? a.walletIndex.compareTo(b.walletIndex) : a.index.compareTo(b.index), - ); + return decoded.map((e) => Account.fromJson(e)).toList()..sort(Account.compare); } // Migration for existing single-account users final oldAccountId = _prefs.getString('account_id'); if (oldAccountId != null) { final oldWalletName = _prefs.getString('wallet_name') ?? 'Account 1'; - final account = Account(walletIndex: 0, index: 0, name: oldWalletName, accountId: oldAccountId); + const legacy = DilithiumSchemeExtension.legacy; + final account = Account( + walletIndex: 0, + index: 0, + name: oldWalletName, + accountId: oldAccountId, + scheme: legacy, + derivationPath: HdWalletService.pathForIndex(0, legacy), + ); await saveAccounts([account]); await setActiveAccount(RegularAccount(account)); // Clean up old keys after migration @@ -94,9 +103,11 @@ class SettingsService { Future addAccount(Account account) async { final accounts = await getAccounts(); - // Check for duplicates by index or accountId before adding + // Check for duplicates by derivation slot or accountId before adding if (!accounts.any( - (a) => (a.walletIndex == account.walletIndex && a.index == account.index) || a.accountId == account.accountId, + (a) => + (a.walletIndex == account.walletIndex && a.index == account.index && a.scheme == account.scheme) || + a.accountId == account.accountId, )) { accounts.add(account); await saveAccounts(accounts); @@ -221,21 +232,28 @@ class SettingsService { } } - Future getAccount({required int walletIndex, required int index}) async { + /// The wallet's first transparent account. + Future getPrimaryAccount() async { final accounts = await getAccounts(); - final ix = accounts.indexWhere((a) => a.walletIndex == walletIndex && a.index == index); - return ix != -1 ? accounts[ix] : null; + return accounts.where((a) => a.walletIndex == 0 && a.accountType != AccountType.encrypted).firstOrNull; } /// Returns the lowest non-negative derivation index not currently used by a - /// (non-encrypted) account in [walletIndex]. Filling the lowest gap first - /// keeps a wallet's accounts contiguous and deterministic: removing then - /// re-adding accounts always reproduces the same indices (and therefore the - /// same addresses) in the same order. - Future getNextFreeAccountIndex(int walletIndex) async { + /// (non-encrypted) account of [scheme] in [walletIndex]; a null [scheme] + /// considers every non-encrypted account. Filling the lowest gap first keeps + /// a wallet's accounts contiguous and deterministic: removing then re-adding + /// accounts always reproduces the same indices (and therefore the same + /// addresses) in the same order. + Future getNextFreeAccountIndex(int walletIndex, {DilithiumScheme? scheme}) async { final accounts = await getAccounts(); final used = accounts - .where((a) => a.walletIndex == walletIndex && a.index >= 0 && a.accountType != AccountType.encrypted) + .where( + (a) => + a.walletIndex == walletIndex && + a.index >= 0 && + a.accountType != AccountType.encrypted && + (scheme == null || a.scheme == scheme), + ) .map((a) => a.index) .toSet(); var index = 0; diff --git a/quantus_sdk/lib/src/services/substrate_service.dart b/quantus_sdk/lib/src/services/substrate_service.dart index d862dc9f7..87893bc4e 100644 --- a/quantus_sdk/lib/src/services/substrate_service.dart +++ b/quantus_sdk/lib/src/services/substrate_service.dart @@ -78,7 +78,9 @@ class SubstrateService { /// extension weights are not part of the metadata and change with the /// runtime, so this is the one fee input that has to be asked from chain. Future queryDispatchWeight(RuntimeCall call) async { - final info = await _paymentQueryInfo(_dummySignedExtrinsic(Uint8List(32), call.encode())); + final info = await _paymentQueryInfo( + _dummySignedExtrinsic(Uint8List(32), call.encode(), scheme: DilithiumSchemeExtension.legacy), + ); return BigInt.from((info['weight'] as Map)['ref_time'] as int); } @@ -262,10 +264,6 @@ class SubstrateService { ); } - /// Dilithium (ML-DSA-87) signature plus public key, carried by every signed - /// extrinsic. - static const int signatureWithPublicKeyBytes = 7219; - /// Largest compact nonce short of the 5-byte encoding. Sizes length /// estimates so the fee is never understated. static const int _maxCompactNonce = (1 << 30) - 1; @@ -276,6 +274,7 @@ class SubstrateService { required Uint8List signature, required int blockNumber, required int nonce, + required DilithiumScheme scheme, }) => ResonanceExtrinsicPayload( signer: signer, method: method, @@ -284,26 +283,29 @@ class SubstrateService { blockNumber: blockNumber, nonce: nonce, tip: 0, - ).encodeResonance(Registry(), ResonanceSignatureType.resonance); + ).encodeResonance(Registry(), scheme); /// Correctly sized but unsigned extrinsic, for fee probes and length math. Uint8List _dummySignedExtrinsic( Uint8List signer, Uint8List method, { + required DilithiumScheme scheme, int blockNumber = 0, int nonce = _maxCompactNonce, }) => _encodeSignedExtrinsic( signer: signer, method: method, - signature: Uint8List(signatureWithPublicKeyBytes), + signature: Uint8List(scheme.signatureWithPublicKeyBytes), blockNumber: blockNumber, nonce: nonce, + scheme: scheme, ); /// Bytes [call] occupies on chain as a signed extrinsic. Address, signature - /// and key sizes are fixed; only the compact nonce varies and is taken at - /// its 4-byte maximum. - int signedExtrinsicLength(RuntimeCall call) => _dummySignedExtrinsic(Uint8List(32), call.encode()).length; + /// and key sizes are fixed per [scheme]; only the compact nonce varies and is + /// taken at its 4-byte maximum. + int signedExtrinsicLength(RuntimeCall call, DilithiumScheme scheme) => + _dummySignedExtrinsic(Uint8List(32), call.encode(), scheme: scheme).length; Future getExtrinsicPayload(Account account, RuntimeCall call, {bool isSigned = true}) async { final ctx = await _getSigningContext(account.accountId); @@ -321,11 +323,7 @@ class SubstrateService { nonce: ctx.nonce, tip: 0, ).encode(Registry()); - final mnemonic = await account.getMnemonic(); - if (mnemonic == null) { - throw Exception('Mnemonic not found for signing.'); - } - final senderWallet = HdWalletService().keyPairAtIndex(mnemonic, account.index); + final senderWallet = await account.getKeypair(); extrinsic = _encodeSignedExtrinsic( signer: Uint8List.fromList(senderWallet.addressBytes), method: encodedCall, @@ -335,11 +333,14 @@ class SubstrateService { ), blockNumber: ctx.blockNumber, nonce: ctx.nonce, + scheme: senderWallet.scheme, ); } else { + // Keyless (hardware) accounts size the probe with the larger legacy scheme. extrinsic = _dummySignedExtrinsic( getAccountId32(account.accountId), encodedCall, + scheme: account.scheme ?? DilithiumSchemeExtension.legacy, blockNumber: ctx.blockNumber, nonce: ctx.nonce, ); @@ -367,24 +368,24 @@ class SubstrateService { return UnsignedTransactionData(payloadToSign: payloadToSign, signer: accountIdBytes, registry: Registry()); } + /// Submits [unsignedData] signed off-device. [signatureWithPublicKey] is the + /// signer's `signature ++ publicKey`; its length identifies the scheme. Future submitExtrinsicWithExternalSignature( UnsignedTransactionData unsignedData, - Uint8List signature, - Uint8List publicKey, + Uint8List signatureWithPublicKey, ) async { - final signatureWithPublicKeyBytes = _combineSignatureAndPubkey(signature, publicKey); - + final scheme = DilithiumSchemeExtension.forSignatureWithPublicKeyLength(signatureWithPublicKey.length); final payload = unsignedData.payloadToSign; final extrinsic = ResonanceExtrinsicPayload( signer: unsignedData.signer, method: payload.method, - signature: signatureWithPublicKeyBytes, + signature: signatureWithPublicKey, eraPeriod: payload.eraPeriod, blockNumber: payload.blockNumber, nonce: payload.nonce, tip: payload.tip, - ).encodeResonance(unsignedData.registry, ResonanceSignatureType.resonance); + ).encodeResonance(unsignedData.registry, scheme); return await _submitExtrinsic(extrinsic); } diff --git a/quantus_sdk/rust/Cargo.toml b/quantus_sdk/rust/Cargo.toml index 7518aa82b..d661db037 100644 --- a/quantus_sdk/rust/Cargo.toml +++ b/quantus_sdk/rust/Cargo.toml @@ -9,8 +9,8 @@ crate-type = ["cdylib", "staticlib", "rlib"] [dependencies] # NOTE: Quantus chain dependencies. qp-poseidon-core = "3.1.0" -qp-rusty-crystals-dilithium = { version = "4.1.1", default-features = false } -qp-rusty-crystals-hdwallet = { version = "4.1.1" } +qp-rusty-crystals-dilithium = { version = "4.1.1", default-features = false, features = ["ml-dsa-65", "ml-dsa-87"] } +qp-rusty-crystals-hdwallet = { version = "4.1.1", features = ["ml-dsa-65"] } flutter_rust_bridge = "=2.12.0" hex = "0.4.3" diff --git a/quantus_sdk/rust/src/api/crypto.rs b/quantus_sdk/rust/src/api/crypto.rs index 1dd8491bb..2f5867697 100644 --- a/quantus_sdk/rust/src/api/crypto.rs +++ b/quantus_sdk/rust/src/api/crypto.rs @@ -1,20 +1,47 @@ use crate::signing_context; use qp_poseidon_core::{hash_bytes, hash_to_bytes, serialization::bytes_to_digest}; -use qp_rusty_crystals_dilithium::ml_dsa_87; pub use qp_rusty_crystals_hdwallet::HDLatticeError; use qp_rusty_crystals_hdwallet::{ - derive_key_from_mnemonic, derive_wormhole_from_mnemonic, mnemonic_to_seed, SensitiveBytes32, - SensitiveBytes64, + derive_wormhole_from_mnemonic, mnemonic_to_seed, SensitiveBytes32, SensitiveBytes64, }; use sp_core::crypto::{AccountId32, Ss58Codec}; use std::convert::AsRef; -type MlDsaKeypair = ml_dsa_87::Keypair; - /// SS58 network prefix of the Quantus chain. Must match the chain runtime /// (`Ss58AddressFormat::custom(189)`) and `AppConstants.ss58prefix` in Dart. const QUANTUS_SS58_PREFIX: u16 = 189; +/// ML-DSA parameter set of a keypair. Mirrors quantus-cli's `DilithiumScheme`; +/// accounts stored before this existed are ML-DSA-87. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DilithiumScheme { + MlDsa65, + MlDsa87, +} + +/// Runs `$body` with `$dsa` and `$hd` bound to the `ml_dsa_65` or `ml_dsa_87` +/// modules of the dilithium and hdwallet crates selected by `$scheme`. +macro_rules! dispatch { + ($scheme:expr, $dsa:ident, $hd:ident, $body:block) => { + match $scheme { + DilithiumScheme::MlDsa65 => { + #[allow(unused_imports)] + use qp_rusty_crystals_dilithium::ml_dsa_65 as $dsa; + #[allow(unused_imports)] + use qp_rusty_crystals_hdwallet::ml_dsa_65 as $hd; + $body + } + DilithiumScheme::MlDsa87 => { + #[allow(unused_imports)] + use qp_rusty_crystals_dilithium::ml_dsa_87 as $dsa; + #[allow(unused_imports)] + use qp_rusty_crystals_hdwallet::ml_dsa_87 as $hd; + $body + } + } + }; +} + #[flutter_rust_bridge::frb(sync)] pub fn set_default_ss58_prefix(prefix: u16) { sp_core::crypto::set_default_ss58_version(sp_core::crypto::Ss58AddressFormat::custom(prefix)); @@ -24,23 +51,26 @@ pub fn set_default_ss58_prefix(prefix: u16) { pub struct Keypair { pub public_key: Vec, pub secret_key: Vec, + pub scheme: DilithiumScheme, } impl Keypair { - fn from_ml_dsa(ml_dsa_keypair: MlDsaKeypair) -> Self { + fn new(scheme: DilithiumScheme, public_key: impl AsRef<[u8]>, secret_key: impl AsRef<[u8]>) -> Self { Keypair { - public_key: ml_dsa_keypair.public().to_bytes().to_vec(), - secret_key: ml_dsa_keypair.secret().to_bytes().to_vec(), + public_key: public_key.as_ref().to_vec(), + secret_key: secret_key.as_ref().to_vec(), + scheme, } } +} - fn to_ml_dsa(&self) -> MlDsaKeypair { - let secret = - ml_dsa_87::SecretKey::from_bytes(&self.secret_key).expect("Failed to parse secret key"); - let public = - ml_dsa_87::PublicKey::from_bytes(&self.public_key).expect("Failed to parse public key"); - MlDsaKeypair::from_parts(secret, public).expect("Keypair halves do not correspond") - } +fn ml_dsa_87_from_entropy(entropy: &mut SensitiveBytes32) -> Keypair { + let keypair = qp_rusty_crystals_dilithium::ml_dsa_87::Keypair::generate(entropy); + Keypair::new( + DilithiumScheme::MlDsa87, + keypair.public().to_bytes(), + keypair.secret().to_bytes(), + ) } /// Convert public key to accountId32 in ss58check format @@ -67,6 +97,7 @@ pub fn ss58_to_account_id(s: &str) -> Result, String> { Ok(AsRef::<[u8]>::as_ref(&account).to_vec()) } +/// Legacy non-HD ML-DSA-87 keypair straight from the mnemonic seed (early CLI and miner accounts). #[flutter_rust_bridge::frb(sync)] pub fn generate_keypair(mnemonic_str: String) -> Result { let mut seed64 = SensitiveBytes64::zeroed(); @@ -75,16 +106,23 @@ pub fn generate_keypair(mnemonic_str: String) -> Result entropy .as_mut_bytes() .copy_from_slice(&seed64.as_bytes()[..32]); - let ml_dsa_keypair = MlDsaKeypair::generate(&mut entropy); - Ok(Keypair::from_ml_dsa(ml_dsa_keypair)) + Ok(ml_dsa_87_from_entropy(&mut entropy)) } #[flutter_rust_bridge::frb(sync)] pub fn generate_derived_keypair( mnemonic_str: String, path: &str, + scheme: DilithiumScheme, ) -> Result { - derive_key_from_mnemonic(&mnemonic_str, None, path).map(Keypair::from_ml_dsa) + dispatch!(scheme, dsa, hd, { + let keypair = hd::derive_key_from_mnemonic(&mnemonic_str, None, path)?; + Ok(Keypair::new( + scheme, + keypair.public().to_bytes(), + keypair.secret().to_bytes(), + )) + }) } #[flutter_rust_bridge::frb(sync)] @@ -126,12 +164,12 @@ pub fn first_hash_to_address(first_hash_hex: String) -> Result { Ok(account.to_ss58check()) } +/// ML-DSA-87 keypair from a raw 32-byte seed (dev accounts). #[flutter_rust_bridge::frb(sync)] pub fn generate_keypair_from_seed(seed: Vec) -> Keypair { let mut seed_array: [u8; 32] = seed.try_into().expect("Seed must be 32 bytes"); let mut entropy = SensitiveBytes32::new(&mut seed_array); - let ml_dsa_keypair = MlDsaKeypair::generate(&mut entropy); - Keypair::from_ml_dsa(ml_dsa_keypair) + ml_dsa_87_from_entropy(&mut entropy) } /// Signs `message`. Spec 148+ uses [`signing_context::EXTRINSIC`]; earlier specs use none. @@ -142,17 +180,21 @@ pub fn sign_message( entropy: Option<[u8; 32]>, spec_version: u32, ) -> Vec { - let ml_dsa_keypair = keypair.to_ml_dsa(); let mut entropy = entropy; let hedge = entropy.as_mut().map(SensitiveBytes32::new); - let signature = ml_dsa_keypair - .sign( - message, - signing_context::context_for_spec(spec_version), - hedge.as_ref(), - ) - .expect("Signing failed"); - signature.to_vec() + let context = signing_context::context_for_spec(spec_version); + dispatch!(keypair.scheme, dsa, hd, { + let secret = + dsa::SecretKey::from_bytes(&keypair.secret_key).expect("Failed to parse secret key"); + let public = + dsa::PublicKey::from_bytes(&keypair.public_key).expect("Failed to parse public key"); + let ml_dsa_keypair = + dsa::Keypair::from_parts(secret, public).expect("Keypair halves do not correspond"); + ml_dsa_keypair + .sign(message, context, hedge.as_ref()) + .expect("Signing failed") + .to_vec() + }) } #[flutter_rust_bridge::frb(sync)] @@ -177,12 +219,12 @@ pub fn verify_message( signature: &[u8], spec_version: u32, ) -> bool { - let ml_dsa_keypair = keypair.to_ml_dsa(); - ml_dsa_keypair.verify( - &message, - &signature, - signing_context::context_for_spec(spec_version), - ) + let context = signing_context::context_for_spec(spec_version); + dispatch!(keypair.scheme, dsa, hd, { + let public = + dsa::PublicKey::from_bytes(&keypair.public_key).expect("Failed to parse public key"); + public.verify(message, signature, context) + }) } #[flutter_rust_bridge::frb(sync)] @@ -201,18 +243,18 @@ pub fn crystal_charlie() -> Keypair { } #[flutter_rust_bridge::frb(sync)] -pub fn public_key_bytes() -> usize { - ml_dsa_87::PUBLICKEYBYTES +pub fn public_key_bytes(scheme: DilithiumScheme) -> u32 { + dispatch!(scheme, dsa, hd, { dsa::PUBLICKEYBYTES as u32 }) } #[flutter_rust_bridge::frb(sync)] -pub fn secret_key_bytes() -> usize { - ml_dsa_87::SECRETKEYBYTES +pub fn secret_key_bytes(scheme: DilithiumScheme) -> u32 { + dispatch!(scheme, dsa, hd, { dsa::SECRETKEYBYTES as u32 }) } #[flutter_rust_bridge::frb(sync)] -pub fn signature_bytes() -> usize { - ml_dsa_87::SIGNBYTES +pub fn signature_bytes(scheme: DilithiumScheme) -> u32 { + dispatch!(scheme, dsa, hd, { dsa::SIGNBYTES as u32 }) } #[flutter_rust_bridge::frb(init)] @@ -224,10 +266,27 @@ pub fn init_app() { #[cfg(test)] mod tests { use super::*; + use qp_rusty_crystals_dilithium::{ml_dsa_65, ml_dsa_87}; const SPEC_WITH_CONTEXT: u32 = signing_context::EXTRINSIC_MIN_SPEC; const SPEC_WITHOUT_CONTEXT: u32 = signing_context::EXTRINSIC_MIN_SPEC - 1; + /// Shared with quantus-cli `test_known_values` and the Dart `generate_keys_test`. + const TEST_MNEMONIC: &str = "orchard answer curve patient visual flower maze noise retreat penalty cage small earth domain scan pitch bottom crunch theme club client swap slice raven"; + const PATH_87_INDEX_0: &str = "m/44'/189189'/0'/0'/0'"; + const PATH_65_INDEX_0: &str = "m/44'/189189'/0'/0'/1'"; + const KNOWN_ADDRESS_87_INDEX_0: &str = "qzm5QCox8Dp5A3oSXZZYHD8YoYgPz7enykZb6RPUropdCyN5h"; + /// `quantus wallet import --scheme ml-dsa-65` (default path) for the same mnemonic. + const KNOWN_ADDRESS_65_INDEX_0: &str = "qzoyC4eRTrexYoutXABVsf61QJZxJim3iWvayRQwEjXWgA4mw"; + + fn set_prefix() { + set_default_ss58_prefix(QUANTUS_SS58_PREFIX); + } + + fn derived(path: &str, scheme: DilithiumScheme) -> Keypair { + generate_derived_keypair(TEST_MNEMONIC.to_string(), path, scheme).expect("derive") + } + #[test] fn test_sign_and_verify() { let message = b"Hello, World!"; @@ -327,4 +386,90 @@ mod tests { let is_valid = verify_message(&keypair, message, &signature, SPEC_WITH_CONTEXT); assert!(is_valid, "Signature verification failed for long message"); } + + #[test] + fn test_legacy_constructors_are_ml_dsa_87() { + assert_eq!(crystal_alice().scheme, DilithiumScheme::MlDsa87); + assert_eq!( + generate_keypair(TEST_MNEMONIC.to_string()).unwrap().scheme, + DilithiumScheme::MlDsa87 + ); + } + + #[test] + fn test_sizes_per_scheme() { + assert_eq!(public_key_bytes(DilithiumScheme::MlDsa65), 1952); + assert_eq!(secret_key_bytes(DilithiumScheme::MlDsa65), 4032); + assert_eq!(signature_bytes(DilithiumScheme::MlDsa65), 3309); + assert_eq!(public_key_bytes(DilithiumScheme::MlDsa87), 2592); + assert_eq!(secret_key_bytes(DilithiumScheme::MlDsa87), 4896); + assert_eq!(signature_bytes(DilithiumScheme::MlDsa87), 4627); + } + + #[test] + fn test_derived_keypair_matches_scheme_sizes() { + for scheme in [DilithiumScheme::MlDsa65, DilithiumScheme::MlDsa87] { + let keypair = derived(PATH_87_INDEX_0, scheme); + assert_eq!(keypair.scheme, scheme); + assert_eq!(keypair.public_key.len(), public_key_bytes(scheme) as usize); + assert_eq!(keypair.secret_key.len(), secret_key_bytes(scheme) as usize); + let signed = sign_message_with_pubkey(&keypair, b"msg", None, SPEC_WITH_CONTEXT); + assert_eq!( + signed.len(), + (signature_bytes(scheme) + public_key_bytes(scheme)) as usize + ); + } + } + + #[test] + fn test_ml_dsa_65_sign_and_verify_with_context() { + let message = b"Hello, World!"; + let keypair = derived(PATH_65_INDEX_0, DilithiumScheme::MlDsa65); + let signature = sign_message(&keypair, message, None, SPEC_WITH_CONTEXT); + let public = ml_dsa_65::PublicKey::from_bytes(&keypair.public_key).unwrap(); + + assert_eq!(signature.len(), 3309); + assert!(public.verify(message, &signature, Some(signing_context::EXTRINSIC))); + assert!(!public.verify(message, &signature, None)); + assert!(verify_message( + &keypair, + message, + &signature, + SPEC_WITH_CONTEXT + )); + assert!(!verify_message( + &keypair, + message, + &signature, + SPEC_WITHOUT_CONTEXT + )); + + let other = derived(PATH_87_INDEX_0, DilithiumScheme::MlDsa87); + assert!(!verify_message(&other, message, &signature, SPEC_WITH_CONTEXT)); + } + + #[test] + fn test_known_ml_dsa_87_address() { + set_prefix(); + let keypair = derived(PATH_87_INDEX_0, DilithiumScheme::MlDsa87); + assert_eq!(to_account_id(&keypair), KNOWN_ADDRESS_87_INDEX_0); + } + + #[test] + fn test_known_ml_dsa_65_address_matches_cli() { + set_prefix(); + let keypair = derived(PATH_65_INDEX_0, DilithiumScheme::MlDsa65); + assert_eq!(to_account_id(&keypair), KNOWN_ADDRESS_65_INDEX_0); + } + + #[test] + fn test_schemes_never_share_an_address() { + set_prefix(); + let a87 = to_account_id(&derived(PATH_87_INDEX_0, DilithiumScheme::MlDsa87)); + let a65 = to_account_id(&derived(PATH_65_INDEX_0, DilithiumScheme::MlDsa65)); + let a65_same_path = to_account_id(&derived(PATH_87_INDEX_0, DilithiumScheme::MlDsa65)); + assert_ne!(a87, a65); + assert_ne!(a87, a65_same_path); + assert_ne!(a65, a65_same_path); + } } diff --git a/quantus_sdk/rust/src/frb_generated.rs b/quantus_sdk/rust/src/frb_generated.rs index e6799d16e..b09af1211 100644 --- a/quantus_sdk/rust/src/frb_generated.rs +++ b/quantus_sdk/rust/src/frb_generated.rs @@ -565,10 +565,14 @@ fn wire__crate__api__crypto__generate_derived_keypair_impl( flutter_rust_bridge::for_generated::SseDeserializer::new(message); let api_mnemonic_str = ::sse_decode(&mut deserializer); let api_path = ::sse_decode(&mut deserializer); + let api_scheme = ::sse_decode(&mut deserializer); deserializer.end(); transform_result_sse::<_, HDLatticeError>((move || { - let output_ok = - crate::api::crypto::generate_derived_keypair(api_mnemonic_str, &api_path)?; + let output_ok = crate::api::crypto::generate_derived_keypair( + api_mnemonic_str, + &api_path, + api_scheme, + )?; Ok(output_ok) })()) }, @@ -853,9 +857,11 @@ fn wire__crate__api__crypto__public_key_bytes_impl( }; let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_scheme = ::sse_decode(&mut deserializer); deserializer.end(); transform_result_sse::<_, ()>((move || { - let output_ok = Result::<_, ()>::Ok(crate::api::crypto::public_key_bytes())?; + let output_ok = + Result::<_, ()>::Ok(crate::api::crypto::public_key_bytes(api_scheme))?; Ok(output_ok) })()) }, @@ -882,9 +888,11 @@ fn wire__crate__api__crypto__secret_key_bytes_impl( }; let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_scheme = ::sse_decode(&mut deserializer); deserializer.end(); transform_result_sse::<_, ()>((move || { - let output_ok = Result::<_, ()>::Ok(crate::api::crypto::secret_key_bytes())?; + let output_ok = + Result::<_, ()>::Ok(crate::api::crypto::secret_key_bytes(api_scheme))?; Ok(output_ok) })()) }, @@ -1019,9 +1027,11 @@ fn wire__crate__api__crypto__signature_bytes_impl( }; let mut deserializer = flutter_rust_bridge::for_generated::SseDeserializer::new(message); + let api_scheme = ::sse_decode(&mut deserializer); deserializer.end(); transform_result_sse::<_, ()>((move || { - let output_ok = Result::<_, ()>::Ok(crate::api::crypto::signature_bytes())?; + let output_ok = + Result::<_, ()>::Ok(crate::api::crypto::signature_bytes(api_scheme))?; Ok(output_ok) })()) }, @@ -1232,14 +1242,35 @@ impl SseDecode for bool { } } +impl SseDecode for crate::api::crypto::DilithiumScheme { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + let mut inner = ::sse_decode(deserializer); + return match inner { + 0 => crate::api::crypto::DilithiumScheme::MlDsa65, + 1 => crate::api::crypto::DilithiumScheme::MlDsa87, + _ => unreachable!("Invalid variant for DilithiumScheme: {}", inner), + }; + } +} + +impl SseDecode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { + deserializer.cursor.read_i32::().unwrap() + } +} + impl SseDecode for crate::api::crypto::Keypair { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { let mut var_publicKey = >::sse_decode(deserializer); let mut var_secretKey = >::sse_decode(deserializer); + let mut var_scheme = ::sse_decode(deserializer); return crate::api::crypto::Keypair { public_key: var_publicKey, secret_key: var_secretKey, + scheme: var_scheme, }; } } @@ -1434,13 +1465,6 @@ impl SseDecode for crate::api::crypto::WormholeResult { } } -impl SseDecode for i32 { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { - deserializer.cursor.read_i32::().unwrap() - } -} - fn pde_ffi_dispatcher_primary_impl( func_id: i32, port: flutter_rust_bridge::for_generated::MessagePort, @@ -1534,12 +1558,34 @@ impl flutter_rust_bridge::IntoIntoDart> for HDLattice } } +// Codec=Dco (DartCObject based), see doc to use other codecs +impl flutter_rust_bridge::IntoDart for crate::api::crypto::DilithiumScheme { + fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { + match self { + Self::MlDsa65 => 0.into_dart(), + Self::MlDsa87 => 1.into_dart(), + _ => unreachable!(), + } + } +} +impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive + for crate::api::crypto::DilithiumScheme +{ +} +impl flutter_rust_bridge::IntoIntoDart + for crate::api::crypto::DilithiumScheme +{ + fn into_into_dart(self) -> crate::api::crypto::DilithiumScheme { + self + } +} // Codec=Dco (DartCObject based), see doc to use other codecs impl flutter_rust_bridge::IntoDart for crate::api::crypto::Keypair { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { [ self.public_key.into_into_dart().into_dart(), self.secret_key.into_into_dart().into_dart(), + self.scheme.into_into_dart().into_dart(), ] .into_dart() } @@ -1687,11 +1733,35 @@ impl SseEncode for bool { } } +impl SseEncode for crate::api::crypto::DilithiumScheme { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + ::sse_encode( + match self { + crate::api::crypto::DilithiumScheme::MlDsa65 => 0, + crate::api::crypto::DilithiumScheme::MlDsa87 => 1, + _ => { + unimplemented!(""); + } + }, + serializer, + ); + } +} + +impl SseEncode for i32 { + // Codec=Sse (Serialization based), see doc to use other codecs + fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { + serializer.cursor.write_i32::(self).unwrap(); + } +} + impl SseEncode for crate::api::crypto::Keypair { // Codec=Sse (Serialization based), see doc to use other codecs fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { >::sse_encode(self.public_key, serializer); >::sse_encode(self.secret_key, serializer); + ::sse_encode(self.scheme, serializer); } } @@ -1851,13 +1921,6 @@ impl SseEncode for crate::api::crypto::WormholeResult { } } -impl SseEncode for i32 { - // Codec=Sse (Serialization based), see doc to use other codecs - fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { - serializer.cursor.write_i32::(self).unwrap(); - } -} - #[cfg(not(target_family = "wasm"))] mod io { // This file is automatically generated, so please do not edit it. diff --git a/quantus_sdk/test/generate_keys_test.dart b/quantus_sdk/test/generate_keys_test.dart index 3a7d1decb..141fa6e6f 100644 --- a/quantus_sdk/test/generate_keys_test.dart +++ b/quantus_sdk/test/generate_keys_test.dart @@ -97,13 +97,48 @@ void main() { const knownAccountHdIndex0 = 'qzm5QCox8Dp5A3oSXZZYHD8YoYgPz7enykZb6RPUropdCyN5h'; // account index 0 const knownAccountHdIndex1 = 'qzmufPopkLKAwDmTzR5uXg8GMp5sUP48CqafJLUz3fPMSSGSh'; // account index 1 - final keyPair1 = HdWalletService().keyPairAtIndex(mnemonic1, 0); - final keyPair2 = HdWalletService().keyPairAtIndex(mnemonic1, 1); + final keyPair1 = HdWalletService().keyPairAtIndex(mnemonic1, 0, DilithiumScheme.mlDsa87); + final keyPair2 = HdWalletService().keyPairAtIndex(mnemonic1, 1, DilithiumScheme.mlDsa87); final accountId1 = toAccountId(obj: keyPair1); final accountId2 = toAccountId(obj: keyPair2); expect(accountId1, knownAccountHdIndex0); expect(accountId2, knownAccountHdIndex1); }); + + test('ML-DSA-65 known values match quantus-cli', () { + // Addresses produced by `quantus wallet import --scheme ml-dsa-65` for the + // same mnemonic. Index 0 uses the default 65 path (.../0'/0'/1'); index 1 + // uses .../1'/0'/1'. The 87 counterparts differ, proving no collision. + const mnemonic = + 'orchard answer curve patient visual flower maze noise retreat penalty cage small earth domain scan pitch bottom crunch theme club client swap slice raven'; + const knownAccount65Index0 = 'qzoyC4eRTrexYoutXABVsf61QJZxJim3iWvayRQwEjXWgA4mw'; + const knownAccount65Index1 = 'qzmTuBUzGHX7tohwjJHASSbCt64cJt6WC6j6v1SHpMTL77UyB'; + + final key0 = HdWalletService().keyPairAtIndex(mnemonic, 0, DilithiumScheme.mlDsa65); + final key1 = HdWalletService().keyPairAtIndex(mnemonic, 1, DilithiumScheme.mlDsa65); + expect(key0.scheme, DilithiumScheme.mlDsa65); + expect(toAccountId(obj: key0), knownAccount65Index0); + expect(toAccountId(obj: key1), knownAccount65Index1); + + // Same mnemonic and index, different scheme, must never share an address. + expect( + toAccountId(obj: HdWalletService().keyPairAtIndex(mnemonic, 0, DilithiumScheme.mlDsa87)), + isNot(knownAccount65Index0), + ); + }); + + test('scheme sizes match Rust and the signature-with-public-key round trips', () { + for (final scheme in DilithiumScheme.values) { + // The pure-Dart size constants must match the authoritative Rust values. + expect(scheme.signatureByteLength, signatureBytes(scheme: scheme)); + expect(scheme.publicKeyByteLength, publicKeyBytes(scheme: scheme)); + final combined = scheme.signatureWithPublicKeyBytes; + expect(combined, signatureBytes(scheme: scheme) + publicKeyBytes(scheme: scheme)); + expect(DilithiumSchemeExtension.forSignatureWithPublicKeyLength(combined), scheme); + } + expect(() => DilithiumSchemeExtension.forSignatureWithPublicKeyLength(1234), throwsFormatException); + }); + test('wormhole derivation known values', () { const mnemonic = 'orchard answer curve patient visual flower maze noise retreat penalty cage small earth domain scan pitch bottom crunch theme club client swap slice raven'; @@ -129,7 +164,7 @@ void main() { const mnemonic1 = 'human snow truck virus now jaguar wall brisk shoe craft gravity diesel'; const knownAccountId = 'qznQKhufTDfU3szAzfgCny7wMhxUN3qjEqneiRUNgC7MjSDyG'; - final keypair = HdWalletService().keyPairAtIndex(mnemonic1, 0); + final keypair = HdWalletService().keyPairAtIndex(mnemonic1, 0, DilithiumScheme.mlDsa87); final accountId = toAccountId(obj: keypair); expect(accountId, knownAccountId); @@ -150,7 +185,7 @@ void main() { test('keystone signature UR round-trips and splits into signature + pubkey', () { const mnemonic = 'human snow truck virus now jaguar wall brisk shoe craft gravity diesel'; - final keypair = HdWalletService().keyPairAtIndex(mnemonic, 0); + final keypair = HdWalletService().keyPairAtIndex(mnemonic, 0, DilithiumScheme.mlDsa87); const hexPayload = '0200007416854906f03a9dff66e3270a736c44e15970ac03a638471523a03069f276ca0700e876481755010000007400000002000000826beefbe2be72645ff376f18de745ac196dc77637436090de4174180706118e5a77ae1c95817ee664cf733fafa7baa8e6244b396a54e57a5bc414b24c52800600'; @@ -168,8 +203,8 @@ void main() { // Hot wallet decodes and splits exactly like KeystoneScanSignatureScreen. final bytes = decodeUr(urParts: parts); - final sigSize = signatureBytes().toInt(); - expect(bytes.length, sigSize + publicKeyBytes().toInt()); + final sigSize = signatureBytes(scheme: keypair.scheme); + expect(bytes.length, sigSize + publicKeyBytes(scheme: keypair.scheme)); final signature = bytes.sublist(0, sigSize); final publicKey = bytes.sublist(sigSize); diff --git a/quantus_sdk/test/models/account_scheme_test.dart b/quantus_sdk/test/models/account_scheme_test.dart new file mode 100644 index 000000000..e948afc09 --- /dev/null +++ b/quantus_sdk/test/models/account_scheme_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; + +void main() { + group('Account scheme serialization', () { + test('legacy JSON without a scheme reads as ML-DSA-87 at the legacy path', () { + final account = Account.fromJson({'walletIndex': 0, 'index': 0, 'name': 'Account 1', 'accountId': 'id'}); + expect(account.scheme, DilithiumScheme.mlDsa87); + expect(account.derivationPath, HdWalletService.pathForIndex(0, DilithiumScheme.mlDsa87)); + }); + + test('a local account round-trips its scheme and path', () { + final account = Account( + walletIndex: 1, + index: 2, + name: 'Account 3', + accountId: 'id', + scheme: DilithiumScheme.mlDsa65, + derivationPath: HdWalletService.pathForIndex(2, DilithiumScheme.mlDsa65), + ); + final restored = Account.fromJson(account.toJson()); + expect(restored.scheme, DilithiumScheme.mlDsa65); + expect(restored.derivationPath, account.derivationPath); + }); + + test('keystone and encrypted accounts carry no scheme', () { + for (final type in [AccountType.keystone, AccountType.encrypted]) { + final account = Account(walletIndex: 0, index: 0, name: 'x', accountId: 'id', accountType: type); + final restored = Account.fromJson(account.toJson()); + expect(restored.scheme, isNull); + expect(restored.derivationPath, isNull); + } + }); + + test('accounts sort current-scheme first, then by index, keyless last', () { + Account local(int i, DilithiumScheme s) => Account( + walletIndex: 0, + index: i, + name: 'a', + accountId: '${s.storageName}_$i', + scheme: s, + derivationPath: HdWalletService.pathForIndex(i, s), + ); + const keystone = Account(walletIndex: 0, index: 3, name: 'k', accountId: 'k', accountType: AccountType.keystone); + final sorted = [ + local(1, DilithiumScheme.mlDsa87), + keystone, + local(0, DilithiumScheme.mlDsa65), + local(0, DilithiumScheme.mlDsa87), + ]..sort(Account.compare); + expect(sorted.map((a) => a.accountId), ['ml-dsa-65_0', 'ml-dsa-87_0', 'ml-dsa-87_1', 'k']); + }); + }); + + group('AccountsService.walletScheme', () { + Account local(int wallet, DilithiumScheme s) => + Account(walletIndex: wallet, index: 0, name: 'a', accountId: '$wallet${s.storageName}', scheme: s); + + test('a wallet with only legacy accounts stays legacy', () { + expect(AccountsService.walletScheme([local(0, DilithiumScheme.mlDsa87)], 0), DilithiumScheme.mlDsa87); + }); + + test('a wallet holding any current-scheme account grows as current', () { + final accounts = [local(0, DilithiumScheme.mlDsa87), local(0, DilithiumScheme.mlDsa65)]; + expect(AccountsService.walletScheme(accounts, 0), DilithiumScheme.mlDsa65); + }); + + test('an empty or unrelated wallet defaults to legacy', () { + expect(AccountsService.walletScheme([local(1, DilithiumScheme.mlDsa65)], 0), DilithiumScheme.mlDsa87); + }); + }); +} diff --git a/quantus_sdk/test/services/account_discovery_scheme_test.dart b/quantus_sdk/test/services/account_discovery_scheme_test.dart new file mode 100644 index 000000000..fc83e8d39 --- /dev/null +++ b/quantus_sdk/test/services/account_discovery_scheme_test.dart @@ -0,0 +1,72 @@ +@Tags(['native']) +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Reports an index as on-chain only when its address is in [onChain], so the +/// gap-limit scan runs against a fixed set instead of the indexer. +class _FakeDiscovery extends AccountDiscoveryService { + final Set onChain; + _FakeDiscovery(super.hd, this.onChain); + + @override + Future> discoverUsedIndices({required String Function(int index) addressAt, int gapLimit = 20}) async { + final used = {}; + for (var i = 0; i < 8; i++) { + if (onChain.contains(addressAt(i))) used.add(i); + } + return used; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + SharedPreferences.setMockInitialValues({}); + await QuantusSdk.init(); + }); + + const mnemonic = + 'orchard answer curve patient visual flower maze noise retreat penalty cage small earth domain scan pitch bottom crunch theme club client swap slice raven'; + + String address(int index, DilithiumScheme scheme) => + HdWalletService().keyPairAtIndex(mnemonic, index, scheme).ss58Address; + + test('discovery scans both schemes and tags each account', () async { + final onChain = { + address(0, DilithiumScheme.mlDsa65), + address(0, DilithiumScheme.mlDsa87), + address(2, DilithiumScheme.mlDsa87), + }; + final discovered = await _FakeDiscovery( + HdWalletService(), + onChain, + ).discoverAccounts(mnemonic: mnemonic, walletIndex: 0); + + // Current scheme first (65 at index 0), then legacy by index (87 at 0 and 2). + expect(discovered.map((a) => (a.scheme, a.index)).toList(), [ + (DilithiumScheme.mlDsa65, 0), + (DilithiumScheme.mlDsa87, 0), + (DilithiumScheme.mlDsa87, 2), + ]); + for (final account in discovered) { + expect(account.accountId, address(account.index, account.scheme!)); + expect(account.derivationPath, HdWalletService.pathForIndex(account.index, account.scheme!)); + } + }); + + test('discovery finds legacy accounts even when the current-scheme root is empty', () async { + final onChain = {address(1, DilithiumScheme.mlDsa87)}; + final discovered = await _FakeDiscovery( + HdWalletService(), + onChain, + ).discoverAccounts(mnemonic: mnemonic, walletIndex: 0); + + expect(discovered, hasLength(1)); + expect(discovered.single.scheme, DilithiumScheme.mlDsa87); + expect(discovered.single.index, 1); + }); +} diff --git a/quantus_sdk/test/services/recovery_proxy_encoding_test.dart b/quantus_sdk/test/services/recovery_proxy_encoding_test.dart index b5babbb62..aa2dfbfa1 100644 --- a/quantus_sdk/test/services/recovery_proxy_encoding_test.dart +++ b/quantus_sdk/test/services/recovery_proxy_encoding_test.dart @@ -56,7 +56,11 @@ void main() { // WRONG: If we use toAccountId with the AccountId32 as publicKey, it will hash again final doubleHashedAddress = crypto.toAccountId( - obj: crypto.Keypair(publicKey: Uint8List.fromList(correctAccountIdBytes), secretKey: Uint8List(0)), + obj: crypto.Keypair( + publicKey: Uint8List.fromList(correctAccountIdBytes), + secretKey: Uint8List(0), + scheme: crypto.DilithiumScheme.mlDsa87, + ), ); // Decode to get the bytes - they should NOT match the original AccountId32 @@ -85,7 +89,11 @@ void main() { // WRONG: What the bug was doing - passing to toAccountId which hashes again final wrongAddress = crypto.toAccountId( - obj: crypto.Keypair(publicKey: Uint8List.fromList(storageReturnedAccountId), secretKey: Uint8List(0)), + obj: crypto.Keypair( + publicKey: Uint8List.fromList(storageReturnedAccountId), + secretKey: Uint8List(0), + scheme: crypto.DilithiumScheme.mlDsa87, + ), ); final wrongAddressBytes = crypto.ss58ToAccountId(s: wrongAddress); diff --git a/quantus_sdk/test/services/settings_service_test.dart b/quantus_sdk/test/services/settings_service_test.dart index 1793041dc..394191cc4 100644 --- a/quantus_sdk/test/services/settings_service_test.dart +++ b/quantus_sdk/test/services/settings_service_test.dart @@ -9,8 +9,22 @@ void main() { late SettingsService settingsService; // Accounts for testing - const account1 = Account(walletIndex: 0, index: 0, name: 'Account 1', accountId: 'id_1'); - const account2 = Account(walletIndex: 0, index: 1, name: 'Account 2', accountId: 'id_2'); + final account1 = Account( + walletIndex: 0, + index: 0, + name: 'Account 1', + accountId: 'id_1', + scheme: DilithiumSchemeExtension.current, + derivationPath: HdWalletService.pathForIndex(0, DilithiumSchemeExtension.current), + ); + final account2 = Account( + walletIndex: 0, + index: 1, + name: 'Account 2', + accountId: 'id_2', + scheme: DilithiumSchemeExtension.current, + derivationPath: HdWalletService.pathForIndex(1, DilithiumSchemeExtension.current), + ); const account3 = Account(walletIndex: 0, index: 2, name: 'Account 3', accountId: 'id_3'); setUp(() async { @@ -108,7 +122,7 @@ void main() { await settingsService.saveAccounts([account1, account2]); // Act - await settingsService.setActiveAccount(const RegularAccount(account2)); + await settingsService.setActiveAccount(RegularAccount(account2)); final activeAccount = (await settingsService.getActiveAccount())!; // Assert @@ -122,7 +136,7 @@ void main() { // Act & Assert expect( - () async => await settingsService.setActiveAccount(const RegularAccount(account2)), + () async => await settingsService.setActiveAccount(RegularAccount(account2)), throwsA(isA().having((e) => e.toString(), 'message', contains('Account index does not exist'))), ); }); @@ -157,7 +171,7 @@ void main() { // Arrange await settingsService.initialize(); await settingsService.saveAccounts([account1, account2, account3]); - await settingsService.setActiveAccount(const RegularAccount(account2)); + await settingsService.setActiveAccount(RegularAccount(account2)); // Act await settingsService.removeAccount(account2); diff --git a/quantus_sdk/test/services/transaction_fee_test.dart b/quantus_sdk/test/services/transaction_fee_test.dart index 36d417097..fdd972f9f 100644 --- a/quantus_sdk/test/services/transaction_fee_test.dart +++ b/quantus_sdk/test/services/transaction_fee_test.dart @@ -24,16 +24,23 @@ void main() { test('signed extrinsic length sizes the nonce at its 4-byte maximum', () { final dest = const multi_address.$MultiAddress().id(List.filled(32, 2)); - int length(BigInt amount) => SubstrateService().signedExtrinsicLength( + int length(BigInt amount, DilithiumScheme scheme) => SubstrateService().signedExtrinsicLength( const balances_pallet.Txs().transferAllowDeath(dest: dest, value: amount), + scheme, ); - expect(length(_tenQuan), 7303 + 3); - expect(length(BigInt.from(10).pow(10)), 7303 + 3 - 1); + // ML-DSA-87: 7219-byte signature+pubkey. ML-DSA-65: 5261, i.e. 1958 bytes shorter. + expect(length(_tenQuan, DilithiumScheme.mlDsa87), 7303 + 3); + expect(length(BigInt.from(10).pow(10), DilithiumScheme.mlDsa87), 7303 + 3 - 1); + expect(length(_tenQuan, DilithiumScheme.mlDsa65), 7303 + 3 - 1958); }); test('transfer fee is the chain fee plus the nonce headroom', () { - final fee = BalancesService().transferFee(_tenQuan, dispatchWeight: _liveDispatchWeight); + final fee = BalancesService().transferFee( + _tenQuan, + dispatchWeight: _liveDispatchWeight, + scheme: DilithiumScheme.mlDsa87, + ); expect(fee, _expectedPartialFee + lengthFeePerByte * BigInt.from(3)); }); } diff --git a/quantus_sdk/test/ur_qr_frame_test.dart b/quantus_sdk/test/ur_qr_frame_test.dart index 5e47cccf8..5cb308f57 100644 --- a/quantus_sdk/test/ur_qr_frame_test.dart +++ b/quantus_sdk/test/ur_qr_frame_test.dart @@ -16,27 +16,32 @@ void main() { // Regression: the ML-DSA signature-plus-public-key payload (7,219 bytes) at // large fragment settings used to produce UR frames longer than a version-40 // QR can hold, throwing QrInputTooLongException after signing. - test('signature payload fits QR frames at every fragment setting', () { - final payloadSize = (signatureBytes() + publicKeyBytes()).toInt(); - expect(payloadSize, 7219); - final data = List.generate(payloadSize, (i) => i % 256); - - for (var fragment = 300; fragment <= 1500; fragment += 25) { - final parts = encodeUrForQr(data: data, maxFragmentLength: fragment); - var longest = ''; - for (final part in parts) { - expect(part.length, lessThanOrEqualTo(maxUrScanPartChars), reason: 'fragment setting $fragment'); - if (part.length > longest.length) longest = part; + for (final (scheme, expectedSize) in [(DilithiumScheme.mlDsa65, 5261), (DilithiumScheme.mlDsa87, 7219)]) { + test('${scheme.storageName} signature payload fits QR frames at every fragment setting', () { + final payloadSize = signatureBytes(scheme: scheme) + publicKeyBytes(scheme: scheme); + expect(payloadSize, expectedSize); + final data = List.generate(payloadSize, (i) => i % 256); + + for (var fragment = 300; fragment <= 1500; fragment += 25) { + final parts = encodeUrForQr(data: data, maxFragmentLength: fragment); + var longest = ''; + for (final part in parts) { + expect(part.length, lessThanOrEqualTo(maxUrScanPartChars), reason: 'fragment setting $fragment'); + if (part.length > longest.length) longest = part; + } + QrCode.fromData(data: longest, errorCorrectLevel: QrErrorCorrectLevel.L); + expect(decodeUr(urParts: parts), equals(data), reason: 'fragment setting $fragment'); } - QrCode.fromData(data: longest, errorCorrectLevel: QrErrorCorrectLevel.L); - expect(decodeUr(urParts: parts), equals(data), reason: 'fragment setting $fragment'); - } - }); + }); + } // The scanners refuse frames declaring more parts than the cap, so the // smallest fragment setting a user can pick must still encode within it. test('signature payload stays within the scan cap at the smallest fragment setting', () { - final data = List.generate((signatureBytes() + publicKeyBytes()).toInt(), (i) => i % 256); + final data = List.generate( + signatureBytes(scheme: DilithiumScheme.mlDsa87) + publicKeyBytes(scheme: DilithiumScheme.mlDsa87), + (i) => i % 256, + ); final parts = encodeUrForQr(data: data, maxFragmentLength: 50); expect(parts.length, lessThanOrEqualTo(maxUrScanParts)); From 4b90a4c5dca106d6938e85897ffd61d6f31f1000 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 3 Sep 2026 13:32:23 +0800 Subject: [PATCH 2/3] review: source ML-DSA sizes from the crate, clarify scheme sort key, 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. --- cold-wallet-app/lib/models/cold_account.dart | 6 ++++-- .../screens/send/regular_send_strategy.dart | 2 +- .../patrol_test/support/send_preflight.dart | 2 +- .../dilithium_scheme_extension.dart | 19 ++++--------------- quantus_sdk/lib/src/models/account.dart | 12 ++++++++++-- .../lib/src/services/substrate_service.dart | 3 +-- quantus_sdk/rust/src/api/crypto.rs | 13 +++++++++++-- quantus_sdk/test/generate_keys_test.dart | 5 +---- .../test/services/transaction_fee_test.dart | 8 ++++++++ 9 files changed, 41 insertions(+), 29 deletions(-) diff --git a/cold-wallet-app/lib/models/cold_account.dart b/cold-wallet-app/lib/models/cold_account.dart index aade08225..19e60bd24 100644 --- a/cold-wallet-app/lib/models/cold_account.dart +++ b/cold-wallet-app/lib/models/cold_account.dart @@ -50,14 +50,16 @@ class ColdAccount { if (left != null && right != null) { final byIndex = left.compareTo(right); if (byIndex != 0) return byIndex; - return _schemeRank(a.scheme).compareTo(_schemeRank(b.scheme)); + return _schemeSortOrder(a.scheme).compareTo(_schemeSortOrder(b.scheme)); } if (left != null) return -1; if (right != null) return 1; return a.derivationPath.compareTo(b.derivationPath); } - static int _schemeRank(DilithiumScheme scheme) => scheme == DilithiumSchemeExtension.current ? 0 : 1; + /// Sort position by scheme (current first). This is an ordering key, not the + /// derivation path index (which is 0 for 87, 1 for 65). + static int _schemeSortOrder(DilithiumScheme scheme) => scheme == DilithiumSchemeExtension.current ? 0 : 1; /// Scheme new accounts of this wallet use: the current scheme once the wallet /// holds any account of it, otherwise the legacy one, so pre-existing wallets diff --git a/mobile-app/lib/v2/screens/send/regular_send_strategy.dart b/mobile-app/lib/v2/screens/send/regular_send_strategy.dart index 3a6a5d558..9e598073c 100644 --- a/mobile-app/lib/v2/screens/send/regular_send_strategy.dart +++ b/mobile-app/lib/v2/screens/send/regular_send_strategy.dart @@ -79,7 +79,7 @@ class RegularSendStrategy extends SendStrategy { @override ProviderListenable> feeProvider({required String recipient, required BigInt amount}) => - regularSendFeeProvider((amount: amount, scheme: account.scheme ?? DilithiumSchemeExtension.legacy)); + regularSendFeeProvider((amount: amount, scheme: account.feeSizingScheme)); @override void retryFee(WidgetRef ref, {required String recipient, required BigInt amount}) => diff --git a/mobile-app/patrol_test/support/send_preflight.dart b/mobile-app/patrol_test/support/send_preflight.dart index e236267c3..493090fd1 100644 --- a/mobile-app/patrol_test/support/send_preflight.dart +++ b/mobile-app/patrol_test/support/send_preflight.dart @@ -18,7 +18,7 @@ class SendPreflight { final fee = balancesService.transferFee( ed, dispatchWeight: await balancesService.transferDispatchWeight(), - scheme: account.scheme ?? DilithiumSchemeExtension.legacy, + scheme: account.feeSizingScheme, ); final required = ed + fee; diff --git a/quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart b/quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart index 7d58f9131..206faca42 100644 --- a/quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart +++ b/quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart @@ -34,21 +34,10 @@ extension DilithiumSchemeExtension on DilithiumScheme { ); } - /// Bytes of an ML-DSA signature. FIPS 204 fixed constants, cross-checked - /// against the Rust `signatureBytes` in tests. - int get signatureByteLength => switch (this) { - DilithiumScheme.mlDsa65 => 3309, - DilithiumScheme.mlDsa87 => 4627, - }; - - /// Bytes of an ML-DSA public key. - int get publicKeyByteLength => switch (this) { - DilithiumScheme.mlDsa65 => 1952, - DilithiumScheme.mlDsa87 => 2592, - }; - - /// Bytes of `signature ++ publicKey`, the payload every signed extrinsic carries. - int get signatureWithPublicKeyBytes => signatureByteLength + publicKeyByteLength; + /// Bytes of `signature ++ publicKey`, the payload every signed extrinsic + /// carries. Sourced from the rusty-crystals crate via the Rust bridge, so the + /// sizes are never duplicated in Dart. + int get signatureWithPublicKeyBytes => signatureBytes(scheme: this) + publicKeyBytes(scheme: this); /// The scheme whose `signature ++ publicKey` is [length] bytes long. static DilithiumScheme forSignatureWithPublicKeyLength(int length) => DilithiumScheme.values.firstWhere( diff --git a/quantus_sdk/lib/src/models/account.dart b/quantus_sdk/lib/src/models/account.dart index ea2ef3f2b..0b247cbce 100644 --- a/quantus_sdk/lib/src/models/account.dart +++ b/quantus_sdk/lib/src/models/account.dart @@ -103,15 +103,23 @@ class Account implements BaseAccount { ); } + /// Scheme to size a signed extrinsic's fee and length against when this + /// account is the sender. Hardware (keystone) accounts hold no local key and + /// their scheme is only known once the device signs, so the larger ML-DSA-87 + /// is used to avoid ever understating the fee. + DilithiumScheme get feeSizingScheme => scheme ?? DilithiumSchemeExtension.legacy; + /// Wallet, then scheme (current first, keyless accounts last), then derivation index. static int compare(Account a, Account b) { final w = a.walletIndex.compareTo(b.walletIndex); if (w != 0) return w; - final s = _schemeRank(a.scheme).compareTo(_schemeRank(b.scheme)); + final s = _schemeSortOrder(a.scheme).compareTo(_schemeSortOrder(b.scheme)); return s != 0 ? s : a.index.compareTo(b.index); } - static int _schemeRank(DilithiumScheme? scheme) => switch (scheme) { + /// Sort position by scheme (current first, legacy next, keyless last). This is + /// an ordering key, not the derivation path index (which is 0 for 87, 1 for 65). + static int _schemeSortOrder(DilithiumScheme? scheme) => switch (scheme) { DilithiumSchemeExtension.current => 0, DilithiumSchemeExtension.legacy => 1, null => 2, diff --git a/quantus_sdk/lib/src/services/substrate_service.dart b/quantus_sdk/lib/src/services/substrate_service.dart index 87893bc4e..63287a371 100644 --- a/quantus_sdk/lib/src/services/substrate_service.dart +++ b/quantus_sdk/lib/src/services/substrate_service.dart @@ -336,11 +336,10 @@ class SubstrateService { scheme: senderWallet.scheme, ); } else { - // Keyless (hardware) accounts size the probe with the larger legacy scheme. extrinsic = _dummySignedExtrinsic( getAccountId32(account.accountId), encodedCall, - scheme: account.scheme ?? DilithiumSchemeExtension.legacy, + scheme: account.feeSizingScheme, blockNumber: ctx.blockNumber, nonce: ctx.nonce, ); diff --git a/quantus_sdk/rust/src/api/crypto.rs b/quantus_sdk/rust/src/api/crypto.rs index 2f5867697..5b593d743 100644 --- a/quantus_sdk/rust/src/api/crypto.rs +++ b/quantus_sdk/rust/src/api/crypto.rs @@ -55,7 +55,11 @@ pub struct Keypair { } impl Keypair { - fn new(scheme: DilithiumScheme, public_key: impl AsRef<[u8]>, secret_key: impl AsRef<[u8]>) -> Self { + fn new( + scheme: DilithiumScheme, + public_key: impl AsRef<[u8]>, + secret_key: impl AsRef<[u8]>, + ) -> Self { Keypair { public_key: public_key.as_ref().to_vec(), secret_key: secret_key.as_ref().to_vec(), @@ -445,7 +449,12 @@ mod tests { )); let other = derived(PATH_87_INDEX_0, DilithiumScheme::MlDsa87); - assert!(!verify_message(&other, message, &signature, SPEC_WITH_CONTEXT)); + assert!(!verify_message( + &other, + message, + &signature, + SPEC_WITH_CONTEXT + )); } #[test] diff --git a/quantus_sdk/test/generate_keys_test.dart b/quantus_sdk/test/generate_keys_test.dart index 141fa6e6f..4fae88924 100644 --- a/quantus_sdk/test/generate_keys_test.dart +++ b/quantus_sdk/test/generate_keys_test.dart @@ -127,11 +127,8 @@ void main() { ); }); - test('scheme sizes match Rust and the signature-with-public-key round trips', () { + test('the signature-with-public-key size round trips to its scheme', () { for (final scheme in DilithiumScheme.values) { - // The pure-Dart size constants must match the authoritative Rust values. - expect(scheme.signatureByteLength, signatureBytes(scheme: scheme)); - expect(scheme.publicKeyByteLength, publicKeyBytes(scheme: scheme)); final combined = scheme.signatureWithPublicKeyBytes; expect(combined, signatureBytes(scheme: scheme) + publicKeyBytes(scheme: scheme)); expect(DilithiumSchemeExtension.forSignatureWithPublicKeyLength(combined), scheme); diff --git a/quantus_sdk/test/services/transaction_fee_test.dart b/quantus_sdk/test/services/transaction_fee_test.dart index fdd972f9f..fd3bf011e 100644 --- a/quantus_sdk/test/services/transaction_fee_test.dart +++ b/quantus_sdk/test/services/transaction_fee_test.dart @@ -1,8 +1,12 @@ +@Tags(['native']) +library; + import 'package:flutter_test/flutter_test.dart'; import 'package:quantus_sdk/generated/planck/pallets/balances.dart' as balances_pallet; import 'package:quantus_sdk/generated/planck/pallets/system.dart' as system_pallet; import 'package:quantus_sdk/generated/planck/types/sp_runtime/multiaddress/multi_address.dart' as multi_address; import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:quantus_sdk/src/rust/frb_generated.dart'; /// `payment_queryInfo` on a1-planck (spec 144) for a dummy-signed /// `transfer_allow_death` of 10 QUAN with a 1-byte nonce: 7303 bytes, @@ -17,6 +21,10 @@ final BigInt _expectedPartialFee = BigInt.from(13622025000); final BigInt _tenQuan = BigInt.from(10).pow(13); void main() { + setUpAll(() async { + await RustLib.init(); + }); + test('inclusion fee reproduces the chain fee from length and dispatch weight', () { expect(system_pallet.Constants().blockWeights.perClass.normal.baseExtrinsic.refTime, BigInt.from(767297000)); expect(inclusionFee(length: 7303, dispatchWeight: _liveDispatchWeight), _expectedPartialFee); From 9d3d17797b9b44ac9e7f4cd6b2ff2974ee73f0f6 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Thu, 3 Sep 2026 13:40:24 +0800 Subject: [PATCH 3/3] review: read the signature+pubkey size from chain metadata, not the Rust 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. --- .../extensions/dilithium_scheme_extension.dart | 15 ++++++++++++--- .../test/services/transaction_fee_test.dart | 8 -------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart b/quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart index 206faca42..49d920e0c 100644 --- a/quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart +++ b/quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart @@ -1,3 +1,5 @@ +import 'package:quantus_sdk/generated/planck/types/qp_dilithium_crypto/types/dilithium65_signature_with_public.dart'; +import 'package:quantus_sdk/generated/planck/types/qp_dilithium_crypto/types/dilithium87_signature_with_public.dart'; import 'package:quantus_sdk/src/rust/api/crypto.dart'; /// Scheme-dependent constants, in one place. Conventions match quantus-cli. @@ -35,9 +37,16 @@ extension DilithiumSchemeExtension on DilithiumScheme { } /// Bytes of `signature ++ publicKey`, the payload every signed extrinsic - /// carries. Sourced from the rusty-crystals crate via the Rust bridge, so the - /// sizes are never duplicated in Dart. - int get signatureWithPublicKeyBytes => signatureBytes(scheme: this) + publicKeyBytes(scheme: this); + /// carries. Read from the chain metadata's fixed-size codec, so it always + /// matches the wire format the runtime expects. + int get signatureWithPublicKeyBytes => switch (this) { + DilithiumScheme.mlDsa65 => Dilithium65SignatureWithPublic.codec.sizeHint( + const Dilithium65SignatureWithPublic(bytes: []), + ), + DilithiumScheme.mlDsa87 => Dilithium87SignatureWithPublic.codec.sizeHint( + const Dilithium87SignatureWithPublic(bytes: []), + ), + }; /// The scheme whose `signature ++ publicKey` is [length] bytes long. static DilithiumScheme forSignatureWithPublicKeyLength(int length) => DilithiumScheme.values.firstWhere( diff --git a/quantus_sdk/test/services/transaction_fee_test.dart b/quantus_sdk/test/services/transaction_fee_test.dart index fd3bf011e..fdd972f9f 100644 --- a/quantus_sdk/test/services/transaction_fee_test.dart +++ b/quantus_sdk/test/services/transaction_fee_test.dart @@ -1,12 +1,8 @@ -@Tags(['native']) -library; - import 'package:flutter_test/flutter_test.dart'; import 'package:quantus_sdk/generated/planck/pallets/balances.dart' as balances_pallet; import 'package:quantus_sdk/generated/planck/pallets/system.dart' as system_pallet; import 'package:quantus_sdk/generated/planck/types/sp_runtime/multiaddress/multi_address.dart' as multi_address; import 'package:quantus_sdk/quantus_sdk.dart'; -import 'package:quantus_sdk/src/rust/frb_generated.dart'; /// `payment_queryInfo` on a1-planck (spec 144) for a dummy-signed /// `transfer_allow_death` of 10 QUAN with a 1-byte nonce: 7303 bytes, @@ -21,10 +17,6 @@ final BigInt _expectedPartialFee = BigInt.from(13622025000); final BigInt _tenQuan = BigInt.from(10).pow(13); void main() { - setUpAll(() async { - await RustLib.init(); - }); - test('inclusion fee reproduces the chain fee from length and dispatch weight', () { expect(system_pallet.Constants().blockWeights.perClass.normal.baseExtrinsic.refTime, BigInt.from(767297000)); expect(inclusionFee(length: 7303, dispatchWeight: _liveDispatchWeight), _expectedPartialFee);