diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 370b0abe..731360f9 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,12 @@ + + + bitboxManager!.signETHRLPTransaction( + chainId ?? 1, + derivationPath!, + bytesToHex(payload), + isEIP1559, + ), ); final r = bytesToHex(sig.sublist(0, 32)); @@ -101,7 +103,9 @@ class BitboxCredentials extends CredentialsWithKnownAddress { Future signPersonalMessage(Uint8List payload, {int? chainId}) { return _synchronizeSign(() async { if (!isConnected) throw const BitboxNotConnectedException(); - return await bitboxManager!.signETHMessage(chainId ?? 1, derivationPath!, payload); + return await _runOrThrowDisconnect( + () => bitboxManager!.signETHMessage(chainId ?? 1, derivationPath!, payload), + ); }); } @@ -113,14 +117,43 @@ class BitboxCredentials extends CredentialsWithKnownAddress { return _synchronizeSign(() async { if (!isConnected) throw const BitboxNotConnectedException(); - final signatureBytes = await bitboxManager!.signETHTypedMessage( - chainId, - derivationPath!, - Uint8List.fromList(utf8.encode(jsonData)), + final signatureBytes = await _runOrThrowDisconnect( + () => bitboxManager!.signETHTypedMessage( + chainId, + derivationPath!, + Uint8List.fromList(utf8.encode(jsonData)), + ), ); return '0x${convert.hex.encode(signatureBytes)}'; }); } + /// Wrap a sign call so that a BLE/USB drop mid-operation surfaces as + /// [BitboxNotConnectedException] instead of a raw plugin error. If the + /// device is still reachable after the failure, the original error wins. + Future _runOrThrowDisconnect(Future Function() op) async { + try { + return await op(); + } catch (_) { + if (await _deviceLost()) { + clearBitbox(); + throw const BitboxNotConnectedException(); + } + rethrow; + } + } + + Future _deviceLost() async { + final manager = bitboxManager; + if (manager == null) return true; + try { + final devices = await manager.devices; + return devices.isEmpty; + } catch (_) { + // Probing the device list itself failed — treat as lost. + return true; + } + } + bool get isConnected => bitboxManager != null; } diff --git a/lib/packages/repository/wallet_repository.dart b/lib/packages/repository/wallet_repository.dart index f04cc516..13b9d582 100644 --- a/lib/packages/repository/wallet_repository.dart +++ b/lib/packages/repository/wallet_repository.dart @@ -9,15 +9,33 @@ class WalletRepository { const WalletRepository(this._appDatabase, this._secureStorage); - Future createWallet(String name, WalletType type, String seed) async { + Future createWallet( + String name, + WalletType type, + String seed, + String address, + ) async { final encryptedSeed = await _encryptSeed(seed); - return _appDatabase.insertWallet(name, encryptedSeed, '', type.index); + return _appDatabase.insertWallet(name, encryptedSeed, address, type.index); } Future createViewWallet(String name, WalletType type, String address) => _appDatabase.insertWallet(name, '', address, type.index); - Future getWalletById(int id) async { + /// Returns the wallet row with the encrypted seed *still encrypted*. Use this + /// at app startup so we don't pay the mnemonic-decrypt / BIP32-derivation + /// cost just to render the dashboard — the cached address is enough. + Future getWalletInfo(int id) => _appDatabase.getWalletById(id); + + /// Backfills the address column for legacy software-wallet rows that were + /// created before address-caching landed. After this runs once, subsequent + /// loads of the same row stay on the fast view-wallet path. + Future updateAddress(int id, String address) => + _appDatabase.updateWalletAddress(id, address); + + /// Returns the wallet row with the seed decrypted. Only call this when the + /// private key is actually needed (signing a sell, revealing the seed). + Future getUnlockedWalletById(int id) async { final info = await _appDatabase.getWalletById(id); if (info == null) return null; if (info.seed.isEmpty) return info; diff --git a/lib/packages/service/app_store.dart b/lib/packages/service/app_store.dart index 48e1fa2e..ef114750 100644 --- a/lib/packages/service/app_store.dart +++ b/lib/packages/service/app_store.dart @@ -10,6 +10,12 @@ class AppStore { AWallet? _wallet; + /// Callback that decrypts the mnemonic and returns a fully unlocked + /// [SoftwareWallet]. Wired up after DI registers `WalletService`; null until + /// then. Used by [ensureUnlocked] so callers don't have to import the + /// service layer just to upgrade a view-wallet. + Future Function()? _unlocker; + AppStore(this.getApiConfig, this.sessionCache); set wallet(AWallet wallet_) => _wallet = wallet_; @@ -22,4 +28,19 @@ class AppStore { ApiConfig get apiConfig => getApiConfig(); String get primaryAddress => wallet.currentAccount.primaryAddress.address.hex; + + void attachUnlocker(Future Function() unlocker) { + _unlocker = unlocker; + } + + /// Upgrades the current wallet from [SoftwareViewWallet] (address only) to a + /// fully unlocked [SoftwareWallet] (mnemonic in memory) so the next sign + /// operation can run. No-op for wallets that aren't locked, or when no + /// unlocker has been wired (e.g. tests). + Future ensureUnlocked() async { + if (_wallet is! SoftwareViewWallet) return; + final unlocker = _unlocker; + if (unlocker == null) return; + _wallet = await unlocker(); + } } diff --git a/lib/packages/service/dfx/dfx_auth_service.dart b/lib/packages/service/dfx/dfx_auth_service.dart index e6353471..033b6715 100644 --- a/lib/packages/service/dfx/dfx_auth_service.dart +++ b/lib/packages/service/dfx/dfx_auth_service.dart @@ -24,21 +24,42 @@ abstract class DFXAuthService { String get walletAddress => wallet.primaryAddress.address.hexEip55; - Future getSignMessage() async { - final uri = buildUri(host, signMessagePath, {'address': walletAddress}); + Future getSignMessage() => _fetchSignMessage(walletAddress); + + /// Create-and-persist the auth signature for [account] without going through + /// `appStore.wallet`. Used during the BitBox pairing flow so the signature is + /// captured while the hardware wallet is guaranteed connected — every + /// subsequent buy / KYC / user-data call can then run off the cached + /// signature without needing the BitBox. + /// + /// No-op if a signature for this address is already in the cache. + Future ensureSignatureFor(AWalletAccount account) async { + final address = account.primaryAddress.address.hexEip55; + await appStore.sessionCache.loadSignature(); + if (appStore.sessionCache.signature != null && + appStore.sessionCache.signatureAddress == address) { + return; + } + final message = await _fetchSignMessage(address); + final signature = await account.signMessage(message).timeout(_signMessageTimeout); + if (signature.isEmpty || signature == '0x') { + throw const SigningCancelledException(); + } + await appStore.sessionCache.saveSignature(address, signature); + } + + Future _fetchSignMessage(String address) async { + final uri = buildUri(host, signMessagePath, {'address': address}); final response = await appStore.httpClient .get(uri, headers: {'accept': 'application/json'}) .timeout(_httpTimeout); - - if (response.statusCode == 200) { - final responseBody = jsonDecode(response.body); - return responseBody['message'] as String; - } else { + if (response.statusCode != 200) { throw Exception( 'Failed to get sign message. Status: ${response.statusCode} ${response.body}', ); } + return (jsonDecode(response.body) as Map)['message'] as String; } // Exceptions this method can throw on the BitBox path: @@ -54,6 +75,9 @@ abstract class DFXAuthService { return cached; } + // Cache miss — we actually need the private key. Decrypt the mnemonic on + // demand if the currently loaded wallet is a view-only software wallet. + await appStore.ensureUnlocked(); final signature = await wallet.signMessage(message).timeout(_signMessageTimeout); if (signature.isEmpty || signature == '0x') { throw const SigningCancelledException(); diff --git a/lib/packages/service/dfx/exceptions/bitbox_exception.dart b/lib/packages/service/dfx/exceptions/bitbox_exception.dart index 65d4bbeb..45462029 100644 --- a/lib/packages/service/dfx/exceptions/bitbox_exception.dart +++ b/lib/packages/service/dfx/exceptions/bitbox_exception.dart @@ -1,3 +1,6 @@ class BitboxNotConnectedException implements Exception { const BitboxNotConnectedException(); + + @override + String toString() => 'BitBox is not connected'; } diff --git a/lib/packages/service/dfx/models/payment/payment_info_error.dart b/lib/packages/service/dfx/models/payment/payment_info_error.dart index 0c53cc40..7f4c7104 100644 --- a/lib/packages/service/dfx/models/payment/payment_info_error.dart +++ b/lib/packages/service/dfx/models/payment/payment_info_error.dart @@ -2,5 +2,6 @@ enum PaymentInfoError { registrationRequired, kycRequired, minAmountNotMet, + bitboxDisconnected, unknown, } diff --git a/lib/packages/service/dfx/real_unit_registration_service.dart b/lib/packages/service/dfx/real_unit_registration_service.dart index 117301b5..2e331604 100644 --- a/lib/packages/service/dfx/real_unit_registration_service.dart +++ b/lib/packages/service/dfx/real_unit_registration_service.dart @@ -51,6 +51,9 @@ class RealUnitRegistrationService extends DFXAuthService { /// registers a wallet and and adds the wallet to the new user Future completeRegistration(Registration registration) async { + // EIP-712 registration signature requires the private key — promote the + // view-wallet (if any) to a fully unlocked SoftwareWallet first. + await appStore.ensureUnlocked(); final credentials = appStore.wallet.primaryAccount.primaryAddress; // BitBox firmware rejects non-ASCII bytes in EIP-712 string fields. // Transliterate everything that goes into the signed envelope AND the diff --git a/lib/packages/service/dfx/real_unit_sell_payment_info_service.dart b/lib/packages/service/dfx/real_unit_sell_payment_info_service.dart index fa512bf7..98a38195 100644 --- a/lib/packages/service/dfx/real_unit_sell_payment_info_service.dart +++ b/lib/packages/service/dfx/real_unit_sell_payment_info_service.dart @@ -78,6 +78,10 @@ class RealUnitSellPaymentInfoService extends DFXAuthService { /// Confirms payment for Software Wallet Future confirmPayment(SellPaymentInfo paymentInfo) async { + // EIP-712 + EIP-7702 typed-data signing requires the private key; promote + // the view-wallet to a fully unlocked SoftwareWallet before reading + // credentials. + await appStore.ensureUnlocked(); final credentials = appStore.wallet.currentAccount.primaryAddress; _validateEip7702Data(paymentInfo.eip7702, credentials.address.hexEip55, paymentInfo.amount); diff --git a/lib/packages/service/wallet_service.dart b/lib/packages/service/wallet_service.dart index b2ab069e..875b9f72 100644 --- a/lib/packages/service/wallet_service.dart +++ b/lib/packages/service/wallet_service.dart @@ -13,8 +13,7 @@ class WalletService { Future createSeedWallet(String name) async { final mnemonic = bip39.generateMnemonic(); - final walletId = await _repository.createWallet(name, WalletType.software, mnemonic); - return SoftwareWallet(walletId, name, mnemonic); + return _persistSoftwareWallet(name, mnemonic); } Future createBitboxWallet(String name) async { @@ -25,9 +24,19 @@ class WalletService { } Future restoreWallet(String name, String seed) async { - final walletId = await _repository.createWallet(name, WalletType.software, seed); - await _settingsRepository.saveCurrentWalletId(walletId); - return SoftwareWallet(walletId, name, seed); + final wallet = await _persistSoftwareWallet(name, seed); + await _settingsRepository.saveCurrentWalletId(wallet.id); + return wallet; + } + + /// Builds the BIP32 wallet once to derive the public address, then persists + /// `(encryptedSeed, address)` so app-start can render the dashboard from the + /// cached address without re-running the derivation. + Future _persistSoftwareWallet(String name, String seed) async { + final fullWallet = SoftwareWallet(0, name, seed); + final address = fullWallet.currentAccount.primaryAddress.address.hexEip55; + final id = await _repository.createWallet(name, WalletType.software, seed, address); + return SoftwareWallet(id, name, seed); } Future createDebugWallet(String address) async { @@ -36,19 +45,45 @@ class WalletService { return DebugWallet(walletId, 'Debug', address); } + /// Loads a wallet using only what's persisted in clear text — for software + /// wallets this means a [SoftwareViewWallet] (address only, no mnemonic in + /// memory). Use [unlockWalletById] when the private key is actually needed. Future getWalletById(int id) async { - final result = (await _repository.getWalletById(id))!; - final walletType = WalletType.values[result.type]; + final info = (await _repository.getWalletInfo(id))!; + final walletType = WalletType.values[info.type]; switch (walletType) { case WalletType.software: - return SoftwareWallet(result.id, result.name, result.seed); + // Legacy rows created before address-caching landed have an empty + // address column — decrypt the mnemonic this one time, persist the + // derived address back to the row, then keep using the fast path on + // subsequent loads. + if (info.address.isEmpty) { + final unlocked = (await _repository.getUnlockedWalletById(id))!; + final wallet = SoftwareWallet(unlocked.id, unlocked.name, unlocked.seed); + await _repository.updateAddress( + id, + wallet.currentAccount.primaryAddress.address.hexEip55, + ); + return wallet; + } + return SoftwareViewWallet(info.id, info.name, info.address); case WalletType.bitbox: - return BitboxWallet(result.id, result.name, result.address, _bitboxService); + return BitboxWallet(info.id, info.name, info.address, _bitboxService); case WalletType.debug: - return DebugWallet(result.id, result.name, result.address); + return DebugWallet(info.id, info.name, info.address); } } + /// Decrypts the mnemonic and returns a [SoftwareWallet] ready to sign. + /// Throws if the wallet type is not software. + Future unlockWalletById(int id) async { + final info = (await _repository.getUnlockedWalletById(id))!; + if (WalletType.values[info.type] != WalletType.software) { + throw StateError('unlockWalletById called for non-software wallet'); + } + return SoftwareWallet(info.id, info.name, info.seed); + } + Future setCurrentWallet(int walletId) async => await _settingsRepository.saveCurrentWalletId(walletId); @@ -57,6 +92,11 @@ class WalletService { return getWalletById(id); } + Future unlockCurrentWallet() async { + final id = _settingsRepository.currentWalletId!; + return unlockWalletById(id); + } + Future deleteCurrentWallet() async { final id = _settingsRepository.currentWalletId!; await _repository.deleteWallet(id); diff --git a/lib/packages/storage/secure_storage.dart b/lib/packages/storage/secure_storage.dart index baef701f..9bb0f1f7 100644 --- a/lib/packages/storage/secure_storage.dart +++ b/lib/packages/storage/secure_storage.dart @@ -41,8 +41,16 @@ class SecureStorage { return Uint8List.fromList(List.generate(16, (_) => random.nextInt(256))); } - static const _pinHashIterations = 600000; - static const _legacyPinHashIterations = 10000; + // PIN-hash iteration count, picked for sub-second verification on mid-range + // phones. The PIN hash + salt live in [FlutterSecureStorage] (Android Keystore + // / iOS Keychain), so an offline brute-force first requires breaking that + // hardware-backed boundary. Online brute-force against the app UI is bounded + // by the lockout cascade in `verify_pin_cubit.dart`. The stronger guarantee + // for the actual private key comes from the OS-keystore-managed mnemonic + // encryption key — not from this hash. Earlier 10k / 600k hashes are still + // accepted and transparently upgraded to [_pinHashIterations]. + static const _pinHashIterations = 100000; + static const _legacyIterationCandidates = [600000, 10000]; static String hashPin(String pin, Uint8List salt, {int iterations = _pinHashIterations}) { final derivator = KeyDerivator('SHA-256/HMAC/PBKDF2'); @@ -51,9 +59,9 @@ class SecureStorage { return bytesToHex(derivator.process(utf8.encode(pin))); } - /// Off-main-thread variant of [hashPin]. PBKDF2 with 600k iterations takes - /// several seconds on a phone and freezes the UI when run synchronously, so - /// any caller reachable from the UI should await this instead. + /// Off-main-thread variant of [hashPin]. Even at the reduced iteration count + /// PBKDF2 dominates the visible unlock latency, so any caller reachable from + /// the UI should await this instead of running it synchronously. static Future hashPinAsync( String pin, Uint8List salt, { @@ -90,11 +98,15 @@ class SecureStorage { if (await hashPinAsync(pin, salt) == hash) return true; - // Transparent rehash: verify against legacy iterations, upgrade if match - if (await hashPinAsync(pin, salt, iterations: _legacyPinHashIterations) == hash) { - final newHash = await hashPinAsync(pin, salt); - await setPinHash(newHash); - return true; + // Transparent rehash: any earlier iteration count we ever shipped is still + // accepted exactly once, then upgraded to the current target so subsequent + // unlocks pay the fast path. + for (final legacy in _legacyIterationCandidates) { + if (await hashPinAsync(pin, salt, iterations: legacy) == hash) { + final newHash = await hashPinAsync(pin, salt); + await setPinHash(newHash); + return true; + } } return false; diff --git a/lib/packages/storage/wallet_storage.dart b/lib/packages/storage/wallet_storage.dart index b348e0f8..87dc1f0c 100644 --- a/lib/packages/storage/wallet_storage.dart +++ b/lib/packages/storage/wallet_storage.dart @@ -9,6 +9,10 @@ extension WalletStorage on AppDatabase { Future getWalletById(int id) => (select(walletInfos)..where((row) => row.id.equals(id))).getSingleOrNull(); + Future updateWalletAddress(int id, String address) => + (update(walletInfos)..where((row) => row.id.equals(id))) + .write(WalletInfosCompanion(address: Value(address))); + Future insertWalletAccount(int walletId, String name, int accountIndex) => into(walletAccountInfos).insert( WalletAccountInfosCompanion.insert( diff --git a/lib/packages/wallet/exceptions/wallet_locked_exception.dart b/lib/packages/wallet/exceptions/wallet_locked_exception.dart new file mode 100644 index 00000000..180aec97 --- /dev/null +++ b/lib/packages/wallet/exceptions/wallet_locked_exception.dart @@ -0,0 +1,9 @@ +/// Thrown when a sign operation hits a [SoftwareViewWallet] — the mnemonic is +/// still encrypted on disk and the caller must unlock the wallet (via +/// `WalletService.unlockCurrentWallet`) before signing. +class WalletLockedException implements Exception { + const WalletLockedException(); + + @override + String toString() => 'Wallet is locked'; +} diff --git a/lib/packages/wallet/wallet.dart b/lib/packages/wallet/wallet.dart index b83e3a08..1ff45cc7 100644 --- a/lib/packages/wallet/wallet.dart +++ b/lib/packages/wallet/wallet.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:bip32/bip32.dart'; import 'package:bip39/bip39.dart'; import 'package:realunit_wallet/packages/hardware_wallet/bitbox.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/wallet_locked_exception.dart'; import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; import 'package:web3dart/crypto.dart'; import 'package:web3dart/web3dart.dart'; @@ -46,6 +47,54 @@ class SoftwareWallet extends AWallet { void selectAccount(int index) => _currentAccount = WalletAccount(_bip32, index); } +/// Software wallet without the mnemonic in memory — only the public address is +/// cached. Used at app startup so the dashboard renders before the (expensive +/// and rarely needed) BIP32 derivation happens. Must be upgraded to a full +/// [SoftwareWallet] via `WalletService.unlockCurrentWallet` before any sign +/// operation. +class SoftwareViewWallet extends AWallet { + @override + WalletType get walletType => WalletType.software; + + @override + late final SoftwareViewWalletAccount primaryAccount; + + late SoftwareViewWalletAccount _currentAccount; + + @override + SoftwareViewWalletAccount get currentAccount => _currentAccount; + + SoftwareViewWallet(super.id, super.name, String address) { + primaryAccount = SoftwareViewWalletAccount(0, _LockedCredentials(address)); + _currentAccount = primaryAccount; + } +} + +class _LockedCredentials extends CredentialsWithKnownAddress { + final EthereumAddress _address; + + _LockedCredentials(String hexAddress) : _address = EthereumAddress.fromHex(hexAddress); + + @override + EthereumAddress get address => _address; + + @override + MsgSignature signToEcSignature(Uint8List payload, {int? chainId, bool isEIP1559 = false}) => + throw const WalletLockedException(); + + @override + Future signToSignature(Uint8List payload, {int? chainId, bool isEIP1559 = false}) => + throw const WalletLockedException(); + + @override + Future signPersonalMessage(Uint8List payload, {int? chainId}) => + throw const WalletLockedException(); + + @override + Uint8List signPersonalMessageToUint8List(Uint8List payload, {int? chainId}) => + throw const WalletLockedException(); +} + class BitboxWallet extends AWallet { @override WalletType get walletType => WalletType.bitbox; diff --git a/lib/packages/wallet/wallet_account.dart b/lib/packages/wallet/wallet_account.dart index c700a08c..ac7777b6 100644 --- a/lib/packages/wallet/wallet_account.dart +++ b/lib/packages/wallet/wallet_account.dart @@ -2,6 +2,7 @@ import 'dart:convert' show utf8; import 'package:bip32/bip32.dart'; import 'package:convert/convert.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/wallet_locked_exception.dart'; import 'package:web3dart/web3dart.dart'; abstract class AWalletAccount { @@ -39,3 +40,11 @@ class BitboxWalletAccount extends AWalletAccount { Future signMessage(String message, {int addressIndex = 0}) async => '0x${hex.encode(await primaryAddress.signPersonalMessage(utf8.encode(message)))}'; } + +class SoftwareViewWalletAccount extends AWalletAccount { + SoftwareViewWalletAccount(super.accountIndex, super.primaryAddress); + + @override + Future signMessage(String message, {int addressIndex = 0}) => + throw const WalletLockedException(); +} diff --git a/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart b/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart index a598c04a..b65a2aed 100644 --- a/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart +++ b/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart @@ -4,6 +4,7 @@ import 'package:async/async.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_price_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/buy_exceptions.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/buy/buy_payment_info.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/payment_info_error.dart'; @@ -70,6 +71,8 @@ class BuyPaymentInfoCubit extends Cubit { ); } on RegistrationRequiredException { return const BuyPaymentInfoFailure(PaymentInfoError.registrationRequired); + } on BitboxNotConnectedException { + return const BuyPaymentInfoFailure(PaymentInfoError.bitboxDisconnected); } catch (e) { developer.log(e.toString()); return const BuyPaymentInfoFailure(PaymentInfoError.unknown); diff --git a/lib/screens/buy/widgets/payment_additional_action_needed_button.dart b/lib/screens/buy/widgets/payment_additional_action_needed_button.dart index 14d92049..9c2c21cf 100644 --- a/lib/screens/buy/widgets/payment_additional_action_needed_button.dart +++ b/lib/screens/buy/widgets/payment_additional_action_needed_button.dart @@ -5,6 +5,7 @@ import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/payment_info_error.dart'; import 'package:realunit_wallet/screens/buy/cubits/buy_converter/buy_converter_cubit.dart'; import 'package:realunit_wallet/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart'; +import 'package:realunit_wallet/screens/hardware_connect_bitbox/show_bitbox_reconnect_sheet.dart'; import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; import 'package:realunit_wallet/styles/colors.dart'; import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; @@ -86,6 +87,23 @@ class PaymentAdditionalActionNeededButton extends StatelessWidget { ), ); } + if (paymentState.error == PaymentInfoError.bitboxDisconnected) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: AppFilledButton( + onPressed: () async { + final paymentInfoCubit = context.read(); + final converterCubit = context.read(); + await showBitboxReconnectSheet(context); + paymentInfoCubit.getPaymentInfo( + amount: amountController.text, + currency: converterCubit.state.currency, + ); + }, + label: S.of(context).bitboxReconnect, + ), + ); + } if (paymentState.error == PaymentInfoError.unknown) { return Padding( padding: const EdgeInsets.symmetric(vertical: 20), diff --git a/lib/screens/buy/widgets/payment_information.dart b/lib/screens/buy/widgets/payment_information.dart index 3c6c4357..c3a9f1b5 100644 --- a/lib/screens/buy/widgets/payment_information.dart +++ b/lib/screens/buy/widgets/payment_information.dart @@ -38,6 +38,11 @@ class PaymentInformation extends StatelessWidget { title: S.of(context).identityCheckRequired, description: S.of(context).identityCheckDescription, ); + } else if (error == PaymentInfoError.bitboxDisconnected) { + return PaymentActionRequired( + title: S.of(context).bitboxDisconnectedTitle, + description: S.of(context).bitboxDisconnectedDescription, + ); } else if (error == PaymentInfoError.unknown) { return PaymentActionRequired( title: S.of(context).paymentInformationFailed, diff --git a/lib/screens/create_wallet/bloc/create_wallet_cubit.dart b/lib/screens/create_wallet/bloc/create_wallet_cubit.dart index 9610733a..65334e28 100644 --- a/lib/screens/create_wallet/bloc/create_wallet_cubit.dart +++ b/lib/screens/create_wallet/bloc/create_wallet_cubit.dart @@ -1,17 +1,33 @@ +import 'dart:developer' as developer; + import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart'; import 'package:realunit_wallet/packages/service/wallet_service.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; part 'create_wallet_state.dart'; class CreateWalletCubit extends Cubit { - CreateWalletCubit(this._service) : super(const CreateWalletState()); + CreateWalletCubit(this._service, this._authService) : super(const CreateWalletState()); final WalletService _service; + final DFXAuthService _authService; void createWallet() async { final wallet = await _service.createSeedWallet('Obi-Wallet-Kenobi'); + // Capture the DFX auth signature while the freshly generated mnemonic is + // still in memory — same rationale as the BitBox pairing flow. Non-fatal + // on failure; the lazy path in DFXAuthService.getSignature recovers later. + try { + await _authService.ensureSignatureFor(wallet.currentAccount); + } catch (e) { + developer.log( + 'initial signature capture failed: $e', + name: '$CreateWalletCubit', + ); + } + emit(state.copyWith(wallet: wallet)); } diff --git a/lib/screens/create_wallet/create_wallet_page.dart b/lib/screens/create_wallet/create_wallet_page.dart index 5092dd0e..b7ff75be 100644 --- a/lib/screens/create_wallet/create_wallet_page.dart +++ b/lib/screens/create_wallet/create_wallet_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; import 'package:realunit_wallet/packages/service/wallet_service.dart'; import 'package:realunit_wallet/screens/create_wallet/bloc/create_wallet_cubit.dart'; import 'package:realunit_wallet/screens/create_wallet/create_wallet_view.dart'; @@ -10,7 +11,12 @@ class CreateWalletPage extends StatelessWidget { @override Widget build(BuildContext context) => BlocProvider( - create: (_) => CreateWalletCubit(getIt())..createWallet(), + create: (_) => CreateWalletCubit( + getIt(), + // DfxKycService is the smallest concrete DFXAuthService — only used here + // as a transport for ensureSignatureFor(account). + getIt(), + )..createWallet(), child: const CreateWalletView(), ); } diff --git a/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart b/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart index d7b46fe6..73044e04 100644 --- a/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart +++ b/lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart @@ -4,6 +4,7 @@ import 'dart:developer' as developer; import 'package:bitbox_flutter/bitbox_flutter.dart' as sdk; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/hardware_wallet/bitbox.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart'; import 'package:realunit_wallet/packages/service/wallet_service.dart'; import 'package:realunit_wallet/packages/utils/device_info.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; @@ -25,7 +26,8 @@ class ConnectBitboxCubit extends Cubit { ConnectBitboxCubit( this._service, - this._walletService, { + this._walletService, + this._authService, { Duration confirmPairingTimeout = _defaultConfirmPairingTimeout, Duration createWalletTimeout = _defaultCreateWalletTimeout, Duration pairingPinTimeout = _defaultPairingPinTimeout, @@ -47,6 +49,7 @@ class ConnectBitboxCubit extends Cubit { final BitboxService _service; final WalletService _walletService; + final DFXAuthService _authService; Timer? _checkForTimer; Future? _pendingInit; @@ -149,6 +152,19 @@ class ConnectBitboxCubit extends Cubit { '${_createWalletTimeout.inSeconds}s. Try disconnecting and re-pairing.', ), ); + // Capture the DFX auth signature now, while the BitBox is guaranteed + // connected. Once persisted, every later buy / KYC / user-data call runs + // off the cached signature without needing the hardware wallet present. + // A failure here is non-fatal: we still complete pairing and rely on the + // lazy-create path in DFXAuthService.getSignature as a fallback. + try { + await _authService.ensureSignatureFor(wallet.currentAccount); + } catch (e) { + developer.log( + 'initial signature capture failed: $e', + name: '$ConnectBitboxCubit', + ); + } _service.startConnectionStatusObserver(); emit(BitboxConnected(wallet)); } catch (e) { @@ -169,6 +185,9 @@ class ConnectBitboxCubit extends Cubit { @override Future close() { _checkForTimer?.cancel(); + // Detach from the in-flight init future so any late error doesn't surface + // as an unhandled exception after the cubit is gone. + _pendingInit?.ignore(); _pendingInit = null; return super.close(); } diff --git a/lib/screens/hardware_connect_bitbox/connect_bitbox_page.dart b/lib/screens/hardware_connect_bitbox/connect_bitbox_page.dart index 45df05c7..54c329e6 100644 --- a/lib/screens/hardware_connect_bitbox/connect_bitbox_page.dart +++ b/lib/screens/hardware_connect_bitbox/connect_bitbox_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/hardware_wallet/bitbox.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; import 'package:realunit_wallet/packages/service/wallet_service.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; import 'package:realunit_wallet/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart'; @@ -17,6 +18,9 @@ class ConnectBitboxPage extends StatelessWidget { create: (_) => ConnectBitboxCubit( getIt(), getIt(), + // DfxKycService is the smallest registered DFXAuthService — used only as + // a transport for ensureSignatureFor(account); no KYC-specific calls here. + getIt(), ), child: ConnectBitboxView(onFinish: onFinish), ); diff --git a/lib/screens/hardware_connect_bitbox/show_bitbox_reconnect_sheet.dart b/lib/screens/hardware_connect_bitbox/show_bitbox_reconnect_sheet.dart new file mode 100644 index 00000000..b350636f --- /dev/null +++ b/lib/screens/hardware_connect_bitbox/show_bitbox_reconnect_sheet.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/screens/hardware_connect_bitbox/connect_bitbox_page.dart'; +import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; + +/// Opens the BitBox pairing flow as a bottom sheet for the +/// already-onboarded user, e.g. after the BitBox has been disconnected and a +/// later action (buy info refresh, sell signing, user-data fetch) needs the +/// device back. +/// +/// Emits `SyncWalletServicesEvent` instead of `LoadWalletEvent` because the +/// wallet itself is unchanged — only the underlying transport needs to be +/// re-attached. Returns `true` if the user completed the re-pair. +Future showBitboxReconnectSheet(BuildContext context) async { + final homeBloc = context.read(); + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (sheetContext) => ConnectBitboxPage( + onFinish: (wallet) { + homeBloc.add(SyncWalletServicesEvent(wallet)); + Navigator.of(sheetContext).pop(true); + }, + ), + ); + return result == true; +} diff --git a/lib/screens/restore_wallet/cubit/restore_wallet/restore_wallet_cubit.dart b/lib/screens/restore_wallet/cubit/restore_wallet/restore_wallet_cubit.dart index c1e0381f..27ee43fe 100644 --- a/lib/screens/restore_wallet/cubit/restore_wallet/restore_wallet_cubit.dart +++ b/lib/screens/restore_wallet/cubit/restore_wallet/restore_wallet_cubit.dart @@ -1,14 +1,19 @@ +import 'dart:developer' as developer; + import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart'; import 'package:realunit_wallet/packages/service/wallet_service.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; part 'restore_wallet_state.dart'; class RestoreWalletCubit extends Cubit { - RestoreWalletCubit(this._walletService) : super(const RestoreWalletState()); + RestoreWalletCubit(this._walletService, this._authService) + : super(const RestoreWalletState()); final WalletService _walletService; + final DFXAuthService _authService; void restoreWallet(String seed) async { emit(const RestoreWalletState(isLoading: true)); @@ -17,6 +22,18 @@ class RestoreWalletCubit extends Cubit { final wallet = await _walletService.restoreWallet('Obi-Wallet-Kenobi', normalizedSeed); + // Capture the DFX auth signature while the freshly restored mnemonic is + // still in memory — same rationale as the BitBox pairing flow. Non-fatal + // on failure; the lazy path in DFXAuthService.getSignature recovers later. + try { + await _authService.ensureSignatureFor(wallet.currentAccount); + } catch (e) { + developer.log( + 'initial signature capture failed: $e', + name: '$RestoreWalletCubit', + ); + } + emit( RestoreWalletState( isLoading: false, @@ -24,5 +41,4 @@ class RestoreWalletCubit extends Cubit { ), ); } - } diff --git a/lib/screens/restore_wallet/restore_wallet_page.dart b/lib/screens/restore_wallet/restore_wallet_page.dart index e16903f7..6325b25d 100644 --- a/lib/screens/restore_wallet/restore_wallet_page.dart +++ b/lib/screens/restore_wallet/restore_wallet_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; import 'package:realunit_wallet/packages/service/wallet_service.dart'; import 'package:realunit_wallet/screens/restore_wallet/cubit/restore_wallet/restore_wallet_cubit.dart'; import 'package:realunit_wallet/screens/restore_wallet/cubit/validate_seed/validate_seed_cubit.dart'; @@ -15,6 +16,9 @@ class RestoreWalletPage extends StatelessWidget { BlocProvider( create: (_) => RestoreWalletCubit( getIt(), + // DfxKycService is the smallest concrete DFXAuthService — only used + // here as a transport for ensureSignatureFor(account). + getIt(), ), ), BlocProvider( diff --git a/lib/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart b/lib/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart index 919115ad..00f6d3fc 100644 --- a/lib/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart +++ b/lib/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart @@ -4,6 +4,7 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_price_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/buy_exceptions.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/payment_info_error.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/sell_payment_info.dart'; @@ -60,6 +61,13 @@ class SellPaymentInfoCubit extends Cubit { message: e.toString(), ), ); + } on BitboxNotConnectedException catch (e) { + emit( + SellPaymentInfoFailure( + PaymentInfoError.bitboxDisconnected, + message: e.toString(), + ), + ); } catch (e) { developer.log(e.toString()); emit( diff --git a/lib/screens/sell_bitbox/cubit/sell_bitbox_cubit.dart b/lib/screens/sell_bitbox/cubit/sell_bitbox_cubit.dart index 0d93fa68..c4c36785 100644 --- a/lib/screens/sell_bitbox/cubit/sell_bitbox_cubit.dart +++ b/lib/screens/sell_bitbox/cubit/sell_bitbox_cubit.dart @@ -8,6 +8,7 @@ import 'package:realunit_wallet/packages/hardware_wallet/bitbox_credentials.dart import 'package:realunit_wallet/packages/service/app_store.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/broadcast_transaction_request_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/sell_payment_info.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_sell_payment_info_service.dart'; @@ -71,6 +72,9 @@ class SellBitboxCubit extends Cubit { } void _startEthPolling() { + // Cancel any prior timer first — retryAfterConnection can re-enter the + // faucet/polling branch while a previous timer is still alive. + _ethPollingTimer?.cancel(); _ethPollingTimer = Timer.periodic(const Duration(seconds: 5), (_) async { try { final balance = await _blockchainService.getEthBalance(_appStore.primaryAddress); @@ -103,11 +107,17 @@ class SellBitboxCubit extends Cubit { emit(SellBitboxError('BitBox wallet not connected')); return; } + if (!credentials.isConnected) { + emit(SellBitboxBitboxRequired()); + return; + } try { emit(SellBitboxSwapping()); final signedSwap = await _signTransaction(swapState.rawSwapTransaction, credentials); emit(SellBitboxAwaitingDepositConfirm(signedSwap, swapState.rawDepositTransaction)); + } on BitboxNotConnectedException { + emit(SellBitboxBitboxRequired()); } catch (e) { emit(SellBitboxError(e.toString())); } @@ -122,6 +132,10 @@ class SellBitboxCubit extends Cubit { emit(SellBitboxError('BitBox wallet not connected')); return; } + if (!credentials.isConnected) { + emit(SellBitboxBitboxRequired()); + return; + } try { emit(SellBitboxDepositing()); @@ -131,6 +145,8 @@ class SellBitboxCubit extends Cubit { depositState.signedSwapTransaction, ); await _broadcastDepositAndConfirm(depositState.signedSwapTransaction, signedDeposit); + } on BitboxNotConnectedException { + emit(SellBitboxBitboxRequired()); } catch (e) { emit(SellBitboxError(e.toString())); } diff --git a/lib/screens/sell_bitbox/sell_bitbox_page.dart b/lib/screens/sell_bitbox/sell_bitbox_page.dart index 1cc1cb9e..8a3effdc 100644 --- a/lib/screens/sell_bitbox/sell_bitbox_page.dart +++ b/lib/screens/sell_bitbox/sell_bitbox_page.dart @@ -7,8 +7,7 @@ import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service. import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/sell_payment_info.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_sell_payment_info_service.dart'; -import 'package:realunit_wallet/screens/hardware_connect_bitbox/connect_bitbox_page.dart'; -import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; +import 'package:realunit_wallet/screens/hardware_connect_bitbox/show_bitbox_reconnect_sheet.dart'; import 'package:realunit_wallet/screens/sell_bitbox/cubit/sell_bitbox_cubit.dart'; import 'package:realunit_wallet/screens/sell_bitbox/widgets/sell_bitbox_deposit_step.dart'; import 'package:realunit_wallet/screens/sell_bitbox/widgets/sell_bitbox_eth_step.dart'; @@ -47,17 +46,8 @@ class SellBitboxView extends StatelessWidget { return BlocConsumer( listener: (context, state) async { if (state is SellBitboxBitboxRequired) { - final result = await showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (_) => ConnectBitboxPage( - onFinish: (wallet) { - context.read().add(SyncWalletServicesEvent(wallet)); - context.pop(true); - }, - ), - ); - if (result == true && context.mounted) { + final reconnected = await showBitboxReconnectSheet(context); + if (reconnected && context.mounted) { context.read().retryAfterConnection(); } return; diff --git a/lib/screens/settings_seed/bloc/settings_seed_cubit.dart b/lib/screens/settings_seed/bloc/settings_seed_cubit.dart index f9f3ed6a..23467a95 100644 --- a/lib/screens/settings_seed/bloc/settings_seed_cubit.dart +++ b/lib/screens/settings_seed/bloc/settings_seed_cubit.dart @@ -1,13 +1,24 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; part 'settings_seed_state.dart'; class SettingsSeedCubit extends Cubit { - final SoftwareWallet wallet; + final AppStore _appStore; - SettingsSeedCubit(this.wallet) : super(SettingsSeedState(wallet.seed)); + SettingsSeedCubit(this._appStore) : super(const SettingsSeedState('')) { + _loadSeed(); + } + + Future _loadSeed() async { + // Revealing the recovery phrase needs the actual mnemonic in memory — + // promote a view-wallet to its unlocked form before reading the seed. + await _appStore.ensureUnlocked(); + final wallet = _appStore.wallet as SoftwareWallet; + emit(SettingsSeedState(wallet.seed)); + } void toggleShowSeed() => emit(state.copyWith(showSeed: !state.showSeed)); } diff --git a/lib/screens/settings_seed/settings_seed_page.dart b/lib/screens/settings_seed/settings_seed_page.dart index ebd0ea6c..35ac7be7 100644 --- a/lib/screens/settings_seed/settings_seed_page.dart +++ b/lib/screens/settings_seed/settings_seed_page.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; -import 'package:realunit_wallet/packages/wallet/wallet.dart'; import 'package:realunit_wallet/screens/settings_seed/bloc/settings_seed_cubit.dart'; import 'package:realunit_wallet/screens/settings_seed/settings_seed_view.dart'; import 'package:realunit_wallet/setup/di.dart'; @@ -11,7 +10,7 @@ class SettingsSeedPage extends StatelessWidget { @override Widget build(BuildContext context) => BlocProvider( - create: (_) => SettingsSeedCubit((getIt().wallet as SoftwareWallet)), + create: (_) => SettingsSeedCubit(getIt()), child: const SettingsSeedView(), ); } diff --git a/lib/screens/settings_user_data/cubit/settings_user_data_cubit.dart b/lib/screens/settings_user_data/cubit/settings_user_data_cubit.dart index c8332766..36fc8c18 100644 --- a/lib/screens/settings_user_data/cubit/settings_user_data_cubit.dart +++ b/lib/screens/settings_user_data/cubit/settings_user_data_cubit.dart @@ -1,7 +1,10 @@ +import 'dart:developer' as developer; + import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_level_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_user_type.dart'; @@ -90,8 +93,11 @@ class SettingsUserDataCubit extends Cubit { pendingSteps: pendingSteps, ), ); + } on BitboxNotConnectedException { + emit(const SettingsUserDataBitboxDisconnected()); } catch (e) { - emit(SettingsUserDataFailure(e.toString())); + developer.log(e.toString()); + emit(const SettingsUserDataFailure()); } } } diff --git a/lib/screens/settings_user_data/cubit/settings_user_data_state.dart b/lib/screens/settings_user_data/cubit/settings_user_data_state.dart index a2e5d3b9..7c2d5514 100644 --- a/lib/screens/settings_user_data/cubit/settings_user_data_state.dart +++ b/lib/screens/settings_user_data/cubit/settings_user_data_state.dart @@ -16,12 +16,11 @@ class SettingsUserDataLoading extends SettingsUserDataState { } class SettingsUserDataFailure extends SettingsUserDataState { - final String message; - - const SettingsUserDataFailure(this.message); + const SettingsUserDataFailure(); +} - @override - List get props => [message]; +class SettingsUserDataBitboxDisconnected extends SettingsUserDataState { + const SettingsUserDataBitboxDisconnected(); } class SettingsUserDataSuccess extends SettingsUserDataState { diff --git a/lib/screens/settings_user_data/settings_user_data_page.dart b/lib/screens/settings_user_data/settings_user_data_page.dart index 547eb8fd..f19caa1e 100644 --- a/lib/screens/settings_user_data/settings_user_data_page.dart +++ b/lib/screens/settings_user_data/settings_user_data_page.dart @@ -8,10 +8,12 @@ import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; import 'package:realunit_wallet/packages/service/dfx/models/kyc/kyc_level.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_wallet_service.dart'; +import 'package:realunit_wallet/screens/hardware_connect_bitbox/show_bitbox_reconnect_sheet.dart'; import 'package:realunit_wallet/screens/settings_user_data/cubit/settings_user_data_cubit.dart'; import 'package:realunit_wallet/setup/di.dart'; import 'package:realunit_wallet/setup/routing/routes/settings_routes.dart'; import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; class SettingsUserDataPage extends StatelessWidget { const SettingsUserDataPage({super.key}); @@ -129,8 +131,11 @@ class SettingsUserDataView extends StatelessWidget { SettingsUserDataLoading() => const Center( child: CupertinoActivityIndicator(), ), - SettingsUserDataFailure(:final message) => Center( - child: Text(message), + SettingsUserDataBitboxDisconnected() => _BitboxDisconnectedView( + onReconnected: () => context.read().getUserData(), + ), + SettingsUserDataFailure() => Center( + child: Text(S.of(context).userDataLoadFailed), ), _ => const SizedBox.shrink(), @@ -141,6 +146,46 @@ class SettingsUserDataView extends StatelessWidget { } } +class _BitboxDisconnectedView extends StatelessWidget { + const _BitboxDisconnectedView({required this.onReconnected}); + + final VoidCallback onReconnected; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 16, + children: [ + Text( + S.of(context).bitboxDisconnectedTitle, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + Text( + S.of(context).bitboxDisconnectedDescription, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: RealUnitColors.neutral500, + ), + ), + AppFilledButton( + onPressed: () async { + await showBitboxReconnectSheet(context); + onReconnected(); + }, + label: S.of(context).bitboxReconnect, + ), + ], + ), + ), + ); + } +} + class _UserDataRow extends StatelessWidget { const _UserDataRow({ required this.label, diff --git a/lib/setup/di.dart b/lib/setup/di.dart index fa844228..5436b079 100644 --- a/lib/setup/di.dart +++ b/lib/setup/di.dart @@ -126,6 +126,12 @@ void setupServices() { getIt(), ), ); + // Wire the lazy-unlock callback so `AppStore.ensureUnlocked()` can promote + // a view-wallet to an unlocked SoftwareWallet without the auth-service layer + // having to import WalletService directly. + getIt().attachUnlocker( + () => getIt().unlockCurrentWallet(), + ); getIt.registerFactory( () => TransactionHistoryService( getIt(), diff --git a/test/packages/service/dfx/dfx_auth_service_test.dart b/test/packages/service/dfx/dfx_auth_service_test.dart index 21ed8002..15790e91 100644 --- a/test/packages/service/dfx/dfx_auth_service_test.dart +++ b/test/packages/service/dfx/dfx_auth_service_test.dart @@ -113,6 +113,7 @@ void main() { walletAccount = _StubWalletAccount(validSig); when(() => appStore.sessionCache).thenReturn(sessionCache); + when(() => appStore.ensureUnlocked()).thenAnswer((_) async {}); when(() => sessionCache.signature).thenReturn(null); when(() => sessionCache.signatureAddress).thenReturn(null); when(() => sessionCache.authToken).thenReturn(null); diff --git a/test/packages/service/dfx/real_unit_registration_service_happy_test.dart b/test/packages/service/dfx/real_unit_registration_service_happy_test.dart index bbb96746..586d7bb3 100644 --- a/test/packages/service/dfx/real_unit_registration_service_happy_test.dart +++ b/test/packages/service/dfx/real_unit_registration_service_happy_test.dart @@ -50,6 +50,7 @@ void main() { .thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); when(() => appStore.sessionCache).thenReturn(session); when(() => appStore.wallet).thenReturn(wallet); + when(() => appStore.ensureUnlocked()).thenAnswer((_) async {}); when(() => wallet.primaryAccount).thenReturn(account); when(() => account.primaryAddress).thenReturn(_privKey); }); diff --git a/test/packages/service/dfx/real_unit_registration_service_test.dart b/test/packages/service/dfx/real_unit_registration_service_test.dart index 923d12b1..7a3539fa 100644 --- a/test/packages/service/dfx/real_unit_registration_service_test.dart +++ b/test/packages/service/dfx/real_unit_registration_service_test.dart @@ -48,6 +48,7 @@ void main() { .thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); when(() => appStore.sessionCache).thenReturn(session); when(() => appStore.wallet).thenReturn(wallet); + when(() => appStore.ensureUnlocked()).thenAnswer((_) async {}); when(() => wallet.primaryAccount).thenReturn(account); }); diff --git a/test/packages/service/dfx/real_unit_sell_payment_info_service_confirm_test.dart b/test/packages/service/dfx/real_unit_sell_payment_info_service_confirm_test.dart index 1d44ff67..da2b7295 100644 --- a/test/packages/service/dfx/real_unit_sell_payment_info_service_confirm_test.dart +++ b/test/packages/service/dfx/real_unit_sell_payment_info_service_confirm_test.dart @@ -100,6 +100,9 @@ void main() { .thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); when(() => appStore.sessionCache).thenReturn(session); when(() => appStore.wallet).thenReturn(wallet); + // Wallet is already a full SoftwareWallet in this fixture — ensureUnlocked + // is a no-op for non-view wallets, but mocktail still needs a stub. + when(() => appStore.ensureUnlocked()).thenAnswer((_) async {}); when(() => wallet.currentAccount).thenReturn(account); when(() => account.primaryAddress).thenReturn(_privKey); }); diff --git a/test/packages/service/wallet_service_test.dart b/test/packages/service/wallet_service_test.dart index 80790485..473e37d4 100644 --- a/test/packages/service/wallet_service_test.dart +++ b/test/packages/service/wallet_service_test.dart @@ -46,12 +46,13 @@ void main() { when(() => settings.saveCurrentWalletId(any())).thenAnswer((_) async => true); when(() => settings.removeCurrentWalletId()).thenAnswer((_) async => true); when(() => repo.deleteWallet(any())).thenAnswer((_) async {}); + when(() => repo.updateAddress(any(), any())).thenAnswer((_) async {}); }); group('$WalletService', () { group('createSeedWallet', () { - test('returns a SoftwareWallet with the generated mnemonic persisted', () async { - when(() => repo.createWallet(any(), any(), any())).thenAnswer((_) async => 42); + test('returns a SoftwareWallet with the generated mnemonic and address persisted', () async { + when(() => repo.createWallet(any(), any(), any(), any())).thenAnswer((_) async => 42); final wallet = await service.createSeedWallet('Main'); @@ -60,11 +61,15 @@ void main() { expect(wallet.name, 'Main'); // Generated mnemonic must be valid bip39. expect(service.validateSeed(wallet.seed), isTrue); - verify(() => repo.createWallet('Main', WalletType.software, wallet.seed)).called(1); + // Address from the wallet must match what was stored in the repo. + final expectedAddress = wallet.currentAccount.primaryAddress.address.hexEip55; + verify( + () => repo.createWallet('Main', WalletType.software, wallet.seed, expectedAddress), + ).called(1); }); test('does not set the wallet as current (caller is responsible)', () async { - when(() => repo.createWallet(any(), any(), any())).thenAnswer((_) async => 42); + when(() => repo.createWallet(any(), any(), any(), any())).thenAnswer((_) async => 42); await service.createSeedWallet('Main'); @@ -74,14 +79,17 @@ void main() { group('restoreWallet', () { test('persists the provided seed and marks the wallet as current', () async { - when(() => repo.createWallet(any(), any(), any())).thenAnswer((_) async => 7); + when(() => repo.createWallet(any(), any(), any(), any())).thenAnswer((_) async => 7); final wallet = await service.restoreWallet('Restored', _testMnemonic); expect(wallet.id, 7); expect(wallet.name, 'Restored'); expect(wallet.seed, _testMnemonic); - verify(() => repo.createWallet('Restored', WalletType.software, _testMnemonic)).called(1); + final expectedAddress = wallet.currentAccount.primaryAddress.address.hexEip55; + verify( + () => repo.createWallet('Restored', WalletType.software, _testMnemonic, expectedAddress), + ).called(1); verify(() => settings.saveCurrentWalletId(7)).called(1); }); }); @@ -101,8 +109,28 @@ void main() { }); group('getWalletById', () { - test('returns SoftwareWallet for software type', () async { - when(() => repo.getWalletById(1)).thenAnswer( + test('returns SoftwareViewWallet (address only) for cached-address software rows', () async { + when(() => repo.getWalletInfo(1)).thenAnswer( + (_) async => _info( + id: 1, + name: 'Main', + address: _debugAddress, + type: WalletType.software, + ), + ); + + final wallet = await service.getWalletById(1); + + expect(wallet, isA()); + verifyNever(() => repo.getUnlockedWalletById(any())); + }); + + test('falls back to unlocked SoftwareWallet for legacy rows and backfills the address', + () async { + when(() => repo.getWalletInfo(1)).thenAnswer( + (_) async => _info(id: 1, name: 'Main', type: WalletType.software), + ); + when(() => repo.getUnlockedWalletById(1)).thenAnswer( (_) async => _info(id: 1, name: 'Main', seed: _testMnemonic, type: WalletType.software), ); @@ -110,10 +138,15 @@ void main() { expect(wallet, isA()); expect((wallet as SoftwareWallet).seed, _testMnemonic); + // The next load takes the fast path because the address has been + // backfilled into the row. + verify( + () => repo.updateAddress(1, wallet.currentAccount.primaryAddress.address.hexEip55), + ).called(1); }); test('returns DebugWallet for debug type', () async { - when(() => repo.getWalletById(2)).thenAnswer( + when(() => repo.getWalletInfo(2)).thenAnswer( (_) async => _info(id: 2, name: 'Debug', address: _debugAddress, type: WalletType.debug), ); @@ -124,12 +157,33 @@ void main() { }); test('throws when the repository returns null (no such id)', () async { - when(() => repo.getWalletById(404)).thenAnswer((_) async => null); + when(() => repo.getWalletInfo(404)).thenAnswer((_) async => null); expect(() => service.getWalletById(404), throwsA(isA())); }); }); + group('unlockWalletById', () { + test('returns a fully unlocked SoftwareWallet', () async { + when(() => repo.getUnlockedWalletById(1)).thenAnswer( + (_) async => _info(id: 1, name: 'Main', seed: _testMnemonic, type: WalletType.software), + ); + + final wallet = await service.unlockWalletById(1); + + expect(wallet, isA()); + expect(wallet.seed, _testMnemonic); + }); + + test('throws for non-software wallet types', () async { + when(() => repo.getUnlockedWalletById(2)).thenAnswer( + (_) async => _info(id: 2, name: 'BBox', address: _debugAddress, type: WalletType.bitbox), + ); + + expect(() => service.unlockWalletById(2), throwsA(isA())); + }); + }); + group('setCurrentWallet', () { test('delegates to SettingsRepository.saveCurrentWalletId', () async { await service.setCurrentWallet(5); @@ -141,8 +195,13 @@ void main() { group('getCurrentWallet', () { test('reads the current id and resolves it through getWalletById', () async { when(() => settings.currentWalletId).thenReturn(3); - when(() => repo.getWalletById(3)).thenAnswer( - (_) async => _info(id: 3, name: 'Saved', seed: _testMnemonic, type: WalletType.software), + when(() => repo.getWalletInfo(3)).thenAnswer( + (_) async => _info( + id: 3, + name: 'Saved', + address: _debugAddress, + type: WalletType.software, + ), ); final wallet = await service.getCurrentWallet(); @@ -158,6 +217,20 @@ void main() { }); }); + group('unlockCurrentWallet', () { + test('reads the current id and resolves it through unlockWalletById', () async { + when(() => settings.currentWalletId).thenReturn(3); + when(() => repo.getUnlockedWalletById(3)).thenAnswer( + (_) async => _info(id: 3, name: 'Saved', seed: _testMnemonic, type: WalletType.software), + ); + + final wallet = await service.unlockCurrentWallet(); + + expect(wallet, isA()); + expect(wallet.seed, _testMnemonic); + }); + }); + group('deleteCurrentWallet', () { test('deletes the wallet and clears the current-id setting', () async { when(() => settings.currentWalletId).thenReturn(8); diff --git a/test/screens/create_wallet/create_wallet_cubit_test.dart b/test/screens/create_wallet/create_wallet_cubit_test.dart index 1d6f3669..b69336ad 100644 --- a/test/screens/create_wallet/create_wallet_cubit_test.dart +++ b/test/screens/create_wallet/create_wallet_cubit_test.dart @@ -1,25 +1,38 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart'; import 'package:realunit_wallet/packages/service/wallet_service.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; import 'package:realunit_wallet/screens/create_wallet/bloc/create_wallet_cubit.dart'; class _MockWalletService extends Mock implements WalletService {} +class _MockAuthService extends Mock implements DFXAuthService {} + +class _FakeWalletAccount extends Fake implements AWalletAccount {} + const _testMnemonic = 'test test test test test test test test test test test junk'; void main() { late _MockWalletService service; + late _MockAuthService authService; + + setUpAll(() { + registerFallbackValue(_FakeWalletAccount()); + }); setUp(() { service = _MockWalletService(); + authService = _MockAuthService(); + when(() => authService.ensureSignatureFor(any())).thenAnswer((_) async {}); }); group('$CreateWalletCubit', () { test('initial state hides the seed and has no wallet', () { - final cubit = CreateWalletCubit(service); + final cubit = CreateWalletCubit(service, authService); expect(cubit.state.hideSeed, isTrue); expect(cubit.state.wallet, isNull); @@ -29,17 +42,18 @@ void main() { final wallet = SoftwareWallet(7, 'Obi-Wallet-Kenobi', _testMnemonic); when(() => service.createSeedWallet(any())).thenAnswer((_) async => wallet); - final cubit = CreateWalletCubit(service); + final cubit = CreateWalletCubit(service, authService); cubit.createWallet(); await cubit.stream.firstWhere((s) => s.wallet != null); expect(cubit.state.wallet, same(wallet)); verify(() => service.createSeedWallet('Obi-Wallet-Kenobi')).called(1); + verify(() => authService.ensureSignatureFor(wallet.currentAccount)).called(1); }); blocTest( 'toggleShowSeed flips hideSeed between true and false', - build: () => CreateWalletCubit(service), + build: () => CreateWalletCubit(service, authService), act: (cubit) { cubit.toggleShowSeed(); cubit.toggleShowSeed(); @@ -53,7 +67,7 @@ void main() { test('toggleShowSeed preserves the wallet field', () async { final wallet = SoftwareWallet(1, 'W', _testMnemonic); when(() => service.createSeedWallet(any())).thenAnswer((_) async => wallet); - final cubit = CreateWalletCubit(service); + final cubit = CreateWalletCubit(service, authService); cubit.createWallet(); await cubit.stream.firstWhere((s) => s.wallet != null); diff --git a/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart b/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart index a44f728a..cc71ab3c 100644 --- a/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart +++ b/test/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit_test.dart @@ -5,8 +5,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/packages/hardware_wallet/bitbox.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart'; import 'package:realunit_wallet/packages/service/wallet_service.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; import 'package:realunit_wallet/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart'; class _MockBitboxService extends Mock implements BitboxService {} @@ -15,17 +17,23 @@ class _MockWalletService extends Mock implements WalletService {} class _MockBitboxWallet extends Mock implements BitboxWallet {} +class _MockAuthService extends Mock implements DFXAuthService {} + class _FakeBitboxDevice extends Fake implements sdk.BitboxDevice {} +class _FakeBitboxWalletAccount extends Fake implements BitboxWalletAccount {} + void main() { late _MockBitboxService service; late _MockWalletService walletService; + late _MockAuthService authService; late _FakeBitboxDevice device; late _MockBitboxWallet wallet; setUpAll(() { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; registerFallbackValue(_FakeBitboxDevice()); + registerFallbackValue(_FakeBitboxWalletAccount()); }); tearDownAll(() { @@ -35,6 +43,7 @@ void main() { setUp(() { service = _MockBitboxService(); walletService = _MockWalletService(); + authService = _MockAuthService(); device = _FakeBitboxDevice(); wallet = _MockBitboxWallet(); @@ -42,6 +51,8 @@ void main() { when(() => service.getAllUsbDevices()).thenAnswer((_) async => []); when(() => service.startConnectionStatusObserver()).thenReturn(null); when(() => walletService.createBitboxWallet(any())).thenAnswer((_) async => wallet); + when(() => wallet.currentAccount).thenReturn(_FakeBitboxWalletAccount()); + when(() => authService.ensureSignatureFor(any())).thenAnswer((_) async {}); }); // Tests pass in short timeouts so the bounce-back path can be exercised in @@ -53,6 +64,7 @@ void main() { }) => ConnectBitboxCubit( service, walletService, + authService, confirmPairingTimeout: confirmPairingTimeout, createWalletTimeout: createWalletTimeout, pairingPinTimeout: pairingPinTimeout, diff --git a/test/screens/home_settings_user_data_states_test.dart b/test/screens/home_settings_user_data_states_test.dart index 098e9218..0c23108d 100644 --- a/test/screens/home_settings_user_data_states_test.dart +++ b/test/screens/home_settings_user_data_states_test.dart @@ -70,14 +70,14 @@ void main() { expect(const SettingsUserDataInitial(), isNot(const SettingsUserDataLoading())); }); - test('Failure props pin the message', () { + test('Failure equals itself', () { expect( - const SettingsUserDataFailure('boom'), - const SettingsUserDataFailure('boom'), + const SettingsUserDataFailure(), + const SettingsUserDataFailure(), ); expect( - const SettingsUserDataFailure('boom'), - isNot(const SettingsUserDataFailure('other')), + const SettingsUserDataFailure(), + isNot(const SettingsUserDataBitboxDisconnected()), ); }); diff --git a/test/screens/restore_wallet/restore_wallet_cubit_test.dart b/test/screens/restore_wallet/restore_wallet_cubit_test.dart index e04ca088..5d107e61 100644 --- a/test/screens/restore_wallet/restore_wallet_cubit_test.dart +++ b/test/screens/restore_wallet/restore_wallet_cubit_test.dart @@ -1,24 +1,37 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart'; import 'package:realunit_wallet/packages/service/wallet_service.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; import 'package:realunit_wallet/screens/restore_wallet/cubit/restore_wallet/restore_wallet_cubit.dart'; class _MockWalletService extends Mock implements WalletService {} +class _MockAuthService extends Mock implements DFXAuthService {} + +class _FakeWalletAccount extends Fake implements AWalletAccount {} + const _testMnemonic = 'test test test test test test test test test test test junk'; void main() { late _MockWalletService service; + late _MockAuthService authService; + + setUpAll(() { + registerFallbackValue(_FakeWalletAccount()); + }); setUp(() { service = _MockWalletService(); + authService = _MockAuthService(); + when(() => authService.ensureSignatureFor(any())).thenAnswer((_) async {}); }); group('$RestoreWalletCubit', () { test('initial state is not loading and has no wallet', () { - final cubit = RestoreWalletCubit(service); + final cubit = RestoreWalletCubit(service, authService); expect(cubit.state.isLoading, isFalse); expect(cubit.state.wallet, isNull); @@ -27,7 +40,7 @@ void main() { test('restoreWallet normalises whitespace before delegating to the service', () async { final restored = SoftwareWallet(1, 'Obi-Wallet-Kenobi', _testMnemonic); when(() => service.restoreWallet(any(), any())).thenAnswer((_) async => restored); - final cubit = RestoreWalletCubit(service); + final cubit = RestoreWalletCubit(service, authService); // Caller pastes a seed with leading/trailing/inner extra spaces. cubit.restoreWallet(' test test test test test test test test test test test junk '); @@ -35,6 +48,7 @@ void main() { // Restore service receives the canonicalised, single-spaced mnemonic. verify(() => service.restoreWallet('Obi-Wallet-Kenobi', _testMnemonic)).called(1); + verify(() => authService.ensureSignatureFor(restored.currentAccount)).called(1); expect(cubit.state.wallet, same(restored)); expect(cubit.state.isLoading, isFalse); }); @@ -42,7 +56,7 @@ void main() { test('restoreWallet emits an interim isLoading=true state', () async { final restored = SoftwareWallet(1, 'W', _testMnemonic); when(() => service.restoreWallet(any(), any())).thenAnswer((_) async => restored); - final cubit = RestoreWalletCubit(service); + final cubit = RestoreWalletCubit(service, authService); final loadingFuture = cubit.stream.firstWhere((s) => s.isLoading); cubit.restoreWallet(_testMnemonic); diff --git a/test/screens/settings_seed/settings_seed_cubit_test.dart b/test/screens/settings_seed/settings_seed_cubit_test.dart index 03d1fd00..100ae99d 100644 --- a/test/screens/settings_seed/settings_seed_cubit_test.dart +++ b/test/screens/settings_seed/settings_seed_cubit_test.dart @@ -1,8 +1,12 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; import 'package:realunit_wallet/screens/settings_seed/bloc/settings_seed_cubit.dart'; +class _MockAppStore extends Mock implements AppStore {} + // Canonical BIP39 test mnemonic — recommended fixture for any wallet code // path that needs a deterministic, well-known seed. const _testSeed = @@ -10,38 +14,47 @@ const _testSeed = void main() { late SoftwareWallet wallet; + late _MockAppStore appStore; setUp(() { wallet = SoftwareWallet(1, 'Test', _testSeed); + appStore = _MockAppStore(); + when(() => appStore.ensureUnlocked()).thenAnswer((_) async {}); + when(() => appStore.wallet).thenReturn(wallet); }); group('$SettingsSeedCubit', () { - test('initial state mirrors the wallet seed with showSeed=false', () { - final cubit = SettingsSeedCubit(wallet); + test('initial state surfaces the wallet seed after ensureUnlocked', () async { + final cubit = SettingsSeedCubit(appStore); + await cubit.stream.firstWhere((s) => s.seed.isNotEmpty); expect(cubit.state.seed, _testSeed); expect(cubit.state.showSeed, isFalse); + verify(() => appStore.ensureUnlocked()).called(1); }); blocTest( 'toggleShowSeed flips showSeed and keeps seed unchanged', - build: () => SettingsSeedCubit(wallet), + build: () => SettingsSeedCubit(appStore), + wait: const Duration(milliseconds: 10), act: (c) => c.toggleShowSeed(), - expect: () => [ - const SettingsSeedState(_testSeed, showSeed: true), - ], + verify: (c) { + expect(c.state.seed, _testSeed); + expect(c.state.showSeed, isTrue); + }, ); blocTest( 'toggleShowSeed twice returns to showSeed=false', - build: () => SettingsSeedCubit(wallet), + build: () => SettingsSeedCubit(appStore), + wait: const Duration(milliseconds: 10), act: (c) => c ..toggleShowSeed() ..toggleShowSeed(), - expect: () => [ - const SettingsSeedState(_testSeed, showSeed: true), - const SettingsSeedState(_testSeed), - ], + verify: (c) { + expect(c.state.seed, _testSeed); + expect(c.state.showSeed, isFalse); + }, ); }); diff --git a/test/screens/settings_user_data/settings_user_data_cubit_test.dart b/test/screens/settings_user_data/settings_user_data_cubit_test.dart index 5b7e9f46..e8364b6f 100644 --- a/test/screens/settings_user_data/settings_user_data_cubit_test.dart +++ b/test/screens/settings_user_data/settings_user_data_cubit_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_country_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_level_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/kyc/dto/kyc_step_dto.dart'; @@ -194,7 +195,7 @@ void main() { final cubit = build(); await cubit.stream.firstWhere((s) => s is SettingsUserDataFailure); - expect((cubit.state as SettingsUserDataFailure).message, contains('network')); + expect(cubit.state, isA()); }); test('Failure when countryService.getCountryBySymbol throws', () async { @@ -213,10 +214,20 @@ void main() { final cubit = build(); await cubit.stream.firstWhere((s) => s is SettingsUserDataFailure); - expect( - (cubit.state as SettingsUserDataFailure).message, - contains('unknown country'), + expect(cubit.state, isA()); + }); + + test('BitboxDisconnected when BitboxNotConnectedException thrown', () async { + when(() => walletService.getWalletStatus()) + .thenAnswer((_) async => throw const BitboxNotConnectedException()); + when(() => kycService.getKycStatus()).thenAnswer( + (_) async => const KycLevelDto(kycLevel: KycLevel.level0, kycSteps: []), ); + + final cubit = build(); + await cubit.stream.firstWhere((s) => s is SettingsUserDataBitboxDisconnected); + + expect(cubit.state, isA()); }); }); } diff --git a/test/screens/settings_user_data/settings_user_data_page_test.dart b/test/screens/settings_user_data/settings_user_data_page_test.dart index a727b176..855ab862 100644 --- a/test/screens/settings_user_data/settings_user_data_page_test.dart +++ b/test/screens/settings_user_data/settings_user_data_page_test.dart @@ -75,15 +75,11 @@ void main() { }); testWidgets('renders correctly when loading user data failed', (tester) async { - final errorMessage = 'not working bro'; - when(() => settingsUserDataCubit.state).thenReturn(SettingsUserDataFailure(errorMessage)); + when(() => settingsUserDataCubit.state).thenReturn(const SettingsUserDataFailure()); await tester.pumpApp(buildSubject(const SettingsUserDataView())); - expect( - find.byWidgetPredicate((Widget widget) => widget is Text && widget.data == errorMessage), - findsOne, - ); + expect(find.text(S.current.userDataLoadFailed), findsOne); }); testWidgets('renders correctly when user data loaded successfully', (tester) async {