Skip to content
Draft
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
16 changes: 16 additions & 0 deletions lib/core/wallet/domain/inconsistent_wallet_state_exception.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import 'package:bb_mobile/core/errors/bull_exception.dart';

/// Wallet records exist for [fingerprint] but their seed is no longer in the
/// seed store (keystore invalidation, interrupted restore), so anything that
/// derives the xprv is doomed.
///
/// Thrown where the records are reused — cold start and default-wallet
/// creation — so the state is named at detection time instead of surfacing as
/// a `SeedNotFoundException` inside whichever flow happens to need the xprv
/// first (#137). The remedy is restoring the wallet from a backup.
class InconsistentWalletStateException extends BullException {
InconsistentWalletStateException({required this.fingerprint})
: super('Wallet records without a seed for fingerprint: $fingerprint');

final String fingerprint;
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:bb_mobile/core/settings/data/settings_repository.dart';
import 'package:bb_mobile/core/utils/logger.dart';
import 'package:bb_mobile/core/wallet/data/repositories/wallet_repository.dart';
import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart';
import 'package:bb_mobile/core/wallet/domain/inconsistent_wallet_state_exception.dart';

class CreateDefaultWalletsUsecase {
final SeedRepository _seedRepository;
Expand Down Expand Up @@ -41,7 +42,14 @@ class CreateDefaultWalletsUsecase {
);
final hasBitcoin = existing.any((w) => w.network.isBitcoin);
final hasLiquid = existing.any((w) => w.network.isLiquid);
if (hasBitcoin && hasLiquid) return existing;
if (hasBitcoin && hasLiquid) {
await _ensureSeedsPresent(
existing,
mnemonicWords: mnemonicWords,
passphrase: passphrase,
);
return existing;
}

final isGenerated = mnemonicWords == null;
final mnemonic = mnemonicWords ?? _mnemonicGenerator.generate();
Expand Down Expand Up @@ -91,10 +99,59 @@ class CreateDefaultWalletsUsecase {
}

return [...existing, ...created];
} on InconsistentWalletStateException {
// Its own diagnosis; stringifying it into CreateDefaultWalletsException
// would hide the one failure whose remedy is wallet recovery (#137).
rethrow;
} catch (e) {
throw CreateDefaultWalletsException(e.toString());
}
}

/// Guards the reuse path: records whose seed is gone would otherwise pass as
/// "wallets already exist" and blow up later in the first flow that derives
/// the xprv. Presence check only — no seed material is read or logged.
///
/// When the caller supplied the mnemonic (restore-from-backup) and it is the
/// mnemonic these records were built from, the missing seed is simply stored
/// back: that is exactly the recovery the exception tells users to perform,
/// so it must not itself be rejected as an inconsistent state.
Future<void> _ensureSeedsPresent(
List<Wallet> wallets, {
List<String>? mnemonicWords,
String? passphrase,
}) async {
final fingerprints = wallets.map((w) => w.masterFingerprint).toSet();
for (final fingerprint in fingerprints) {
if (await _seedRepository.exists(fingerprint)) continue;

final restorable =
mnemonicWords != null &&
_seedRepository.fingerprintFor(
mnemonicWords: mnemonicWords,
passphrase: passphrase,
) ==
fingerprint;
if (restorable) {
await _seedRepository.createFromMnemonic(
mnemonicWords: mnemonicWords,
passphrase: passphrase,
);
continue;
}

final inconsistent = InconsistentWalletStateException(
fingerprint: fingerprint,
);
log.severe(
message:
'CreateDefaultWalletsUsecase: default wallet records without a seed',
error: inconsistent,
trace: StackTrace.current,
);
throw inconsistent;
}
}
}

class CreateDefaultWalletsException extends BullException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:bb_mobile/core/settings/data/settings_repository.dart';
import 'package:bb_mobile/core/utils/logger.dart';
import 'package:bb_mobile/core/wallet/data/repositories/wallet_repository.dart';
import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart';
import 'package:bb_mobile/core/wallet/domain/inconsistent_wallet_state_exception.dart';

