Skip to content
Merged
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
73 changes: 73 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:developer' as developer;

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
Expand All @@ -14,11 +16,14 @@ import 'package:realunit_wallet/setup/routing/router_config.dart';
import 'package:realunit_wallet/setup/routing/routes/app_routes.dart';
import 'package:realunit_wallet/setup/routing/routes/onboarding_routes.dart';
import 'package:realunit_wallet/setup/routing/routes/pin_routes.dart';
import 'package:realunit_wallet/styles/colors.dart';
import 'package:realunit_wallet/styles/themes.dart';

Future<void> main() async {
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();

_installErrorHandlers();

// only preserve splash screen for 3 seconds for release version
if (kReleaseMode) {
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
Expand All @@ -38,6 +43,25 @@ Future<void> _initialize() async {
);
}

/// Defense-in-depth against uncaught build/paint exceptions. Without this, a
/// single throw inside a widget's `build` (e.g. the empty-BitBox-address
/// `EthereumAddress.fromHex("")` crash) surfaces in release as a bare grey
/// [ErrorWidget] with no branding and no signal to the user. We log every such
/// error and replace the grey box with a minimal on-brand surface.
void _installErrorHandlers() {
final defaultOnError = FlutterError.onError;
FlutterError.onError = (details) {
developer.log(
'uncaught Flutter error: ${details.exceptionAsString()}',
name: 'WalletApp',
error: details.exception,
stackTrace: details.stack,
);
defaultOnError?.call(details);
};
ErrorWidget.builder = (details) => _RealUnitErrorView(details: details);
}

Future<void> _initializeWithSplashDuration() async {
await Future.wait([
_initialize(),
Expand Down Expand Up @@ -127,6 +151,11 @@ class _WalletAppState extends State<WalletApp> {
targetRoute = PinRoutes.setup;
} else if (!pinState.isPinVerified) {
targetRoute = PinRoutes.verify;
} else if (homeState.bitboxAddressRecoveryNeeded) {
// A BitBox wallet was persisted with an empty/invalid address — divert to
// the re-pairing recovery flow instead of loading it into the dashboard
// (which would crash the build via `EthereumAddress.fromHex("")`).
targetRoute = AppRoutes.bitboxAddressRecovery;
} else if (homeState.openWallet == null) {
// Wallet not loaded yet — trigger load and wait for HomeBloc update
_loadWalletIfNeeded();
Expand All @@ -138,3 +167,47 @@ class _WalletAppState extends State<WalletApp> {
routerConfig.goNamed(targetRoute);
}
}

/// Minimal on-brand replacement for the default grey [ErrorWidget]. Rendered by
/// [ErrorWidget.builder] for any uncaught build/paint exception, so it lives
/// outside the [MaterialApp] localization scope — the copy is deliberately
/// hardcoded (no `S.of(context)`) because this is a last-resort surface that
/// must render even when localization is unavailable. In debug it also shows
/// the exception text to keep diagnosis fast; release shows only the friendly
/// line.
class _RealUnitErrorView extends StatelessWidget {
const _RealUnitErrorView({required this.details});

final FlutterErrorDetails details;

@override
Widget build(BuildContext context) => Directionality(
textDirection: TextDirection.ltr,
child: Container(
color: RealUnitColors.neutral50,
alignment: Alignment.center,
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: [
Icon(Icons.error_outline, color: RealUnitColors.status.red600, size: 48),
const Text(
'Something went wrong. Please restart the app.',
textAlign: TextAlign.center,
style: TextStyle(
color: RealUnitColors.neutral900,
fontWeight: FontWeight.w600,
),
),
if (kDebugMode)
Text(
details.exceptionAsString(),
textAlign: TextAlign.center,
style: const TextStyle(color: RealUnitColors.neutral500),
),
],
),
),
);
}
29 changes: 29 additions & 0 deletions lib/packages/hardware_wallet/bitbox.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@ import 'dart:developer' as developer;

import 'package:bitbox_flutter/bitbox_flutter.dart';
import 'package:realunit_wallet/packages/hardware_wallet/bitbox_credentials.dart';
import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_address_unavailable_exception.dart';

class BitboxService {
// ETH's canonical BIP-44 derivation path. Centralised here so the create
// and heal flows share one literal — a drift between them would quote two
// different addresses for the same device.
static const _ethDerivationPath = "m/44'/60'/0'/0/0";

// Observer poll period is widened in production and tightened in tests so
// device-loss-recovery behaviour can be exercised in real time without
// five-second sleeps.
Expand Down Expand Up @@ -122,4 +128,27 @@ class BitboxService {
final didVerify = await bitboxManager.channelHashVerify();
if (!didVerify) throw Exception('Failed to verify');
}

/// Derives the wallet's ETH address from the device, retrying transient empty
/// reads before giving up.
///
/// The SDK coerces a native `null` into `""` at the transport boundary
/// (`bitbox_usb_method_channel.dart`'s `return result ?? ''`), so a device
/// that isn't fully ready (e.g. a BLE stall right after channel-hash verify)
/// resolves `getETHAddress` with an empty string instead of throwing. This is
/// the single boundary through which create + heal fetch the address, so an
/// empty read can never be persisted: a transient stall self-recovers across
/// [attempts], and a persistent one throws [BitboxAddressUnavailableException]
/// instead of handing back `""`.
Future<String> getEthAddress({
int attempts = 3,
Duration retryDelay = const Duration(milliseconds: 200),
}) async {
for (var attempt = 0; attempt < attempts; attempt++) {
final address = await bitboxManager.getETHAddress(1, _ethDerivationPath);
if (address.isNotEmpty) return address;
if (attempt < attempts - 1) await Future<void>.delayed(retryDelay);
}
throw const BitboxAddressUnavailableException();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/// Raised when a BitBox device fails to hand back a usable ETH address — either
/// at pairing time ([WalletService.createBitboxWallet]) or while self-healing a
/// wallet that was persisted with an empty/invalid address
/// ([WalletService.healCurrentBitboxAddress]).
///
/// Persisting an empty address used to crash the dashboard on the next launch:
/// `EthereumAddress.fromHex("")` throws an uncaught `ArgumentError` inside the
/// build phase, which surfaces as a grey [ErrorWidget] in release. Surfacing a
/// typed exception lets the pairing / recovery flow fall back to its retry path
/// instead.
class BitboxAddressUnavailableException implements Exception {
const BitboxAddressUnavailableException();

@override
String toString() => 'BitBox did not return a valid wallet address';
}
65 changes: 64 additions & 1 deletion lib/packages/service/wallet_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import 'package:realunit_wallet/packages/hardware_wallet/bitbox.dart';
import 'package:realunit_wallet/packages/repository/settings_repository.dart';
import 'package:realunit_wallet/packages/repository/wallet_repository.dart';
import 'package:realunit_wallet/packages/service/app_store.dart';
import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_address_unavailable_exception.dart';
import 'package:realunit_wallet/packages/wallet/wallet.dart';
import 'package:web3dart/web3dart.dart';

class WalletService {
final WalletRepository _repository;
Expand Down Expand Up @@ -85,12 +87,73 @@ class WalletService {
}

Future<BitboxWallet> createBitboxWallet(String name) async {
final address = await _bitboxService.bitboxManager.getETHAddress(1, "m/44'/60'/0'/0/0");
// [BitboxService.getEthAddress] already retries the transient empty read
// the SDK produces when it coerces a native `null` into `""` (its
// `return result ?? ''` transport boundary) and throws on a persistent
// one. The format guard below is defence-in-depth — it also rejects the
// rarer non-empty-but-malformed read before `EthereumAddress.fromHex`
// would crash the dashboard build on the next launch.
final address = await _bitboxService.getEthAddress();
if (!_isValidEthAddress(address)) {
throw const BitboxAddressUnavailableException();
}
final walletId = await _repository.createViewWallet(name, WalletType.bitbox, address);
await setCurrentWallet(walletId);
return BitboxWallet(walletId, name, address, _bitboxService);
}

/// True when [address] parses as a canonical 20-byte Ethereum address.
///
/// Uses the same web3dart parser that every wallet credential class relies on
/// so the validity boundary here matches exactly the one that would otherwise
/// throw deep in the dashboard build. An empty string fails fast through the
/// [FormatException]/[ArgumentError] catch — no need to special-case it.
static bool _isValidEthAddress(String address) {
try {
EthereumAddress.fromHex(address);
return true;
} on FormatException {
return false;
} on ArgumentError {
return false;
}
}

/// Non-throwing probe used at app load to decide whether the current wallet
/// is a BitBox row that was persisted with an empty/invalid address (the
/// pre-fix data corruption). Returns false for software/debug wallets, for a
/// missing current id, for an absent row, and for a BitBox row whose address
/// parses cleanly — i.e. it only flags the rows that would crash the
/// dashboard build, leaving every healthy wallet on the normal load path.
Future<bool> currentWalletNeedsAddressRecovery() async {
final id = _settingsRepository.currentWalletId;
if (id == null) return false;
final info = await _repository.getWalletInfo(id);
if (info == null) return false;
if (WalletType.values[info.type] != WalletType.bitbox) return false;
return !_isValidEthAddress(info.address);
}

/// Re-derives the ETH address from a freshly re-paired BitBox and backfills it
/// onto the current wallet row, replacing the empty/invalid one. This is local
/// crypto/device recovery — the address is a deterministic function of the
/// device, not account state owned by the API — so it does not belong behind a
/// network round-trip. Throws [BitboxAddressUnavailableException] if the device
/// still returns an unusable address so the recovery UI can retry rather than
/// re-persist the corruption.
Future<BitboxWallet> healCurrentBitboxAddress() async {
final id = _settingsRepository.currentWalletId!;
final info = (await _repository.getWalletInfo(id))!;
// Shares the retry + empty-guard boundary with createBitboxWallet; the
// format check stays as defence-in-depth (see that method).
final address = await _bitboxService.getEthAddress();
if (!_isValidEthAddress(address)) {
throw const BitboxAddressUnavailableException();
}
await _repository.updateAddress(id, address);
return BitboxWallet(id, info.name, address, _bitboxService);
}

/// Persists a user-supplied seed phrase immediately — the user typed an
/// existing mnemonic, so there is no verify-seed quiz to gate the write
/// behind. Deferring would not help: the seed is already known and the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart';
import 'package:realunit_wallet/screens/hardware_connect_bitbox/connect_bitbox_view.dart';
import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart';
import 'package:realunit_wallet/setup/di.dart';

/// Full-page host for the BitBox pairing ceremony when an already-persisted
/// BitBox wallet is missing a valid address (the pre-fix data corruption that
/// crashed the dashboard build). Reuses the exact [ConnectBitboxView] from the
/// initial-pairing flow, but wires the cubit's wallet-acquisition step to
/// [WalletService.healCurrentBitboxAddress] so re-pairing backfills the address
/// onto the existing row instead of creating a second wallet.
///
/// On finish it feeds the healed wallet back through [LoadWalletEvent]; the
/// HomeBloc listener in `main` then re-runs `_navigate`, which lands on the
/// dashboard now that the recovery flag is cleared.
class BitboxAddressRecoveryPage extends StatelessWidget {
const BitboxAddressRecoveryPage({super.key});

@override
Widget build(BuildContext context) => Scaffold(
body: BlocProvider(
create: (_) => ConnectBitboxCubit(
getIt<BitboxService>(),
getIt<WalletService>(),
// DfxKycService is the smallest registered DFXAuthService — used only as
// a transport for ensureSignatureFor(account); no KYC-specific calls
// here. Mirrors ConnectBitboxPage.
getIt<DfxKycService>(),
acquireWallet: () => getIt<WalletService>().healCurrentBitboxAddress(),
),
child: ConnectBitboxView(
onFinish: (wallet) => getIt<HomeBloc>().add(LoadWalletEvent(wallet)),
// This page is the only navigation-stack entry (reached via `goNamed`),
// so `context.pop` would throw `GoError`. The BitBox wallet is view-only
// (keys live on the device, address is deterministically re-derivable by
// re-pairing), so deleting the corrupted local row is non-destructive:
// the handler resets HomeState (hasWallet:false,
// bitboxAddressRecoveryNeeded:false) and `main._navigate` then lands on
// the welcome/onboarding screen — no throw, no re-route loop.
onCancel: () => getIt<HomeBloc>().add(const DeleteCurrentWalletEvent()),
),
),
);
}
28 changes: 19 additions & 9 deletions lib/screens/hardware_connect_bitbox/bloc/connect_bitbox_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ class ConnectBitboxCubit extends Cubit<BitboxConnectionState> {
Duration confirmPairingTimeout = _defaultConfirmPairingTimeout,
Duration createWalletTimeout = _defaultCreateWalletTimeout,
Duration pairingPinTimeout = _defaultPairingPinTimeout,
Future<BitboxWallet> Function()? acquireWallet,
}) : _confirmPairingTimeout = confirmPairingTimeout,
_createWalletTimeout = createWalletTimeout,
_pairingPinTimeout = pairingPinTimeout,
_acquireWallet = acquireWallet,
super(BitboxNotConnected()) {
_startScanning();
}
Expand All @@ -42,6 +44,16 @@ class ConnectBitboxCubit extends Cubit<BitboxConnectionState> {
final Duration _createWalletTimeout;
final Duration _pairingPinTimeout;

/// Injectable wallet-acquisition step. `null` selects the default behaviour:
/// the initial-pairing flow creates a brand-new view wallet. The
/// address-recovery flow passes [WalletService.healCurrentBitboxAddress] so
/// the same pairing ceremony backfills the empty address onto the existing
/// row instead of creating a second wallet.
final Future<BitboxWallet> Function()? _acquireWallet;

Future<BitboxWallet> _acquireWalletOrDefault() =>
(_acquireWallet ?? (() => _walletService.createBitboxWallet('Luke-Skywallet')))();

Future<void> _startScanning() async {
if (DeviceInfo.instance.isIOS) await _service.startScan();
_checkForTimer = Timer.periodic(const Duration(milliseconds: 500), (_) => checkForBitbox());
Expand Down Expand Up @@ -143,15 +155,13 @@ class ConnectBitboxCubit extends Cubit<BitboxConnectionState> {
'Disconnect the device, restart the app, and re-pair.',
),
);
final wallet = await _walletService
.createBitboxWallet('Luke-Skywallet')
.timeout(
_createWalletTimeout,
onTimeout: () => throw TimeoutException(
'BitBox did not return an ETH address within '
'${_createWalletTimeout.inSeconds}s. Try disconnecting and re-pairing.',
),
);
final wallet = await _acquireWalletOrDefault().timeout(
_createWalletTimeout,
onTimeout: () => throw TimeoutException(
'BitBox did not return an ETH address within '
'${_createWalletTimeout.inSeconds}s. Try disconnecting and re-pairing.',
),
);
_service.startConnectionStatusObserver();
await _captureAuthSignature(wallet);
} catch (e) {
Expand Down
13 changes: 10 additions & 3 deletions lib/screens/hardware_connect_bitbox/connect_bitbox_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,17 @@ import 'package:realunit_wallet/styles/colors.dart';
import 'package:realunit_wallet/widgets/handlebars.dart';

class ConnectBitboxView extends StatelessWidget {
const ConnectBitboxView({super.key, required this.onFinish});
const ConnectBitboxView({super.key, required this.onFinish, this.onCancel});

final void Function(AWallet wallet) onFinish;

/// Overrides the default cancel handler. Bottom-sheet hosts leave this null so
/// cancelling pops the sheet (`context.pop`). Full-page hosts reached via
/// `goNamed` (e.g. [BitboxAddressRecoveryPage]) must pass a safe callback —
/// there `context.pop` throws `GoError` because the recovery route is the only
/// stack entry and `canPop()` is false.
final VoidCallback? onCancel;

@override
Widget build(BuildContext context) => SafeArea(
child: SizedBox(
Expand Down Expand Up @@ -64,7 +71,7 @@ class ConnectBitboxView extends StatelessWidget {
title: S.of(context).connectBitboxTitle,
imagePath: 'assets/images/illustrations/bitbox_connected.svg',
onConfirm: context.read<ConnectBitboxCubit>().confirmPairing,
onCancel: context.pop,
onCancel: onCancel ?? context.pop,
child: Column(
spacing: 16,
children: [
Expand Down Expand Up @@ -162,7 +169,7 @@ class ConnectBitboxView extends StatelessWidget {
_ => ConnectContent(
title: S.of(context).connectBitboxTitle,
imagePath: 'assets/images/illustrations/bitbox_connect.svg',
onCancel: context.pop,
onCancel: onCancel ?? context.pop,
child: Text(
DeviceInfo.instance.isIOS
? S.of(context).connectBitboxContentIos
Expand Down
Loading
Loading