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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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