diff --git a/CHANGELOG.md b/CHANGELOG.md index 803f698..10eb56a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,33 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +## 2025-10-22 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`nekoton_repository` - `v2.0.3-dev.0`](#nekoton_repository---v203-dev0) + +--- + +#### `nekoton_repository` - `v2.0.3-dev.0` + + - **FIX**(EWM-397): fix tests & create tests for new naming logic. ([81c66a2d](https://github.com/broxus/nekoton_repository/commit/81c66a2dd6bbf2e0e78477af910cac045b910205)) + - **FIX**(EWM-397): formatting. ([5a26933b](https://github.com/broxus/nekoton_repository/commit/5a26933bb70e622259d2d71196a95430df7604ae)) + +## 2.0.3-dev.0 + + - **FIX**(EWM-397): fix tests & create tests for new naming logic. ([81c66a2d](https://github.com/broxus/nekoton_repository/commit/81c66a2dd6bbf2e0e78477af910cac045b910205)) + - **FIX**(EWM-397): formatting. ([5a26933b](https://github.com/broxus/nekoton_repository/commit/5a26933bb70e622259d2d71196a95430df7604ae)) + + ## 2025-10-20 ### Changes diff --git a/lib/src/models/account_list.dart b/lib/src/models/account_list.dart index 7589c32..21c3eb3 100644 --- a/lib/src/models/account_list.dart +++ b/lib/src/models/account_list.dart @@ -36,7 +36,8 @@ class AccountList extends Equatable { /// Add account to key with [publicKey] and [walletType]. /// [workchain] specify Transport network that should be used for this account - /// [name] is optional and if not specified, auto-generated name will be used. + /// [name] is optional and if not specified, auto-generated name will be used + /// in format "Account N.M". Future
addAccount({ required WalletType walletType, required int workchain, @@ -46,6 +47,9 @@ class AccountList extends Equatable { AccountToAdd( name: name ?? + GetIt.instance().generateDefaultAccountName( + publicKey, + ) ?? GetIt.instance().currentTransport .defaultAccountName(walletType), publicKey: publicKey, diff --git a/lib/src/nekoton_repository.dart b/lib/src/nekoton_repository.dart index bc382d9..e1266f5 100644 --- a/lib/src/nekoton_repository.dart +++ b/lib/src/nekoton_repository.dart @@ -466,6 +466,61 @@ class NekotonRepository _keyStore.keysStream.listen((keys) => _hasSeeds.add(keys.isNotEmpty)); } + /// Generates default account name in format "Account N.M" + /// where N is the key position in the seed (1-based) and M is the account + /// position within that key (1-based). + /// + /// Returns null if the publicKey is not found in any seed. + String? generateDefaultAccountName(PublicKey publicKey) { + final seed = seedList.findSeedByAnyPublicKey(publicKey); + if (seed == null) return null; + + // Find the key within the seed + final keyIndex = seed.allKeys.indexWhere( + (key) => key.publicKey == publicKey, + ); + if (keyIndex == -1) return null; + + // Key position is 1-based (master key = 1, first derived = 2, etc.) + final keyPosition = keyIndex + 1; + + // Get next account number by finding max existing number + 1 + final accountPosition = _getNextAccountNumber( + seed.allKeys[keyIndex].accountList.allAccounts, + keyPosition, + ); + + return 'Account $keyPosition.$accountPosition'; + } + + /// Gets the next available account number for a specific key position + /// by finding the maximum number in existing account names + /// (format: "Account N.M") and returning max M + 1 for the given N. + /// Returns 1 if no accounts exist or no default names are found. + int _getNextAccountNumber(List accounts, int keyPosition) { + if (accounts.isEmpty) return 1; + + var maxNumber = 0; + + for (final account in accounts) { + final name = account.name; + + // Parse "Account N.M" format + final match = RegExp(r'^Account (\d+)\.(\d+)$').firstMatch(name); + if (match != null) { + final n = int.tryParse(match.group(1) ?? ''); + final m = int.tryParse(match.group(2) ?? ''); + + // Only consider accounts for this specific key position + if (n == keyPosition && m != null && m > maxNumber) { + maxNumber = m; + } + } + } + + return maxNumber + 1; + } + void _logHandler(fnb.LogEntry logEntry) { final logLevel = _toLogLevel(logEntry.level); diff --git a/lib/src/repositories/account_repository/account_repository_impl.dart b/lib/src/repositories/account_repository/account_repository_impl.dart index 54c6393..ba2e781 100644 --- a/lib/src/repositories/account_repository/account_repository_impl.dart +++ b/lib/src/repositories/account_repository/account_repository_impl.dart @@ -1,4 +1,5 @@ import 'package:collection/collection.dart'; +import 'package:get_it/get_it.dart'; import 'package:nekoton_repository/nekoton_repository.dart'; /// Implementation of AccountRepository. @@ -63,6 +64,9 @@ mixin AccountRepositoryImpl on TransportRepository AccountToAdd( name: name ?? + GetIt.instance().generateDefaultAccountName( + publicKey, + ) ?? currentTransport.defaultAccountName( existingWalletInfo.walletType, ), diff --git a/lib/src/repositories/seed_repository/seed_repository_impl.dart b/lib/src/repositories/seed_repository/seed_repository_impl.dart index fe4b72f..d0eddc4 100644 --- a/lib/src/repositories/seed_repository/seed_repository_impl.dart +++ b/lib/src/repositories/seed_repository/seed_repository_impl.dart @@ -4,6 +4,8 @@ import 'package:get_it/get_it.dart'; import 'package:nekoton_repository/nekoton_repository.dart'; import 'package:rxdart/rxdart.dart'; +const seedPrefix = 'Seed '; + /// Implementation of SeedRepository. /// Usage /// ``` @@ -159,7 +161,11 @@ mixin SeedKeyRepositoryImpl implements SeedKeyRepository { String? name, SeedAddType addType = SeedAddType.create, }) async { - name = name?.isEmpty ?? true ? null : name; + // Generate default seed name if not provided + if (name?.isEmpty ?? true) { + name = '$seedPrefix${_getNextSeedNumber()}'; + } + mnemonicType ??= phrase.length == 24 ? const MnemonicType.legacy() : const MnemonicType.bip39( @@ -239,6 +245,11 @@ mixin SeedKeyRepositoryImpl implements SeedKeyRepository { required int workchainId, String? name, }) async { + // Generate default seed name if not provided + if (name?.isEmpty ?? true) { + name = '$seedPrefix${_getNextSeedNumber()}'; + } + final publicKey = await keyStore.addKey( LedgerKeyCreateInput(accountId: accountId, name: name), ); @@ -266,8 +277,16 @@ mixin SeedKeyRepositoryImpl implements SeedKeyRepository { final transport = currentTransport; if (transport.transport.disposed) return; + // For newly created seeds, this will be the first account on master key + // At this point the seed might not be in seedList yet, so we use fallback + final accountName = + GetIt.instance().generateDefaultAccountName( + publicKey, + ) ?? + transport.defaultAccountName(transport.defaultWalletType); + final defaultAccount = AccountToAdd( - name: transport.defaultAccountName(transport.defaultWalletType), + name: accountName, publicKey: publicKey, contract: transport.defaultWalletType, workchain: workchainId, @@ -356,7 +375,10 @@ mixin SeedKeyRepositoryImpl implements SeedKeyRepository { publicKey: a.publicKey, contract: a.walletType, workchain: a.address.workchain, - name: transport.defaultAccountName(a.walletType), + name: + GetIt.instance() + .generateDefaultAccountName(a.publicKey) ?? + transport.defaultAccountName(a.walletType), ), ), ); @@ -434,7 +456,10 @@ mixin SeedKeyRepositoryImpl implements SeedKeyRepository { if (activeAccounts.isEmpty) { accountsToAdd.add( AccountToAdd( - name: transport.defaultAccountName(transport.defaultWalletType), + name: + GetIt.instance() + .generateDefaultAccountName(key) ?? + transport.defaultAccountName(transport.defaultWalletType), publicKey: key, contract: transport.defaultWalletType, workchain: workchainId, @@ -451,7 +476,10 @@ mixin SeedKeyRepositoryImpl implements SeedKeyRepository { publicKey: a.publicKey, contract: a.walletType, workchain: a.address.workchain, - name: transport.defaultAccountName(a.walletType), + name: + GetIt.instance() + .generateDefaultAccountName(a.publicKey) ?? + transport.defaultAccountName(a.walletType), ), ), ); @@ -659,4 +687,30 @@ mixin SeedKeyRepositoryImpl implements SeedKeyRepository { } await GetIt.instance().removeAccounts(accountsToRemove); } + + /// Gets the next available seed number by finding the maximum number + /// in existing seed names (format: "Seed N") and returning max + 1. + /// Returns 1 if no seeds exist or no default names are found. + int _getNextSeedNumber() { + final seedMeta = storageRepository.seedMeta; + if (seedMeta.isEmpty) return 1; + + var maxNumber = 0; + + for (final metadata in seedMeta.values) { + final name = metadata.name; + if (name == null) continue; + + // Parse "Seed " format + final match = RegExp('^$seedPrefix(\\d+)\$').firstMatch(name); + if (match != null) { + final number = int.tryParse(match.group(1) ?? ''); + if (number != null && number > maxNumber) { + maxNumber = number; + } + } + } + + return maxNumber + 1; + } } diff --git a/pubspec.yaml b/pubspec.yaml index f9dc8e1..1efdebc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ --- name: nekoton_repository description: Nekoton repository package -version: 2.0.1 +version: 2.0.3-dev.0 repository: https://github.com/broxus/nekoton_repository environment: @@ -49,8 +49,7 @@ flutter_gen: package_parameter_enabled: true flutter: - assets: - - assets/abi/ + assets: [assets/abi/] melos: useRootAsPackage: true @@ -61,10 +60,12 @@ melos: codegen: description: Generate code for all packages - exec: "find . -type f -name \"*.gen.dart\" -delete && flutter packages pub run build_runner build --delete-conflicting-outputs && dart format lib/generated/assets.gen.dart lib/nekoton_repository.module.dart" + exec: find . -type f -name "*.gen.dart" -delete && flutter packages pub run + build_runner build --delete-conflicting-outputs && dart format lib/generated/assets.gen.dart + lib/nekoton_repository.module.dart failFast: true packageFilters: - dependsOn: "build_runner" + dependsOn: build_runner analyze: description: Analyze a specific package in this project. @@ -82,7 +83,7 @@ melos: test: description: Run Flutter tests for a specific package in this project. - exec: "flutter test test" + exec: flutter test test failFast: true packageFilters: flutter: true @@ -90,7 +91,8 @@ melos: test:integration: run: melos exec -c 1 --fail-fast -- "flutter test integration_test" - description: Run Flutter teintegration teststs for a specific package in this project. + description: Run Flutter teintegration teststs for a specific package in this + project. packageFilters: flutter: true dirExists: integration_test @@ -112,4 +114,3 @@ melos: version: hooks: preCommit: melos bs && git add --all - diff --git a/test/src/nekoton_repository_account_naming_test.dart b/test/src/nekoton_repository_account_naming_test.dart new file mode 100644 index 0000000..9fa3d3f --- /dev/null +++ b/test/src/nekoton_repository_account_naming_test.dart @@ -0,0 +1,361 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:nekoton_repository/nekoton_repository.dart'; +import 'package:rxdart/rxdart.dart'; + +class MockBridge extends Mock implements NekotonBridgeApi {} + +/// Testable version of NekotonRepository that allows setting seed list directly +class TestableNekotonRepository extends NekotonRepository { + final _testSeedListSubject = BehaviorSubject(); + + void setSeedList(SeedList seedList) { + _testSeedListSubject.add(seedList); + } + + @override + SeedList get seedList => _testSeedListSubject.hasValue + ? _testSeedListSubject.value + : SeedList( + seedMeta: const {}, + allKeys: const [], + mappedAccounts: const {}, + ); + + @override + Future dispose() async { + await _testSeedListSubject.close(); + await super.dispose(); + } +} + +void main() { + setUpAll(() { + NekotonBridge.initMock(api: MockBridge()); + }); + + group('NekotonRepository.generateDefaultAccountName', () { + test('first account on master key gets "Account 1.1"', () { + // Arrange + final repository = TestableNekotonRepository(); + const masterKey = PublicKey(publicKey: 'masterkey1'); + final masterKeyEntry = KeyStoreEntry( + name: 'MasterKey', + masterKey: masterKey, + accountId: 0, + publicKey: masterKey, + signerName: const KeySigner.derived().name, + ); + + final seedList = SeedList( + seedMeta: {masterKey: const SeedMetadata(name: '${seedPrefix}1')}, + allKeys: [masterKeyEntry], + mappedAccounts: const {}, + ); + + repository.setSeedList(seedList); + + // Act + final accountName = repository.generateDefaultAccountName(masterKey); + + // Assert + expect(accountName, equals('Account 1.1')); + }); + + test('sequential accounts get incremented numbers', () { + // Arrange + final repository = TestableNekotonRepository(); + const masterKey = PublicKey(publicKey: 'masterkey1'); + final masterKeyEntry = KeyStoreEntry( + name: 'MasterKey', + masterKey: masterKey, + accountId: 0, + publicKey: masterKey, + signerName: const KeySigner.derived().name, + ); + + const account1 = KeyAccount( + account: AssetsList( + name: 'Account 1.1', + additionalAssets: {}, + tonWallet: TonWalletAsset( + publicKey: masterKey, + address: Address(address: '0:account1'), + contract: WalletType.everWallet(), + ), + ), + publicKey: masterKey, + isExternal: false, + isHidden: false, + ); + + const account2 = KeyAccount( + account: AssetsList( + name: 'Account 1.2', + additionalAssets: {}, + tonWallet: TonWalletAsset( + publicKey: masterKey, + address: Address(address: '0:account2'), + contract: WalletType.everWallet(), + ), + ), + publicKey: masterKey, + isExternal: false, + isHidden: false, + ); + + final seedList = SeedList( + seedMeta: {masterKey: const SeedMetadata(name: '${seedPrefix}1')}, + allKeys: [masterKeyEntry], + mappedAccounts: { + masterKey: const AccountList( + allAccounts: [account1, account2], + publicKey: masterKey, + ), + }, + ); + + repository.setSeedList(seedList); + + // Act + final accountName = repository.generateDefaultAccountName(masterKey); + + // Assert + expect(accountName, equals('Account 1.3')); + }); + + test('gaps are not filled - Account 1.1, 1.3 -> next is 1.4', () { + // Arrange + final repository = TestableNekotonRepository(); + const masterKey = PublicKey(publicKey: 'masterkey1'); + final masterKeyEntry = KeyStoreEntry( + name: 'MasterKey', + masterKey: masterKey, + accountId: 0, + publicKey: masterKey, + signerName: const KeySigner.derived().name, + ); + + const account1 = KeyAccount( + account: AssetsList( + name: 'Account 1.1', + additionalAssets: {}, + tonWallet: TonWalletAsset( + publicKey: masterKey, + address: Address(address: '0:account1'), + contract: WalletType.everWallet(), + ), + ), + publicKey: masterKey, + isExternal: false, + isHidden: false, + ); + + const account3 = KeyAccount( + account: AssetsList( + name: 'Account 1.3', + additionalAssets: {}, + tonWallet: TonWalletAsset( + publicKey: masterKey, + address: Address(address: '0:account3'), + contract: WalletType.everWallet(), + ), + ), + publicKey: masterKey, + isExternal: false, + isHidden: false, + ); + + final seedList = SeedList( + seedMeta: {masterKey: const SeedMetadata(name: '${seedPrefix}1')}, + allKeys: [masterKeyEntry], + mappedAccounts: { + masterKey: const AccountList( + allAccounts: [account1, account3], + publicKey: masterKey, + ), + }, + ); + + repository.setSeedList(seedList); + + // Act + final accountName = repository.generateDefaultAccountName(masterKey); + + // Assert + expect(accountName, equals('Account 1.4')); + }); + + test('custom names do not affect counter', () { + // Arrange + final repository = TestableNekotonRepository(); + const masterKey = PublicKey(publicKey: 'masterkey1'); + final masterKeyEntry = KeyStoreEntry( + name: 'MasterKey', + masterKey: masterKey, + accountId: 0, + publicKey: masterKey, + signerName: const KeySigner.derived().name, + ); + + const account1 = KeyAccount( + account: AssetsList( + name: 'Account 1.1', + additionalAssets: {}, + tonWallet: TonWalletAsset( + publicKey: masterKey, + address: Address(address: '0:account1'), + contract: WalletType.everWallet(), + ), + ), + publicKey: masterKey, + isExternal: false, + isHidden: false, + ); + + const customAccount = KeyAccount( + account: AssetsList( + name: 'My Custom Account', + additionalAssets: {}, + tonWallet: TonWalletAsset( + publicKey: masterKey, + address: Address(address: '0:custom'), + contract: WalletType.everWallet(), + ), + ), + publicKey: masterKey, + isExternal: false, + isHidden: false, + ); + + const account3 = KeyAccount( + account: AssetsList( + name: 'Account 1.3', + additionalAssets: {}, + tonWallet: TonWalletAsset( + publicKey: masterKey, + address: Address(address: '0:account3'), + contract: WalletType.everWallet(), + ), + ), + publicKey: masterKey, + isExternal: false, + isHidden: false, + ); + + final seedList = SeedList( + seedMeta: {masterKey: const SeedMetadata(name: '${seedPrefix}1')}, + allKeys: [masterKeyEntry], + mappedAccounts: { + masterKey: const AccountList( + allAccounts: [account1, customAccount, account3], + publicKey: masterKey, + ), + }, + ); + + repository.setSeedList(seedList); + + // Act + final accountName = repository.generateDefaultAccountName(masterKey); + + // Assert + expect(accountName, equals('Account 1.4')); + }); + + test('different key positions are independent', () { + // Arrange + final repository = TestableNekotonRepository(); + const masterKey = PublicKey(publicKey: 'masterkey1'); + const subKey = PublicKey(publicKey: 'subkey1'); + + final masterKeyEntry = KeyStoreEntry( + name: 'MasterKey', + masterKey: masterKey, + accountId: 0, + publicKey: masterKey, + signerName: const KeySigner.derived().name, + ); + + final subKeyEntry = KeyStoreEntry( + name: 'SubKey', + masterKey: masterKey, + accountId: 1, + publicKey: subKey, + signerName: const KeySigner.derived().name, + ); + + const masterAccount1 = KeyAccount( + account: AssetsList( + name: 'Account 1.1', + additionalAssets: {}, + tonWallet: TonWalletAsset( + publicKey: masterKey, + address: Address(address: '0:master1'), + contract: WalletType.everWallet(), + ), + ), + publicKey: masterKey, + isExternal: false, + isHidden: false, + ); + + const masterAccount2 = KeyAccount( + account: AssetsList( + name: 'Account 1.2', + additionalAssets: {}, + tonWallet: TonWalletAsset( + publicKey: masterKey, + address: Address(address: '0:master2'), + contract: WalletType.everWallet(), + ), + ), + publicKey: masterKey, + isExternal: false, + isHidden: false, + ); + + const subAccount1 = KeyAccount( + account: AssetsList( + name: 'Account 2.1', + additionalAssets: {}, + tonWallet: TonWalletAsset( + publicKey: subKey, + address: Address(address: '0:sub1'), + contract: WalletType.everWallet(), + ), + ), + publicKey: subKey, + isExternal: false, + isHidden: false, + ); + + final seedList = SeedList( + seedMeta: {masterKey: const SeedMetadata(name: '${seedPrefix}1')}, + allKeys: [masterKeyEntry, subKeyEntry], + mappedAccounts: { + masterKey: const AccountList( + allAccounts: [masterAccount1, masterAccount2], + publicKey: masterKey, + ), + subKey: const AccountList( + allAccounts: [subAccount1], + publicKey: subKey, + ), + }, + ); + + repository.setSeedList(seedList); + + // Act + final masterAccountName = repository.generateDefaultAccountName( + masterKey, + ); + final subAccountName = repository.generateDefaultAccountName(subKey); + + // Assert + expect(masterAccountName, equals('Account 1.3')); + expect(subAccountName, equals('Account 2.2')); + }); + }); +} diff --git a/test/src/seed_repository_test.dart b/test/src/seed_repository_test.dart index 88433d0..360a61e 100644 --- a/test/src/seed_repository_test.dart +++ b/test/src/seed_repository_test.dart @@ -27,6 +27,11 @@ class MockAccountRepository extends Mock implements AccountRepository { Future removeAccounts(List accounts) async {} } +class MockNekotonRepository extends Mock implements NekotonRepository { + @override + String? generateDefaultAccountName(PublicKey publicKey) => null; +} + class MockArcTransportBoxTrait extends Mock implements ArcTransportBoxTrait {} class MockCreateKeyInput extends Mock implements CreateKeyInput {} @@ -63,6 +68,7 @@ void main() { late MockAccountsStorage accountsStorage; late MockNekotonStorageRepository storageRepository; late MockAccountRepository accountRepository; + late MockNekotonRepository nekotonRepository; late MockTransportStrategy transport; late MockProtoTransport proto; late SeedKeyRepositoryTest repository; @@ -99,6 +105,7 @@ void main() { accountsStorage = MockAccountsStorage(); storageRepository = MockNekotonStorageRepository(); accountRepository = MockAccountRepository(); + nekotonRepository = MockNekotonRepository(); transport = MockTransportStrategy(); proto = MockProtoTransport(); @@ -106,6 +113,7 @@ void main() { registerFallbackValue(box); GetIt.instance.registerSingleton(accountRepository); + GetIt.instance.registerSingleton(nekotonRepository); repository = SeedKeyRepositoryTest( transport, @@ -126,6 +134,7 @@ void main() { ).thenReturn('defaultAccountName'); when(() => proto.disposed).thenReturn(false); when(() => proto.transportBox).thenReturn(box); + when(() => storageRepository.seedMeta).thenReturn({}); }); setUpAll(() { @@ -328,6 +337,197 @@ void main() { }); }); + group('addSeed - default naming', () { + test('first seed without name gets "Seed 1"', () async { + final phrase = List.generate(12, (i) => 'word$i'); + const password = 'password'; + const publicKey = PublicKey(publicKey: 'publickey'); + + when(() => keyStore.addKey(any())).thenAnswer((_) async => publicKey); + when(() => keyStore.getPublicKeys(any())).thenAnswer((_) async => []); + when(() => accountsStorage.accounts).thenReturn([]); + when(() => storageRepository.seedMeta).thenReturn({}); + when( + () => storageRepository.updateSeedMetadata( + masterKey: publicKey, + meta: any(named: 'meta'), + ), + ).thenAnswer((_) async {}); + when( + () => bridge.crateApiMergedTonWalletDartWrapperFindExistingWallets( + transport: any(named: 'transport'), + publicKey: any(named: 'publicKey'), + walletTypes: any(named: 'walletTypes'), + workchainId: any(named: 'workchainId'), + ), + ).thenAnswer((_) => Future.value('[]')); + + await repository.addSeed( + phrase: phrase, + password: password, + workchainId: 0, + ); + + verify( + () => storageRepository.updateSeedMetadata( + masterKey: publicKey, + meta: any( + named: 'meta', + that: predicate( + (meta) => meta.name == '${seedPrefix}1', + ), + ), + ), + ); + }); + + test('sequential seeds get incremented numbers', () async { + final phrase = List.generate(12, (i) => 'word$i'); + const password = 'password'; + const publicKey = PublicKey(publicKey: 'publickey'); + const pk1 = PublicKey(publicKey: 'pk1'); + const pk2 = PublicKey(publicKey: 'pk2'); + + when(() => keyStore.addKey(any())).thenAnswer((_) async => publicKey); + when(() => keyStore.getPublicKeys(any())).thenAnswer((_) async => []); + when(() => accountsStorage.accounts).thenReturn([]); + when(() => storageRepository.seedMeta).thenReturn({ + pk1: const SeedMetadata(name: '${seedPrefix}1'), + pk2: const SeedMetadata(name: '${seedPrefix}2'), + }); + when( + () => storageRepository.updateSeedMetadata( + masterKey: publicKey, + meta: any(named: 'meta'), + ), + ).thenAnswer((_) async {}); + when( + () => bridge.crateApiMergedTonWalletDartWrapperFindExistingWallets( + transport: any(named: 'transport'), + publicKey: any(named: 'publicKey'), + walletTypes: any(named: 'walletTypes'), + workchainId: any(named: 'workchainId'), + ), + ).thenAnswer((_) => Future.value('[]')); + + await repository.addSeed( + phrase: phrase, + password: password, + workchainId: 0, + ); + + verify( + () => storageRepository.updateSeedMetadata( + masterKey: publicKey, + meta: any( + named: 'meta', + that: predicate( + (meta) => meta.name == '${seedPrefix}3', + ), + ), + ), + ); + }); + + test('gaps are not filled - next after Seed 1 and 3 is 4', () async { + final phrase = List.generate(12, (i) => 'word$i'); + const password = 'password'; + const publicKey = PublicKey(publicKey: 'publickey'); + const pk1 = PublicKey(publicKey: 'pk1'); + const pk3 = PublicKey(publicKey: 'pk3'); + + when(() => keyStore.addKey(any())).thenAnswer((_) async => publicKey); + when(() => keyStore.getPublicKeys(any())).thenAnswer((_) async => []); + when(() => accountsStorage.accounts).thenReturn([]); + when(() => storageRepository.seedMeta).thenReturn({ + pk1: const SeedMetadata(name: '${seedPrefix}1'), + pk3: const SeedMetadata(name: '${seedPrefix}3'), + }); + when( + () => storageRepository.updateSeedMetadata( + masterKey: publicKey, + meta: any(named: 'meta'), + ), + ).thenAnswer((_) async {}); + when( + () => bridge.crateApiMergedTonWalletDartWrapperFindExistingWallets( + transport: any(named: 'transport'), + publicKey: any(named: 'publicKey'), + walletTypes: any(named: 'walletTypes'), + workchainId: any(named: 'workchainId'), + ), + ).thenAnswer((_) => Future.value('[]')); + + await repository.addSeed( + phrase: phrase, + password: password, + workchainId: 0, + ); + + verify( + () => storageRepository.updateSeedMetadata( + masterKey: publicKey, + meta: any( + named: 'meta', + that: predicate( + (meta) => meta.name == '${seedPrefix}4', + ), + ), + ), + ); + }); + + test('custom names do not affect counter', () async { + final phrase = List.generate(12, (i) => 'word$i'); + const password = 'password'; + const publicKey = PublicKey(publicKey: 'publickey'); + const pk1 = PublicKey(publicKey: 'pk1'); + const pk2 = PublicKey(publicKey: 'pk2'); + const pk5 = PublicKey(publicKey: 'pk5'); + + when(() => keyStore.addKey(any())).thenAnswer((_) async => publicKey); + when(() => keyStore.getPublicKeys(any())).thenAnswer((_) async => []); + when(() => accountsStorage.accounts).thenReturn([]); + when(() => storageRepository.seedMeta).thenReturn({ + pk1: const SeedMetadata(name: '${seedPrefix}1'), + pk2: const SeedMetadata(name: 'My Custom Seed'), + pk5: const SeedMetadata(name: '${seedPrefix}5'), + }); + when( + () => storageRepository.updateSeedMetadata( + masterKey: publicKey, + meta: any(named: 'meta'), + ), + ).thenAnswer((_) async {}); + when( + () => bridge.crateApiMergedTonWalletDartWrapperFindExistingWallets( + transport: any(named: 'transport'), + publicKey: any(named: 'publicKey'), + walletTypes: any(named: 'walletTypes'), + workchainId: any(named: 'workchainId'), + ), + ).thenAnswer((_) => Future.value('[]')); + + await repository.addSeed( + phrase: phrase, + password: password, + workchainId: 0, + ); + + verify( + () => storageRepository.updateSeedMetadata( + masterKey: publicKey, + meta: any( + named: 'meta', + that: predicate( + (meta) => meta.name == '${seedPrefix}6', + ), + ), + ), + ); + }); + }); + group('removeKeys', () { test('calls keystore and triggers removing accounts', () async { const publicKeys = [