Skip to content
Merged
6 changes: 6 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>

<!-- BitBox02 connects via USB-OTG on Android. Declared as optional so the
app still installs on tablets/devices without USB host support; the
BitBox flow simply won't be usable there. The actual per-device
permission is requested at runtime by bitbox_flutter. -->
<uses-feature android:name="android.hardware.usb.host" android:required="false" />

<application
android:label="Real Unit Wallet"
android:name="${applicationName}"
Expand Down
4 changes: 4 additions & 0 deletions assets/languages/strings_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
"biometricAuthenticationActivateDescription": "Verwenden Sie Face ID oder Ihren Fingerabdruck, um Ihre Wallet schnell und sicher zu entsperren.",
"birthday": "Geburtstag",
"bitbox": "BitBox",
"bitboxDisconnectedDescription": "Die Verbindung zur BitBox wurde unterbrochen. Bitte verbinden Sie Ihre BitBox erneut und versuchen Sie es noch einmal.",
"bitboxDisconnectedTitle": "BitBox ist nicht verbunden",
"bitboxReconnect": "BitBox erneut verbinden",
"blockchain": "Blockchain",
"buy": "Kaufen",
"buyExecutedDescription": "Sobald Ihre Überweisung eingegangen ist, übertragen wir die REALU-Token in Ihre Wallet. Über den Fortschritt Ihrer Transaktion informieren wir Sie per E-Mail.",
Expand Down Expand Up @@ -306,6 +309,7 @@
"twoFaSendCodeFailed": "Es ist ein Problem beim Senden der Mail aufgetreten",
"twoFaWrongCode": "Der Code ist falsch",
"userData": "Nutzerdaten",
"userDataLoadFailed": "Beim Laden der Nutzerdaten ist ein Fehler aufgetreten.",
"userDataNotFound": "Zu dieser Wallet sind keine Nutzerdaten hinterlegt.",
"verifySeedInvalid": "Die Wörter stimmen nicht überein",
"verifySeedSubtitle": "Bitte geben Sie die folgenden Wörter aus Ihrer Wiederherstellungsphrase ein, um zu bestätigen, dass Sie sie korrekt notiert haben.",
Expand Down
4 changes: 4 additions & 0 deletions assets/languages/strings_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
"biometricAuthenticationActivateDescription": "Use Face ID or fingerprint to unlock your wallet quickly and securely.",
"birthday": "Birthday",
"bitbox": "BitBox",
"bitboxDisconnectedDescription": "The connection to the BitBox was lost. Please reconnect your BitBox and try again.",
"bitboxDisconnectedTitle": "BitBox is not connected",
"bitboxReconnect": "Reconnect BitBox",
"blockchain": "Blockchain",
"buy": "Buy",
"buyExecutedDescription": "As soon as your transfer has been received, we will transfer the REALU tokens to your wallet. We will inform you about the progress of your transaction by email.",
Expand Down Expand Up @@ -306,6 +309,7 @@
"twoFaSendCodeFailed": "There was a problem sending the email",
"twoFaWrongCode": "The Code is wrong",
"userData": "User data",
"userDataLoadFailed": "An error occurred while loading user data.",
"userDataNotFound": "No user data is stored for this wallet.",
"verifySeedInvalid": "The words don't match",
"verifySeedSubtitle": "Please enter the following words from your recovery phrase to confirm you've written them down correctly.",
Expand Down
20 changes: 18 additions & 2 deletions lib/packages/hardware_wallet/bitbox.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:developer' as developer;

