Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
45cd7fd
feat(migration): service layer for software-to-BitBox wallet migration
TaprootFreak Aug 3, 2026
0b9396f
feat(migration): software-to-BitBox migration wizard UI
TaprootFreak Aug 3, 2026
219e785
fix(migration): escape hatch for terminal transfer failures + wizard …
TaprootFreak Aug 3, 2026
69a465b
test(migration): drop invalid type arguments on registerFallbackValue
TaprootFreak Aug 3, 2026
9c12275
test(migration): fix async stream assertion and pop context in sheet …
TaprootFreak Aug 3, 2026
5ec792d
fix(migration): review round 1 service hardenings
TaprootFreak Aug 3, 2026
ac4fe91
fix(migration): review round 1 wizard fixes
TaprootFreak Aug 3, 2026
55aae30
test: bind cached auth tokens to the stub wallet address in missed su…
TaprootFreak Aug 3, 2026
4b5ccb5
test: finish address-bound token adaptation and drop invalid const
TaprootFreak Aug 3, 2026
59e9f61
test(migration): stabilize new suites after review-round-1 fixes
TaprootFreak Aug 3, 2026
91f57a1
fix(migration): review round 2 hardenings
TaprootFreak Aug 3, 2026
abd65d5
fix(migration): add key parameter to the publicized register failure …
TaprootFreak Aug 3, 2026
08507cd
test: lowercase the case-mangled fixture before EIP-55 normalization
TaprootFreak Aug 3, 2026
9a208a8
fix(migration): round 3 review minors
TaprootFreak Aug 3, 2026
1950fa2
fix(migration): round 3 review findings
TaprootFreak Aug 3, 2026
2d26def
test: align auth-service fixtures with the round-3 identity guard
TaprootFreak Aug 3, 2026
3cc611b
test: bind the cancelled-signature stubs to the fixture address as well
TaprootFreak Aug 3, 2026
8183725
fix(migration): contain the identity-change marker in every public au…
TaprootFreak Aug 3, 2026
f6caafd
style(migration): wrap the getAuthToken delegation and complete the m…
TaprootFreak Aug 3, 2026
da58abb
test(migration): pin the persisted-wallet signature contract
TaprootFreak Aug 3, 2026
6274fdd
test(goldens): regenerate baselines on the self-hosted runner
github-actions[bot] Aug 3, 2026
6f773e9
ci: trigger checks on the regenerated golden baseline
TaprootFreak Aug 3, 2026
cbc2304
test: close the last four uncovered lines for the coverage floor
TaprootFreak Aug 4, 2026
f7d9c39
test(migration): golden coverage for the wizard's visual states
TaprootFreak Aug 4, 2026
87e9e25
test(goldens): regenerate baselines on the self-hosted runner
github-actions[bot] Aug 4, 2026
9698a40
ci: trigger checks on the regenerated wizard baselines
TaprootFreak Aug 4, 2026
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
22 changes: 22 additions & 0 deletions assets/languages/strings_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,28 @@
"legalDocuments": "Rechtsdokumente",
"location": "Ort",
"max": "Max",
"migrateBitbox": "Auf BitBox umziehen",
"migrateBitboxAlreadyLinkedError": "Diese BitBox-Adresse ist bereits mit einem anderen Konto verknüpft. Kontaktieren Sie den Support, falls Sie dies für einen Fehler halten.",
"migrateBitboxCompleting": "Umzug wird abgeschlossen…",
"migrateBitboxIntroBalance": "Aktueller Bestand",
"migrateBitboxIntroDescription": "Verbinden Sie Ihre BitBox, registrieren Sie sie im Aktienregister und übertragen Sie Ihr gesamtes REALU-Guthaben in einem Durchlauf. Ihre Software-Wallet bleibt danach als Backup erhalten.",
"migrateBitboxIntroTitle": "Umzug auf Ihre BitBox",
"migrateBitboxLinking": "Wallet wird verknüpft…",
"migrateBitboxPreparingTransfer": "Guthaben wird vorbereitet…",
"migrateBitboxRegisterConfirmHint": "Bestätigen Sie die Registrierung auf Ihrer BitBox, um fortzufahren.",
"migrateBitboxRegisterCta": "Wallet registrieren",
"migrateBitboxRegisterDescription": "Ihre BitBox wird Ihrem bestehenden Konto hinzugefügt und im Aktienregister registriert.",
"migrateBitboxRegisterTitle": "BitBox registrieren",
"migrateBitboxRegistrationMissingError": "Ihre Software-Wallet ist noch nicht registriert. Bitte schliessen Sie zuerst die normale Registrierung ab und versuchen Sie den Umzug danach erneut.",
"migrateBitboxRegistrationPendingInfo": "Ihre Registrierung wird geprüft. Ihr REALU-Guthaben bleibt auf Ihrer Software-Wallet — Sie können den Umzug fortsetzen, sobald die Prüfung abgeschlossen ist.",
"migrateBitboxSettling": "Transfer wird bestätigt…",
"migrateBitboxSettlingTimeoutInfo": "Die Übertragung läuft noch auf der Blockchain. Die App bleibt vorerst auf Ihrer Software-Wallet. Öffnen Sie den Umzug später erneut — er wird automatisch fortgesetzt.",
"migrateBitboxStart": "Loslegen",
"migrateBitboxSuccessDescription": "Ihre BitBox ist jetzt Ihre aktive Wallet und hält Ihr REALU-Guthaben. Ihre Software-Wallet bleibt als Backup erhalten.",
"migrateBitboxSuccessTitle": "Umzug abgeschlossen",
"migrateBitboxTransferCta": "Guthaben übertragen",
"migrateBitboxTransferDescription": "Ihr gesamtes REALU-Guthaben wird auf Ihre neue BitBox-Adresse übertragen. Dabei fallen keine Gasgebühren an.",
"migrateBitboxTransferTitle": "Guthaben übertragen",
"month": "Monat",
"name": "Name",
"networkMainnet": "Mainnet",
Expand Down
22 changes: 22 additions & 0 deletions assets/languages/strings_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,28 @@
"legalDocuments": "Legal documents",
"location": "Location",
"max": "Max",
"migrateBitbox": "Move to BitBox",
"migrateBitboxAlreadyLinkedError": "This BitBox address is already linked to a different account. Contact support if you believe this is a mistake.",
"migrateBitboxCompleting": "Finishing move…",
"migrateBitboxIntroBalance": "Current balance",
"migrateBitboxIntroDescription": "Connect your BitBox, register it in the share register, and transfer your full REALU balance in one flow. Your software wallet stays available as a backup afterwards.",
"migrateBitboxIntroTitle": "Move to your BitBox",
"migrateBitboxLinking": "Linking wallet…",
"migrateBitboxPreparingTransfer": "Preparing balance…",
"migrateBitboxRegisterConfirmHint": "Confirm the registration on your BitBox to continue.",
"migrateBitboxRegisterCta": "Register wallet",
"migrateBitboxRegisterDescription": "Your BitBox will be added to your existing account and registered in the share register.",
"migrateBitboxRegisterTitle": "Register your BitBox",
"migrateBitboxRegistrationMissingError": "Your software wallet is not registered yet. Please complete the normal registration first, then try the move again.",
"migrateBitboxRegistrationPendingInfo": "Your registration is being reviewed. Your REALU balance stays on your software wallet — you can continue the move once the review is complete.",
"migrateBitboxSettling": "Confirming transfer…",
"migrateBitboxSettlingTimeoutInfo": "The transfer is still processing on the blockchain. The app will remain on your software wallet for now. Open the move again later — it will continue automatically.",
"migrateBitboxStart": "Get started",
"migrateBitboxSuccessDescription": "Your BitBox is now your active wallet and holds your REALU balance. Your software wallet remains available as a backup.",
"migrateBitboxSuccessTitle": "Move complete",
"migrateBitboxTransferCta": "Transfer balance",
"migrateBitboxTransferDescription": "Your full REALU balance will be transferred to your new BitBox address. This does not cost any gas.",
"migrateBitboxTransferTitle": "Transfer your balance",
"month": "Month",
"name": "Name",
"networkMainnet": "Mainnet",
Expand Down
27 changes: 27 additions & 0 deletions lib/packages/repository/wallet_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:realunit_wallet/packages/storage/database.dart';
import 'package:realunit_wallet/packages/storage/secure_storage.dart';
import 'package:realunit_wallet/packages/storage/wallet_storage.dart';
import 'package:realunit_wallet/packages/wallet/wallet.dart';
import 'package:web3dart/web3dart.dart';

