diff --git a/lib/services/accounts/accounts.dart b/lib/services/accounts/accounts.dart index 3edeaf64..ec83f715 100644 --- a/lib/services/accounts/accounts.dart +++ b/lib/services/accounts/accounts.dart @@ -14,7 +14,7 @@ abstract class AccountsOptionsInterface {} /// /// This is used to store wallet backups and the implementation is platform specific. abstract class AccountsServiceInterface { - final int _version = 4; + final int _version = 5; int get version => _version; diff --git a/lib/services/accounts/backup.dart b/lib/services/accounts/backup.dart index f658f697..32c5fee5 100644 --- a/lib/services/accounts/backup.dart +++ b/lib/services/accounts/backup.dart @@ -81,3 +81,31 @@ class BackupWallet { String get key => '$address@$alias'; String get value => privateKey; } + +class BackupWalletV5 extends BackupWallet { + final String accountFactoryAddress; + + BackupWalletV5({ + required super.address, + required super.alias, + required super.privateKey, + required String accountFactoryAddress, + }) : accountFactoryAddress = + EthereumAddress.fromHex(accountFactoryAddress).hexEip55; + + // Fixes the 'json' super parameter lint + BackupWalletV5.fromJson(super.json) + : accountFactoryAddress = + EthereumAddress.fromHex(json['accountFactoryAddress']).hexEip55, + super.fromJson(); + + @override + Map toJson() { + final json = super.toJson(); + json['accountFactoryAddress'] = accountFactoryAddress; + return json; + } + + @override + String get key => '$address@$accountFactoryAddress@$alias'; +} diff --git a/lib/services/accounts/native/android.dart b/lib/services/accounts/native/android.dart index 83a074a1..6829748a 100644 --- a/lib/services/accounts/native/android.dart +++ b/lib/services/accounts/native/android.dart @@ -1,3 +1,4 @@ +import 'package:citizenwallet/services/config/utils.dart'; import 'package:citizenwallet/services/credentials/credentials.dart'; import 'package:citizenwallet/services/db/backup/db.dart'; import 'package:citizenwallet/utils/encrypt.dart'; @@ -6,6 +7,7 @@ import 'package:citizenwallet/services/db/backup/accounts.dart'; import 'package:citizenwallet/services/accounts/backup.dart'; import 'package:citizenwallet/services/accounts/accounts.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:web3dart/crypto.dart'; @@ -62,6 +64,7 @@ class AndroidAccountsService extends AccountsServiceInterface { } // write the account data in the accounts table + // TODO: use DBAccountV4, with getAccountFactoryAddressByAlias final account = DBAccount( alias: legacyBackup.alias, address: EthereumAddress.fromHex(legacyBackup.address), @@ -90,6 +93,81 @@ class AndroidAccountsService extends AccountsServiceInterface { } } }, + 5: () async { + // Read all credentials from secure storage + final allValues = await _credentials.readAll(); + + // Filter keys that match the old format: address@alias + // These are keys that don't start with backupPrefix and contain exactly one '@' + final oldFormatKeys = allValues.keys.where((key) { + if (key.startsWith(backupPrefix) || + key == versionPrefix || + key == pinCodeKey || + key == pinCodeCheckKey) { + return false; + } + // Check if it matches address@alias format (one @ symbol) + final parts = key.split('@'); + if (parts.length != 2) { + return false; + } + + // Validate that the first part is a valid Ethereum address + try { + EthereumAddress.fromHex(parts[0]); + return true; + } catch (_) { + return false; + } + }).toList(); + + final toDelete = []; + + for (final oldKey in oldFormatKeys) { + final privateKeyValue = allValues[oldKey]; + if (privateKeyValue == null) { + continue; + } + + // Parse the old key format: address@alias + final parts = oldKey.split('@'); + if (parts.length != 2) { + continue; + } + + final address = parts[0]; + final alias = parts[1]; + + try { + // Get the account factory address for this alias + final accountFactoryAddress = + getAccountFactoryAddressByAlias(alias); + + // Create a BackupWalletV5 with the new format + final backup = BackupWalletV5( + address: address, + alias: alias, + accountFactoryAddress: accountFactoryAddress, + privateKey: privateKeyValue, + ); + + // Write the credential with the new key format + await _credentials.write(backup.key, backup.value); + + // Mark old key for deletion + toDelete.add(oldKey); + } catch (e) { + // If we can't determine the account factory address, skip this key + debugPrint('Error migrating key $oldKey: $e'); + continue; + } + } + + // Delete all old format keys + for (final key in toDelete) { + await _credentials.delete(key); + } + }, }; // run all migrations diff --git a/lib/services/accounts/native/apple.dart b/lib/services/accounts/native/apple.dart index 849f9f1f..b5a7189b 100644 --- a/lib/services/accounts/native/apple.dart +++ b/lib/services/accounts/native/apple.dart @@ -2,10 +2,12 @@ import 'package:citizenwallet/services/accounts/backup.dart'; import 'package:citizenwallet/services/accounts/accounts.dart'; import 'package:citizenwallet/services/accounts/options.dart'; import 'package:citizenwallet/services/accounts/utils.dart'; +import 'package:citizenwallet/services/config/utils.dart'; import 'package:citizenwallet/services/credentials/credentials.dart'; import 'package:citizenwallet/services/credentials/native/apple.dart'; import 'package:citizenwallet/services/db/backup/accounts.dart'; import 'package:citizenwallet/services/db/backup/db.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:web3dart/credentials.dart'; import 'package:web3dart/crypto.dart'; @@ -166,6 +168,7 @@ class AppleAccountsService extends AccountsServiceInterface { } // write the account data in the accounts table + // TODO: use DBAccountV4, with getAccountFactoryAddressByAlias final DBAccount account = DBAccount( alias: legacyBackup.alias, address: EthereumAddress.fromHex(legacyBackup.address), @@ -203,6 +206,81 @@ class AppleAccountsService extends AccountsServiceInterface { } } }, + 5: () async { + // Read all credentials from Keychain + final allValues = await _credentials.readAll(); + + // Filter keys that match the old format: address@alias + // These are keys that don't start with backupPrefix and contain exactly one '@' + final oldFormatKeys = allValues.keys.where((key) { + if (key.startsWith(backupPrefix) || key == versionPrefix) { + return false; + } + // Check if it matches address@alias format (one @ symbol) + final parts = key.split('@'); + if (parts.length != 2) { + return false; + } + + // Validate that the first part is a valid Ethereum address + try { + EthereumAddress.fromHex(parts[0]); + return true; + } catch (_) { + return false; + } + }).toList(); + + final toDelete = []; + + for (final oldKey in oldFormatKeys) { + final privateKeyValue = allValues[oldKey]; + if (privateKeyValue == null) { + continue; + } + + // Parse the old key format: address@alias + final parts = oldKey.split('@'); + if (parts.length != 2) { + continue; + } + + final address = parts[0]; + final alias = parts[1]; + + try { + // Get the account factory address for this alias + final accountFactoryAddress = + getAccountFactoryAddressByAlias(alias); + + // Create a BackupWalletV5 with the new format + final backup = BackupWalletV5( + address: address, + alias: alias, + accountFactoryAddress: accountFactoryAddress, + privateKey: privateKeyValue, + ); + + // Write the credential with the new key format + await _credentials.write(backup.key, backup.value); + + // Mark old key for deletion + toDelete.add(oldKey); + } catch (e) { + // If we can't determine the account factory address, skip this key + debugPrint('Error migrating key $oldKey: $e'); + continue; + } + } + + // Delete all old format keys + for (final key in toDelete) { + final saved = await _credentials.containsKey(key); + if (saved) { + await _credentials.delete(key); + } + } + }, }; // run all migrations diff --git a/lib/services/db/backup/accounts.dart b/lib/services/db/backup/accounts.dart index 0a8bb868..ecaa0cd9 100644 --- a/lib/services/db/backup/accounts.dart +++ b/lib/services/db/backup/accounts.dart @@ -60,15 +60,62 @@ class DBAccount { } } +class DBAccountV4 extends DBAccount { + final EthereumAddress accountFactoryAddress; + + DBAccountV4({ + required super.alias, + required super.address, + required super.name, + super.username, + super.privateKey, + super.profile, + required this.accountFactoryAddress, + }) : super(); + + // Override toMap to include accountFactoryAddress and update the ID format + @override + Map toMap() { + final map = super.toMap(); + // Update the ID to the V4 format: address@accountFactoryAddress@alias + map['id'] = getAccountIdV4( + address: address, + alias: alias, + accountFactoryAddress: accountFactoryAddress, + ); + map['accountFactoryAddress'] = accountFactoryAddress.hexEip55; + return map; + } + + // fromMap factory for the V4 structure + factory DBAccountV4.fromMap(Map map) { + return DBAccountV4( + alias: map['alias'], + address: EthereumAddress.fromHex(map['address']), + name: map['name'], + username: map['username'], + accountFactoryAddress: + EthereumAddress.fromHex(map['accountFactoryAddress']), + privateKey: map['privateKey'] != null + ? EthPrivateKey.fromHex(map['privateKey']) + : null, + profile: map['profile'] != null + ? ProfileV1.fromJson(jsonDecode(map['profile'])) + : null, + ); + } +} + String getAccountID(EthereumAddress address, String alias) { return '${address.hexEip55}@$alias'; } -String getAccountIDNew( - {required EthereumAddress address, - required String alias, - required String accountFactoryAddress}) { - return '${address.hexEip55}@$accountFactoryAddress@$alias'; +String getAccountIdV4({ + required EthereumAddress address, + required String alias, + required EthereumAddress accountFactoryAddress, +}) { + return '${address.hexEip55}@${accountFactoryAddress.hexEip55}@$alias'; } class UserHandle { @@ -126,8 +173,8 @@ class AccountsTable extends DBTable { 4: [ 'ALTER TABLE $name ADD COLUMN accountFactoryAddress TEXT DEFAULT ""', 'PopulateAccountFactoryAddressMigration', - 'InsertRowsInNewIdFormatMigration', // Insert the rows in the new format $address@$accountFactoryAddress@$alias - // TODO: delete the rows in the old format $address@$alias + 'InsertRowsInV4IdFormatMigration', // Insert the rows in the new format $address@$accountFactoryAddress@$alias + ] }; @@ -142,8 +189,8 @@ class AccountsTable extends DBTable { await _populateAccountFactoryAddressMigration(db, name); continue; - case 'InsertRowsInNewIdFormatMigration': - await _insertRowsInNewIdFormatMigration(db, name); + case 'InsertRowsInV4IdFormatMigration': + await _insertRowsInV4IdFormatMigration(db, name); continue; } @@ -178,35 +225,51 @@ class AccountsTable extends DBTable { } } - Future _insertRowsInNewIdFormatMigration( - Database db, String name) async { - List> accounts = await db.query(name); // + Future _insertRowsInV4IdFormatMigration( + Database db, + String name, + ) async { + List> accounts = await db.query(name); + // Create all DBAccountV4 objects + final List dbAccountsV4 = []; for (final Map account in accounts) { - final address = account['address'] as String; - final accountFactoryAddress = account['accountFactoryAddress'] as String; - final alias = account['alias'] as String; - - final newId = getAccountIDNew( - address: EthereumAddress.fromHex(address), - alias: alias, - accountFactoryAddress: accountFactoryAddress); + final dbAccountV4 = DBAccountV4( + alias: account['alias'] as String, + address: EthereumAddress.fromHex(account['address'] as String), + name: account['name'] as String, + username: account['username'] as String?, + privateKey: account['privateKey'] != null + ? EthPrivateKey.fromHex(account['privateKey'] as String) + : null, + profile: account['profile'] != null + ? ProfileV1.fromJson(jsonDecode(account['profile'] as String)) + : null, + accountFactoryAddress: + EthereumAddress.fromHex(account['accountFactoryAddress'] as String), + ); + dbAccountsV4.add(dbAccountV4); + } - await db.insert( + // Batch insert new accounts + final batch = db.batch(); + for (final dbAccountV4 in dbAccountsV4) { + batch.insert( name, - { - 'id': newId, - 'alias': account['alias'], - 'address': account['address'], - 'accountFactoryAddress': account['accountFactoryAddress'], - 'name': account['name'], - 'username': account['username'], - 'privateKey': account['privateKey'], - 'profile': account['profile'], - }, + dbAccountV4.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); } + await batch.commit(noResult: true); + + // Delete old accounts only after successful insert + for (final Map account in accounts) { + await db.delete( + name, + where: 'id = ?', + whereArgs: [account['id']], + ); + } } // get account by id diff --git a/lib/services/preferences/preferences.dart b/lib/services/preferences/preferences.dart index 14d8a7d6..a6e89ecd 100644 --- a/lib/services/preferences/preferences.dart +++ b/lib/services/preferences/preferences.dart @@ -81,6 +81,15 @@ class PreferencesService { String? get lastAlias => _preferences.getString('lastAlias'); + // save the last account factory address that was opened + Future setLastAccountFactoryAddress(String accountFactoryAddress) async { + await _preferences.setString( + 'lastAccountFactoryAddress', accountFactoryAddress); + } + + String? get lastAccountFactoryAddress => + _preferences.getString('lastAccountFactoryAddress'); + // save the last link that was opened on web Future setLastWalletLink(String link) async { await _preferences.setString('lastWalletLink', link); @@ -160,5 +169,4 @@ class PreferencesService { String? getLanguageCode() { return _preferences.getString('languageCode'); } - }