import 'package:bitbox_flutter/bitbox_flutter.dart';
import 'package:realunit_wallet/packages/hardware_wallet/bitbox_credentials.dart';
Expand All @@ -15,9 +16,9 @@ class BitboxService {

BitboxCredentials getCredentials(String address) {
final credentials = BitboxCredentials(address);
_credentials = credentials;
if (_isConnected) {
credentials.setBitbox(bitboxManager);
_credentials = credentials;
}
return credentials;
}
Expand All @@ -27,6 +28,11 @@ class BitboxService {
final didInit = await bitboxManager.initBitBox();
if (!didInit) throw Exception('Failed to init');
_isConnected = true;
// Restore the bitbox manager on any credentials handed out before this
// (re-)connect — otherwise existing wallets keep a cleared credentials
// instance and the next sign throws BitboxNotConnectedException even
// though the device is back online.
_credentials?.setBitbox(bitboxManager);
return didInit;
}

Expand All @@ -37,8 +43,18 @@ class BitboxService {
if (devices.isEmpty) {
_isConnected = false;
_credentials?.clearBitbox();
_credentials = null;
// Keep the _credentials reference so init() can re-attach the manager
// on the same instance after a reconnect.
stopConnectionStatusObserver();
// Close the underlying transport. Required on Android so the USB
// file-descriptor is released — otherwise the next connect() can
// fail because the OS still considers the device claimed. Safe on
// iOS where the BLE link is already gone at this point.
try {
await bitboxManager.disconnect();
} catch (e) {
developer.log('disconnect after device-loss failed: $e', name: '$BitboxService');
}
}
});
}
Expand Down
53 changes: 43 additions & 10 deletions lib/packages/hardware_wallet/bitbox_credentials.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,13 @@ class BitboxCredentials extends CredentialsWithKnownAddress {
if (!isConnected) throw const BitboxNotConnectedException();

if (isEIP1559) payload = payload.sublist(1);
final sig = await bitboxManager!.signETHRLPTransaction(
chainId ?? 1,
derivationPath!,
bytesToHex(payload),
isEIP1559,
final sig = await _runOrThrowDisconnect(
() => bitboxManager!.signETHRLPTransaction(
chainId ?? 1,
derivationPath!,
bytesToHex(payload),
isEIP1559,
),
);

final r = bytesToHex(sig.sublist(0, 32));
Expand Down Expand Up @@ -101,7 +103,9 @@ class BitboxCredentials extends CredentialsWithKnownAddress {
Future<Uint8List> 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),
);
});
}

Expand All @@ -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<T> _runOrThrowDisconnect<T>(Future<T> Function() op) async {
try {
return await op();
} catch (_) {
if (await _deviceLost()) {
clearBitbox();
throw const BitboxNotConnectedException();
}
rethrow;
}
}

Future<bool> _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;
}
24 changes: 21 additions & 3 deletions lib/packages/repository/wallet_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,33 @@ class WalletRepository {

const WalletRepository(this._appDatabase, this._secureStorage);

Future<int> createWallet(String name, WalletType type, String seed) async {
Future<int> 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<int> createViewWallet(String name, WalletType type, String address) =>
_appDatabase.insertWallet(name, '', address, type.index);

Future<WalletInfo?> 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<WalletInfo?> 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<void> 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<WalletInfo?> getUnlockedWalletById(int id) async {
final info = await _appDatabase.getWalletById(id);
if (info == null) return null;
if (info.seed.isEmpty) return info;
Expand Down
21 changes: 21 additions & 0 deletions lib/packages/service/app_store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<AWallet> Function()? _unlocker;

AppStore(this.getApiConfig, this.sessionCache);

set wallet(AWallet wallet_) => _wallet = wallet_;
Expand All @@ -22,4 +28,19 @@ class AppStore {
ApiConfig get apiConfig => getApiConfig();

String get primaryAddress => wallet.currentAccount.primaryAddress.address.hex;

void attachUnlocker(Future<AWallet> 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<void> ensureUnlocked() async {
if (_wallet is! SoftwareViewWallet) return;
final unlocker = _unlocker;
if (unlocker == null) return;
_wallet = await unlocker();
}
}
38 changes: 31 additions & 7 deletions lib/packages/service/dfx/dfx_auth_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,42 @@ abstract class DFXAuthService {

String get walletAddress => wallet.primaryAddress.address.hexEip55;

Future<String> getSignMessage() async {
final uri = buildUri(host, signMessagePath, {'address': walletAddress});
Future<String> 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<void> 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<String> _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<String, dynamic>)['message'] as String;
}

// Exceptions this method can throw on the BitBox path:
Expand All @@ -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();
Expand Down
3 changes: 3 additions & 0 deletions lib/packages/service/dfx/exceptions/bitbox_exception.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
class BitboxNotConnectedException implements Exception {
const BitboxNotConnectedException();

@override
String toString() => 'BitBox is not connected';
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ enum PaymentInfoError {
registrationRequired,
kycRequired,
minAmountNotMet,
bitboxDisconnected,
unknown,
}
3 changes: 3 additions & 0 deletions lib/packages/service/dfx/real_unit_registration_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ class RealUnitRegistrationService extends DFXAuthService {

/// registers a wallet and and adds the wallet to the new user
Future<RegistrationStatus> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ class RealUnitSellPaymentInfoService extends DFXAuthService {

/// Confirms payment for Software Wallet
Future<void> 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);

Expand Down
Loading
Loading