class WalletRepository {
final AppDatabase _appDatabase;
Expand All @@ -22,6 +23,32 @@ class WalletRepository {
Future<int> createViewWallet(String name, WalletType type, String address) =>
_appDatabase.insertWallet(name, '', address, type.index);

/// Returns the row id of an existing BitBox wallet with [address], or null.
/// Used by the migration wizard to make committing a paired device idempotent.
Future<int?> getBitboxWalletIdByAddress(String address) async {
final normalizedAddress = _normalizedAddress(address);
if (normalizedAddress == null) return null;
final candidates = await _appDatabase.getWalletsByType(
WalletType.bitbox.index,
);
for (final candidate in candidates) {
if (_normalizedAddress(candidate.address) == normalizedAddress) {
return candidate.id;
}
}
return null;
}

String? _normalizedAddress(String address) {
try {
return EthereumAddress.fromHex(address).hexEip55;
} on FormatException {
return null;
} on ArgumentError {
return null;
}
}

/// 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.
Expand Down
58 changes: 41 additions & 17 deletions lib/packages/service/balance_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -59,23 +59,7 @@ class BalanceService {

if (response.statusCode == 200) {
if (generation == _syncGeneration) _accountMissing = false;

final json = jsonDecode(response.body);
final balanceString = json['balance'] as String?;

if (balanceString != null) {
final balanceValue = BigInt.parse(balanceString);

await _balanceRepository.saveBalance(
Balance(
chainId: _appStore.apiConfig.asset.chainId,
contractAddress: _appStore.apiConfig.asset.address,
walletAddress: address,
balance: balanceValue,
asset: _appStore.apiConfig.asset,
),
);
}
await _parseAndPersistBalance(address, response.body);
} else if (response.statusCode == 404) {
if (generation == _syncGeneration) _accountMissing = true;
}
Expand All @@ -84,6 +68,46 @@ class BalanceService {
}
}

/// Fresh, fail-loud balance read for money-moving flows (migration wizard):
/// performs the API fetch and THROWS on any transport error, non-200 status,
/// or unparseable body instead of falling back to the persisted cache. On
/// success the fresh value is also persisted (same shape as [updateBalance])
/// and returned. The polling [updateBalance] path stays log-and-continue —
/// this method exists precisely because that path may serve stale data.
Future<Balance> fetchBalance(String address) async {
final uri = buildUri(_host, '$_balancePath/$address');
final response = await _appStore.httpClient.get(uri);

if (response.statusCode == 404) {
throw Exception('RealUnit account not found (404) for address $address');
}
if (response.statusCode != 200) {
throw Exception(
'Failed to fetch RealUnit balance. Status: ${response.statusCode} ${response.body}',
);
}

return _parseAndPersistBalance(address, response.body);
}

Future<Balance> _parseAndPersistBalance(String address, String responseBody) async {
final json = jsonDecode(responseBody) as Map<String, dynamic>;
final balanceString = json['balance'];
if (balanceString is! String) {
throw const FormatException('Balance response has no parseable balance');
}

final balance = Balance(
chainId: _appStore.apiConfig.asset.chainId,
contractAddress: _appStore.apiConfig.asset.address,
walletAddress: address,
balance: BigInt.parse(balanceString),
asset: _appStore.apiConfig.asset,
);
await _balanceRepository.saveBalance(balance);
return balance;
}

Future<Balance?> getBalance(Asset asset, String address) =>
_balanceRepository.getBalance(asset, address);
}
20 changes: 17 additions & 3 deletions lib/packages/service/debug_auth_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const _signatureKey = 'debugAuthSignature';
class DebugAuthService {
final AppStore _appStore;
final SharedPreferences _prefs;
String? _fetchedSignMessage;
String? _fetchedSignMessageAddress;

DebugAuthService(this._appStore, this._prefs);

Expand All @@ -31,12 +33,20 @@ class DebugAuthService {

if (response.statusCode == 200) {
final body = jsonDecode(response.body);
return body['message'] as String;
final message = body['message'] as String;
_fetchedSignMessage = message;
_fetchedSignMessageAddress = address;
return message;
}
throw Exception('Failed to fetch sign message (${response.statusCode})');
}

Future<void> authenticate(String address, String signature) async {
final signedMessage = _fetchedSignMessageAddress == address ? _fetchedSignMessage : null;
if (signedMessage == null) {
throw StateError('No fetched sign message for address $address');
}

final uri = buildUri(_appStore.apiConfig.apiHost, '/v1/auth');
final response = await _appStore.httpClient.post(
uri,
Expand All @@ -51,8 +61,12 @@ class DebugAuthService {
if (response.statusCode == 201) {
final body = jsonDecode(response.body) as Map<String, dynamic>;
final checksumAddress = EthereumAddress.fromHex(address).hexEip55;
_appStore.sessionCache.setAuthToken(body['accessToken'] as String);
await _appStore.sessionCache.saveSignature(checksumAddress, signature);
_appStore.sessionCache.setAuthToken(body['accessToken'] as String, checksumAddress);
await _appStore.sessionCache.saveSignature(
checksumAddress,
signature,
signedMessage,
);
await _prefs.setString(_addressKey, address);
await _prefs.setString(_signatureKey, signature);
} else {
Expand Down
Loading
Loading