diff --git a/PR_BODY.md b/PR_BODY.md new file mode 100644 index 0000000000..c6e66bc6f1 --- /dev/null +++ b/PR_BODY.md @@ -0,0 +1,19 @@ +Summary + +- Preserve `WalletConfig.isLegacyWallet` in `copy()` so legacy wallets route to `AuthRestoreRequested`. +- Sanitize legacy wallet names: replace non-alphanumeric (Unicode letters/digits) except "_" with "_". +- Resolve collisions by appending the lowest integer suffix (e.g., name, name_1, name_2, ...). +- Apply during legacy migration in `AuthBloc._onRestore`; delete legacy entry after success. + +Why + +Fixes a critical bug where some users can't log into wallets created with old versions and the wallet disappears. + +Testing + +- Create a legacy wallet with special characters and attempt login; verify migration uses sanitized unique name and signs in. +- Verify if a non-legacy wallet already has the sanitized name, the migrated wallet uses the lowest available _N suffix. + +Notes + +No API changes. Static analysis passes for changed files. diff --git a/lib/bloc/auth_bloc/auth_bloc.dart b/lib/bloc/auth_bloc/auth_bloc.dart index fb7f1b857a..46ac879a00 100644 --- a/lib/bloc/auth_bloc/auth_bloc.dart +++ b/lib/bloc/auth_bloc/auth_bloc.dart @@ -239,12 +239,46 @@ class AuthBloc extends Bloc with TrezorAuthMixin { Emitter emit, ) async { try { - if (await _didSignInExistingWallet(event.wallet, event.password)) { + // Legacy wallets: sanitize base name, try sign-in, then resolve + // uniqueness only if needed. Non-legacy restores (seed imports) keep the + // user-provided name unchanged. + Wallet workingWallet = event.wallet; + if (event.wallet.isLegacyWallet) { + final String baseName = _walletsRepository.sanitizeLegacyMigrationName( + event.wallet.name, + ); + final Wallet sanitizedBaseWallet = event.wallet.copyWith( + name: baseName, + ); + + // Attempt sign-in with sanitized base name first to avoid creating a + // suffixed duplicate when a migrated wallet already exists. + if (await _didSignInExistingWallet( + sanitizedBaseWallet, + event.password, + )) { + add( + AuthSignInRequested( + wallet: sanitizedBaseWallet, + password: event.password, + ), + ); + _log.warning('Wallet $baseName already exists, attempting sign-in'); + return; + } + + // Otherwise, resolve the lowest available unique name for registration. + final String uniqueName = await _walletsRepository + .resolveUniqueWalletName(baseName); + workingWallet = event.wallet.copyWith(name: uniqueName); + } + + if (await _didSignInExistingWallet(workingWallet, event.password)) { add( - AuthSignInRequested(wallet: event.wallet, password: event.password), + AuthSignInRequested(wallet: workingWallet, password: event.password), ); _log.warning( - 'Wallet ${event.wallet.name} already exists, attempting sign-in', + 'Wallet ${workingWallet.name} already exists, attempting sign-in', ); return; } @@ -254,10 +288,10 @@ class AuthBloc extends Bloc with TrezorAuthMixin { final weakPasswordsAllowed = await _areWeakPasswordsAllowed(); await _kdfSdk.auth.register( password: event.password, - walletName: event.wallet.name, + walletName: workingWallet.name, mnemonic: Mnemonic.plaintext(event.seed), options: AuthOptions( - derivationMethod: event.wallet.config.type == WalletType.hdwallet + derivationMethod: workingWallet.config.type == WalletType.hdwallet ? DerivationMethod.hdWallet : DerivationMethod.iguana, allowWeakPassword: weakPasswordsAllowed, @@ -268,16 +302,18 @@ class AuthBloc extends Bloc with TrezorAuthMixin { 'Successfully restored wallet from a seed. ' 'Setting up wallet metadata and logging in...', ); - await _kdfSdk.setWalletType(event.wallet.config.type); - await _kdfSdk.confirmSeedBackup(hasBackup: event.wallet.config.hasBackup); + await _kdfSdk.setWalletType(workingWallet.config.type); + await _kdfSdk.confirmSeedBackup( + hasBackup: workingWallet.config.hasBackup, + ); // Filter out geo-blocked assets from default coins before adding to wallet final allowedDefaultCoins = _filterBlockedAssets(enabledByDefaultCoins); await _kdfSdk.addActivatedCoins(allowedDefaultCoins); - if (event.wallet.config.activatedCoins.isNotEmpty) { + if (workingWallet.config.activatedCoins.isNotEmpty) { // Seed import files and legacy wallets may contain removed or unsupported // coins, so we filter them out before adding them to the wallet metadata. final availableWalletCoins = _filterOutUnsupportedCoins( - event.wallet.config.activatedCoins, + workingWallet.config.activatedCoins, ); // Also filter out geo-blocked assets from restored wallet coins final allowedWalletCoins = _filterBlockedAssets(availableWalletCoins); @@ -289,7 +325,7 @@ class AuthBloc extends Bloc with TrezorAuthMixin { if (event.wallet.isLegacyWallet) { _log.info( 'Migration successful. ' - 'Deleting legacy wallet ${event.wallet.name}', + 'Deleting legacy wallet ${workingWallet.name}', ); await _walletsRepository.deleteWallet( event.wallet, diff --git a/lib/blocs/wallets_repository.dart b/lib/blocs/wallets_repository.dart index 187f3de976..66da80202f 100644 --- a/lib/blocs/wallets_repository.dart +++ b/lib/blocs/wallets_repository.dart @@ -220,4 +220,38 @@ class WalletsRepository { } } } + + /// Sanitizes a legacy wallet name for migration by replacing any + /// non-alphanumeric character (Unicode letters/digits) except underscore + /// with an underscore. This ensures compatibility with stricter name rules + /// in the target storage/backend. + String sanitizeLegacyMigrationName(String name) { + final sanitized = name.replaceAll( + RegExp(r'[^\p{L}\p{N}_]', unicode: true), + '_', + ); + // Avoid returning an empty string + return sanitized.isEmpty ? '_' : sanitized; + } + + /// Resolves a unique wallet name by appending the lowest integer suffix + /// starting at 1 that makes the name unique across both SDK and legacy + /// wallets. If [baseName] is already unique, it is returned unchanged. + Future resolveUniqueWalletName(String baseName) async { + final List allWallets = await getWallets(); + final Set existing = allWallets.map((w) => w.name).toSet(); + if (!existing.contains(baseName)) return baseName; + + int i = 1; + while (existing.contains('${baseName}_$i')) { + i++; + } + return '${baseName}_$i'; + } + + /// Convenience helper for migration: sanitize and then ensure uniqueness. + Future sanitizeAndResolveLegacyWalletName(String legacyName) async { + final sanitized = sanitizeLegacyMigrationName(legacyName); + return resolveUniqueWalletName(sanitized); + } } diff --git a/lib/model/wallet.dart b/lib/model/wallet.dart index b7227b0544..df3ad20551 100644 --- a/lib/model/wallet.dart +++ b/lib/model/wallet.dart @@ -67,12 +67,8 @@ class Wallet { Wallet copy() { return Wallet(id: id, name: name, config: config.copy()); } - - Wallet copyWith({ - String? id, - String? name, - WalletConfig? config, - }) { + + Wallet copyWith({String? id, String? name, WalletConfig? config}) { return Wallet( id: id ?? this.id, name: name ?? this.name, @@ -129,9 +125,12 @@ class WalletConfig { type: type, seedPhrase: seedPhrase, pubKey: pubKey, + // Preserve legacy flag when copying config; losing this flag breaks + // legacy login flow and can hide the wallet from lists. + isLegacyWallet: isLegacyWallet, ); } - + WalletConfig copyWith({ String? seedPhrase, String? pubKey, diff --git a/pubspec.yaml b/pubspec.yaml index e5110333b9..538222be3a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.9.3+0 +version: 0.9.3+1 environment: sdk: ">=3.8.1 <4.0.0"