From 45cd7fd1e5f84f8a98a59af4b1cf637e5a879587 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:26:16 +0200 Subject: [PATCH 01/26] feat(migration): service layer for software-to-BitBox wallet migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - authenticateLinkedAccount: POST /v1/auth for a new address carrying the current session's bearer token, so the API links the address to the same account (OptionalJwtAuthGuard); 409 surfaces as AddressAlreadyLinkedException - bearerTokenOverride on authenticatedGet/Put/Post: explicit-token calls skip the 401 refresh (a refresh would mint a token for the wrong identity) - acquireUncommittedBitboxWallet/persistBitboxWallet: uncommitted draft with id-0 sentinel, idempotent persist deduplicated by address, no current-wallet switch (mirrors the software-wallet draft/commit pair) - getRegistrationInfoWith/registerWalletFor: registration flow in an explicit token+account context for the migration wizard - buildSignMessage now mirrors the API's environment-scoped auth message ([dev]_ prefix on testnet) — auth against the dev API was broken since the API introduced env-scoped sign messages --- .../repository/wallet_repository.dart | 10 + .../service/dfx/dfx_auth_service.dart | 124 ++++++++- .../address_already_linked_exception.dart | 11 + .../dfx/real_unit_registration_service.dart | 60 ++++- lib/packages/service/wallet_service.dart | 41 +++ lib/packages/storage/wallet_storage.dart | 6 + .../repository/wallet_repository_test.dart | 16 ++ .../service/dfx/dfx_auth_service_test.dart | 241 ++++++++++++++++++ .../exceptions/exception_surface_test.dart | 2 + .../real_unit_registration_service_test.dart | 124 +++++++++ .../packages/service/wallet_service_test.dart | 98 +++++++ .../packages/storage/wallet_storage_test.dart | 18 ++ 12 files changed, 737 insertions(+), 14 deletions(-) create mode 100644 lib/packages/service/dfx/exceptions/address_already_linked_exception.dart diff --git a/lib/packages/repository/wallet_repository.dart b/lib/packages/repository/wallet_repository.dart index e2ffbce74..2341b6fee 100644 --- a/lib/packages/repository/wallet_repository.dart +++ b/lib/packages/repository/wallet_repository.dart @@ -22,6 +22,16 @@ class WalletRepository { Future 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 getBitboxWalletIdByAddress(String address) async { + final info = await _appDatabase.getWalletByTypeAndAddress( + WalletType.bitbox.index, + address, + ); + return info?.id; + } + /// 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. diff --git a/lib/packages/service/dfx/dfx_auth_service.dart b/lib/packages/service/dfx/dfx_auth_service.dart index 23ac17bb1..86f78836c 100644 --- a/lib/packages/service/dfx/dfx_auth_service.dart +++ b/lib/packages/service/dfx/dfx_auth_service.dart @@ -5,6 +5,7 @@ import 'dart:developer' as developer; import 'package:http/http.dart' as http; import 'package:realunit_wallet/packages/config/api_config.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/address_already_linked_exception.dart'; import 'package:realunit_wallet/packages/service/wallet_service.dart'; import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; @@ -38,12 +39,18 @@ abstract class DFXAuthService { static const _signMessageTimeout = Duration(minutes: 3); static const _httpTimeout = Duration(seconds: 20); - /// Auth sign-in message, derived locally from the address. Mirrors the - /// server's `Config.auth.signMessageGeneral` template (DFXswiss/api): the - /// backend re-derives this exact string from the address on every verify - /// (stateless, no nonce) and accepts it, so there is no need to first - /// round-trip through `GET /v1/auth/signMessage`. Dropping that call also - /// removes a network-timeout failure mode from the onboarding/pairing flow. + /// Auth sign-in message body (without environment scoping), derived + /// locally from the address. Mirrors the server's + /// `Config.auth.signMessageGeneral` template (DFXswiss/api): the backend + /// re-derives this string from the address on every verify (stateless, no + /// nonce) and accepts it, so there is no need to first round-trip through + /// `GET /v1/auth/signMessage`. Dropping that call also removes a + /// network-timeout failure mode from the onboarding/pairing flow. + /// + /// Non-PRD environments prefix the full message with `[]_` via + /// [buildSignMessage] so a signature from one environment cannot + /// authenticate on another (api `signMessagePrefix`); PRD keeps this + /// historical body byte-for-byte. static const _signMessagePrefix = 'By_signing_this_message,_you_confirm_that_you_are_the_sole_owner_' 'of_the_provided_Blockchain_address._Your_ID:_'; @@ -62,8 +69,77 @@ abstract class DFXAuthService { String getSignMessage() => buildSignMessage(walletAddress); - /// Builds the deterministic auth sign-in message for [address] (EIP-55). - String buildSignMessage(String address) => '$_signMessagePrefix$address'; + /// Mirrors the server template INCLUDING its environment scoping (DFXswiss/api + /// config.ts `signMessagePrefix`): non-PRD environments prefix the message + /// with `[]_` so a signature from one environment cannot authenticate on + /// another; PRD keeps the historical text byte-for-byte. The app only ever + /// talks to dev (testnet) or prd (mainnet), so the dev prefix is the single + /// non-PRD case here. + String buildSignMessage(String address) => + '${appStore.apiConfig.networkMode.isTestnet ? '[dev]_' : ''}$_signMessagePrefix$address'; + + /// Authenticates [account]'s address against `POST /v1/auth` while sending + /// [linkBearerToken] (the JWT of the CURRENTLY signed-in wallet) as + /// Authorization header. The API's OptionalJwtAuthGuard then creates the new + /// address under the SAME UserData as the token owner (DFXswiss/api + /// auth.controller → authenticate(dto, ip, jwt?.account, jwt?.user)), which is + /// what makes the one-tap ADD_WALLET registration state reachable for the new + /// address. Returns the access token issued FOR THE NEW ADDRESS. The returned + /// token is intentionally NOT written to the session cache — the caller owns + /// the decision when the app switches identity. + /// Throws [AddressAlreadyLinkedException] on 409 (address belongs to another + /// account), [SigningCancelledException] on empty/cancelled signature. + Future authenticateLinkedAccount( + AWalletAccount account, + String linkBearerToken, + ) async { + final address = account.primaryAddress.address.hexEip55; + + await appStore.sessionCache.loadSignature(); + final cachedSignature = appStore.sessionCache.signature; + final signatureAddress = appStore.sessionCache.signatureAddress; + late final String signature; + if (cachedSignature != null && signatureAddress == address) { + signature = cachedSignature; + } else { + signature = await account + .signMessage(buildSignMessage(address)) + .timeout(_signMessageTimeout); + if (signature.isEmpty || signature == '0x') { + throw const SigningCancelledException(); + } + await appStore.sessionCache.saveSignature(address, signature); + } + + final requestBody = jsonEncode({ + 'wallet': walletName, + 'address': address, + 'signature': signature, + }); + + final uri = buildUri(host, authPath); + final response = await appStore.httpClient + .post( + uri, + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $linkBearerToken', + }, + body: requestBody, + ) + .timeout(_httpTimeout); + + if (response.statusCode == 201) { + final responseBody = jsonDecode(response.body) as Map; + return responseBody['accessToken'] as String; + } else if (response.statusCode == 409) { + throw const AddressAlreadyLinkedException(); + } else { + throw Exception( + 'Failed to link address. Status: ${response.statusCode} ${response.body}', + ); + } + } /// Create-and-persist the auth signature for [account] without going through /// `appStore.wallet`. Used during the BitBox pairing flow so the signature is @@ -173,9 +249,14 @@ abstract class DFXAuthService { return getAuthToken(); } + /// When [bearerTokenOverride] is non-null it is used verbatim and the + /// 401-refresh path is skipped (a refresh would mint a token for the + /// CURRENT wallet — wrong context for an override call). `null` keeps + /// today's session-token + one-shot 401-refresh behaviour. Future authenticatedGet( Uri uri, { Map headers = const {}, + String? bearerTokenOverride, }) { return _authenticated( (token) => appStore.httpClient.get( @@ -185,14 +266,20 @@ abstract class DFXAuthService { 'Authorization': 'Bearer $token', }, ), + bearerTokenOverride: bearerTokenOverride, ); } + /// When [bearerTokenOverride] is non-null it is used verbatim and the + /// 401-refresh path is skipped (a refresh would mint a token for the + /// CURRENT wallet — wrong context for an override call). `null` keeps + /// today's session-token + one-shot 401-refresh behaviour. Future authenticatedPut( Uri uri, { Map headers = const {}, Object? body, Encoding? encoding, + String? bearerTokenOverride, }) { return _authenticated( (token) => appStore.httpClient.put( @@ -204,14 +291,20 @@ abstract class DFXAuthService { body: body, encoding: encoding, ), + bearerTokenOverride: bearerTokenOverride, ); } + /// When [bearerTokenOverride] is non-null it is used verbatim and the + /// 401-refresh path is skipped (a refresh would mint a token for the + /// CURRENT wallet — wrong context for an override call). `null` keeps + /// today's session-token + one-shot 401-refresh behaviour. Future authenticatedPost( Uri uri, { Map headers = const {}, Object? body, Encoding? encoding, + String? bearerTokenOverride, }) { return _authenticated( (token) => appStore.httpClient.post( @@ -223,6 +316,7 @@ abstract class DFXAuthService { body: body, encoding: encoding, ), + bearerTokenOverride: bearerTokenOverride, ); } @@ -230,9 +324,19 @@ abstract class DFXAuthService { /// refreshed token. The caller is responsible for passing the token into /// the request headers (so each verb can keep its own `body`/`encoding` /// arguments without us re-serialising them here). + /// + /// When [bearerTokenOverride] is non-null that exact token is used and the + /// 401-refresh is skipped — a refresh would mint a token for the CURRENT + /// wallet, which is the wrong identity for an override-scoped call. The + /// 401 response is returned to the caller unchanged. Future _authenticated( - Future Function(String? token) request, - ) async { + Future Function(String? token) request, { + String? bearerTokenOverride, + }) async { + if (bearerTokenOverride != null) { + return request(bearerTokenOverride); + } + var authToken = await getAuthToken(); var response = await request(authToken); diff --git a/lib/packages/service/dfx/exceptions/address_already_linked_exception.dart b/lib/packages/service/dfx/exceptions/address_already_linked_exception.dart new file mode 100644 index 000000000..a75e05c59 --- /dev/null +++ b/lib/packages/service/dfx/exceptions/address_already_linked_exception.dart @@ -0,0 +1,11 @@ +/// `POST /v1/auth` with a link token returned 409 — the address is already +/// attached to a DIFFERENT account and can therefore not be linked to the +/// current user (api-side ConflictException 'Address already linked to +/// another account'). +class AddressAlreadyLinkedException implements Exception { + const AddressAlreadyLinkedException(); + + @override + String toString() => + 'AddressAlreadyLinkedException: address is already linked to another account'; +} diff --git a/lib/packages/service/dfx/real_unit_registration_service.dart b/lib/packages/service/dfx/real_unit_registration_service.dart index ef0bd1400..7a3e0761b 100644 --- a/lib/packages/service/dfx/real_unit_registration_service.dart +++ b/lib/packages/service/dfx/real_unit_registration_service.dart @@ -17,6 +17,8 @@ import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_u import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_info_dto.dart'; import 'package:realunit_wallet/packages/utils/ascii_transliterate.dart'; import 'package:realunit_wallet/packages/wallet/eip712_signer.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; +import 'package:web3dart/web3dart.dart'; class RealUnitRegistrationService extends DFXAuthService { RealUnitRegistrationService(super.appStore, super.walletService); @@ -34,11 +36,24 @@ class RealUnitRegistrationService extends DFXAuthService { /// registration form, a one-tap "add wallet" confirmation, is already /// registered, or is blocked on KYC — see `RealUnitRegistrationState`. /// Renamed from the deprecated `/v1/realunit/wallet/status` mirror. - Future getRegistrationInfo() async { + Future getRegistrationInfo() => + _getRegistrationInfo(); + + /// [getRegistrationInfo] evaluated in an EXPLICIT auth context: [bearerToken] + /// is used verbatim instead of the session token. Lets the migration wizard + /// query the registration state OF THE NEW LINKED ADDRESS while the software + /// wallet stays the app-wide current wallet. + Future getRegistrationInfoWith(String bearerToken) => + _getRegistrationInfo(bearerTokenOverride: bearerToken); + + Future _getRegistrationInfo({ + String? bearerTokenOverride, + }) async { final uri = buildUri(host, _registrationInfoPath); final response = await authenticatedGet( uri, headers: {'Content-Type': 'application/json'}, + bearerTokenOverride: bearerTokenOverride, ); if (response.statusCode != 200) { @@ -55,11 +70,14 @@ class RealUnitRegistrationService extends DFXAuthService { /// the device's local date. A device in a timezone ahead of UTC would /// otherwise sign tomorrow's date and be rejected by the backend. Fetched /// immediately before signing so it is always fresh. - Future getRegistrationDate() async { + Future getRegistrationDate() => _getRegistrationDate(); + + Future _getRegistrationDate({String? bearerTokenOverride}) async { final uri = buildUri(host, _registerDatePath); final response = await authenticatedGet( uri, headers: {'Content-Type': 'application/json'}, + bearerTokenOverride: bearerTokenOverride, ); if (response.statusCode != 200) { @@ -238,8 +256,41 @@ class RealUnitRegistrationService extends DFXAuthService { } } - Future _registerWallet(RealUnitUserDataDto userData, String registrationDate) async { - final credentials = appStore.wallet.primaryAccount.primaryAddress; + /// [registerWallet] in an EXPLICIT context: signs the EIP-712 registration + /// envelope with [account] (the new wallet — a BitBox confirms on the device) + /// and submits with [bearerToken] (the new address's JWT from + /// authenticateLinkedAccount). Does NOT touch appStore.wallet and does NOT + /// unlock/lock the software wallet — the signer is [account], nothing else. + Future registerWalletFor( + AWalletAccount account, + RealUnitUserDataDto userData, + String bearerToken, + ) async { + final registrationDate = await _getRegistrationDate(bearerTokenOverride: bearerToken); + return _submitRegisterWallet( + credentials: account.primaryAddress, + userData: userData, + registrationDate: registrationDate, + bearerTokenOverride: bearerToken, + ); + } + + Future _registerWallet( + RealUnitUserDataDto userData, + String registrationDate, + ) => + _submitRegisterWallet( + credentials: appStore.wallet.primaryAccount.primaryAddress, + userData: userData, + registrationDate: registrationDate, + ); + + Future _submitRegisterWallet({ + required CredentialsWithKnownAddress credentials, + required RealUnitUserDataDto userData, + required String registrationDate, + String? bearerTokenOverride, + }) async { // Same ASCII guard as completeRegistration — see comment there. final signature = await Eip712Signer.signRegistration( credentials: credentials, @@ -271,6 +322,7 @@ class RealUnitRegistrationService extends DFXAuthService { 'Content-Type': 'application/json', }, body: jsonEncode(requestDto), + bearerTokenOverride: bearerTokenOverride, ); if (response.statusCode != 201 && response.statusCode != 202) { diff --git a/lib/packages/service/wallet_service.dart b/lib/packages/service/wallet_service.dart index 83b32e5fc..7d787f919 100644 --- a/lib/packages/service/wallet_service.dart +++ b/lib/packages/service/wallet_service.dart @@ -102,6 +102,47 @@ class WalletService { return BitboxWallet(walletId, name, address, _bitboxService); } + /// Reads the ETH address from the connected BitBox and returns an + /// UNCOMMITTED [BitboxWallet] draft — `id` is the `0` sentinel, no row is + /// written to `walletInfos` and the current wallet is NOT switched. Pair with + /// [persistBitboxWallet] once the new address is registered server-side, + /// mirroring [generateUncommittedSeedWallet]/[commitGeneratedWallet] so an + /// aborted wizard leaves no orphan wallet row behind. + /// Throws [BitboxAddressUnavailableException] on an unusable address (same + /// guard as [createBitboxWallet]). + Future acquireUncommittedBitboxWallet(String name) async { + final address = await _bitboxService.getEthAddress(); + if (!_isValidEthAddress(address)) { + throw const BitboxAddressUnavailableException(); + } + return BitboxWallet(0, name, address, _bitboxService); + } + + /// Persists a [draft] from [acquireUncommittedBitboxWallet] WITHOUT switching + /// the current wallet. Deduplicates by address: an existing BitBox row with + /// the same address is reused instead of inserting a second row (covers a + /// device that was already paired once outside the wizard, and makes the call + /// idempotent across wizard re-entries). Asserts on a non-draft id in dev + /// (mirror of [commitGeneratedWallet]). + /// + /// The migration wizard calls this BEFORE any funds move (right after the new + /// address is registered server-side), so an app death between the balance + /// transfer and the final wallet switch still leaves a local wallet row + /// pointing at the funded address. The final switch is a plain + /// [setCurrentWallet] — deliberately not part of this method. + Future persistBitboxWallet(BitboxWallet draft) async { + assert( + draft.id == 0, + 'persistBitboxWallet expects an uncommitted draft (id == 0); ' + 'got id=${draft.id} — likely double-commit or wrong caller.', + ); + final address = draft.primaryAccount.primaryAddress.address.hexEip55; + final existingId = await _repository.getBitboxWalletIdByAddress(address); + final id = existingId ?? + await _repository.createViewWallet(draft.name, WalletType.bitbox, address); + return BitboxWallet(id, draft.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 diff --git a/lib/packages/storage/wallet_storage.dart b/lib/packages/storage/wallet_storage.dart index 81317d765..70c455f05 100644 --- a/lib/packages/storage/wallet_storage.dart +++ b/lib/packages/storage/wallet_storage.dart @@ -9,6 +9,12 @@ extension WalletStorage on AppDatabase { Future getWalletById(int id) => (select(walletInfos)..where((row) => row.id.equals(id))).getSingleOrNull(); + Future getWalletByTypeAndAddress(int walletType, String address) => + (select(walletInfos) + ..where((row) => row.type.equals(walletType) & row.address.equals(address)) + ..limit(1)) + .getSingleOrNull(); + Future updateWalletAddress(int id, String address) => (update( walletInfos, )..where((row) => row.id.equals(id))).write(WalletInfosCompanion(address: Value(address))); diff --git a/test/packages/repository/wallet_repository_test.dart b/test/packages/repository/wallet_repository_test.dart index b9ab2ded5..3e0b83fce 100644 --- a/test/packages/repository/wallet_repository_test.dart +++ b/test/packages/repository/wallet_repository_test.dart @@ -72,6 +72,22 @@ void main() { verifyNever(() => secureStorage.getOrCreateMnemonicKey()); }); + test( + 'getBitboxWalletIdByAddress returns the BitBox row id or null', + () async { + final bitboxId = await repo.createViewWallet( + 'Hardware', + WalletType.bitbox, + address, + ); + // Same address, different type — must NOT match the BitBox lookup. + await repo.createViewWallet('SoftwareView', WalletType.software, address); + + expect(await repo.getBitboxWalletIdByAddress(address), bitboxId); + expect(await repo.getBitboxWalletIdByAddress('0xNoSuchAddress000000000000000000000001'), isNull); + }, + ); + test('getWalletInfo returns the row with the seed still encrypted', () async { final id = await repo.createWallet(walletName, WalletType.software, seed, address); diff --git a/test/packages/service/dfx/dfx_auth_service_test.dart b/test/packages/service/dfx/dfx_auth_service_test.dart index c5b685dd0..1cb159921 100644 --- a/test/packages/service/dfx/dfx_auth_service_test.dart +++ b/test/packages/service/dfx/dfx_auth_service_test.dart @@ -12,6 +12,7 @@ import 'package:realunit_wallet/packages/config/network_mode.dart'; import 'package:realunit_wallet/packages/repository/cache_repository.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/address_already_linked_exception.dart'; import 'package:realunit_wallet/packages/service/session_cache.dart'; import 'package:realunit_wallet/packages/service/wallet_service.dart'; import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; @@ -397,6 +398,77 @@ void main() { ); expect(service.refreshAuthTokenCalls, 1); }); + + test( + 'bearerTokenOverride uses the exact token and skips the 401-refresh path', + () async { + final seenAuthHeaders = []; + var calls = 0; + final client = MockClient((request) async { + calls++; + seenAuthHeaders.add(request.headers['Authorization']); + return http.Response('{"message":"Unauthorized"}', 401); + }); + final service = _RetryTestAuthService(_RetryTestAppStore(client), _MockWalletService()); + + final response = await service.authenticatedGet( + Uri.parse('https://api.example.test/v1/resource'), + bearerTokenOverride: 'link-jwt-for-new-address', + ); + + expect(response.statusCode, 401); + expect(calls, 1, reason: 'override must not retry on 401'); + expect(seenAuthHeaders, ['Bearer link-jwt-for-new-address']); + expect(service.authTokenCalls, 0); + expect(service.refreshAuthTokenCalls, 0); + }, + ); + + test( + 'authenticatedPost bearerTokenOverride lands in the Authorization header', + () async { + String? auth; + final client = MockClient((request) async { + auth = request.headers['Authorization']; + return http.Response('{"ok":true}', 201); + }); + final service = _RetryTestAuthService(_RetryTestAppStore(client), _MockWalletService()); + + await service.authenticatedPost( + Uri.parse('https://api.example.test/v1/resource'), + headers: {'Content-Type': 'application/json'}, + body: '{"value":1}', + bearerTokenOverride: 'override-post-token', + ); + + expect(auth, 'Bearer override-post-token'); + expect(service.authTokenCalls, 0); + expect(service.refreshAuthTokenCalls, 0); + }, + ); + + test( + 'authenticatedPut bearerTokenOverride lands in the Authorization header', + () async { + String? auth; + final client = MockClient((request) async { + auth = request.headers['Authorization']; + return http.Response('{"ok":true}', 200); + }); + final service = _RetryTestAuthService(_RetryTestAppStore(client), _MockWalletService()); + + await service.authenticatedPut( + Uri.parse('https://api.example.test/v1/resource'), + headers: {'Content-Type': 'application/json'}, + body: '{"value":1}', + bearerTokenOverride: 'override-put-token', + ); + + expect(auth, 'Bearer override-put-token'); + expect(service.authTokenCalls, 0); + expect(service.refreshAuthTokenCalls, 0); + }, + ); }); // ------------------------------------------------------------------------- @@ -595,6 +667,30 @@ void main() { expect(httpCalled, isFalse, reason: 'no /v1/auth/signMessage round-trip'); }); + test('buildSignMessage on mainnet keeps the historical PRD text byte-for-byte', () { + final client = MockClient((_) async => http.Response('', 200)); + final message = buildService(client).buildSignMessage(walletAddress); + + expect( + message, + 'By_signing_this_message,_you_confirm_that_you_are_the_sole_owner_' + 'of_the_provided_Blockchain_address._Your_ID:_$walletAddress', + ); + expect(message.startsWith('[dev]_'), isFalse); + }); + + test('buildSignMessage on testnet prefixes the message with [dev]_', () { + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.testnet)); + final client = MockClient((_) async => http.Response('', 200)); + final message = buildService(client).buildSignMessage(walletAddress); + + expect( + message, + '[dev]_By_signing_this_message,_you_confirm_that_you_are_the_sole_owner_' + 'of_the_provided_Blockchain_address._Your_ID:_$walletAddress', + ); + }); + test( 'getAuthResponse POSTs /v1/auth with wallet + address + signature, returns the parsed 201', () async { @@ -770,6 +866,151 @@ void main() { }); }); + // ------------------------------------------------------------------------- + // authenticateLinkedAccount — link a NEW address under the current UserData + // by POSTing /v1/auth with the CURRENT wallet's JWT as Authorization. + // ------------------------------------------------------------------------- + + group('$DFXAuthService authenticateLinkedAccount', () { + late _MockAppStore appStore; + late _MockSessionCache sessionCache; + late _MockWalletService walletService; + late _StubWalletAccount account; + + const accountAddress = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + final accountAddressEip55 = EthereumAddress.fromHex(accountAddress).hexEip55; + const stubSignature = '0xfeedface'; + const linkBearerToken = 'jwt-of-current-software-wallet'; + + setUp(() { + appStore = _MockAppStore(); + sessionCache = _MockSessionCache(); + walletService = _MockWalletService(); + account = _StubWalletAccount(stubSignature, address: accountAddress); + + when(() => appStore.sessionCache).thenReturn(sessionCache); + when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => sessionCache.loadSignature()).thenAnswer((_) async {}); + when(() => sessionCache.signature).thenReturn(null); + when(() => sessionCache.signatureAddress).thenReturn(null); + when(() => sessionCache.saveSignature(any(), any())).thenAnswer((_) async {}); + when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); + when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); + }); + + _SignatureTestAuthService buildService(http.Client client) { + when(() => appStore.httpClient).thenReturn(client); + return _SignatureTestAuthService( + appStore, + walletService, + account, + accountAddressEip55, + ); + } + + test( + 'cache-miss: signs, caches, POSTs /v1/auth with link Bearer + body, returns accessToken', + () async { + Map? sentBody; + Map? sentHeaders; + String? sentMethod; + String? sentPath; + final client = MockClient((request) async { + sentMethod = request.method; + sentPath = request.url.path; + sentHeaders = request.headers; + sentBody = jsonDecode(request.body) as Map; + return http.Response(jsonEncode({'accessToken': 'jwt-for-new-address'}), 201); + }); + + final token = await buildService(client).authenticateLinkedAccount( + account, + linkBearerToken, + ); + + expect(token, 'jwt-for-new-address'); + expect(sentMethod, 'POST'); + expect(sentPath, '/v1/auth'); + expect(sentHeaders!['authorization'], 'Bearer $linkBearerToken'); + expect(sentHeaders!['content-type'], contains('application/json')); + expect(sentBody!['wallet'], 'RealUnit'); + expect(sentBody!['address'], accountAddressEip55); + expect(sentBody!['signature'], stubSignature); + expect(account.signCallCount, 1); + verify(() => sessionCache.saveSignature(accountAddressEip55, stubSignature)).called(1); + // Returned token must NOT be written to the session cache — the + // caller owns the identity switch. + verifyNever(() => sessionCache.setAuthToken(any())); + }, + ); + + test('cache-hit: reuses the cached signature and does not re-sign', () async { + when(() => sessionCache.signature).thenReturn(stubSignature); + when(() => sessionCache.signatureAddress).thenReturn(accountAddressEip55); + Map? sentBody; + final client = MockClient((request) async { + sentBody = jsonDecode(request.body) as Map; + return http.Response(jsonEncode({'accessToken': 'jwt-linked'}), 201); + }); + + final token = await buildService(client).authenticateLinkedAccount( + account, + linkBearerToken, + ); + + expect(token, 'jwt-linked'); + expect(account.signCallCount, 0); + expect(sentBody!['signature'], stubSignature); + verifyNever(() => sessionCache.saveSignature(any(), any())); + }); + + for (final empty in const ['', '0x']) { + test( + 'throws SigningCancelledException when the device returns "$empty"', + () async { + account = _StubWalletAccount(empty, address: accountAddress); + final client = MockClient((_) async => http.Response('should-not-be-called', 500)); + + await expectLater( + () => buildService(client).authenticateLinkedAccount(account, linkBearerToken), + throwsA(isA()), + ); + }, + ); + } + + test('throws AddressAlreadyLinkedException on 409', () async { + final client = MockClient( + (_) async => http.Response( + jsonEncode({'statusCode': 409, 'message': 'Address already linked to another account'}), + 409, + ), + ); + + await expectLater( + () => buildService(client).authenticateLinkedAccount(account, linkBearerToken), + throwsA(isA()), + ); + }); + + test('throws a generic Exception on non-201/409 status (e.g. 500)', () async { + final client = MockClient( + (_) async => http.Response('upstream broken', 500), + ); + + await expectLater( + () => buildService(client).authenticateLinkedAccount(account, linkBearerToken), + throwsA( + isA().having( + (e) => e.toString(), + 'toString()', + contains('Failed to link address. Status: 500'), + ), + ), + ); + }); + }); + // ------------------------------------------------------------------------- // _signMessageTimeout — 3 min cap on the sign ceremony. // diff --git a/test/packages/service/dfx/exceptions/exception_surface_test.dart b/test/packages/service/dfx/exceptions/exception_surface_test.dart index 55387f3cb..3fb87c1bb 100644 --- a/test/packages/service/dfx/exceptions/exception_surface_test.dart +++ b/test/packages/service/dfx/exceptions/exception_surface_test.dart @@ -1,4 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/address_already_linked_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_address_unavailable_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; @@ -22,6 +23,7 @@ void main() { final exceptions = [ const BitboxNotConnectedException(), const BitboxAddressUnavailableException(), + const AddressAlreadyLinkedException(), const SigningCancelledException(), const ApiException(code: 'TEST', message: 'test'), const RegistrationRejectedException(code: 'TEST', message: 'test'), 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 dc31f7ca6..19e963b77 100644 --- a/test/packages/service/dfx/real_unit_registration_service_test.dart +++ b/test/packages/service/dfx/real_unit_registration_service_test.dart @@ -13,6 +13,7 @@ import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/buy_exceptions.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/registration_rejected_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_email_status.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_status.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_user_type.dart'; import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_state.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; @@ -174,6 +175,30 @@ void main() { }); }); + group('$RealUnitRegistrationService.getRegistrationInfoWith', () { + test( + 'GETs /v1/realunit/registration with the explicit bearerToken, not the session JWT', + () async { + String? auth; + String? path; + final client = MockClient((request) async { + auth = request.headers['Authorization']; + path = request.url.path; + return http.Response( + jsonEncode({'state': 'AddWallet', 'userData': null}), + 200, + ); + }); + + final info = await build(client).getRegistrationInfoWith('jwt-for-linked-address'); + + expect(info.state, RealUnitRegistrationState.addWallet); + expect(path, '/v1/realunit/registration'); + expect(auth, 'Bearer jwt-for-linked-address'); + }, + ); + }); + group('$RealUnitRegistrationService.completeRegistration', () { Registration buildRegistration() => const Registration( type: RegistrationUserType.human, @@ -378,6 +403,105 @@ void main() { }); }); + group('$RealUnitRegistrationService.registerWalletFor', () { + RealUnitUserDataDto buildUserData() => const RealUnitUserDataDto( + email: 'a@b.com', + name: 'Ada Lovelace', + type: 'HUMAN', + phoneNumber: '+41 79 000 00 00', + birthday: '1815-12-10', + nationality: 'CH', + addressStreet: 'Bahnhofstrasse 1', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: 'CH', + swissTaxResidence: true, + lang: 'de', + kycData: KycPersonalData( + accountType: KycAccountType.personal, + firstName: 'Ada', + lastName: 'Lovelace', + phone: '+41 79 000 00 00', + address: KycAddress( + street: 'Bahnhofstrasse', + zip: '8000', + city: 'Zurich', + country: 41, + ), + ), + ); + + test( + 'signs with the explicit account credentials and submits with the override bearer token', + () async { + final linkedAccount = _MockAccount(); + final linkedCreds = FakeBitboxCredentials(); + when(() => linkedAccount.primaryAddress).thenReturn(linkedCreds); + + final seenAuthHeaders = []; + final seenPaths = []; + Map? registerBody; + final client = MockClient((request) async { + seenPaths.add(request.url.path); + seenAuthHeaders.add(request.headers['Authorization']); + if (request.url.path == '/v1/realunit/register/date') { + return http.Response(jsonEncode({'date': '2026-07-13'}), 200); + } + if (request.url.path == '/v1/realunit/register/wallet') { + registerBody = jsonDecode(request.body) as Map; + return http.Response(jsonEncode({'status': 'completed'}), 201); + } + return http.Response('unexpected', 500); + }); + + final status = await build(client).registerWalletFor( + linkedAccount, + buildUserData(), + 'jwt-for-linked-address', + ); + + expect(status, RegistrationStatus.completed); + // Both the date fetch and the register POST must carry the override + // token — not the session JWT of the software wallet. + expect( + seenAuthHeaders, + everyElement(equals('Bearer jwt-for-linked-address')), + ); + expect(seenPaths, contains('/v1/realunit/register/date')); + expect(seenPaths, contains('/v1/realunit/register/wallet')); + expect(registerBody!['walletAddress'], linkedCreds.address.hexEip55); + expect(registerBody!['registrationDate'], '2026-07-13'); + expect((registerBody!['signature'] as String).length, 132); + // Explicit-account path must NOT unlock/lock the software wallet. + verifyNever(() => walletService.ensureCurrentWalletUnlocked()); + verifyNever(() => walletService.lockCurrentWallet()); + }, + ); + + test('throws BitboxNotConnectedException when the explicit account is disconnected', () async { + final linkedAccount = _MockAccount(); + when(() => linkedAccount.primaryAddress).thenReturn( + FakeBitboxCredentials(behavior: FakeBitboxBehavior.disconnect)..bitboxManager = null, + ); + final client = MockClient((request) async { + if (request.url.path == '/v1/realunit/register/date') { + return http.Response('{"date":"2026-07-13"}', 200); + } + return http.Response('{}', 201); + }); + + expect( + () => build(client).registerWalletFor( + linkedAccount, + buildUserData(), + 'jwt-for-linked-address', + ), + throwsA(isA()), + ); + verifyNever(() => walletService.ensureCurrentWalletUnlocked()); + }); + }); + group('malformed JSON responses', () { test('registerEmail with non-JSON 201 throws FormatException', () async { final client = MockClient((_) async => http.Response('not json', 201)); diff --git a/test/packages/service/wallet_service_test.dart b/test/packages/service/wallet_service_test.dart index 95ce405f3..9f07716cf 100644 --- a/test/packages/service/wallet_service_test.dart +++ b/test/packages/service/wallet_service_test.dart @@ -290,6 +290,104 @@ void main() { }); }); + group('acquireUncommittedBitboxWallet', () { + test( + 'returns a draft BitboxWallet with id=0 and does NOT insert or switch', + () async { + when(() => bitbox.getEthAddress()).thenAnswer((_) async => _debugAddress); + when(() => bitbox.getCredentials(any())).thenReturn(BitboxCredentials(_debugAddress)); + + final draft = await service.acquireUncommittedBitboxWallet('Migration'); + + expect(draft, isA()); + expect( + draft.id, + 0, + reason: + 'uncommitted drafts use the 0 sentinel until persistBitboxWallet lands the row', + ); + expect(draft.name, 'Migration'); + expect( + draft.primaryAccount.primaryAddress.address.hexEip55, + BitboxCredentials(_debugAddress).address.hexEip55, + ); + verify(() => bitbox.getEthAddress()).called(1); + verifyNever(() => repo.createViewWallet(any(), any(), any())); + verifyNever(() => settings.saveCurrentWalletId(any())); + }, + ); + + test('throws BitboxAddressUnavailableException on a malformed address', () async { + when(() => bitbox.getEthAddress()).thenAnswer((_) async => 'not-a-hex-address'); + + await expectLater( + () => service.acquireUncommittedBitboxWallet('Migration'), + throwsA(isA()), + ); + verifyNever(() => repo.createViewWallet(any(), any(), any())); + verifyNever(() => settings.saveCurrentWalletId(any())); + }); + + test('throws BitboxAddressUnavailableException on an empty address', () async { + when(() => bitbox.getEthAddress()).thenAnswer((_) async => ''); + + await expectLater( + () => service.acquireUncommittedBitboxWallet('Migration'), + throwsA(isA()), + ); + verifyNever(() => repo.createViewWallet(any(), any(), any())); + verifyNever(() => settings.saveCurrentWalletId(any())); + }); + }); + + group('persistBitboxWallet', () { + setUp(() { + when(() => bitbox.getCredentials(any())).thenReturn(BitboxCredentials(_debugAddress)); + }); + + test('inserts a new BitBox view row when no existing row matches the address', () async { + when(() => repo.getBitboxWalletIdByAddress(any())).thenAnswer((_) async => null); + when(() => repo.createViewWallet(any(), any(), any())).thenAnswer((_) async => 33); + + final draft = BitboxWallet(0, 'Migration', _debugAddress, bitbox); + final persisted = await service.persistBitboxWallet(draft); + + expect(persisted.id, 33); + expect(persisted.name, 'Migration'); + verify(() => repo.getBitboxWalletIdByAddress(any())).called(1); + verify( + () => repo.createViewWallet('Migration', WalletType.bitbox, any()), + ).called(1); + // Migration commits the row WITHOUT switching current wallet — the + // wizard switches only after the funds move completes. + verifyNever(() => settings.saveCurrentWalletId(any())); + }); + + test( + 'reuses an existing BitBox row with the same address (idempotent dedup)', + () async { + when(() => repo.getBitboxWalletIdByAddress(any())).thenAnswer((_) async => 17); + + final draft = BitboxWallet(0, 'Migration', _debugAddress, bitbox); + final persisted = await service.persistBitboxWallet(draft); + + expect(persisted.id, 17); + expect(persisted.name, 'Migration'); + verifyNever(() => repo.createViewWallet(any(), any(), any())); + verifyNever(() => settings.saveCurrentWalletId(any())); + }, + ); + + test('asserts that the draft carries the id=0 sentinel', () async { + final alreadyPersisted = BitboxWallet(99, 'Migration', _debugAddress, bitbox); + + await expectLater( + () => service.persistBitboxWallet(alreadyPersisted), + throwsA(isA()), + ); + }); + }); + group('currentWalletNeedsAddressRecovery', () { test('true for a BitBox row persisted with an empty address', () async { when(() => settings.currentWalletId).thenReturn(5); diff --git a/test/packages/storage/wallet_storage_test.dart b/test/packages/storage/wallet_storage_test.dart index 84e63052e..2037ab84a 100644 --- a/test/packages/storage/wallet_storage_test.dart +++ b/test/packages/storage/wallet_storage_test.dart @@ -41,6 +41,24 @@ void main() { expect(await db.getWalletById(9999), isNull); }); + test( + 'getWalletByTypeAndAddress returns the matching row and null when type or address differs', + () async { + final bitboxId = await db.insertWallet('Hardware', '', '0xBitBox', 1); + await db.insertWallet('Software', 'seed', '0xBitBox', 0); + await db.insertWallet('OtherHw', '', '0xOther', 1); + + final hit = await db.getWalletByTypeAndAddress(1, '0xBitBox'); + expect(hit, isNotNull); + expect(hit!.id, bitboxId); + expect(hit.type, 1); + expect(hit.address, '0xBitBox'); + + expect(await db.getWalletByTypeAndAddress(0, '0xBitBox'), isNotNull); + expect(await db.getWalletByTypeAndAddress(1, '0xMissing'), isNull); + }, + ); + test('updateWalletAddress mutates only the address column', () async { final id = await db.insertWallet('Main', 'enc-seed', '0xOld', 0); From 0b9396f1ca2aa63d27b6bc968ebd3a8eb9154a66 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:43:55 +0200 Subject: [PATCH 02/26] feat(migration): software-to-BitBox migration wizard UI Settings entry (software wallets only) opening a guided wizard: pair the BitBox as an uncommitted draft via the existing connect sheet, link the new address to the current account and register it in the share register (one-tap, EIP-712 on the device), transfer the full REALU balance through the unchanged gasless SendProcessCubit (software wallet stays current throughout), then persist-deduplicated wallet row switch + session swap. Wizard is resumable at every step; a missing balance read fails loud instead of silently skipping the transfer. --- assets/languages/strings_de.arb | 19 ++ assets/languages/strings_en.arb | 19 ++ .../connect_bitbox_page.dart | 4 +- .../migrate_bitbox/migrate_bitbox_cubit.dart | 266 ++++++++++++++++++ .../migrate_bitbox/migrate_bitbox_state.dart | 103 +++++++ .../migrate_bitbox/migrate_bitbox_page.dart | 138 +++++++++ .../widgets/migrate_intro_view.dart | 66 +++++ .../widgets/migrate_register_view.dart | 97 +++++++ .../widgets/migrate_result_views.dart | 157 +++++++++++ .../widgets/migrate_transfer_view.dart | 214 ++++++++++++++ lib/screens/settings/settings_page.dart | 12 + lib/setup/routing/router_config.dart | 8 + .../routing/routes/migration_routes.dart | 3 + 13 files changed, 1105 insertions(+), 1 deletion(-) create mode 100644 lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart create mode 100644 lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_state.dart create mode 100644 lib/screens/migrate_bitbox/migrate_bitbox_page.dart create mode 100644 lib/screens/migrate_bitbox/widgets/migrate_intro_view.dart create mode 100644 lib/screens/migrate_bitbox/widgets/migrate_register_view.dart create mode 100644 lib/screens/migrate_bitbox/widgets/migrate_result_views.dart create mode 100644 lib/screens/migrate_bitbox/widgets/migrate_transfer_view.dart create mode 100644 lib/setup/routing/routes/migration_routes.dart diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index a2da73a5c..ab7e42ce0 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -164,6 +164,25 @@ "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: ${amount} REALU", + "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…", + "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.", + "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", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 5b9dab60a..713658d03 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -164,6 +164,25 @@ "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: ${amount} REALU", + "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…", + "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.", + "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", diff --git a/lib/screens/hardware_connect_bitbox/connect_bitbox_page.dart b/lib/screens/hardware_connect_bitbox/connect_bitbox_page.dart index 54c329e6b..9928804ff 100644 --- a/lib/screens/hardware_connect_bitbox/connect_bitbox_page.dart +++ b/lib/screens/hardware_connect_bitbox/connect_bitbox_page.dart @@ -9,9 +9,10 @@ import 'package:realunit_wallet/screens/hardware_connect_bitbox/connect_bitbox_v import 'package:realunit_wallet/setup/di.dart'; class ConnectBitboxPage extends StatelessWidget { - const ConnectBitboxPage({super.key, required this.onFinish}); + const ConnectBitboxPage({super.key, required this.onFinish, this.acquireWallet}); final void Function(AWallet wallet) onFinish; + final Future Function()? acquireWallet; @override Widget build(BuildContext context) => BlocProvider( @@ -21,6 +22,7 @@ class ConnectBitboxPage extends StatelessWidget { // DfxKycService is the smallest registered DFXAuthService — used only as // a transport for ensureSignatureFor(account); no KYC-specific calls here. getIt(), + acquireWallet: acquireWallet, ), child: ConnectBitboxView(onFinish: onFinish), ); diff --git a/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart b/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart new file mode 100644 index 000000000..3be28e2b1 --- /dev/null +++ b/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart @@ -0,0 +1,266 @@ +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/balance_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/address_already_linked_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_status.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_state.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; + +part 'migrate_bitbox_state.dart'; + +class MigrateBitboxCubit extends Cubit { + MigrateBitboxCubit( + this._walletService, + // DfxKycService is the smallest registered DFXAuthService — used only as + // a transport for ensureSignatureFor(account); no KYC-specific calls here. + DfxKycService authService, + this._registrationService, + this._balanceService, + this._appStore, + ) : _authService = authService, + super(const MigrateBitboxIntro()); + + final WalletService _walletService; + final DFXAuthService _authService; + final RealUnitRegistrationService _registrationService; + final BalanceService _balanceService; + final AppStore _appStore; + + BitboxWallet? _draft; + String? _newJwt; + String? _bitboxSignature; + BitboxWallet? _persisted; + Future Function()? _pendingRetry; + + Future startPairing() async { + emit(const MigrateBitboxAwaitingDevice()); + } + + /// Called by the view when the ConnectBitboxPage sheet is dismissed without + /// onFinish firing (user cancelled pairing). No-op guard so it is safe to call + /// unconditionally after the sheet future completes. + void cancelPairing() { + if (state is! MigrateBitboxAwaitingDevice) return; + emit(const MigrateBitboxIntro()); + } + + Future onDevicePaired(BitboxWallet draft) async { + emit(const MigrateBitboxLinking()); + _draft = draft; + try { + final oldJwt = await _authService.getAuthToken(); + if (oldJwt == null) { + _pendingRetry = null; + emit(const MigrateBitboxFailure(MigrateBitboxFailureReason.generic)); + return; + } + _newJwt = await _authService.authenticateLinkedAccount(draft.currentAccount, oldJwt); + + final draftAddress = draft.currentAccount.primaryAddress.address.hexEip55; + // authenticateLinkedAccount has already cached the signature via + // sessionCache.saveSignature if it had to sign fresh; a cache hit skipped + // that. Either way, read it back here rather than re-deriving it. + _bitboxSignature = _appStore.sessionCache.signatureAddress == draftAddress + ? _appStore.sessionCache.signature + : null; + + final info = await _registrationService.getRegistrationInfoWith(_newJwt!); + switch (info.state) { + case RealUnitRegistrationState.addWallet: + final userData = info.realUnitUserDataDto; + if (userData == null) { + _pendingRetry = null; + emit( + const MigrateBitboxFailure( + MigrateBitboxFailureReason.generic, + message: 'API returned addWallet without userData', + ), + ); + return; + } + _pendingRetry = null; + emit(MigrateBitboxRegisterReady(userData, draftAddress)); + case RealUnitRegistrationState.alreadyRegistered: + if (info.manualReview == true) { + _pendingRetry = null; + emit(const MigrateBitboxRegistrationPending()); + return; + } + await _persistAndPrepareTransfer(); + case RealUnitRegistrationState.newRegistration: + _pendingRetry = null; + emit( + const MigrateBitboxFailure(MigrateBitboxFailureReason.registrationMissing), + ); + } + } on AddressAlreadyLinkedException { + _pendingRetry = null; + emit( + const MigrateBitboxFailure(MigrateBitboxFailureReason.addressAlreadyLinked), + ); + } on SigningCancelledException { + _pendingRetry = () => onDevicePaired(draft); + emit( + const MigrateBitboxFailure( + MigrateBitboxFailureReason.signatureCancelled, + canRetry: true, + ), + ); + } on BitboxNotConnectedException { + _pendingRetry = () => onDevicePaired(draft); + emit( + const MigrateBitboxFailure( + MigrateBitboxFailureReason.bitboxNotConnected, + canRetry: true, + ), + ); + } catch (e) { + _pendingRetry = () => onDevicePaired(draft); + emit( + MigrateBitboxFailure( + MigrateBitboxFailureReason.generic, + message: e.toString(), + canRetry: true, + ), + ); + } + } + + /// Only valid while [state] is [MigrateBitboxRegisterReady]. + Future register() async { + final current = state; + if (current is! MigrateBitboxRegisterReady) return; + final userData = current.userData; + emit(const MigrateBitboxRegistering()); + try { + final status = await _registrationService.registerWalletFor( + _draft!.currentAccount, + userData, + _newJwt!, + ); + switch (status) { + case RegistrationStatus.completed: + case RegistrationStatus.alreadyRegistered: + await _persistAndPrepareTransfer(); + case RegistrationStatus.pendingReview: + case RegistrationStatus.forwardingFailed: + _pendingRetry = null; + emit(const MigrateBitboxRegistrationPending()); + } + } on SigningCancelledException { + _pendingRetry = register; + emit( + const MigrateBitboxFailure( + MigrateBitboxFailureReason.signatureCancelled, + canRetry: true, + ), + ); + } on BitboxNotConnectedException { + _pendingRetry = register; + emit( + const MigrateBitboxFailure( + MigrateBitboxFailureReason.bitboxNotConnected, + canRetry: true, + ), + ); + } catch (e) { + _pendingRetry = register; + emit( + MigrateBitboxFailure( + MigrateBitboxFailureReason.generic, + message: e.toString(), + canRetry: true, + ), + ); + } + } + + /// Re-runs whatever action last failed with `canRetry: true`. No-op if there + /// is nothing to retry. + Future retry() async { + final action = _pendingRetry; + if (action == null) return; + await action(); + } + + Future _persistAndPrepareTransfer() async { + _persisted = await _walletService.persistBitboxWallet(_draft!); + final softwareAddress = _appStore.primaryAddress; + final bitboxAddress = _persisted!.currentAccount.primaryAddress.address.hexEip55; + + await _balanceService.updateBalance(softwareAddress); + final balance = await _balanceService.getBalance( + _appStore.apiConfig.asset, + softwareAddress, + ); + if (balance == null) { + // Fail-loud: NEVER interpret a missing balance read as zero — that would + // silently skip the transfer and end the wizard "successfully" without + // moving any funds. + _pendingRetry = _persistAndPrepareTransfer; + emit( + const MigrateBitboxFailure( + MigrateBitboxFailureReason.generic, + message: 'balance unavailable', + canRetry: true, + ), + ); + return; + } + + final amount = balance.balance.toInt(); + if (amount == 0) { + // Nothing to transfer — e.g. re-entering the wizard after a transfer that + // already completed in a prior run. + await finishMigration(); + return; + } + _pendingRetry = null; + emit( + MigrateBitboxTransferReady( + fromAddress: softwareAddress, + toAddress: bitboxAddress, + amount: amount, + ), + ); + } + + /// Only valid while [state] is [MigrateBitboxTransferReady]. + void startTransfer() { + final current = state; + if (current is! MigrateBitboxTransferReady) return; + emit( + MigrateBitboxTransferring( + toAddress: current.toAddress, + amount: current.amount, + ), + ); + } + + Future finishMigration() async { + emit(const MigrateBitboxCompleting()); + final persisted = _persisted!; + await _walletService.setCurrentWallet(persisted.id); + final signature = _bitboxSignature; + if (signature != null) { + final bitboxAddress = persisted.currentAccount.primaryAddress.address.hexEip55; + // The lazy path in DFXAuthService.getSignature still recovers on the next + // authenticated call if this is skipped — mirrors + // ConnectBitboxCubit.continueWithoutSignature. + await _appStore.sessionCache.saveSignature(bitboxAddress, signature); + } + // After setCurrentWallet, before the view's HomeBloc reload, so any sync + // triggered by the reload is already authenticated as the new wallet. + _appStore.sessionCache.setAuthToken(_newJwt!); + _pendingRetry = null; + emit(MigrateBitboxSuccess(persisted)); + } +} diff --git a/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_state.dart b/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_state.dart new file mode 100644 index 000000000..022e96922 --- /dev/null +++ b/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_state.dart @@ -0,0 +1,103 @@ +part of 'migrate_bitbox_cubit.dart'; + +enum MigrateBitboxFailureReason { + addressAlreadyLinked, + registrationMissing, + signatureCancelled, + bitboxNotConnected, + generic, +} + +sealed class MigrateBitboxState extends Equatable { + const MigrateBitboxState(); + + @override + List get props => []; +} + +class MigrateBitboxIntro extends MigrateBitboxState { + const MigrateBitboxIntro(); +} + +/// The view reacts to this by opening the ConnectBitboxPage bottom sheet. +class MigrateBitboxAwaitingDevice extends MigrateBitboxState { + const MigrateBitboxAwaitingDevice(); +} + +class MigrateBitboxLinking extends MigrateBitboxState { + const MigrateBitboxLinking(); +} + +class MigrateBitboxRegisterReady extends MigrateBitboxState { + const MigrateBitboxRegisterReady(this.userData, this.bitboxAddress); + + final RealUnitUserDataDto userData; + final String bitboxAddress; + + @override + List get props => [userData, bitboxAddress]; +} + +class MigrateBitboxRegistering extends MigrateBitboxState { + const MigrateBitboxRegistering(); +} + +/// Registration is parked in manual review (Aktionariat forward pending, or the +/// wallet was already registered elsewhere and needs staff review). The wizard +/// ends here; the balance stays on the software wallet and the user may re-open +/// the wizard later once the review completes. +class MigrateBitboxRegistrationPending extends MigrateBitboxState { + const MigrateBitboxRegistrationPending(); +} + +class MigrateBitboxTransferReady extends MigrateBitboxState { + const MigrateBitboxTransferReady({ + required this.fromAddress, + required this.toAddress, + required this.amount, + }); + + final String fromAddress; + final String toAddress; + final int amount; + + @override + List get props => [fromAddress, toAddress, amount]; +} + +/// Carries the same recipient/amount as [MigrateBitboxTransferReady] so the view +/// can build the embedded [SendProcessCubit] without needing to remember the +/// prior state itself. +class MigrateBitboxTransferring extends MigrateBitboxState { + const MigrateBitboxTransferring({required this.toAddress, required this.amount}); + + final String toAddress; + final int amount; + + @override + List get props => [toAddress, amount]; +} + +class MigrateBitboxCompleting extends MigrateBitboxState { + const MigrateBitboxCompleting(); +} + +class MigrateBitboxSuccess extends MigrateBitboxState { + const MigrateBitboxSuccess(this.wallet); + + final BitboxWallet wallet; + + @override + List get props => [wallet]; +} + +class MigrateBitboxFailure extends MigrateBitboxState { + const MigrateBitboxFailure(this.reason, {this.message, this.canRetry = false}); + + final MigrateBitboxFailureReason reason; + final String? message; + final bool canRetry; + + @override + List get props => [reason, message, canRetry]; +} diff --git a/lib/screens/migrate_bitbox/migrate_bitbox_page.dart b/lib/screens/migrate_bitbox/migrate_bitbox_page.dart new file mode 100644 index 000000000..685fb6dcd --- /dev/null +++ b/lib/screens/migrate_bitbox/migrate_bitbox_page.dart @@ -0,0 +1,138 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/balance_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_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/connect_bitbox_page.dart'; +import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_intro_view.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_register_view.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_result_views.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_transfer_view.dart'; +import 'package:realunit_wallet/setup/di.dart'; + +class MigrateBitboxPage extends StatelessWidget { + const MigrateBitboxPage({super.key}); + + @override + Widget build(BuildContext context) => BlocProvider( + create: (_) => MigrateBitboxCubit( + getIt(), + // DfxKycService is the smallest registered DFXAuthService — used only as + // a transport for ensureSignatureFor(account); no KYC-specific calls here. + getIt(), + getIt(), + getIt(), + getIt(), + ), + child: const MigrateBitboxViewManager(), + ); +} + +class MigrateBitboxViewManager extends StatelessWidget { + const MigrateBitboxViewManager({super.key}); + + @override + Widget build(BuildContext context) => BlocConsumer( + listenWhen: (_, current) => + current is MigrateBitboxAwaitingDevice || current is MigrateBitboxSuccess, + listener: (context, state) async { + if (state is MigrateBitboxAwaitingDevice) { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => ConnectBitboxPage( + acquireWallet: () => + getIt().acquireUncommittedBitboxWallet('Luke-Skywallet'), + onFinish: (wallet) => + context.read().onDevicePaired(wallet as BitboxWallet), + ), + ); + if (context.mounted) { + context.read().cancelPairing(); + } + } + if (state is MigrateBitboxSuccess) { + context.read().add(LoadWalletEvent(state.wallet)); + } + }, + builder: (context, state) => PopScope( + canPop: switch (state) { + MigrateBitboxIntro() || + MigrateBitboxRegisterReady() || + MigrateBitboxTransferReady() || + MigrateBitboxRegistrationPending() || + MigrateBitboxFailure() || + MigrateBitboxSuccess() => true, + _ => false, + }, + child: switch (state) { + MigrateBitboxIntro() || MigrateBitboxAwaitingDevice() => const MigrateIntroView(), + MigrateBitboxLinking() => _MigrateBitboxProgressPage( + label: S.of(context).migrateBitboxLinking, + ), + MigrateBitboxRegisterReady(:final userData, :final bitboxAddress) => + MigrateRegisterView( + userData: userData, + bitboxAddress: bitboxAddress, + ), + MigrateBitboxRegistering() => _MigrateBitboxProgressPage( + label: S.of(context).migrateBitboxRegisterTitle, + ), + MigrateBitboxRegistrationPending() => + const MigrateBitboxRegistrationPendingPage(), + MigrateBitboxTransferReady(:final fromAddress, :final toAddress, :final amount) => + MigrateTransferReadyView( + fromAddress: fromAddress, + toAddress: toAddress, + amount: amount, + ), + MigrateBitboxTransferring(:final toAddress, :final amount) => MigrateTransferringView( + toAddress: toAddress, + amount: amount, + ), + MigrateBitboxCompleting() => _MigrateBitboxProgressPage( + label: S.of(context).migrateBitboxCompleting, + ), + MigrateBitboxSuccess() => const MigrateBitboxSuccessPage(), + MigrateBitboxFailure(:final reason, :final canRetry) => MigrateBitboxFailurePage( + reason: reason, + canRetry: canRetry, + ), + }, + ), + ); +} + +class _MigrateBitboxProgressPage extends StatelessWidget { + const _MigrateBitboxProgressPage({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text(S.of(context).migrateBitbox)), + body: SafeArea( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + spacing: 16, + children: [ + const CupertinoActivityIndicator(radius: 16), + Text( + label, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + ), + ); +} diff --git a/lib/screens/migrate_bitbox/widgets/migrate_intro_view.dart b/lib/screens/migrate_bitbox/widgets/migrate_intro_view.dart new file mode 100644 index 000000000..0773210be --- /dev/null +++ b/lib/screens/migrate_bitbox/widgets/migrate_intro_view.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/models/balance.dart'; +import 'package:realunit_wallet/packages/repository/balance_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/screens/dashboard/bloc/balance_cubit.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart'; +import 'package:realunit_wallet/setup/di.dart'; +import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart'; + +class MigrateIntroView extends StatelessWidget { + const MigrateIntroView({super.key}); + + @override + Widget build(BuildContext context) => BlocProvider( + create: (_) => BalanceCubit( + getIt(), + asset: getIt().apiConfig.asset, + walletAddress: getIt().primaryAddress, + ), + child: Scaffold( + appBar: AppBar(title: Text(S.of(context).migrateBitbox)), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: SafeArea( + child: ScrollableActionsLayout( + centerBody: true, + body: Column( + spacing: 16, + children: [ + Text( + S.of(context).migrateBitboxIntroTitle, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium, + ), + Text( + S.of(context).migrateBitboxIntroDescription, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: RealUnitColors.neutral500), + ), + BlocBuilder( + builder: (context, state) => Text( + S.of(context).migrateBitboxIntroBalance('${state.balance}'), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ], + ), + actions: [ + AppFilledButton( + label: S.of(context).migrateBitboxStart, + onPressed: () => context.read().startPairing(), + ), + ], + ), + ), + ), + ), + ); +} diff --git a/lib/screens/migrate_bitbox/widgets/migrate_register_view.dart b/lib/screens/migrate_bitbox/widgets/migrate_register_view.dart new file mode 100644 index 000000000..e31ad9259 --- /dev/null +++ b/lib/screens/migrate_bitbox/widgets/migrate_register_view.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart'; +import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart'; + +class MigrateRegisterView extends StatelessWidget { + const MigrateRegisterView({ + super.key, + required this.userData, + required this.bitboxAddress, + }); + + final RealUnitUserDataDto userData; + final String bitboxAddress; + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text(S.of(context).migrateBitbox)), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: SafeArea( + child: ScrollableActionsLayout( + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 16, + children: [ + Text( + S.of(context).migrateBitboxRegisterTitle, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium, + ), + Text( + S.of(context).migrateBitboxRegisterDescription, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: RealUnitColors.neutral500), + ), + _MigrateRegisterInfoRow( + label: S.of(context).name, + value: userData.name, + ), + _MigrateRegisterInfoRow( + label: S.of(context).walletAddress, + value: _truncateAddress(bitboxAddress), + ), + Text( + S.of(context).migrateBitboxRegisterConfirmHint, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: RealUnitColors.neutral500), + ), + ], + ), + actions: [ + AppFilledButton( + label: S.of(context).migrateBitboxRegisterCta, + onPressed: () => context.read().register(), + ), + ], + ), + ), + ), + ); +} + +class _MigrateRegisterInfoRow extends StatelessWidget { + const _MigrateRegisterInfoRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + Text( + label, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: RealUnitColors.neutral500), + ), + Text(value, style: Theme.of(context).textTheme.bodyLarge), + ], + ); +} + +String _truncateAddress(String address) { + if (address.length <= 12) return address; + return '${address.substring(0, 6)}…${address.substring(address.length - 4)}'; +} diff --git a/lib/screens/migrate_bitbox/widgets/migrate_result_views.dart b/lib/screens/migrate_bitbox/widgets/migrate_result_views.dart new file mode 100644 index 000000000..660ff4e9c --- /dev/null +++ b/lib/screens/migrate_bitbox/widgets/migrate_result_views.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.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'; +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart'; + +class MigrateBitboxRegistrationPendingPage extends StatelessWidget { + const MigrateBitboxRegistrationPendingPage({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text(S.of(context).migrateBitbox)), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: SafeArea( + child: ScrollableActionsLayout( + centerBody: true, + body: Column( + spacing: 16, + children: [ + const Icon( + Icons.hourglass_top_rounded, + size: 64, + color: RealUnitColors.realUnitBlue, + ), + Text( + S.of(context).migrateBitboxRegistrationPendingInfo, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: RealUnitColors.neutral500), + ), + ], + ), + actions: [ + AppFilledButton( + label: S.of(context).close, + onPressed: () => context.pop(), + ), + ], + ), + ), + ), + ); +} + +class MigrateBitboxSuccessPage extends StatelessWidget { + const MigrateBitboxSuccessPage({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text(S.of(context).migrateBitbox)), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: SafeArea( + child: ScrollableActionsLayout( + centerBody: true, + body: Column( + spacing: 16, + children: [ + const Icon( + Icons.check_circle_rounded, + size: 64, + color: RealUnitColors.realUnitBlue, + ), + Text( + S.of(context).migrateBitboxSuccessTitle, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium, + ), + Text( + S.of(context).migrateBitboxSuccessDescription, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: RealUnitColors.neutral500), + ), + ], + ), + actions: [ + AppFilledButton( + label: S.of(context).done, + onPressed: () => context.goNamed(AppRoutes.dashboard), + ), + ], + ), + ), + ), + ); +} + +class MigrateBitboxFailurePage extends StatelessWidget { + const MigrateBitboxFailurePage({ + super.key, + required this.reason, + required this.canRetry, + }); + + final MigrateBitboxFailureReason reason; + final bool canRetry; + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text(S.of(context).migrateBitbox)), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: SafeArea( + child: ScrollableActionsLayout( + centerBody: true, + body: Column( + spacing: 16, + children: [ + Icon( + Icons.error_rounded, + size: 64, + color: RealUnitColors.status.red600, + ), + Text( + _failureMessage(context, reason), + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: RealUnitColors.neutral500), + ), + ], + ), + actions: [ + if (canRetry) + AppFilledButton( + label: S.of(context).retry, + onPressed: () => context.read().retry(), + ), + AppFilledButton( + variant: FilledButtonVariant.secondary, + label: S.of(context).close, + onPressed: () => context.pop(), + ), + ], + ), + ), + ), + ); +} + +String _failureMessage(BuildContext context, MigrateBitboxFailureReason reason) => switch (reason) { + MigrateBitboxFailureReason.addressAlreadyLinked => + S.of(context).migrateBitboxAlreadyLinkedError, + MigrateBitboxFailureReason.registrationMissing => + S.of(context).migrateBitboxRegistrationMissingError, + MigrateBitboxFailureReason.signatureCancelled => S.of(context).sendFailureSignatureCancelled, + MigrateBitboxFailureReason.bitboxNotConnected => S.of(context).connectBitboxFailed, + MigrateBitboxFailureReason.generic => S.of(context).connectBitboxFailed, +}; diff --git a/lib/screens/migrate_bitbox/widgets/migrate_transfer_view.dart b/lib/screens/migrate_bitbox/widgets/migrate_transfer_view.dart new file mode 100644 index 000000000..fc2084803 --- /dev/null +++ b/lib/screens/migrate_bitbox/widgets/migrate_transfer_view.dart @@ -0,0 +1,214 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_transfer_service.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_process/send_process_cubit.dart'; +import 'package:realunit_wallet/setup/di.dart'; +import 'package:realunit_wallet/styles/colors.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; +import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart'; + +class MigrateTransferReadyView extends StatelessWidget { + const MigrateTransferReadyView({ + super.key, + required this.fromAddress, + required this.toAddress, + required this.amount, + }); + + final String fromAddress; + final String toAddress; + final int amount; + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text(S.of(context).migrateBitbox)), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: SafeArea( + child: ScrollableActionsLayout( + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: 16, + children: [ + Text( + S.of(context).migrateBitboxTransferTitle, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium, + ), + Text( + S.of(context).migrateBitboxTransferDescription, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: RealUnitColors.neutral500), + ), + _MigrateTransferInfoRow( + label: S.of(context).from, + value: _truncateAddress(fromAddress), + ), + _MigrateTransferInfoRow( + label: S.of(context).to, + value: _truncateAddress(toAddress), + ), + _MigrateTransferInfoRow( + label: S.of(context).sendConfirmAmount, + value: '$amount REALU', + ), + ], + ), + actions: [ + AppFilledButton( + label: S.of(context).migrateBitboxTransferCta, + onPressed: () => context.read().startTransfer(), + ), + ], + ), + ), + ), + ); +} + +class MigrateTransferringView extends StatelessWidget { + const MigrateTransferringView({ + super.key, + required this.toAddress, + required this.amount, + }); + + final String toAddress; + final int amount; + + @override + Widget build(BuildContext context) => BlocProvider( + create: (_) => SendProcessCubit( + transferService: getIt(), + appStore: getIt(), + recipient: toAddress, + amount: amount, + )..start(), + child: Scaffold( + appBar: AppBar(title: Text(S.of(context).migrateBitbox)), + body: const Padding( + padding: EdgeInsets.symmetric(horizontal: 20), + child: SafeArea(child: _EmbeddedSendProcessView()), + ), + ), + ); +} + +class _EmbeddedSendProcessView extends StatelessWidget { + const _EmbeddedSendProcessView(); + + @override + Widget build(BuildContext context) => BlocConsumer( + listenWhen: (_, current) => current is SendProcessSuccess, + listener: (context, state) { + if (state is SendProcessSuccess) { + context.read().finishMigration(); + } + }, + builder: (context, state) => switch (state) { + SendProcessInitial() || SendProcessPreparing() => _progressLayout( + context, + S.of(context).sendPreparing, + ), + SendProcessSigning() => _progressLayout(context, S.of(context).sendSigning), + SendProcessFailure(:final reason, :final canRetry) => ScrollableActionsLayout( + centerBody: true, + body: Column( + spacing: 16, + children: [ + Icon( + Icons.error_rounded, + size: 64, + color: RealUnitColors.status.red600, + ), + Text( + _failureMessage(context, reason), + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: RealUnitColors.neutral500), + ), + ], + ), + actions: [ + AppFilledButton( + label: S.of(context).retry, + onPressed: canRetry + ? () => context.read().retryConfirm() + : null, + ), + ], + ), + SendProcessSuccess() => _progressLayout(context, S.of(context).sendPreparing), + }, + ); + + Widget _progressLayout(BuildContext context, String label) => ScrollableActionsLayout( + centerBody: true, + body: Column( + mainAxisSize: MainAxisSize.min, + spacing: 16, + children: [ + const CupertinoActivityIndicator(radius: 16), + Text( + label, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ); + + String _failureMessage(BuildContext context, SendProcessFailureReason reason) => switch (reason) { + SendProcessFailureReason.signatureUnsupported => S.of(context).sendFailureSignatureUnsupported, + SendProcessFailureReason.signatureCancelled => S.of(context).sendFailureSignatureCancelled, + SendProcessFailureReason.gasFundingUnavailable => S.of(context).sendFailureGasUnavailable, + SendProcessFailureReason.invalidRequest => S.of(context).sendFailureInvalidRequest, + SendProcessFailureReason.registrationOrKycRequired => + S.of(context).sendFailureRegistrationOrKycRequired, + SendProcessFailureReason.confirmMismatch => S.of(context).sendFailureConfirmMismatch, + SendProcessFailureReason.generic => S.of(context).sendFailureGeneric, + }; +} + +class _MigrateTransferInfoRow extends StatelessWidget { + const _MigrateTransferInfoRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) => Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + Flexible( + child: Text( + label, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: RealUnitColors.neutral500), + ), + ), + Flexible( + child: Text( + value, + textAlign: TextAlign.end, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ], + ); +} + +String _truncateAddress(String address) { + if (address.length <= 12) return address; + return '${address.substring(0, 6)}…${address.substring(address.length - 4)}'; +} diff --git a/lib/screens/settings/settings_page.dart b/lib/screens/settings/settings_page.dart index d036b7587..fa89b1663 100644 --- a/lib/screens/settings/settings_page.dart +++ b/lib/screens/settings/settings_page.dart @@ -13,6 +13,7 @@ import 'package:realunit_wallet/screens/settings/widgets/settings_confirm_logout import 'package:realunit_wallet/screens/settings/widgets/settings_section.dart'; import 'package:realunit_wallet/screens/settings/widgets/settings_version_unlock.dart'; import 'package:realunit_wallet/setup/di.dart'; +import 'package:realunit_wallet/setup/routing/routes/migration_routes.dart'; import 'package:realunit_wallet/setup/routing/routes/pin_routes.dart'; import 'package:realunit_wallet/setup/routing/routes/settings_routes.dart'; import 'package:realunit_wallet/styles/colors.dart'; @@ -110,6 +111,17 @@ class SettingsPage extends StatelessWidget { trailing: _forwardIcon, onTap: () => context.pushNamed(SettingsRoutes.walletAddress), ), + if (context.read().state.openWallet?.walletType == WalletType.software) + SettingOption( + title: S.of(context).migrateBitbox, + leading: const Icon( + Icons.usb_rounded, + size: 24, + color: RealUnitColors.realUnitBlue, + ), + trailing: _forwardIcon, + onTap: () => context.pushNamed(MigrationRoutes.migrateBitbox), + ), if (context.read().state.openWallet?.walletType == WalletType.software) SettingOption( title: S.of(context).settingsWalletBackup, diff --git a/lib/setup/routing/router_config.dart b/lib/setup/routing/router_config.dart index 72c9d050f..98920fdb3 100644 --- a/lib/setup/routing/router_config.dart +++ b/lib/setup/routing/router_config.dart @@ -13,6 +13,7 @@ import 'package:realunit_wallet/screens/home/home_page.dart'; import 'package:realunit_wallet/screens/kyc/kyc_page_manager.dart'; import 'package:realunit_wallet/screens/legal/legal_disclaimer_page.dart'; import 'package:realunit_wallet/screens/legal/subpages/legal_document_page.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/migrate_bitbox_page.dart'; import 'package:realunit_wallet/screens/onboarding/onboarding_completed_page.dart'; import 'package:realunit_wallet/screens/pay/pay_scan_page.dart'; import 'package:realunit_wallet/screens/pin/setup_pin_page.dart'; @@ -51,6 +52,7 @@ import 'package:realunit_wallet/setup/routing/boot_navigation.dart'; import 'package:realunit_wallet/setup/routing/routes/app_link_entry.dart'; import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; import 'package:realunit_wallet/setup/routing/routes/legal_routes.dart'; +import 'package:realunit_wallet/setup/routing/routes/migration_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/setup/routing/routes/settings_routes.dart'; @@ -237,6 +239,12 @@ final GoRouter routerConfig = GoRouter( builder: (_, _) => const BitboxAddressRecoveryPage(), ), + GoRoute( + name: MigrationRoutes.migrateBitbox, + path: '/migrateBitbox', + builder: (_, _) => const MigrateBitboxPage(), + ), + GoRoute( name: SettingsRoutes.settings, path: '/settings', diff --git a/lib/setup/routing/routes/migration_routes.dart b/lib/setup/routing/routes/migration_routes.dart new file mode 100644 index 000000000..15f6f9dfd --- /dev/null +++ b/lib/setup/routing/routes/migration_routes.dart @@ -0,0 +1,3 @@ +abstract final class MigrationRoutes { + static const String migrateBitbox = 'migrateBitbox'; +} From 219e7856d62b612e26962a3c205be41937ca57c5 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:11:07 +0200 Subject: [PATCH 03/26] fix(migration): escape hatch for terminal transfer failures + wizard test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A definitive (non-retryable) SendProcessFailure inside the embedded transfer flow left the user stuck: disabled retry button while the transferring state also blocked the system pop. The wizard cubit now leaves the transfer flow into its own retryable failure state, and retrying runs a fresh persist-and-prepare pass (balance re-read) instead of blindly re-sending the dead intent. The register retry now restores the stored RegisterReady state first — the previous pending-retry closure was a no-op against the state guard. Success-branch context use moved ahead of the await branch (use_build_context_synchronously). Adds the full wizard test suite: cubit state machine incl. every failure branch and the zero-balance / already-registered skips, page-manager routing per state, embedded-transfer listener behaviour, settings tile visibility, responsive matrix tests and surface-catalog entries for every sticky-CTA view. --- .../migrate_bitbox/migrate_bitbox_cubit.dart | 27 + .../migrate_bitbox/migrate_bitbox_page.dart | 10 +- .../widgets/migrate_transfer_view.dart | 10 +- test/helper/responsive_surface_catalog.dart | 56 +- .../migrate_bitbox_cubit_test.dart | 653 ++++++++++++++++++ .../migrate_bitbox_page_test.dart | 344 +++++++++ ...migrate_bitbox_responsive_matrix_test.dart | 229 ++++++ .../widgets/migrate_intro_view_test.dart | 87 +++ .../widgets/migrate_register_view_test.dart | 89 +++ .../widgets/migrate_result_views_test.dart | 167 +++++ .../widgets/migrate_transfer_view_test.dart | 308 +++++++++ test/screens/settings/settings_page_test.dart | 135 ++++ 12 files changed, 2106 insertions(+), 9 deletions(-) create mode 100644 test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart create mode 100644 test/screens/migrate_bitbox/migrate_bitbox_page_test.dart create mode 100644 test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart create mode 100644 test/screens/migrate_bitbox/widgets/migrate_intro_view_test.dart create mode 100644 test/screens/migrate_bitbox/widgets/migrate_register_view_test.dart create mode 100644 test/screens/migrate_bitbox/widgets/migrate_result_views_test.dart create mode 100644 test/screens/migrate_bitbox/widgets/migrate_transfer_view_test.dart create mode 100644 test/screens/settings/settings_page_test.dart diff --git a/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart b/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart index 3be28e2b1..980fef4f8 100644 --- a/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart +++ b/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart @@ -39,6 +39,8 @@ class MigrateBitboxCubit extends Cubit { String? _bitboxSignature; BitboxWallet? _persisted; Future Function()? _pendingRetry; + MigrateBitboxRegisterReady? _registerRetryState; + bool _pendingRegisterRetry = false; Future startPairing() async { emit(const MigrateBitboxAwaitingDevice()); @@ -138,6 +140,8 @@ class MigrateBitboxCubit extends Cubit { Future register() async { final current = state; if (current is! MigrateBitboxRegisterReady) return; + _registerRetryState = current; + _pendingRegisterRetry = false; final userData = current.userData; emit(const MigrateBitboxRegistering()); try { @@ -157,6 +161,7 @@ class MigrateBitboxCubit extends Cubit { } } on SigningCancelledException { _pendingRetry = register; + _pendingRegisterRetry = true; emit( const MigrateBitboxFailure( MigrateBitboxFailureReason.signatureCancelled, @@ -165,6 +170,7 @@ class MigrateBitboxCubit extends Cubit { ); } on BitboxNotConnectedException { _pendingRetry = register; + _pendingRegisterRetry = true; emit( const MigrateBitboxFailure( MigrateBitboxFailureReason.bitboxNotConnected, @@ -173,6 +179,7 @@ class MigrateBitboxCubit extends Cubit { ); } catch (e) { _pendingRetry = register; + _pendingRegisterRetry = true; emit( MigrateBitboxFailure( MigrateBitboxFailureReason.generic, @@ -188,6 +195,9 @@ class MigrateBitboxCubit extends Cubit { Future retry() async { final action = _pendingRetry; if (action == null) return; + if (_pendingRegisterRetry) { + emit(_registerRetryState!); + } await action(); } @@ -245,6 +255,23 @@ class MigrateBitboxCubit extends Cubit { ); } + /// Leaves the embedded transfer flow after a DEFINITIVE (non-retryable) + /// SendProcessFailure. Re-entering via retry runs a fresh + /// _persistAndPrepareTransfer — the dead intent is discarded and the balance + /// re-read, so a partially-executed transfer surfaces as a reduced (or zero) + /// remaining amount instead of a blind re-send. + void onTransferFailedTerminally(String message) { + if (state is! MigrateBitboxTransferring) return; + _pendingRetry = _persistAndPrepareTransfer; + emit( + MigrateBitboxFailure( + MigrateBitboxFailureReason.generic, + message: message, + canRetry: true, + ), + ); + } + Future finishMigration() async { emit(const MigrateBitboxCompleting()); final persisted = _persisted!; diff --git a/lib/screens/migrate_bitbox/migrate_bitbox_page.dart b/lib/screens/migrate_bitbox/migrate_bitbox_page.dart index 685fb6dcd..8e7d67ce3 100644 --- a/lib/screens/migrate_bitbox/migrate_bitbox_page.dart +++ b/lib/screens/migrate_bitbox/migrate_bitbox_page.dart @@ -43,6 +43,13 @@ class MigrateBitboxViewManager extends StatelessWidget { listenWhen: (_, current) => current is MigrateBitboxAwaitingDevice || current is MigrateBitboxSuccess, listener: (context, state) async { + // Synchronous context use first: the await in the AwaitingDevice branch + // below would otherwise trip use_build_context_synchronously for every + // context read that follows it in this async closure. + if (state is MigrateBitboxSuccess) { + context.read().add(LoadWalletEvent(state.wallet)); + return; + } if (state is MigrateBitboxAwaitingDevice) { await showModalBottomSheet( context: context, @@ -58,9 +65,6 @@ class MigrateBitboxViewManager extends StatelessWidget { context.read().cancelPairing(); } } - if (state is MigrateBitboxSuccess) { - context.read().add(LoadWalletEvent(state.wallet)); - } }, builder: (context, state) => PopScope( canPop: switch (state) { diff --git a/lib/screens/migrate_bitbox/widgets/migrate_transfer_view.dart b/lib/screens/migrate_bitbox/widgets/migrate_transfer_view.dart index fc2084803..79840e16c 100644 --- a/lib/screens/migrate_bitbox/widgets/migrate_transfer_view.dart +++ b/lib/screens/migrate_bitbox/widgets/migrate_transfer_view.dart @@ -105,11 +105,19 @@ class _EmbeddedSendProcessView extends StatelessWidget { @override Widget build(BuildContext context) => BlocConsumer( - listenWhen: (_, current) => current is SendProcessSuccess, + listenWhen: (_, current) => switch (current) { + SendProcessSuccess() || SendProcessFailure(canRetry: false) => true, + _ => false, + }, listener: (context, state) { if (state is SendProcessSuccess) { context.read().finishMigration(); } + if (state case SendProcessFailure(:final reason, canRetry: false)) { + context.read().onTransferFailedTerminally( + _failureMessage(context, reason), + ); + } }, builder: (context, state) => switch (state) { SendProcessInitial() || SendProcessPreparing() => _progressLayout( diff --git a/test/helper/responsive_surface_catalog.dart b/test/helper/responsive_surface_catalog.dart index 8c35aa01c..3c25b5fbd 100644 --- a/test/helper/responsive_surface_catalog.dart +++ b/test/helper/responsive_surface_catalog.dart @@ -273,9 +273,55 @@ const kResponsiveSurfaceCatalog = [ matrixTestPath: 'test/screens/pin/pin_sheets_responsive_matrix_test.dart', productionPath: 'lib/screens/pin/widgets/enable_biometric_bottom_sheet.dart', ), - // Migration covers 36 surfaces total (bitbox_connect_sheet + 35 above). No - // further known candidates remain from the prior sweep. welcome_page was - // reviewed and found safe (scrolls end-to-end, no separate sticky CTA) — not - // a migration candidate. Not exhaustive — review responsibility for every - // new sticky-CTA surface. + ResponsiveSurface( + id: 'migrate_bitbox_intro_view', + description: 'BitBox migration intro (start CTA)', + matrixTestPath: + 'test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart', + productionPath: 'lib/screens/migrate_bitbox/widgets/migrate_intro_view.dart', + ), + ResponsiveSurface( + id: 'migrate_bitbox_register_view', + description: 'BitBox migration registration confirmation (register CTA)', + matrixTestPath: + 'test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart', + productionPath: 'lib/screens/migrate_bitbox/widgets/migrate_register_view.dart', + ), + ResponsiveSurface( + id: 'migrate_bitbox_transfer_ready_view', + description: 'BitBox migration transfer confirmation (transfer CTA)', + matrixTestPath: + 'test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart', + productionPath: 'lib/screens/migrate_bitbox/widgets/migrate_transfer_view.dart', + ), + ResponsiveSurface( + id: 'migrate_bitbox_registration_pending_page', + description: 'BitBox migration registration-pending result (close CTA)', + matrixTestPath: + 'test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart', + productionPath: 'lib/screens/migrate_bitbox/widgets/migrate_result_views.dart', + ), + ResponsiveSurface( + id: 'migrate_bitbox_success_page', + description: 'BitBox migration success result (done CTA)', + matrixTestPath: + 'test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart', + productionPath: 'lib/screens/migrate_bitbox/widgets/migrate_result_views.dart', + ), + ResponsiveSurface( + id: 'migrate_bitbox_failure_page', + description: 'BitBox migration failure result (retry/close CTA states)', + matrixTestPath: + 'test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart', + productionPath: 'lib/screens/migrate_bitbox/widgets/migrate_result_views.dart', + ), + // The embedded SendProcess failure layout is a private, transient builder + // branch in migrate_transfer_view.dart, so it cannot truthfully satisfy the + // catalog's public-widget reachability contract. Its retryable and terminal + // CTA behaviour is covered through MigrateTransferringView in + // migrate_transfer_view_test.dart instead of adding a misleading entry. + // + // welcome_page remains intentionally excluded because it scrolls end-to-end + // and has no separate sticky CTA. Not exhaustive — review responsibility + // remains for every new sticky-CTA surface. ]; diff --git a/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart b/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart new file mode 100644 index 000000000..51a59bb12 --- /dev/null +++ b/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart @@ -0,0 +1,653 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/models/balance.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/balance_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/address_already_linked_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_status.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_info_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_state.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/packages/service/session_cache.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart'; +import 'package:web3dart/web3dart.dart'; + +class _MockWalletService extends Mock implements WalletService {} + +class _MockDfxKycService extends Mock implements DfxKycService {} + +class _MockRegistrationService extends Mock implements RealUnitRegistrationService {} + +class _MockBalanceService extends Mock implements BalanceService {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockSessionCache extends Mock implements SessionCache {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +class _MockBitboxWallet extends Mock implements BitboxWallet {} + +class _MockBitboxWalletAccount extends Mock implements BitboxWalletAccount {} + +const _userData = RealUnitUserDataDto( + email: 'ada@example.com', + name: 'Ada Lovelace', + type: 'HUMAN', + phoneNumber: '+41 79 000 00 00', + birthday: '1815-12-10', + nationality: 'CH', + addressStreet: 'Bahnhofstrasse 1', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: 'CH', + swissTaxResidence: true, + lang: 'de', + kycData: KycPersonalData( + accountType: KycAccountType.personal, + firstName: 'Ada', + lastName: 'Lovelace', + phone: '+41 79 000 00 00', + address: KycAddress( + street: 'Bahnhofstrasse', + zip: '8000', + city: 'Zurich', + country: 41, + ), + ), +); + +void main() { + const softwareAddress = '0x0000000000000000000000000000000000000001'; + const oldJwt = 'old-jwt'; + const newJwt = 'new-jwt'; + const signature = '0xsigned'; + + late String draftAddress; + late String persistedAddress; + + late _MockWalletService walletService; + late _MockDfxKycService authService; + late _MockRegistrationService registrationService; + late _MockBalanceService balanceService; + late _MockAppStore appStore; + late _MockSessionCache sessionCache; + late _MockApiConfig apiConfig; + late _MockBitboxWallet draft; + late _MockBitboxWallet persisted; + late _MockBitboxWalletAccount draftAccount; + late _MockBitboxWalletAccount persistedAccount; + + Balance balance(int amount) => Balance( + chainId: realUnitAsset.chainId, + contractAddress: realUnitAsset.address, + walletAddress: softwareAddress, + balance: BigInt.from(amount), + asset: realUnitAsset, + ); + + RealUnitRegistrationInfoDto info( + RealUnitRegistrationState state, { + RealUnitUserDataDto? userData, + bool? manualReview, + }) => RealUnitRegistrationInfoDto( + state: state, + realUnitUserDataDto: userData, + manualReview: manualReview, + ); + + setUpAll(() { + registerFallbackValue(_MockBitboxWallet()); + registerFallbackValue(_MockBitboxWalletAccount()); + registerFallbackValue(_userData); + registerFallbackValue(realUnitAsset); + }); + + setUp(() { + walletService = _MockWalletService(); + authService = _MockDfxKycService(); + registrationService = _MockRegistrationService(); + balanceService = _MockBalanceService(); + appStore = _MockAppStore(); + sessionCache = _MockSessionCache(); + apiConfig = _MockApiConfig(); + draft = _MockBitboxWallet(); + persisted = _MockBitboxWallet(); + draftAccount = _MockBitboxWalletAccount(); + persistedAccount = _MockBitboxWalletAccount(); + + final draftCredentials = EthPrivateKey.fromHex( + 'fb1ace12f9801e85f3db1b3935dd47d9f064f98152466f47c701b5e12680e612', + ); + final persistedCredentials = EthPrivateKey.fromHex( + '7d1d0f68f145b214e49c1a5c6c31a5570358ec80025c5d25f6a56f21fbe6342f', + ); + draftAddress = draftCredentials.address.hexEip55; + persistedAddress = persistedCredentials.address.hexEip55; + when(() => draft.id).thenReturn(0); + when(() => draft.currentAccount).thenReturn(draftAccount); + when(() => draftAccount.primaryAddress).thenReturn(draftCredentials); + when(() => persisted.id).thenReturn(42); + when(() => persisted.currentAccount).thenReturn(persistedAccount); + when(() => persistedAccount.primaryAddress).thenReturn(persistedCredentials); + + when(() => appStore.primaryAddress).thenReturn(softwareAddress); + when(() => appStore.apiConfig).thenReturn(apiConfig); + when(() => appStore.sessionCache).thenReturn(sessionCache); + when(() => apiConfig.asset).thenReturn(realUnitAsset); + when(() => sessionCache.signatureAddress).thenReturn(draftAddress); + when(() => sessionCache.signature).thenReturn(signature); + when(() => sessionCache.saveSignature(any(), any())).thenAnswer((_) async {}); + when(() => sessionCache.setAuthToken(any())).thenReturn(null); + + when(() => authService.getAuthToken()).thenAnswer((_) async => oldJwt); + when( + () => authService.authenticateLinkedAccount(any(), any()), + ).thenAnswer((_) async => newJwt); + when( + () => registrationService.getRegistrationInfoWith(any()), + ).thenAnswer((_) async => info(RealUnitRegistrationState.alreadyRegistered)); + when( + () => registrationService.registerWalletFor(any(), any(), any()), + ).thenAnswer((_) async => RegistrationStatus.completed); + when(() => walletService.persistBitboxWallet(any())).thenAnswer((_) async => persisted); + when(() => walletService.setCurrentWallet(any())).thenAnswer((_) async {}); + when(() => balanceService.updateBalance(any())).thenAnswer((_) async {}); + when( + () => balanceService.getBalance(any(), any()), + ).thenAnswer((_) async => balance(5)); + }); + + MigrateBitboxCubit buildCubit() { + final cubit = MigrateBitboxCubit( + walletService, + authService, + registrationService, + balanceService, + appStore, + ); + addTearDown(cubit.close); + return cubit; + } + + Future reachRegisterReady(MigrateBitboxCubit cubit) async { + when( + () => registrationService.getRegistrationInfoWith(any()), + ).thenAnswer( + (_) async => info( + RealUnitRegistrationState.addWallet, + userData: _userData, + ), + ); + await cubit.onDevicePaired(draft); + expect(cubit.state, isA()); + } + + Future reachTransferReady(MigrateBitboxCubit cubit) async { + await cubit.onDevicePaired(draft); + expect(cubit.state, isA()); + } + + group('$MigrateBitboxCubit pairing', () { + test('starts in Intro and startPairing emits AwaitingDevice', () async { + final cubit = buildCubit(); + + expect(cubit.state, const MigrateBitboxIntro()); + await cubit.startPairing(); + + expect(cubit.state, const MigrateBitboxAwaitingDevice()); + }); + + test('cancelPairing is effective only from AwaitingDevice', () async { + final cubit = buildCubit(); + final emissions = []; + final subscription = cubit.stream.listen(emissions.add); + addTearDown(subscription.cancel); + + cubit.cancelPairing(); + expect(emissions, isEmpty); + + await cubit.startPairing(); + cubit.cancelPairing(); + + expect(emissions, [const MigrateBitboxAwaitingDevice(), const MigrateBitboxIntro()]); + }); + }); + + group('$MigrateBitboxCubit onDevicePaired', () { + test('matching cached signature and addWallet userData emit RegisterReady', () async { + when( + () => registrationService.getRegistrationInfoWith(newJwt), + ).thenAnswer( + (_) async => info( + RealUnitRegistrationState.addWallet, + userData: _userData, + ), + ); + final cubit = buildCubit(); + + await cubit.onDevicePaired(draft); + + expect( + cubit.state, + MigrateBitboxRegisterReady(_userData, draftAddress), + ); + verify( + () => authService.authenticateLinkedAccount(draftAccount, oldJwt), + ).called(1); + verify(() => registrationService.getRegistrationInfoWith(newJwt)).called(1); + }); + + test('addWallet without userData fails loud and retry is a no-op', () async { + when( + () => registrationService.getRegistrationInfoWith(any()), + ).thenAnswer((_) async => info(RealUnitRegistrationState.addWallet)); + final cubit = buildCubit(); + + await cubit.onDevicePaired(draft); + + expect( + cubit.state, + const MigrateBitboxFailure( + MigrateBitboxFailureReason.generic, + message: 'API returned addWallet without userData', + ), + ); + await cubit.retry(); + verify(() => authService.getAuthToken()).called(1); + }); + + test('alreadyRegistered with manual review emits RegistrationPending', () async { + when( + () => registrationService.getRegistrationInfoWith(any()), + ).thenAnswer( + (_) async => info( + RealUnitRegistrationState.alreadyRegistered, + manualReview: true, + ), + ); + final cubit = buildCubit(); + + await cubit.onDevicePaired(draft); + + expect(cubit.state, const MigrateBitboxRegistrationPending()); + verifyNever(() => walletService.persistBitboxWallet(any())); + }); + + test('alreadyRegistered persists before balance refresh and emits TransferReady', () async { + final cubit = buildCubit(); + + await cubit.onDevicePaired(draft); + + final state = cubit.state as MigrateBitboxTransferReady; + expect(state.fromAddress, softwareAddress); + expect(state.toAddress, persistedAddress); + expect(state.amount, 5); + verifyInOrder([ + () => walletService.persistBitboxWallet(draft), + () => balanceService.updateBalance(softwareAddress), + ]); + }); + + test('newRegistration emits registrationMissing', () async { + when( + () => registrationService.getRegistrationInfoWith(any()), + ).thenAnswer((_) async => info(RealUnitRegistrationState.newRegistration)); + final cubit = buildCubit(); + + await cubit.onDevicePaired(draft); + + expect( + cubit.state, + const MigrateBitboxFailure(MigrateBitboxFailureReason.registrationMissing), + ); + }); + + test('missing old JWT emits generic failure with no pending retry', () async { + when(() => authService.getAuthToken()).thenAnswer((_) async => null); + final cubit = buildCubit(); + + await cubit.onDevicePaired(draft); + await cubit.retry(); + + expect( + cubit.state, + const MigrateBitboxFailure(MigrateBitboxFailureReason.generic), + ); + verify(() => authService.getAuthToken()).called(1); + verifyNever(() => authService.authenticateLinkedAccount(any(), any())); + }); + + test('AddressAlreadyLinkedException is terminal', () async { + when( + () => authService.authenticateLinkedAccount(any(), any()), + ).thenThrow(const AddressAlreadyLinkedException()); + final cubit = buildCubit(); + + await cubit.onDevicePaired(draft); + await cubit.retry(); + + expect( + cubit.state, + const MigrateBitboxFailure(MigrateBitboxFailureReason.addressAlreadyLinked), + ); + verify(() => authService.getAuthToken()).called(1); + }); + + test('SigningCancelledException is retryable with the same draft', () async { + var attempts = 0; + when( + () => authService.authenticateLinkedAccount(any(), any()), + ).thenAnswer((_) async { + attempts++; + if (attempts == 1) throw const SigningCancelledException(); + return newJwt; + }); + final cubit = buildCubit(); + + await cubit.onDevicePaired(draft); + expect( + cubit.state, + const MigrateBitboxFailure( + MigrateBitboxFailureReason.signatureCancelled, + canRetry: true, + ), + ); + await cubit.retry(); + + verify(() => authService.getAuthToken()).called(2); + verify( + () => authService.authenticateLinkedAccount(draftAccount, oldJwt), + ).called(2); + }); + + test('BitboxNotConnectedException is retryable with the same draft', () async { + var attempts = 0; + when( + () => authService.authenticateLinkedAccount(any(), any()), + ).thenAnswer((_) async { + attempts++; + if (attempts == 1) throw const BitboxNotConnectedException(); + return newJwt; + }); + final cubit = buildCubit(); + + await cubit.onDevicePaired(draft); + expect( + cubit.state, + const MigrateBitboxFailure( + MigrateBitboxFailureReason.bitboxNotConnected, + canRetry: true, + ), + ); + await cubit.retry(); + + verify(() => authService.getAuthToken()).called(2); + }); + + test('unexpected exception keeps its message and retries the same draft', () async { + var attempts = 0; + when( + () => authService.authenticateLinkedAccount(any(), any()), + ).thenAnswer((_) async { + attempts++; + if (attempts == 1) throw Exception('link failed'); + return newJwt; + }); + final cubit = buildCubit(); + + await cubit.onDevicePaired(draft); + expect( + cubit.state, + const MigrateBitboxFailure( + MigrateBitboxFailureReason.generic, + message: 'Exception: link failed', + canRetry: true, + ), + ); + await cubit.retry(); + + verify(() => authService.getAuthToken()).called(2); + }); + }); + + group('$MigrateBitboxCubit register', () { + test('is a no-op outside RegisterReady', () async { + final cubit = buildCubit(); + final initial = cubit.state; + + await cubit.register(); + + expect(cubit.state, same(initial)); + verifyNever(() => registrationService.registerWalletFor(any(), any(), any())); + }); + + for (final status in [ + RegistrationStatus.completed, + RegistrationStatus.alreadyRegistered, + ]) { + test('$status persists and prepares transfer', () async { + final cubit = buildCubit(); + await reachRegisterReady(cubit); + when( + () => registrationService.registerWalletFor(any(), any(), any()), + ).thenAnswer((_) async => status); + + await cubit.register(); + + expect(cubit.state, isA()); + verify( + () => registrationService.registerWalletFor(draftAccount, _userData, newJwt), + ).called(1); + verify(() => walletService.persistBitboxWallet(draft)).called(1); + }); + } + + for (final status in [ + RegistrationStatus.pendingReview, + RegistrationStatus.forwardingFailed, + ]) { + test('$status emits RegistrationPending', () async { + final cubit = buildCubit(); + await reachRegisterReady(cubit); + when( + () => registrationService.registerWalletFor(any(), any(), any()), + ).thenAnswer((_) async => status); + + await cubit.register(); + + expect(cubit.state, const MigrateBitboxRegistrationPending()); + }); + } + + final retryableErrors = <(Exception, MigrateBitboxFailureReason)>[ + (const SigningCancelledException(), MigrateBitboxFailureReason.signatureCancelled), + (const BitboxNotConnectedException(), MigrateBitboxFailureReason.bitboxNotConnected), + (Exception('registration failed'), MigrateBitboxFailureReason.generic), + ]; + for (final (error, reason) in retryableErrors) { + test('$error is retryable and retry invokes registerWalletFor again', () async { + final cubit = buildCubit(); + await reachRegisterReady(cubit); + var attempts = 0; + when( + () => registrationService.registerWalletFor(any(), any(), any()), + ).thenAnswer((_) async { + attempts++; + if (attempts == 1) throw error; + return RegistrationStatus.completed; + }); + + await cubit.register(); + + final failure = cubit.state as MigrateBitboxFailure; + expect(failure.reason, reason); + expect(failure.canRetry, isTrue); + if (reason == MigrateBitboxFailureReason.generic) { + expect(failure.message, 'Exception: registration failed'); + } + + await cubit.retry(); + + verify( + () => registrationService.registerWalletFor(draftAccount, _userData, newJwt), + ).called(2); + }); + } + }); + + group('$MigrateBitboxCubit transfer preparation', () { + test('missing balance fails loud and retry repeats persistence and balance read', () async { + when( + () => balanceService.getBalance(any(), any()), + ).thenAnswer((_) async => null); + final cubit = buildCubit(); + + await cubit.onDevicePaired(draft); + expect( + cubit.state, + const MigrateBitboxFailure( + MigrateBitboxFailureReason.generic, + message: 'balance unavailable', + canRetry: true, + ), + ); + + await cubit.retry(); + + verify(() => walletService.persistBitboxWallet(draft)).called(2); + verify(() => balanceService.updateBalance(softwareAddress)).called(2); + verify(() => balanceService.getBalance(realUnitAsset, softwareAddress)).called(2); + }); + + test('zero balance completes without emitting TransferReady', () async { + when( + () => balanceService.getBalance(any(), any()), + ).thenAnswer((_) async => balance(0)); + final cubit = buildCubit(); + final emitted = []; + final subscription = cubit.stream.listen(emitted.add); + addTearDown(subscription.cancel); + + await cubit.onDevicePaired(draft); + + expect(cubit.state, MigrateBitboxSuccess(persisted)); + expect(emitted.whereType(), isEmpty); + }); + + test('positive balance truncates to an integer amount', () async { + when( + () => balanceService.getBalance(any(), any()), + ).thenAnswer((_) async => balance(37)); + final cubit = buildCubit(); + + await cubit.onDevicePaired(draft); + + expect( + cubit.state, + MigrateBitboxTransferReady( + fromAddress: softwareAddress, + toAddress: persistedAddress, + amount: 37, + ), + ); + }); + }); + + group('$MigrateBitboxCubit transfer and completion', () { + test('startTransfer is a no-op outside TransferReady', () { + final cubit = buildCubit(); + final initial = cubit.state; + + cubit.startTransfer(); + + expect(cubit.state, same(initial)); + }); + + test('startTransfer preserves recipient and amount', () async { + final cubit = buildCubit(); + await reachTransferReady(cubit); + + cubit.startTransfer(); + + expect( + cubit.state, + MigrateBitboxTransferring(toAddress: persistedAddress, amount: 5), + ); + }); + + test('terminal transfer failure is a no-op outside Transferring', () { + final cubit = buildCubit(); + final initial = cubit.state; + + cubit.onTransferFailedTerminally('definitive'); + + expect(cubit.state, same(initial)); + }); + + test('terminal transfer failure retries with a fresh balance read', () async { + final cubit = buildCubit(); + await reachTransferReady(cubit); + cubit.startTransfer(); + + cubit.onTransferFailedTerminally('definitive failure'); + + expect( + cubit.state, + const MigrateBitboxFailure( + MigrateBitboxFailureReason.generic, + message: 'definitive failure', + canRetry: true, + ), + ); + await cubit.retry(); + + verify(() => balanceService.updateBalance(softwareAddress)).called(2); + verify(() => balanceService.getBalance(realUnitAsset, softwareAddress)).called(2); + expect(cubit.state, isA()); + }); + + test('matching signature is persisted before the new auth token', () async { + when( + () => balanceService.getBalance(any(), any()), + ).thenAnswer((_) async => balance(0)); + final cubit = buildCubit(); + + await cubit.onDevicePaired(draft); + + verifyInOrder([ + () => walletService.setCurrentWallet(42), + () => sessionCache.saveSignature(persistedAddress, signature), + () => sessionCache.setAuthToken(newJwt), + ]); + expect(cubit.state, MigrateBitboxSuccess(persisted)); + }); + + test('signature-address mismatch skips signature persistence', () async { + when(() => sessionCache.signatureAddress).thenReturn(softwareAddress); + when( + () => balanceService.getBalance(any(), any()), + ).thenAnswer((_) async => balance(0)); + final cubit = buildCubit(); + + await cubit.onDevicePaired(draft); + + verifyInOrder([ + () => walletService.setCurrentWallet(42), + () => sessionCache.setAuthToken(newJwt), + ]); + verifyNever(() => sessionCache.saveSignature(any(), any())); + expect(cubit.state, MigrateBitboxSuccess(persisted)); + }); + }); +} diff --git a/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart b/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart new file mode 100644 index 000000000..ccceca20c --- /dev/null +++ b/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart @@ -0,0 +1,344 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/models/balance.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/hardware_wallet/bitbox.dart'; +import 'package:realunit_wallet/packages/repository/balance_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/balance_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_transfer_service.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.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/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/migrate_bitbox_page.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_intro_view.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_register_view.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_result_views.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_transfer_view.dart'; + +import '../../helper/pump_app.dart'; + +class _MockMigrateBitboxCubit extends MockCubit + implements MigrateBitboxCubit {} + +class _MockHomeBloc extends MockBloc implements HomeBloc {} + +class _MockWalletService extends Mock implements WalletService {} + +class _MockDfxKycService extends Mock implements DfxKycService {} + +class _MockRegistrationService extends Mock implements RealUnitRegistrationService {} + +class _MockBalanceService extends Mock implements BalanceService {} + +class _MockBalanceRepository extends Mock implements BalanceRepository {} + +class _MockTransferService extends Mock implements RealUnitTransferService {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +class _MockBitboxService extends Mock implements BitboxService {} + +class _MockBitboxWallet extends Mock implements BitboxWallet {} + +class _MockDebugWallet extends Mock implements DebugWallet {} + +const _userData = RealUnitUserDataDto( + email: 'ada@example.com', + name: 'Ada Lovelace', + type: 'HUMAN', + phoneNumber: '+41 79 000 00 00', + birthday: '1815-12-10', + nationality: 'CH', + addressStreet: 'Bahnhofstrasse 1', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: 'CH', + swissTaxResidence: true, + lang: 'de', + kycData: KycPersonalData( + accountType: KycAccountType.personal, + firstName: 'Ada', + lastName: 'Lovelace', + phone: '+41 79 000 00 00', + address: KycAddress( + street: 'Bahnhofstrasse', + zip: '8000', + city: 'Zurich', + country: 41, + ), + ), +); + +void main() { + const softwareAddress = '0x0000000000000000000000000000000000000001'; + final mappingWallet = _MockBitboxWallet(); + late _MockMigrateBitboxCubit cubit; + late _MockHomeBloc homeBloc; + late _MockBitboxWallet bitboxWallet; + late _MockWalletService walletService; + + Balance fixtureBalance() => Balance( + chainId: realUnitAsset.chainId, + contractAddress: realUnitAsset.address, + walletAddress: softwareAddress, + balance: BigInt.zero, + asset: realUnitAsset, + ); + + setUpAll(() { + registerFallbackValue(fixtureBalance()); + registerFallbackValue(_MockBitboxWallet()); + registerFallbackValue(const LoadCurrentWalletEvent()); + + walletService = _MockWalletService(); + final authService = _MockDfxKycService(); + final registrationService = _MockRegistrationService(); + final balanceService = _MockBalanceService(); + final balanceRepository = _MockBalanceRepository(); + final transferService = _MockTransferService(); + final appStore = _MockAppStore(); + final apiConfig = _MockApiConfig(); + final bitboxService = _MockBitboxService(); + final debugWallet = _MockDebugWallet(); + + when(() => appStore.apiConfig).thenReturn(apiConfig); + when(() => appStore.primaryAddress).thenReturn(softwareAddress); + when(() => appStore.wallet).thenReturn(debugWallet); + when(() => apiConfig.asset).thenReturn(realUnitAsset); + when(() => debugWallet.walletType).thenReturn(WalletType.debug); + when( + () => balanceRepository.watchBalance(any()), + ).thenAnswer((_) => Stream.value(fixtureBalance())); + when(() => bitboxService.startScan()).thenAnswer((_) async => true); + when(() => bitboxService.getAllUsbDevices()).thenAnswer((_) async => []); + + GetIt.instance.registerSingleton(walletService); + GetIt.instance.registerSingleton(authService); + GetIt.instance.registerSingleton( + registrationService, + ); + GetIt.instance.registerSingleton(balanceService); + GetIt.instance.registerSingleton(balanceRepository); + GetIt.instance.registerSingleton(transferService); + GetIt.instance.registerSingleton(appStore); + GetIt.instance.registerSingleton(bitboxService); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + setUp(() { + cubit = _MockMigrateBitboxCubit(); + homeBloc = _MockHomeBloc(); + bitboxWallet = _MockBitboxWallet(); + when(() => homeBloc.state).thenReturn(const HomeState()); + whenListen( + homeBloc, + const Stream.empty(), + initialState: const HomeState(), + ); + when(() => homeBloc.add(any())).thenReturn(null); + when(() => cubit.cancelPairing()).thenReturn(null); + when(() => cubit.onDevicePaired(any())).thenAnswer((_) async {}); + when(() => cubit.finishMigration()).thenAnswer((_) async {}); + when( + () => cubit.onTransferFailedTerminally(any()), + ).thenReturn(null); + }); + + Future pumpState( + WidgetTester tester, + MigrateBitboxState state, + ) async { + when(() => cubit.state).thenReturn(state); + whenListen( + cubit, + const Stream.empty(), + initialState: state, + ); + await tester.pumpApp( + BlocProvider.value( + value: homeBloc, + child: BlocProvider.value( + value: cubit, + child: const MigrateBitboxViewManager(), + ), + ), + ); + await tester.pump(); + } + + testWidgets('$MigrateBitboxPage creates its cubit and renders the intro', ( + tester, + ) async { + await tester.pumpApp(const MigrateBitboxPage()); + await tester.pump(); + + expect(find.byType(MigrateIntroView), findsOneWidget); + }); + + group('$MigrateBitboxViewManager state to view mapping', () { + final cases = <(MigrateBitboxState, Type)>[ + (const MigrateBitboxIntro(), MigrateIntroView), + (const MigrateBitboxAwaitingDevice(), MigrateIntroView), + (const MigrateBitboxRegisterReady(_userData, '0xbitbox'), MigrateRegisterView), + (const MigrateBitboxRegistrationPending(), MigrateBitboxRegistrationPendingPage), + ( + const MigrateBitboxTransferReady( + fromAddress: '0xfrom', + toAddress: '0xto', + amount: 9, + ), + MigrateTransferReadyView, + ), + ( + const MigrateBitboxTransferring(toAddress: '0xto', amount: 9), + MigrateTransferringView, + ), + (MigrateBitboxSuccess(mappingWallet), MigrateBitboxSuccessPage), + ( + const MigrateBitboxFailure(MigrateBitboxFailureReason.generic), + MigrateBitboxFailurePage, + ), + ]; + + for (final (state, widgetType) in cases) { + testWidgets('$state renders $widgetType', (tester) async { + await pumpState(tester, state); + + expect(find.byType(widgetType), findsOneWidget); + }); + } + + final progressCases = <(MigrateBitboxState, String)>[ + (const MigrateBitboxLinking(), 'linking'), + (const MigrateBitboxRegistering(), 'registering'), + (const MigrateBitboxCompleting(), 'completing'), + ]; + for (final (state, label) in progressCases) { + testWidgets('$state renders the $label progress page', (tester) async { + await pumpState(tester, state); + + expect(find.byType(CupertinoActivityIndicator), findsOneWidget); + expect(find.byType(Scaffold), findsOneWidget); + }); + } + }); + + group('$MigrateBitboxViewManager PopScope canPop', () { + final cases = <(MigrateBitboxState, bool)>[ + (const MigrateBitboxIntro(), true), + (const MigrateBitboxAwaitingDevice(), false), + (const MigrateBitboxLinking(), false), + (const MigrateBitboxRegisterReady(_userData, '0xbitbox'), true), + (const MigrateBitboxRegistering(), false), + (const MigrateBitboxRegistrationPending(), true), + ( + const MigrateBitboxTransferReady( + fromAddress: '0xfrom', + toAddress: '0xto', + amount: 9, + ), + true, + ), + (const MigrateBitboxTransferring(toAddress: '0xto', amount: 9), false), + (const MigrateBitboxCompleting(), false), + (MigrateBitboxSuccess(mappingWallet), true), + ( + const MigrateBitboxFailure(MigrateBitboxFailureReason.generic), + true, + ), + ]; + + for (final (state, expected) in cases) { + testWidgets('$state canPop=$expected', (tester) async { + await pumpState(tester, state); + + final popScope = tester.widget(find.byType(PopScope)); + expect(popScope.canPop, expected); + }); + } + }); + + testWidgets('AwaitingDevice emission opens the ConnectBitboxPage sheet', ( + tester, + ) async { + whenListen( + cubit, + Stream.value( + const MigrateBitboxAwaitingDevice(), + ), + initialState: const MigrateBitboxIntro(), + ); + await tester.pumpApp( + BlocProvider.value( + value: homeBloc, + child: BlocProvider.value( + value: cubit, + child: const MigrateBitboxViewManager(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.byType(ConnectBitboxPage), findsOneWidget); + final sheet = tester.widget( + find.byType(ConnectBitboxPage), + ); + when( + () => walletService.acquireUncommittedBitboxWallet(any()), + ).thenAnswer((_) async => bitboxWallet); + + expect(await sheet.acquireWallet!(), same(bitboxWallet)); + sheet.onFinish(bitboxWallet); + + verify( + () => walletService.acquireUncommittedBitboxWallet('Luke-Skywallet'), + ).called(1); + verify(() => cubit.onDevicePaired(bitboxWallet)).called(1); + + await tester.tap(find.text(S.current.cancel)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + verify(() => cubit.cancelPairing()).called(1); + }); + + testWidgets('Success emission dispatches LoadWalletEvent to HomeBloc', ( + tester, + ) async { + whenListen( + cubit, + Stream.value(MigrateBitboxSuccess(bitboxWallet)), + initialState: const MigrateBitboxIntro(), + ); + await tester.pumpApp( + BlocProvider.value( + value: homeBloc, + child: BlocProvider.value( + value: cubit, + child: const MigrateBitboxViewManager(), + ), + ), + ); + await tester.pump(); + + verify(() => homeBloc.add(LoadWalletEvent(bitboxWallet))).called(1); + }); +} diff --git a/test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart b/test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart new file mode 100644 index 000000000..89b80f64b --- /dev/null +++ b/test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart @@ -0,0 +1,229 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/models/balance.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/repository/balance_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_intro_view.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_register_view.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_result_views.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_transfer_view.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; +import 'package:realunit_wallet/styles/themes.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../helper/helper.dart'; + +class _MockMigrateBitboxCubit extends MockCubit + implements MigrateBitboxCubit {} + +class _MockBalanceRepository extends Mock implements BalanceRepository {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +const _userData = RealUnitUserDataDto( + email: 'ada@example.com', + name: 'Ada Lovelace with an intentionally long accessibility name', + type: 'HUMAN', + phoneNumber: '+41 79 000 00 00', + birthday: '1815-12-10', + nationality: 'CH', + addressStreet: 'Bahnhofstrasse 1', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: 'CH', + swissTaxResidence: true, + lang: 'de', + kycData: KycPersonalData( + accountType: KycAccountType.personal, + firstName: 'Ada', + lastName: 'Lovelace', + phone: '+41 79 000 00 00', + address: KycAddress( + street: 'Bahnhofstrasse', + zip: '8000', + city: 'Zurich', + country: 41, + ), + ), +); + +void main() { + const address = '0x0000000000000000000000000000000000000001'; + + Balance fixtureBalance() => Balance( + chainId: realUnitAsset.chainId, + contractAddress: realUnitAsset.address, + walletAddress: address, + balance: BigInt.from(999999999), + asset: realUnitAsset, + ); + + setUpAll(() { + registerFallbackValue(fixtureBalance()); + final appStore = _MockAppStore(); + final apiConfig = _MockApiConfig(); + final balanceRepository = _MockBalanceRepository(); + when(() => appStore.apiConfig).thenReturn(apiConfig); + when(() => appStore.primaryAddress).thenReturn(address); + when(() => apiConfig.asset).thenReturn(realUnitAsset); + when( + () => balanceRepository.watchBalance(any()), + ).thenAnswer((_) => Stream.value(fixtureBalance())); + GetIt.instance.registerSingleton(appStore); + GetIt.instance.registerSingleton(balanceRepository); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + _MockMigrateBitboxCubit buildCubit() { + final cubit = _MockMigrateBitboxCubit(); + when(() => cubit.state).thenReturn(const MigrateBitboxIntro()); + whenListen( + cubit, + const Stream.empty(), + initialState: const MigrateBitboxIntro(), + ); + when(() => cubit.startPairing()).thenAnswer((_) async {}); + when(() => cubit.register()).thenAnswer((_) async {}); + when(() => cubit.startTransfer()).thenReturn(null); + when(() => cubit.retry()).thenAnswer((_) async {}); + return cubit; + } + + Future pumpSurface( + WidgetTester tester, + MatrixCell cell, + Widget surface, + _MockMigrateBitboxCubit cubit, + ) async { + await tester.binding.setSurfaceSize(cell.mediaQuery.size); + addTearDown(() async => tester.binding.setSurfaceSize(null)); + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, _) => const Scaffold(body: Text('matrix-host')), + ), + GoRoute( + path: '/surface', + builder: (_, _) => BlocProvider.value( + value: cubit, + child: surface, + ), + ), + GoRoute( + name: AppRoutes.dashboard, + path: '/dashboard', + builder: (_, _) => const Scaffold(body: Text('dashboard')), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MediaQuery( + data: cell.mediaQuery, + child: MaterialApp.router( + routerConfig: router, + theme: realUnitTheme, + locale: const Locale('de'), + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + ), + ), + ); + router.push('/surface'); + await tester.pumpAndSettle(); + } + + final surfaces = <(String, Widget Function(), Type)>[ + ('intro', () => const MigrateIntroView(), MigrateIntroView), + ( + 'register', + () => const MigrateRegisterView( + userData: _userData, + bitboxAddress: '0x1234567890abcdef1234567890abcdef12345678', + ), + MigrateRegisterView, + ), + ( + 'transfer-ready', + () => const MigrateTransferReadyView( + fromAddress: '0x1234567890abcdef1234567890abcdef12345678', + toAddress: '0xabcdef1234567890abcdef1234567890abcdef12', + amount: 999999999, + ), + MigrateTransferReadyView, + ), + ( + 'registration-pending', + () => const MigrateBitboxRegistrationPendingPage(), + MigrateBitboxRegistrationPendingPage, + ), + ( + 'success', + () => const MigrateBitboxSuccessPage(), + MigrateBitboxSuccessPage, + ), + ( + 'failure-retryable', + () => const MigrateBitboxFailurePage( + reason: MigrateBitboxFailureReason.registrationMissing, + canRetry: true, + ), + MigrateBitboxFailurePage, + ), + ( + 'failure-terminal', + () => const MigrateBitboxFailurePage( + reason: MigrateBitboxFailureReason.addressAlreadyLinked, + canRetry: false, + ), + MigrateBitboxFailurePage, + ), + ]; + + for (final (surfaceId, buildSurface, surfaceType) in surfaces) { + group('$surfaceId responsive matrix (full device × textScale)', () { + for (final cell in kFullResponsiveMatrix) { + testWidgets(cell.id, (tester) async { + await withTargetPlatform(cell.device.platform, () async { + final cubit = buildCubit(); + await expectNoLayoutOverflow( + tester, + () => pumpSurface(tester, cell, buildSurface(), cubit), + reason: '$surfaceId overflow on ${cell.label}', + ); + + await expectFullyTappable( + tester, + find.byType(AppFilledButton).first, + within: find.byType(surfaceType), + reason: '$surfaceId primary CTA not tappable on ${cell.label}', + ); + await tester.pumpAndSettle(); + }); + }); + } + }); + } +} diff --git a/test/screens/migrate_bitbox/widgets/migrate_intro_view_test.dart b/test/screens/migrate_bitbox/widgets/migrate_intro_view_test.dart new file mode 100644 index 000000000..dbff18f28 --- /dev/null +++ b/test/screens/migrate_bitbox/widgets/migrate_intro_view_test.dart @@ -0,0 +1,87 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/models/balance.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/repository/balance_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_intro_view.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../../helper/pump_app.dart'; + +class _MockMigrateBitboxCubit extends MockCubit + implements MigrateBitboxCubit {} + +class _MockBalanceRepository extends Mock implements BalanceRepository {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +void main() { + const address = '0x0000000000000000000000000000000000000001'; + late _MockMigrateBitboxCubit cubit; + late _MockBalanceRepository balanceRepository; + + Balance fixtureBalance([int amount = 0]) => Balance( + chainId: realUnitAsset.chainId, + contractAddress: realUnitAsset.address, + walletAddress: address, + balance: BigInt.from(amount), + asset: realUnitAsset, + ); + + setUpAll(() { + registerFallbackValue(fixtureBalance()); + final appStore = _MockAppStore(); + final apiConfig = _MockApiConfig(); + when(() => appStore.apiConfig).thenReturn(apiConfig); + when(() => appStore.primaryAddress).thenReturn(address); + when(() => apiConfig.asset).thenReturn(realUnitAsset); + GetIt.instance.registerSingleton(appStore); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + setUp(() { + cubit = _MockMigrateBitboxCubit(); + balanceRepository = _MockBalanceRepository(); + when(() => cubit.state).thenReturn(const MigrateBitboxIntro()); + whenListen( + cubit, + const Stream.empty(), + initialState: const MigrateBitboxIntro(), + ); + when(() => cubit.startPairing()).thenAnswer((_) async {}); + when( + () => balanceRepository.watchBalance(any()), + ).thenAnswer((_) => Stream.value(fixtureBalance(123))); + GetIt.instance.registerSingleton(balanceRepository); + }); + + tearDown(() async { + await GetIt.instance.unregister(); + }); + + testWidgets('shows the live balance and starts pairing from the primary CTA', ( + tester, + ) async { + await tester.pumpApp( + BlocProvider.value( + value: cubit, + child: const MigrateIntroView(), + ), + ); + await tester.pump(); + + expect(find.textContaining('123'), findsOneWidget); + await tester.tap(find.byType(AppFilledButton)); + + verify(() => cubit.startPairing()).called(1); + }); +} diff --git a/test/screens/migrate_bitbox/widgets/migrate_register_view_test.dart b/test/screens/migrate_bitbox/widgets/migrate_register_view_test.dart new file mode 100644 index 000000000..fc1c81145 --- /dev/null +++ b/test/screens/migrate_bitbox/widgets/migrate_register_view_test.dart @@ -0,0 +1,89 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_register_view.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../../helper/pump_app.dart'; + +class _MockMigrateBitboxCubit extends MockCubit + implements MigrateBitboxCubit {} + +const _userData = RealUnitUserDataDto( + email: 'ada@example.com', + name: 'Ada Lovelace', + type: 'HUMAN', + phoneNumber: '+41 79 000 00 00', + birthday: '1815-12-10', + nationality: 'CH', + addressStreet: 'Bahnhofstrasse 1', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: 'CH', + swissTaxResidence: true, + lang: 'de', + kycData: KycPersonalData( + accountType: KycAccountType.personal, + firstName: 'Ada', + lastName: 'Lovelace', + phone: '+41 79 000 00 00', + address: KycAddress( + street: 'Bahnhofstrasse', + zip: '8000', + city: 'Zurich', + country: 41, + ), + ), +); + +void main() { + late _MockMigrateBitboxCubit cubit; + + setUp(() { + cubit = _MockMigrateBitboxCubit(); + when(() => cubit.state).thenReturn( + const MigrateBitboxRegisterReady(_userData, '0x1234567890abcdef'), + ); + whenListen( + cubit, + const Stream.empty(), + initialState: const MigrateBitboxRegisterReady( + _userData, + '0x1234567890abcdef', + ), + ); + when(() => cubit.register()).thenAnswer((_) async {}); + }); + + Future pumpView(WidgetTester tester, String address) => tester.pumpApp( + BlocProvider.value( + value: cubit, + child: MigrateRegisterView( + userData: _userData, + bitboxAddress: address, + ), + ), + ); + + testWidgets('shows user data, truncates a long address, and registers', ( + tester, + ) async { + await pumpView(tester, '0x1234567890abcdef'); + + expect(find.text('Ada Lovelace'), findsOneWidget); + expect(find.text('0x1234…cdef'), findsOneWidget); + await tester.tap(find.byType(AppFilledButton)); + + verify(() => cubit.register()).called(1); + }); + + testWidgets('keeps a short address unchanged', (tester) async { + await pumpView(tester, '0x1234'); + + expect(find.text('0x1234'), findsOneWidget); + }); +} diff --git a/test/screens/migrate_bitbox/widgets/migrate_result_views_test.dart b/test/screens/migrate_bitbox/widgets/migrate_result_views_test.dart new file mode 100644 index 000000000..602f3dc72 --- /dev/null +++ b/test/screens/migrate_bitbox/widgets/migrate_result_views_test.dart @@ -0,0 +1,167 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_result_views.dart'; +import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +class _MockMigrateBitboxCubit extends MockCubit + implements MigrateBitboxCubit {} + +void main() { + late _MockMigrateBitboxCubit cubit; + + setUp(() { + cubit = _MockMigrateBitboxCubit(); + when(() => cubit.state).thenReturn( + const MigrateBitboxFailure(MigrateBitboxFailureReason.generic), + ); + whenListen( + cubit, + const Stream.empty(), + initialState: const MigrateBitboxFailure( + MigrateBitboxFailureReason.generic, + ), + ); + when(() => cubit.retry()).thenAnswer((_) async {}); + }); + + Future pumpSurface(WidgetTester tester, Widget surface) async { + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, _) => const Scaffold(body: Text('host')), + ), + GoRoute( + path: '/surface', + builder: (_, _) => BlocProvider.value( + value: cubit, + child: surface, + ), + ), + GoRoute( + name: AppRoutes.dashboard, + path: '/dashboard', + builder: (_, _) => const Scaffold(body: Text('dashboard')), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + ), + ); + router.push('/surface'); + await tester.pumpAndSettle(); + } + + testWidgets('RegistrationPending close returns to the previous route', ( + tester, + ) async { + await pumpSurface( + tester, + const MigrateBitboxRegistrationPendingPage(), + ); + + expect( + find.text(S.current.migrateBitboxRegistrationPendingInfo), + findsOneWidget, + ); + await tester.tap(find.byType(AppFilledButton)); + await tester.pumpAndSettle(); + + expect(find.text('host'), findsOneWidget); + }); + + testWidgets('Success shows its copy and goes to dashboard', (tester) async { + await pumpSurface(tester, const MigrateBitboxSuccessPage()); + + expect(find.text(S.current.migrateBitboxSuccessTitle), findsOneWidget); + expect(find.text(S.current.migrateBitboxSuccessDescription), findsOneWidget); + await tester.tap(find.byType(AppFilledButton)); + await tester.pumpAndSettle(); + + expect(find.text('dashboard'), findsOneWidget); + }); + + final failureCases = <(MigrateBitboxFailureReason, String)>[ + ( + MigrateBitboxFailureReason.addressAlreadyLinked, + 'addressAlreadyLinked', + ), + ( + MigrateBitboxFailureReason.registrationMissing, + 'registrationMissing', + ), + ( + MigrateBitboxFailureReason.signatureCancelled, + 'signatureCancelled', + ), + ( + MigrateBitboxFailureReason.bitboxNotConnected, + 'bitboxNotConnected', + ), + (MigrateBitboxFailureReason.generic, 'generic'), + ]; + + for (final (reason, label) in failureCases) { + testWidgets('Failure $label resolves localized copy and closes', ( + tester, + ) async { + await pumpSurface( + tester, + MigrateBitboxFailurePage(reason: reason, canRetry: false), + ); + + final expected = switch (reason) { + MigrateBitboxFailureReason.addressAlreadyLinked => + S.current.migrateBitboxAlreadyLinkedError, + MigrateBitboxFailureReason.registrationMissing => + S.current.migrateBitboxRegistrationMissingError, + MigrateBitboxFailureReason.signatureCancelled => + S.current.sendFailureSignatureCancelled, + MigrateBitboxFailureReason.bitboxNotConnected => + S.current.connectBitboxFailed, + MigrateBitboxFailureReason.generic => S.current.connectBitboxFailed, + }; + expect(find.text(expected), findsOneWidget); + expect(find.text(S.current.retry), findsNothing); + expect(find.byType(AppFilledButton), findsOneWidget); + + await tester.tap(find.byType(AppFilledButton)); + await tester.pumpAndSettle(); + expect(find.text('host'), findsOneWidget); + }); + } + + testWidgets('retryable Failure shows Retry and dispatches retry', (tester) async { + await pumpSurface( + tester, + const MigrateBitboxFailurePage( + reason: MigrateBitboxFailureReason.generic, + canRetry: true, + ), + ); + + expect(find.byType(AppFilledButton), findsNWidgets(2)); + await tester.tap(find.text(S.current.retry)); + + verify(() => cubit.retry()).called(1); + }); +} diff --git a/test/screens/migrate_bitbox/widgets/migrate_transfer_view_test.dart b/test/screens/migrate_bitbox/widgets/migrate_transfer_view_test.dart new file mode 100644 index 000000000..c5b213b45 --- /dev/null +++ b/test/screens/migrate_bitbox/widgets/migrate_transfer_view_test.dart @@ -0,0 +1,308 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.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/exceptions/payment/transfer_exceptions.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_transfer_service.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_transfer_view.dart'; +import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; + +import '../../../helper/pump_app.dart'; + +class _MockMigrateBitboxCubit extends MockCubit + implements MigrateBitboxCubit {} + +class _MockTransferService extends Mock implements RealUnitTransferService {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockSoftwareWallet extends Mock implements SoftwareWallet {} + +RealUnitTransferPaymentInfoDto _info() => RealUnitTransferPaymentInfoDto.fromJson({ + 'id': 42, + 'uid': 'RTmigration', + 'toAddress': '0xRecipient', + 'amount': 5, + 'tokenAddress': '0xRealu', + 'chainId': 1, + 'eip7702': { + 'relayerAddress': '0xrelay', + 'delegationManagerAddress': '0xmanager', + 'delegatorAddress': '0xdelegator', + 'userNonce': 0, + 'domain': { + 'name': 'd', + 'version': '1', + 'chainId': 1, + 'verifyingContract': '0xmanager', + }, + 'types': { + 'Delegation': >[], + 'Caveat': >[], + }, + 'message': { + 'delegate': '0xrelay', + 'delegator': '0xsender', + 'authority': '0xroot', + 'caveats': >[], + 'salt': 0, + }, + 'tokenAddress': '0xRealu', + 'amountWei': '5', + 'recipient': '0xRecipient', + }, +}); + +void main() { + late _MockMigrateBitboxCubit migrateCubit; + late _MockTransferService transferService; + late _MockAppStore appStore; + late _MockSoftwareWallet wallet; + + setUpAll(() { + registerFallbackValue(const RealUnitTransferDto(toAddress: '0x', amount: 1)); + registerFallbackValue(_info()); + }); + + setUp(() { + migrateCubit = _MockMigrateBitboxCubit(); + transferService = _MockTransferService(); + appStore = _MockAppStore(); + wallet = _MockSoftwareWallet(); + when(() => migrateCubit.state).thenReturn( + const MigrateBitboxTransferring(toAddress: '0xRecipient', amount: 5), + ); + whenListen( + migrateCubit, + const Stream.empty(), + initialState: const MigrateBitboxTransferring( + toAddress: '0xRecipient', + amount: 5, + ), + ); + when(() => migrateCubit.startTransfer()).thenReturn(null); + when(() => migrateCubit.finishMigration()).thenAnswer((_) async {}); + when( + () => migrateCubit.onTransferFailedTerminally(any()), + ).thenReturn(null); + when(() => wallet.walletType).thenReturn(WalletType.software); + when(() => appStore.wallet).thenReturn(wallet); + GetIt.instance.registerSingleton(transferService); + GetIt.instance.registerSingleton(appStore); + }); + + tearDown(() async { + await GetIt.instance.unregister(); + await GetIt.instance.unregister(); + }); + + Future pumpReady( + WidgetTester tester, { + String from = '0x1234567890abcdef', + String to = '0xabcdef1234567890', + }) => tester.pumpApp( + BlocProvider.value( + value: migrateCubit, + child: MigrateTransferReadyView( + fromAddress: from, + toAddress: to, + amount: 5, + ), + ), + ); + + Future pumpTransferring(WidgetTester tester) async { + await tester.pumpApp( + BlocProvider.value( + value: migrateCubit, + child: const MigrateTransferringView( + toAddress: '0xRecipient', + amount: 5, + ), + ), + ); + await tester.pump(); + await tester.pump(); + await tester.pump(); + } + + testWidgets('TransferReady truncates long addresses and starts transfer', ( + tester, + ) async { + await pumpReady(tester); + + expect(find.text('0x1234…cdef'), findsOneWidget); + expect(find.text('0xabcd…7890'), findsOneWidget); + expect(find.text('5 REALU'), findsOneWidget); + await tester.tap(find.byType(AppFilledButton)); + + verify(() => migrateCubit.startTransfer()).called(1); + }); + + testWidgets('TransferReady keeps short addresses unchanged', (tester) async { + await pumpReady(tester, from: '0x1234', to: '0xabcd'); + + expect(find.text('0x1234'), findsOneWidget); + expect(find.text('0xabcd'), findsOneWidget); + }); + + testWidgets('embedded process renders preparing, signing, then finishes on success', ( + tester, + ) async { + final prepare = Completer(); + final confirm = Completer(); + when(() => transferService.prepareTransfer(any())).thenAnswer((_) => prepare.future); + when( + () => transferService.confirmTransfer( + any(), + confirmedRecipient: any(named: 'confirmedRecipient'), + confirmedAmount: any(named: 'confirmedAmount'), + ), + ).thenAnswer((_) => confirm.future); + + await pumpTransferring(tester); + expect(find.text(S.current.sendPreparing), findsOneWidget); + expect(find.byType(CupertinoActivityIndicator), findsOneWidget); + + prepare.complete(_info()); + await tester.pump(); + await tester.pump(); + expect(find.text(S.current.sendSigning), findsOneWidget); + + confirm.complete('0xtx'); + await tester.pump(); + await tester.pump(); + + verify(() => migrateCubit.finishMigration()).called(1); + }); + + testWidgets('retryable failure CTA reconfirms the retained transfer intent', ( + tester, + ) async { + when(() => transferService.prepareTransfer(any())).thenAnswer((_) async => _info()); + var confirms = 0; + when( + () => transferService.confirmTransfer( + any(), + confirmedRecipient: any(named: 'confirmedRecipient'), + confirmedAmount: any(named: 'confirmedAmount'), + ), + ).thenAnswer((_) async { + confirms++; + if (confirms == 1) throw Exception('transport lost'); + return '0xretry'; + }); + + await pumpTransferring(tester); + expect(find.text(S.current.retry), findsOneWidget); + + await tester.tap(find.byType(AppFilledButton)); + await tester.pump(); + await tester.pump(); + + verify( + () => transferService.confirmTransfer( + any(), + confirmedRecipient: '0xRecipient', + confirmedAmount: 5, + ), + ).called(2); + verify(() => migrateCubit.finishMigration()).called(1); + }); + + final terminalCases = <(String, Exception, String)>[ + ( + 'signature unsupported', + const TransferSignatureUnsupportedException(), + 'signatureUnsupported', + ), + ( + 'signature cancelled', + const SigningCancelledException(), + 'signatureCancelled', + ), + ( + 'gas unavailable', + const TransferGasFundingUnavailableException(), + 'gasFundingUnavailable', + ), + ( + 'invalid request', + const ApiException(statusCode: 400, code: 'BAD', message: 'bad'), + 'invalidRequest', + ), + ( + 'registration required', + const RegistrationRequiredException( + code: 'REGISTRATION_REQUIRED', + message: 'register', + ), + 'registrationOrKycRequired', + ), + ( + 'bitbox disconnected', + const BitboxNotConnectedException(), + 'signatureUnsupported', + ), + ('generic', Exception('boom'), 'generic'), + ]; + + for (final (label, error, reasonName) in terminalCases) { + testWidgets('$label automatically exits the embedded dead end', (tester) async { + when( + () => transferService.prepareTransfer(any()), + ).thenAnswer((_) async => throw error); + + await pumpTransferring(tester); + + final expectedMessage = switch (reasonName) { + 'signatureUnsupported' => S.current.sendFailureSignatureUnsupported, + 'signatureCancelled' => S.current.sendFailureSignatureCancelled, + 'gasFundingUnavailable' => S.current.sendFailureGasUnavailable, + 'invalidRequest' => S.current.sendFailureInvalidRequest, + 'registrationOrKycRequired' => S.current.sendFailureRegistrationOrKycRequired, + _ => S.current.sendFailureGeneric, + }; + expect(find.text(expectedMessage), findsOneWidget); + verify( + () => migrateCubit.onTransferFailedTerminally(expectedMessage), + ).called(1); + }); + } + + testWidgets('confirm mismatch resolves its localized terminal message', ( + tester, + ) async { + when(() => transferService.prepareTransfer(any())).thenAnswer((_) async => _info()); + when( + () => transferService.confirmTransfer( + any(), + confirmedRecipient: any(named: 'confirmedRecipient'), + confirmedAmount: any(named: 'confirmedAmount'), + ), + ).thenThrow(const TransferConfirmMismatchException('mismatch')); + + await pumpTransferring(tester); + + expect(find.text(S.current.sendFailureConfirmMismatch), findsOneWidget); + verify( + () => migrateCubit.onTransferFailedTerminally( + S.current.sendFailureConfirmMismatch, + ), + ).called(1); + }); +} diff --git a/test/screens/settings/settings_page_test.dart b/test/screens/settings/settings_page_test.dart new file mode 100644 index 000000000..188e4f222 --- /dev/null +++ b/test/screens/settings/settings_page_test.dart @@ -0,0 +1,135 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; +import 'package:realunit_wallet/screens/settings/bloc/settings_bloc.dart'; +import 'package:realunit_wallet/screens/settings/settings_page.dart'; +import 'package:realunit_wallet/setup/routing/routes/migration_routes.dart'; + +class MockHomeBloc extends MockBloc implements HomeBloc {} + +class MockSettingsBloc extends MockBloc + implements SettingsBloc {} + +class _MockSoftwareWallet extends Mock implements SoftwareWallet {} + +class _MockBitboxWallet extends Mock implements BitboxWallet {} + +void main() { + late MockHomeBloc homeBloc; + late MockSettingsBloc settingsBloc; + + setUp(() { + homeBloc = MockHomeBloc(); + settingsBloc = MockSettingsBloc(); + when(() => settingsBloc.state).thenReturn(const SettingsState()); + whenListen( + settingsBloc, + const Stream.empty(), + initialState: const SettingsState(), + ); + GetIt.instance.registerSingleton(settingsBloc); + }); + + tearDown(() async => GetIt.instance.reset()); + + Future> pumpSettings(WidgetTester tester, AWallet wallet) async { + when(() => homeBloc.state).thenReturn(HomeState(openWallet: wallet)); + whenListen( + homeBloc, + const Stream.empty(), + initialState: HomeState(openWallet: wallet), + ); + final pushedRoutes = []; + final router = GoRouter( + initialLocation: '/settings', + routes: [ + GoRoute( + path: '/settings', + builder: (_, _) => BlocProvider.value( + value: homeBloc, + child: const SettingsPage(), + ), + ), + GoRoute( + name: MigrationRoutes.migrateBitbox, + path: '/migrate-bitbox', + builder: (_, _) { + pushedRoutes.add(MigrationRoutes.migrateBitbox); + return const Scaffold(body: Text('migration-destination')); + }, + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router( + routerConfig: router, + localizationsDelegates: const [ + S.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + ), + ); + await tester.pumpAndSettle(); + return pushedRoutes; + } + + testWidgets('migration tile is visible for a software wallet', ( + tester, + ) async { + final wallet = _MockSoftwareWallet(); + when(() => wallet.walletType).thenReturn(WalletType.software); + + await pumpSettings(tester, wallet); + + expect(find.text(S.current.migrateBitbox), findsOneWidget); + }); + + testWidgets('migration tile is hidden for a BitBox wallet', (tester) async { + final wallet = _MockBitboxWallet(); + when(() => wallet.walletType).thenReturn(WalletType.bitbox); + + await pumpSettings(tester, wallet); + + expect(find.text(S.current.migrateBitbox), findsNothing); + }); + + testWidgets('migration tile is hidden for a debug wallet', (tester) async { + final wallet = DebugWallet( + 7, + 'Debug', + '0x0000000000000000000000000000000000000001', + ); + + await pumpSettings(tester, wallet); + + expect(find.text(S.current.migrateBitbox), findsNothing); + }); + + testWidgets('tapping the software-wallet tile pushes the named migration route', ( + tester, + ) async { + final wallet = _MockSoftwareWallet(); + when(() => wallet.walletType).thenReturn(WalletType.software); + final pushedRoutes = await pumpSettings(tester, wallet); + final tile = find.text(S.current.migrateBitbox); + await tester.ensureVisible(tile); + + await tester.tap(tile); + await tester.pumpAndSettle(); + + expect(pushedRoutes, [MigrationRoutes.migrateBitbox]); + expect(find.text('migration-destination'), findsOneWidget); + }); +} From 69a465bd2df88fb87376f3275d2ed96983d310fc Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:12:44 +0200 Subject: [PATCH 04/26] test(migration): drop invalid type arguments on registerFallbackValue mocktail's registerFallbackValue takes no type parameters; the explicit arguments were an analyzer error (wrong_number_of_type_arguments_method). --- .../cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart | 4 ++-- test/screens/migrate_bitbox/migrate_bitbox_page_test.dart | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart b/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart index 51a59bb12..941c92723 100644 --- a/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart +++ b/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart @@ -107,8 +107,8 @@ void main() { ); setUpAll(() { - registerFallbackValue(_MockBitboxWallet()); - registerFallbackValue(_MockBitboxWalletAccount()); + registerFallbackValue(_MockBitboxWallet()); + registerFallbackValue(_MockBitboxWalletAccount()); registerFallbackValue(_userData); registerFallbackValue(realUnitAsset); }); diff --git a/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart b/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart index ccceca20c..a34cb428d 100644 --- a/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart +++ b/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart @@ -103,8 +103,8 @@ void main() { setUpAll(() { registerFallbackValue(fixtureBalance()); - registerFallbackValue(_MockBitboxWallet()); - registerFallbackValue(const LoadCurrentWalletEvent()); + registerFallbackValue(_MockBitboxWallet()); + registerFallbackValue(const LoadCurrentWalletEvent()); walletService = _MockWalletService(); final authService = _MockDfxKycService(); From 9c12275accab7372e26251ce404b80190202e737 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:16:15 +0200 Subject: [PATCH 05/26] test(migration): fix async stream assertion and pop context in sheet test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cancelPairing test asserted before the stream listener microtask ran; the sheet test tapped the ConnectBitboxView cancel button, which pops via go_router, without a GoRouter in the tree — mount the manager on a single-entry GoRouter stack (same pattern as connect_bitbox_view_test). --- .../migrate_bitbox_cubit_test.dart | 3 ++ .../migrate_bitbox_page_test.dart | 33 +++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart b/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart index 941c92723..8a49fc32a 100644 --- a/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart +++ b/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart @@ -219,6 +219,9 @@ void main() { await cubit.startPairing(); cubit.cancelPairing(); + // Stream listeners are serviced on the microtask queue — flush it so + // both emissions have reached the collector before asserting. + await Future.delayed(Duration.zero); expect(emissions, [const MigrateBitboxAwaitingDevice(), const MigrateBitboxIntro()]); }); diff --git a/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart b/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart index a34cb428d..4c187f6d5 100644 --- a/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart +++ b/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart @@ -2,8 +2,10 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; +import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/models/balance.dart'; @@ -285,13 +287,32 @@ void main() { ), initialState: const MigrateBitboxIntro(), ); - await tester.pumpApp( - BlocProvider.value( - value: homeBloc, - child: BlocProvider.value( - value: cubit, - child: const MigrateBitboxViewManager(), + // The sheet's ConnectBitboxView cancel button pops via go_router, so the + // manager must sit on a real (single-entry) GoRouter stack — mirrors + // connect_bitbox_view_test.dart's pumpViewOnSingleEntryStack. + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (_, _) => BlocProvider.value( + value: homeBloc, + child: BlocProvider.value( + value: cubit, + child: const MigrateBitboxViewManager(), + ), + ), ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + MaterialApp.router( + localizationsDelegates: [ + S.delegate, + GlobalMaterialLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + routerConfig: router, ), ); await tester.pump(); From 5ec792df61d205238c69d6ed28b8c50b6a4afca3 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:13:20 +0200 Subject: [PATCH 06/26] fix(migration): review round 1 service hardenings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BalanceService.fetchBalance: fresh fail-loud read for money-moving flows — throws on transport/non-200/parse instead of serving the stale cache the polling path may hold - session auth-token slot is now address-bound and getAuthToken discards a late auth response when the active wallet changed mid-flight, closing the race where a stale software refresh could overwrite the migrated BitBox JWT - signature cache is scoped to the exact signed message, so legacy testnet signatures invalidated by the environment-prefixed sign message fall back to a fresh sign instead of looping on rejected auth - @no-integration-test annotations on the BitBox address acquisition paths - import order fix in the registration service test --- lib/packages/service/balance_service.dart | 58 ++++-- lib/packages/service/debug_auth_service.dart | 20 +- .../service/dfx/dfx_auth_service.dart | 47 +++-- lib/packages/service/session_cache.dart | 34 +++- lib/packages/service/wallet_service.dart | 6 + .../migrate_bitbox/migrate_bitbox_cubit.dart | 5 +- .../link_wallet_connect_flow_test.dart | 3 +- test/integration/sell_bitbox_flow_test.dart | 5 +- .../service/balance_service_test.dart | 69 ++++++- .../service/debug_auth_service_test.dart | 26 ++- .../service/dfx/dfx_auth_service_test.dart | 191 ++++++++++++++++-- .../dfx/dfx_bank_account_service_test.dart | 16 +- .../dfx/dfx_blockchain_api_service_test.dart | 5 + .../service/dfx/dfx_faucet_service_test.dart | 21 +- .../dfx/dfx_kyc_service_rest_test.dart | 5 +- .../service/dfx/dfx_kyc_service_test.dart | 5 +- .../service/dfx/dfx_support_service_test.dart | 11 +- .../service/dfx/dfx_widget_service_test.dart | 10 +- .../dfx/real_unit_legal_service_test.dart | 8 +- .../dfx/real_unit_pay_service_test.dart | 5 +- .../dfx/real_unit_pdf_service_test.dart | 10 +- ..._unit_registration_service_happy_test.dart | 3 +- .../real_unit_registration_service_test.dart | 16 +- ...ell_payment_info_service_confirm_test.dart | 2 +- ...l_unit_sell_payment_info_service_test.dart | 5 +- ..._payment_info_service_validation_test.dart | 5 +- .../dfx/real_unit_transfer_service_test.dart | 2 +- test/packages/service/session_cache_test.dart | 42 +++- .../transaction_history_service_test.dart | 30 ++- .../migrate_bitbox_cubit_test.dart | 6 +- 30 files changed, 568 insertions(+), 103 deletions(-) diff --git a/lib/packages/service/balance_service.dart b/lib/packages/service/balance_service.dart index becbf6f2c..d9b0ccf57 100644 --- a/lib/packages/service/balance_service.dart +++ b/lib/packages/service/balance_service.dart @@ -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; } @@ -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 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 _parseAndPersistBalance(String address, String responseBody) async { + final json = jsonDecode(responseBody) as Map; + 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 getBalance(Asset asset, String address) => _balanceRepository.getBalance(asset, address); } diff --git a/lib/packages/service/debug_auth_service.dart b/lib/packages/service/debug_auth_service.dart index ed8c17ed1..d337bd94c 100644 --- a/lib/packages/service/debug_auth_service.dart +++ b/lib/packages/service/debug_auth_service.dart @@ -11,6 +11,8 @@ const _signatureKey = 'debugAuthSignature'; class DebugAuthService { final AppStore _appStore; final SharedPreferences _prefs; + String? _fetchedSignMessage; + String? _fetchedSignMessageAddress; DebugAuthService(this._appStore, this._prefs); @@ -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 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, @@ -51,8 +61,12 @@ class DebugAuthService { if (response.statusCode == 201) { final body = jsonDecode(response.body) as Map; 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 { diff --git a/lib/packages/service/dfx/dfx_auth_service.dart b/lib/packages/service/dfx/dfx_auth_service.dart index 86f78836c..727e685ac 100644 --- a/lib/packages/service/dfx/dfx_auth_service.dart +++ b/lib/packages/service/dfx/dfx_auth_service.dart @@ -98,17 +98,21 @@ abstract class DFXAuthService { await appStore.sessionCache.loadSignature(); final cachedSignature = appStore.sessionCache.signature; final signatureAddress = appStore.sessionCache.signatureAddress; + final message = buildSignMessage(address); late final String signature; - if (cachedSignature != null && signatureAddress == address) { + // Legacy entries without a message scope intentionally miss here: the + // environment-prefixed sign-message change made those signatures unsafe + // to reuse across dev and production. + if (cachedSignature != null && + signatureAddress == address && + appStore.sessionCache.signedMessage == message) { signature = cachedSignature; } else { - signature = await account - .signMessage(buildSignMessage(address)) - .timeout(_signMessageTimeout); + signature = await account.signMessage(message).timeout(_signMessageTimeout); if (signature.isEmpty || signature == '0x') { throw const SigningCancelledException(); } - await appStore.sessionCache.saveSignature(address, signature); + await appStore.sessionCache.saveSignature(address, signature, message); } final requestBody = jsonEncode({ @@ -150,18 +154,19 @@ abstract class DFXAuthService { /// No-op if a signature for this address is already in the cache. Future ensureSignatureFor(AWalletAccount account) async { final address = account.primaryAddress.address.hexEip55; + final message = buildSignMessage(address); await appStore.sessionCache.loadSignature(); if (appStore.sessionCache.signature != null && - appStore.sessionCache.signatureAddress == address) { + appStore.sessionCache.signatureAddress == address && + appStore.sessionCache.signedMessage == message) { return; } - final message = buildSignMessage(address); final signature = await account.signMessage(message).timeout(_signMessageTimeout); if (signature.isEmpty || signature == '0x') { throw const SigningCancelledException(); } - await appStore.sessionCache.saveSignature(address, signature); + await appStore.sessionCache.saveSignature(address, signature, message); } // Exceptions this method can throw on the BitBox path: @@ -173,7 +178,9 @@ abstract class DFXAuthService { Future getSignature(String message) async { final cached = appStore.sessionCache.signature; final cachedAddress = appStore.sessionCache.signatureAddress; - if (cached != null && cachedAddress == walletAddress) { + if (cached != null && + cachedAddress == walletAddress && + appStore.sessionCache.signedMessage == message) { return cached; } @@ -189,7 +196,7 @@ abstract class DFXAuthService { if (signature.isEmpty || signature == '0x') { throw const SigningCancelledException(); } - await appStore.sessionCache.saveSignature(walletAddress, signature); + await appStore.sessionCache.saveSignature(walletAddress, signature, message); return signature; } finally { await walletService.lockCurrentWallet(); @@ -234,12 +241,26 @@ abstract class DFXAuthService { // empty-signature guard in `getSignature` covers the cancel/disconnect // case gracefully, and the SDK no longer panics on NACK. Future getAuthToken() async { - if (appStore.sessionCache.authToken == null) { + while (true) { + final addressBeforeAuth = walletAddress; + final cachedToken = appStore.sessionCache.authToken; + if (cachedToken != null && + appStore.sessionCache.authTokenAddress == addressBeforeAuth) { + return cachedToken; + } + await appStore.sessionCache.loadSignature(); final response = await getAuthResponse(); - appStore.sessionCache.setAuthToken(response['accessToken'] as String); + + // Close the late-commit race when the active wallet identity changes + // while /v1/auth is in flight. Discard the old identity's response and + // retry against the now-current wallet context. + if (walletAddress != addressBeforeAuth) continue; + + final token = response['accessToken'] as String; + appStore.sessionCache.setAuthToken(token, addressBeforeAuth); + return token; } - return appStore.sessionCache.authToken; } void invalidateAuthToken() => appStore.sessionCache.clearAuthToken(); diff --git a/lib/packages/service/session_cache.dart b/lib/packages/service/session_cache.dart index 8562369c8..2e8f6240b 100644 --- a/lib/packages/service/session_cache.dart +++ b/lib/packages/service/session_cache.dart @@ -3,40 +3,66 @@ import 'package:realunit_wallet/packages/repository/cache_repository.dart'; class SessionCache { static const _signatureKey = 'cached_signature'; static const _signatureAddressKey = 'cached_signature_address'; + static const _signatureMessageKey = 'cached_signature_message'; final CacheRepository _cacheRepository; SessionCache(CacheRepository cacheRepository) : _cacheRepository = cacheRepository; String? _authToken; + String? _authTokenAddress; String? _signature; String? _signatureAddress; + String? _signedMessage; String? get authToken => _authToken; + String? get authTokenAddress => _authTokenAddress; String? get signature => _signature; String? get signatureAddress => _signatureAddress; + String? get signedMessage => _signedMessage; - void setAuthToken(String token) => _authToken = token; + void setAuthToken(String token, String address) { + _authToken = token; + _authTokenAddress = address; + } - void clearAuthToken() => _authToken = null; + void clearAuthToken() { + _authToken = null; + _authTokenAddress = null; + } - Future saveSignature(String address, String signature) async { + Future saveSignature( + String address, + String signature, [ + String? signedMessage, + ]) async { + final message = signedMessage ?? + (_signatureAddress == address && _signature == signature ? _signedMessage : null); _signature = signature; _signatureAddress = address; + _signedMessage = message; await _cacheRepository.write(_signatureKey, signature); await _cacheRepository.write(_signatureAddressKey, address); + if (message != null) { + await _cacheRepository.write(_signatureMessageKey, message); + } else { + await _cacheRepository.delete(_signatureMessageKey); + } } Future loadSignature() async { _signature ??= await _cacheRepository.read(_signatureKey); _signatureAddress ??= await _cacheRepository.read(_signatureAddressKey); + _signedMessage ??= await _cacheRepository.read(_signatureMessageKey); } Future clear() async { _signature = null; _signatureAddress = null; - _authToken = null; + _signedMessage = null; + clearAuthToken(); await _cacheRepository.delete(_signatureKey); await _cacheRepository.delete(_signatureAddressKey); + await _cacheRepository.delete(_signatureMessageKey); } } diff --git a/lib/packages/service/wallet_service.dart b/lib/packages/service/wallet_service.dart index 7d787f919..316b3f9fc 100644 --- a/lib/packages/service/wallet_service.dart +++ b/lib/packages/service/wallet_service.dart @@ -86,6 +86,9 @@ class WalletService { return commitGeneratedWallet(draft); } + // @no-integration-test: no BitBox firmware/transport harness is wired into + // the repo yet; the BitboxService.getEthAddress boundary is covered by unit + // tests with mocked transport (see wallet_service_test.dart). Future createBitboxWallet(String name) async { // [BitboxService.getEthAddress] already retries the transient empty read // the SDK produces when it coerces a native `null` into `""` (its @@ -110,6 +113,9 @@ class WalletService { /// aborted wizard leaves no orphan wallet row behind. /// Throws [BitboxAddressUnavailableException] on an unusable address (same /// guard as [createBitboxWallet]). + // @no-integration-test: no BitBox firmware/transport harness is wired into + // the repo yet; the BitboxService.getEthAddress boundary is covered by unit + // tests with mocked transport (see wallet_service_test.dart). Future acquireUncommittedBitboxWallet(String name) async { final address = await _bitboxService.getEthAddress(); if (!_isValidEthAddress(address)) { diff --git a/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart b/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart index 980fef4f8..1404ccc41 100644 --- a/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart +++ b/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart @@ -286,7 +286,10 @@ class MigrateBitboxCubit extends Cubit { } // After setCurrentWallet, before the view's HomeBloc reload, so any sync // triggered by the reload is already authenticated as the new wallet. - _appStore.sessionCache.setAuthToken(_newJwt!); + _appStore.sessionCache.setAuthToken( + _newJwt!, + persisted.currentAccount.primaryAddress.address.hexEip55, + ); _pendingRetry = null; emit(MigrateBitboxSuccess(persisted)); } diff --git a/test/integration/link_wallet_connect_flow_test.dart b/test/integration/link_wallet_connect_flow_test.dart index dc0e0cf0b..d6b9d5779 100644 --- a/test/integration/link_wallet_connect_flow_test.dart +++ b/test/integration/link_wallet_connect_flow_test.dart @@ -85,14 +85,15 @@ void main() { account = _MockAccount(); walletService = _MockWalletService(); session = SessionCache(_MockCacheRepository()); - session.setAuthToken('jwt-1'); // signDelay zero keeps the ceremony synchronous-ish for tight assertions. credentials = FakeBitboxCredentials(signDelay: Duration.zero); + session.setAuthToken('jwt-1', credentials.address.hexEip55); when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); when(() => appStore.sessionCache).thenReturn(session); when(() => appStore.wallet).thenReturn(wallet); when(() => wallet.primaryAccount).thenReturn(account); + when(() => wallet.currentAccount).thenReturn(account); when(() => account.primaryAddress).thenReturn(credentials); when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); diff --git a/test/integration/sell_bitbox_flow_test.dart b/test/integration/sell_bitbox_flow_test.dart index c721f8832..b4f82a077 100644 --- a/test/integration/sell_bitbox_flow_test.dart +++ b/test/integration/sell_bitbox_flow_test.dart @@ -131,10 +131,9 @@ void main() { account = _MockWalletAccount(); walletService = _MockWalletService(); session = SessionCache(_MockCacheRepository()); - // Pre-seed an auth token so the service skips its sign-message round-trip. - session.setAuthToken('jwt-test'); - creds = FakeBitboxCredentials(signDelay: Duration.zero); + // Pre-seed an auth token so the service skips its sign-message round-trip. + session.setAuthToken('jwt-test', creds.address.hexEip55); when(() => appStore.wallet).thenReturn(wallet); when( diff --git a/test/packages/service/balance_service_test.dart b/test/packages/service/balance_service_test.dart index 13b9345db..b30abf666 100644 --- a/test/packages/service/balance_service_test.dart +++ b/test/packages/service/balance_service_test.dart @@ -64,7 +64,7 @@ void main() { AppStore buildAppStore(Future Function(http.Request) handler) { final client = MockClient(handler); return TestAppStore(client, () => apiConfig, MockCacheRepository()) - ..sessionCache.setAuthToken('test-auth-token'); + ..sessionCache.setAuthToken('test-auth-token', '0xTestAddress'); } group('$BalanceService', () { @@ -155,6 +155,73 @@ void main() { verifyNever(() => balanceRepository.saveBalance(any())); }); + + group('fetchBalance', () { + test('returns and persists a fresh balance on 200', () async { + Balance? savedBalance; + when(() => balanceRepository.saveBalance(any())).thenAnswer((invocation) async { + savedBalance = invocation.positionalArguments.single as Balance; + }); + final appStore = buildAppStore( + (_) async => http.Response(jsonEncode({'balance': '98765'}), 200), + ); + final service = BalanceService(balanceRepository, appStore); + + final balance = await service.fetchBalance('0xFresh'); + + expect(balance.balance, BigInt.from(98765)); + expect(balance.walletAddress, '0xFresh'); + expect(balance.asset, realUnitAsset); + expect(savedBalance, same(balance)); + }); + + for (final statusCode in const [404, 500]) { + test('throws on HTTP $statusCode without persisting', () async { + final appStore = buildAppStore( + (_) async => http.Response('error', statusCode), + ); + final service = BalanceService(balanceRepository, appStore); + + await expectLater( + service.fetchBalance('0xFresh'), + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('$statusCode'), + ), + ), + ); + verifyNever(() => balanceRepository.saveBalance(any())); + }); + } + + test('rethrows a transport failure without persisting', () async { + final appStore = buildAppStore( + (_) async => throw const http.ClientException('offline'), + ); + final service = BalanceService(balanceRepository, appStore); + + await expectLater( + service.fetchBalance('0xFresh'), + throwsA(isA()), + ); + verifyNever(() => balanceRepository.saveBalance(any())); + }); + + test('throws on an unparseable balance body without persisting', () async { + final appStore = buildAppStore( + (_) async => http.Response(jsonEncode({'balance': 'NaN'}), 200), + ); + final service = BalanceService(balanceRepository, appStore); + + await expectLater( + service.fetchBalance('0xFresh'), + throwsA(isA()), + ); + verifyNever(() => balanceRepository.saveBalance(any())); + }); + }); }); test('getBalance delegates to BalanceRepository.getBalance', () async { diff --git a/test/packages/service/debug_auth_service_test.dart b/test/packages/service/debug_auth_service_test.dart index 55ca4986f..ae43c3368 100644 --- a/test/packages/service/debug_auth_service_test.dart +++ b/test/packages/service/debug_auth_service_test.dart @@ -100,6 +100,9 @@ void main() { Uri? sentUri; Map? body; final client = MockClient((request) async { + if (request.url.path.endsWith('/signMessage')) { + return http.Response(jsonEncode({'message': 'Sign me exactly'}), 200); + } sentUri = request.url; body = jsonDecode(request.body) as Map; return http.Response( @@ -113,8 +116,10 @@ void main() { const addressLower = '0x9f5713deacb8e9cab6c2d3fae1afc2715f8d2d71'; const checksum = '0x9F5713DEacB8e9CAB6c2d3FaE1AFc2715F8D2D71'; const signature = '0xdeadbeef'; + const signMessage = 'Sign me exactly'; final svc = await build(client); + await svc.fetchSignMessage(addressLower); await svc.authenticate(addressLower, signature); expect(sentUri!.path, '/v1/auth'); @@ -124,22 +129,39 @@ void main() { // Auth token lands in the session cache. expect(session.authToken, 'jwt-OK'); + expect(session.authTokenAddress, checksum); // Signature lands under the EIP-55 checksum address. expect(session.signatureAddress, checksum); + expect(session.signedMessage, signMessage); // The raw address + signature persist to SharedPreferences. expect(svc.savedAddress, addressLower); expect(svc.savedSignature, signature); }); test('non-201 → throws Exception with the status code', () async { - final client = MockClient((_) async => http.Response('boom', 500)); + final client = MockClient( + (request) async => request.url.path.endsWith('/signMessage') + ? http.Response(jsonEncode({'message': 'Sign me'}), 200) + : http.Response('boom', 500), + ); + final service = await build(client); + await service.fetchSignMessage('0xabc'); expect( - () async => (await build(client)).authenticate('0xabc', '0xsig'), + () async => service.authenticate('0xabc', '0xsig'), throwsA( predicate((e) => e.toString().contains('500')), ), ); }); + + test('requires a sign message fetched for the same address', () async { + final service = await build(MockClient((_) async => http.Response('{}', 500))); + + await expectLater( + service.authenticate('0xabc', '0xsig'), + throwsA(isA()), + ); + }); }); } diff --git a/test/packages/service/dfx/dfx_auth_service_test.dart b/test/packages/service/dfx/dfx_auth_service_test.dart index 1cb159921..a0e66c2eb 100644 --- a/test/packages/service/dfx/dfx_auth_service_test.dart +++ b/test/packages/service/dfx/dfx_auth_service_test.dart @@ -135,6 +135,26 @@ class _SignatureTestAuthService extends DFXAuthService { String get walletAddress => _address; } +class _LateCommitAuthService extends DFXAuthService { + _LateCommitAuthService(super.appStore, super.walletService, this.currentAddress); + + String currentAddress; + final authResponses = >>[]; + + @override + AWalletAccount get wallet => throw UnimplementedError(); + + @override + String get walletAddress => currentAddress; + + @override + Future> getAuthResponse([bool sendWalletName = true]) { + final completer = Completer>(); + authResponses.add(completer); + return completer.future; + } +} + // --------------------------------------------------------------------------- // Helpers — authenticated request retry-on-401 surface. // --------------------------------------------------------------------------- @@ -200,12 +220,17 @@ void main() { walletService = _MockWalletService(); when(() => appStore.sessionCache).thenReturn(sessionCache); + when(() => appStore.apiConfig).thenReturn( + const ApiConfig(networkMode: NetworkMode.mainnet), + ); when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); when(() => sessionCache.signature).thenReturn(null); when(() => sessionCache.signatureAddress).thenReturn(null); + when(() => sessionCache.signedMessage).thenReturn(null); when(() => sessionCache.authToken).thenReturn(null); - when(() => sessionCache.saveSignature(any(), any())).thenAnswer((_) async {}); + when(() => sessionCache.authTokenAddress).thenReturn(null); + when(() => sessionCache.saveSignature(any(), any(), any())).thenAnswer((_) async {}); }); _SignatureTestAuthService buildService() => @@ -215,12 +240,13 @@ void main() { test('returns the cached signature when address matches (no re-sign)', () async { when(() => sessionCache.signature).thenReturn(validSig); when(() => sessionCache.signatureAddress).thenReturn(address); + when(() => sessionCache.signedMessage).thenReturn('msg'); final result = await buildService().getSignature('msg'); expect(result, validSig); expect(walletAccount.signCallCount, 0); - verifyNever(() => sessionCache.saveSignature(any(), any())); + verifyNever(() => sessionCache.saveSignature(any(), any(), any())); }); test('signs and caches when no cached signature exists', () async { @@ -228,7 +254,7 @@ void main() { expect(result, validSig); expect(walletAccount.signCallCount, 1); - verify(() => sessionCache.saveSignature(address, validSig)).called(1); + verify(() => sessionCache.saveSignature(address, validSig, 'msg')).called(1); }); test('signs again when the cached signature belongs to a different address', () async { @@ -243,6 +269,39 @@ void main() { expect(walletAccount.signCallCount, 1); }); + test('testnet legacy cache without the exact message scope re-signs', () async { + when(() => appStore.apiConfig).thenReturn( + const ApiConfig(networkMode: NetworkMode.testnet), + ); + when(() => sessionCache.signature).thenReturn(validSig); + when(() => sessionCache.signatureAddress).thenReturn(address); + when(() => sessionCache.signedMessage).thenReturn(null); + final service = buildService(); + final message = service.buildSignMessage(address); + + final result = await service.getSignature(message); + + expect(result, validSig); + expect(walletAccount.signCallCount, 1); + verify(() => sessionCache.saveSignature(address, validSig, message)).called(1); + }); + + test('testnet cache with a different scoped message re-signs', () async { + when(() => appStore.apiConfig).thenReturn( + const ApiConfig(networkMode: NetworkMode.testnet), + ); + when(() => sessionCache.signature).thenReturn(validSig); + when(() => sessionCache.signatureAddress).thenReturn(address); + when(() => sessionCache.signedMessage).thenReturn('mainnet-message'); + final service = buildService(); + final message = service.buildSignMessage(address); + + await service.getSignature(message); + + expect(walletAccount.signCallCount, 1); + verify(() => sessionCache.saveSignature(address, validSig, message)).called(1); + }); + for (final emptySignature in const ['', '0x']) { test( 'throws SigningCancelledException when the wallet returns "$emptySignature"', @@ -501,7 +560,8 @@ void main() { when(() => sessionCache.loadSignature()).thenAnswer((_) async {}); when(() => sessionCache.signature).thenReturn(null); when(() => sessionCache.signatureAddress).thenReturn(null); - when(() => sessionCache.saveSignature(any(), any())).thenAnswer((_) async {}); + when(() => sessionCache.signedMessage).thenReturn(null); + when(() => sessionCache.saveSignature(any(), any(), any())).thenAnswer((_) async {}); when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); }); @@ -519,12 +579,16 @@ void main() { test('short-circuits when the cache already holds the address signature', () async { when(() => sessionCache.signature).thenReturn(stubSignature); when(() => sessionCache.signatureAddress).thenReturn(accountAddressEip55); + final service = buildService(); + when(() => sessionCache.signedMessage).thenReturn( + service.buildSignMessage(accountAddressEip55), + ); - await buildService().ensureSignatureFor(account); + await service.ensureSignatureFor(account); // No sign ceremony, no save — the cached entry already matches. expect(account.signCallCount, 0); - verifyNever(() => sessionCache.saveSignature(any(), any())); + verifyNever(() => sessionCache.saveSignature(any(), any(), any())); }); test('builds the sign message locally, signs, and persists when the cache is cold', () async { @@ -535,14 +599,21 @@ void main() { }); when(() => appStore.httpClient).thenReturn(client); - await buildService().ensureSignatureFor(account); + final service = buildService(); + await service.ensureSignatureFor(account); // No /v1/auth/signMessage round-trip — the message is derived locally // from the account address (EIP-55 checksummed), the BitBox-pairing // entry point. expect(httpCalled, isFalse); expect(account.signCallCount, 1); - verify(() => sessionCache.saveSignature(accountAddressEip55, stubSignature)).called(1); + verify( + () => sessionCache.saveSignature( + accountAddressEip55, + stubSignature, + service.buildSignMessage(accountAddressEip55), + ), + ).called(1); }); test('signs again when the cached entry belongs to a different address', () async { @@ -556,10 +627,17 @@ void main() { ), ); - await buildService().ensureSignatureFor(account); + final service = buildService(); + await service.ensureSignatureFor(account); expect(account.signCallCount, 1); - verify(() => sessionCache.saveSignature(accountAddressEip55, stubSignature)).called(1); + verify( + () => sessionCache.saveSignature( + accountAddressEip55, + stubSignature, + service.buildSignMessage(accountAddressEip55), + ), + ).called(1); }); for (final empty in const ['', '0x']) { @@ -803,19 +881,61 @@ void main() { expect(second, 'jwt-1'); expect(authCalls, 1); expect(sessionCache.authToken, 'jwt-1'); + expect(sessionCache.authTokenAddress, walletAddress); }, ); + test('getAuthToken treats a token for another address as a cache miss', () async { + sessionCache.setAuthToken( + 'jwt-for-old-wallet', + '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + ); + var authCalls = 0; + final client = MockClient((_) async { + authCalls++; + return http.Response(jsonEncode({'accessToken': 'jwt-for-current-wallet'}), 201); + }); + + final token = await buildService(client).getAuthToken(); + + expect(token, 'jwt-for-current-wallet'); + expect(authCalls, 1); + expect(sessionCache.authTokenAddress, walletAddress); + }); + + test('late auth response cannot commit after the wallet address changes', () async { + const oldAddress = '0x1111111111111111111111111111111111111111'; + const newAddress = '0x2222222222222222222222222222222222222222'; + final service = _LateCommitAuthService(appStore, walletService, oldAddress); + + final tokenFuture = service.getAuthToken(); + await Future.delayed(Duration.zero); + expect(service.authResponses, hasLength(1)); + + service.currentAddress = newAddress; + service.authResponses.single.complete({'accessToken': 'jwt-old'}); + await Future.delayed(Duration.zero); + + expect(sessionCache.authToken, isNull); + expect(service.authResponses, hasLength(2)); + service.authResponses.last.complete({'accessToken': 'jwt-new'}); + + expect(await tokenFuture, 'jwt-new'); + expect(sessionCache.authToken, 'jwt-new'); + expect(sessionCache.authTokenAddress, newAddress); + }); + test('invalidateAuthToken clears the cached JWT', () { - sessionCache.setAuthToken('to-be-cleared'); + sessionCache.setAuthToken('to-be-cleared', walletAddress); buildService(MockClient((_) async => http.Response('', 200))).invalidateAuthToken(); expect(sessionCache.authToken, isNull); + expect(sessionCache.authTokenAddress, isNull); }); test('refreshAuthToken clears the cache and forces a fresh /v1/auth round-trip', () async { - sessionCache.setAuthToken('stale-jwt'); + sessionCache.setAuthToken('stale-jwt', walletAddress); var authCalls = 0; final client = MockClient((request) async { authCalls++; @@ -829,6 +949,7 @@ void main() { // Cache was cleared → exactly one auth round-trip. expect(authCalls, 1); expect(sessionCache.authToken, 'jwt-fresh'); + expect(sessionCache.authTokenAddress, walletAddress); }); }); @@ -893,7 +1014,8 @@ void main() { when(() => sessionCache.loadSignature()).thenAnswer((_) async {}); when(() => sessionCache.signature).thenReturn(null); when(() => sessionCache.signatureAddress).thenReturn(null); - when(() => sessionCache.saveSignature(any(), any())).thenAnswer((_) async {}); + when(() => sessionCache.signedMessage).thenReturn(null); + when(() => sessionCache.saveSignature(any(), any(), any())).thenAnswer((_) async {}); when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); }); @@ -937,10 +1059,17 @@ void main() { expect(sentBody!['address'], accountAddressEip55); expect(sentBody!['signature'], stubSignature); expect(account.signCallCount, 1); - verify(() => sessionCache.saveSignature(accountAddressEip55, stubSignature)).called(1); + final service = buildService(client); + verify( + () => sessionCache.saveSignature( + accountAddressEip55, + stubSignature, + service.buildSignMessage(accountAddressEip55), + ), + ).called(1); // Returned token must NOT be written to the session cache — the // caller owns the identity switch. - verifyNever(() => sessionCache.setAuthToken(any())); + verifyNever(() => sessionCache.setAuthToken(any(), any())); }, ); @@ -953,7 +1082,11 @@ void main() { return http.Response(jsonEncode({'accessToken': 'jwt-linked'}), 201); }); - final token = await buildService(client).authenticateLinkedAccount( + final service = buildService(client); + when(() => sessionCache.signedMessage).thenReturn( + service.buildSignMessage(accountAddressEip55), + ); + final token = await service.authenticateLinkedAccount( account, linkBearerToken, ); @@ -961,7 +1094,28 @@ void main() { expect(token, 'jwt-linked'); expect(account.signCallCount, 0); expect(sentBody!['signature'], stubSignature); - verifyNever(() => sessionCache.saveSignature(any(), any())); + verifyNever(() => sessionCache.saveSignature(any(), any(), any())); + }); + + test('cache message mismatch re-signs before authenticating the linked account', () async { + when(() => sessionCache.signature).thenReturn(stubSignature); + when(() => sessionCache.signatureAddress).thenReturn(accountAddressEip55); + when(() => sessionCache.signedMessage).thenReturn('wrong-environment-message'); + final client = MockClient( + (_) async => http.Response(jsonEncode({'accessToken': 'jwt-linked'}), 201), + ); + final service = buildService(client); + + await service.authenticateLinkedAccount(account, linkBearerToken); + + expect(account.signCallCount, 1); + verify( + () => sessionCache.saveSignature( + accountAddressEip55, + stubSignature, + service.buildSignMessage(accountAddressEip55), + ), + ).called(1); }); for (final empty in const ['', '0x']) { @@ -1029,7 +1183,8 @@ void main() { when(() => appStore.sessionCache).thenReturn(sessionCache); when(() => sessionCache.signature).thenReturn(null); when(() => sessionCache.signatureAddress).thenReturn(null); - when(() => sessionCache.saveSignature(any(), any())).thenAnswer((_) async {}); + when(() => sessionCache.signedMessage).thenReturn(null); + when(() => sessionCache.saveSignature(any(), any(), any())).thenAnswer((_) async {}); when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); diff --git a/test/packages/service/dfx/dfx_bank_account_service_test.dart b/test/packages/service/dfx/dfx_bank_account_service_test.dart index 466c803a7..2eb3e602c 100644 --- a/test/packages/service/dfx/dfx_bank_account_service_test.dart +++ b/test/packages/service/dfx/dfx_bank_account_service_test.dart @@ -12,6 +12,7 @@ import 'package:realunit_wallet/packages/service/dfx/dfx_bank_account_service.da import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/session_cache.dart'; import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; class _MockAppStore extends Mock implements AppStore {} @@ -19,6 +20,8 @@ class _MockCacheRepository extends Mock implements CacheRepository {} class _MockWalletService extends Mock implements WalletService {} +const _authMnemonic = 'test test test test test test test test test test test junk'; + Map _bankAccount({ int id = 1, String iban = 'CH5604835012345678009', @@ -37,17 +40,23 @@ void main() { late _MockAppStore appStore; late _MockWalletService walletService; late SessionCache sessionCache; + late SoftwareWallet authWallet; setUp(() { appStore = _MockAppStore(); walletService = _MockWalletService(); sessionCache = SessionCache(_MockCacheRepository()); + authWallet = SoftwareWallet(1, 'Auth', _authMnemonic); // Pre-seed the JWT so `authenticatedGet/Put/Post` short-circuits the // `getAuthToken` refresh path (which would otherwise need a fully // stubbed wallet + signMessage endpoint). - sessionCache.setAuthToken('test-jwt'); + sessionCache.setAuthToken( + 'test-jwt', + authWallet.currentAccount.primaryAddress.address.hexEip55, + ); when(() => appStore.sessionCache).thenReturn(sessionCache); when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => appStore.wallet).thenReturn(authWallet); when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); }); @@ -59,7 +68,10 @@ void main() { group('$DfxBankAccountService', () { test('getBankAccounts GETs /v1/bankAccount with the JWT and maps the list', () async { - sessionCache.setAuthToken('jwt-1'); + sessionCache.setAuthToken( + 'jwt-1', + authWallet.currentAccount.primaryAddress.address.hexEip55, + ); String? capturedAuth; String? capturedPath; String? capturedMethod; diff --git a/test/packages/service/dfx/dfx_blockchain_api_service_test.dart b/test/packages/service/dfx/dfx_blockchain_api_service_test.dart index 443974908..8ca8e3826 100644 --- a/test/packages/service/dfx/dfx_blockchain_api_service_test.dart +++ b/test/packages/service/dfx/dfx_blockchain_api_service_test.dart @@ -12,6 +12,7 @@ import 'package:realunit_wallet/packages/service/dfx/dfx_blockchain_api_service. import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/session_cache.dart'; import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; class _MockAppStore extends Mock implements AppStore {} @@ -20,19 +21,23 @@ class _MockCacheRepository extends Mock implements CacheRepository {} class _MockWalletService extends Mock implements WalletService {} const _testAddress = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; +const _authMnemonic = 'test test test test test test test test test test test junk'; void main() { late _MockAppStore appStore; late _MockWalletService walletService; late SessionCache sessionCache; + late SoftwareWallet authWallet; setUp(() { appStore = _MockAppStore(); walletService = _MockWalletService(); sessionCache = SessionCache(_MockCacheRepository()); + authWallet = SoftwareWallet(1, 'Auth', _authMnemonic); when(() => appStore.sessionCache).thenReturn(sessionCache); when(() => appStore.apiConfig) .thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => appStore.wallet).thenReturn(authWallet); when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); }); diff --git a/test/packages/service/dfx/dfx_faucet_service_test.dart b/test/packages/service/dfx/dfx_faucet_service_test.dart index 5abb68a58..a62ecf2a5 100644 --- a/test/packages/service/dfx/dfx_faucet_service_test.dart +++ b/test/packages/service/dfx/dfx_faucet_service_test.dart @@ -12,6 +12,7 @@ import 'package:realunit_wallet/packages/service/dfx/dfx_faucet_service.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/session_cache.dart'; import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; class _MockAppStore extends Mock implements AppStore {} @@ -19,18 +20,23 @@ class _MockCacheRepository extends Mock implements CacheRepository {} class _MockWalletService extends Mock implements WalletService {} +const _authMnemonic = 'test test test test test test test test test test test junk'; + void main() { late _MockAppStore appStore; late _MockWalletService walletService; late SessionCache sessionCache; + late SoftwareWallet authWallet; setUp(() { appStore = _MockAppStore(); walletService = _MockWalletService(); sessionCache = SessionCache(_MockCacheRepository()); + authWallet = SoftwareWallet(1, 'Auth', _authMnemonic); when(() => appStore.sessionCache).thenReturn(sessionCache); when(() => appStore.apiConfig) .thenReturn(const ApiConfig(networkMode: NetworkMode.testnet)); + when(() => appStore.wallet).thenReturn(authWallet); when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); }); @@ -42,7 +48,10 @@ void main() { group('$DfxFaucetService', () { test('requestFaucet posts to /v1/faucet with the JWT and parses the response', () async { - sessionCache.setAuthToken('jwt-zzz'); + sessionCache.setAuthToken( + 'jwt-zzz', + authWallet.currentAccount.primaryAddress.address.hexEip55, + ); Map? capturedHeaders; String? capturedPath; final client = MockClient((request) async { @@ -65,7 +74,10 @@ void main() { }); test('accepts a 201 response in addition to 200', () async { - sessionCache.setAuthToken('jwt-zzz'); + sessionCache.setAuthToken( + 'jwt-zzz', + authWallet.currentAccount.primaryAddress.address.hexEip55, + ); final client = MockClient((_) async => http.Response( jsonEncode({'txId': 'tx-1', 'amount': 1.0}), 201, @@ -77,7 +89,10 @@ void main() { }); test('throws ApiException on a non-2xx response', () async { - sessionCache.setAuthToken('jwt-zzz'); + sessionCache.setAuthToken( + 'jwt-zzz', + authWallet.currentAccount.primaryAddress.address.hexEip55, + ); final client = MockClient((_) async => http.Response( jsonEncode({'statusCode': 429, 'message': 'Too Many Requests'}), 429, diff --git a/test/packages/service/dfx/dfx_kyc_service_rest_test.dart b/test/packages/service/dfx/dfx_kyc_service_rest_test.dart index e285b8be4..869b4968c 100644 --- a/test/packages/service/dfx/dfx_kyc_service_rest_test.dart +++ b/test/packages/service/dfx/dfx_kyc_service_rest_test.dart @@ -66,7 +66,10 @@ void main() { appStore = _MockAppStore(); walletService = _MockWalletService(); sessionCache = SessionCache(_MockCacheRepository()); - sessionCache.setAuthToken('jwt-1'); + sessionCache.setAuthToken( + 'jwt-1', + EthereumAddress.fromHex('0x0000000000000000000000000000000000000001').hexEip55, + ); when(() => appStore.sessionCache).thenReturn(sessionCache); when(() => appStore.apiConfig) .thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); diff --git a/test/packages/service/dfx/dfx_kyc_service_test.dart b/test/packages/service/dfx/dfx_kyc_service_test.dart index c1e639049..7d7762636 100644 --- a/test/packages/service/dfx/dfx_kyc_service_test.dart +++ b/test/packages/service/dfx/dfx_kyc_service_test.dart @@ -128,7 +128,10 @@ void main() { appStore = _MockAppStore(); walletService = _MockWalletService(); sessionCache = SessionCache(_MockCacheRepository()); - sessionCache.setAuthToken('jwt-1'); + sessionCache.setAuthToken( + 'jwt-1', + EthereumAddress.fromHex('0x0000000000000000000000000000000000000001').hexEip55, + ); when(() => appStore.sessionCache).thenReturn(sessionCache); when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); when(() => appStore.wallet).thenReturn(_StubWallet()); diff --git a/test/packages/service/dfx/dfx_support_service_test.dart b/test/packages/service/dfx/dfx_support_service_test.dart index d0cd927ff..58bd48e95 100644 --- a/test/packages/service/dfx/dfx_support_service_test.dart +++ b/test/packages/service/dfx/dfx_support_service_test.dart @@ -14,6 +14,7 @@ import 'package:realunit_wallet/packages/service/dfx/models/support/support_issu import 'package:realunit_wallet/packages/service/dfx/models/support/support_issue_type.dart'; import 'package:realunit_wallet/packages/service/session_cache.dart'; import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; class _MockAppStore extends Mock implements AppStore {} @@ -21,6 +22,8 @@ class _MockCacheRepository extends Mock implements CacheRepository {} class _MockWalletService extends Mock implements WalletService {} +const _authMnemonic = 'test test test test test test test test test test test junk'; + Map _ticketJson({String uid = 'uid-1'}) => { 'uid': uid, 'state': 'Created', @@ -35,17 +38,23 @@ void main() { late _MockAppStore appStore; late _MockWalletService walletService; late SessionCache sessionCache; + late SoftwareWallet authWallet; setUp(() { appStore = _MockAppStore(); walletService = _MockWalletService(); sessionCache = SessionCache(_MockCacheRepository()); + authWallet = SoftwareWallet(1, 'Auth', _authMnemonic); // Pre-populate the auth token so the base-class getAuthToken short- // circuits without exercising the signing flow. - sessionCache.setAuthToken('jwt-xyz'); + sessionCache.setAuthToken( + 'jwt-xyz', + authWallet.currentAccount.primaryAddress.address.hexEip55, + ); when(() => appStore.sessionCache).thenReturn(sessionCache); when(() => appStore.apiConfig) .thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => appStore.wallet).thenReturn(authWallet); when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); }); diff --git a/test/packages/service/dfx/dfx_widget_service_test.dart b/test/packages/service/dfx/dfx_widget_service_test.dart index b83c38de1..1e5795bd4 100644 --- a/test/packages/service/dfx/dfx_widget_service_test.dart +++ b/test/packages/service/dfx/dfx_widget_service_test.dart @@ -55,13 +55,19 @@ void main() { }); test('isAvailable=true once an auth token is set', () { - sessionCache.setAuthToken('jwt-1'); + sessionCache.setAuthToken( + 'jwt-1', + wallet.currentAccount.primaryAddress.address.hexEip55, + ); expect(DfxWidgetService(appStore, walletService).isAvailable, isTrue); }); test('isAvailable flips back to false after clearAuthToken', () { - sessionCache.setAuthToken('jwt-1'); + sessionCache.setAuthToken( + 'jwt-1', + wallet.currentAccount.primaryAddress.address.hexEip55, + ); final service = DfxWidgetService(appStore, walletService); expect(service.isAvailable, isTrue); diff --git a/test/packages/service/dfx/real_unit_legal_service_test.dart b/test/packages/service/dfx/real_unit_legal_service_test.dart index f9048ea23..c484d8c4b 100644 --- a/test/packages/service/dfx/real_unit_legal_service_test.dart +++ b/test/packages/service/dfx/real_unit_legal_service_test.dart @@ -15,6 +15,7 @@ import 'package:realunit_wallet/packages/service/session_cache.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:web3dart/web3dart.dart'; class _MockAppStore extends Mock implements AppStore {} @@ -39,12 +40,17 @@ void main() { account = _MockAccount(); walletService = _MockWalletService(); session = SessionCache(_MockCacheRepository()); - session.setAuthToken('jwt-1'); + final credentials = EthPrivateKey.fromHex( + 'fb1ace12f9801e85f3db1b3935dd47d9f064f98152466f47c701b5e12680e612', + ); + session.setAuthToken('jwt-1', credentials.address.hexEip55); when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); when(() => appStore.sessionCache).thenReturn(session); when(() => appStore.wallet).thenReturn(wallet); when(() => wallet.primaryAccount).thenReturn(account); + when(() => wallet.currentAccount).thenReturn(account); + when(() => account.primaryAddress).thenReturn(credentials); when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); }); diff --git a/test/packages/service/dfx/real_unit_pay_service_test.dart b/test/packages/service/dfx/real_unit_pay_service_test.dart index 969965031..4423e0620 100644 --- a/test/packages/service/dfx/real_unit_pay_service_test.dart +++ b/test/packages/service/dfx/real_unit_pay_service_test.dart @@ -68,7 +68,10 @@ void main() { account = _MockAccount(); walletService = _MockWalletService(); session = SessionCache(_MockCacheRepository()); - session.setAuthToken('jwt-1'); + session.setAuthToken( + 'jwt-1', + EthereumAddress.fromHex('0x0000000000000000000000000000000000000001').hexEip55, + ); when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); when(() => appStore.sessionCache).thenReturn(session); diff --git a/test/packages/service/dfx/real_unit_pdf_service_test.dart b/test/packages/service/dfx/real_unit_pdf_service_test.dart index b09b01889..5cf8d9d22 100644 --- a/test/packages/service/dfx/real_unit_pdf_service_test.dart +++ b/test/packages/service/dfx/real_unit_pdf_service_test.dart @@ -12,6 +12,7 @@ import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.da import 'package:realunit_wallet/packages/service/dfx/real_unit_pdf_service.dart'; import 'package:realunit_wallet/packages/service/session_cache.dart'; import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; import 'package:realunit_wallet/styles/currency.dart'; import 'package:realunit_wallet/styles/language.dart'; @@ -22,21 +23,28 @@ class _MockCacheRepository extends Mock implements CacheRepository {} class _MockWalletService extends Mock implements WalletService {} const _address = '0x000000000000000000000000000000000000beef'; +const _authMnemonic = 'test test test test test test test test test test test junk'; void main() { late _MockAppStore appStore; late _MockWalletService walletService; late SessionCache sessionCache; + late SoftwareWallet authWallet; setUp(() { appStore = _MockAppStore(); walletService = _MockWalletService(); sessionCache = SessionCache(_MockCacheRepository()); + authWallet = SoftwareWallet(1, 'Auth', _authMnemonic); when(() => appStore.sessionCache).thenReturn(sessionCache); when(() => appStore.apiConfig) .thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => appStore.wallet).thenReturn(authWallet); when(() => appStore.primaryAddress).thenReturn(_address); - sessionCache.setAuthToken('jwt-pdf'); + sessionCache.setAuthToken( + 'jwt-pdf', + authWallet.currentAccount.primaryAddress.address.hexEip55, + ); when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); }); 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 101a31fce..56174a7dd 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,12 +50,13 @@ void main() { account = _MockAccount(); walletService = _MockWalletService(); session = SessionCache(_MockCacheRepository()); - session.setAuthToken('jwt-1'); + session.setAuthToken('jwt-1', _privKey.address.hexEip55); when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); when(() => appStore.sessionCache).thenReturn(session); when(() => appStore.wallet).thenReturn(wallet); when(() => wallet.primaryAccount).thenReturn(account); + when(() => wallet.currentAccount).thenReturn(account); when(() => account.primaryAddress).thenReturn(_privKey); when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); 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 19e963b77..3c387ab7f 100644 --- a/test/packages/service/dfx/real_unit_registration_service_test.dart +++ b/test/packages/service/dfx/real_unit_registration_service_test.dart @@ -12,15 +12,15 @@ import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.da 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/exceptions/registration_rejected_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/registration.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_email_status.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_status.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_user_type.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_state.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; -import 'package:realunit_wallet/packages/service/dfx/models/country/country.dart'; -import 'package:realunit_wallet/packages/service/dfx/models/registration/registration.dart'; -import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; -import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; import 'package:realunit_wallet/packages/service/session_cache.dart'; import 'package:realunit_wallet/packages/service/wallet_service.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; @@ -51,12 +51,15 @@ void main() { account = _MockAccount(); walletService = _MockWalletService(); session = SessionCache(_MockCacheRepository()); - session.setAuthToken('jwt-1'); + final credentials = FakeBitboxCredentials(); + session.setAuthToken('jwt-1', credentials.address.hexEip55); when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); when(() => appStore.sessionCache).thenReturn(session); when(() => appStore.wallet).thenReturn(wallet); when(() => wallet.primaryAccount).thenReturn(account); + when(() => wallet.currentAccount).thenReturn(account); + when(() => account.primaryAddress).thenReturn(credentials); when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); }); @@ -288,7 +291,8 @@ void main() { final repo = _MockCacheRepository(); when(() => repo.read(any())).thenAnswer((_) async => null); when(() => repo.write(any(), any())).thenAnswer((_) async => 1); - session = SessionCache(repo)..setAuthToken('jwt-1'); + session = SessionCache(repo) + ..setAuthToken('jwt-1', account.primaryAddress.address.hexEip55); when(() => appStore.sessionCache).thenReturn(session); final client = MockClient((request) async { 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 289a9b329..7fefcbe2f 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 @@ -99,7 +99,7 @@ void main() { account = _MockAccount(); walletService = _MockWalletService(); session = SessionCache(_MockCacheRepository()); - session.setAuthToken('jwt-1'); + session.setAuthToken('jwt-1', _privKey.address.hexEip55); when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); when(() => appStore.sessionCache).thenReturn(session); diff --git a/test/packages/service/dfx/real_unit_sell_payment_info_service_test.dart b/test/packages/service/dfx/real_unit_sell_payment_info_service_test.dart index 825752719..1f33aaa90 100644 --- a/test/packages/service/dfx/real_unit_sell_payment_info_service_test.dart +++ b/test/packages/service/dfx/real_unit_sell_payment_info_service_test.dart @@ -109,7 +109,10 @@ void main() { account = _MockAccount(); walletService = _MockWalletService(); session = SessionCache(_MockCacheRepository()); - session.setAuthToken('jwt-1'); + session.setAuthToken( + 'jwt-1', + EthereumAddress.fromHex('0x0000000000000000000000000000000000000001').hexEip55, + ); when(() => appStore.apiConfig) .thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); diff --git a/test/packages/service/dfx/real_unit_sell_payment_info_service_validation_test.dart b/test/packages/service/dfx/real_unit_sell_payment_info_service_validation_test.dart index f16db9f49..d60702c53 100644 --- a/test/packages/service/dfx/real_unit_sell_payment_info_service_validation_test.dart +++ b/test/packages/service/dfx/real_unit_sell_payment_info_service_validation_test.dart @@ -106,7 +106,10 @@ void main() { account = _MockAccount(); walletService = _MockWalletService(); session = SessionCache(_MockCacheRepository()); - session.setAuthToken('jwt-1'); + session.setAuthToken( + 'jwt-1', + EthereumAddress.fromHex(_walletAddress).hexEip55, + ); when(() => appStore.apiConfig) .thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); diff --git a/test/packages/service/dfx/real_unit_transfer_service_test.dart b/test/packages/service/dfx/real_unit_transfer_service_test.dart index 475bffe31..914bd585c 100644 --- a/test/packages/service/dfx/real_unit_transfer_service_test.dart +++ b/test/packages/service/dfx/real_unit_transfer_service_test.dart @@ -120,7 +120,7 @@ void main() { account = _MockAccount(); walletService = _MockWalletService(); session = SessionCache(_MockCacheRepository()); - session.setAuthToken('jwt-1'); + session.setAuthToken('jwt-1', _privKey.address.hexEip55); when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); when(() => appStore.sessionCache).thenReturn(session); diff --git a/test/packages/service/session_cache_test.dart b/test/packages/service/session_cache_test.dart index b781a3f2c..90a9ca70b 100644 --- a/test/packages/service/session_cache_test.dart +++ b/test/packages/service/session_cache_test.dart @@ -20,23 +20,26 @@ void main() { group('auth token', () { test('starts null', () { expect(cache.authToken, isNull); + expect(cache.authTokenAddress, isNull); }); test('setAuthToken stores in memory only', () { - cache.setAuthToken('jwt-123'); + cache.setAuthToken('jwt-123', '0xabc'); expect(cache.authToken, 'jwt-123'); + expect(cache.authTokenAddress, '0xabc'); // setAuthToken must NEVER touch the repository — JWTs live for one // session and persisting them to disk would defeat that lifetime. verifyNever(() => repo.write(any(), any())); }); test('clearAuthToken resets to null without touching the repository', () { - cache.setAuthToken('jwt-123'); + cache.setAuthToken('jwt-123', '0xabc'); cache.clearAuthToken(); expect(cache.authToken, isNull); + expect(cache.authTokenAddress, isNull); verifyNever(() => repo.write(any(), any())); verifyNever(() => repo.delete(any())); }); @@ -46,35 +49,42 @@ void main() { test('starts null', () { expect(cache.signature, isNull); expect(cache.signatureAddress, isNull); + expect(cache.signedMessage, isNull); }); - test('saveSignature writes both the signature and the address to the repo', () async { - await cache.saveSignature('0xabc', '0xsig'); + test('saveSignature writes signature, address, and message to the repo', () async { + await cache.saveSignature('0xabc', '0xsig', 'sign-message'); expect(cache.signature, '0xsig'); expect(cache.signatureAddress, '0xabc'); + expect(cache.signedMessage, 'sign-message'); verify(() => repo.write('cached_signature', '0xsig')).called(1); verify(() => repo.write('cached_signature_address', '0xabc')).called(1); + verify(() => repo.write('cached_signature_message', 'sign-message')).called(1); }); test('loadSignature populates from the repo when memory is empty', () async { when(() => repo.read('cached_signature')).thenAnswer((_) async => '0xsig'); when(() => repo.read('cached_signature_address')).thenAnswer((_) async => '0xabc'); + when(() => repo.read('cached_signature_message')) + .thenAnswer((_) async => 'sign-message'); await cache.loadSignature(); expect(cache.signature, '0xsig'); expect(cache.signatureAddress, '0xabc'); + expect(cache.signedMessage, 'sign-message'); }); test('loadSignature does not overwrite an in-memory signature', () async { - await cache.saveSignature('0xabc', '0xsig'); + await cache.saveSignature('0xabc', '0xsig', 'sign-message'); when(() => repo.read(any())).thenAnswer((_) async => 'wrong'); await cache.loadSignature(); expect(cache.signature, '0xsig'); expect(cache.signatureAddress, '0xabc'); + expect(cache.signedMessage, 'sign-message'); verifyNever(() => repo.read(any())); }); @@ -85,23 +95,39 @@ void main() { expect(cache.signature, isNull); expect(cache.signatureAddress, isNull); + expect(cache.signedMessage, isNull); + }); + + test('loadSignature keeps a legacy entry without a message scope', () async { + when(() => repo.read('cached_signature')).thenAnswer((_) async => '0xlegacy'); + when(() => repo.read('cached_signature_address')).thenAnswer((_) async => '0xabc'); + when(() => repo.read('cached_signature_message')).thenAnswer((_) async => null); + + await cache.loadSignature(); + + expect(cache.signature, '0xlegacy'); + expect(cache.signatureAddress, '0xabc'); + expect(cache.signedMessage, isNull); }); }); group('clear', () { - test('removes both signature keys and resets auth token + memory', () async { - cache.setAuthToken('jwt-123'); - await cache.saveSignature('0xabc', '0xsig'); + test('removes all signature keys and resets auth token + memory', () async { + cache.setAuthToken('jwt-123', '0xabc'); + await cache.saveSignature('0xabc', '0xsig', 'sign-message'); clearInteractions(repo); when(() => repo.delete(any())).thenAnswer((_) async {}); await cache.clear(); expect(cache.authToken, isNull); + expect(cache.authTokenAddress, isNull); expect(cache.signature, isNull); expect(cache.signatureAddress, isNull); + expect(cache.signedMessage, isNull); verify(() => repo.delete('cached_signature')).called(1); verify(() => repo.delete('cached_signature_address')).called(1); + verify(() => repo.delete('cached_signature_message')).called(1); }); }); }); diff --git a/test/packages/service/transaction_history_service_test.dart b/test/packages/service/transaction_history_service_test.dart index ada4cebb4..cb1bbad93 100644 --- a/test/packages/service/transaction_history_service_test.dart +++ b/test/packages/service/transaction_history_service_test.dart @@ -12,6 +12,7 @@ import 'package:realunit_wallet/packages/service/app_store.dart'; import 'package:realunit_wallet/packages/service/session_cache.dart'; import 'package:realunit_wallet/packages/service/transaction_history_service.dart'; import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; class _MockAppStore extends Mock implements AppStore {} @@ -23,6 +24,7 @@ class _MockWalletService extends Mock implements WalletService {} const _wallet = '0x000000000000000000000000000000000000beef'; const _other = '0x0000000000000000000000000000000000001234'; +const _authMnemonic = 'test test test test test test test test test test test junk'; Map _txJson({ int id = 1, @@ -49,14 +51,17 @@ void main() { late _MockWalletService walletService; late SessionCache sessionCache; late _MockTransactionRepository txRepo; + late SoftwareWallet authWallet; setUp(() { appStore = _MockAppStore(); walletService = _MockWalletService(); sessionCache = SessionCache(_MockCacheRepository()); txRepo = _MockTransactionRepository(); + authWallet = SoftwareWallet(1, 'Auth', _authMnemonic); when(() => appStore.sessionCache).thenReturn(sessionCache); when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => appStore.wallet).thenReturn(authWallet); when(() => appStore.primaryAddress).thenReturn(_wallet); when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); @@ -76,7 +81,10 @@ void main() { // in `dfx_auth_service_test.dart`. test('GETs /v1/transaction/detail with the Bearer JWT', () async { - sessionCache.setAuthToken('jwt-1'); + sessionCache.setAuthToken( + 'jwt-1', + authWallet.currentAccount.primaryAddress.address.hexEip55, + ); String? auth; String? path; final client = MockClient((request) async { @@ -92,7 +100,10 @@ void main() { }); test('returns [] on non-200 (does not throw)', () async { - sessionCache.setAuthToken('jwt-1'); + sessionCache.setAuthToken( + 'jwt-1', + authWallet.currentAccount.primaryAddress.address.hexEip55, + ); final client = MockClient((_) async => http.Response('boom', 500)); final list = await build(client).fetchPendingTransactions(); @@ -101,7 +112,10 @@ void main() { }); test('filters out completed transactions (isPending=false)', () async { - sessionCache.setAuthToken('jwt-1'); + sessionCache.setAuthToken( + 'jwt-1', + authWallet.currentAccount.primaryAddress.address.hexEip55, + ); final client = MockClient( (_) async => http.Response( jsonEncode([ @@ -121,7 +135,10 @@ void main() { }); test('filters out transactions that do not belong to the current wallet', () async { - sessionCache.setAuthToken('jwt-1'); + sessionCache.setAuthToken( + 'jwt-1', + authWallet.currentAccount.primaryAddress.address.hexEip55, + ); final client = MockClient( (_) async => http.Response( jsonEncode([ @@ -140,7 +157,10 @@ void main() { }); test('wallet match is case-insensitive', () async { - sessionCache.setAuthToken('jwt-1'); + sessionCache.setAuthToken( + 'jwt-1', + authWallet.currentAccount.primaryAddress.address.hexEip55, + ); when(() => appStore.primaryAddress).thenReturn(_wallet.toUpperCase()); final client = MockClient( (_) async => http.Response( diff --git a/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart b/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart index 8a49fc32a..60fa1c148 100644 --- a/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart +++ b/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart @@ -148,7 +148,7 @@ void main() { when(() => sessionCache.signatureAddress).thenReturn(draftAddress); when(() => sessionCache.signature).thenReturn(signature); when(() => sessionCache.saveSignature(any(), any())).thenAnswer((_) async {}); - when(() => sessionCache.setAuthToken(any())).thenReturn(null); + when(() => sessionCache.setAuthToken(any(), any())).thenReturn(null); when(() => authService.getAuthToken()).thenAnswer((_) async => oldJwt); when( @@ -631,7 +631,7 @@ void main() { verifyInOrder([ () => walletService.setCurrentWallet(42), () => sessionCache.saveSignature(persistedAddress, signature), - () => sessionCache.setAuthToken(newJwt), + () => sessionCache.setAuthToken(newJwt, persistedAddress), ]); expect(cubit.state, MigrateBitboxSuccess(persisted)); }); @@ -647,7 +647,7 @@ void main() { verifyInOrder([ () => walletService.setCurrentWallet(42), - () => sessionCache.setAuthToken(newJwt), + () => sessionCache.setAuthToken(newJwt, persistedAddress), ]); verifyNever(() => sessionCache.saveSignature(any(), any())); expect(cubit.state, MigrateBitboxSuccess(persisted)); From ac4fe91034da53de5b508459e7fed2dc20cb4c9a Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:38:48 +0200 Subject: [PATCH 07/26] fix(migration): review round 1 wizard fixes - register step is its own page + cubit (MigrateRegisterCubit, mirror of the KYC link-wallet step) per the multi-step rule; parent keeps routing and exposes draftAccount/linkedJwt fail-loud - linking now pre-checks that the software wallet is registered, always mints a fresh JWT (refreshAuthToken) so an expired bearer can never silently sign the BitBox address up as a separate account, and treats newRegistration-after-link as a retryable link failure - transfer success no longer completes the wizard directly: a settling poll re-reads the fresh source balance (fail-loud fetchBalance) and only switches wallets at zero, re-prepares a remainder transfer if the balance dropped but is nonzero, and fails closed into a resumable timeout state otherwise - embedded transfer failure branch extracted as MigrateTransferFailureView with surface-catalog entry and full responsive-matrix coverage (workaround comment removed) - pairing sheet closes itself on finish; retry() is single-flight with an immediate busy state; intro balance punctuation moved into the template --- assets/languages/strings_de.arb | 5 +- assets/languages/strings_en.arb | 5 +- .../migrate_bitbox/migrate_bitbox_cubit.dart | 211 +++++++---- .../migrate_bitbox/migrate_bitbox_state.dart | 16 +- .../migrate_register_cubit.dart | 68 ++++ .../migrate_register_state.dart | 45 +++ .../migrate_bitbox/migrate_bitbox_page.dart | 24 +- .../widgets/migrate_intro_view.dart | 2 +- .../widgets/migrate_register_view.dart | 154 +++++++- .../widgets/migrate_result_views.dart | 44 ++- .../widgets/migrate_transfer_view.dart | 97 +++-- test/helper/responsive_surface_catalog.dart | 20 +- .../migrate_bitbox_cubit_test.dart | 355 +++++++++++++----- .../migrate_register_cubit_test.dart | 187 +++++++++ .../migrate_bitbox_page_test.dart | 27 +- ...migrate_bitbox_responsive_matrix_test.dart | 28 +- .../widgets/migrate_register_view_test.dart | 154 +++++++- .../widgets/migrate_transfer_view_test.dart | 8 +- 18 files changed, 1219 insertions(+), 231 deletions(-) create mode 100644 lib/screens/migrate_bitbox/cubits/migrate_register/migrate_register_cubit.dart create mode 100644 lib/screens/migrate_bitbox/cubits/migrate_register/migrate_register_state.dart create mode 100644 test/screens/migrate_bitbox/cubits/migrate_register/migrate_register_cubit_test.dart diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index ab7e42ce0..f7685b606 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -167,16 +167,19 @@ "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: ${amount} REALU", + "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", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 713658d03..b2fa0fd05 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -167,16 +167,19 @@ "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: ${amount} REALU", + "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", diff --git a/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart b/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart index 1404ccc41..8e325fcda 100644 --- a/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart +++ b/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; @@ -6,13 +8,13 @@ import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/address_already_linked_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; -import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_status.dart'; import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_state.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; import 'package:realunit_wallet/packages/service/wallet_service.dart'; import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; part 'migrate_bitbox_state.dart'; @@ -39,8 +41,33 @@ class MigrateBitboxCubit extends Cubit { String? _bitboxSignature; BitboxWallet? _persisted; Future Function()? _pendingRetry; - MigrateBitboxRegisterReady? _registerRetryState; - bool _pendingRegisterRetry = false; + _MigrateBitboxRetryKind? _pendingRetryKind; + Timer? _settlingTimer; + int _settlingGeneration = 0; + int _settlingAttempts = 0; + bool _settlingInFlight = false; + + static const _settlingPollInterval = Duration(seconds: 3); + static const _settlingMaxAttempts = 20; + + /// Only valid once onDevicePaired has produced a fresh link (state reaches + /// RegisterReady or later). Throws if accessed earlier. + AWalletAccount get draftAccount { + final draft = _draft; + if (draft == null) { + throw StateError('draftAccount accessed before onDevicePaired'); + } + return draft.currentAccount; + } + + /// Only valid once onDevicePaired has produced a fresh link. + String get linkedJwt { + final jwt = _newJwt; + if (jwt == null) { + throw StateError('linkedJwt accessed before onDevicePaired completed linking'); + } + return jwt; + } Future startPairing() async { emit(const MigrateBitboxAwaitingDevice()); @@ -58,13 +85,29 @@ class MigrateBitboxCubit extends Cubit { emit(const MigrateBitboxLinking()); _draft = draft; try { - final oldJwt = await _authService.getAuthToken(); - if (oldJwt == null) { + final sourceInfo = await _registrationService.getRegistrationInfo(); + if (sourceInfo.state != RealUnitRegistrationState.alreadyRegistered) { _pendingRetry = null; + _pendingRetryKind = null; + emit( + const MigrateBitboxFailure( + MigrateBitboxFailureReason.registrationMissing, + ), + ); + return; + } + + final refreshed = await _authService.refreshAuthToken(); + if (refreshed == null) { + _pendingRetry = null; + _pendingRetryKind = null; emit(const MigrateBitboxFailure(MigrateBitboxFailureReason.generic)); return; } - _newJwt = await _authService.authenticateLinkedAccount(draft.currentAccount, oldJwt); + _newJwt = await _authService.authenticateLinkedAccount( + draft.currentAccount, + refreshed, + ); final draftAddress = draft.currentAccount.primaryAddress.address.hexEip55; // authenticateLinkedAccount has already cached the signature via @@ -80,6 +123,7 @@ class MigrateBitboxCubit extends Cubit { final userData = info.realUnitUserDataDto; if (userData == null) { _pendingRetry = null; + _pendingRetryKind = null; emit( const MigrateBitboxFailure( MigrateBitboxFailureReason.generic, @@ -89,27 +133,36 @@ class MigrateBitboxCubit extends Cubit { return; } _pendingRetry = null; + _pendingRetryKind = null; emit(MigrateBitboxRegisterReady(userData, draftAddress)); case RealUnitRegistrationState.alreadyRegistered: if (info.manualReview == true) { _pendingRetry = null; + _pendingRetryKind = null; emit(const MigrateBitboxRegistrationPending()); return; } await _persistAndPrepareTransfer(); case RealUnitRegistrationState.newRegistration: - _pendingRetry = null; + _pendingRetry = () => onDevicePaired(draft); + _pendingRetryKind = _MigrateBitboxRetryKind.linking; emit( - const MigrateBitboxFailure(MigrateBitboxFailureReason.registrationMissing), + const MigrateBitboxFailure( + MigrateBitboxFailureReason.generic, + message: 'wallet link did not attach to the account', + canRetry: true, + ), ); } } on AddressAlreadyLinkedException { _pendingRetry = null; + _pendingRetryKind = null; emit( const MigrateBitboxFailure(MigrateBitboxFailureReason.addressAlreadyLinked), ); } on SigningCancelledException { _pendingRetry = () => onDevicePaired(draft); + _pendingRetryKind = _MigrateBitboxRetryKind.linking; emit( const MigrateBitboxFailure( MigrateBitboxFailureReason.signatureCancelled, @@ -118,6 +171,7 @@ class MigrateBitboxCubit extends Cubit { ); } on BitboxNotConnectedException { _pendingRetry = () => onDevicePaired(draft); + _pendingRetryKind = _MigrateBitboxRetryKind.linking; emit( const MigrateBitboxFailure( MigrateBitboxFailureReason.bitboxNotConnected, @@ -126,6 +180,7 @@ class MigrateBitboxCubit extends Cubit { ); } catch (e) { _pendingRetry = () => onDevicePaired(draft); + _pendingRetryKind = _MigrateBitboxRetryKind.linking; emit( MigrateBitboxFailure( MigrateBitboxFailureReason.generic, @@ -136,68 +191,33 @@ class MigrateBitboxCubit extends Cubit { } } - /// Only valid while [state] is [MigrateBitboxRegisterReady]. - Future register() async { - final current = state; - if (current is! MigrateBitboxRegisterReady) return; - _registerRetryState = current; - _pendingRegisterRetry = false; - final userData = current.userData; - emit(const MigrateBitboxRegistering()); - try { - final status = await _registrationService.registerWalletFor( - _draft!.currentAccount, - userData, - _newJwt!, - ); - switch (status) { - case RegistrationStatus.completed: - case RegistrationStatus.alreadyRegistered: - await _persistAndPrepareTransfer(); - case RegistrationStatus.pendingReview: - case RegistrationStatus.forwardingFailed: - _pendingRetry = null; - emit(const MigrateBitboxRegistrationPending()); - } - } on SigningCancelledException { - _pendingRetry = register; - _pendingRegisterRetry = true; - emit( - const MigrateBitboxFailure( - MigrateBitboxFailureReason.signatureCancelled, - canRetry: true, - ), - ); - } on BitboxNotConnectedException { - _pendingRetry = register; - _pendingRegisterRetry = true; - emit( - const MigrateBitboxFailure( - MigrateBitboxFailureReason.bitboxNotConnected, - canRetry: true, - ), - ); - } catch (e) { - _pendingRetry = register; - _pendingRegisterRetry = true; - emit( - MigrateBitboxFailure( - MigrateBitboxFailureReason.generic, - message: e.toString(), - canRetry: true, - ), - ); - } + Future onRegisterCompleted() async { + if (state is! MigrateBitboxRegisterReady) return; + await _persistAndPrepareTransfer(); + } + + void onRegisterPending() { + if (state is! MigrateBitboxRegisterReady) return; + _pendingRetry = null; + _pendingRetryKind = null; + emit(const MigrateBitboxRegistrationPending()); } /// Re-runs whatever action last failed with `canRetry: true`. No-op if there /// is nothing to retry. Future retry() async { final action = _pendingRetry; + final kind = _pendingRetryKind; if (action == null) return; - if (_pendingRegisterRetry) { - emit(_registerRetryState!); - } + _pendingRetry = null; + _pendingRetryKind = null; + emit( + switch (kind!) { + _MigrateBitboxRetryKind.linking => const MigrateBitboxLinking(), + _MigrateBitboxRetryKind.transferPreparation => + const MigrateBitboxPreparingTransfer(), + }, + ); await action(); } @@ -216,6 +236,7 @@ class MigrateBitboxCubit extends Cubit { // silently skip the transfer and end the wizard "successfully" without // moving any funds. _pendingRetry = _persistAndPrepareTransfer; + _pendingRetryKind = _MigrateBitboxRetryKind.transferPreparation; emit( const MigrateBitboxFailure( MigrateBitboxFailureReason.generic, @@ -234,6 +255,7 @@ class MigrateBitboxCubit extends Cubit { return; } _pendingRetry = null; + _pendingRetryKind = null; emit( MigrateBitboxTransferReady( fromAddress: softwareAddress, @@ -263,6 +285,7 @@ class MigrateBitboxCubit extends Cubit { void onTransferFailedTerminally(String message) { if (state is! MigrateBitboxTransferring) return; _pendingRetry = _persistAndPrepareTransfer; + _pendingRetryKind = _MigrateBitboxRetryKind.transferPreparation; emit( MigrateBitboxFailure( MigrateBitboxFailureReason.generic, @@ -272,6 +295,59 @@ class MigrateBitboxCubit extends Cubit { ); } + Future onTransferBroadcast() async { + final current = state; + if (current is! MigrateBitboxTransferring) return; + emit(const MigrateBitboxSettling()); + _startSettlingPoll(current.amount); + } + + void _startSettlingPoll(int expectedAmount) { + _settlingTimer?.cancel(); + final generation = ++_settlingGeneration; + _settlingAttempts = 0; + _settlingInFlight = false; + _settlingTimer = Timer.periodic(_settlingPollInterval, (_) async { + if (generation != _settlingGeneration || _settlingInFlight) return; + _settlingInFlight = true; + try { + final balance = await _balanceService.fetchBalance( + _appStore.primaryAddress, + ); + if (isClosed || generation != _settlingGeneration) return; + _settlingAttempts++; + final amount = balance.balance.toInt(); + if (amount == 0) { + _settlingTimer?.cancel(); + await finishMigration(); + if (isClosed || generation != _settlingGeneration) return; + return; + } + if (amount < expectedAmount) { + _settlingTimer?.cancel(); + await _persistAndPrepareTransfer(); + if (isClosed || generation != _settlingGeneration) return; + return; + } + if (_settlingAttempts >= _settlingMaxAttempts) { + _settlingTimer?.cancel(); + emit(const MigrateBitboxSettlingTimeout()); + } + } catch (_) { + if (isClosed || generation != _settlingGeneration) return; + _settlingAttempts++; + if (_settlingAttempts >= _settlingMaxAttempts) { + _settlingTimer?.cancel(); + emit(const MigrateBitboxSettlingTimeout()); + } + } finally { + if (generation == _settlingGeneration) { + _settlingInFlight = false; + } + } + }); + } + Future finishMigration() async { emit(const MigrateBitboxCompleting()); final persisted = _persisted!; @@ -291,6 +367,15 @@ class MigrateBitboxCubit extends Cubit { persisted.currentAccount.primaryAddress.address.hexEip55, ); _pendingRetry = null; + _pendingRetryKind = null; emit(MigrateBitboxSuccess(persisted)); } + + @override + Future close() { + _settlingTimer?.cancel(); + return super.close(); + } } + +enum _MigrateBitboxRetryKind { linking, transferPreparation } diff --git a/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_state.dart b/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_state.dart index 022e96922..d61bc85cf 100644 --- a/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_state.dart +++ b/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_state.dart @@ -38,10 +38,6 @@ class MigrateBitboxRegisterReady extends MigrateBitboxState { List get props => [userData, bitboxAddress]; } -class MigrateBitboxRegistering extends MigrateBitboxState { - const MigrateBitboxRegistering(); -} - /// Registration is parked in manual review (Aktionariat forward pending, or the /// wallet was already registered elsewhere and needs staff review). The wizard /// ends here; the balance stays on the software wallet and the user may re-open @@ -78,6 +74,18 @@ class MigrateBitboxTransferring extends MigrateBitboxState { List get props => [toAddress, amount]; } +class MigrateBitboxSettling extends MigrateBitboxState { + const MigrateBitboxSettling(); +} + +class MigrateBitboxSettlingTimeout extends MigrateBitboxState { + const MigrateBitboxSettlingTimeout(); +} + +class MigrateBitboxPreparingTransfer extends MigrateBitboxState { + const MigrateBitboxPreparingTransfer(); +} + class MigrateBitboxCompleting extends MigrateBitboxState { const MigrateBitboxCompleting(); } diff --git a/lib/screens/migrate_bitbox/cubits/migrate_register/migrate_register_cubit.dart b/lib/screens/migrate_bitbox/cubits/migrate_register/migrate_register_cubit.dart new file mode 100644 index 000000000..fc8b8365f --- /dev/null +++ b/lib/screens/migrate_bitbox/cubits/migrate_register/migrate_register_cubit.dart @@ -0,0 +1,68 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_status.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; + +part 'migrate_register_state.dart'; + +class MigrateRegisterCubit extends Cubit { + MigrateRegisterCubit( + this._registrationService, { + required this.account, + required this.userData, + required this.bearerToken, + }) : super(const MigrateRegisterReady()); + + final RealUnitRegistrationService _registrationService; + final AWalletAccount account; + final RealUnitUserDataDto userData; + final String bearerToken; + + Future submit() async { + emit(const MigrateRegisterSubmitting()); + try { + final status = await _registrationService.registerWalletFor( + account, + userData, + bearerToken, + ); + switch (status) { + case RegistrationStatus.completed: + case RegistrationStatus.alreadyRegistered: + emit(const MigrateRegisterSuccess()); + case RegistrationStatus.pendingReview: + case RegistrationStatus.forwardingFailed: + emit(const MigrateRegisterPending()); + } + } on SigningCancelledException { + emit( + const MigrateRegisterFailure( + MigrateRegisterFailureReason.signatureCancelled, + canRetry: true, + ), + ); + } on BitboxNotConnectedException { + emit( + const MigrateRegisterFailure( + MigrateRegisterFailureReason.bitboxNotConnected, + canRetry: true, + ), + ); + } catch (e) { + emit( + MigrateRegisterFailure( + MigrateRegisterFailureReason.generic, + message: e.toString(), + canRetry: true, + ), + ); + } + } + + /// Mirror of KycLinkWalletCubit.retrySubmit. + Future retrySubmit() => submit(); +} diff --git a/lib/screens/migrate_bitbox/cubits/migrate_register/migrate_register_state.dart b/lib/screens/migrate_bitbox/cubits/migrate_register/migrate_register_state.dart new file mode 100644 index 000000000..f70446c31 --- /dev/null +++ b/lib/screens/migrate_bitbox/cubits/migrate_register/migrate_register_state.dart @@ -0,0 +1,45 @@ +part of 'migrate_register_cubit.dart'; + +enum MigrateRegisterFailureReason { + signatureCancelled, + bitboxNotConnected, + generic, +} + +sealed class MigrateRegisterState extends Equatable { + const MigrateRegisterState(); + + @override + List get props => []; +} + +class MigrateRegisterReady extends MigrateRegisterState { + const MigrateRegisterReady(); +} + +class MigrateRegisterSubmitting extends MigrateRegisterState { + const MigrateRegisterSubmitting(); +} + +class MigrateRegisterPending extends MigrateRegisterState { + const MigrateRegisterPending(); +} + +class MigrateRegisterSuccess extends MigrateRegisterState { + const MigrateRegisterSuccess(); +} + +class MigrateRegisterFailure extends MigrateRegisterState { + const MigrateRegisterFailure( + this.reason, { + this.message, + this.canRetry = false, + }); + + final MigrateRegisterFailureReason reason; + final String? message; + final bool canRetry; + + @override + List get props => [reason, message, canRetry]; +} diff --git a/lib/screens/migrate_bitbox/migrate_bitbox_page.dart b/lib/screens/migrate_bitbox/migrate_bitbox_page.dart index 8e7d67ce3..58bea76a5 100644 --- a/lib/screens/migrate_bitbox/migrate_bitbox_page.dart +++ b/lib/screens/migrate_bitbox/migrate_bitbox_page.dart @@ -54,11 +54,13 @@ class MigrateBitboxViewManager extends StatelessWidget { await showModalBottomSheet( context: context, isScrollControlled: true, - builder: (_) => ConnectBitboxPage( + builder: (sheetContext) => ConnectBitboxPage( acquireWallet: () => getIt().acquireUncommittedBitboxWallet('Luke-Skywallet'), - onFinish: (wallet) => - context.read().onDevicePaired(wallet as BitboxWallet), + onFinish: (wallet) { + Navigator.of(sheetContext).pop(); + context.read().onDevicePaired(wallet as BitboxWallet); + }, ), ); if (context.mounted) { @@ -72,6 +74,7 @@ class MigrateBitboxViewManager extends StatelessWidget { MigrateBitboxRegisterReady() || MigrateBitboxTransferReady() || MigrateBitboxRegistrationPending() || + MigrateBitboxSettlingTimeout() || MigrateBitboxFailure() || MigrateBitboxSuccess() => true, _ => false, @@ -86,9 +89,6 @@ class MigrateBitboxViewManager extends StatelessWidget { userData: userData, bitboxAddress: bitboxAddress, ), - MigrateBitboxRegistering() => _MigrateBitboxProgressPage( - label: S.of(context).migrateBitboxRegisterTitle, - ), MigrateBitboxRegistrationPending() => const MigrateBitboxRegistrationPendingPage(), MigrateBitboxTransferReady(:final fromAddress, :final toAddress, :final amount) => @@ -101,12 +101,22 @@ class MigrateBitboxViewManager extends StatelessWidget { toAddress: toAddress, amount: amount, ), + MigrateBitboxSettling() => _MigrateBitboxProgressPage( + label: S.of(context).migrateBitboxSettling, + ), + MigrateBitboxSettlingTimeout() => + const MigrateBitboxSettlingTimeoutPage(), + MigrateBitboxPreparingTransfer() => _MigrateBitboxProgressPage( + label: S.of(context).migrateBitboxPreparingTransfer, + ), MigrateBitboxCompleting() => _MigrateBitboxProgressPage( label: S.of(context).migrateBitboxCompleting, ), MigrateBitboxSuccess() => const MigrateBitboxSuccessPage(), - MigrateBitboxFailure(:final reason, :final canRetry) => MigrateBitboxFailurePage( + MigrateBitboxFailure(:final reason, :final message, :final canRetry) => + MigrateBitboxFailurePage( reason: reason, + message: message, canRetry: canRetry, ), }, diff --git a/lib/screens/migrate_bitbox/widgets/migrate_intro_view.dart b/lib/screens/migrate_bitbox/widgets/migrate_intro_view.dart index 0773210be..a36871481 100644 --- a/lib/screens/migrate_bitbox/widgets/migrate_intro_view.dart +++ b/lib/screens/migrate_bitbox/widgets/migrate_intro_view.dart @@ -45,7 +45,7 @@ class MigrateIntroView extends StatelessWidget { ), BlocBuilder( builder: (context, state) => Text( - S.of(context).migrateBitboxIntroBalance('${state.balance}'), + '${S.of(context).migrateBitboxIntroBalance}: ${state.balance} REALU', textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyLarge, ), diff --git a/lib/screens/migrate_bitbox/widgets/migrate_register_view.dart b/lib/screens/migrate_bitbox/widgets/migrate_register_view.dart index e31ad9259..3c4fb51ea 100644 --- a/lib/screens/migrate_bitbox/widgets/migrate_register_view.dart +++ b/lib/screens/migrate_bitbox/widgets/migrate_register_view.dart @@ -1,8 +1,13 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; import 'package:realunit_wallet/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/cubits/migrate_register/migrate_register_cubit.dart'; +import 'package:realunit_wallet/setup/di.dart'; import 'package:realunit_wallet/styles/colors.dart'; import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart'; @@ -17,6 +22,80 @@ class MigrateRegisterView extends StatelessWidget { final RealUnitUserDataDto userData; final String bitboxAddress; + @override + Widget build(BuildContext context) { + final parent = context.read(); + return BlocProvider( + create: (_) => MigrateRegisterCubit( + getIt(), + account: parent.draftAccount, + userData: userData, + bearerToken: parent.linkedJwt, + ), + child: _MigrateRegisterBody( + userData: userData, + bitboxAddress: bitboxAddress, + ), + ); + } +} + +class _MigrateRegisterBody extends StatelessWidget { + const _MigrateRegisterBody({ + required this.userData, + required this.bitboxAddress, + }); + + final RealUnitUserDataDto userData; + final String bitboxAddress; + + @override + Widget build(BuildContext context) => BlocConsumer( + listener: (context, state) { + if (state is MigrateRegisterSuccess) { + context.read().onRegisterCompleted(); + } + if (state is MigrateRegisterPending) { + context.read().onRegisterPending(); + } + }, + builder: (context, state) => PopScope( + canPop: state is! MigrateRegisterSubmitting, + child: switch (state) { + MigrateRegisterReady() => _MigrateRegisterForm( + userData: userData, + bitboxAddress: bitboxAddress, + isSubmitting: false, + ), + MigrateRegisterSubmitting() => _MigrateRegisterForm( + userData: userData, + bitboxAddress: bitboxAddress, + isSubmitting: true, + ), + MigrateRegisterFailure(:final reason, :final message, :final canRetry) => + _MigrateRegisterFailureView( + reason: reason, + message: message, + canRetry: canRetry, + ), + MigrateRegisterPending() || MigrateRegisterSuccess() => + const Center(child: CupertinoActivityIndicator()), + }, + ), + ); +} + +class _MigrateRegisterForm extends StatelessWidget { + const _MigrateRegisterForm({ + required this.userData, + required this.bitboxAddress, + required this.isSubmitting, + }); + + final RealUnitUserDataDto userData; + final String bitboxAddress; + final bool isSubmitting; + @override Widget build(BuildContext context) => Scaffold( appBar: AppBar(title: Text(S.of(context).migrateBitbox)), @@ -60,7 +139,67 @@ class MigrateRegisterView extends StatelessWidget { actions: [ AppFilledButton( label: S.of(context).migrateBitboxRegisterCta, - onPressed: () => context.read().register(), + state: isSubmitting + ? FilledButtonState.loading + : FilledButtonState.idle, + onPressed: isSubmitting + ? null + : () => context.read().submit(), + ), + ], + ), + ), + ), + ); +} + +class _MigrateRegisterFailureView extends StatelessWidget { + const _MigrateRegisterFailureView({ + required this.reason, + required this.message, + required this.canRetry, + }); + + final MigrateRegisterFailureReason reason; + final String? message; + final bool canRetry; + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text(S.of(context).migrateBitbox)), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: SafeArea( + child: ScrollableActionsLayout( + centerBody: true, + body: Column( + spacing: 16, + children: [ + Icon( + Icons.error_rounded, + size: 64, + color: RealUnitColors.status.red600, + ), + Text( + _failureMessage(context, reason, message), + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: RealUnitColors.neutral500), + ), + ], + ), + actions: [ + AppFilledButton( + label: S.of(context).retry, + onPressed: canRetry + ? () => context.read().retrySubmit() + : null, + ), + AppFilledButton( + variant: FilledButtonVariant.secondary, + label: S.of(context).close, + onPressed: () => context.pop(), ), ], ), @@ -91,6 +230,19 @@ class _MigrateRegisterInfoRow extends StatelessWidget { ); } +String _failureMessage( + BuildContext context, + MigrateRegisterFailureReason reason, + String? message, +) => switch (reason) { + MigrateRegisterFailureReason.signatureCancelled => + S.of(context).sendFailureSignatureCancelled, + MigrateRegisterFailureReason.bitboxNotConnected => + S.of(context).connectBitboxFailed, + MigrateRegisterFailureReason.generic => + message ?? S.of(context).connectBitboxFailed, +}; + String _truncateAddress(String address) { if (address.length <= 12) return address; return '${address.substring(0, 6)}…${address.substring(address.length - 4)}'; diff --git a/lib/screens/migrate_bitbox/widgets/migrate_result_views.dart b/lib/screens/migrate_bitbox/widgets/migrate_result_views.dart index 660ff4e9c..d5692fd4c 100644 --- a/lib/screens/migrate_bitbox/widgets/migrate_result_views.dart +++ b/lib/screens/migrate_bitbox/widgets/migrate_result_views.dart @@ -48,6 +48,46 @@ class MigrateBitboxRegistrationPendingPage extends StatelessWidget { ); } +class MigrateBitboxSettlingTimeoutPage extends StatelessWidget { + const MigrateBitboxSettlingTimeoutPage({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text(S.of(context).migrateBitbox)), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: SafeArea( + child: ScrollableActionsLayout( + centerBody: true, + body: Column( + spacing: 16, + children: [ + const Icon( + Icons.hourglass_top_rounded, + size: 64, + color: RealUnitColors.realUnitBlue, + ), + Text( + S.of(context).migrateBitboxSettlingTimeoutInfo, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: RealUnitColors.neutral500), + ), + ], + ), + actions: [ + AppFilledButton( + label: S.of(context).close, + onPressed: () => context.pop(), + ), + ], + ), + ), + ), + ); +} + class MigrateBitboxSuccessPage extends StatelessWidget { const MigrateBitboxSuccessPage({super.key}); @@ -97,10 +137,12 @@ class MigrateBitboxFailurePage extends StatelessWidget { const MigrateBitboxFailurePage({ super.key, required this.reason, + this.message, required this.canRetry, }); final MigrateBitboxFailureReason reason; + final String? message; final bool canRetry; @override @@ -120,7 +162,7 @@ class MigrateBitboxFailurePage extends StatelessWidget { color: RealUnitColors.status.red600, ), Text( - _failureMessage(context, reason), + message ?? _failureMessage(context, reason), textAlign: TextAlign.center, style: Theme.of( context, diff --git a/lib/screens/migrate_bitbox/widgets/migrate_transfer_view.dart b/lib/screens/migrate_bitbox/widgets/migrate_transfer_view.dart index 79840e16c..8729ccf87 100644 --- a/lib/screens/migrate_bitbox/widgets/migrate_transfer_view.dart +++ b/lib/screens/migrate_bitbox/widgets/migrate_transfer_view.dart @@ -111,7 +111,7 @@ class _EmbeddedSendProcessView extends StatelessWidget { }, listener: (context, state) { if (state is SendProcessSuccess) { - context.read().finishMigration(); + context.read().onTransferBroadcast(); } if (state case SendProcessFailure(:final reason, canRetry: false)) { context.read().onTransferFailedTerminally( @@ -125,33 +125,12 @@ class _EmbeddedSendProcessView extends StatelessWidget { S.of(context).sendPreparing, ), SendProcessSigning() => _progressLayout(context, S.of(context).sendSigning), - SendProcessFailure(:final reason, :final canRetry) => ScrollableActionsLayout( - centerBody: true, - body: Column( - spacing: 16, - children: [ - Icon( - Icons.error_rounded, - size: 64, - color: RealUnitColors.status.red600, - ), - Text( - _failureMessage(context, reason), - textAlign: TextAlign.center, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: RealUnitColors.neutral500), - ), - ], - ), - actions: [ - AppFilledButton( - label: S.of(context).retry, - onPressed: canRetry - ? () => context.read().retryConfirm() - : null, - ), - ], + SendProcessFailure(:final reason, :final canRetry) => MigrateTransferFailureView( + reason: reason, + canRetry: canRetry, + onRetry: canRetry + ? () => context.read().retryConfirm() + : null, ), SendProcessSuccess() => _progressLayout(context, S.of(context).sendPreparing), }, @@ -173,18 +152,60 @@ class _EmbeddedSendProcessView extends StatelessWidget { ), ); - String _failureMessage(BuildContext context, SendProcessFailureReason reason) => switch (reason) { - SendProcessFailureReason.signatureUnsupported => S.of(context).sendFailureSignatureUnsupported, - SendProcessFailureReason.signatureCancelled => S.of(context).sendFailureSignatureCancelled, - SendProcessFailureReason.gasFundingUnavailable => S.of(context).sendFailureGasUnavailable, - SendProcessFailureReason.invalidRequest => S.of(context).sendFailureInvalidRequest, - SendProcessFailureReason.registrationOrKycRequired => - S.of(context).sendFailureRegistrationOrKycRequired, - SendProcessFailureReason.confirmMismatch => S.of(context).sendFailureConfirmMismatch, - SendProcessFailureReason.generic => S.of(context).sendFailureGeneric, - }; } +class MigrateTransferFailureView extends StatelessWidget { + const MigrateTransferFailureView({ + super.key, + required this.reason, + required this.canRetry, + required this.onRetry, + }); + + final SendProcessFailureReason reason; + final bool canRetry; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) => ScrollableActionsLayout( + centerBody: true, + body: Column( + spacing: 16, + children: [ + Icon( + Icons.error_rounded, + size: 64, + color: RealUnitColors.status.red600, + ), + Text( + _failureMessage(context, reason), + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: RealUnitColors.neutral500), + ), + ], + ), + actions: [ + AppFilledButton( + label: S.of(context).retry, + onPressed: canRetry ? onRetry : null, + ), + ], + ); +} + +String _failureMessage(BuildContext context, SendProcessFailureReason reason) => switch (reason) { + SendProcessFailureReason.signatureUnsupported => S.of(context).sendFailureSignatureUnsupported, + SendProcessFailureReason.signatureCancelled => S.of(context).sendFailureSignatureCancelled, + SendProcessFailureReason.gasFundingUnavailable => S.of(context).sendFailureGasUnavailable, + SendProcessFailureReason.invalidRequest => S.of(context).sendFailureInvalidRequest, + SendProcessFailureReason.registrationOrKycRequired => + S.of(context).sendFailureRegistrationOrKycRequired, + SendProcessFailureReason.confirmMismatch => S.of(context).sendFailureConfirmMismatch, + SendProcessFailureReason.generic => S.of(context).sendFailureGeneric, +}; + class _MigrateTransferInfoRow extends StatelessWidget { const _MigrateTransferInfoRow({required this.label, required this.value}); diff --git a/test/helper/responsive_surface_catalog.dart b/test/helper/responsive_surface_catalog.dart index 3c25b5fbd..b54ce2bac 100644 --- a/test/helper/responsive_surface_catalog.dart +++ b/test/helper/responsive_surface_catalog.dart @@ -301,6 +301,13 @@ const kResponsiveSurfaceCatalog = [ 'test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart', productionPath: 'lib/screens/migrate_bitbox/widgets/migrate_result_views.dart', ), + ResponsiveSurface( + id: 'migrate_bitbox_settling_timeout_page', + description: 'BitBox migration transfer-settling timeout (close CTA)', + matrixTestPath: + 'test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart', + productionPath: 'lib/screens/migrate_bitbox/widgets/migrate_result_views.dart', + ), ResponsiveSurface( id: 'migrate_bitbox_success_page', description: 'BitBox migration success result (done CTA)', @@ -315,12 +322,13 @@ const kResponsiveSurfaceCatalog = [ 'test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart', productionPath: 'lib/screens/migrate_bitbox/widgets/migrate_result_views.dart', ), - // The embedded SendProcess failure layout is a private, transient builder - // branch in migrate_transfer_view.dart, so it cannot truthfully satisfy the - // catalog's public-widget reachability contract. Its retryable and terminal - // CTA behaviour is covered through MigrateTransferringView in - // migrate_transfer_view_test.dart instead of adding a misleading entry. - // + ResponsiveSurface( + id: 'migrate_bitbox_transfer_failure_view', + description: 'BitBox migration embedded transfer failure (retry CTA)', + matrixTestPath: + 'test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart', + productionPath: 'lib/screens/migrate_bitbox/widgets/migrate_transfer_view.dart', + ), // welcome_page remains intentionally excluded because it scrolls end-to-end // and has no separate sticky CTA. Not exhaustive — review responsibility // remains for every new sticky-CTA surface. diff --git a/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart b/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart index 60fa1c148..eda1c5452 100644 --- a/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart +++ b/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart @@ -1,3 +1,6 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/models/balance.dart'; @@ -8,7 +11,6 @@ import 'package:realunit_wallet/packages/service/dfx/dfx_kyc_service.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/address_already_linked_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; -import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_status.dart'; import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_info_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/models/wallet/real_unit_registration_state.dart'; @@ -69,7 +71,7 @@ const _userData = RealUnitUserDataDto( void main() { const softwareAddress = '0x0000000000000000000000000000000000000001'; - const oldJwt = 'old-jwt'; + const refreshedJwt = 'refreshed-jwt'; const newJwt = 'new-jwt'; const signature = '0xsigned'; @@ -150,25 +152,28 @@ void main() { when(() => sessionCache.saveSignature(any(), any())).thenAnswer((_) async {}); when(() => sessionCache.setAuthToken(any(), any())).thenReturn(null); - when(() => authService.getAuthToken()).thenAnswer((_) async => oldJwt); + when(() => authService.refreshAuthToken()).thenAnswer((_) async => refreshedJwt); when( () => authService.authenticateLinkedAccount(any(), any()), ).thenAnswer((_) async => newJwt); when( - () => registrationService.getRegistrationInfoWith(any()), + () => registrationService.getRegistrationInfo(), ).thenAnswer((_) async => info(RealUnitRegistrationState.alreadyRegistered)); when( - () => registrationService.registerWalletFor(any(), any(), any()), - ).thenAnswer((_) async => RegistrationStatus.completed); + () => registrationService.getRegistrationInfoWith(any()), + ).thenAnswer((_) async => info(RealUnitRegistrationState.alreadyRegistered)); when(() => walletService.persistBitboxWallet(any())).thenAnswer((_) async => persisted); when(() => walletService.setCurrentWallet(any())).thenAnswer((_) async {}); when(() => balanceService.updateBalance(any())).thenAnswer((_) async {}); when( () => balanceService.getBalance(any(), any()), ).thenAnswer((_) async => balance(5)); + when( + () => balanceService.fetchBalance(any()), + ).thenAnswer((_) async => balance(5)); }); - MigrateBitboxCubit buildCubit() { + MigrateBitboxCubit buildCubit({bool addCloseTearDown = true}) { final cubit = MigrateBitboxCubit( walletService, authService, @@ -176,7 +181,7 @@ void main() { balanceService, appStore, ); - addTearDown(cubit.close); + if (addCloseTearDown) addTearDown(cubit.close); return cubit; } @@ -198,6 +203,12 @@ void main() { expect(cubit.state, isA()); } + void drain(FakeAsync async) { + for (var i = 0; i < 10; i++) { + async.flushMicrotasks(); + } + } + group('$MigrateBitboxCubit pairing', () { test('starts in Intro and startPairing emits AwaitingDevice', () async { final cubit = buildCubit(); @@ -246,11 +257,37 @@ void main() { MigrateBitboxRegisterReady(_userData, draftAddress), ); verify( - () => authService.authenticateLinkedAccount(draftAccount, oldJwt), + () => authService.authenticateLinkedAccount(draftAccount, refreshedJwt), ).called(1); + verify(() => authService.refreshAuthToken()).called(1); + verifyNever(() => authService.getAuthToken()); verify(() => registrationService.getRegistrationInfoWith(newJwt)).called(1); }); + for (final sourceState in [ + RealUnitRegistrationState.addWallet, + RealUnitRegistrationState.newRegistration, + ]) { + test('$sourceState precheck aborts before refreshing or linking', () async { + when( + () => registrationService.getRegistrationInfo(), + ).thenAnswer((_) async => info(sourceState)); + final cubit = buildCubit(); + + await cubit.onDevicePaired(draft); + + expect( + cubit.state, + const MigrateBitboxFailure( + MigrateBitboxFailureReason.registrationMissing, + ), + ); + verifyNever(() => authService.refreshAuthToken()); + verifyNever(() => authService.authenticateLinkedAccount(any(), any())); + verifyNever(() => registrationService.getRegistrationInfoWith(any())); + }); + } + test('addWallet without userData fails loud and retry is a no-op', () async { when( () => registrationService.getRegistrationInfoWith(any()), @@ -267,7 +304,7 @@ void main() { ), ); await cubit.retry(); - verify(() => authService.getAuthToken()).called(1); + verify(() => authService.refreshAuthToken()).called(1); }); test('alreadyRegistered with manual review emits RegistrationPending', () async { @@ -302,7 +339,7 @@ void main() { ]); }); - test('newRegistration emits registrationMissing', () async { + test('newRegistration after linking is retryable and retries the precheck', () async { when( () => registrationService.getRegistrationInfoWith(any()), ).thenAnswer((_) async => info(RealUnitRegistrationState.newRegistration)); @@ -312,12 +349,18 @@ void main() { expect( cubit.state, - const MigrateBitboxFailure(MigrateBitboxFailureReason.registrationMissing), + const MigrateBitboxFailure( + MigrateBitboxFailureReason.generic, + message: 'wallet link did not attach to the account', + canRetry: true, + ), ); + await cubit.retry(); + verify(() => registrationService.getRegistrationInfo()).called(2); }); - test('missing old JWT emits generic failure with no pending retry', () async { - when(() => authService.getAuthToken()).thenAnswer((_) async => null); + test('missing refreshed JWT emits generic failure with no pending retry', () async { + when(() => authService.refreshAuthToken()).thenAnswer((_) async => null); final cubit = buildCubit(); await cubit.onDevicePaired(draft); @@ -327,7 +370,7 @@ void main() { cubit.state, const MigrateBitboxFailure(MigrateBitboxFailureReason.generic), ); - verify(() => authService.getAuthToken()).called(1); + verify(() => authService.refreshAuthToken()).called(1); verifyNever(() => authService.authenticateLinkedAccount(any(), any())); }); @@ -344,7 +387,7 @@ void main() { cubit.state, const MigrateBitboxFailure(MigrateBitboxFailureReason.addressAlreadyLinked), ); - verify(() => authService.getAuthToken()).called(1); + verify(() => authService.refreshAuthToken()).called(1); }); test('SigningCancelledException is retryable with the same draft', () async { @@ -368,9 +411,9 @@ void main() { ); await cubit.retry(); - verify(() => authService.getAuthToken()).called(2); + verify(() => authService.refreshAuthToken()).called(2); verify( - () => authService.authenticateLinkedAccount(draftAccount, oldJwt), + () => authService.authenticateLinkedAccount(draftAccount, refreshedJwt), ).called(2); }); @@ -395,7 +438,7 @@ void main() { ); await cubit.retry(); - verify(() => authService.getAuthToken()).called(2); + verify(() => authService.refreshAuthToken()).called(2); }); test('unexpected exception keeps its message and retries the same draft', () async { @@ -420,93 +463,61 @@ void main() { ); await cubit.retry(); - verify(() => authService.getAuthToken()).called(2); + verify(() => authService.refreshAuthToken()).called(2); }); }); - group('$MigrateBitboxCubit register', () { - test('is a no-op outside RegisterReady', () async { + group('$MigrateBitboxCubit registration handoff', () { + test('draftAccount and linkedJwt fail loud before pairing', () { final cubit = buildCubit(); - final initial = cubit.state; - await cubit.register(); + expect(() => cubit.draftAccount, throwsStateError); + expect(() => cubit.linkedJwt, throwsStateError); + }); - expect(cubit.state, same(initial)); - verifyNever(() => registrationService.registerWalletFor(any(), any(), any())); + test('draftAccount and linkedJwt expose the freshly linked values', () async { + final cubit = buildCubit(); + await reachRegisterReady(cubit); + + expect(cubit.draftAccount, same(draftAccount)); + expect(cubit.linkedJwt, newJwt); }); - for (final status in [ - RegistrationStatus.completed, - RegistrationStatus.alreadyRegistered, - ]) { - test('$status persists and prepares transfer', () async { - final cubit = buildCubit(); - await reachRegisterReady(cubit); - when( - () => registrationService.registerWalletFor(any(), any(), any()), - ).thenAnswer((_) async => status); + test('onRegisterCompleted is a no-op outside RegisterReady', () async { + final cubit = buildCubit(); - await cubit.register(); + await cubit.onRegisterCompleted(); - expect(cubit.state, isA()); - verify( - () => registrationService.registerWalletFor(draftAccount, _userData, newJwt), - ).called(1); - verify(() => walletService.persistBitboxWallet(draft)).called(1); - }); - } + verifyNever(() => walletService.persistBitboxWallet(any())); + }); - for (final status in [ - RegistrationStatus.pendingReview, - RegistrationStatus.forwardingFailed, - ]) { - test('$status emits RegistrationPending', () async { - final cubit = buildCubit(); - await reachRegisterReady(cubit); - when( - () => registrationService.registerWalletFor(any(), any(), any()), - ).thenAnswer((_) async => status); + test('onRegisterCompleted prepares the transfer from RegisterReady', () async { + final cubit = buildCubit(); + await reachRegisterReady(cubit); - await cubit.register(); + await cubit.onRegisterCompleted(); - expect(cubit.state, const MigrateBitboxRegistrationPending()); - }); - } + expect(cubit.state, isA()); + verify(() => walletService.persistBitboxWallet(draft)).called(1); + }); - final retryableErrors = <(Exception, MigrateBitboxFailureReason)>[ - (const SigningCancelledException(), MigrateBitboxFailureReason.signatureCancelled), - (const BitboxNotConnectedException(), MigrateBitboxFailureReason.bitboxNotConnected), - (Exception('registration failed'), MigrateBitboxFailureReason.generic), - ]; - for (final (error, reason) in retryableErrors) { - test('$error is retryable and retry invokes registerWalletFor again', () async { - final cubit = buildCubit(); - await reachRegisterReady(cubit); - var attempts = 0; - when( - () => registrationService.registerWalletFor(any(), any(), any()), - ).thenAnswer((_) async { - attempts++; - if (attempts == 1) throw error; - return RegistrationStatus.completed; - }); + test('onRegisterPending is a no-op outside RegisterReady', () { + final cubit = buildCubit(); + final initial = cubit.state; - await cubit.register(); + cubit.onRegisterPending(); - final failure = cubit.state as MigrateBitboxFailure; - expect(failure.reason, reason); - expect(failure.canRetry, isTrue); - if (reason == MigrateBitboxFailureReason.generic) { - expect(failure.message, 'Exception: registration failed'); - } + expect(cubit.state, same(initial)); + }); - await cubit.retry(); + test('onRegisterPending emits RegistrationPending from RegisterReady', () async { + final cubit = buildCubit(); + await reachRegisterReady(cubit); - verify( - () => registrationService.registerWalletFor(draftAccount, _userData, newJwt), - ).called(2); - }); - } + cubit.onRegisterPending(); + + expect(cubit.state, const MigrateBitboxRegistrationPending()); + }); }); group('$MigrateBitboxCubit transfer preparation', () { @@ -620,6 +631,33 @@ void main() { expect(cubit.state, isA()); }); + test('retry is single-flight while the linking precheck is pending', () async { + when( + () => authService.authenticateLinkedAccount(any(), any()), + ).thenThrow(Exception('link failed')); + final cubit = buildCubit(); + await cubit.onDevicePaired(draft); + + final precheck = Completer(); + when( + () => registrationService.getRegistrationInfo(), + ).thenAnswer((_) => precheck.future); + when( + () => authService.authenticateLinkedAccount(any(), any()), + ).thenAnswer((_) async => newJwt); + clearInteractions(registrationService); + + final first = cubit.retry(); + final second = cubit.retry(); + await second; + + expect(cubit.state, const MigrateBitboxLinking()); + verify(() => registrationService.getRegistrationInfo()).called(1); + + precheck.complete(info(RealUnitRegistrationState.alreadyRegistered)); + await first; + }); + test('matching signature is persisted before the new auth token', () async { when( () => balanceService.getBalance(any(), any()), @@ -653,4 +691,145 @@ void main() { expect(cubit.state, MigrateBitboxSuccess(persisted)); }); }); + + group('$MigrateBitboxCubit settling', () { + test('onTransferBroadcast is a no-op outside Transferring', () async { + final cubit = buildCubit(); + final initial = cubit.state; + + await cubit.onTransferBroadcast(); + + expect(cubit.state, same(initial)); + verifyNever(() => balanceService.fetchBalance(any())); + }); + + test('zero balance on the first tick finishes the migration', () { + fakeAsync((async) { + when( + () => balanceService.fetchBalance(any()), + ).thenAnswer((_) async => balance(0)); + final cubit = buildCubit(addCloseTearDown: false); + cubit.onDevicePaired(draft); + drain(async); + cubit.startTransfer(); + + cubit.onTransferBroadcast(); + drain(async); + expect(cubit.state, const MigrateBitboxSettling()); + async.elapse(const Duration(seconds: 3)); + drain(async); + + expect(cubit.state, MigrateBitboxSuccess(persisted)); + verify(() => walletService.setCurrentWallet(42)).called(1); + cubit.close(); + async.flushTimers(); + }); + }); + + test('a lower positive balance prepares a transfer for the remainder', () { + fakeAsync((async) { + when( + () => balanceService.fetchBalance(any()), + ).thenAnswer((_) async => balance(3)); + when( + () => balanceService.getBalance(any(), any()), + ).thenAnswer((_) async => balance(3)); + final cubit = buildCubit(addCloseTearDown: false); + cubit.onDevicePaired(draft); + drain(async); + cubit.startTransfer(); + cubit.onTransferBroadcast(); + drain(async); + + async.elapse(const Duration(seconds: 3)); + drain(async); + + expect( + cubit.state, + MigrateBitboxTransferReady( + fromAddress: softwareAddress, + toAddress: persistedAddress, + amount: 3, + ), + ); + cubit.close(); + async.flushTimers(); + }); + }); + + test('an unchanged balance times out after 20 attempts without switching wallets', () { + fakeAsync((async) { + final cubit = buildCubit(addCloseTearDown: false); + cubit.onDevicePaired(draft); + drain(async); + cubit.startTransfer(); + cubit.onTransferBroadcast(); + drain(async); + + for (var i = 0; i < 20; i++) { + async.elapse(const Duration(seconds: 3)); + drain(async); + } + + expect(cubit.state, const MigrateBitboxSettlingTimeout()); + verifyNever(() => walletService.setCurrentWallet(any())); + verify(() => balanceService.fetchBalance(softwareAddress)).called(20); + cubit.close(); + async.flushTimers(); + }); + }); + + test('a failed poll counts as an attempt and a later zero balance succeeds', () { + fakeAsync((async) { + var calls = 0; + when(() => balanceService.fetchBalance(any())).thenAnswer((_) async { + calls++; + if (calls == 1) throw Exception('balance unavailable'); + return balance(0); + }); + final cubit = buildCubit(addCloseTearDown: false); + cubit.onDevicePaired(draft); + drain(async); + cubit.startTransfer(); + cubit.onTransferBroadcast(); + drain(async); + + async.elapse(const Duration(seconds: 3)); + drain(async); + expect(cubit.state, const MigrateBitboxSettling()); + async.elapse(const Duration(seconds: 3)); + drain(async); + + expect(cubit.state, MigrateBitboxSuccess(persisted)); + expect(calls, 2); + cubit.close(); + async.flushTimers(); + }); + }); + + test('overlapping timer ticks do not start a second balance request', () { + fakeAsync((async) { + final pendingBalance = Completer(); + when( + () => balanceService.fetchBalance(any()), + ).thenAnswer((_) => pendingBalance.future); + final cubit = buildCubit(addCloseTearDown: false); + cubit.onDevicePaired(draft); + drain(async); + cubit.startTransfer(); + cubit.onTransferBroadcast(); + drain(async); + + async.elapse(const Duration(seconds: 6)); + drain(async); + + verify(() => balanceService.fetchBalance(softwareAddress)).called(1); + pendingBalance.complete(balance(0)); + drain(async); + expect(cubit.state, MigrateBitboxSuccess(persisted)); + cubit.close(); + async.flushTimers(); + }); + }); + }); } diff --git a/test/screens/migrate_bitbox/cubits/migrate_register/migrate_register_cubit_test.dart b/test/screens/migrate_bitbox/cubits/migrate_register/migrate_register_cubit_test.dart new file mode 100644 index 000000000..87d37e636 --- /dev/null +++ b/test/screens/migrate_bitbox/cubits/migrate_register/migrate_register_cubit_test.dart @@ -0,0 +1,187 @@ +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/exceptions/bitbox_exception.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_status.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/cubits/migrate_register/migrate_register_cubit.dart'; + +class _MockRegistrationService extends Mock implements RealUnitRegistrationService {} + +class _MockWalletAccount extends Mock implements AWalletAccount {} + +const _userData = RealUnitUserDataDto( + email: 'ada@example.com', + name: 'Ada Lovelace', + type: 'HUMAN', + phoneNumber: '+41 79 000 00 00', + birthday: '1815-12-10', + nationality: 'CH', + addressStreet: 'Bahnhofstrasse 1', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: 'CH', + swissTaxResidence: true, + lang: 'de', + kycData: KycPersonalData( + accountType: KycAccountType.personal, + firstName: 'Ada', + lastName: 'Lovelace', + phone: '+41 79 000 00 00', + address: KycAddress( + street: 'Bahnhofstrasse', + zip: '8000', + city: 'Zurich', + country: 41, + ), + ), +); + +void main() { + const bearerToken = 'linked-jwt'; + late _MockRegistrationService registrationService; + late _MockWalletAccount account; + + setUpAll(() { + registerFallbackValue(_MockWalletAccount()); + registerFallbackValue(_userData); + }); + + setUp(() { + registrationService = _MockRegistrationService(); + account = _MockWalletAccount(); + }); + + MigrateRegisterCubit build() => MigrateRegisterCubit( + registrationService, + account: account, + userData: _userData, + bearerToken: bearerToken, + ); + + test('starts ready with the supplied registration context', () { + final cubit = build(); + addTearDown(cubit.close); + + expect(cubit.state, const MigrateRegisterReady()); + expect(cubit.account, same(account)); + expect(cubit.userData, _userData); + expect(cubit.bearerToken, bearerToken); + }); + + for (final status in [ + RegistrationStatus.completed, + RegistrationStatus.alreadyRegistered, + ]) { + blocTest( + '$status emits Submitting then Success', + setUp: () { + when( + () => registrationService.registerWalletFor(any(), any(), any()), + ).thenAnswer((_) async => status); + }, + build: build, + act: (cubit) => cubit.submit(), + expect: () => [ + const MigrateRegisterSubmitting(), + const MigrateRegisterSuccess(), + ], + verify: (_) { + verify( + () => registrationService.registerWalletFor( + account, + _userData, + bearerToken, + ), + ).called(1); + }, + ); + } + + for (final status in [ + RegistrationStatus.pendingReview, + RegistrationStatus.forwardingFailed, + ]) { + blocTest( + '$status emits Submitting then Pending', + setUp: () { + when( + () => registrationService.registerWalletFor(any(), any(), any()), + ).thenAnswer((_) async => status); + }, + build: build, + act: (cubit) => cubit.submit(), + expect: () => [ + const MigrateRegisterSubmitting(), + const MigrateRegisterPending(), + ], + ); + } + + final failures = <(Exception, MigrateRegisterFailure)>[ + ( + const SigningCancelledException(), + const MigrateRegisterFailure( + MigrateRegisterFailureReason.signatureCancelled, + canRetry: true, + ), + ), + ( + const BitboxNotConnectedException(), + const MigrateRegisterFailure( + MigrateRegisterFailureReason.bitboxNotConnected, + canRetry: true, + ), + ), + ( + Exception('registration failed'), + const MigrateRegisterFailure( + MigrateRegisterFailureReason.generic, + message: 'Exception: registration failed', + canRetry: true, + ), + ), + ]; + + for (final (error, expectedFailure) in failures) { + blocTest( + '$error is classified as ${expectedFailure.reason}', + setUp: () { + when( + () => registrationService.registerWalletFor(any(), any(), any()), + ).thenThrow(error); + }, + build: build, + act: (cubit) => cubit.submit(), + expect: () => [const MigrateRegisterSubmitting(), expectedFailure], + ); + } + + blocTest( + 'retrySubmit re-runs registration', + setUp: () { + when( + () => registrationService.registerWalletFor(any(), any(), any()), + ).thenAnswer((_) async => RegistrationStatus.completed); + }, + build: build, + act: (cubit) => cubit.retrySubmit(), + expect: () => [ + const MigrateRegisterSubmitting(), + const MigrateRegisterSuccess(), + ], + verify: (_) { + verify( + () => registrationService.registerWalletFor( + account, + _userData, + bearerToken, + ), + ).called(1); + }, + ); +} diff --git a/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart b/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart index 4c187f6d5..c118116d2 100644 --- a/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart +++ b/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart @@ -22,6 +22,7 @@ import 'package:realunit_wallet/packages/service/dfx/real_unit_transfer_service. import 'package:realunit_wallet/packages/service/wallet_service.dart'; import 'package:realunit_wallet/packages/utils/default_assets.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/connect_bitbox_page.dart'; import 'package:realunit_wallet/screens/home/bloc/home_bloc.dart'; import 'package:realunit_wallet/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart'; @@ -60,6 +61,8 @@ class _MockBitboxWallet extends Mock implements BitboxWallet {} class _MockDebugWallet extends Mock implements DebugWallet {} +class _MockWalletAccount extends Mock implements AWalletAccount {} + const _userData = RealUnitUserDataDto( email: 'ada@example.com', name: 'Ada Lovelace', @@ -94,6 +97,7 @@ void main() { late _MockHomeBloc homeBloc; late _MockBitboxWallet bitboxWallet; late _MockWalletService walletService; + late _MockWalletAccount draftAccount; Balance fixtureBalance() => Balance( chainId: realUnitAsset.chainId, @@ -106,6 +110,7 @@ void main() { setUpAll(() { registerFallbackValue(fixtureBalance()); registerFallbackValue(_MockBitboxWallet()); + registerFallbackValue(_MockWalletAccount()); registerFallbackValue(const LoadCurrentWalletEvent()); walletService = _MockWalletService(); @@ -148,6 +153,7 @@ void main() { cubit = _MockMigrateBitboxCubit(); homeBloc = _MockHomeBloc(); bitboxWallet = _MockBitboxWallet(); + draftAccount = _MockWalletAccount(); when(() => homeBloc.state).thenReturn(const HomeState()); whenListen( homeBloc, @@ -157,7 +163,11 @@ void main() { when(() => homeBloc.add(any())).thenReturn(null); when(() => cubit.cancelPairing()).thenReturn(null); when(() => cubit.onDevicePaired(any())).thenAnswer((_) async {}); - when(() => cubit.finishMigration()).thenAnswer((_) async {}); + when(() => cubit.draftAccount).thenReturn(draftAccount); + when(() => cubit.linkedJwt).thenReturn('linked-jwt'); + when(() => cubit.onRegisterCompleted()).thenAnswer((_) async {}); + when(() => cubit.onRegisterPending()).thenReturn(null); + when(() => cubit.onTransferBroadcast()).thenAnswer((_) async {}); when( () => cubit.onTransferFailedTerminally(any()), ).thenReturn(null); @@ -200,6 +210,7 @@ void main() { (const MigrateBitboxAwaitingDevice(), MigrateIntroView), (const MigrateBitboxRegisterReady(_userData, '0xbitbox'), MigrateRegisterView), (const MigrateBitboxRegistrationPending(), MigrateBitboxRegistrationPendingPage), + (const MigrateBitboxSettlingTimeout(), MigrateBitboxSettlingTimeoutPage), ( const MigrateBitboxTransferReady( fromAddress: '0xfrom', @@ -229,7 +240,8 @@ void main() { final progressCases = <(MigrateBitboxState, String)>[ (const MigrateBitboxLinking(), 'linking'), - (const MigrateBitboxRegistering(), 'registering'), + (const MigrateBitboxPreparingTransfer(), 'preparing transfer'), + (const MigrateBitboxSettling(), 'settling'), (const MigrateBitboxCompleting(), 'completing'), ]; for (final (state, label) in progressCases) { @@ -248,8 +260,8 @@ void main() { (const MigrateBitboxAwaitingDevice(), false), (const MigrateBitboxLinking(), false), (const MigrateBitboxRegisterReady(_userData, '0xbitbox'), true), - (const MigrateBitboxRegistering(), false), (const MigrateBitboxRegistrationPending(), true), + (const MigrateBitboxPreparingTransfer(), false), ( const MigrateBitboxTransferReady( fromAddress: '0xfrom', @@ -259,6 +271,8 @@ void main() { true, ), (const MigrateBitboxTransferring(toAddress: '0xto', amount: 9), false), + (const MigrateBitboxSettling(), false), + (const MigrateBitboxSettlingTimeout(), true), (const MigrateBitboxCompleting(), false), (MigrateBitboxSuccess(mappingWallet), true), ( @@ -328,15 +342,14 @@ void main() { expect(await sheet.acquireWallet!(), same(bitboxWallet)); sheet.onFinish(bitboxWallet); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); verify( () => walletService.acquireUncommittedBitboxWallet('Luke-Skywallet'), ).called(1); verify(() => cubit.onDevicePaired(bitboxWallet)).called(1); - - await tester.tap(find.text(S.current.cancel)); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 400)); + expect(find.byType(ConnectBitboxPage), findsNothing); verify(() => cubit.cancelPairing()).called(1); }); diff --git a/test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart b/test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart index 89b80f64b..693ef68ef 100644 --- a/test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart +++ b/test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart @@ -13,12 +13,15 @@ import 'package:realunit_wallet/packages/repository/balance_repository.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; import 'package:realunit_wallet/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart'; import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_intro_view.dart'; import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_register_view.dart'; import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_result_views.dart'; import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_transfer_view.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_process/send_process_cubit.dart'; import 'package:realunit_wallet/setup/routing/routes/app_routes.dart'; import 'package:realunit_wallet/styles/themes.dart'; import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; @@ -34,6 +37,10 @@ class _MockAppStore extends Mock implements AppStore {} class _MockApiConfig extends Mock implements ApiConfig {} +class _MockRegistrationService extends Mock implements RealUnitRegistrationService {} + +class _MockWalletAccount extends Mock implements AWalletAccount {} + const _userData = RealUnitUserDataDto( email: 'ada@example.com', name: 'Ada Lovelace with an intentionally long accessibility name', @@ -74,6 +81,7 @@ void main() { setUpAll(() { registerFallbackValue(fixtureBalance()); + registerFallbackValue(_MockWalletAccount()); final appStore = _MockAppStore(); final apiConfig = _MockApiConfig(); final balanceRepository = _MockBalanceRepository(); @@ -85,6 +93,9 @@ void main() { ).thenAnswer((_) => Stream.value(fixtureBalance())); GetIt.instance.registerSingleton(appStore); GetIt.instance.registerSingleton(balanceRepository); + GetIt.instance.registerSingleton( + _MockRegistrationService(), + ); }); tearDownAll(() async => GetIt.instance.reset()); @@ -98,7 +109,8 @@ void main() { initialState: const MigrateBitboxIntro(), ); when(() => cubit.startPairing()).thenAnswer((_) async {}); - when(() => cubit.register()).thenAnswer((_) async {}); + when(() => cubit.draftAccount).thenReturn(_MockWalletAccount()); + when(() => cubit.linkedJwt).thenReturn('linked-jwt'); when(() => cubit.startTransfer()).thenReturn(null); when(() => cubit.retry()).thenAnswer((_) async {}); return cubit; @@ -179,6 +191,11 @@ void main() { () => const MigrateBitboxRegistrationPendingPage(), MigrateBitboxRegistrationPendingPage, ), + ( + 'settling-timeout', + () => const MigrateBitboxSettlingTimeoutPage(), + MigrateBitboxSettlingTimeoutPage, + ), ( 'success', () => const MigrateBitboxSuccessPage(), @@ -200,6 +217,15 @@ void main() { ), MigrateBitboxFailurePage, ), + ( + 'transfer-failure-retryable', + () => MigrateTransferFailureView( + reason: SendProcessFailureReason.generic, + canRetry: true, + onRetry: () {}, + ), + MigrateTransferFailureView, + ), ]; for (final (surfaceId, buildSurface, surfaceType) in surfaces) { diff --git a/test/screens/migrate_bitbox/widgets/migrate_register_view_test.dart b/test/screens/migrate_bitbox/widgets/migrate_register_view_test.dart index fc1c81145..9ca71a1a7 100644 --- a/test/screens/migrate_bitbox/widgets/migrate_register_view_test.dart +++ b/test/screens/migrate_bitbox/widgets/migrate_register_view_test.dart @@ -1,9 +1,20 @@ +import 'dart:async'; + import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/registration_status.dart'; import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/packages/wallet/exceptions/signing_cancelled_exception.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; import 'package:realunit_wallet/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart'; import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_register_view.dart'; import 'package:realunit_wallet/widgets/buttons/app_filled_button.dart'; @@ -13,6 +24,10 @@ import '../../../helper/pump_app.dart'; class _MockMigrateBitboxCubit extends MockCubit implements MigrateBitboxCubit {} +class _MockRegistrationService extends Mock implements RealUnitRegistrationService {} + +class _MockWalletAccount extends Mock implements AWalletAccount {} + const _userData = RealUnitUserDataDto( email: 'ada@example.com', name: 'Ada Lovelace', @@ -41,27 +56,46 @@ const _userData = RealUnitUserDataDto( ); void main() { - late _MockMigrateBitboxCubit cubit; + late _MockMigrateBitboxCubit parentCubit; + late _MockRegistrationService registrationService; + late _MockWalletAccount account; + + setUpAll(() { + registerFallbackValue(_MockWalletAccount()); + registerFallbackValue(_userData); + }); setUp(() { - cubit = _MockMigrateBitboxCubit(); - when(() => cubit.state).thenReturn( + parentCubit = _MockMigrateBitboxCubit(); + registrationService = _MockRegistrationService(); + account = _MockWalletAccount(); + when(() => parentCubit.state).thenReturn( const MigrateBitboxRegisterReady(_userData, '0x1234567890abcdef'), ); whenListen( - cubit, + parentCubit, const Stream.empty(), initialState: const MigrateBitboxRegisterReady( _userData, '0x1234567890abcdef', ), ); - when(() => cubit.register()).thenAnswer((_) async {}); + when(() => parentCubit.draftAccount).thenReturn(account); + when(() => parentCubit.linkedJwt).thenReturn('linked-jwt'); + when(() => parentCubit.onRegisterCompleted()).thenAnswer((_) async {}); + when(() => parentCubit.onRegisterPending()).thenReturn(null); + GetIt.instance.registerSingleton( + registrationService, + ); + }); + + tearDown(() async { + await GetIt.instance.unregister(); }); Future pumpView(WidgetTester tester, String address) => tester.pumpApp( BlocProvider.value( - value: cubit, + value: parentCubit, child: MigrateRegisterView( userData: _userData, bitboxAddress: address, @@ -69,16 +103,26 @@ void main() { ), ); - testWidgets('shows user data, truncates a long address, and registers', ( + testWidgets('ready shows user data, truncates a long address, and submits', ( tester, ) async { + when( + () => registrationService.registerWalletFor(any(), any(), any()), + ).thenAnswer((_) async => RegistrationStatus.completed); await pumpView(tester, '0x1234567890abcdef'); expect(find.text('Ada Lovelace'), findsOneWidget); expect(find.text('0x1234…cdef'), findsOneWidget); await tester.tap(find.byType(AppFilledButton)); + await tester.pump(); - verify(() => cubit.register()).called(1); + verify( + () => registrationService.registerWalletFor( + account, + _userData, + 'linked-jwt', + ), + ).called(1); }); testWidgets('keeps a short address unchanged', (tester) async { @@ -86,4 +130,98 @@ void main() { expect(find.text('0x1234'), findsOneWidget); }); + + testWidgets('submitting disables back navigation and shows a loading button', ( + tester, + ) async { + final pending = Completer(); + when( + () => registrationService.registerWalletFor(any(), any(), any()), + ).thenAnswer((_) => pending.future); + await pumpView(tester, '0x1234'); + + await tester.tap(find.byType(AppFilledButton)); + await tester.pump(); + + expect(tester.widget(find.byType(PopScope)).canPop, isFalse); + final button = tester.widget(find.bySubtype()); + expect(button.onPressed, isNull); + + pending.complete(RegistrationStatus.completed); + await tester.pump(); + }); + + final failureCases = <(Exception, String)>[ + ( + const SigningCancelledException(), + S.current.sendFailureSignatureCancelled, + ), + ( + const BitboxNotConnectedException(), + S.current.connectBitboxFailed, + ), + (Exception('registration failed'), 'Exception: registration failed'), + ]; + + for (final (error, expectedMessage) in failureCases) { + testWidgets('$error shows the classified failure and retries', (tester) async { + var calls = 0; + when( + () => registrationService.registerWalletFor(any(), any(), any()), + ).thenAnswer((_) async { + calls++; + if (calls == 1) throw error; + return RegistrationStatus.completed; + }); + await pumpView(tester, '0x1234'); + + await tester.tap(find.byType(AppFilledButton)); + await tester.pump(); + await tester.pump(); + + expect(find.text(expectedMessage), findsOneWidget); + expect(tester.widget(find.byType(PopScope)).canPop, isTrue); + expect(find.text(S.current.close), findsOneWidget); + + await tester.tap(find.widgetWithText(AppFilledButton, S.current.retry)); + await tester.pump(); + await tester.pump(); + expect(calls, 2); + }); + } + + testWidgets('completed registration notifies the parent and shows a transient spinner', ( + tester, + ) async { + when( + () => registrationService.registerWalletFor(any(), any(), any()), + ).thenAnswer((_) async => RegistrationStatus.completed); + await pumpView(tester, '0x1234'); + + await tester.tap(find.byType(AppFilledButton)); + await tester.pump(); + await tester.pump(); + + verify(() => parentCubit.onRegisterCompleted()).called(1); + expect(find.byType(CupertinoActivityIndicator), findsOneWidget); + }); + + for (final status in [ + RegistrationStatus.pendingReview, + RegistrationStatus.forwardingFailed, + ]) { + testWidgets('$status notifies the parent registration is pending', (tester) async { + when( + () => registrationService.registerWalletFor(any(), any(), any()), + ).thenAnswer((_) async => status); + await pumpView(tester, '0x1234'); + + await tester.tap(find.byType(AppFilledButton)); + await tester.pump(); + await tester.pump(); + + verify(() => parentCubit.onRegisterPending()).called(1); + expect(find.byType(CupertinoActivityIndicator), findsOneWidget); + }); + } } diff --git a/test/screens/migrate_bitbox/widgets/migrate_transfer_view_test.dart b/test/screens/migrate_bitbox/widgets/migrate_transfer_view_test.dart index c5b213b45..d8f3dd48e 100644 --- a/test/screens/migrate_bitbox/widgets/migrate_transfer_view_test.dart +++ b/test/screens/migrate_bitbox/widgets/migrate_transfer_view_test.dart @@ -95,7 +95,7 @@ void main() { ), ); when(() => migrateCubit.startTransfer()).thenReturn(null); - when(() => migrateCubit.finishMigration()).thenAnswer((_) async {}); + when(() => migrateCubit.onTransferBroadcast()).thenAnswer((_) async {}); when( () => migrateCubit.onTransferFailedTerminally(any()), ).thenReturn(null); @@ -160,7 +160,7 @@ void main() { expect(find.text('0xabcd'), findsOneWidget); }); - testWidgets('embedded process renders preparing, signing, then finishes on success', ( + testWidgets('embedded process renders preparing, signing, then starts settling on success', ( tester, ) async { final prepare = Completer(); @@ -187,7 +187,7 @@ void main() { await tester.pump(); await tester.pump(); - verify(() => migrateCubit.finishMigration()).called(1); + verify(() => migrateCubit.onTransferBroadcast()).called(1); }); testWidgets('retryable failure CTA reconfirms the retained transfer intent', ( @@ -221,7 +221,7 @@ void main() { confirmedAmount: 5, ), ).called(2); - verify(() => migrateCubit.finishMigration()).called(1); + verify(() => migrateCubit.onTransferBroadcast()).called(1); }); final terminalCases = <(String, Exception, String)>[ From 55aae30d38da3516ea4c8340a68dd0470da0cfe0 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:41:51 +0200 Subject: [PATCH 08/26] test: bind cached auth tokens to the stub wallet address in missed suites The address-bound token slot change had not been carried into the brokerbot and buy-payment-info suites. --- .../dfx/dfx_brokerbot_service_test.dart | 46 ++++++++++++++++--- ...al_unit_buy_payment_info_service_test.dart | 10 +++- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/test/packages/service/dfx/dfx_brokerbot_service_test.dart b/test/packages/service/dfx/dfx_brokerbot_service_test.dart index 0fff8002a..131d1c548 100644 --- a/test/packages/service/dfx/dfx_brokerbot_service_test.dart +++ b/test/packages/service/dfx/dfx_brokerbot_service_test.dart @@ -12,7 +12,10 @@ import 'package:realunit_wallet/packages/service/dfx/dfx_brokerbot_service.dart' import 'package:realunit_wallet/packages/service/dfx/exceptions/api_exception.dart'; import 'package:realunit_wallet/packages/service/session_cache.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/styles/currency.dart'; +import 'package:web3dart/web3dart.dart'; class _MockAppStore extends Mock implements AppStore {} @@ -20,6 +23,34 @@ class _MockCacheRepository extends Mock implements CacheRepository {} class _MockWalletService extends Mock implements WalletService {} +class _StubCreds extends Fake implements CredentialsWithKnownAddress { + @override + EthereumAddress get address => + EthereumAddress.fromHex('0x0000000000000000000000000000000000000001'); +} + +class _StubWalletAccount extends AWalletAccount { + _StubWalletAccount() : super(0, _StubCreds()); + @override + Future signMessage(String message, {int addressIndex = 0}) async => '0x'; +} + +class _StubWallet extends AWallet { + _StubWallet() : super(1, 'Stub'); + @override + WalletType get walletType => WalletType.software; + @override + AWalletAccount get primaryAccount => _StubWalletAccount(); + @override + AWalletAccount get currentAccount => _StubWalletAccount(); +} + +/// The address every cached auth token in this file is bound to — must match +/// [_StubCreds.address] so `getAuthToken`'s address check treats the token as +/// belonging to the current wallet. +final _stubAddress = + EthereumAddress.fromHex('0x0000000000000000000000000000000000000001').hexEip55; + void main() { late _MockAppStore appStore; late _MockWalletService walletService; @@ -31,6 +62,7 @@ void main() { sessionCache = SessionCache(_MockCacheRepository()); when(() => appStore.sessionCache).thenReturn(sessionCache); when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => appStore.wallet).thenReturn(_StubWallet()); when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); }); @@ -172,7 +204,7 @@ void main() { group('getSellPrice', () { test('GETs /sellPrice with the Bearer JWT', () async { - sessionCache.setAuthToken('jwt-1'); + sessionCache.setAuthToken('jwt-1', _stubAddress); String? auth; Uri? uri; final client = MockClient((request) async { @@ -198,7 +230,7 @@ void main() { }); test('throws ApiException with the JSON body on non-200', () async { - sessionCache.setAuthToken('jwt-1'); + sessionCache.setAuthToken('jwt-1', _stubAddress); final client = MockClient( (_) async => http.Response( jsonEncode({'statusCode': 422, 'message': 'no'}), @@ -230,7 +262,7 @@ void main() { group('getSellShares', () { test('GETs /sellShares with the Bearer JWT and maps the response', () async { - sessionCache.setAuthToken('jwt-2'); + sessionCache.setAuthToken('jwt-2', _stubAddress); final client = MockClient((request) async { expect(request.headers['Authorization'], 'Bearer jwt-2'); return http.Response( @@ -251,7 +283,7 @@ void main() { }); test('throws ApiException on non-200', () async { - sessionCache.setAuthToken('jwt-2'); + sessionCache.setAuthToken('jwt-2', _stubAddress); final client = MockClient( (_) async => http.Response( jsonEncode({'statusCode': 503, 'message': 'broker offline'}), @@ -266,7 +298,7 @@ void main() { }); test('normalises a comma decimal separator before parsing (300,75 → amount=300.75)', () async { - sessionCache.setAuthToken('jwt-2'); + sessionCache.setAuthToken('jwt-2', _stubAddress); Uri? uri; final client = MockClient((request) async { uri = request.url; @@ -333,7 +365,7 @@ void main() { }); test('getSellPrice with non-JSON 200 throws FormatException', () { - sessionCache.setAuthToken('jwt-test'); + sessionCache.setAuthToken('jwt-test', _stubAddress); final client = MockClient((_) async => http.Response('not json', 200)); expect( () => buildLocal(client).getSellPrice('10', Currency.chf), @@ -350,7 +382,7 @@ void main() { }); test('getSellShares with non-JSON 200 throws FormatException', () { - sessionCache.setAuthToken('jwt-test'); + sessionCache.setAuthToken('jwt-test', _stubAddress); final client = MockClient((_) async => http.Response('not json', 200)); expect( () => buildLocal(client).getSellShares('100', Currency.chf), diff --git a/test/packages/service/dfx/real_unit_buy_payment_info_service_test.dart b/test/packages/service/dfx/real_unit_buy_payment_info_service_test.dart index 32daf97d9..592adaeac 100644 --- a/test/packages/service/dfx/real_unit_buy_payment_info_service_test.dart +++ b/test/packages/service/dfx/real_unit_buy_payment_info_service_test.dart @@ -13,7 +13,9 @@ import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/buy_exce import 'package:realunit_wallet/packages/service/dfx/real_unit_buy_payment_info_service.dart'; import 'package:realunit_wallet/packages/service/session_cache.dart'; import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; import 'package:realunit_wallet/styles/currency.dart'; +import 'package:web3dart/web3dart.dart'; class MockApiConfig extends Mock implements ApiConfig {} @@ -89,7 +91,13 @@ void main() { AppStore buildAppStore(Future Function(http.Request) handler) { final client = MockClient(handler); - return TestAppStore(client, () => apiConfig)..sessionCache.setAuthToken('test-auth-token'); + // The cached token is address-bound: give the store a wallet with the same + // address so getAuthToken's identity check accepts the cache hit. + final stubAddress = + EthereumAddress.fromHex('0x0000000000000000000000000000000000000001').hexEip55; + return TestAppStore(client, () => apiConfig) + ..wallet = DebugWallet(1, 'Debug', stubAddress) + ..sessionCache.setAuthToken('test-auth-token', stubAddress); } group('$RealUnitBuyPaymentInfoService', () { From 4b5ccb57c4a8caf1f6e054c3f1350625d92585bc Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:44:37 +0200 Subject: [PATCH 09/26] test: finish address-bound token adaptation and drop invalid const Five blockchain-api suite call sites were missed by the address-binding change; ClientException has no const constructor. --- test/packages/service/balance_service_test.dart | 2 +- .../service/dfx/dfx_blockchain_api_service_test.dart | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/test/packages/service/balance_service_test.dart b/test/packages/service/balance_service_test.dart index b30abf666..d81176a01 100644 --- a/test/packages/service/balance_service_test.dart +++ b/test/packages/service/balance_service_test.dart @@ -198,7 +198,7 @@ void main() { test('rethrows a transport failure without persisting', () async { final appStore = buildAppStore( - (_) async => throw const http.ClientException('offline'), + (_) async => throw http.ClientException('offline'), ); final service = BalanceService(balanceRepository, appStore); diff --git a/test/packages/service/dfx/dfx_blockchain_api_service_test.dart b/test/packages/service/dfx/dfx_blockchain_api_service_test.dart index 8ca8e3826..424321115 100644 --- a/test/packages/service/dfx/dfx_blockchain_api_service_test.dart +++ b/test/packages/service/dfx/dfx_blockchain_api_service_test.dart @@ -49,7 +49,7 @@ void main() { group('$DfxBlockchainApiService', () { test('getEthBalance posts the address + chain + asset id with the JWT', () async { - sessionCache.setAuthToken('jwt-abc'); + sessionCache.setAuthToken('jwt-abc', authWallet.currentAccount.primaryAddress.address.hexEip55); Map? capturedBody; Map? capturedHeaders; final client = MockClient((request) async { @@ -79,7 +79,7 @@ void main() { test('uses "Sepolia" as the blockchain name on the testnet chain', () async { when(() => appStore.apiConfig) .thenReturn(const ApiConfig(networkMode: NetworkMode.testnet)); - sessionCache.setAuthToken('jwt-abc'); + sessionCache.setAuthToken('jwt-abc', authWallet.currentAccount.primaryAddress.address.hexEip55); Map? capturedBody; final client = MockClient((request) async { capturedBody = jsonDecode(request.body) as Map; @@ -92,7 +92,7 @@ void main() { }); test('returns 0.0 when the balances list is empty', () async { - sessionCache.setAuthToken('jwt-abc'); + sessionCache.setAuthToken('jwt-abc', authWallet.currentAccount.primaryAddress.address.hexEip55); final client = MockClient((_) async => http.Response( jsonEncode({'balances': []}), 200, @@ -102,7 +102,7 @@ void main() { }); test('accepts a 201 response in addition to 200', () async { - sessionCache.setAuthToken('jwt-abc'); + sessionCache.setAuthToken('jwt-abc', authWallet.currentAccount.primaryAddress.address.hexEip55); final client = MockClient((_) async => http.Response( jsonEncode({ 'balances': [ @@ -116,7 +116,7 @@ void main() { }); test('throws ApiException on a non-2xx response', () async { - sessionCache.setAuthToken('jwt-abc'); + sessionCache.setAuthToken('jwt-abc', authWallet.currentAccount.primaryAddress.address.hexEip55); // 4xx other than 401 — bypasses the refresh-on-401 retry path and is // surfaced to the caller directly. The dedicated 401-retry behaviour is // covered in dfx_auth_service_test.dart. From 59e9f6154f66cf706975f4ad736cab23539f22aa Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:00:37 +0200 Subject: [PATCH 10/26] test(migration): stabilize new suites after review-round-1 fixes Settling remainder case now stubs a genuinely lower follow-up balance, S.current is only read after localization is pumped, the PopScope matrix targets the outer manager scope (the register step page nests its own), the malformed-JSON brokerbot group gets the wallet stub its sibling setup already had, and the auth-service suite binds cached tokens to the stub wallet address and instruments saveSignature calls explicitly. --- .../service/dfx/dfx_auth_service_test.dart | 56 +++++++++++++------ .../dfx/dfx_brokerbot_service_test.dart | 1 + .../migrate_bitbox_cubit_test.dart | 6 +- .../migrate_bitbox_page_test.dart | 2 +- .../widgets/migrate_register_view_test.dart | 21 ++++--- 5 files changed, 56 insertions(+), 30 deletions(-) diff --git a/test/packages/service/dfx/dfx_auth_service_test.dart b/test/packages/service/dfx/dfx_auth_service_test.dart index a0e66c2eb..ab8dc86a8 100644 --- a/test/packages/service/dfx/dfx_auth_service_test.dart +++ b/test/packages/service/dfx/dfx_auth_service_test.dart @@ -230,6 +230,7 @@ void main() { when(() => sessionCache.signedMessage).thenReturn(null); when(() => sessionCache.authToken).thenReturn(null); when(() => sessionCache.authTokenAddress).thenReturn(null); + when(() => sessionCache.loadSignature()).thenAnswer((_) async {}); when(() => sessionCache.saveSignature(any(), any(), any())).thenAnswer((_) async {}); }); @@ -319,9 +320,16 @@ void main() { group('getAuthToken', () { test('returns the cached auth token without re-signing', () async { + final walletAddress = walletAccount.primaryAddress.address.hexEip55; when(() => sessionCache.authToken).thenReturn('cached.jwt.token'); + when(() => sessionCache.authTokenAddress).thenReturn(walletAddress); - final token = await buildService().getAuthToken(); + final token = await _SignatureTestAuthService( + appStore, + walletService, + walletAccount, + walletAddress, + ).getAuthToken(); expect(token, 'cached.jwt.token'); expect(walletAccount.signCallCount, 0); @@ -1033,6 +1041,14 @@ void main() { test( 'cache-miss: signs, caches, POSTs /v1/auth with link Bearer + body, returns accessToken', () async { + var saveCalls = 0; + List? savedArguments; + when( + () => sessionCache.saveSignature(any(), any(), any()), + ).thenAnswer((invocation) async { + saveCalls++; + savedArguments = invocation.positionalArguments; + }); Map? sentBody; Map? sentHeaders; String? sentMethod; @@ -1045,7 +1061,8 @@ void main() { return http.Response(jsonEncode({'accessToken': 'jwt-for-new-address'}), 201); }); - final token = await buildService(client).authenticateLinkedAccount( + final service = buildService(client); + final token = await service.authenticateLinkedAccount( account, linkBearerToken, ); @@ -1059,14 +1076,12 @@ void main() { expect(sentBody!['address'], accountAddressEip55); expect(sentBody!['signature'], stubSignature); expect(account.signCallCount, 1); - final service = buildService(client); - verify( - () => sessionCache.saveSignature( - accountAddressEip55, - stubSignature, - service.buildSignMessage(accountAddressEip55), - ), - ).called(1); + expect(saveCalls, 1); + expect(savedArguments, [ + accountAddressEip55, + stubSignature, + service.buildSignMessage(accountAddressEip55), + ]); // Returned token must NOT be written to the session cache — the // caller owns the identity switch. verifyNever(() => sessionCache.setAuthToken(any(), any())); @@ -1098,6 +1113,14 @@ void main() { }); test('cache message mismatch re-signs before authenticating the linked account', () async { + var saveCalls = 0; + List? savedArguments; + when( + () => sessionCache.saveSignature(any(), any(), any()), + ).thenAnswer((invocation) async { + saveCalls++; + savedArguments = invocation.positionalArguments; + }); when(() => sessionCache.signature).thenReturn(stubSignature); when(() => sessionCache.signatureAddress).thenReturn(accountAddressEip55); when(() => sessionCache.signedMessage).thenReturn('wrong-environment-message'); @@ -1109,13 +1132,12 @@ void main() { await service.authenticateLinkedAccount(account, linkBearerToken); expect(account.signCallCount, 1); - verify( - () => sessionCache.saveSignature( - accountAddressEip55, - stubSignature, - service.buildSignMessage(accountAddressEip55), - ), - ).called(1); + expect(saveCalls, 1); + expect(savedArguments, [ + accountAddressEip55, + stubSignature, + service.buildSignMessage(accountAddressEip55), + ]); }); for (final empty in const ['', '0x']) { diff --git a/test/packages/service/dfx/dfx_brokerbot_service_test.dart b/test/packages/service/dfx/dfx_brokerbot_service_test.dart index 131d1c548..1093afa81 100644 --- a/test/packages/service/dfx/dfx_brokerbot_service_test.dart +++ b/test/packages/service/dfx/dfx_brokerbot_service_test.dart @@ -347,6 +347,7 @@ void main() { sessionCache = SessionCache(_MockCacheRepository()); when(() => appStore.sessionCache).thenReturn(sessionCache); when(() => appStore.apiConfig).thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => appStore.wallet).thenReturn(_StubWallet()); when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); }); diff --git a/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart b/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart index eda1c5452..2bd69d5a2 100644 --- a/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart +++ b/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart @@ -728,12 +728,16 @@ void main() { test('a lower positive balance prepares a transfer for the remainder', () { fakeAsync((async) { + var balanceReads = 0; when( () => balanceService.fetchBalance(any()), ).thenAnswer((_) async => balance(3)); when( () => balanceService.getBalance(any(), any()), - ).thenAnswer((_) async => balance(3)); + ).thenAnswer((_) async { + balanceReads++; + return balance(balanceReads == 1 ? 5 : 3); + }); final cubit = buildCubit(addCloseTearDown: false); cubit.onDevicePaired(draft); drain(async); diff --git a/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart b/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart index c118116d2..cfc6ae436 100644 --- a/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart +++ b/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart @@ -285,7 +285,7 @@ void main() { testWidgets('$state canPop=$expected', (tester) async { await pumpState(tester, state); - final popScope = tester.widget(find.byType(PopScope)); + final popScope = tester.widget(find.byType(PopScope).first); expect(popScope.canPop, expected); }); } diff --git a/test/screens/migrate_bitbox/widgets/migrate_register_view_test.dart b/test/screens/migrate_bitbox/widgets/migrate_register_view_test.dart index 9ca71a1a7..ff35b6832 100644 --- a/test/screens/migrate_bitbox/widgets/migrate_register_view_test.dart +++ b/test/screens/migrate_bitbox/widgets/migrate_register_view_test.dart @@ -151,19 +151,13 @@ void main() { await tester.pump(); }); - final failureCases = <(Exception, String)>[ - ( - const SigningCancelledException(), - S.current.sendFailureSignatureCancelled, - ), - ( - const BitboxNotConnectedException(), - S.current.connectBitboxFailed, - ), - (Exception('registration failed'), 'Exception: registration failed'), + final failureCases = [ + const SigningCancelledException(), + const BitboxNotConnectedException(), + Exception('registration failed'), ]; - for (final (error, expectedMessage) in failureCases) { + for (final error in failureCases) { testWidgets('$error shows the classified failure and retries', (tester) async { var calls = 0; when( @@ -179,6 +173,11 @@ void main() { await tester.pump(); await tester.pump(); + final expectedMessage = switch (error) { + SigningCancelledException() => S.current.sendFailureSignatureCancelled, + BitboxNotConnectedException() => S.current.connectBitboxFailed, + _ => error.toString(), + }; expect(find.text(expectedMessage), findsOneWidget); expect(tester.widget(find.byType(PopScope)).canPop, isTrue); expect(find.text(S.current.close), findsOneWidget); From 91f57a10a0ec48fc8043ade821488c4a84c8c578 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:04:31 +0200 Subject: [PATCH 11/26] fix(migration): review round 2 hardenings - prepare path now uses the fail-loud fetchBalance (a swallowed refresh error can no longer surface a stale zero and finish the wizard with funds left) - settling follow-ups and retry actions run through a guarded wrapper that re-installs the retry on failure instead of stranding a busy state; the settling timer survives fallible follow-ups - close() invalidates the settling generation and finishMigration re-checks after every await, so a wizard closed mid-flight cannot half-execute the identity switch - getAuthToken builds message and request from an atomic snapshot, commits only against it and gives up after three identity changes (closes the A-B-A cache poisoning window) - BitBox addresses are EIP-55-normalized on every write and the dedup lookup compares normalized (the SDK's casing is documented as inconsistent) - register failure surface is public with catalog + matrix coverage; register step receives account/bearer via constructor instead of live parent reads; misleading injection comment corrected --- .../repository/wallet_repository.dart | 23 +- .../service/dfx/dfx_auth_service.dart | 61 +++- lib/packages/service/wallet_service.dart | 12 +- lib/packages/storage/wallet_storage.dart | 3 + .../migrate_bitbox/migrate_bitbox_cubit.dart | 87 ++++-- .../migrate_bitbox/migrate_bitbox_page.dart | 7 +- .../widgets/migrate_register_view.dart | 38 +-- test/helper/responsive_surface_catalog.dart | 7 + .../repository/wallet_repository_test.dart | 25 +- .../service/dfx/dfx_auth_service_test.dart | 136 +++++++-- .../packages/service/wallet_service_test.dart | 45 ++- .../migrate_bitbox_cubit_test.dart | 281 ++++++++++++++++-- .../migrate_bitbox_page_test.dart | 7 + ...migrate_bitbox_responsive_matrix_test.dart | 41 ++- .../widgets/migrate_register_view_test.dart | 4 +- 15 files changed, 655 insertions(+), 122 deletions(-) diff --git a/lib/packages/repository/wallet_repository.dart b/lib/packages/repository/wallet_repository.dart index 2341b6fee..580b9429f 100644 --- a/lib/packages/repository/wallet_repository.dart +++ b/lib/packages/repository/wallet_repository.dart @@ -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; @@ -25,11 +26,27 @@ class WalletRepository { /// 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 getBitboxWalletIdByAddress(String address) async { - final info = await _appDatabase.getWalletByTypeAndAddress( + final normalizedAddress = _normalizedAddress(address); + if (normalizedAddress == null) return null; + final candidates = await _appDatabase.getWalletsByType( WalletType.bitbox.index, - address, ); - return info?.id; + 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 diff --git a/lib/packages/service/dfx/dfx_auth_service.dart b/lib/packages/service/dfx/dfx_auth_service.dart index 727e685ac..f75a7fa1f 100644 --- a/lib/packages/service/dfx/dfx_auth_service.dart +++ b/lib/packages/service/dfx/dfx_auth_service.dart @@ -176,10 +176,20 @@ abstract class DFXAuthService { // BitBox swift wrapper returns empty bytes / `'0x'`, normalised here. // * `TimeoutException` — the user never confirms within `_signMessageTimeout`. Future getSignature(String message) async { + final account = wallet; + final address = walletAddress; + return _getSignatureFor(account, address, message); + } + + Future _getSignatureFor( + AWalletAccount account, + String address, + String message, + ) async { final cached = appStore.sessionCache.signature; final cachedAddress = appStore.sessionCache.signatureAddress; if (cached != null && - cachedAddress == walletAddress && + cachedAddress == address && appStore.sessionCache.signedMessage == message) { return cached; } @@ -192,11 +202,18 @@ abstract class DFXAuthService { // RealUnitRegistrationService.completeRegistration / registerWallet. await walletService.ensureCurrentWalletUnlocked(); try { - final signature = await wallet.signMessage(message).timeout(_signMessageTimeout); + final currentAccount = wallet; + final signingAccount = + currentAccount.primaryAddress.address.hexEip55 == address + ? currentAccount + : account; + final signature = await signingAccount + .signMessage(message) + .timeout(_signMessageTimeout); if (signature.isEmpty || signature == '0x') { throw const SigningCancelledException(); } - await appStore.sessionCache.saveSignature(walletAddress, signature, message); + await appStore.sessionCache.saveSignature(address, signature, message); return signature; } finally { await walletService.lockCurrentWallet(); @@ -204,17 +221,31 @@ abstract class DFXAuthService { } Future> getAuthResponse([bool sendWalletName = true]) async { - final signature = await getSignature(getSignMessage()); + final account = wallet; + final address = walletAddress; + return _getAuthResponseFor(account, address, sendWalletName); + } + + Future> _getAuthResponseFor( + AWalletAccount account, + String address, + bool sendWalletName, + ) async { + final signature = await _getSignatureFor( + account, + address, + buildSignMessage(address), + ); final requestBody = jsonEncode( sendWalletName ? { 'wallet': walletName, - 'address': walletAddress, + 'address': address, 'signature': signature, } : { - 'address': walletAddress, + 'address': address, 'signature': signature, }, ); @@ -241,26 +272,32 @@ abstract class DFXAuthService { // empty-signature guard in `getSignature` covers the cancel/disconnect // case gracefully, and the SDK no longer panics on NACK. Future getAuthToken() async { - while (true) { - final addressBeforeAuth = walletAddress; + for (var attempt = 0; attempt < 3; attempt++) { + final accountSnapshot = wallet; + final addressSnapshot = walletAddress; final cachedToken = appStore.sessionCache.authToken; if (cachedToken != null && - appStore.sessionCache.authTokenAddress == addressBeforeAuth) { + appStore.sessionCache.authTokenAddress == addressSnapshot) { return cachedToken; } await appStore.sessionCache.loadSignature(); - final response = await getAuthResponse(); + final response = await _getAuthResponseFor( + accountSnapshot, + addressSnapshot, + true, + ); // Close the late-commit race when the active wallet identity changes // while /v1/auth is in flight. Discard the old identity's response and // retry against the now-current wallet context. - if (walletAddress != addressBeforeAuth) continue; + if (walletAddress != addressSnapshot) continue; final token = response['accessToken'] as String; - appStore.sessionCache.setAuthToken(token, addressBeforeAuth); + appStore.sessionCache.setAuthToken(token, addressSnapshot); return token; } + throw Exception('wallet identity changed during authentication'); } void invalidateAuthToken() => appStore.sessionCache.clearAuthToken(); diff --git a/lib/packages/service/wallet_service.dart b/lib/packages/service/wallet_service.dart index 316b3f9fc..9137841e4 100644 --- a/lib/packages/service/wallet_service.dart +++ b/lib/packages/service/wallet_service.dart @@ -100,9 +100,14 @@ class WalletService { if (!_isValidEthAddress(address)) { throw const BitboxAddressUnavailableException(); } - final walletId = await _repository.createViewWallet(name, WalletType.bitbox, address); + final normalizedAddress = EthereumAddress.fromHex(address).hexEip55; + final walletId = await _repository.createViewWallet( + name, + WalletType.bitbox, + normalizedAddress, + ); await setCurrentWallet(walletId); - return BitboxWallet(walletId, name, address, _bitboxService); + return BitboxWallet(walletId, name, normalizedAddress, _bitboxService); } /// Reads the ETH address from the connected BitBox and returns an @@ -121,7 +126,8 @@ class WalletService { if (!_isValidEthAddress(address)) { throw const BitboxAddressUnavailableException(); } - return BitboxWallet(0, name, address, _bitboxService); + final normalizedAddress = EthereumAddress.fromHex(address).hexEip55; + return BitboxWallet(0, name, normalizedAddress, _bitboxService); } /// Persists a [draft] from [acquireUncommittedBitboxWallet] WITHOUT switching diff --git a/lib/packages/storage/wallet_storage.dart b/lib/packages/storage/wallet_storage.dart index 70c455f05..63d8000d1 100644 --- a/lib/packages/storage/wallet_storage.dart +++ b/lib/packages/storage/wallet_storage.dart @@ -15,6 +15,9 @@ extension WalletStorage on AppDatabase { ..limit(1)) .getSingleOrNull(); + Future> getWalletsByType(int walletType) => + (select(walletInfos)..where((row) => row.type.equals(walletType))).get(); + Future updateWalletAddress(int id, String address) => (update( walletInfos, )..where((row) => row.id.equals(id))).write(WalletInfosCompanion(address: Value(address))); diff --git a/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart b/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart index 8e325fcda..4280f33ed 100644 --- a/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart +++ b/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:realunit_wallet/models/balance.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; import 'package:realunit_wallet/packages/service/balance_service.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_auth_service.dart'; @@ -21,8 +22,9 @@ part 'migrate_bitbox_state.dart'; class MigrateBitboxCubit extends Cubit { MigrateBitboxCubit( this._walletService, - // DfxKycService is the smallest registered DFXAuthService — used only as - // a transport for ensureSignatureFor(account); no KYC-specific calls here. + // DfxKycService is the smallest registered DFXAuthService — used purely as + // the auth transport (refreshAuthToken / authenticateLinkedAccount); no + // KYC-specific calls here. DfxKycService authService, this._registrationService, this._balanceService, @@ -142,7 +144,10 @@ class MigrateBitboxCubit extends Cubit { emit(const MigrateBitboxRegistrationPending()); return; } - await _persistAndPrepareTransfer(); + await _runSafely( + _persistAndPrepareTransfer, + _MigrateBitboxRetryKind.transferPreparation, + ); case RealUnitRegistrationState.newRegistration: _pendingRetry = () => onDevicePaired(draft); _pendingRetryKind = _MigrateBitboxRetryKind.linking; @@ -193,7 +198,10 @@ class MigrateBitboxCubit extends Cubit { Future onRegisterCompleted() async { if (state is! MigrateBitboxRegisterReady) return; - await _persistAndPrepareTransfer(); + await _runSafely( + _persistAndPrepareTransfer, + _MigrateBitboxRetryKind.transferPreparation, + ); } void onRegisterPending() { @@ -209,16 +217,41 @@ class MigrateBitboxCubit extends Cubit { final action = _pendingRetry; final kind = _pendingRetryKind; if (action == null) return; + final retryKind = kind!; _pendingRetry = null; _pendingRetryKind = null; emit( - switch (kind!) { + switch (retryKind) { _MigrateBitboxRetryKind.linking => const MigrateBitboxLinking(), _MigrateBitboxRetryKind.transferPreparation => const MigrateBitboxPreparingTransfer(), }, ); - await action(); + await _runSafely(action, retryKind); + } + + /// Runs a fallible wizard action without losing its retry affordance. The + /// action is re-installed before the failure is exposed, so a second retry + /// remains possible even when the first retry attempt itself throws. + Future _runSafely( + Future Function() action, + _MigrateBitboxRetryKind kind, { + Future Function()? retryAction, + }) async { + try { + await action(); + } catch (e) { + if (isClosed) return; + _pendingRetry = retryAction ?? action; + _pendingRetryKind = kind; + emit( + MigrateBitboxFailure( + MigrateBitboxFailureReason.generic, + message: e.toString(), + canRetry: true, + ), + ); + } } Future _persistAndPrepareTransfer() async { @@ -226,15 +259,11 @@ class MigrateBitboxCubit extends Cubit { final softwareAddress = _appStore.primaryAddress; final bitboxAddress = _persisted!.currentAccount.primaryAddress.address.hexEip55; - await _balanceService.updateBalance(softwareAddress); - final balance = await _balanceService.getBalance( - _appStore.apiConfig.asset, - softwareAddress, - ); - if (balance == null) { - // Fail-loud: NEVER interpret a missing balance read as zero — that would - // silently skip the transfer and end the wizard "successfully" without - // moving any funds. + late final Balance balance; + try { + balance = await _balanceService.fetchBalance(softwareAddress); + } catch (_) { + if (isClosed) return; _pendingRetry = _persistAndPrepareTransfer; _pendingRetryKind = _MigrateBitboxRetryKind.transferPreparation; emit( @@ -246,11 +275,12 @@ class MigrateBitboxCubit extends Cubit { ); return; } + if (isClosed) return; final amount = balance.balance.toInt(); if (amount == 0) { - // Nothing to transfer — e.g. re-entering the wizard after a transfer that - // already completed in a prior run. + // Nothing to transfer — e.g. re-entering the wizard after a transfer + // that already completed in a prior run. await finishMigration(); return; } @@ -318,15 +348,22 @@ class MigrateBitboxCubit extends Cubit { _settlingAttempts++; final amount = balance.balance.toInt(); if (amount == 0) { - _settlingTimer?.cancel(); - await finishMigration(); + await _runSafely( + finishMigration, + _MigrateBitboxRetryKind.transferPreparation, + retryAction: _persistAndPrepareTransfer, + ); if (isClosed || generation != _settlingGeneration) return; + _settlingTimer?.cancel(); return; } if (amount < expectedAmount) { - _settlingTimer?.cancel(); - await _persistAndPrepareTransfer(); + await _runSafely( + _persistAndPrepareTransfer, + _MigrateBitboxRetryKind.transferPreparation, + ); if (isClosed || generation != _settlingGeneration) return; + _settlingTimer?.cancel(); return; } if (_settlingAttempts >= _settlingMaxAttempts) { @@ -349,9 +386,11 @@ class MigrateBitboxCubit extends Cubit { } Future finishMigration() async { + if (isClosed) return; emit(const MigrateBitboxCompleting()); final persisted = _persisted!; await _walletService.setCurrentWallet(persisted.id); + if (isClosed) return; final signature = _bitboxSignature; if (signature != null) { final bitboxAddress = persisted.currentAccount.primaryAddress.address.hexEip55; @@ -359,6 +398,7 @@ class MigrateBitboxCubit extends Cubit { // authenticated call if this is skipped — mirrors // ConnectBitboxCubit.continueWithoutSignature. await _appStore.sessionCache.saveSignature(bitboxAddress, signature); + if (isClosed) return; } // After setCurrentWallet, before the view's HomeBloc reload, so any sync // triggered by the reload is already authenticated as the new wallet. @@ -366,6 +406,7 @@ class MigrateBitboxCubit extends Cubit { _newJwt!, persisted.currentAccount.primaryAddress.address.hexEip55, ); + if (isClosed) return; _pendingRetry = null; _pendingRetryKind = null; emit(MigrateBitboxSuccess(persisted)); @@ -373,6 +414,10 @@ class MigrateBitboxCubit extends Cubit { @override Future close() { + // Invalidate callbacks already awaiting a balance/finish operation. A + // wizard closed mid-flight must not half-apply the identity switch; a + // later zero-balance re-entry completes the migration cleanly. + _settlingGeneration++; _settlingTimer?.cancel(); return super.close(); } diff --git a/lib/screens/migrate_bitbox/migrate_bitbox_page.dart b/lib/screens/migrate_bitbox/migrate_bitbox_page.dart index 58bea76a5..e9e357139 100644 --- a/lib/screens/migrate_bitbox/migrate_bitbox_page.dart +++ b/lib/screens/migrate_bitbox/migrate_bitbox_page.dart @@ -24,8 +24,9 @@ class MigrateBitboxPage extends StatelessWidget { Widget build(BuildContext context) => BlocProvider( create: (_) => MigrateBitboxCubit( getIt(), - // DfxKycService is the smallest registered DFXAuthService — used only as - // a transport for ensureSignatureFor(account); no KYC-specific calls here. + // DfxKycService is the smallest registered DFXAuthService — used purely + // as the auth transport (refreshAuthToken / authenticateLinkedAccount); + // no KYC-specific calls here. getIt(), getIt(), getIt(), @@ -86,6 +87,8 @@ class MigrateBitboxViewManager extends StatelessWidget { ), MigrateBitboxRegisterReady(:final userData, :final bitboxAddress) => MigrateRegisterView( + account: context.read().draftAccount, + bearerToken: context.read().linkedJwt, userData: userData, bitboxAddress: bitboxAddress, ), diff --git a/lib/screens/migrate_bitbox/widgets/migrate_register_view.dart b/lib/screens/migrate_bitbox/widgets/migrate_register_view.dart index 3c4fb51ea..2a497ec21 100644 --- a/lib/screens/migrate_bitbox/widgets/migrate_register_view.dart +++ b/lib/screens/migrate_bitbox/widgets/migrate_register_view.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:realunit_wallet/generated/i18n.dart'; import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; import 'package:realunit_wallet/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart'; import 'package:realunit_wallet/screens/migrate_bitbox/cubits/migrate_register/migrate_register_cubit.dart'; import 'package:realunit_wallet/setup/di.dart'; @@ -15,29 +16,30 @@ import 'package:realunit_wallet/widgets/scrollable_actions_layout.dart'; class MigrateRegisterView extends StatelessWidget { const MigrateRegisterView({ super.key, + required this.account, + required this.bearerToken, required this.userData, required this.bitboxAddress, }); + final AWalletAccount account; + final String bearerToken; final RealUnitUserDataDto userData; final String bitboxAddress; @override - Widget build(BuildContext context) { - final parent = context.read(); - return BlocProvider( - create: (_) => MigrateRegisterCubit( - getIt(), - account: parent.draftAccount, - userData: userData, - bearerToken: parent.linkedJwt, - ), - child: _MigrateRegisterBody( - userData: userData, - bitboxAddress: bitboxAddress, - ), - ); - } + Widget build(BuildContext context) => BlocProvider( + create: (_) => MigrateRegisterCubit( + getIt(), + account: account, + userData: userData, + bearerToken: bearerToken, + ), + child: _MigrateRegisterBody( + userData: userData, + bitboxAddress: bitboxAddress, + ), + ); } class _MigrateRegisterBody extends StatelessWidget { @@ -73,7 +75,7 @@ class _MigrateRegisterBody extends StatelessWidget { isSubmitting: true, ), MigrateRegisterFailure(:final reason, :final message, :final canRetry) => - _MigrateRegisterFailureView( + MigrateRegisterFailureView( reason: reason, message: message, canRetry: canRetry, @@ -153,8 +155,8 @@ class _MigrateRegisterForm extends StatelessWidget { ); } -class _MigrateRegisterFailureView extends StatelessWidget { - const _MigrateRegisterFailureView({ +class MigrateRegisterFailureView extends StatelessWidget { + const MigrateRegisterFailureView({ required this.reason, required this.message, required this.canRetry, diff --git a/test/helper/responsive_surface_catalog.dart b/test/helper/responsive_surface_catalog.dart index b54ce2bac..78c5446a2 100644 --- a/test/helper/responsive_surface_catalog.dart +++ b/test/helper/responsive_surface_catalog.dart @@ -287,6 +287,13 @@ const kResponsiveSurfaceCatalog = [ 'test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart', productionPath: 'lib/screens/migrate_bitbox/widgets/migrate_register_view.dart', ), + ResponsiveSurface( + id: 'migrate_bitbox_register_failure_view', + description: 'BitBox migration registration failure (retry/close CTAs)', + matrixTestPath: + 'test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart', + productionPath: 'lib/screens/migrate_bitbox/widgets/migrate_register_view.dart', + ), ResponsiveSurface( id: 'migrate_bitbox_transfer_ready_view', description: 'BitBox migration transfer confirmation (transfer CTA)', diff --git a/test/packages/repository/wallet_repository_test.dart b/test/packages/repository/wallet_repository_test.dart index 3e0b83fce..8fb76cd53 100644 --- a/test/packages/repository/wallet_repository_test.dart +++ b/test/packages/repository/wallet_repository_test.dart @@ -8,6 +8,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 _MockSecureStorage extends Mock implements SecureStorage {} @@ -73,17 +74,33 @@ void main() { }); test( - 'getBitboxWalletIdByAddress returns the BitBox row id or null', + 'getBitboxWalletIdByAddress normalizes legacy rows and ignores malformed candidates', () async { + await repo.createViewWallet('MalformedHardware', WalletType.bitbox, ''); + final normalizedAddress = EthereumAddress.fromHex(address).hexEip55; final bitboxId = await repo.createViewWallet( 'Hardware', WalletType.bitbox, - address, + normalizedAddress.toLowerCase(), ); // Same address, different type — must NOT match the BitBox lookup. - await repo.createViewWallet('SoftwareView', WalletType.software, address); + await repo.createViewWallet( + 'SoftwareView', + WalletType.software, + normalizedAddress, + ); - expect(await repo.getBitboxWalletIdByAddress(address), bitboxId); + expect( + await repo.getBitboxWalletIdByAddress(normalizedAddress), + bitboxId, + ); + expect( + await repo.getBitboxWalletIdByAddress( + '0x3333333333333333333333333333333333333333', + ), + isNull, + ); + expect(await repo.getBitboxWalletIdByAddress(''), isNull); expect(await repo.getBitboxWalletIdByAddress('0xNoSuchAddress000000000000000000000001'), isNull); }, ); diff --git a/test/packages/service/dfx/dfx_auth_service_test.dart b/test/packages/service/dfx/dfx_auth_service_test.dart index ab8dc86a8..0e0f91d88 100644 --- a/test/packages/service/dfx/dfx_auth_service_test.dart +++ b/test/packages/service/dfx/dfx_auth_service_test.dart @@ -135,24 +135,20 @@ class _SignatureTestAuthService extends DFXAuthService { String get walletAddress => _address; } -class _LateCommitAuthService extends DFXAuthService { - _LateCommitAuthService(super.appStore, super.walletService, this.currentAddress); +class _MutableIdentityAuthService extends DFXAuthService { + _MutableIdentityAuthService( + super.appStore, + super.walletService, + this.currentAccount, + ); - String currentAddress; - final authResponses = >>[]; + AWalletAccount currentAccount; @override - AWalletAccount get wallet => throw UnimplementedError(); - - @override - String get walletAddress => currentAddress; + AWalletAccount get wallet => currentAccount; @override - Future> getAuthResponse([bool sendWalletName = true]) { - final completer = Completer>(); - authResponses.add(completer); - return completer.future; - } + String get walletAddress => currentAccount.primaryAddress.address.hexEip55; } // --------------------------------------------------------------------------- @@ -911,26 +907,112 @@ void main() { expect(sessionCache.authTokenAddress, walletAddress); }); - test('late auth response cannot commit after the wallet address changes', () async { - const oldAddress = '0x1111111111111111111111111111111111111111'; - const newAddress = '0x2222222222222222222222222222222222222222'; - final service = _LateCommitAuthService(appStore, walletService, oldAddress); + test('A-B-A changes never commit the B response under A', () async { + final accountA = _StubWalletAccount( + '0xsignature-a', + address: '0x1111111111111111111111111111111111111111', + ); + final accountB = _StubWalletAccount( + '0xsignature-b', + address: '0x2222222222222222222222222222222222222222', + ); + final pendingResponses = List.generate( + 3, + (_) => Completer(), + ); + final requestArrivals = List.generate(3, (_) => Completer()); + final sentBodies = >[]; + var requestIndex = 0; + final client = MockClient((request) { + sentBodies.add(jsonDecode(request.body) as Map); + final index = requestIndex++; + requestArrivals[index].complete(); + return pendingResponses[index].future; + }); + when(() => appStore.httpClient).thenReturn(client); + final service = _MutableIdentityAuthService( + appStore, + walletService, + accountA, + ); final tokenFuture = service.getAuthToken(); - await Future.delayed(Duration.zero); - expect(service.authResponses, hasLength(1)); + await requestArrivals[0].future; + expect(sentBodies.single['address'], service.walletAddress); - service.currentAddress = newAddress; - service.authResponses.single.complete({'accessToken': 'jwt-old'}); - await Future.delayed(Duration.zero); + service.currentAccount = accountB; + pendingResponses[0].complete( + http.Response(jsonEncode({'accessToken': 'jwt-a-old'}), 201), + ); + await requestArrivals[1].future; + expect(sentBodies, hasLength(2)); + expect( + sentBodies[1]['address'], + accountB.primaryAddress.address.hexEip55, + ); + service.currentAccount = accountA; + pendingResponses[1].complete( + http.Response(jsonEncode({'accessToken': 'jwt-b'}), 201), + ); + await requestArrivals[2].future; expect(sessionCache.authToken, isNull); - expect(service.authResponses, hasLength(2)); - service.authResponses.last.complete({'accessToken': 'jwt-new'}); + expect(sentBodies, hasLength(3)); + expect( + sentBodies[2]['address'], + accountA.primaryAddress.address.hexEip55, + ); - expect(await tokenFuture, 'jwt-new'); - expect(sessionCache.authToken, 'jwt-new'); - expect(sessionCache.authTokenAddress, newAddress); + pendingResponses[2].complete( + http.Response(jsonEncode({'accessToken': 'jwt-a-current'}), 201), + ); + + expect(await tokenFuture, 'jwt-a-current'); + expect(sessionCache.authToken, 'jwt-a-current'); + expect( + sessionCache.authTokenAddress, + accountA.primaryAddress.address.hexEip55, + ); + }); + + test('flapping identity fails loudly after three authentication attempts', () async { + final accountA = _StubWalletAccount( + '0xsignature-a', + address: '0x1111111111111111111111111111111111111111', + ); + final accountB = _StubWalletAccount( + '0xsignature-b', + address: '0x2222222222222222222222222222222222222222', + ); + late _MutableIdentityAuthService service; + var authCalls = 0; + final client = MockClient((_) async { + authCalls++; + service.currentAccount = identical(service.currentAccount, accountA) + ? accountB + : accountA; + return http.Response(jsonEncode({'accessToken': 'jwt-$authCalls'}), 201); + }); + when(() => appStore.httpClient).thenReturn(client); + service = _MutableIdentityAuthService( + appStore, + walletService, + accountA, + ); + + await expectLater( + service.getAuthToken(), + throwsA( + isA().having( + (e) => e.toString(), + 'toString()', + contains('wallet identity changed during authentication'), + ), + ), + ); + + expect(authCalls, 3); + expect(sessionCache.authToken, isNull); }); test('invalidateAuthToken clears the cached JWT', () { diff --git a/test/packages/service/wallet_service_test.dart b/test/packages/service/wallet_service_test.dart index 9f07716cf..fad232161 100644 --- a/test/packages/service/wallet_service_test.dart +++ b/test/packages/service/wallet_service_test.dart @@ -12,6 +12,7 @@ import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_address_u import 'package:realunit_wallet/packages/service/wallet_service.dart'; import 'package:realunit_wallet/packages/storage/database.dart'; import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:web3dart/web3dart.dart'; class _MockWalletRepository extends Mock implements WalletRepository {} @@ -23,6 +24,7 @@ class _MockAppStore extends Mock implements AppStore {} const _testMnemonic = 'test test test test test test test test test test test junk'; const _debugAddress = '0x0000000000000000000000000000000000000001'; +const _lowercaseAddress = '0x9f5713deacb8e9cab6c2d3fae1afc2715f8d2d71'; WalletInfo _info({ int id = 1, @@ -249,6 +251,22 @@ void main() { verify(() => settings.saveCurrentWalletId(11)).called(1); }); + test('normalizes the device address before persisting and returning it', () async { + final normalized = EthereumAddress.fromHex(_lowercaseAddress).hexEip55; + when(() => bitbox.getEthAddress()).thenAnswer((_) async => _lowercaseAddress); + when(() => repo.createViewWallet(any(), any(), any())).thenAnswer((_) async => 12); + when( + () => bitbox.getCredentials(any()), + ).thenReturn(BitboxCredentials(_lowercaseAddress)); + + final wallet = await service.createBitboxWallet('Hardware'); + + expect(wallet.currentAccount.primaryAddress.address.hexEip55, normalized); + verify( + () => repo.createViewWallet('Hardware', WalletType.bitbox, normalized), + ).called(1); + }); + test('propagates a BitBox derivation failure without writing to the repo', () async { when(() => bitbox.getEthAddress()).thenThrow(Exception('USB transport dropped')); @@ -317,6 +335,18 @@ void main() { }, ); + test('normalizes the device address in the uncommitted draft', () async { + final normalized = EthereumAddress.fromHex(_lowercaseAddress).hexEip55; + when(() => bitbox.getEthAddress()).thenAnswer((_) async => _lowercaseAddress); + when( + () => bitbox.getCredentials(any()), + ).thenReturn(BitboxCredentials(_lowercaseAddress)); + + final draft = await service.acquireUncommittedBitboxWallet('Migration'); + + expect(draft.currentAccount.primaryAddress.address.hexEip55, normalized); + }); + test('throws BitboxAddressUnavailableException on a malformed address', () async { when(() => bitbox.getEthAddress()).thenAnswer((_) async => 'not-a-hex-address'); @@ -364,15 +394,22 @@ void main() { }); test( - 'reuses an existing BitBox row with the same address (idempotent dedup)', + 'reuses a legacy lowercase BitBox row for the normalized incoming address', () async { - when(() => repo.getBitboxWalletIdByAddress(any())).thenAnswer((_) async => 17); - - final draft = BitboxWallet(0, 'Migration', _debugAddress, bitbox); + final normalized = EthereumAddress.fromHex(_lowercaseAddress).hexEip55; + when( + () => bitbox.getCredentials(any()), + ).thenReturn(BitboxCredentials(_lowercaseAddress)); + when( + () => repo.getBitboxWalletIdByAddress(normalized), + ).thenAnswer((_) async => 17); + + final draft = BitboxWallet(0, 'Migration', _lowercaseAddress, bitbox); final persisted = await service.persistBitboxWallet(draft); expect(persisted.id, 17); expect(persisted.name, 'Migration'); + verify(() => repo.getBitboxWalletIdByAddress(normalized)).called(1); verifyNever(() => repo.createViewWallet(any(), any(), any())); verifyNever(() => settings.saveCurrentWalletId(any())); }, diff --git a/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart b/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart index 2bd69d5a2..eafbeaae9 100644 --- a/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart +++ b/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart @@ -164,10 +164,6 @@ void main() { ).thenAnswer((_) async => info(RealUnitRegistrationState.alreadyRegistered)); when(() => walletService.persistBitboxWallet(any())).thenAnswer((_) async => persisted); when(() => walletService.setCurrentWallet(any())).thenAnswer((_) async {}); - when(() => balanceService.updateBalance(any())).thenAnswer((_) async {}); - when( - () => balanceService.getBalance(any(), any()), - ).thenAnswer((_) async => balance(5)); when( () => balanceService.fetchBalance(any()), ).thenAnswer((_) async => balance(5)); @@ -324,7 +320,7 @@ void main() { verifyNever(() => walletService.persistBitboxWallet(any())); }); - test('alreadyRegistered persists before balance refresh and emits TransferReady', () async { + test('alreadyRegistered persists before the fresh balance read and emits TransferReady', () async { final cubit = buildCubit(); await cubit.onDevicePaired(draft); @@ -335,7 +331,7 @@ void main() { expect(state.amount, 5); verifyInOrder([ () => walletService.persistBitboxWallet(draft), - () => balanceService.updateBalance(softwareAddress), + () => balanceService.fetchBalance(softwareAddress), ]); }); @@ -501,6 +497,34 @@ void main() { verify(() => walletService.persistBitboxWallet(draft)).called(1); }); + test('registration handoff failure stays retryable and a later retry succeeds', () async { + final cubit = buildCubit(); + await reachRegisterReady(cubit); + var persistCalls = 0; + when(() => walletService.persistBitboxWallet(draft)).thenAnswer((_) async { + persistCalls++; + if (persistCalls < 3) throw Exception('persist failed'); + return persisted; + }); + + await cubit.onRegisterCompleted(); + expect( + cubit.state, + const MigrateBitboxFailure( + MigrateBitboxFailureReason.generic, + message: 'Exception: persist failed', + canRetry: true, + ), + ); + + await cubit.retry(); + expect(cubit.state, isA()); + await cubit.retry(); + + expect(cubit.state, isA()); + expect(persistCalls, 3); + }); + test('onRegisterPending is a no-op outside RegisterReady', () { final cubit = buildCubit(); final initial = cubit.state; @@ -521,10 +545,10 @@ void main() { }); group('$MigrateBitboxCubit transfer preparation', () { - test('missing balance fails loud and retry repeats persistence and balance read', () async { + test('fetch failure fails loud despite an implicit zero cache and never switches', () async { when( - () => balanceService.getBalance(any(), any()), - ).thenAnswer((_) async => null); + () => balanceService.fetchBalance(any()), + ).thenThrow(Exception('network unavailable')); final cubit = buildCubit(); await cubit.onDevicePaired(draft); @@ -540,13 +564,16 @@ void main() { await cubit.retry(); verify(() => walletService.persistBitboxWallet(draft)).called(2); - verify(() => balanceService.updateBalance(softwareAddress)).called(2); - verify(() => balanceService.getBalance(realUnitAsset, softwareAddress)).called(2); + verify(() => balanceService.fetchBalance(softwareAddress)).called(2); + verifyNever(() => balanceService.updateBalance(any())); + verifyNever(() => balanceService.getBalance(any(), any())); + verifyNever(() => walletService.setCurrentWallet(any())); + verifyNever(() => sessionCache.setAuthToken(any(), any())); }); test('zero balance completes without emitting TransferReady', () async { when( - () => balanceService.getBalance(any(), any()), + () => balanceService.fetchBalance(any()), ).thenAnswer((_) async => balance(0)); final cubit = buildCubit(); final emitted = []; @@ -561,7 +588,7 @@ void main() { test('positive balance truncates to an integer amount', () async { when( - () => balanceService.getBalance(any(), any()), + () => balanceService.fetchBalance(any()), ).thenAnswer((_) async => balance(37)); final cubit = buildCubit(); @@ -576,6 +603,27 @@ void main() { ), ); }); + + test('close during the fresh balance fetch prevents completion and later effects', () async { + final cubit = buildCubit(addCloseTearDown: false); + await reachRegisterReady(cubit); + final fetchStarted = Completer(); + final pendingBalance = Completer(); + when(() => balanceService.fetchBalance(any())).thenAnswer((_) { + fetchStarted.complete(); + return pendingBalance.future; + }); + + final preparation = cubit.onRegisterCompleted(); + await fetchStarted.future; + await cubit.close(); + pendingBalance.complete(balance(0)); + await preparation; + + verifyNever(() => walletService.setCurrentWallet(any())); + verifyNever(() => sessionCache.saveSignature(any(), any())); + verifyNever(() => sessionCache.setAuthToken(any(), any())); + }); }); group('$MigrateBitboxCubit transfer and completion', () { @@ -626,8 +674,7 @@ void main() { ); await cubit.retry(); - verify(() => balanceService.updateBalance(softwareAddress)).called(2); - verify(() => balanceService.getBalance(realUnitAsset, softwareAddress)).called(2); + verify(() => balanceService.fetchBalance(softwareAddress)).called(2); expect(cubit.state, isA()); }); @@ -660,7 +707,7 @@ void main() { test('matching signature is persisted before the new auth token', () async { when( - () => balanceService.getBalance(any(), any()), + () => balanceService.fetchBalance(any()), ).thenAnswer((_) async => balance(0)); final cubit = buildCubit(); @@ -677,7 +724,7 @@ void main() { test('signature-address mismatch skips signature persistence', () async { when(() => sessionCache.signatureAddress).thenReturn(softwareAddress); when( - () => balanceService.getBalance(any(), any()), + () => balanceService.fetchBalance(any()), ).thenAnswer((_) async => balance(0)); final cubit = buildCubit(); @@ -690,6 +737,44 @@ void main() { verifyNever(() => sessionCache.saveSignature(any(), any())); expect(cubit.state, MigrateBitboxSuccess(persisted)); }); + + test('close during signature persistence prevents the auth-token commit', () { + fakeAsync((async) { + final pendingSignatureSave = Completer(); + when( + () => balanceService.fetchBalance(any()), + ).thenAnswer((_) async => balance(0)); + when( + () => sessionCache.saveSignature(any(), any()), + ).thenAnswer((_) => pendingSignatureSave.future); + final cubit = buildCubit(addCloseTearDown: false); + + cubit.onDevicePaired(draft); + drain(async); + expect(cubit.state, const MigrateBitboxCompleting()); + + cubit.close(); + pendingSignatureSave.complete(); + drain(async); + + verifyNever(() => sessionCache.setAuthToken(any(), any())); + async.flushTimers(); + }); + }); + + test('finishMigration is a no-op after close', () { + fakeAsync((async) { + final cubit = buildCubit(addCloseTearDown: false); + + cubit.close(); + cubit.finishMigration(); + drain(async); + + verifyNever(() => walletService.setCurrentWallet(any())); + verifyNever(() => sessionCache.saveSignature(any(), any())); + verifyNever(() => sessionCache.setAuthToken(any(), any())); + }); + }); }); group('$MigrateBitboxCubit settling', () { @@ -705,9 +790,10 @@ void main() { test('zero balance on the first tick finishes the migration', () { fakeAsync((async) { + var calls = 0; when( () => balanceService.fetchBalance(any()), - ).thenAnswer((_) async => balance(0)); + ).thenAnswer((_) async => balance(calls++ == 0 ? 5 : 0)); final cubit = buildCubit(addCloseTearDown: false); cubit.onDevicePaired(draft); drain(async); @@ -731,9 +817,6 @@ void main() { var balanceReads = 0; when( () => balanceService.fetchBalance(any()), - ).thenAnswer((_) async => balance(3)); - when( - () => balanceService.getBalance(any(), any()), ).thenAnswer((_) async { balanceReads++; return balance(balanceReads == 1 ? 5 : 3); @@ -769,6 +852,7 @@ void main() { cubit.startTransfer(); cubit.onTransferBroadcast(); drain(async); + clearInteractions(balanceService); for (var i = 0; i < 20; i++) { async.elapse(const Duration(seconds: 3)); @@ -788,7 +872,8 @@ void main() { var calls = 0; when(() => balanceService.fetchBalance(any())).thenAnswer((_) async { calls++; - if (calls == 1) throw Exception('balance unavailable'); + if (calls == 1) return balance(5); + if (calls == 2) throw Exception('balance unavailable'); return balance(0); }); final cubit = buildCubit(addCloseTearDown: false); @@ -805,7 +890,7 @@ void main() { drain(async); expect(cubit.state, MigrateBitboxSuccess(persisted)); - expect(calls, 2); + expect(calls, 3); cubit.close(); async.flushTimers(); }); @@ -814,9 +899,14 @@ void main() { test('overlapping timer ticks do not start a second balance request', () { fakeAsync((async) { final pendingBalance = Completer(); + var calls = 0; when( () => balanceService.fetchBalance(any()), - ).thenAnswer((_) => pendingBalance.future); + ).thenAnswer((_) { + calls++; + if (calls == 1) return Future.value(balance(5)); + return pendingBalance.future; + }); final cubit = buildCubit(addCloseTearDown: false); cubit.onDevicePaired(draft); drain(async); @@ -827,7 +917,7 @@ void main() { async.elapse(const Duration(seconds: 6)); drain(async); - verify(() => balanceService.fetchBalance(softwareAddress)).called(1); + expect(calls, 2); pendingBalance.complete(balance(0)); drain(async); expect(cubit.state, MigrateBitboxSuccess(persisted)); @@ -835,5 +925,146 @@ void main() { async.flushTimers(); }); }); + + test('zero-balance finish failure becomes retryable instead of hanging', () { + fakeAsync((async) { + var balanceCalls = 0; + when(() => balanceService.fetchBalance(any())).thenAnswer( + (_) async => balance(balanceCalls++ == 0 ? 5 : 0), + ); + when( + () => walletService.setCurrentWallet(any()), + ).thenThrow(Exception('wallet switch failed')); + final cubit = buildCubit(addCloseTearDown: false); + cubit.onDevicePaired(draft); + drain(async); + cubit.startTransfer(); + cubit.onTransferBroadcast(); + drain(async); + + async.elapse(const Duration(seconds: 3)); + drain(async); + + expect( + cubit.state, + const MigrateBitboxFailure( + MigrateBitboxFailureReason.generic, + message: 'Exception: wallet switch failed', + canRetry: true, + ), + ); + async.elapse(const Duration(seconds: 6)); + drain(async); + expect(balanceCalls, 2); + expect(cubit.state, isA()); + cubit.close(); + async.flushTimers(); + }); + }); + + test('lower balance with a persistence failure becomes retryable', () { + fakeAsync((async) { + var balanceCalls = 0; + when(() => balanceService.fetchBalance(any())).thenAnswer((_) async { + balanceCalls++; + return balance(balanceCalls == 1 ? 5 : 3); + }); + var persistCalls = 0; + when(() => walletService.persistBitboxWallet(any())).thenAnswer((_) async { + persistCalls++; + if (persistCalls == 2) throw Exception('persist failed'); + return persisted; + }); + final cubit = buildCubit(addCloseTearDown: false); + cubit.onDevicePaired(draft); + drain(async); + cubit.startTransfer(); + cubit.onTransferBroadcast(); + drain(async); + + async.elapse(const Duration(seconds: 3)); + drain(async); + + expect( + cubit.state, + const MigrateBitboxFailure( + MigrateBitboxFailureReason.generic, + message: 'Exception: persist failed', + canRetry: true, + ), + ); + async.elapse(const Duration(seconds: 6)); + drain(async); + expect(balanceCalls, 2); + expect(cubit.state, isA()); + cubit.close(); + async.flushTimers(); + }); + }); + + test('close during a pending settling balance prevents all finish side effects', () { + fakeAsync((async) { + final pendingBalance = Completer(); + var balanceCalls = 0; + when(() => balanceService.fetchBalance(any())).thenAnswer((_) { + balanceCalls++; + if (balanceCalls == 1) return Future.value(balance(5)); + return pendingBalance.future; + }); + final cubit = buildCubit(addCloseTearDown: false); + cubit.onDevicePaired(draft); + drain(async); + cubit.startTransfer(); + cubit.onTransferBroadcast(); + drain(async); + async.elapse(const Duration(seconds: 3)); + drain(async); + + cubit.close(); + pendingBalance.complete(balance(0)); + drain(async); + + verifyNever(() => walletService.setCurrentWallet(any())); + verifyNever(() => sessionCache.saveSignature(any(), any())); + verifyNever(() => sessionCache.setAuthToken(any(), any())); + async.flushTimers(); + }); + }); + + test('close during setCurrentWallet prevents signature, token, and success emission', () { + fakeAsync((async) { + final pendingSwitch = Completer(); + var balanceCalls = 0; + when(() => balanceService.fetchBalance(any())).thenAnswer( + (_) async => balance(balanceCalls++ == 0 ? 5 : 0), + ); + when( + () => walletService.setCurrentWallet(any()), + ).thenAnswer((_) => pendingSwitch.future); + final cubit = buildCubit(addCloseTearDown: false); + final emitted = []; + final subscription = cubit.stream.listen(emitted.add); + cubit.onDevicePaired(draft); + drain(async); + cubit.startTransfer(); + cubit.onTransferBroadcast(); + drain(async); + async.elapse(const Duration(seconds: 3)); + drain(async); + expect(cubit.state, const MigrateBitboxCompleting()); + + cubit.close(); + final emissionCountAtClose = emitted.length; + pendingSwitch.complete(); + drain(async); + + expect(emitted, hasLength(emissionCountAtClose)); + expect(emitted.whereType(), isEmpty); + verifyNever(() => sessionCache.saveSignature(any(), any())); + verifyNever(() => sessionCache.setAuthToken(any(), any())); + subscription.cancel(); + async.flushTimers(); + }); + }); }); } diff --git a/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart b/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart index cfc6ae436..9819b5bac 100644 --- a/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart +++ b/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart @@ -235,6 +235,13 @@ void main() { await pumpState(tester, state); expect(find.byType(widgetType), findsOneWidget); + if (state is MigrateBitboxRegisterReady) { + final view = tester.widget( + find.byType(MigrateRegisterView), + ); + expect(view.account, same(draftAccount)); + expect(view.bearerToken, 'linked-jwt'); + } }); } diff --git a/test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart b/test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart index 693ef68ef..6a9fd277e 100644 --- a/test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart +++ b/test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart @@ -17,6 +17,7 @@ import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_serv import 'package:realunit_wallet/packages/utils/default_assets.dart'; import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; import 'package:realunit_wallet/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/cubits/migrate_register/migrate_register_cubit.dart'; import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_intro_view.dart'; import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_register_view.dart'; import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_result_views.dart'; @@ -31,6 +32,9 @@ import '../../helper/helper.dart'; class _MockMigrateBitboxCubit extends MockCubit implements MigrateBitboxCubit {} +class _MockMigrateRegisterCubit extends MockCubit + implements MigrateRegisterCubit {} + class _MockBalanceRepository extends Mock implements BalanceRepository {} class _MockAppStore extends Mock implements AppStore {} @@ -116,6 +120,18 @@ void main() { return cubit; } + _MockMigrateRegisterCubit buildRegisterCubit() { + final cubit = _MockMigrateRegisterCubit(); + when(() => cubit.state).thenReturn(const MigrateRegisterReady()); + whenListen( + cubit, + const Stream.empty(), + initialState: const MigrateRegisterReady(), + ); + when(() => cubit.retrySubmit()).thenAnswer((_) async {}); + return cubit; + } + Future pumpSurface( WidgetTester tester, MatrixCell cell, @@ -171,12 +187,26 @@ void main() { ('intro', () => const MigrateIntroView(), MigrateIntroView), ( 'register', - () => const MigrateRegisterView( + () => MigrateRegisterView( + account: _MockWalletAccount(), + bearerToken: 'linked-jwt', userData: _userData, bitboxAddress: '0x1234567890abcdef1234567890abcdef12345678', ), MigrateRegisterView, ), + ( + 'register-failure-retryable', + () => BlocProvider.value( + value: buildRegisterCubit(), + child: const MigrateRegisterFailureView( + reason: MigrateRegisterFailureReason.generic, + message: 'An intentionally long registration failure message', + canRetry: true, + ), + ), + MigrateRegisterFailureView, + ), ( 'transfer-ready', () => const MigrateTransferReadyView( @@ -247,6 +277,15 @@ void main() { reason: '$surfaceId primary CTA not tappable on ${cell.label}', ); await tester.pumpAndSettle(); + if (surfaceType == MigrateRegisterFailureView) { + await expectFullyTappable( + tester, + find.byType(AppFilledButton).last, + within: find.byType(surfaceType), + reason: '$surfaceId close CTA not tappable on ${cell.label}', + ); + await tester.pumpAndSettle(); + } }); }); } diff --git a/test/screens/migrate_bitbox/widgets/migrate_register_view_test.dart b/test/screens/migrate_bitbox/widgets/migrate_register_view_test.dart index ff35b6832..c7881605b 100644 --- a/test/screens/migrate_bitbox/widgets/migrate_register_view_test.dart +++ b/test/screens/migrate_bitbox/widgets/migrate_register_view_test.dart @@ -80,8 +80,6 @@ void main() { '0x1234567890abcdef', ), ); - when(() => parentCubit.draftAccount).thenReturn(account); - when(() => parentCubit.linkedJwt).thenReturn('linked-jwt'); when(() => parentCubit.onRegisterCompleted()).thenAnswer((_) async {}); when(() => parentCubit.onRegisterPending()).thenReturn(null); GetIt.instance.registerSingleton( @@ -97,6 +95,8 @@ void main() { BlocProvider.value( value: parentCubit, child: MigrateRegisterView( + account: account, + bearerToken: 'linked-jwt', userData: _userData, bitboxAddress: address, ), From abd65d569229d1174fad6646da0a95d47e29e363 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:05:50 +0200 Subject: [PATCH 12/26] fix(migration): add key parameter to the publicized register failure view --- lib/screens/migrate_bitbox/widgets/migrate_register_view.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/screens/migrate_bitbox/widgets/migrate_register_view.dart b/lib/screens/migrate_bitbox/widgets/migrate_register_view.dart index 2a497ec21..74b37c988 100644 --- a/lib/screens/migrate_bitbox/widgets/migrate_register_view.dart +++ b/lib/screens/migrate_bitbox/widgets/migrate_register_view.dart @@ -157,6 +157,7 @@ class _MigrateRegisterForm extends StatelessWidget { class MigrateRegisterFailureView extends StatelessWidget { const MigrateRegisterFailureView({ + super.key, required this.reason, required this.message, required this.canRetry, From 08507cde500237db5c105562e16b2963e8e5c311 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:08:15 +0200 Subject: [PATCH 13/26] test: lowercase the case-mangled fixture before EIP-55 normalization fromHex rejects mixed-case input with an invalid checksum by design; uniform-case input skips the check. --- test/packages/repository/wallet_repository_test.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/packages/repository/wallet_repository_test.dart b/test/packages/repository/wallet_repository_test.dart index 8fb76cd53..b34435c93 100644 --- a/test/packages/repository/wallet_repository_test.dart +++ b/test/packages/repository/wallet_repository_test.dart @@ -77,7 +77,10 @@ void main() { 'getBitboxWalletIdByAddress normalizes legacy rows and ignores malformed candidates', () async { await repo.createViewWallet('MalformedHardware', WalletType.bitbox, ''); - final normalizedAddress = EthereumAddress.fromHex(address).hexEip55; + // The file-wide fixture is deliberately case-mangled (not EIP-55 + // conformant), which fromHex rejects for mixed case — lowercase it + // first; uniform-case input skips the checksum validation. + final normalizedAddress = EthereumAddress.fromHex(address.toLowerCase()).hexEip55; final bitboxId = await repo.createViewWallet( 'Hardware', WalletType.bitbox, From 9a208a89492af51371e112fd7bf4661fa2bec29e Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:20:53 +0200 Subject: [PATCH 14/26] fix(migration): round 3 review minors healCurrentBitboxAddress now EIP-55-normalizes like the create/acquire paths (with a lowercase-device-address test); the DfxKycService injection comment no longer claims it is the smallest registered auth service; the close() comment states the actual guarantee (no rollback of completed side effects, later re-entry completes the move). --- lib/packages/service/wallet_service.dart | 7 +++++-- .../migrate_bitbox/migrate_bitbox_cubit.dart | 13 +++++++------ .../migrate_bitbox/migrate_bitbox_page.dart | 5 ++--- test/packages/service/wallet_service_test.dart | 16 ++++++++++++++++ 4 files changed, 30 insertions(+), 11 deletions(-) diff --git a/lib/packages/service/wallet_service.dart b/lib/packages/service/wallet_service.dart index 9137841e4..6ecc12f6e 100644 --- a/lib/packages/service/wallet_service.dart +++ b/lib/packages/service/wallet_service.dart @@ -199,10 +199,13 @@ class WalletService { 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)) { + final rawAddress = await _bitboxService.getEthAddress(); + if (!_isValidEthAddress(rawAddress)) { throw const BitboxAddressUnavailableException(); } + // Same EIP-55 normalization as the create/acquire paths — the SDK's + // address casing is not guaranteed consistent across reads. + final address = EthereumAddress.fromHex(rawAddress).hexEip55; await _repository.updateAddress(id, address); return BitboxWallet(id, info.name, address, _bitboxService); } diff --git a/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart b/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart index 4280f33ed..395853cec 100644 --- a/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart +++ b/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart @@ -22,9 +22,8 @@ part 'migrate_bitbox_state.dart'; class MigrateBitboxCubit extends Cubit { MigrateBitboxCubit( this._walletService, - // DfxKycService is the smallest registered DFXAuthService — used purely as - // the auth transport (refreshAuthToken / authenticateLinkedAccount); no - // KYC-specific calls here. + // DfxKycService serves purely as the auth transport here + // (refreshAuthToken / authenticateLinkedAccount); no KYC-specific calls. DfxKycService authService, this._registrationService, this._balanceService, @@ -414,9 +413,11 @@ class MigrateBitboxCubit extends Cubit { @override Future close() { - // Invalidate callbacks already awaiting a balance/finish operation. A - // wizard closed mid-flight must not half-apply the identity switch; a - // later zero-balance re-entry completes the migration cleanly. + // Invalidate settling callbacks already awaiting a balance/finish + // operation: steps that have not run yet are skipped after close. + // Already-completed side effects (e.g. a finished setCurrentWallet) are + // not rolled back — a later re-entry completes the migration cleanly + // via the zero-balance skip. _settlingGeneration++; _settlingTimer?.cancel(); return super.close(); diff --git a/lib/screens/migrate_bitbox/migrate_bitbox_page.dart b/lib/screens/migrate_bitbox/migrate_bitbox_page.dart index e9e357139..d716095b3 100644 --- a/lib/screens/migrate_bitbox/migrate_bitbox_page.dart +++ b/lib/screens/migrate_bitbox/migrate_bitbox_page.dart @@ -24,9 +24,8 @@ class MigrateBitboxPage extends StatelessWidget { Widget build(BuildContext context) => BlocProvider( create: (_) => MigrateBitboxCubit( getIt(), - // DfxKycService is the smallest registered DFXAuthService — used purely - // as the auth transport (refreshAuthToken / authenticateLinkedAccount); - // no KYC-specific calls here. + // DfxKycService serves purely as the auth transport here + // (refreshAuthToken / authenticateLinkedAccount); no KYC-specific calls. getIt(), getIt(), getIt(), diff --git a/test/packages/service/wallet_service_test.dart b/test/packages/service/wallet_service_test.dart index fad232161..863dbc8cf 100644 --- a/test/packages/service/wallet_service_test.dart +++ b/test/packages/service/wallet_service_test.dart @@ -499,6 +499,22 @@ void main() { verify(() => repo.updateAddress(5, _debugAddress)).called(1); }); + test('normalizes a lowercase device address to EIP-55 before persisting', () async { + const lowercase = '0xbc6a215909b7c412eea434389c34cd2600aa1260'; + final checksummed = EthereumAddress.fromHex(lowercase).hexEip55; + when(() => settings.currentWalletId).thenReturn(5); + when(() => repo.getWalletInfo(5)).thenAnswer( + (_) async => _info(id: 5, name: 'Hardware', address: '', type: WalletType.bitbox), + ); + when(() => bitbox.getEthAddress()).thenAnswer((_) async => lowercase); + when(() => bitbox.getCredentials(any())).thenReturn(BitboxCredentials(checksummed)); + + final wallet = await service.healCurrentBitboxAddress(); + + expect(wallet.currentAccount.primaryAddress.address.hexEip55, checksummed); + verify(() => repo.updateAddress(5, checksummed)).called(1); + }); + test('propagates BitboxAddressUnavailableException and does NOT persist', () async { when(() => settings.currentWalletId).thenReturn(5); when(() => repo.getWalletInfo(5)).thenAnswer( From 1950fa253c2b3eb54a5f91244629ba79c332d68b Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:04:25 +0200 Subject: [PATCH 15/26] fix(migration): round 3 review findings - the auth snapshot path no longer falls back to a potentially locked stale account object: an identity change during unlock now throws a file-private marker that the bounded retry loop catches with a fresh snapshot - finishMigration passes the signed message scope to saveSignature so the cached BitBox signature survives the message-scoped cache check - all three BitBox address write paths validate and normalize on the lowercased raw address, so mixed-case device output is normalized instead of rejected as unavailable --- .../service/dfx/dfx_auth_service.dart | 49 +++++---- lib/packages/service/wallet_service.dart | 16 +-- .../migrate_bitbox/migrate_bitbox_cubit.dart | 6 +- .../service/dfx/dfx_auth_service_test.dart | 101 ++++++++++++++++++ .../packages/service/wallet_service_test.dart | 60 +++++++++++ .../migrate_bitbox_cubit_test.dart | 22 ++-- 6 files changed, 220 insertions(+), 34 deletions(-) diff --git a/lib/packages/service/dfx/dfx_auth_service.dart b/lib/packages/service/dfx/dfx_auth_service.dart index f75a7fa1f..1a597ce8c 100644 --- a/lib/packages/service/dfx/dfx_auth_service.dart +++ b/lib/packages/service/dfx/dfx_auth_service.dart @@ -203,11 +203,10 @@ abstract class DFXAuthService { await walletService.ensureCurrentWalletUnlocked(); try { final currentAccount = wallet; - final signingAccount = - currentAccount.primaryAddress.address.hexEip55 == address - ? currentAccount - : account; - final signature = await signingAccount + if (currentAccount.primaryAddress.address.hexEip55 != address) { + throw _WalletIdentityChangedException(); + } + final signature = await currentAccount .signMessage(message) .timeout(_signMessageTimeout); if (signature.isEmpty || signature == '0x') { @@ -282,20 +281,24 @@ abstract class DFXAuthService { } await appStore.sessionCache.loadSignature(); - final response = await _getAuthResponseFor( - accountSnapshot, - addressSnapshot, - true, - ); - - // Close the late-commit race when the active wallet identity changes - // while /v1/auth is in flight. Discard the old identity's response and - // retry against the now-current wallet context. - if (walletAddress != addressSnapshot) continue; - - final token = response['accessToken'] as String; - appStore.sessionCache.setAuthToken(token, addressSnapshot); - return token; + try { + final response = await _getAuthResponseFor( + accountSnapshot, + addressSnapshot, + true, + ); + + // Close the late-commit race when the active wallet identity changes + // while /v1/auth is in flight. Discard the old identity's response and + // retry against the now-current wallet context. + if (walletAddress != addressSnapshot) continue; + + final token = response['accessToken'] as String; + appStore.sessionCache.setAuthToken(token, addressSnapshot); + return token; + } on _WalletIdentityChangedException { + continue; + } } throw Exception('wallet identity changed during authentication'); } @@ -406,3 +409,11 @@ abstract class DFXAuthService { return response; } } + +/// Internal marker: the active wallet identity changed between taking the +/// snapshot in [DFXAuthService.getAuthToken] and finishing the unlock in +/// [DFXAuthService._getSignatureFor]. Never signs with a stale (possibly +/// locked) snapshot account — the caller must retry with a fresh snapshot. +/// File-private by design: this must never leak into the public exception +/// surface, it is caught within this file. +class _WalletIdentityChangedException implements Exception {} diff --git a/lib/packages/service/wallet_service.dart b/lib/packages/service/wallet_service.dart index 6ecc12f6e..195c54ce3 100644 --- a/lib/packages/service/wallet_service.dart +++ b/lib/packages/service/wallet_service.dart @@ -97,10 +97,11 @@ class WalletService { // 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)) { + if (!_isValidEthAddress(address.toLowerCase())) { throw const BitboxAddressUnavailableException(); } - final normalizedAddress = EthereumAddress.fromHex(address).hexEip55; + final normalizedAddress = + EthereumAddress.fromHex(address.toLowerCase()).hexEip55; final walletId = await _repository.createViewWallet( name, WalletType.bitbox, @@ -123,10 +124,11 @@ class WalletService { // tests with mocked transport (see wallet_service_test.dart). Future acquireUncommittedBitboxWallet(String name) async { final address = await _bitboxService.getEthAddress(); - if (!_isValidEthAddress(address)) { + if (!_isValidEthAddress(address.toLowerCase())) { throw const BitboxAddressUnavailableException(); } - final normalizedAddress = EthereumAddress.fromHex(address).hexEip55; + final normalizedAddress = + EthereumAddress.fromHex(address.toLowerCase()).hexEip55; return BitboxWallet(0, name, normalizedAddress, _bitboxService); } @@ -161,6 +163,8 @@ class WalletService { /// 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. + /// Callers lowercase SDK addresses before validation and normalization so + /// mixed-case values without a valid EIP-55 checksum are not rejected early. static bool _isValidEthAddress(String address) { try { EthereumAddress.fromHex(address); @@ -200,12 +204,12 @@ class WalletService { // Shares the retry + empty-guard boundary with createBitboxWallet; the // format check stays as defence-in-depth (see that method). final rawAddress = await _bitboxService.getEthAddress(); - if (!_isValidEthAddress(rawAddress)) { + if (!_isValidEthAddress(rawAddress.toLowerCase())) { throw const BitboxAddressUnavailableException(); } // Same EIP-55 normalization as the create/acquire paths — the SDK's // address casing is not guaranteed consistent across reads. - final address = EthereumAddress.fromHex(rawAddress).hexEip55; + final address = EthereumAddress.fromHex(rawAddress.toLowerCase()).hexEip55; await _repository.updateAddress(id, address); return BitboxWallet(id, info.name, address, _bitboxService); } diff --git a/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart b/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart index 395853cec..7e1687606 100644 --- a/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart +++ b/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart @@ -396,7 +396,11 @@ class MigrateBitboxCubit extends Cubit { // The lazy path in DFXAuthService.getSignature still recovers on the next // authenticated call if this is skipped — mirrors // ConnectBitboxCubit.continueWithoutSignature. - await _appStore.sessionCache.saveSignature(bitboxAddress, signature); + await _appStore.sessionCache.saveSignature( + bitboxAddress, + signature, + _authService.buildSignMessage(bitboxAddress), + ); if (isClosed) return; } // After setCurrentWallet, before the view's HomeBloc reload, so any sync diff --git a/test/packages/service/dfx/dfx_auth_service_test.dart b/test/packages/service/dfx/dfx_auth_service_test.dart index 0e0f91d88..02bbd0cc4 100644 --- a/test/packages/service/dfx/dfx_auth_service_test.dart +++ b/test/packages/service/dfx/dfx_auth_service_test.dart @@ -64,6 +64,16 @@ class _StubWalletAccount extends AWalletAccount { } } +class _LockedSnapshotWalletAccount extends _StubWalletAccount { + _LockedSnapshotWalletAccount({required String address}) + : super('unused', address: address); + + @override + Future signMessage(String message, {int addressIndex = 0}) { + throw StateError('locked snapshot account'); + } +} + class _StubCredentials extends CredentialsWithKnownAddress { _StubCredentials(String hexAddress) : _address = EthereumAddress.fromHex(hexAddress); @@ -907,6 +917,97 @@ void main() { expect(sessionCache.authTokenAddress, walletAddress); }); + test( + 'wallet identity change during unlock retries with a fresh snapshot', + () async { + final accountA = _LockedSnapshotWalletAccount( + address: '0x1111111111111111111111111111111111111111', + ); + final accountB = _StubWalletAccount( + '0xsignature-b', + address: '0x2222222222222222222222222222222222222222', + ); + late _MutableIdentityAuthService service; + var unlockCalls = 0; + when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async { + unlockCalls++; + if (unlockCalls == 1) service.currentAccount = accountB; + }); + Map? sentBody; + final client = MockClient((request) async { + sentBody = jsonDecode(request.body) as Map; + return http.Response(jsonEncode({'accessToken': 'jwt-b'}), 201); + }); + when(() => appStore.httpClient).thenReturn(client); + service = _MutableIdentityAuthService( + appStore, + walletService, + accountA, + ); + + final token = await service.getAuthToken(); + + expect(token, 'jwt-b'); + expect(unlockCalls, 2); + expect(accountB.signCallCount, 1); + expect( + sentBody!['address'], + accountB.primaryAddress.address.hexEip55, + ); + verify(() => walletService.lockCurrentWallet()).called(2); + }, + ); + + test( + 'identity change during every unlock fails loudly after three attempts', + () async { + final accountA = _StubWalletAccount( + '0xsignature-a', + address: '0x1111111111111111111111111111111111111111', + ); + final accountB = _StubWalletAccount( + '0xsignature-b', + address: '0x2222222222222222222222222222222222222222', + ); + late _MutableIdentityAuthService service; + var unlockCalls = 0; + when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async { + unlockCalls++; + service.currentAccount = identical(service.currentAccount, accountA) + ? accountB + : accountA; + }); + var authCalls = 0; + final client = MockClient((_) async { + authCalls++; + return http.Response(jsonEncode({'accessToken': 'unexpected'}), 201); + }); + when(() => appStore.httpClient).thenReturn(client); + service = _MutableIdentityAuthService( + appStore, + walletService, + accountA, + ); + + await expectLater( + service.getAuthToken(), + throwsA( + isA().having( + (e) => e.toString(), + 'toString()', + contains('wallet identity changed during authentication'), + ), + ), + ); + + expect(unlockCalls, 3); + expect(authCalls, 0); + expect(accountA.signCallCount, 0); + expect(accountB.signCallCount, 0); + verify(() => walletService.lockCurrentWallet()).called(3); + }, + ); + test('A-B-A changes never commit the B response under A', () async { final accountA = _StubWalletAccount( '0xsignature-a', diff --git a/test/packages/service/wallet_service_test.dart b/test/packages/service/wallet_service_test.dart index 863dbc8cf..9fac1efc3 100644 --- a/test/packages/service/wallet_service_test.dart +++ b/test/packages/service/wallet_service_test.dart @@ -25,6 +25,7 @@ class _MockAppStore extends Mock implements AppStore {} const _testMnemonic = 'test test test test test test test test test test test junk'; const _debugAddress = '0x0000000000000000000000000000000000000001'; const _lowercaseAddress = '0x9f5713deacb8e9cab6c2d3fae1afc2715f8d2d71'; +const _invalidMixedCaseAddress = '0x52908400098527886e0F7030069857D2E4169EE7'; WalletInfo _info({ int id = 1, @@ -267,6 +268,26 @@ void main() { ).called(1); }); + test('accepts a checksum-invalid mixed-case device address', () async { + final normalized = EthereumAddress.fromHex( + _invalidMixedCaseAddress.toLowerCase(), + ).hexEip55; + when( + () => bitbox.getEthAddress(), + ).thenAnswer((_) async => _invalidMixedCaseAddress); + when(() => repo.createViewWallet(any(), any(), any())).thenAnswer((_) async => 13); + when( + () => bitbox.getCredentials(any()), + ).thenReturn(BitboxCredentials(normalized)); + + final wallet = await service.createBitboxWallet('Hardware'); + + expect(wallet.currentAccount.primaryAddress.address.hexEip55, normalized); + verify( + () => repo.createViewWallet('Hardware', WalletType.bitbox, normalized), + ).called(1); + }); + test('propagates a BitBox derivation failure without writing to the repo', () async { when(() => bitbox.getEthAddress()).thenThrow(Exception('USB transport dropped')); @@ -347,6 +368,24 @@ void main() { expect(draft.currentAccount.primaryAddress.address.hexEip55, normalized); }); + test('accepts a checksum-invalid mixed-case address in the draft', () async { + final normalized = EthereumAddress.fromHex( + _invalidMixedCaseAddress.toLowerCase(), + ).hexEip55; + when( + () => bitbox.getEthAddress(), + ).thenAnswer((_) async => _invalidMixedCaseAddress); + when( + () => bitbox.getCredentials(any()), + ).thenReturn(BitboxCredentials(normalized)); + + final draft = await service.acquireUncommittedBitboxWallet('Migration'); + + expect(draft.currentAccount.primaryAddress.address.hexEip55, normalized); + verifyNever(() => repo.createViewWallet(any(), any(), any())); + verifyNever(() => settings.saveCurrentWalletId(any())); + }); + test('throws BitboxAddressUnavailableException on a malformed address', () async { when(() => bitbox.getEthAddress()).thenAnswer((_) async => 'not-a-hex-address'); @@ -515,6 +554,27 @@ void main() { verify(() => repo.updateAddress(5, checksummed)).called(1); }); + test('accepts a checksum-invalid mixed-case address when healing', () async { + final normalized = EthereumAddress.fromHex( + _invalidMixedCaseAddress.toLowerCase(), + ).hexEip55; + when(() => settings.currentWalletId).thenReturn(5); + when(() => repo.getWalletInfo(5)).thenAnswer( + (_) async => _info(id: 5, name: 'Hardware', address: '', type: WalletType.bitbox), + ); + when( + () => bitbox.getEthAddress(), + ).thenAnswer((_) async => _invalidMixedCaseAddress); + when( + () => bitbox.getCredentials(any()), + ).thenReturn(BitboxCredentials(normalized)); + + final wallet = await service.healCurrentBitboxAddress(); + + expect(wallet.currentAccount.primaryAddress.address.hexEip55, normalized); + verify(() => repo.updateAddress(5, normalized)).called(1); + }); + test('propagates BitboxAddressUnavailableException and does NOT persist', () async { when(() => settings.currentWalletId).thenReturn(5); when(() => repo.getWalletInfo(5)).thenAnswer( diff --git a/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart b/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart index eafbeaae9..f0ae69b91 100644 --- a/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart +++ b/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart @@ -74,6 +74,7 @@ void main() { const refreshedJwt = 'refreshed-jwt'; const newJwt = 'new-jwt'; const signature = '0xsigned'; + const signMessage = 'scoped-sign-message'; late String draftAddress; late String persistedAddress; @@ -149,10 +150,11 @@ void main() { when(() => apiConfig.asset).thenReturn(realUnitAsset); when(() => sessionCache.signatureAddress).thenReturn(draftAddress); when(() => sessionCache.signature).thenReturn(signature); - when(() => sessionCache.saveSignature(any(), any())).thenAnswer((_) async {}); + when(() => sessionCache.saveSignature(any(), any(), any())).thenAnswer((_) async {}); when(() => sessionCache.setAuthToken(any(), any())).thenReturn(null); when(() => authService.refreshAuthToken()).thenAnswer((_) async => refreshedJwt); + when(() => authService.buildSignMessage(any())).thenReturn(signMessage); when( () => authService.authenticateLinkedAccount(any(), any()), ).thenAnswer((_) async => newJwt); @@ -621,7 +623,7 @@ void main() { await preparation; verifyNever(() => walletService.setCurrentWallet(any())); - verifyNever(() => sessionCache.saveSignature(any(), any())); + verifyNever(() => sessionCache.saveSignature(any(), any(), any())); verifyNever(() => sessionCache.setAuthToken(any(), any())); }); }); @@ -715,7 +717,11 @@ void main() { verifyInOrder([ () => walletService.setCurrentWallet(42), - () => sessionCache.saveSignature(persistedAddress, signature), + () => sessionCache.saveSignature( + persistedAddress, + signature, + signMessage, + ), () => sessionCache.setAuthToken(newJwt, persistedAddress), ]); expect(cubit.state, MigrateBitboxSuccess(persisted)); @@ -734,7 +740,7 @@ void main() { () => walletService.setCurrentWallet(42), () => sessionCache.setAuthToken(newJwt, persistedAddress), ]); - verifyNever(() => sessionCache.saveSignature(any(), any())); + verifyNever(() => sessionCache.saveSignature(any(), any(), any())); expect(cubit.state, MigrateBitboxSuccess(persisted)); }); @@ -745,7 +751,7 @@ void main() { () => balanceService.fetchBalance(any()), ).thenAnswer((_) async => balance(0)); when( - () => sessionCache.saveSignature(any(), any()), + () => sessionCache.saveSignature(any(), any(), any()), ).thenAnswer((_) => pendingSignatureSave.future); final cubit = buildCubit(addCloseTearDown: false); @@ -771,7 +777,7 @@ void main() { drain(async); verifyNever(() => walletService.setCurrentWallet(any())); - verifyNever(() => sessionCache.saveSignature(any(), any())); + verifyNever(() => sessionCache.saveSignature(any(), any(), any())); verifyNever(() => sessionCache.setAuthToken(any(), any())); }); }); @@ -1025,7 +1031,7 @@ void main() { drain(async); verifyNever(() => walletService.setCurrentWallet(any())); - verifyNever(() => sessionCache.saveSignature(any(), any())); + verifyNever(() => sessionCache.saveSignature(any(), any(), any())); verifyNever(() => sessionCache.setAuthToken(any(), any())); async.flushTimers(); }); @@ -1060,7 +1066,7 @@ void main() { expect(emitted, hasLength(emissionCountAtClose)); expect(emitted.whereType(), isEmpty); - verifyNever(() => sessionCache.saveSignature(any(), any())); + verifyNever(() => sessionCache.saveSignature(any(), any(), any())); verifyNever(() => sessionCache.setAuthToken(any(), any())); subscription.cancel(); async.flushTimers(); From 2d26def86f38bb365be532b708964557a92fb483 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:09:03 +0200 Subject: [PATCH 16/26] test: align auth-service fixtures with the round-3 identity guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stubs' reported wallet address and the account's actual address are compared since round 3 — bind the stub account to the fixture address, use a checksum-neutral digits-only fixture in the wire-surface group, and give the hanging-account timeout test the account's real derived address. --- .../packages/service/dfx/dfx_auth_service_test.dart | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/test/packages/service/dfx/dfx_auth_service_test.dart b/test/packages/service/dfx/dfx_auth_service_test.dart index 02bbd0cc4..62cb4d2a4 100644 --- a/test/packages/service/dfx/dfx_auth_service_test.dart +++ b/test/packages/service/dfx/dfx_auth_service_test.dart @@ -222,7 +222,9 @@ void main() { setUp(() { appStore = _MockAppStore(); sessionCache = _MockSessionCache(); - walletAccount = _StubWalletAccount(validSig); + // The account must carry the same address the service reports — the + // identity guard in _getSignatureFor compares the two since round 3. + walletAccount = _StubWalletAccount(validSig, address: address); walletService = _MockWalletService(); when(() => appStore.sessionCache).thenReturn(sessionCache); @@ -716,7 +718,10 @@ void main() { late _MockWalletService walletService; late _StubWalletAccount account; - const walletAddress = '0xdddddddddddddddddddddddddddddddddddddddd'; + // Digits-only fixture: EIP-55 checksumming leaves it unchanged, so the + // raw constant stays equal to the account's hexEip55 the identity guard + // in _getSignatureFor compares against. + const walletAddress = '0x4444444444444444444444444444444444444444'; const validSignature = '0xfeedface'; setUp(() { @@ -1393,11 +1398,13 @@ void main() { when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); + // The service address must match the hanging account's real derived + // address, otherwise the identity guard fires before the hang. final service = _SignatureTestAuthService( appStore, walletService, account, - '0xEeEeEeEeEeEeEeEeEeEeEeEeEeEeEeEeEeEeEeEe', + account.primaryAddress.address.hexEip55, ); Object? caught; From 3cc611b90e8f52b1ee467ba83fe62b8347c2b718 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:11:05 +0200 Subject: [PATCH 17/26] test: bind the cancelled-signature stubs to the fixture address as well --- test/packages/service/dfx/dfx_auth_service_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/packages/service/dfx/dfx_auth_service_test.dart b/test/packages/service/dfx/dfx_auth_service_test.dart index 62cb4d2a4..546e9d194 100644 --- a/test/packages/service/dfx/dfx_auth_service_test.dart +++ b/test/packages/service/dfx/dfx_auth_service_test.dart @@ -315,7 +315,7 @@ void main() { test( 'throws SigningCancelledException when the wallet returns "$emptySignature"', () async { - walletAccount = _StubWalletAccount(emptySignature); + walletAccount = _StubWalletAccount(emptySignature, address: address); expect( () => buildService().getSignature('msg'), From 818372585919267f64c8b3eb05b4c9aec22ff34b Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:35:14 +0200 Subject: [PATCH 18/26] fix(migration): contain the identity-change marker in every public auth path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getSignature and getAuthResponse previously delegated to the snapshot helpers without catching the file-private identity marker — a mid-unlock wallet switch would have leaked it. A shared _withIdentityRetry helper now wraps all three public entry points with the bounded fresh-snapshot retry and the readable fail-loud exception, so the marker genuinely never leaves the file. --- .../service/dfx/dfx_auth_service.dart | 93 ++++++++------- .../service/dfx/dfx_auth_service_test.dart | 112 ++++++++++++++++++ 2 files changed, 163 insertions(+), 42 deletions(-) diff --git a/lib/packages/service/dfx/dfx_auth_service.dart b/lib/packages/service/dfx/dfx_auth_service.dart index 1a597ce8c..8fee1f5e0 100644 --- a/lib/packages/service/dfx/dfx_auth_service.dart +++ b/lib/packages/service/dfx/dfx_auth_service.dart @@ -37,6 +37,7 @@ Future warmAuthSignature( abstract class DFXAuthService { static const walletName = 'RealUnit'; static const _signMessageTimeout = Duration(minutes: 3); + static const _maxIdentityAttempts = 3; static const _httpTimeout = Duration(seconds: 20); /// Auth sign-in message body (without environment scoping), derived @@ -175,10 +176,27 @@ abstract class DFXAuthService { // * `SigningCancelledException` — the user cancels on the device, so the // BitBox swift wrapper returns empty bytes / `'0x'`, normalised here. // * `TimeoutException` — the user never confirms within `_signMessageTimeout`. - Future getSignature(String message) async { - final account = wallet; - final address = walletAddress; - return _getSignatureFor(account, address, message); + Future getSignature(String message) => _withIdentityRetry( + (account, address) => _getSignatureFor(account, address, message), + ); + + /// Runs [attempt] with a fresh wallet snapshot per try. Retries when the + /// active wallet identity changed mid-flight (the file-private marker), and + /// fails loud with a readable exception after [_maxIdentityAttempts] — the + /// marker therefore never leaves this file. + Future _withIdentityRetry( + Future Function(AWalletAccount account, String address) attempt, + ) async { + for (var i = 0; i < _maxIdentityAttempts; i++) { + final account = wallet; + final address = walletAddress; + try { + return await attempt(account, address); + } on _WalletIdentityChangedException { + continue; + } + } + throw Exception('wallet identity changed during authentication'); } Future _getSignatureFor( @@ -219,11 +237,9 @@ abstract class DFXAuthService { } } - Future> getAuthResponse([bool sendWalletName = true]) async { - final account = wallet; - final address = walletAddress; - return _getAuthResponseFor(account, address, sendWalletName); - } + Future> getAuthResponse([bool sendWalletName = true]) => _withIdentityRetry( + (account, address) => _getAuthResponseFor(account, address, sendWalletName), + ); Future> _getAuthResponseFor( AWalletAccount account, @@ -270,38 +286,31 @@ abstract class DFXAuthService { // bitbox_flutter v0.0.2 fixed the BLE force-unwrap and dedup hang, the // empty-signature guard in `getSignature` covers the cancel/disconnect // case gracefully, and the SDK no longer panics on NACK. - Future getAuthToken() async { - for (var attempt = 0; attempt < 3; attempt++) { - final accountSnapshot = wallet; - final addressSnapshot = walletAddress; - final cachedToken = appStore.sessionCache.authToken; - if (cachedToken != null && - appStore.sessionCache.authTokenAddress == addressSnapshot) { - return cachedToken; - } + Future getAuthToken() => _withIdentityRetry((accountSnapshot, addressSnapshot) async { + final cachedToken = appStore.sessionCache.authToken; + if (cachedToken != null && + appStore.sessionCache.authTokenAddress == addressSnapshot) { + return cachedToken; + } - await appStore.sessionCache.loadSignature(); - try { - final response = await _getAuthResponseFor( - accountSnapshot, - addressSnapshot, - true, - ); - - // Close the late-commit race when the active wallet identity changes - // while /v1/auth is in flight. Discard the old identity's response and - // retry against the now-current wallet context. - if (walletAddress != addressSnapshot) continue; - - final token = response['accessToken'] as String; - appStore.sessionCache.setAuthToken(token, addressSnapshot); - return token; - } on _WalletIdentityChangedException { - continue; - } + await appStore.sessionCache.loadSignature(); + final response = await _getAuthResponseFor( + accountSnapshot, + addressSnapshot, + true, + ); + + // Close the late-commit race when the active wallet identity changes + // while /v1/auth is in flight. Discard the old identity's response and + // retry against the now-current wallet context. + if (walletAddress != addressSnapshot) { + throw _WalletIdentityChangedException(); } - throw Exception('wallet identity changed during authentication'); - } + + final token = response['accessToken'] as String; + appStore.sessionCache.setAuthToken(token, addressSnapshot); + return token; + }); void invalidateAuthToken() => appStore.sessionCache.clearAuthToken(); @@ -411,9 +420,9 @@ abstract class DFXAuthService { } /// Internal marker: the active wallet identity changed between taking the -/// snapshot in [DFXAuthService.getAuthToken] and finishing the unlock in +/// snapshot in [DFXAuthService._withIdentityRetry] and finishing the unlock in /// [DFXAuthService._getSignatureFor]. Never signs with a stale (possibly /// locked) snapshot account — the caller must retry with a fresh snapshot. -/// File-private by design: this must never leak into the public exception -/// surface, it is caught within this file. +/// Caught exclusively by [DFXAuthService._withIdentityRetry] — never escapes +/// this file. class _WalletIdentityChangedException implements Exception {} diff --git a/test/packages/service/dfx/dfx_auth_service_test.dart b/test/packages/service/dfx/dfx_auth_service_test.dart index 546e9d194..7613dceb8 100644 --- a/test/packages/service/dfx/dfx_auth_service_test.dart +++ b/test/packages/service/dfx/dfx_auth_service_test.dart @@ -311,6 +311,77 @@ void main() { verify(() => sessionCache.saveSignature(address, validSig, message)).called(1); }); + test( + 'identity change mid-unlock retries with a fresh snapshot and signs with the new account', + () async { + final accountA = _LockedSnapshotWalletAccount( + address: '0x1111111111111111111111111111111111111111', + ); + final accountB = _StubWalletAccount( + '0xsignature-b', + address: '0x2222222222222222222222222222222222222222', + ); + late _MutableIdentityAuthService service; + var unlockCalls = 0; + when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async { + unlockCalls++; + if (unlockCalls == 1) service.currentAccount = accountB; + }); + service = _MutableIdentityAuthService( + appStore, + walletService, + accountA, + ); + + final signature = await service.getSignature('msg'); + + expect(signature, '0xsignature-b'); + expect(accountB.signCallCount, 1); + expect(unlockCalls, 2); + verify(() => walletService.lockCurrentWallet()).called(2); + }, + ); + + test('permanent flapping identity fails loudly after three attempts', () async { + final accountA = _StubWalletAccount( + '0xsignature-a', + address: '0x1111111111111111111111111111111111111111', + ); + final accountB = _StubWalletAccount( + '0xsignature-b', + address: '0x2222222222222222222222222222222222222222', + ); + late _MutableIdentityAuthService service; + var unlockCalls = 0; + when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async { + unlockCalls++; + service.currentAccount = identical(service.currentAccount, accountA) + ? accountB + : accountA; + }); + service = _MutableIdentityAuthService( + appStore, + walletService, + accountA, + ); + + await expectLater( + service.getSignature('msg'), + throwsA( + isA().having( + (e) => e.toString(), + 'toString()', + contains('wallet identity changed during authentication'), + ), + ), + ); + + expect(unlockCalls, 3); + expect(accountA.signCallCount, 0); + expect(accountB.signCallCount, 0); + verify(() => walletService.lockCurrentWallet()).called(3); + }); + for (final emptySignature in const ['', '0x']) { test( 'throws SigningCancelledException when the wallet returns "$emptySignature"', @@ -881,6 +952,47 @@ void main() { ); }); + test( + 'getAuthResponse retries with a fresh snapshot when identity changes mid-unlock', + () async { + final accountA = _LockedSnapshotWalletAccount( + address: '0x1111111111111111111111111111111111111111', + ); + final accountB = _StubWalletAccount( + '0xsignature-b', + address: '0x2222222222222222222222222222222222222222', + ); + late _MutableIdentityAuthService service; + var unlockCalls = 0; + when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async { + unlockCalls++; + if (unlockCalls == 1) service.currentAccount = accountB; + }); + Map? sentBody; + final client = MockClient((request) async { + sentBody = jsonDecode(request.body) as Map; + return http.Response(jsonEncode({'accessToken': 'jwt-b'}), 201); + }); + when(() => appStore.httpClient).thenReturn(client); + service = _MutableIdentityAuthService( + appStore, + walletService, + accountA, + ); + + final response = await service.getAuthResponse(); + + expect(response['accessToken'], 'jwt-b'); + expect(accountB.signCallCount, 1); + expect( + sentBody!['address'], + accountB.primaryAddress.address.hexEip55, + ); + expect(unlockCalls, 2); + verify(() => walletService.lockCurrentWallet()).called(2); + }, + ); + test( 'getAuthToken hits the sign-then-auth round-trip on a cold cache and caches the token', () async { From f6caafd041d75fd584ba7981956349437aaaa77f Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:45:56 +0200 Subject: [PATCH 19/26] style(migration): wrap the getAuthToken delegation and complete the marker doc --- .../service/dfx/dfx_auth_service.dart | 60 ++++++++++--------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/lib/packages/service/dfx/dfx_auth_service.dart b/lib/packages/service/dfx/dfx_auth_service.dart index 8fee1f5e0..6db21525c 100644 --- a/lib/packages/service/dfx/dfx_auth_service.dart +++ b/lib/packages/service/dfx/dfx_auth_service.dart @@ -286,31 +286,33 @@ abstract class DFXAuthService { // bitbox_flutter v0.0.2 fixed the BLE force-unwrap and dedup hang, the // empty-signature guard in `getSignature` covers the cancel/disconnect // case gracefully, and the SDK no longer panics on NACK. - Future getAuthToken() => _withIdentityRetry((accountSnapshot, addressSnapshot) async { - final cachedToken = appStore.sessionCache.authToken; - if (cachedToken != null && - appStore.sessionCache.authTokenAddress == addressSnapshot) { - return cachedToken; - } + Future getAuthToken() => _withIdentityRetry( + (accountSnapshot, addressSnapshot) async { + final cachedToken = appStore.sessionCache.authToken; + if (cachedToken != null && + appStore.sessionCache.authTokenAddress == addressSnapshot) { + return cachedToken; + } - await appStore.sessionCache.loadSignature(); - final response = await _getAuthResponseFor( - accountSnapshot, - addressSnapshot, - true, - ); + await appStore.sessionCache.loadSignature(); + final response = await _getAuthResponseFor( + accountSnapshot, + addressSnapshot, + true, + ); - // Close the late-commit race when the active wallet identity changes - // while /v1/auth is in flight. Discard the old identity's response and - // retry against the now-current wallet context. - if (walletAddress != addressSnapshot) { - throw _WalletIdentityChangedException(); - } + // Close the late-commit race when the active wallet identity changes + // while /v1/auth is in flight. Discard the old identity's response and + // retry against the now-current wallet context. + if (walletAddress != addressSnapshot) { + throw _WalletIdentityChangedException(); + } - final token = response['accessToken'] as String; - appStore.sessionCache.setAuthToken(token, addressSnapshot); - return token; - }); + final token = response['accessToken'] as String; + appStore.sessionCache.setAuthToken(token, addressSnapshot); + return token; + }, + ); void invalidateAuthToken() => appStore.sessionCache.clearAuthToken(); @@ -419,10 +421,12 @@ abstract class DFXAuthService { } } -/// Internal marker: the active wallet identity changed between taking the -/// snapshot in [DFXAuthService._withIdentityRetry] and finishing the unlock in -/// [DFXAuthService._getSignatureFor]. Never signs with a stale (possibly -/// locked) snapshot account — the caller must retry with a fresh snapshot. -/// Caught exclusively by [DFXAuthService._withIdentityRetry] — never escapes -/// this file. +/// Internal marker: the active wallet identity changed during an +/// authentication attempt — either between taking the snapshot in +/// [DFXAuthService._withIdentityRetry] and finishing the unlock in +/// [DFXAuthService._getSignatureFor], or after the auth response arrived but +/// before its late commit gate in [DFXAuthService.getAuthToken]. Never signs +/// with (or commits for) a stale snapshot — the caller must retry with a +/// fresh snapshot. Caught exclusively by [DFXAuthService._withIdentityRetry] +/// — never escapes this file. class _WalletIdentityChangedException implements Exception {} From da58abbc428b53689bd09263dd4281fbd2164581 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:56:23 +0200 Subject: [PATCH 20/26] test(migration): pin the persisted-wallet signature contract The persisted fixture now shares the draft's address (matching persistBitboxWallet's behaviour) and buildSignMessage is stubbed on the exact address, so a signature saved under a foreign address or a wrong message argument fails the test. --- .../migrate_bitbox/migrate_bitbox_cubit_test.dart | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart b/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart index f0ae69b91..5ec2ca43c 100644 --- a/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart +++ b/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart @@ -132,9 +132,10 @@ void main() { final draftCredentials = EthPrivateKey.fromHex( 'fb1ace12f9801e85f3db1b3935dd47d9f064f98152466f47c701b5e12680e612', ); - final persistedCredentials = EthPrivateKey.fromHex( - '7d1d0f68f145b214e49c1a5c6c31a5570358ec80025c5d25f6a56f21fbe6342f', - ); + // persistBitboxWallet reuses the draft's address for the persisted row — + // modelling them with different keys would let a signature captured for + // the draft be saved under a foreign address without the test noticing. + final persistedCredentials = draftCredentials; draftAddress = draftCredentials.address.hexEip55; persistedAddress = persistedCredentials.address.hexEip55; when(() => draft.id).thenReturn(0); @@ -154,7 +155,9 @@ void main() { when(() => sessionCache.setAuthToken(any(), any())).thenReturn(null); when(() => authService.refreshAuthToken()).thenAnswer((_) async => refreshedJwt); - when(() => authService.buildSignMessage(any())).thenReturn(signMessage); + // Exact-argument stub: a wrong address handed to buildSignMessage must + // surface as a missing-stub error instead of silently matching. + when(() => authService.buildSignMessage(persistedAddress)).thenReturn(signMessage); when( () => authService.authenticateLinkedAccount(any(), any()), ).thenAnswer((_) async => newJwt); From 6274fddbb207269c9e2a3cb0e86dbed9b218c9e8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:38:51 +0200 Subject: [PATCH 21/26] test(goldens): regenerate baselines on the self-hosted runner --- .../goldens/macos/settings_page_default.png | Bin 40965 -> 44858 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/test/goldens/screens/settings/goldens/macos/settings_page_default.png b/test/goldens/screens/settings/goldens/macos/settings_page_default.png index fcbb882f0ea3316800ec6cf95830ad265abcc046..f82545ed53166b1b883eb8a81fd6bf82f67422d6 100644 GIT binary patch delta 27325 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 ZFuR{+diK5KW(5j}jy{-TN delta 23408 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

LbA6%k?x`XThdN>AB5w< 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

?Jp_3gay%2Mzv zd4;l4D1f^SJT&@cvhG0xqN0mw*;wq*{j-Gd8U9P$Wm-nDJ@c}%=|qrZQTQ=Ef_HRf z=t|$$eqv(jm6*)yk6dS)pYE5rnuu6eFQs6Li}e%M2DAFZT9zKsiGd|1oK_2*%cdD- zx`k-|Qs0nU-5;T{I2viR_M=LW^E|^!wAvqn8T#FN!>hmFye?bwl|eTAS>G_3+umOC zd@ndzL{_KJr{!uBa^a@Y`f-O(_06XGVdni;BT`WeEiWgZ{R>Zk%1>SxIX)#)o)JGx`)bfw`x}?F&%?4 z@nlxcVAVZYibdP0^f&3a)L|sGUrq{(_IOhX-x#fQjOWJ`e?SQ+D^*C`QB+l?u`+PO z7KW#|;}k(^4>8dr8d=fR&F%}_U-m2Fw8S>}=&u%7=5$f;lq^yqEs$PeXwm`>I7p#R z?jRky__N>fs=H}(F`h4dtg?$CSK~GEP7=?mF;ZYoYz*#K|EjASrVj7wRk6-ur(JA# z*m3=Q!JzPO17>=5LzBVUX%TD1UF-<%oMT9+xju+6x*k!iy5kxd*%)AuA1q@I^IX`c z7{Y8B1>!5q>zL|PqWtVqtCgv^BwF?)1HD=9LOedeek)k%o-Ljg!-4{Jovh1W^r+5U zK76{#&)|j=LD;|W<6hIe0exADk^e#6-B57NI-iqERr&DBSU5QhkJ2tqdu$R;uDRJr zB2`*#^o63r)7eSYze;>7`&M9}z{dSBWxU&{p%eTHr+IP1KfWAtdWM(RZ1Q00R*nWa zZ4cVf>*otjML+AJwnIjkH3y%J3$^dLE7?i8jOgfSZI~8!`LgBim`7gJ`nza??^N0L zB!UsXV`0;DG3pOh@!CGHqSl>%{yu|Nw?YFkbu^xqe0qe4kq^Jmnl6g^VDDzT`l56f z^|YywN%STl8C_dCXVnpyx(8Fnjm|3gwkxAddp9>5TCxq)To)N#86=}&koZ+FjBHeR zNK^a~e{vtS-3Ul5LFJNrMMBElSNKHEeAeYfh`CR4%M_KqI-o~i|xZXLvfYxhvf_@QX@e6e`%URpN7I*`Qi z)cVoAf7cJ9hoonu`vvZb+`>7A`^&j4)}w_2t0ADXVVBP$8n*=K88HKmeQj+PAFsu` zkd)v$ku$}MSFFCXrz(~P{;NH%!I;^m`-Rc{mKODJQ3!p!;oK8+kh+$WlabMrZvVY{ z*4Zm{%^l1OHx|{mZvg#cT#9l=FtsA?CQnV~sthb>iGS_wHChi%(5(x=**sRC%d1e; zg@EibV|Rjk5o?L2-CdL4zn3D+W!2TKHvAd1rS_$_8f|Nxuf?J`C*Hkx!AVI-;;U*S z`?yw?($&QM7MF{N#FyCt?XX7Jm5qyyI}Ty-Ozj$?m-xONOcK_VXCmw?A)e6o-FLy5 zFLNUJ=GIJKH-pg@8H!70a;)P%MqYvf$JI_|nz4S>lOEdoR3ueaNOSVEd3QyB^e%zs zHuH4+7vS2)CK8vb=%6e*4jX1A3^8v;Y7A From 6f773e92ba234286c7badd3b612619e4c6547426 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:42:36 +0200 Subject: [PATCH 22/26] ci: trigger checks on the regenerated golden baseline The baseline commit was pushed by the actions bot, whose default-token pushes intentionally start no workflow runs. From cbc23046982e5ec10721276b4878c21c3cacdf13 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:06:12 +0200 Subject: [PATCH 23/26] test: close the last four uncovered lines for the coverage floor The keep-message and drop-message branches of saveSignature's optional third argument and the settling timeout reached through consecutive poll errors were unexercised. --- test/packages/service/session_cache_test.dart | 19 +++++++++++++ .../migrate_bitbox_cubit_test.dart | 27 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/test/packages/service/session_cache_test.dart b/test/packages/service/session_cache_test.dart index 90a9ca70b..f92b8520b 100644 --- a/test/packages/service/session_cache_test.dart +++ b/test/packages/service/session_cache_test.dart @@ -63,6 +63,25 @@ void main() { verify(() => repo.write('cached_signature_message', 'sign-message')).called(1); }); + test('re-saving the identical address+signature without a message keeps the stored message', () async { + await cache.saveSignature('0xabc', '0xsig', 'sign-message'); + + await cache.saveSignature('0xabc', '0xsig'); + + expect(cache.signedMessage, 'sign-message'); + verify(() => repo.write('cached_signature_message', 'sign-message')).called(2); + verifyNever(() => repo.delete('cached_signature_message')); + }); + + test('saving a different signature without a message drops the persisted message', () async { + await cache.saveSignature('0xabc', '0xsig', 'sign-message'); + + await cache.saveSignature('0xabc', '0xother'); + + expect(cache.signedMessage, isNull); + verify(() => repo.delete('cached_signature_message')).called(1); + }); + test('loadSignature populates from the repo when memory is empty', () async { when(() => repo.read('cached_signature')).thenAnswer((_) async => '0xsig'); when(() => repo.read('cached_signature_address')).thenAnswer((_) async => '0xabc'); diff --git a/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart b/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart index 5ec2ca43c..16164e90d 100644 --- a/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart +++ b/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart @@ -853,6 +853,33 @@ void main() { }); }); + test('20 consecutive poll errors also time out without switching wallets', () { + // Pins the catch-branch timeout: fetch failures count as attempts and + // must reach the same fail-closed SettlingTimeout as unchanged balances. + fakeAsync((async) { + final cubit = buildCubit(addCloseTearDown: false); + cubit.onDevicePaired(draft); + drain(async); + cubit.startTransfer(); + cubit.onTransferBroadcast(); + drain(async); + clearInteractions(balanceService); + when(() => balanceService.fetchBalance(any())) + .thenAnswer((_) async => throw Exception('poll offline')); + + for (var i = 0; i < 20; i++) { + async.elapse(const Duration(seconds: 3)); + drain(async); + } + + expect(cubit.state, const MigrateBitboxSettlingTimeout()); + verifyNever(() => walletService.setCurrentWallet(any())); + verify(() => balanceService.fetchBalance(softwareAddress)).called(20); + cubit.close(); + async.flushTimers(); + }); + }); + test('an unchanged balance times out after 20 attempts without switching wallets', () { fakeAsync((async) { final cubit = buildCubit(addCloseTearDown: false); From f7d9c39e1c2957d2aa5242aaf0d53dac066e27d3 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:54:12 +0200 Subject: [PATCH 24/26] test(migration): golden coverage for the wizard's visual states Eleven baselines matching the repo's per-screen golden convention: intro, register, registration pending, transfer ready/in-flight/failure, settling, settling timeout, success and both failure variants. PNGs are rendered by the self-hosted runner via golden-regenerate. --- .../migrate_bitbox_golden_test.dart | 274 ++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 test/goldens/screens/migrate_bitbox/migrate_bitbox_golden_test.dart diff --git a/test/goldens/screens/migrate_bitbox/migrate_bitbox_golden_test.dart b/test/goldens/screens/migrate_bitbox/migrate_bitbox_golden_test.dart new file mode 100644 index 000000000..c9311667d --- /dev/null +++ b/test/goldens/screens/migrate_bitbox/migrate_bitbox_golden_test.dart @@ -0,0 +1,274 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/models/balance.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/repository/balance_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_transfer_service.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/migrate_bitbox_page.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_result_views.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_transfer_view.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_process/send_process_cubit.dart'; + +import '../../../helper/helper.dart'; + +class _MockMigrateBitboxCubit extends MockCubit + implements MigrateBitboxCubit {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockBalanceRepository extends Mock implements BalanceRepository {} + +class _MockRegistrationService extends Mock implements RealUnitRegistrationService {} + +class _MockSoftwareWallet extends Mock implements SoftwareWallet {} + +class _MockTransferService extends Mock implements RealUnitTransferService {} + +class _MockWalletAccount extends Mock implements AWalletAccount {} + +const _softwareAddress = '0x0000000000000000000000000000000000000001'; +const _bitboxAddress = '0x1234567890abcdef1234567890abcdef12345678'; + +const _userData = RealUnitUserDataDto( + email: 'ada@example.com', + name: 'Ada Lovelace', + type: 'HUMAN', + phoneNumber: '+41 79 000 00 00', + birthday: '1815-12-10', + nationality: 'CH', + addressStreet: 'Bahnhofstrasse 1', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: 'CH', + swissTaxResidence: true, + lang: 'de', + kycData: KycPersonalData( + accountType: KycAccountType.personal, + firstName: 'Ada', + lastName: 'Lovelace', + phone: '+41 79 000 00 00', + address: KycAddress( + street: 'Bahnhofstrasse', + zip: '8000', + city: 'Zurich', + country: 41, + ), + ), +); + +Balance _balance(int shares) => Balance( + chainId: realUnitAsset.chainId, + contractAddress: realUnitAsset.address, + walletAddress: _softwareAddress, + balance: BigInt.from(shares), + asset: realUnitAsset, +); + +void main() { + late _MockMigrateBitboxCubit migrateCubit; + late _MockWalletAccount draftAccount; + + setUpAll(() { + registerFallbackValue(_balance(0)); + registerFallbackValue( + const RealUnitTransferDto(toAddress: _bitboxAddress, amount: 1), + ); + + final apiConfig = _MockApiConfig(); + final appStore = _MockAppStore(); + final balanceRepository = _MockBalanceRepository(); + final registrationService = _MockRegistrationService(); + final softwareWallet = _MockSoftwareWallet(); + final transferService = _MockTransferService(); + final pendingPrepare = Completer(); + + when(() => apiConfig.asset).thenReturn(realUnitAsset); + when(() => appStore.apiConfig).thenReturn(apiConfig); + when(() => appStore.primaryAddress).thenReturn(_softwareAddress); + when(() => appStore.wallet).thenReturn(softwareWallet); + when(() => softwareWallet.walletType).thenReturn(WalletType.software); + when( + () => balanceRepository.watchBalance(any()), + ).thenAnswer((_) => Stream.value(_balance(42))); + when( + () => transferService.prepareTransfer(any()), + ).thenAnswer((_) => pendingPrepare.future); + + GetIt.instance.registerSingleton(appStore); + GetIt.instance.registerSingleton(balanceRepository); + GetIt.instance.registerSingleton( + registrationService, + ); + GetIt.instance.registerSingleton(transferService); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + setUp(() { + migrateCubit = _MockMigrateBitboxCubit(); + draftAccount = _MockWalletAccount(); + when(() => migrateCubit.state).thenReturn(const MigrateBitboxIntro()); + when(() => migrateCubit.draftAccount).thenReturn(draftAccount); + when(() => migrateCubit.linkedJwt).thenReturn('linked-jwt'); + }); + + Widget buildManager(MigrateBitboxState state) { + when(() => migrateCubit.state).thenReturn(state); + whenListen( + migrateCubit, + const Stream.empty(), + initialState: state, + ); + return wrapForGolden( + BlocProvider.value( + value: migrateCubit, + child: const MigrateBitboxViewManager(), + ), + ); + } + + group('$MigrateBitboxViewManager', () { + goldenTest( + 'intro with the current REALU balance', + fileName: 'migrate_bitbox_intro', + constraints: phoneConstraints, + builder: () => buildManager(const MigrateBitboxIntro()), + ); + + goldenTest( + 'registration ready with user data and BitBox address', + fileName: 'migrate_bitbox_register_ready', + constraints: phoneConstraints, + builder: () => buildManager( + const MigrateBitboxRegisterReady(_userData, _bitboxAddress), + ), + ); + + goldenTest( + 'transfer summary with source, destination, and amount', + fileName: 'migrate_bitbox_transfer_ready', + constraints: phoneConstraints, + builder: () => buildManager( + const MigrateBitboxTransferReady( + fromAddress: _softwareAddress, + toAddress: _bitboxAddress, + amount: 42, + ), + ), + ); + + goldenTest( + 'transfer preparation in progress', + fileName: 'migrate_bitbox_transferring', + constraints: phoneConstraints, + pumpBeforeTest: pumpOnce, + builder: () => buildManager( + const MigrateBitboxTransferring( + toAddress: _bitboxAddress, + amount: 42, + ), + ), + ); + + goldenTest( + 'settling in progress', + fileName: 'migrate_bitbox_settling', + constraints: phoneConstraints, + pumpBeforeTest: pumpOnce, + builder: () => buildManager(const MigrateBitboxSettling()), + ); + }); + + group('standalone migration result views', () { + goldenTest( + 'registration pending manual review', + fileName: 'migrate_bitbox_registration_pending', + constraints: phoneConstraints, + builder: () => wrapForGolden( + const MigrateBitboxRegistrationPendingPage(), + ), + ); + + goldenTest( + 'retryable transfer failure with unavailable gas funding', + fileName: 'migrate_bitbox_transfer_failure', + constraints: phoneConstraints, + builder: () => wrapForGolden( + Builder( + builder: (context) => Scaffold( + appBar: AppBar(title: Text(S.of(context).migrateBitbox)), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: SafeArea( + child: MigrateTransferFailureView( + reason: SendProcessFailureReason.gasFundingUnavailable, + canRetry: true, + onRetry: () {}, + ), + ), + ), + ), + ), + ), + ); + + goldenTest( + 'settling timeout', + fileName: 'migrate_bitbox_settling_timeout', + constraints: phoneConstraints, + builder: () => wrapForGolden( + const MigrateBitboxSettlingTimeoutPage(), + ), + ); + + goldenTest( + 'successful migration', + fileName: 'migrate_bitbox_success', + constraints: phoneConstraints, + builder: () => wrapForGolden(const MigrateBitboxSuccessPage()), + ); + + goldenTest( + 'retryable generic migration failure', + fileName: 'migrate_bitbox_failure_retryable', + constraints: phoneConstraints, + builder: () => wrapForGolden( + const MigrateBitboxFailurePage( + reason: MigrateBitboxFailureReason.generic, + canRetry: true, + ), + ), + ); + + goldenTest( + 'already-linked migration failure without retry', + fileName: 'migrate_bitbox_failure_already_linked', + constraints: phoneConstraints, + builder: () => wrapForGolden( + const MigrateBitboxFailurePage( + reason: MigrateBitboxFailureReason.addressAlreadyLinked, + canRetry: false, + ), + ), + ); + }); +} From 87e9e25bcbc97b26ee52a107e5664db26fee7934 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:55:25 +0200 Subject: [PATCH 25/26] test(goldens): regenerate baselines on the self-hosted runner --- .../migrate_bitbox_failure_already_linked.png | Bin 0 -> 19011 bytes .../macos/migrate_bitbox_failure_retryable.png | Bin 0 -> 16055 bytes .../goldens/macos/migrate_bitbox_intro.png | Bin 0 -> 31492 bytes .../macos/migrate_bitbox_register_ready.png | Bin 0 -> 35125 bytes .../migrate_bitbox_registration_pending.png | Bin 0 -> 22362 bytes .../goldens/macos/migrate_bitbox_settling.png | Bin 0 -> 8209 bytes .../macos/migrate_bitbox_settling_timeout.png | Bin 0 -> 22884 bytes .../goldens/macos/migrate_bitbox_success.png | Bin 0 -> 25230 bytes .../macos/migrate_bitbox_transfer_failure.png | Bin 0 -> 18921 bytes .../macos/migrate_bitbox_transfer_ready.png | Bin 0 -> 32227 bytes .../macos/migrate_bitbox_transferring.png | Bin 0 -> 8908 bytes 11 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_failure_already_linked.png create mode 100644 test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_failure_retryable.png create mode 100644 test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_intro.png create mode 100644 test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_register_ready.png create mode 100644 test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_registration_pending.png create mode 100644 test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_settling.png create mode 100644 test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_settling_timeout.png create mode 100644 test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_success.png create mode 100644 test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_transfer_failure.png create mode 100644 test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_transfer_ready.png create mode 100644 test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_transferring.png diff --git a/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_failure_already_linked.png b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_failure_already_linked.png new file mode 100644 index 0000000000000000000000000000000000000000..6fff804039de39bcf44c9c23b028f18007123bab GIT binary patch literal 19011 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 NO*OrTB`Qzf{4Z;IlZyZV literal 0 HcmV?d00001 diff --git a/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_failure_retryable.png b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_failure_retryable.png new file mode 100644 index 0000000000000000000000000000000000000000..9431630abbc9d88d4603fe2834814a7888d92e95 GIT binary patch literal 16055 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 literal 0 HcmV?d00001 diff --git a/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_intro.png b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_intro.png new file mode 100644 index 0000000000000000000000000000000000000000..694b5399acbc33981f7d2944fbbab3b3fe895e4a GIT binary patch literal 31492 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%0AelM`-*;;OCw62{}91 z;hkd#!ZC+3q}{I2h@}~mR3H&(UKqWMh3XY5iWISk7dRW!{_EXaT2SndVfm7G!~PS6 zjV6b%Cw)|eWZ3U8C&z>8Y{9g@||I<17|Jv~T_4Kx# zy}eB2HG~f%Z~eJc^CmKv3^AMlwkJMrq1INP7fFsE_P31^Lm3XaxR$JwEeezqt`Y+u zfp~FSw7aLrHlV8VD{YO<4xXMHP+mr>T@<9d;T_2vzWH%~aI0@4TKyB?R3ls6-KMX! zOq_k0dy5jTnkT!r1dw+ic=7gzJ5&dMTnjKVifi5u$w)3n6G@CxsotbKtj_M-z^bJu zeM8J|URh7i1(0^~^CD_SZY~eK@qdB}&~mZJ#bz*ThdT2f1n4rK-d35){VUO+Kd>>sHz5G#{pH`mEX0!1gE6$|dmQB?~;)rA2 zmb&VjSEW>4y}hsmV6u0eok*s66A?4|HmL`zKc;3-E-jA=#f5fX`4Ltl_7|xTz@n;N zY~vl~l%=d9Ha%iAE9l?2HBE z8imMsKQRb)98M4QT$@!$En)~L4!>=EDZ9j2E)B!&OowB9v${93o<0E{*X}^rr@USpDEf@}^Lv6G*X1en6@sW0RL`E9| zYPem(TD>^%db)end&Xtw=Eud4vsHB})huVacQJm#7^03XgZr5-;pUBM zsOZUSNzv2RQ;khc+9d5sRgTEh=f8f@7g4+76hKhWVli65x%xqUQlpc#d6v&N!_0+! z6A2;Q$|%X{grd+V3dJrd=9d@VkAA03sj;GCe|=%ZAl1&!&c5)V5{x;5nPLlD*ihZ4 zj2A{&?2BBs4P@V$VKNu%Zif1_`k&=c-}vYBtj`U&rWS!Pw7H`rXsGCmf(&Oq9X22E z;om(Ef$u+$ja;Ga@Q5Hd2pT3}NMNDAn23s5<>wd3TFc7HDA8IbKq~1WR`H7Cst2#p z>7j#N-dC{xI8H&3wC7r`Jfq{WNBxIn>eVd*P1xb?;4Gl$n zOnEiusb;(~aNoN7duL%PQ6J)GaCqS(1dx*U;Tyo3RJ@r-E**#a+Sa!It&MNtUA%mL$gUP#fYU2D_;k1Zz$NvGwhpFbT~OQCk=t zP?yosf%!M7-2v7r0sYwK$D&S(GBW+xaUx>hyxw~}jz@U&w*g^undnWi1fKs;NsNNY z0^_hrm~)s{e~3Aa+c$fuBw9q)-L^{Ecg;yigrDJo{|%_a!_e!k; zek^Iit;6ldlPH_1B?9x%w;cMc;r#bCO>Y&?i3PVrF3Bxb2|W1T}} zUECA%Z134OUu?rZa&ZYK9Q{VXJp1?Weoi4SZTt~U>jHzizc~RHRa{+X4^~_nnps&{ zv7t|iM?bi8=Z>trJOxFHkjG;)8Wj8&q0CLV{h&@;K1OqtsijC~Gp}2!=N()t49=8l7MsGaQrP>Nmw4z!lnH+`K zBT6`u2fBOrE_5f?$-iYNY!A>}n$O7glTn8)HZ%nGsNDcwSc_>y*c~0~D$&GfL`MNW zH&-Q%8U*x>yT#aFCHPvc5&>hbw^Pbei#BqJmvFuNk5I{CW)OS((zV7k^lRF#mN@QKSeZsZ%$G9>QHYvkNo>CCHjn;#5>yL)^TA!l)qDX23%bE2A8 z;|2)qEb9e&eF|0rQQ5Pf(!A_=@+9|sQcO#653b1wK1H8v^gs=UGdD0H;zv9!)wH3+ z+>58+qFhG{Na3C(&N?nr0j8$qfTLU-DSfvZg|-muIcb2{xVA(~v(&?6I)_S zviccS`OO=2=g#w}=;$P!IwiC@Em6s`AJ8|$6}(gmZp3X$HCNWH!fS&^0(CgwgA)8i zL4N*sQsd5v7ZE$gti7~qTJq#fA{KLBAN_UsFru_<;d$~gAP_>9C^1JNYkQvpj3pos zEdsbhXY+en$7Fn4e9Hftb2Es!09Za)S3*cApHF%}uOoRkzGDoLhs@c{-P_xK(4W zii(PQ4}eN>JQ8v{8UZ=4>S^U-oFZ-uP}rJioGJMjmC_^;lPY;+h=_%c%r`XWh#3%R zy&L0L`_sciC}xc%pA;F~)NvS;<3}uh5)aSn>{!Sdt~^S%oiW+5nv%*vmGKPgmxrW$?erd?3x)}9cfb0E2JpS(&hCnE zNOy6~?Vy|}tlqiLOk=nd#|rYq!Fo1qRT}7vv@AOT_$+NiCxKSVA`z7Z)*wo!@6_>(+1dlR_ES zBqStE%Gcv$A+<6*Od$3Nkq_%Ut*^CMHzvmM4ZTm1%G`Zd!so@dBEjO^hQ~nPDWjn%JGCe&l4P%%n zKpb<{f7X(v3NQEf=mQ!fKf>Vc-RVcE!=@k1MT@}FQliq{oU=5W0gqT(x4Ja`dcB~68j^8#N7)Vmx`g$j8YhpKxJ;D?Q z2sC5A8M^WyYO|I>PKQ8CPUA>ergNCd1!tu7^XJdAls8Wm7ZnK)&CJf0Z8q+!27h}< zS{?o84BcCt{-D=28?@y}tXu87(32>jG?2SFh6bQd`D7Rry-5l`+27wPSnjMtt)uso#aF9-VSAdw@q2x&{`QVuOGwStY?(JefuYCga}b0vCADh+r9q1 zCP>_C{(0q;ce^oAOx^4FS2Hz@54^RrI@Kxm?q*do#5sC=^672S`Z2YRn>!s&IXJqn zS|^V>WA@dX*9gfa#tN`A`TO?=o!>CHpriAzxIPxMMaRtK92`oShd^(HeOSYgKbDRAI7dtCM$7XUAZ%GXgVEkh`2^^7}p|L=^P$$07@2T!Ytyj%eU|*Ft z)tea!bCCMc4S}&dke!&>ZNVg(qIf{8 zYd1PigyqYq8uD}{qe#5O@#QU`iqdq%dogq3ff^?Ws{QLsr=TrbBPMikIz<%tw!lGt zwcG#Nl5g+i>HP@v>m5ZTJ4f$o?OqD!Ad<_5T5cirl;wK19nB4aFDhkz@NIhwA478R zb3p9@*#ejq@#Awq+G>=yW7g5#Z)+&X%9hve*B|a;+q@M*YQ2a0R>_@t6}7k{v!1_n zVe#9yZ>7~F+5x9zH4>=5Jmkqhu+vy39bWEych%UZX~{~5uT$btk%HZIo&AQ2b}&#s z%7hBCCa)S_9R0g?6axaXInA69`{&P(DY_KfQ1>o6BwiE{USPwB__Fc?KqL+^{mnX- z!dJF_%L;f8TwALGRKb^ez5;Uv-`_llji>Dq_PgEO+`e^oq6eqe%e=gRZn$n?kXej0 z@GAw~eO=ataXjRWioN3VM}r&EdB?c8UmW9l_%|@t@!yUA8sWd5@Lv@C7X|-M6yR!f zPVirsuk#7F$pA}x$Z24#u8$a+b}8C;Tf7TQ^!13`;L0~T9;MCCKVA4uU&8R@wv5yd zh|c2|6tv0bDAGX~gRCRU8$abs{zp=*TXt|$E5+;4-8itMd)en7$U2YHTw~p|o=>{w zFH}E=M<;n+DEanxUX6~~pu}aN!L>wAo_p6$ut9l6o!&j8WBR!mruzVEyf8R4MJ9GT zGLIUr;nVuo$!+KmEH-#pX)*cxPpkPooln6KatE-fEw}a*{NfS0Dr#IU2)!ZN zgImNN`)OH#Xviypju`bW&@gMwi`qgNOJs^zO^7VFWKPeW545NTBM`@e@MBHjw(1(i+`Gc_Cd`UdgK73MLWD^;3ySy{r&y zn`wj?gycf?iJBTtmWQcUg0N#>Tn2|)Bt1caA-U@_8xL>42NltuEWfArOd9ke1`dXPD?OF1Z(*K8y^N*)Ds7Aw@z}GLSl4jyIGe@G->}X7u%5fEh#FtZd5YB{ zay@HFSMX|#2yMSpzcE?&5YR=Up7OC7qiS>Q!J|J*fgEDn{d^ek)*AusciaPT$GIqk zim#9NL)$Oc<5x2N_jnJH!Lb1qF8#rHADj0XQ>Y6Aey8IkLW`9(Ww{!x?^MC`Sh~v@ zb>kP#5r@VH(%Kr$>A)G0#LUXGP;p!2CV&jElqhu0Ec(FitfPgV}t=~97If>~l zD1MDUJc%BD`MjVuKy#-vPDb~u3uz(jqRCIP-*~~OgjC`1%gfhmKG$Xchs*A(7=6r9 zuT%fV7ucs4Q7SXtYMRnxF2ILsqFgFsPhTRe?2h+&5$>H_h&}7dFLD0w82`is7Vk9= zfrSGNB7WJ*qL_t5BAOgyx4U|N6*OJRo^Z+iq@F3Ifo`mZ{nHVgVndHRU{{BI^A7q} zkZMqp2fsfw)UuRW-)@km=wJD~cd-ZX;Vap&Qd7_CwN1%ICWTK{Ean>QW?-SS^Dpr<0DJF?P{gy+MEQvOyq=sLJKDG*VVn5%h#{A z*dNnH3DcB}ruyJlOcMV1J*<^kYt#9S3$B-nW*M`mS@V}AEE6|M1U;?qcHNxVt`8aP zZ?M{qa0r&9$pU*6eMj_b{5@Bel&*YnYHfDTLc*M4cOx;SKNJC@#kI$JkK$MToD;U> z0z1{}hc|_irwK9BdVBj1ga)CyC;hh;8pc#0BLu z6zrpCnuaG$Qx*4pYf4~r@cCmC!V{WyZI1EY!*zun;ja=#;X*$vMKm*U$^1~VnTzl3 z3e2cssiCJ%FRAQhl(r7##uumdka%aOK>gH!6bM9|0iK*4DOd;`KO~?E6 z|Gl19Icl}o+7BEYIdqzvf_=Yqg^FVq2VMEzrZCWYO=P-ys)C*}T{iTus<5zNxd++5 z1TqJrcs!k5Gz`^vva8F!ZtFQj=rVsE= zPgoPsYyHk}>SR|e2wlqG;h5@v?csCl`gQr_cEt6Xe0x2X1^7X9sF2@{r(!i*PetW<==wRX+2Oli3~pDxZ# zmFY`Z!Zp3fQeQfUJOxY5mw(I>9;Fm)g!=s6ys4sSr`5Tkf{F}jMUwE^4nP#JdX|D# z)9HuB2PIHsK2O(V0Mw(TR&rhTo3YOds{}wT%UsFo-n070rkgUz9}cS@ZmnQ%m->tv ze*>sPCuMzbxE9JkBARe^%{CBRz#Uy{?H<2RQV<_y>+|uCE>(|+jpk%vHo@Y9U;jB* z`Jiv`VCrN7+{#jKV$Ax~t+VN=u+kqp9Lg86S(Sf9@LFmcBmIb7knBdMsV}2`f5n_| zmkuMzi=Sb0RFJ|a!{k(n_YKg#Hs14>KUQ1)K&ZF~BAcvb#o)Kd-jJFdPVgOo%==~Z zJAoiPIeL-#&^385d8znN5I!X zUrMIG-yH$o?m20{|D7^X9d_T9xjdlml+r!>v=oT(NJ3*_6iGfJ5cxvaIn%oq6jB5I zx_Ue=Kb{WUsOREKos0GnKWgeII|W4#T`W01!k>VMfge}-+eEf{B*}FPt@!~I<{0`X zT}+ndm)Y^WI_y0ZwX@&n+V{6?wZ=5dp}H+P|Am(2CFOI70+*q;j(jH@uL?hipIvi}R8`T~(17Lup*nogy(@QX`0oAI>FV@O&>DwKj%{h5CmcvO<#5` z{hsx5gs`10283{E=J@e1c@~BG66VdH@HNTdu(Gv>Z;Oh)-yPQtLe~|6bSHSYfO!nK zP`(n@Z%WjpK`}S|;af?7X833&bF7(D=?-5ePH^3~xv!(7)34>|N-)mF8?1OJ2z9#& zTwK&^kzSlal%1pU{Pf45G7W*<^ApZV{5E8Tscgbh#VLc2KuVTXv-Uwi%FQ#}M!@yQ ziLIVBPIYN$+Cv^+XO~R;Xi4IV0&L08=!W8uK*z1R42aAvMmBgbVi`j zIqW3Y!5>H2@};@L()`rC4-Z{9uTI0u#H2v*E^TW{hLI9pRwjLF|@dc Jz5DRR{{VBpuU`NF literal 0 HcmV?d00001 diff --git a/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_register_ready.png b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_register_ready.png new file mode 100644 index 0000000000000000000000000000000000000000..3801bd2907f8127936f9f5c37506c19e0523afd4 GIT binary patch literal 35125 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 K6Zqrj@BR-J>agqp literal 0 HcmV?d00001 diff --git a/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_registration_pending.png b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_registration_pending.png new file mode 100644 index 0000000000000000000000000000000000000000..c7170f318cad7df1c8d29c8555e810406c775868 GIT binary patch literal 22362 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 b9k%}mCU&loHxVvs00000NkvXXu0mjfMh#xq literal 0 HcmV?d00001 diff --git a/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_settling.png b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_settling.png new file mode 100644 index 0000000000000000000000000000000000000000..7349a27ac2a5a6e74c328c777340f39f7c4e8e94 GIT binary patch literal 8209 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Э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 O0000d>8skd_jVZjf%In}dMT9fvrCbW8Vt^BeE| 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 W2MHjN!0&2e(0OS1pjz|E%l`rIVhof3 literal 0 HcmV?d00001 diff --git a/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_transfer_ready.png b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_transfer_ready.png new file mode 100644 index 0000000000000000000000000000000000000000..05f46aacf5c752cc1861b522b188e929ee5eb2d6 GIT binary patch literal 32227 zcmce;byQqI_bu3g1lI&7c!CFa4W0x7!7aGEySrO(w*bN2-GdX{-Q8WL_`Z4Xtu<@@ zocU#S7IZh=w{G22`|Q2XsT=e|QVbaZ4*>#!Abny1Ob}8s{C*b#A%=Vv7LapHIaqX1!cfG9T_GBvvx{BA*d-3s z6hXrbTbvP}n9&YpuKaK#mrcAs% z?b^vz*TCRtA#pL4+2%Nv(;2Cgf!G&K5bu=G_6;0~FV9a(1~fsq)r_{!;F%8;G>I=7 z3IY=N3y%MZH*QcHpil*+rO{CcdH$SRv$C>|LThRpJ8Q&2d-g?BboTU!=IIf}9=gcI zLA#+yZCD#_Za;B2l0pj!2|>Hx+pYVQ_$%~;;pd$*MT}kXQt#L}GF5Ncc)b51`}Y5< zGg%LE4NUX$dKFi!RB~>Oy0#k*0|Qg6vL!v!BBsYVx_u%{7ogyN6amfauay9+1}_Ll zf{JSVjno&dL%_=mOfKvj81UMA3>+}SmOnJX^#4!3@hqU|X(&dEm7OUt*uuuZ;_2z} z%-7P;!1X1E>8iTC9Nm$Jt2ErJbO}S1@O+mxpfavswi~d~^U|?i)BEF32 zs}tCBhy9cD1-nsaIiUZf0eOUEO7L(zNpThHlgIs@Q=E0+Mhl1rx)Ej}(Q-2*Z++ zIGk%#3CYReMBc^IX$YvR^Yw%g_>`zMR=KFrskIEkyixlBYjE#uKPAD#Z9;A^H^Z3C zXg|0EtB`k9WAWS$tM=wB5G<0;et8R;@8{?HL7$S@I{uZ@7&J(b3w2e0QBqN{%#DwY z)tcsanVE*-BNE4=m!ZCnD4zeJp)t~u#(R!iQ*?G_U9?;l#r#~d=)yseFf!~6gN3zV z2kn`eNrH!mXL&Y(E8N>f%t(b>!^XCAv>9ZeFCklxgeS(HirVNoGhZ7O9UZdJ#Djwa zw=z+T-{9h;wHMRJz-m@cB%+}~ChXM2lKBKlKETAI=M`mDDsQ)uOQJI}s;Zx1g8 z9$7?2hDk<7wtalPeS936*R_e!cmr-{f8S;cJ4kq@;n3phl9@TTy=M;ZA7go7V4Z)$ zeC@x{QHTKr1ricM*GZm#wUUD$x78^eVryHAy`5E0UAYd&c}z^qS~~A@d1+W^Xqn-v zxBW6bQzFuKXNMI99U(k?5Sn{qzf~5Wtcms)X@v*rq=|b#^!~4t>5$&}Bs3`BbLTi`Y1I#)VC@wDG zZyX(ZxsTlf`JWpch?Sa+Ka!G`kQ{V~p zQ1##qb+Jp-)$(+;PcJPm9~8l_R#*|LB_vd~c^$^U#-`91hA{uM6wZ3=-qu3MYy7&# zY<4hl`|6Mzht(X)KxKTSp7%=JScOZWL?tZHB#L_9$jr=-p8pwRJ)N*u(xgS>Y373F z=g*%EPIE}Z4bL}jWLeodW#*4CVfTWREF#tqW;tdmO@<^(&gaMysf>tLr|<_4;_*$M z5&W)1x3{-SPh*9~`y~w4U6!VIcD&m*QQvZIHqPnGcCYza=;kLpo)5Y*R=gs?tLLSM zDY@la`V#5E&&|(-Ma9K4MgbA;*O90>><$>%Oc#8i15gTS*TbgO+W~e}mn^TZ1(`%} zSYOnQReehKXgof=OMaf2)BZ8Kv2W3;h>J1mr~nRMnwe1+$-G!(vyC;atfuC(jZH7h z%_F;-dd(-NI(1Lv?(24sYMD#GYyZA-o9+ib24qFS7AaT4Lo|Qsd$CHhsM!yQ=egGE zjn$%anBDQ0hNJj{gZD@XPT$tOs_W`rhr~Dkt#2?o0;@-BD#hvBK2{QfDiT7HZyIXn zc}TpKHxxvPO+89Qb)J~NHKW^lI0d;ofXJgi@oIdebq50j(l#-H0Xb=n;Ge_+$8K%D zFE`w^{*oh8eIDxWoHdsLf$%?EFb}7qj^~F*_)yR>d?5d5rH<&AyR*9Pw!Dwd!|)}4 z7779db*BSD`GCK2IF&VS{b0kl?bWMqFl_0oK5HM>VCuF*Nk(7d_5luLSAjM$$c(ls;tG}UzdYTFGK)}=`0maFA)53xov z*xBRa)Ig<}+ffs3+w_Ol;vI72mkA=IBUrO5M2PFf?sm$xpP=kZ)ZQE=)5j)tFDX^y zkw$&lypv4jE37E(pPPHT-V-i;OJH=zD?1(kD=Eo;K(T~6-sI!*F~|t>jPw{u+FQ)y zr$9fztMW!D-U95Xgcs@I(kf4?<` zEHyt^-Cq$rl-uENKf1f!uT)C$b+{Z`CI^v`mFi3%dB~fq$_i#u`J><#oi%??uC1zj zSljBq-{KpVw?83GJ=0%25WI* zfS%640}iBe9B!?*c|}_Wk6_?zWKf~{29EL3 zvC}=#vwN+!7SHZQt2h6g4bNL-N*$}0RaC6K-k_2tZ(mq=Pa*`uRAz>ozUB#Zl$W z)_{v*6c|IwR@UP4QF~MUNWo%ByN~zj^wbbQEKw&X&Nx~Rcx-w#{bdVxrrQsWYb7{I z>GGEsmwL}}eK;u`c6fP`>GHS7S7t-71NUDkS-J~iODjtk7Z+oO8JH_8V&cxP&vj5$ zWb!d$<>e{q=mY@zbDrnqybj*;hz2X!b>z(B66K*QH^7T@#$F#`+N!HRLAOFRFQ?Vfz__?@OB!UZox(;W zwYRr_G(Lt8u+`Z;X+_gsGmToO~Y7!YC_Zq;qO%u{MnOhCnUc6Y6EjqV6|snY7&Rt^rj zoCx0GBL)d0@M#d;-QB?<@^-h>x_64YK6NV=t1HUO%O~#@1SKYJN$zgdJT|LQQ35R2 zn)tiAGnRvBZEYRHb-|KfP@<4D_0U#y1WS&Q)X^_fKb}xo+3|;%;u^H73J_ zRqtne{|@&Fm=?Jvm|9r{$!MTMid-M%FAk_I&g3tcStiH$pxdqtSf{1$52UZV-oPkg zV-N2G##*HMjHu93GQZ9!*EiU|X6u;yyTAg~yl~5a^OF(b%1hE(Zf%?;Qn~RH=X&EW zfjJUyy!3Uty71v_d2%xL@f5lZB&t8Q@#S>33%13JL9$0eW0%Ha@sDG!8$+SHvZv<} z3*nOivzbC-F(`M94iDYC@0UGJ%I4ZMEerxcqLaVJfZ}gWb2Qn+e^C(jNd;RO1RiFa z7yCO|x5&1_M?w3pgy4;w;Wmqw`&Wu&CA)Ir1zNwQ+0Cuh*@-Gk!o0mnQ{#=ks;Q-p zDeo^B7+4Lrl!%G!%5Akg25=BqJFKl)(Qx@QN35a9w~`Y)Ku)HYxmm7WEtI;@CD-{*#SD_)tD=0Eun7NfSJvldrK=R$+ zRY6XUnCYxxZj-D;#M--~w6&=7h`?iYuums2qB%&m7exnL`;=&6W@hFb&xZSD-q8*C z#(-Ki9xJc*nHd}+kDHdUi+pB%t*#xbo4w>?%bUN92Q!xuJ@~3}YGWQZ|0r(U&ar;B zSYy8ABQ}fM%I-TB3z=J|&eA%^T_$a9opPOR^PKScDF!Ac?;nQ!iCBd0=s*ho9UAJi zXtn5JUw+JxP5j!|r&MA3MsOsxH!8cA(dEcB#|gu1i8+0h&-DbbD21j6>aj6q;^;o& zw?UCvYaTuL z1vU}BDOW-Qi60W zsrhBGJ+E`c#QaV7R8>|E^6mQYa5sO9!{y2%nGQc`)cE(^Ow4PDEV)!i9RITnS7$_s z)ac#kjMCDGOd%MXBR)DhI_ZYS%8$*?w^pKGMMTwmnJbUoPHP`p&&}q7VdY-vnUwbzZUU7PMVQQCJR9qBWW4C?e zQD$?^f3UwRn`eAUiC)8}-hYY9|J7-yjB7}YEC2pbNmX_AP~M{Anw0h@15u;>@#=$l z%k2?tH3j<_x`;5g)B+|n9)02U!ID>wxVoV%XS!R~ZJeu#35`TDlkz%`ICgC@%A882 zb8OGG4=jw<%qUv%fUKQjsW%`p>Q1*yvsr0rge}ioCe;pce3Yraf<=!P@50VszrGM_ zZE>rjlZ}6bf{gIjLg-uTe(s;ChKx)bSQMDjdqG2~xEf~W_Qpyjb1Cm08OjHTh@VU) zIh4Eg(q)u$8qH5FSMS-97oQ8mz_1v}Z~+b&iq9>&9&dUh+j+8jf2@65(VemjCXK`G z0dD)q$PdUN%e@)&raEn01LyOar59p&6XioaeY~PMf+9XSy-k-_FdjN>sxs~bb$8vl z?D3RYMF>unR;3QoSYy!%*uv~C)GLRsQ1|GkKrkO|Ue8bMDYKO^dkkj%y6^d|BY9~Q z&=9_t+uXAiJ@3N4(1pz_1ixG*1`j%lW50qLD|v!r7^tH^@65O{(-RqyRo$2W$PoeQ zhloAco2E9;=r)HjY&+~cSEm74X?9rZc>+i1cym4p6IY%sW<_|Nap-Qece{SX|%{lOnNEP&85tEb3di6f4y%%AwE7LNH(|9 z!OmnIkT9L+qoYl~%DnJ(pX;UenSa@G=FvVryb3K()OQ6+{P!x8$A2*IudL3yc-7>i z=jK#?=NNcK^=X=$qaY$87842-E3xsCy@Ps1R6SBa)A|H`E3;MpkmRPVzKZh>;1Aa9&=JBl9CJGY4g5+%aaRe_ttmb8|ADbheY-3TS*BU5Hy3m{q)$$o4E{+I{8NoJ-Wrby+cQ>}SUbrY5+tkhn zjGBBNCw^)2i%TasIG{3y5JGa%}X#aj@~tBzkCxbYxT*= z*3vvFP&1!DLrjZ`rPXA8%1$hETrAig?-0RCzvG*}>JtWDsP*#UOg(<#lDpXcE&ua{ zP8gN^*ZVABJv$KtAE@)6POsI`BfP1PkNoOaJt6YdnOnjRo81ONmi_D(#rWw<=b- zYU>M`X}Pv-oX)3`R)a=KsSB*-U-rFjnF}pQQps^T3;8CkxD4STy?>DIXJ}O$#wYBhcw9fSr#ryu={>yBz+hWXU7tXmssHg_NRP{Ne?N8)W z0;d(uy$WgU+WK*AX7uQ5jpkapO=paBBb)nhwa>=-gna7Nk<4mxiV>)-v%%BxgPx`F zAw6cCmS?0LbYj^J3a<$xU=t-^APLNNuhS5YvL~M)o;Mpv95c%8O_hUHBD&!g4N- zfBP|a1nHRql)mJ9{}y*35qFTvg}rY!Bsd(Z$Mc&sp1FMW&MB;+I-@mjGFo=Fm({j2 zEL(*|DKb3h>5))QTt?+dP-QhaY)B9&hBbuL+@PKBoi!f|>x4Jlx{~7VsSBcgy)9c~U-Cf<$d!8Y;cYq!elDwmx5)SRF2qiWxttl7wp=H>M^-<{o_TwCdA7;Isz1$=io z+1(RCOTvSO6qpF#tn7bMsz1P0QchS6x#T7;DD0(IMQt1X?AfOSqorhN0r~8t$J(+~ zhEIiCZ&_B^6$$YO#TkqL^sHp~x}!umyQu>2iY*Qf<)k|y^Hoa2(pQPzQV#+_Eh|pb zqQSD0#PSo%!~T}^gkOG-z4XMNXoza2wi0-p(eAGIP*`9VM%?GZKic%@+_#KV(iQv0 z7m|r0AHsSNIpjQA?+>e)-d}%cakZej=Y3wT_2?+7gjb+Tg=b-VbZar^o@(NOovjz) zu)TZBr}_vrdgE5ja_qZ@hu%>l+Br9dL{$6}E-|!yNk^*=4gEzs5lK-%l6<~OxFTE; zw0d@ASwlW+5)loi@uWPumE2t@4koi9G`k(Uxtn+_d1bWlqRsTe;@1!etLPIlRUo3_ zr~5mNi)E&_#$zp`5L10jUff<>Y}B&-;&E|duXnwh$NvyE9VgLy)tna?7SY08+gC4yHlW@1isKO>_*f3lI1lQG%$Hm72BL)ZN_?_VBw-4v)MO zqtl@9M!wk}@rNw&@|0<}f0$rrSpnFXS<>?A5?TA9NsoV!D_MJJU}WvGdwg|$HFQ`| zHsiF-NKeZ@9$T`TCM1`Xq7_GM_@~yinzV9QXI^PWRs~+jc4&Zw1NH$gR(SQkecNI^ zq5^HgYiTtC_k|{GCR}K46U0!$IU>l(T1TIw(JF?LY9c`TwdAly=^sCXMMlCqBVRpn z*t0?9b(BCuNX>i54vbm!r3k}O)ipe+bYMH%r)2is2nZaoAuF0X-HGbUqn>xpjQ0^* zN0-!(uKLqR^NFP0~=Epn8KC0p;wKlexz(9v2N*FIH5%7z7Fb% z(9eYCH^*Oa`l$;z>~4+?Ih^esx;qke+8B^pW^YjE6=juvh99#ZdgTW(s;h9kS!|eD zt33>eii@QvEtK;w*i(6^bD{?yiuT~|K0DU^UTm!qY{CvJHys?A5{C={m?3H|z_=F8 zJbQcmAt$p%00~xAJaBJMTE)J!1v7S%!*vg#M2tdfI3_BA+wKEorK{eA1Jj-$S`=2N zZvp<#p8^31*WeyE`dFr&?QK8#0Rye4Q0cn&cCgarU%yYuLrbgi8YHfbAxun6wU7Sr z%-4a<1}Vtxk>_-*gKFn!>HGR!(wrRw1}{%)`Ac+6vajUvUscT0$M*9-mwL=1qsEsw zp4L-Y+E^Q&vgbCreez)YAN69v{6iq`d0jAD)aeo! zwJ0HN!#z+$?v$ViePFbWn2GjA7n@yfaUoZ zTM8-yzwXVOogu#+V~en-r^TqW)ClpYT1IC>kKYEHofBPfX(ehTEUf;rit||4hlz;N z3=AvllGpol7aH8K0qV~ltDZ9z(Q>Uv6Fmunfa6gk;JjqrH$_0Kl&p(R2v1v7 zv*P7KS34+@mXitJsl#}EeJx_@is#CcGQlPr{PAtm^&XGyk!|VMQ`;Lm$3||g4YXTK zLkkU5T8;DO9=CZ=Z^N%!Od+uRxQ9ZT{jTcH{p9j;b#)HY9|lTEHG#^F?$bYg4`jlX z-ZYHQA|TlEAB0h&cehXeX$tyLca+cLT6LZe}Qv} z(6AqJD#MBN+@)|?4bHmT*zNz8L<`$S-&#bt3>gDyR#wd@rltnX{Rx0?JgH7pvIc1@ zQ&is%vbFGqGuvi|TobJ+_#RPzN=mvnkl%V+&=8Q1LsG@4qe*D;%9MGJQbQ>v-(!$j zC7qvJbpuK>*dZGjD*2h8pMC=~j+B}l9s2&3l<^}XGr8}d#jTeB74Xu+0l<`Wh)DTn z>xEvUyR*XJa^6yw772iuX_!$q`I*~nSlN1aak0^4zRNz9WM_1LYdl7%R}~*X$^duc z8v9*nXnXbil69EbVvh3RZd#z};z%m8Ul%Mczr28fkqccWT<}O~n8}JiZnkf4YvDUF zMEW+~8$kR=1NA+R6TGTDQE&MuT+{QMD-Isq37|_~`u=v;twN7Zk09pb)^p%u^(nU& zpmXN-^l%*_cDIw4m7*Fnkba(1N?cDyjay%*$hUb-TI8QZK?aVwsj2DT#HGFo-)2FR5(T+{ z>y|s{Tyjwy&N`x^;z3L51Lu(MVc&hsRl$|wLaZlCQMn7N)>oTzZ}~1SZ_}DyOqHF@ zgW;9g46xp_cU^0@3EtA-$XoJz3)0V&#Fj|q@DOqrFnzmYrS_F!S=%gt%0 zCUZGoA^4K_I9~eguAE-{P**OV&_{t#?Q)u)tJ;OZ$xl|BuGp4UcL1GggjT`^U|Bnnv!&#Qp219EbZ(ejl%kLC~1F>l9PTj&Bca?`P-Mpa7-Klh!0p zLdU6=_g`vP-~1s@zY~NTxZVqOsiCS4N7g-@Af;h{%~$5ITqFH9Q20Yy*#~4IX_^AD z;y)+=+kVKa0md5qOXe1P_U|ZUDo%gp)bTGpt>2%LS5g1qLGoMPx|^F-f!vU;I)7xzh?KRmSip!oC2d-UR1KffZOD!SbcfgTi6cw@+uUv zwvF58WZZv%%SR1P+Y%(AUO_SrJzP9E;+1o&OHrN=GWk8^&AC5Q-~UimjHn3ycDqyc zXdpN2a5VPmXHbKWiZlUAN)sFe&KPNi4EEYbg1i&RO=2)Mz*Fi{BmIjOrr6)TAbxQO zvUwLdNk*;fMsO6+CIp9;=T~Ys&L%DF!m(|9e06r)bb|DY!eCTSysm_omwVUIA=yob z+yI6_{BSsL6^!ws0u+B)t3?Z>zkhG99Y1qj>DR|qUErI9#qlkdTunvx>2kOq;q}uL zT5&yat^H!MrCxV;)>K3?^+*VA`)jhKoGWq?^X2y+><|(lu1@ZyLoldBmF=T#;>u{HZ!*T8H@XtVR zAP8;p!n>ATq*(L4(qwp@oOyOw;hTepWF9Ombo@YmKb`i-J9+uPb(VNy@6e%2KirXf zD0Xrl_jpk8P7K+%H3EOE^1MuIz8N}AoZW>sE&KMMb@%8!R~dGAdZYiR#)$RtTCDPM zkEM!;%E6t?M?i|=BH+%}YyLsCm3g^Ya`7P4z8lvx)_x7S*&KmhaPoH1Ihu%KU}fys zi`QSjCJ-w#8~K`XnlaTHkBv3_zOei#*lhdUsSfWWQB)4N)Qs9?P3O~HGic{|5j|1N ziYHgwtzH13&UOSi9QQ!c{Hu<4`L!Vjsl~^Y-BqsyF7v<#8=2FOvMg$Tudf6*iiL}m z@3}{3eAkcHhj%A7PaSa>f73rbTz_y;xap{r>g*R>LRvW*dowbE7mx}&yy9$2+BgUl zjyE@qmSFJoZbG39dci=-)5Q-AtXQ3CugV#_ z6aW47ffEvXMIMe9=tFVr z#$Wv$Zp*`)yIa>KHUk`s^7|D|*W07FN(p`Q{rh|PCvyS&j9R*Ayi^OJMh(0<89; zN~4g&EL42p%QQ5=#NI37zYW5OovdbHWs{kwmzS4C1h6>wj{{ko(b!~PYid?54+G#C8j^r=8$DYg)Gbo*~*4<9b@L0u*oQGXcY z?R?D1&d;OP8mo`LkJzWMlK3?IUlo`bt3EvO?;QWt8SnieEf4MWIek1oDNz!L-`ClxVuZ;wFu)dEdU{}=U=P%?&| zC`OyiF1>4%O+W&Sy!7WP0uq5IY9ig~g~z`slS=@Od@{WtFH6bJ*%yPfwJsB8p5t+NZA&sESLx#|_OkD9Oq#|C(FX z^!8=Oj+>aUdDKjDIrulEM_pe zAg;cZllvEAX4U(|yhF<(BK?)qd_mIN>$4|F>LIvS7`-^ECN^w^x~Njv@AsAnGSJm{|`XZBwVS1C&;031vKD{9WjPvcnu(8$60C!SSNUy8@=IrVrQ(ZWL+hx|^7csInn|kW<+6!w z1+OmBv)icCC-F*dlKJ`N_E0ltQ&AEsN(S2YdvyoWlz**=6a>i_n zRy;(~QKt{dzVX9bDmu{~?X9jn2G__3FKg`}oJJJier|W%KSL7ns>CC1ql?Y>Kym23 z`(PwnlKU|F>0it)EhDRV;epCfr8U)6Yr%o(&^4~gU#_^AxHrJVdnq6^hkfip!S{rw zpvzQ{gXPoE*hkU?j?L_G6e-P5V|{Z(RAZCG>R8;Y^`lf4!)SrxkkkjOGsx(2EBDgP zGCcQssEGlP!6thByz8;AWMNklZ3a9ZR$6kQUOYZ?+PmJHp-O+TVn zx^?s+mxPtMwz~q9ZuW#&u0VP*RZ)Hzd=RefzDZo`I%2k})4>Y1<- zvh7cqoS*iw@f;YYhl2Qu36oKaJ_+{};5osnstZu1G|UwYNo=pLA~|f+-CXpxuCDKf z!jr*P+@e-d)mF3UagDPzDx4SnOk!C!FeKSeASJTb{>BDAM#A0-YLcP6878W|aTOia zaeqqE-X&D`Wo+&CAjhd6Ek>rrmX2TT?AZ(A0gw?Dkj71ZFP4n%G^eX7yzW)AOSau; zS8jG1Ov^(G!|Bxh;z+tc1+nQ$$;La$r011RvhfrVCbnrB==AaN3@zp$nlILefKazZ zLBiS+ybA5^{zv#mB!|O|1i>aZ98!9UI~)9)9svf%HL$xNx{=}g89D{ihhs-Y~)_>EH|l^#hYL%c(Walb_E)+XnR zEOP7ZJRK@+WCV+0WaxdieEr-HJK~9$U5WYs#G%ocYkGBz-C$8Al_=6thzDwVXS*ym z36?ne%yhu_y(}{VRy{wHkcm*9I@`)=V^xeK{tZdu(SWr~*y16>+`dyA^cjZ(K^;LV zkDYVJ8?$?@;_wz36yI~>dp%OnX-;kARngT_nK9c{$Lt~%_m$)b*|V8dgv(C{YMPjZ zZ>Qlx$D3cJK%0o^LdF$lCJkfc<+O$;&{ZrDA@(a?f->LOTt@c;4>^H}4?MH3g?zGC zpYysD=JZuf!+;Y{2Ar#@qDO-HC*D4kD#POgBZ7M5>rS%q+n<2wGj{q$tYkv-nk<83PB87?ks#36MK)X=+yv40}bNNW7i`#rxY1eOUmVuOo~OtQzED z`?0ZsIe?lL?f=4+fT!Et(~3%ES4O?i{r=u9z;1&7N@xsQZH&1_>1_VnEVI z3F8-_GSkv*3-D8>06Y~P_#ZD>aVG;zV)e@;+H{{h=6ZPbRE`}Tg(t9^VZgk(VNDh% z+?~`Dh4vK6qbt`K1Fk27*2ZTf#G~#w27Nz5=yG z62&y&Accwy{T;>exuagxSu@%;{jzsnVRpa41uD@5EXcb4e@X-j2bIrq={749fpO+N z)EKS_Dws1ZQtk{_wsu_^o@tR^bd8zEyUBmA#q*<xR0mxJZi&n<0HNh+{cIJswFHDHS#c&9#HZ(?I~OJxzp5aB~&@Kv77H) ztaI^@#O20GwtwHg|E!7%AmnSdw-poZnL6&L58C4~W>IRNsic#)?oN|_%O31+2erR) zzd2uyKC#}-EP4iQ?^lI);AmSlJE?Zt)koyGE~%s##+YOzhhuk z)bzO$Lj|1ls(V5WlltXFk|osUbh%8v|0iVf2e__)2|-C!x)SR6Ridw>7XP_DDSoSM zadRnhsYVw9&d}FSdE5Th-6&cBIgDVEyhqe{e{KnP2zb=MgfO~fGw!dMk)Gprne&

%8l0W|E-3+y1HrhgmM|Z=e>=0T)^@C^UgS5kW)k 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 bkmrf6*^LY2Lf|#-AP_{y=wZQwKi~W>$5R_a literal 0 HcmV?d00001 diff --git a/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_transferring.png b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_transferring.png new file mode 100644 index 0000000000000000000000000000000000000000..8086ae0d216296dc03f899929171ee4b118c9ec5 GIT binary patch literal 8908 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 literal 0 HcmV?d00001 From 9698a405353878aa47c81610aea9a9f2e51bcd01 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:55:53 +0200 Subject: [PATCH 26/26] ci: trigger checks on the regenerated wizard baselines