From e1387ace2fff4c45a253f3bac8397f4085eaec4d Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Wed, 22 Oct 2025 16:55:08 +0200 Subject: [PATCH 1/5] fix(auth,migration): preserve legacy flag and sanitize wallet name during migration\n\n- Keep WalletConfig.isLegacyWallet in copy() to ensure legacy flow\n- Sanitize legacy wallet names (non-alnum except _) and ensure uniqueness\n- Use sanitized, unique name in restore flow; delete legacy after success\n\nCloses: #none --- lib/bloc/auth_bloc/auth_bloc.dart | 39 +++++++++++++++++++++---------- lib/blocs/wallets_repository.dart | 34 +++++++++++++++++++++++++++ lib/model/wallet.dart | 13 +++++------ 3 files changed, 67 insertions(+), 19 deletions(-) diff --git a/lib/bloc/auth_bloc/auth_bloc.dart b/lib/bloc/auth_bloc/auth_bloc.dart index fb7f1b857a..7a13b913c6 100644 --- a/lib/bloc/auth_bloc/auth_bloc.dart +++ b/lib/bloc/auth_bloc/auth_bloc.dart @@ -239,12 +239,25 @@ class AuthBloc extends Bloc with TrezorAuthMixin { Emitter emit, ) async { try { - if (await _didSignInExistingWallet(event.wallet, event.password)) { + // Sanitize and ensure the migrated wallet name is unique before any + // registration attempts. This avoids conflicts when legacy names contain + // unsupported characters or collide with existing wallets after + // sanitization. + final String sanitizedUniqueName = await _walletsRepository + .sanitizeAndResolveLegacyWalletName(event.wallet.name); + final Wallet sanitizedWallet = event.wallet.copyWith( + name: sanitizedUniqueName, + ); + + if (await _didSignInExistingWallet(sanitizedWallet, event.password)) { add( - AuthSignInRequested(wallet: event.wallet, password: event.password), + AuthSignInRequested( + wallet: sanitizedWallet, + password: event.password, + ), ); _log.warning( - 'Wallet ${event.wallet.name} already exists, attempting sign-in', + 'Wallet ${sanitizedWallet.name} already exists, attempting sign-in', ); return; } @@ -254,10 +267,10 @@ class AuthBloc extends Bloc with TrezorAuthMixin { final weakPasswordsAllowed = await _areWeakPasswordsAllowed(); await _kdfSdk.auth.register( password: event.password, - walletName: event.wallet.name, + walletName: sanitizedWallet.name, mnemonic: Mnemonic.plaintext(event.seed), options: AuthOptions( - derivationMethod: event.wallet.config.type == WalletType.hdwallet + derivationMethod: sanitizedWallet.config.type == WalletType.hdwallet ? DerivationMethod.hdWallet : DerivationMethod.iguana, allowWeakPassword: weakPasswordsAllowed, @@ -268,16 +281,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(sanitizedWallet.config.type); + await _kdfSdk.confirmSeedBackup( + hasBackup: sanitizedWallet.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 (sanitizedWallet.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, + sanitizedWallet.config.activatedCoins, ); // Also filter out geo-blocked assets from restored wallet coins final allowedWalletCoins = _filterBlockedAssets(availableWalletCoins); @@ -286,13 +301,13 @@ class AuthBloc extends Bloc with TrezorAuthMixin { // Delete legacy wallet on successful restoration & login to avoid // duplicates in the wallet list - if (event.wallet.isLegacyWallet) { + if (sanitizedWallet.isLegacyWallet) { _log.info( 'Migration successful. ' - 'Deleting legacy wallet ${event.wallet.name}', + 'Deleting legacy wallet ${sanitizedWallet.name}', ); await _walletsRepository.deleteWallet( - event.wallet, + sanitizedWallet, password: event.password, ); } 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, From 3e796122bedec0e8cbd3df935e14cc3aa2513d93 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:03:20 +0200 Subject: [PATCH 2/5] chore(deps): update sdk submodule and pubspec lock references\n\n- Align submodules and pubspec to latest workspace state\n- No app code changes --- pubspec.yaml | 2 +- sdk | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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" diff --git a/sdk b/sdk index c5e3ad41dd..9881ff2e0d 160000 --- a/sdk +++ b/sdk @@ -1 +1 @@ -Subproject commit c5e3ad41dde0659e1c02e69c0b628a21ab0d6bfe +Subproject commit 9881ff2e0da95295cd6629e1b709af779fcac856 From dc47c01a073c0506a117f83d707d1ecfe5a1ea79 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:08:36 +0200 Subject: [PATCH 3/5] chore(ci): add PR_BODY.md helper file for CLI editing --- PR_BODY.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 PR_BODY.md 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. From ee69228b176b7d3a79fee8b594ee10c378e8dbb0 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:10:27 +0200 Subject: [PATCH 4/5] fix(migration): only sanitize names for legacy wallets; keep non-legacy unchanged --- lib/bloc/auth_bloc/auth_bloc.dart | 42 +++++++++++++++---------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/lib/bloc/auth_bloc/auth_bloc.dart b/lib/bloc/auth_bloc/auth_bloc.dart index 7a13b913c6..b76b478245 100644 --- a/lib/bloc/auth_bloc/auth_bloc.dart +++ b/lib/bloc/auth_bloc/auth_bloc.dart @@ -239,25 +239,25 @@ class AuthBloc extends Bloc with TrezorAuthMixin { Emitter emit, ) async { try { - // Sanitize and ensure the migrated wallet name is unique before any - // registration attempts. This avoids conflicts when legacy names contain - // unsupported characters or collide with existing wallets after - // sanitization. - final String sanitizedUniqueName = await _walletsRepository - .sanitizeAndResolveLegacyWalletName(event.wallet.name); - final Wallet sanitizedWallet = event.wallet.copyWith( - name: sanitizedUniqueName, - ); + // For legacy wallets only: sanitize and ensure the migrated wallet name + // is unique before any registration attempts. Non-legacy restores (seed + // imports) should keep the user-provided name unchanged. + Wallet workingWallet = event.wallet; + if (event.wallet.isLegacyWallet) { + final String sanitizedUniqueName = await _walletsRepository + .sanitizeAndResolveLegacyWalletName(event.wallet.name); + workingWallet = event.wallet.copyWith(name: sanitizedUniqueName); + } - if (await _didSignInExistingWallet(sanitizedWallet, event.password)) { + if (await _didSignInExistingWallet(workingWallet, event.password)) { add( AuthSignInRequested( - wallet: sanitizedWallet, + wallet: workingWallet, password: event.password, ), ); _log.warning( - 'Wallet ${sanitizedWallet.name} already exists, attempting sign-in', + 'Wallet ${workingWallet.name} already exists, attempting sign-in', ); return; } @@ -267,10 +267,10 @@ class AuthBloc extends Bloc with TrezorAuthMixin { final weakPasswordsAllowed = await _areWeakPasswordsAllowed(); await _kdfSdk.auth.register( password: event.password, - walletName: sanitizedWallet.name, + walletName: workingWallet.name, mnemonic: Mnemonic.plaintext(event.seed), options: AuthOptions( - derivationMethod: sanitizedWallet.config.type == WalletType.hdwallet + derivationMethod: workingWallet.config.type == WalletType.hdwallet ? DerivationMethod.hdWallet : DerivationMethod.iguana, allowWeakPassword: weakPasswordsAllowed, @@ -281,18 +281,18 @@ class AuthBloc extends Bloc with TrezorAuthMixin { 'Successfully restored wallet from a seed. ' 'Setting up wallet metadata and logging in...', ); - await _kdfSdk.setWalletType(sanitizedWallet.config.type); + await _kdfSdk.setWalletType(workingWallet.config.type); await _kdfSdk.confirmSeedBackup( - hasBackup: sanitizedWallet.config.hasBackup, + 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 (sanitizedWallet.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( - sanitizedWallet.config.activatedCoins, + workingWallet.config.activatedCoins, ); // Also filter out geo-blocked assets from restored wallet coins final allowedWalletCoins = _filterBlockedAssets(availableWalletCoins); @@ -301,13 +301,13 @@ class AuthBloc extends Bloc with TrezorAuthMixin { // Delete legacy wallet on successful restoration & login to avoid // duplicates in the wallet list - if (sanitizedWallet.isLegacyWallet) { + if (event.wallet.isLegacyWallet) { _log.info( 'Migration successful. ' - 'Deleting legacy wallet ${sanitizedWallet.name}', + 'Deleting legacy wallet ${workingWallet.name}', ); await _walletsRepository.deleteWallet( - sanitizedWallet, + event.wallet, password: event.password, ); } From 578c7f5542f9de359d7cafc02f5aad685a570ec7 Mon Sep 17 00:00:00 2001 From: CharlVS <77973576+CharlVS@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:39:33 +0200 Subject: [PATCH 5/5] fix(migration): attempt sign-in with sanitized base name before resolving uniqueness\n\nPrevents duplicate wallets by avoiding premature suffixing during legacy migration. --- lib/bloc/auth_bloc/auth_bloc.dart | 41 +++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/lib/bloc/auth_bloc/auth_bloc.dart b/lib/bloc/auth_bloc/auth_bloc.dart index b76b478245..46ac879a00 100644 --- a/lib/bloc/auth_bloc/auth_bloc.dart +++ b/lib/bloc/auth_bloc/auth_bloc.dart @@ -239,22 +239,43 @@ class AuthBloc extends Bloc with TrezorAuthMixin { Emitter emit, ) async { try { - // For legacy wallets only: sanitize and ensure the migrated wallet name - // is unique before any registration attempts. Non-legacy restores (seed - // imports) should keep the user-provided name unchanged. + // 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 sanitizedUniqueName = await _walletsRepository - .sanitizeAndResolveLegacyWalletName(event.wallet.name); - workingWallet = event.wallet.copyWith(name: sanitizedUniqueName); + 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: workingWallet, - password: event.password, - ), + AuthSignInRequested(wallet: workingWallet, password: event.password), ); _log.warning( 'Wallet ${workingWallet.name} already exists, attempting sign-in',