class CheckForExistingDefaultWalletsUsecase {
final SettingsRepository _settingsRepository;
Expand Down Expand Up @@ -85,21 +86,26 @@ class CheckForExistingDefaultWalletsUsecase {
}

log.fine('FINE: found default wallet');
await Future.wait(
defaultWallets.map((wallet) async {
try {
await _seedRepository.get(wallet.masterFingerprint);
log.fine('FINE: Seed Found');
} catch (e) {
log.severe(
message: 'Seed not found for default wallet ',
error: e,
trace: StackTrace.current,
);
rethrow;
}
}),
);
// Presence check only — never reads seed material. Records whose seed is
// gone are an inconsistent wallet state named here, at the point the
// records are reused, instead of a SeedNotFoundException deep inside the
// first flow that derives the xprv (#137). `exists` still propagates
// KeychainLockedException, which AppStartupBloc treats as transient.
final fingerprints = defaultWallets
.map((wallet) => wallet.masterFingerprint)
.toSet();
for (final fingerprint in fingerprints) {
if (await _seedRepository.exists(fingerprint)) continue;
final inconsistent = InconsistentWalletStateException(
fingerprint: fingerprint,
);
log.severe(
message: 'Seed not found for default wallet',
error: inconsistent,
trace: StackTrace.current,
);
throw inconsistent;
}
return true;
}
}
16 changes: 14 additions & 2 deletions lib/features/app_startup/ui/app_startup_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'package:bb_mobile/core/utils/build_context_x.dart';
import 'package:bb_mobile/core/utils/constants.dart';
import 'package:bb_mobile/core/widgets/buttons/button.dart';
import 'package:bb_mobile/core/widgets/share_logs_widget.dart';
import 'package:bb_mobile/core/wallet/domain/inconsistent_wallet_state_exception.dart';
import 'package:bb_mobile/features/app_startup/presentation/bloc/app_startup_bloc.dart';
import 'package:bb_mobile/features/app_unlock/ui/app_unlock_router.dart';
import 'package:bb_mobile/features/onboarding/ui/onboarding_router.dart';
Expand Down Expand Up @@ -93,6 +94,17 @@ class AppStartupFailureScreen extends StatelessWidget {

@override
Widget build(BuildContext context) {
// Wallet records without their seed have one remedy — restore from backup
// — so they get their own copy instead of the generic "restart the app"
// advice, which can never resolve them (#137).
final isInconsistentWalletState = e is InconsistentWalletStateException;
final title = isInconsistentWalletState
? context.loc.walletDataIncompleteTitle
: context.loc.appStartupErrorTitle;
final message = isInconsistentWalletState
? context.loc.walletDataIncompleteRestoreMessage
: context.loc.appStartupErrorMessage;

return Scaffold(
body: Center(
child: Padding(
Expand All @@ -115,7 +127,7 @@ class AppStartupFailureScreen extends StatelessWidget {
Icon(Icons.error_outline, color: context.appColors.error),
const Gap(8),
Text(
context.loc.appStartupErrorTitle,
title,
style: context.font.headlineLarge?.copyWith(
color: context.appColors.error,
),
Expand All @@ -125,7 +137,7 @@ class AppStartupFailureScreen extends StatelessWidget {
subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
context.loc.appStartupErrorMessage,
message,
style: context.font.bodyMedium?.copyWith(
color: context.appColors.secondary.withValues(alpha: 0.7),
),
Expand Down
6 changes: 6 additions & 0 deletions lib/features/onboarding/domain/onboarding_failure.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ final class OnboardingUnexpectedFailure extends OnboardingFailure {
const OnboardingUnexpectedFailure([super.logMessage]);
}

/// Default wallet records are on the device without their seed, so onboarding
/// cannot reuse them; the user has to restore from a backup (#137).
final class OnboardingInconsistentWalletStateFailure extends OnboardingFailure {
const OnboardingInconsistentWalletStateFailure([super.logMessage]);
}

final class OnboardingBackupVerificationPersistenceFailure
extends OnboardingFailure {
const OnboardingBackupVerificationPersistenceFailure([super.logMessage]);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:bb_mobile/core/utils/logger.dart';
import 'package:bb_mobile/core/utils/result.dart';
import 'package:bb_mobile/core/wallet/domain/entities/wallet.dart';
import 'package:bb_mobile/core/wallet/domain/inconsistent_wallet_state_exception.dart';
import 'package:bb_mobile/core/wallet/domain/usecases/create_default_wallets_usecase.dart';
import 'package:bb_mobile/features/onboarding/domain/onboarding_failure.dart';
import 'package:meta/meta.dart';
Expand Down Expand Up @@ -32,6 +33,17 @@ class CreateOnboardingWalletsUsecase {
return const Err(failure);
}
return Ok(wallets);
} on InconsistentWalletStateException catch (error, trace) {
log.severe(
message: 'createOnboardingWallets found wallet records without a seed',
error: error,
trace: trace,
);
return Err(
OnboardingInconsistentWalletStateFailure(
'wallet records without a seed for ${error.fingerprint}',
),
);
} on CreateDefaultWalletsException catch (error, trace) {
log.severe(
message: 'createOnboardingWallets failed',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import 'package:flutter/widgets.dart';
extension OnboardingFailureL10n on OnboardingFailure {
String toTranslated(BuildContext context) => switch (this) {
OnboardingUnexpectedFailure() => context.loc.walletSetupErrorTryAgain,
OnboardingInconsistentWalletStateFailure() =>
context.loc.walletDataIncompleteRestoreMessage,
OnboardingBackupVerificationPersistenceFailure() =>
context.loc.onboardingBackupVerificationSaveFailed,
};
Expand Down
8 changes: 8 additions & 0 deletions localization/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -17978,5 +17978,13 @@
"backupSettingsMetadataAttentionNeeded": "Attention needed",
"@backupSettingsMetadataAttentionNeeded": {
"description": "Muted line under the metadata backup status row when a write was rejected or recovery is blocked; never shown for a write that is merely queued"
},
"walletDataIncompleteTitle": "Wallet data incomplete",
"@walletDataIncompleteTitle": {
"description": "Title shown when wallet records exist on this device but their seed is missing, so the wallet must be restored from a backup."
},
"walletDataIncompleteRestoreMessage": "This device still has your wallet records, but the seed that unlocks them is gone. Restore your wallet from your backup on this device to use it again.",
"@walletDataIncompleteRestoreMessage": {
"description": "Body copy shown when wallet records survive without their seed: the only remedy is restoring the wallet from a backup."
}
}
Loading