Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions cold-wallet-app/lib/components/derivation_field.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class DerivationField extends StatefulWidget {

class _DerivationFieldState extends State<DerivationField> {
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;

Expand All @@ -34,8 +34,9 @@ class _DerivationFieldState extends State<DerivationField> {
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);

Expand Down
89 changes: 65 additions & 24 deletions cold-wallet-app/lib/models/cold_account.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand All @@ -19,54 +22,92 @@ 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.
int? get templateIndex {
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 _schemeSortOrder(a.scheme).compareTo(_schemeSortOrder(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) {
/// 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
/// stay uniform.
static DilithiumScheme walletScheme(Iterable<ColdAccount> 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<String, dynamic> json) =>
ColdAccount(label: json['label'] as String, index: json['index'] as int?, path: json['path'] as String?);
factory ColdAccount.fromJson(Map<String, dynamic> 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<String, dynamic> toJson() => {'label': label, if (index != null) 'index': index, if (path != null) 'path': path};
Map<String, dynamic> toJson() => {
'label': label,
if (index != null) 'index': index,
if (path != null) 'path': path,
'scheme': scheme.storageName,
};
}
11 changes: 7 additions & 4 deletions cold-wallet-app/lib/providers/wallet_providers.dart
Original file line number Diff line number Diff line change
Expand Up @@ -215,26 +215,29 @@ final addressesProvider = Provider<Map<String, ColdAccount>>((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,
};
});

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.
final addressProvider = Provider<String?>((ref) => ref.watch(addressesProvider).keys.firstOrNull);

/// 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<String, String>((ref, path) async {
final derivedAddressProvider = FutureProvider.autoDispose.family<String, ({String path, DilithiumScheme scheme})>((
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
Expand Down
32 changes: 21 additions & 11 deletions cold-wallet-app/lib/screens/add_account_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,18 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
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;
}
Expand All @@ -81,17 +89,17 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
}

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;
}
Expand All @@ -111,7 +119,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
// 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;
Expand Down Expand Up @@ -275,7 +283,7 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
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,
Expand Down Expand Up @@ -306,7 +314,9 @@ class _AddAccountScreenState extends ConsumerState<AddAccountScreen> {
}

final path = account.derivationPath;
final address = _previewPath == path ? ref.watch(derivedAddressProvider(path)) : const AsyncValue<String>.loading();
final address = _previewPath == path
? ref.watch(derivedAddressProvider((path: path, scheme: account.scheme)))
: const AsyncValue<String>.loading();

return Container(
padding: const EdgeInsets.all(14),
Expand Down
2 changes: 1 addition & 1 deletion cold-wallet-app/lib/screens/create_wallet_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class _CreateWalletScreenState extends State<CreateWalletScreen> {
MaterialPageRoute(
builder: (_) => SetPasswordScreen(
mnemonic: words.join(' '),
accounts: [ColdAccount(label: 'Account 1', index: 0)],
accounts: [ColdAccount(label: 'Account 1', index: 0, scheme: DilithiumSchemeExtension.current)],
),
),
);
Expand Down
17 changes: 14 additions & 3 deletions cold-wallet-app/lib/screens/import_wallet_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class _ImportWalletScreenState extends State<ImportWalletScreen> {
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() {
Expand Down Expand Up @@ -79,14 +79,25 @@ class _ImportWalletScreenState extends State<ImportWalletScreen> {
});

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) {
Expand Down
4 changes: 2 additions & 2 deletions cold-wallet-app/lib/screens/sign_transaction_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ class _SignTransactionScreenState extends ConsumerState<SignTransactionScreen> {
});
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),
Expand Down
3 changes: 2 additions & 1 deletion cold-wallet-app/lib/services/vault_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String, dynamic>;
Expand Down
Loading
Loading