diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index a2da73a5..f7685b60 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -164,6 +164,28 @@ "legalDocuments": "Rechtsdokumente", "location": "Ort", "max": "Max", + "migrateBitbox": "Auf BitBox umziehen", + "migrateBitboxAlreadyLinkedError": "Diese BitBox-Adresse ist bereits mit einem anderen Konto verknüpft. Kontaktieren Sie den Support, falls Sie dies für einen Fehler halten.", + "migrateBitboxCompleting": "Umzug wird abgeschlossen…", + "migrateBitboxIntroBalance": "Aktueller Bestand", + "migrateBitboxIntroDescription": "Verbinden Sie Ihre BitBox, registrieren Sie sie im Aktienregister und übertragen Sie Ihr gesamtes REALU-Guthaben in einem Durchlauf. Ihre Software-Wallet bleibt danach als Backup erhalten.", + "migrateBitboxIntroTitle": "Umzug auf Ihre BitBox", + "migrateBitboxLinking": "Wallet wird verknüpft…", + "migrateBitboxPreparingTransfer": "Guthaben wird vorbereitet…", + "migrateBitboxRegisterConfirmHint": "Bestätigen Sie die Registrierung auf Ihrer BitBox, um fortzufahren.", + "migrateBitboxRegisterCta": "Wallet registrieren", + "migrateBitboxRegisterDescription": "Ihre BitBox wird Ihrem bestehenden Konto hinzugefügt und im Aktienregister registriert.", + "migrateBitboxRegisterTitle": "BitBox registrieren", + "migrateBitboxRegistrationMissingError": "Ihre Software-Wallet ist noch nicht registriert. Bitte schliessen Sie zuerst die normale Registrierung ab und versuchen Sie den Umzug danach erneut.", + "migrateBitboxRegistrationPendingInfo": "Ihre Registrierung wird geprüft. Ihr REALU-Guthaben bleibt auf Ihrer Software-Wallet — Sie können den Umzug fortsetzen, sobald die Prüfung abgeschlossen ist.", + "migrateBitboxSettling": "Transfer wird bestätigt…", + "migrateBitboxSettlingTimeoutInfo": "Die Übertragung läuft noch auf der Blockchain. Die App bleibt vorerst auf Ihrer Software-Wallet. Öffnen Sie den Umzug später erneut — er wird automatisch fortgesetzt.", + "migrateBitboxStart": "Loslegen", + "migrateBitboxSuccessDescription": "Ihre BitBox ist jetzt Ihre aktive Wallet und hält Ihr REALU-Guthaben. Ihre Software-Wallet bleibt als Backup erhalten.", + "migrateBitboxSuccessTitle": "Umzug abgeschlossen", + "migrateBitboxTransferCta": "Guthaben übertragen", + "migrateBitboxTransferDescription": "Ihr gesamtes REALU-Guthaben wird auf Ihre neue BitBox-Adresse übertragen. Dabei fallen keine Gasgebühren an.", + "migrateBitboxTransferTitle": "Guthaben übertragen", "month": "Monat", "name": "Name", "networkMainnet": "Mainnet", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 5b9dab60..b2fa0fd0 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -164,6 +164,28 @@ "legalDocuments": "Legal documents", "location": "Location", "max": "Max", + "migrateBitbox": "Move to BitBox", + "migrateBitboxAlreadyLinkedError": "This BitBox address is already linked to a different account. Contact support if you believe this is a mistake.", + "migrateBitboxCompleting": "Finishing move…", + "migrateBitboxIntroBalance": "Current balance", + "migrateBitboxIntroDescription": "Connect your BitBox, register it in the share register, and transfer your full REALU balance in one flow. Your software wallet stays available as a backup afterwards.", + "migrateBitboxIntroTitle": "Move to your BitBox", + "migrateBitboxLinking": "Linking wallet…", + "migrateBitboxPreparingTransfer": "Preparing balance…", + "migrateBitboxRegisterConfirmHint": "Confirm the registration on your BitBox to continue.", + "migrateBitboxRegisterCta": "Register wallet", + "migrateBitboxRegisterDescription": "Your BitBox will be added to your existing account and registered in the share register.", + "migrateBitboxRegisterTitle": "Register your BitBox", + "migrateBitboxRegistrationMissingError": "Your software wallet is not registered yet. Please complete the normal registration first, then try the move again.", + "migrateBitboxRegistrationPendingInfo": "Your registration is being reviewed. Your REALU balance stays on your software wallet — you can continue the move once the review is complete.", + "migrateBitboxSettling": "Confirming transfer…", + "migrateBitboxSettlingTimeoutInfo": "The transfer is still processing on the blockchain. The app will remain on your software wallet for now. Open the move again later — it will continue automatically.", + "migrateBitboxStart": "Get started", + "migrateBitboxSuccessDescription": "Your BitBox is now your active wallet and holds your REALU balance. Your software wallet remains available as a backup.", + "migrateBitboxSuccessTitle": "Move complete", + "migrateBitboxTransferCta": "Transfer balance", + "migrateBitboxTransferDescription": "Your full REALU balance will be transferred to your new BitBox address. This does not cost any gas.", + "migrateBitboxTransferTitle": "Transfer your balance", "month": "Month", "name": "Name", "networkMainnet": "Mainnet", diff --git a/lib/packages/repository/wallet_repository.dart b/lib/packages/repository/wallet_repository.dart index e2ffbce7..580b9429 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; @@ -22,6 +23,32 @@ 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 normalizedAddress = _normalizedAddress(address); + if (normalizedAddress == null) return null; + final candidates = await _appDatabase.getWalletsByType( + WalletType.bitbox.index, + ); + for (final candidate in candidates) { + if (_normalizedAddress(candidate.address) == normalizedAddress) { + return candidate.id; + } + } + return null; + } + + String? _normalizedAddress(String address) { + try { + return EthereumAddress.fromHex(address).hexEip55; + } on FormatException { + return null; + } on ArgumentError { + return null; + } + } + /// Returns the wallet row with the encrypted seed *still encrypted*. Use this /// at app startup so we don't pay the mnemonic-decrypt / BIP32-derivation /// cost just to render the dashboard — the cached address is enough. diff --git a/lib/packages/service/balance_service.dart b/lib/packages/service/balance_service.dart index becbf6f2..d9b0ccf5 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 ed8c17ed..d337bd94 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 23ac17bb..6db21525 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'; @@ -36,14 +37,21 @@ 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, 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 +70,81 @@ 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; + final message = buildSignMessage(address); + late final String signature; + // 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(message).timeout(_signMessageTimeout); + if (signature.isEmpty || signature == '0x') { + throw const SigningCancelledException(); + } + await appStore.sessionCache.saveSignature(address, signature, message); + } + + 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 @@ -74,18 +155,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: @@ -94,10 +176,39 @@ 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 { + 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( + AWalletAccount account, + String address, + String message, + ) async { final cached = appStore.sessionCache.signature; final cachedAddress = appStore.sessionCache.signatureAddress; - if (cached != null && cachedAddress == walletAddress) { + if (cached != null && + cachedAddress == address && + appStore.sessionCache.signedMessage == message) { return cached; } @@ -109,29 +220,47 @@ abstract class DFXAuthService { // RealUnitRegistrationService.completeRegistration / registerWallet. await walletService.ensureCurrentWalletUnlocked(); try { - final signature = await wallet.signMessage(message).timeout(_signMessageTimeout); + final currentAccount = wallet; + if (currentAccount.primaryAddress.address.hexEip55 != address) { + throw _WalletIdentityChangedException(); + } + final signature = await currentAccount + .signMessage(message) + .timeout(_signMessageTimeout); if (signature.isEmpty || signature == '0x') { throw const SigningCancelledException(); } - await appStore.sessionCache.saveSignature(walletAddress, signature); + await appStore.sessionCache.saveSignature(address, signature, message); return signature; } finally { await walletService.lockCurrentWallet(); } } - Future> getAuthResponse([bool sendWalletName = true]) async { - final signature = await getSignature(getSignMessage()); + Future> getAuthResponse([bool sendWalletName = true]) => _withIdentityRetry( + (account, address) => _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, }, ); @@ -157,14 +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() async { - if (appStore.sessionCache.authToken == null) { + 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 getAuthResponse(); - appStore.sessionCache.setAuthToken(response['accessToken'] as String); - } - return appStore.sessionCache.authToken; - } + 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(); + } + + final token = response['accessToken'] as String; + appStore.sessionCache.setAuthToken(token, addressSnapshot); + return token; + }, + ); void invalidateAuthToken() => appStore.sessionCache.clearAuthToken(); @@ -173,9 +321,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 +338,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 +363,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 +388,7 @@ abstract class DFXAuthService { body: body, encoding: encoding, ), + bearerTokenOverride: bearerTokenOverride, ); } @@ -230,9 +396,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); @@ -244,3 +420,13 @@ abstract class DFXAuthService { return response; } } + +/// 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 {} 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 00000000..a75e05c5 --- /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 ef0bd140..7a3e0761 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/session_cache.dart b/lib/packages/service/session_cache.dart index 8562369c..2e8f6240 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 83b32e5f..195c54ce 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 @@ -94,12 +97,64 @@ 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 walletId = await _repository.createViewWallet(name, WalletType.bitbox, address); + final normalizedAddress = + EthereumAddress.fromHex(address.toLowerCase()).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 + /// 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]). + // @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.toLowerCase())) { + throw const BitboxAddressUnavailableException(); + } + final normalizedAddress = + EthereumAddress.fromHex(address.toLowerCase()).hexEip55; + return BitboxWallet(0, name, normalizedAddress, _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. @@ -108,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); @@ -146,10 +203,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.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.toLowerCase()).hexEip55; await _repository.updateAddress(id, address); return BitboxWallet(id, info.name, address, _bitboxService); } diff --git a/lib/packages/storage/wallet_storage.dart b/lib/packages/storage/wallet_storage.dart index 81317d76..63d8000d 100644 --- a/lib/packages/storage/wallet_storage.dart +++ b/lib/packages/storage/wallet_storage.dart @@ -9,6 +9,15 @@ 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> 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/hardware_connect_bitbox/connect_bitbox_page.dart b/lib/screens/hardware_connect_bitbox/connect_bitbox_page.dart index 54c329e6..9928804f 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 00000000..7e168760 --- /dev/null +++ b/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart @@ -0,0 +1,431 @@ +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'; +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/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'; + +class MigrateBitboxCubit extends Cubit { + MigrateBitboxCubit( + this._walletService, + // DfxKycService serves purely as the auth transport here + // (refreshAuthToken / authenticateLinkedAccount); no KYC-specific calls. + 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; + _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()); + } + + /// 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 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, + refreshed, + ); + + 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; + _pendingRetryKind = null; + emit( + const MigrateBitboxFailure( + MigrateBitboxFailureReason.generic, + message: 'API returned addWallet without userData', + ), + ); + 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 _runSafely( + _persistAndPrepareTransfer, + _MigrateBitboxRetryKind.transferPreparation, + ); + case RealUnitRegistrationState.newRegistration: + _pendingRetry = () => onDevicePaired(draft); + _pendingRetryKind = _MigrateBitboxRetryKind.linking; + emit( + 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, + canRetry: true, + ), + ); + } on BitboxNotConnectedException { + _pendingRetry = () => onDevicePaired(draft); + _pendingRetryKind = _MigrateBitboxRetryKind.linking; + emit( + const MigrateBitboxFailure( + MigrateBitboxFailureReason.bitboxNotConnected, + canRetry: true, + ), + ); + } catch (e) { + _pendingRetry = () => onDevicePaired(draft); + _pendingRetryKind = _MigrateBitboxRetryKind.linking; + emit( + MigrateBitboxFailure( + MigrateBitboxFailureReason.generic, + message: e.toString(), + canRetry: true, + ), + ); + } + } + + Future onRegisterCompleted() async { + if (state is! MigrateBitboxRegisterReady) return; + await _runSafely( + _persistAndPrepareTransfer, + _MigrateBitboxRetryKind.transferPreparation, + ); + } + + 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; + final retryKind = kind!; + _pendingRetry = null; + _pendingRetryKind = null; + emit( + switch (retryKind) { + _MigrateBitboxRetryKind.linking => const MigrateBitboxLinking(), + _MigrateBitboxRetryKind.transferPreparation => + const MigrateBitboxPreparingTransfer(), + }, + ); + 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 { + _persisted = await _walletService.persistBitboxWallet(_draft!); + final softwareAddress = _appStore.primaryAddress; + final bitboxAddress = _persisted!.currentAccount.primaryAddress.address.hexEip55; + + late final Balance balance; + try { + balance = await _balanceService.fetchBalance(softwareAddress); + } catch (_) { + if (isClosed) return; + _pendingRetry = _persistAndPrepareTransfer; + _pendingRetryKind = _MigrateBitboxRetryKind.transferPreparation; + emit( + const MigrateBitboxFailure( + MigrateBitboxFailureReason.generic, + message: 'balance unavailable', + canRetry: true, + ), + ); + 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. + await finishMigration(); + return; + } + _pendingRetry = null; + _pendingRetryKind = 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, + ), + ); + } + + /// 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; + _pendingRetryKind = _MigrateBitboxRetryKind.transferPreparation; + emit( + MigrateBitboxFailure( + MigrateBitboxFailureReason.generic, + message: message, + canRetry: true, + ), + ); + } + + 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) { + await _runSafely( + finishMigration, + _MigrateBitboxRetryKind.transferPreparation, + retryAction: _persistAndPrepareTransfer, + ); + if (isClosed || generation != _settlingGeneration) return; + _settlingTimer?.cancel(); + return; + } + if (amount < expectedAmount) { + await _runSafely( + _persistAndPrepareTransfer, + _MigrateBitboxRetryKind.transferPreparation, + ); + if (isClosed || generation != _settlingGeneration) return; + _settlingTimer?.cancel(); + 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 { + 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; + // 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, + _authService.buildSignMessage(bitboxAddress), + ); + 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. + _appStore.sessionCache.setAuthToken( + _newJwt!, + persisted.currentAccount.primaryAddress.address.hexEip55, + ); + if (isClosed) return; + _pendingRetry = null; + _pendingRetryKind = null; + emit(MigrateBitboxSuccess(persisted)); + } + + @override + Future close() { + // 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(); + } +} + +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 new file mode 100644 index 00000000..d61bc85c --- /dev/null +++ b/lib/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_state.dart @@ -0,0 +1,111 @@ +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]; +} + +/// 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 MigrateBitboxSettling extends MigrateBitboxState { + const MigrateBitboxSettling(); +} + +class MigrateBitboxSettlingTimeout extends MigrateBitboxState { + const MigrateBitboxSettlingTimeout(); +} + +class MigrateBitboxPreparingTransfer extends MigrateBitboxState { + const MigrateBitboxPreparingTransfer(); +} + +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/cubits/migrate_register/migrate_register_cubit.dart b/lib/screens/migrate_bitbox/cubits/migrate_register/migrate_register_cubit.dart new file mode 100644 index 00000000..fc8b8365 --- /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 00000000..f70446c3 --- /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 new file mode 100644 index 00000000..d716095b --- /dev/null +++ b/lib/screens/migrate_bitbox/migrate_bitbox_page.dart @@ -0,0 +1,154 @@ +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 serves purely as the auth transport here + // (refreshAuthToken / authenticateLinkedAccount); no KYC-specific calls. + 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 { + // 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, + isScrollControlled: true, + builder: (sheetContext) => ConnectBitboxPage( + acquireWallet: () => + getIt().acquireUncommittedBitboxWallet('Luke-Skywallet'), + onFinish: (wallet) { + Navigator.of(sheetContext).pop(); + context.read().onDevicePaired(wallet as BitboxWallet); + }, + ), + ); + if (context.mounted) { + context.read().cancelPairing(); + } + } + }, + builder: (context, state) => PopScope( + canPop: switch (state) { + MigrateBitboxIntro() || + MigrateBitboxRegisterReady() || + MigrateBitboxTransferReady() || + MigrateBitboxRegistrationPending() || + MigrateBitboxSettlingTimeout() || + MigrateBitboxFailure() || + MigrateBitboxSuccess() => true, + _ => false, + }, + child: switch (state) { + MigrateBitboxIntro() || MigrateBitboxAwaitingDevice() => const MigrateIntroView(), + MigrateBitboxLinking() => _MigrateBitboxProgressPage( + label: S.of(context).migrateBitboxLinking, + ), + MigrateBitboxRegisterReady(:final userData, :final bitboxAddress) => + MigrateRegisterView( + account: context.read().draftAccount, + bearerToken: context.read().linkedJwt, + userData: userData, + bitboxAddress: bitboxAddress, + ), + 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, + ), + 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 message, :final canRetry) => + MigrateBitboxFailurePage( + reason: reason, + message: message, + 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 00000000..a3687148 --- /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} REALU', + 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 00000000..74b37c98 --- /dev/null +++ b/lib/screens/migrate_bitbox/widgets/migrate_register_view.dart @@ -0,0 +1,252 @@ +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/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'; +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.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) => BlocProvider( + create: (_) => MigrateRegisterCubit( + getIt(), + account: account, + userData: userData, + bearerToken: bearerToken, + ), + 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)), + 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, + state: isSubmitting + ? FilledButtonState.loading + : FilledButtonState.idle, + onPressed: isSubmitting + ? null + : () => context.read().submit(), + ), + ], + ), + ), + ), + ); +} + +class MigrateRegisterFailureView extends StatelessWidget { + const MigrateRegisterFailureView({ + super.key, + 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(), + ), + ], + ), + ), + ), + ); +} + +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 _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 new file mode 100644 index 00000000..d5692fd4 --- /dev/null +++ b/lib/screens/migrate_bitbox/widgets/migrate_result_views.dart @@ -0,0 +1,199 @@ +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 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}); + + @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, + this.message, + required this.canRetry, + }); + + final MigrateBitboxFailureReason 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( + message ?? _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 00000000..8729ccf8 --- /dev/null +++ b/lib/screens/migrate_bitbox/widgets/migrate_transfer_view.dart @@ -0,0 +1,243 @@ +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) => switch (current) { + SendProcessSuccess() || SendProcessFailure(canRetry: false) => true, + _ => false, + }, + listener: (context, state) { + if (state is SendProcessSuccess) { + context.read().onTransferBroadcast(); + } + if (state case SendProcessFailure(:final reason, canRetry: false)) { + context.read().onTransferFailedTerminally( + _failureMessage(context, reason), + ); + } + }, + 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) => MigrateTransferFailureView( + reason: reason, + canRetry: canRetry, + onRetry: 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, + ), + ], + ), + ); + +} + +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}); + + 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 d036b758..fa89b166 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 72c9d050..98920fdb 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 00000000..15f6f9df --- /dev/null +++ b/lib/setup/routing/routes/migration_routes.dart @@ -0,0 +1,3 @@ +abstract final class MigrationRoutes { + static const String migrateBitbox = 'migrateBitbox'; +} diff --git a/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_failure_already_linked.png b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_failure_already_linked.png new file mode 100644 index 00000000..6fff8040 Binary files /dev/null and b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_failure_already_linked.png differ diff --git a/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_failure_retryable.png b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_failure_retryable.png new file mode 100644 index 00000000..9431630a Binary files /dev/null and b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_failure_retryable.png differ diff --git a/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_intro.png b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_intro.png new file mode 100644 index 00000000..694b5399 Binary files /dev/null and b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_intro.png differ diff --git a/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_register_ready.png b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_register_ready.png new file mode 100644 index 00000000..3801bd29 Binary files /dev/null and b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_register_ready.png differ diff --git a/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_registration_pending.png b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_registration_pending.png new file mode 100644 index 00000000..c7170f31 Binary files /dev/null and b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_registration_pending.png differ diff --git a/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_settling.png b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_settling.png new file mode 100644 index 00000000..7349a27a Binary files /dev/null and b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_settling.png differ diff --git a/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_settling_timeout.png b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_settling_timeout.png new file mode 100644 index 00000000..e1ca7a99 Binary files /dev/null and b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_settling_timeout.png differ diff --git a/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_success.png b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_success.png new file mode 100644 index 00000000..8ca4bff1 Binary files /dev/null and b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_success.png differ diff --git a/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_transfer_failure.png b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_transfer_failure.png new file mode 100644 index 00000000..737af9c6 Binary files /dev/null and b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_transfer_failure.png differ diff --git a/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_transfer_ready.png b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_transfer_ready.png new file mode 100644 index 00000000..05f46aac Binary files /dev/null and b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_transfer_ready.png differ diff --git a/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_transferring.png b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_transferring.png new file mode 100644 index 00000000..8086ae0d Binary files /dev/null and b/test/goldens/screens/migrate_bitbox/goldens/macos/migrate_bitbox_transferring.png differ diff --git a/test/goldens/screens/migrate_bitbox/migrate_bitbox_golden_test.dart b/test/goldens/screens/migrate_bitbox/migrate_bitbox_golden_test.dart new file mode 100644 index 00000000..c9311667 --- /dev/null +++ b/test/goldens/screens/migrate_bitbox/migrate_bitbox_golden_test.dart @@ -0,0 +1,274 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/generated/i18n.dart'; +import 'package:realunit_wallet/models/balance.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/repository/balance_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/transfer/dto/real_unit_transfer_payment_info_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/registration/kyc/kyc_personal_data.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/user/dto/real_unit_user_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_registration_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_transfer_service.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/migrate_bitbox_page.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_result_views.dart'; +import 'package:realunit_wallet/screens/migrate_bitbox/widgets/migrate_transfer_view.dart'; +import 'package:realunit_wallet/screens/send/cubits/send_process/send_process_cubit.dart'; + +import '../../../helper/helper.dart'; + +class _MockMigrateBitboxCubit extends MockCubit + implements MigrateBitboxCubit {} + +class _MockApiConfig extends Mock implements ApiConfig {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockBalanceRepository extends Mock implements BalanceRepository {} + +class _MockRegistrationService extends Mock implements RealUnitRegistrationService {} + +class _MockSoftwareWallet extends Mock implements SoftwareWallet {} + +class _MockTransferService extends Mock implements RealUnitTransferService {} + +class _MockWalletAccount extends Mock implements AWalletAccount {} + +const _softwareAddress = '0x0000000000000000000000000000000000000001'; +const _bitboxAddress = '0x1234567890abcdef1234567890abcdef12345678'; + +const _userData = RealUnitUserDataDto( + email: 'ada@example.com', + name: 'Ada Lovelace', + type: 'HUMAN', + phoneNumber: '+41 79 000 00 00', + birthday: '1815-12-10', + nationality: 'CH', + addressStreet: 'Bahnhofstrasse 1', + addressPostalCode: '8000', + addressCity: 'Zurich', + addressCountry: 'CH', + swissTaxResidence: true, + lang: 'de', + kycData: KycPersonalData( + accountType: KycAccountType.personal, + firstName: 'Ada', + lastName: 'Lovelace', + phone: '+41 79 000 00 00', + address: KycAddress( + street: 'Bahnhofstrasse', + zip: '8000', + city: 'Zurich', + country: 41, + ), + ), +); + +Balance _balance(int shares) => Balance( + chainId: realUnitAsset.chainId, + contractAddress: realUnitAsset.address, + walletAddress: _softwareAddress, + balance: BigInt.from(shares), + asset: realUnitAsset, +); + +void main() { + late _MockMigrateBitboxCubit migrateCubit; + late _MockWalletAccount draftAccount; + + setUpAll(() { + registerFallbackValue(_balance(0)); + registerFallbackValue( + const RealUnitTransferDto(toAddress: _bitboxAddress, amount: 1), + ); + + final apiConfig = _MockApiConfig(); + final appStore = _MockAppStore(); + final balanceRepository = _MockBalanceRepository(); + final registrationService = _MockRegistrationService(); + final softwareWallet = _MockSoftwareWallet(); + final transferService = _MockTransferService(); + final pendingPrepare = Completer(); + + when(() => apiConfig.asset).thenReturn(realUnitAsset); + when(() => appStore.apiConfig).thenReturn(apiConfig); + when(() => appStore.primaryAddress).thenReturn(_softwareAddress); + when(() => appStore.wallet).thenReturn(softwareWallet); + when(() => softwareWallet.walletType).thenReturn(WalletType.software); + when( + () => balanceRepository.watchBalance(any()), + ).thenAnswer((_) => Stream.value(_balance(42))); + when( + () => transferService.prepareTransfer(any()), + ).thenAnswer((_) => pendingPrepare.future); + + GetIt.instance.registerSingleton(appStore); + GetIt.instance.registerSingleton(balanceRepository); + GetIt.instance.registerSingleton( + registrationService, + ); + GetIt.instance.registerSingleton(transferService); + }); + + tearDownAll(() async => GetIt.instance.reset()); + + setUp(() { + migrateCubit = _MockMigrateBitboxCubit(); + draftAccount = _MockWalletAccount(); + when(() => migrateCubit.state).thenReturn(const MigrateBitboxIntro()); + when(() => migrateCubit.draftAccount).thenReturn(draftAccount); + when(() => migrateCubit.linkedJwt).thenReturn('linked-jwt'); + }); + + Widget buildManager(MigrateBitboxState state) { + when(() => migrateCubit.state).thenReturn(state); + whenListen( + migrateCubit, + const Stream.empty(), + initialState: state, + ); + return wrapForGolden( + BlocProvider.value( + value: migrateCubit, + child: const MigrateBitboxViewManager(), + ), + ); + } + + group('$MigrateBitboxViewManager', () { + goldenTest( + 'intro with the current REALU balance', + fileName: 'migrate_bitbox_intro', + constraints: phoneConstraints, + builder: () => buildManager(const MigrateBitboxIntro()), + ); + + goldenTest( + 'registration ready with user data and BitBox address', + fileName: 'migrate_bitbox_register_ready', + constraints: phoneConstraints, + builder: () => buildManager( + const MigrateBitboxRegisterReady(_userData, _bitboxAddress), + ), + ); + + goldenTest( + 'transfer summary with source, destination, and amount', + fileName: 'migrate_bitbox_transfer_ready', + constraints: phoneConstraints, + builder: () => buildManager( + const MigrateBitboxTransferReady( + fromAddress: _softwareAddress, + toAddress: _bitboxAddress, + amount: 42, + ), + ), + ); + + goldenTest( + 'transfer preparation in progress', + fileName: 'migrate_bitbox_transferring', + constraints: phoneConstraints, + pumpBeforeTest: pumpOnce, + builder: () => buildManager( + const MigrateBitboxTransferring( + toAddress: _bitboxAddress, + amount: 42, + ), + ), + ); + + goldenTest( + 'settling in progress', + fileName: 'migrate_bitbox_settling', + constraints: phoneConstraints, + pumpBeforeTest: pumpOnce, + builder: () => buildManager(const MigrateBitboxSettling()), + ); + }); + + group('standalone migration result views', () { + goldenTest( + 'registration pending manual review', + fileName: 'migrate_bitbox_registration_pending', + constraints: phoneConstraints, + builder: () => wrapForGolden( + const MigrateBitboxRegistrationPendingPage(), + ), + ); + + goldenTest( + 'retryable transfer failure with unavailable gas funding', + fileName: 'migrate_bitbox_transfer_failure', + constraints: phoneConstraints, + builder: () => wrapForGolden( + Builder( + builder: (context) => Scaffold( + appBar: AppBar(title: Text(S.of(context).migrateBitbox)), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: SafeArea( + child: MigrateTransferFailureView( + reason: SendProcessFailureReason.gasFundingUnavailable, + canRetry: true, + onRetry: () {}, + ), + ), + ), + ), + ), + ), + ); + + goldenTest( + 'settling timeout', + fileName: 'migrate_bitbox_settling_timeout', + constraints: phoneConstraints, + builder: () => wrapForGolden( + const MigrateBitboxSettlingTimeoutPage(), + ), + ); + + goldenTest( + 'successful migration', + fileName: 'migrate_bitbox_success', + constraints: phoneConstraints, + builder: () => wrapForGolden(const MigrateBitboxSuccessPage()), + ); + + goldenTest( + 'retryable generic migration failure', + fileName: 'migrate_bitbox_failure_retryable', + constraints: phoneConstraints, + builder: () => wrapForGolden( + const MigrateBitboxFailurePage( + reason: MigrateBitboxFailureReason.generic, + canRetry: true, + ), + ), + ); + + goldenTest( + 'already-linked migration failure without retry', + fileName: 'migrate_bitbox_failure_already_linked', + constraints: phoneConstraints, + builder: () => wrapForGolden( + const MigrateBitboxFailurePage( + reason: MigrateBitboxFailureReason.addressAlreadyLinked, + canRetry: false, + ), + ), + ); + }); +} 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 fcbb882f..f82545ed 100644 Binary files a/test/goldens/screens/settings/goldens/macos/settings_page_default.png and b/test/goldens/screens/settings/goldens/macos/settings_page_default.png differ diff --git a/test/helper/responsive_surface_catalog.dart b/test/helper/responsive_surface_catalog.dart index 8c35aa01..78c5446a 100644 --- a/test/helper/responsive_surface_catalog.dart +++ b/test/helper/responsive_surface_catalog.dart @@ -273,9 +273,70 @@ 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_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)', + 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_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)', + 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', + ), + 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/integration/link_wallet_connect_flow_test.dart b/test/integration/link_wallet_connect_flow_test.dart index dc0e0cf0..d6b9d577 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 c721f883..b4f82a07 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/repository/wallet_repository_test.dart b/test/packages/repository/wallet_repository_test.dart index b9ab2ded..b34435c9 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 {} @@ -72,6 +73,41 @@ void main() { verifyNever(() => secureStorage.getOrCreateMnemonicKey()); }); + test( + 'getBitboxWalletIdByAddress normalizes legacy rows and ignores malformed candidates', + () async { + await repo.createViewWallet('MalformedHardware', WalletType.bitbox, ''); + // 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, + normalizedAddress.toLowerCase(), + ); + // Same address, different type — must NOT match the BitBox lookup. + await repo.createViewWallet( + 'SoftwareView', + WalletType.software, + normalizedAddress, + ); + + expect( + await repo.getBitboxWalletIdByAddress(normalizedAddress), + bitboxId, + ); + expect( + await repo.getBitboxWalletIdByAddress( + '0x3333333333333333333333333333333333333333', + ), + isNull, + ); + expect(await repo.getBitboxWalletIdByAddress(''), isNull); + 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/balance_service_test.dart b/test/packages/service/balance_service_test.dart index 13b9345d..d81176a0 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 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 55ca4986..ae43c336 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 c5b685dd..7613dceb 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'; @@ -63,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); @@ -134,6 +145,22 @@ class _SignatureTestAuthService extends DFXAuthService { String get walletAddress => _address; } +class _MutableIdentityAuthService extends DFXAuthService { + _MutableIdentityAuthService( + super.appStore, + super.walletService, + this.currentAccount, + ); + + AWalletAccount currentAccount; + + @override + AWalletAccount get wallet => currentAccount; + + @override + String get walletAddress => currentAccount.primaryAddress.address.hexEip55; +} + // --------------------------------------------------------------------------- // Helpers — authenticated request retry-on-401 surface. // --------------------------------------------------------------------------- @@ -195,16 +222,24 @@ 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); + 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.loadSignature()).thenAnswer((_) async {}); + when(() => sessionCache.saveSignature(any(), any(), any())).thenAnswer((_) async {}); }); _SignatureTestAuthService buildService() => @@ -214,12 +249,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 { @@ -227,7 +263,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 { @@ -242,11 +278,115 @@ 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); + }); + + 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"', () async { - walletAccount = _StubWalletAccount(emptySignature); + walletAccount = _StubWalletAccount(emptySignature, address: address); expect( () => buildService().getSignature('msg'), @@ -259,9 +399,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); @@ -397,6 +544,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); + }, + ); }); // ------------------------------------------------------------------------- @@ -429,7 +647,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 {}); }); @@ -447,12 +666,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 { @@ -463,14 +686,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 { @@ -484,10 +714,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']) { @@ -552,7 +789,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(() { @@ -595,6 +835,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 { @@ -688,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 { @@ -707,19 +1012,238 @@ 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( + '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', + 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 requestArrivals[0].future; + expect(sentBodies.single['address'], service.walletAddress); + + 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(sentBodies, hasLength(3)); + expect( + sentBodies[2]['address'], + accountA.primaryAddress.address.hexEip55, + ); + + 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', () { - 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++; @@ -733,6 +1257,7 @@ void main() { // Cache was cleared → exactly one auth round-trip. expect(authCalls, 1); expect(sessionCache.authToken, 'jwt-fresh'); + expect(sessionCache.authTokenAddress, walletAddress); }); }); @@ -770,6 +1295,198 @@ 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.signedMessage).thenReturn(null); + when(() => sessionCache.saveSignature(any(), 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 { + var saveCalls = 0; + List? savedArguments; + when( + () => sessionCache.saveSignature(any(), any(), any()), + ).thenAnswer((invocation) async { + saveCalls++; + savedArguments = invocation.positionalArguments; + }); + 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 service = buildService(client); + final token = await service.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); + 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())); + }, + ); + + 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 service = buildService(client); + when(() => sessionCache.signedMessage).thenReturn( + service.buildSignMessage(accountAddressEip55), + ); + final token = await service.authenticateLinkedAccount( + account, + linkBearerToken, + ); + + expect(token, 'jwt-linked'); + expect(account.signCallCount, 0); + expect(sentBody!['signature'], stubSignature); + verifyNever(() => sessionCache.saveSignature(any(), any(), any())); + }); + + 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'); + final client = MockClient( + (_) async => http.Response(jsonEncode({'accessToken': 'jwt-linked'}), 201), + ); + final service = buildService(client); + + await service.authenticateLinkedAccount(account, linkBearerToken); + + expect(account.signCallCount, 1); + expect(saveCalls, 1); + expect(savedArguments, [ + accountAddressEip55, + stubSignature, + service.buildSignMessage(accountAddressEip55), + ]); + }); + + 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. // @@ -788,15 +1505,18 @@ 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 {}); + // 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; 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 466c803a..2eb3e602 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 44397490..42432111 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 {}); }); @@ -44,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 { @@ -74,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; @@ -87,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, @@ -97,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': [ @@ -111,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. diff --git a/test/packages/service/dfx/dfx_brokerbot_service_test.dart b/test/packages/service/dfx/dfx_brokerbot_service_test.dart index 0fff8002..1093afa8 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; @@ -315,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 {}); }); @@ -333,7 +366,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 +383,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/dfx_faucet_service_test.dart b/test/packages/service/dfx/dfx_faucet_service_test.dart index 5abb68a5..a62ecf2a 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 e285b8be..869b4968 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 c1e63904..7d776263 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 d0cd927f..58bd48e9 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 b83c38de..1e5795bd 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/exceptions/exception_surface_test.dart b/test/packages/service/dfx/exceptions/exception_surface_test.dart index 55387f3c..3fb87c1b 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_buy_payment_info_service_test.dart b/test/packages/service/dfx/real_unit_buy_payment_info_service_test.dart index 32daf97d..592adaea 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', () { 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 f9048ea2..c484d8c4 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 96996503..4423e062 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 b09b0188..5cf8d9d2 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 101a31fc..56174a7d 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 dc31f7ca..3c387ab7 100644 --- a/test/packages/service/dfx/real_unit_registration_service_test.dart +++ b/test/packages/service/dfx/real_unit_registration_service_test.dart @@ -12,14 +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'; @@ -50,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 {}); }); @@ -174,6 +178,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, @@ -263,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 { @@ -378,6 +407,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/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 289a9b32..7fefcbe2 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 82575271..1f33aaa9 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 f16db9f4..d60702c5 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 475bffe3..914bd585 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 b781a3f2..f92b8520 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,61 @@ 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('re-saving the identical address+signature without a message keeps the stored message', () async { + await cache.saveSignature('0xabc', '0xsig', 'sign-message'); + + await cache.saveSignature('0xabc', '0xsig'); + + expect(cache.signedMessage, 'sign-message'); + verify(() => repo.write('cached_signature_message', 'sign-message')).called(2); + verifyNever(() => repo.delete('cached_signature_message')); + }); + + test('saving a different signature without a message drops the persisted message', () async { + await cache.saveSignature('0xabc', '0xsig', 'sign-message'); + + await cache.saveSignature('0xabc', '0xother'); + + expect(cache.signedMessage, isNull); + verify(() => repo.delete('cached_signature_message')).called(1); }); test('loadSignature populates from the repo when memory is empty', () async { when(() => repo.read('cached_signature')).thenAnswer((_) async => '0xsig'); when(() => repo.read('cached_signature_address')).thenAnswer((_) async => '0xabc'); + 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 +114,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 ada4cebb..cb1bbad9 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/packages/service/wallet_service_test.dart b/test/packages/service/wallet_service_test.dart index 95ce405f..9fac1efc 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,8 @@ 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, @@ -249,6 +252,42 @@ 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('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')); @@ -290,6 +329,141 @@ 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('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('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'); + + 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 a legacy lowercase BitBox row for the normalized incoming address', + () async { + 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())); + }, + ); + + 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); @@ -364,6 +538,43 @@ 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('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/packages/storage/wallet_storage_test.dart b/test/packages/storage/wallet_storage_test.dart index 84e63052..2037ab84 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); 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 00000000..16164e90 --- /dev/null +++ b/test/screens/migrate_bitbox/cubits/migrate_bitbox/migrate_bitbox_cubit_test.dart @@ -0,0 +1,1106 @@ +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'; +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/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 refreshedJwt = 'refreshed-jwt'; + const newJwt = 'new-jwt'; + const signature = '0xsigned'; + const signMessage = 'scoped-sign-message'; + + 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', + ); + // 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); + 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(), any())).thenAnswer((_) async {}); + when(() => sessionCache.setAuthToken(any(), any())).thenReturn(null); + + when(() => authService.refreshAuthToken()).thenAnswer((_) async => refreshedJwt); + // 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); + when( + () => registrationService.getRegistrationInfo(), + ).thenAnswer((_) async => info(RealUnitRegistrationState.alreadyRegistered)); + when( + () => registrationService.getRegistrationInfoWith(any()), + ).thenAnswer((_) async => info(RealUnitRegistrationState.alreadyRegistered)); + when(() => walletService.persistBitboxWallet(any())).thenAnswer((_) async => persisted); + when(() => walletService.setCurrentWallet(any())).thenAnswer((_) async {}); + when( + () => balanceService.fetchBalance(any()), + ).thenAnswer((_) async => balance(5)); + }); + + MigrateBitboxCubit buildCubit({bool addCloseTearDown = true}) { + final cubit = MigrateBitboxCubit( + walletService, + authService, + registrationService, + balanceService, + appStore, + ); + if (addCloseTearDown) 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()); + } + + 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(); + + 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(); + // 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()]); + }); + }); + + 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, 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()), + ).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.refreshAuthToken()).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 the fresh balance read 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.fetchBalance(softwareAddress), + ]); + }); + + test('newRegistration after linking is retryable and retries the precheck', () async { + when( + () => registrationService.getRegistrationInfoWith(any()), + ).thenAnswer((_) async => info(RealUnitRegistrationState.newRegistration)); + final cubit = buildCubit(); + + await cubit.onDevicePaired(draft); + + expect( + cubit.state, + 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 refreshed JWT emits generic failure with no pending retry', () async { + when(() => authService.refreshAuthToken()).thenAnswer((_) async => null); + final cubit = buildCubit(); + + await cubit.onDevicePaired(draft); + await cubit.retry(); + + expect( + cubit.state, + const MigrateBitboxFailure(MigrateBitboxFailureReason.generic), + ); + verify(() => authService.refreshAuthToken()).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.refreshAuthToken()).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.refreshAuthToken()).called(2); + verify( + () => authService.authenticateLinkedAccount(draftAccount, refreshedJwt), + ).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.refreshAuthToken()).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.refreshAuthToken()).called(2); + }); + }); + + group('$MigrateBitboxCubit registration handoff', () { + test('draftAccount and linkedJwt fail loud before pairing', () { + final cubit = buildCubit(); + + expect(() => cubit.draftAccount, throwsStateError); + expect(() => cubit.linkedJwt, throwsStateError); + }); + + 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); + }); + + test('onRegisterCompleted is a no-op outside RegisterReady', () async { + final cubit = buildCubit(); + + await cubit.onRegisterCompleted(); + + verifyNever(() => walletService.persistBitboxWallet(any())); + }); + + test('onRegisterCompleted prepares the transfer from RegisterReady', () async { + final cubit = buildCubit(); + await reachRegisterReady(cubit); + + await cubit.onRegisterCompleted(); + + expect(cubit.state, isA()); + 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; + + cubit.onRegisterPending(); + + expect(cubit.state, same(initial)); + }); + + test('onRegisterPending emits RegistrationPending from RegisterReady', () async { + final cubit = buildCubit(); + await reachRegisterReady(cubit); + + cubit.onRegisterPending(); + + expect(cubit.state, const MigrateBitboxRegistrationPending()); + }); + }); + + group('$MigrateBitboxCubit transfer preparation', () { + test('fetch failure fails loud despite an implicit zero cache and never switches', () async { + when( + () => balanceService.fetchBalance(any()), + ).thenThrow(Exception('network unavailable')); + 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.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.fetchBalance(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.fetchBalance(any()), + ).thenAnswer((_) async => balance(37)); + final cubit = buildCubit(); + + await cubit.onDevicePaired(draft); + + expect( + cubit.state, + MigrateBitboxTransferReady( + fromAddress: softwareAddress, + toAddress: persistedAddress, + amount: 37, + ), + ); + }); + + 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(), any())); + verifyNever(() => sessionCache.setAuthToken(any(), any())); + }); + }); + + 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.fetchBalance(softwareAddress)).called(2); + 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.fetchBalance(any()), + ).thenAnswer((_) async => balance(0)); + final cubit = buildCubit(); + + await cubit.onDevicePaired(draft); + + verifyInOrder([ + () => walletService.setCurrentWallet(42), + () => sessionCache.saveSignature( + persistedAddress, + signature, + signMessage, + ), + () => sessionCache.setAuthToken(newJwt, persistedAddress), + ]); + expect(cubit.state, MigrateBitboxSuccess(persisted)); + }); + + test('signature-address mismatch skips signature persistence', () async { + when(() => sessionCache.signatureAddress).thenReturn(softwareAddress); + when( + () => balanceService.fetchBalance(any()), + ).thenAnswer((_) async => balance(0)); + final cubit = buildCubit(); + + await cubit.onDevicePaired(draft); + + verifyInOrder([ + () => walletService.setCurrentWallet(42), + () => sessionCache.setAuthToken(newJwt, persistedAddress), + ]); + verifyNever(() => sessionCache.saveSignature(any(), 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(), 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(), any())); + verifyNever(() => sessionCache.setAuthToken(any(), any())); + }); + }); + }); + + 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) { + var calls = 0; + when( + () => balanceService.fetchBalance(any()), + ).thenAnswer((_) async => balance(calls++ == 0 ? 5 : 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) { + var balanceReads = 0; + when( + () => balanceService.fetchBalance(any()), + ).thenAnswer((_) async { + balanceReads++; + return balance(balanceReads == 1 ? 5 : 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('20 consecutive poll errors also time out without switching wallets', () { + // Pins the catch-branch timeout: fetch failures count as attempts and + // must reach the same fail-closed SettlingTimeout as unchanged balances. + fakeAsync((async) { + final cubit = buildCubit(addCloseTearDown: false); + cubit.onDevicePaired(draft); + drain(async); + cubit.startTransfer(); + cubit.onTransferBroadcast(); + drain(async); + clearInteractions(balanceService); + when(() => balanceService.fetchBalance(any())) + .thenAnswer((_) async => throw Exception('poll offline')); + + for (var i = 0; i < 20; i++) { + async.elapse(const Duration(seconds: 3)); + drain(async); + } + + expect(cubit.state, const MigrateBitboxSettlingTimeout()); + verifyNever(() => walletService.setCurrentWallet(any())); + verify(() => balanceService.fetchBalance(softwareAddress)).called(20); + cubit.close(); + async.flushTimers(); + }); + }); + + test('an unchanged balance times out after 20 attempts without switching wallets', () { + fakeAsync((async) { + final cubit = buildCubit(addCloseTearDown: false); + cubit.onDevicePaired(draft); + drain(async); + cubit.startTransfer(); + cubit.onTransferBroadcast(); + drain(async); + clearInteractions(balanceService); + + 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) return balance(5); + if (calls == 2) 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, 3); + cubit.close(); + async.flushTimers(); + }); + }); + + test('overlapping timer ticks do not start a second balance request', () { + fakeAsync((async) { + final pendingBalance = Completer(); + var calls = 0; + when( + () => balanceService.fetchBalance(any()), + ).thenAnswer((_) { + calls++; + if (calls == 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: 6)); + drain(async); + + expect(calls, 2); + pendingBalance.complete(balance(0)); + drain(async); + expect(cubit.state, MigrateBitboxSuccess(persisted)); + cubit.close(); + 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(), 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(), any())); + verifyNever(() => sessionCache.setAuthToken(any(), any())); + subscription.cancel(); + 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 00000000..87d37e63 --- /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 new file mode 100644 index 00000000..9819b5ba --- /dev/null +++ b/test/screens/migrate_bitbox/migrate_bitbox_page_test.dart @@ -0,0 +1,385 @@ +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'; +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/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'; +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 {} + +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 softwareAddress = '0x0000000000000000000000000000000000000001'; + final mappingWallet = _MockBitboxWallet(); + late _MockMigrateBitboxCubit cubit; + late _MockHomeBloc homeBloc; + late _MockBitboxWallet bitboxWallet; + late _MockWalletService walletService; + late _MockWalletAccount draftAccount; + + Balance fixtureBalance() => Balance( + chainId: realUnitAsset.chainId, + contractAddress: realUnitAsset.address, + walletAddress: softwareAddress, + balance: BigInt.zero, + asset: realUnitAsset, + ); + + setUpAll(() { + registerFallbackValue(fixtureBalance()); + registerFallbackValue(_MockBitboxWallet()); + registerFallbackValue(_MockWalletAccount()); + 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(); + draftAccount = _MockWalletAccount(); + 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.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); + }); + + 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 MigrateBitboxSettlingTimeout(), MigrateBitboxSettlingTimeoutPage), + ( + 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); + if (state is MigrateBitboxRegisterReady) { + final view = tester.widget( + find.byType(MigrateRegisterView), + ); + expect(view.account, same(draftAccount)); + expect(view.bearerToken, 'linked-jwt'); + } + }); + } + + final progressCases = <(MigrateBitboxState, String)>[ + (const MigrateBitboxLinking(), 'linking'), + (const MigrateBitboxPreparingTransfer(), 'preparing transfer'), + (const MigrateBitboxSettling(), 'settling'), + (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 MigrateBitboxRegistrationPending(), true), + (const MigrateBitboxPreparingTransfer(), false), + ( + const MigrateBitboxTransferReady( + fromAddress: '0xfrom', + toAddress: '0xto', + amount: 9, + ), + true, + ), + (const MigrateBitboxTransferring(toAddress: '0xto', amount: 9), false), + (const MigrateBitboxSettling(), false), + (const MigrateBitboxSettlingTimeout(), true), + (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).first); + expect(popScope.canPop, expected); + }); + } + }); + + testWidgets('AwaitingDevice emission opens the ConnectBitboxPage sheet', ( + tester, + ) async { + whenListen( + cubit, + Stream.value( + const MigrateBitboxAwaitingDevice(), + ), + initialState: const MigrateBitboxIntro(), + ); + // 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(); + 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); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + verify( + () => walletService.acquireUncommittedBitboxWallet('Luke-Skywallet'), + ).called(1); + verify(() => cubit.onDevicePaired(bitboxWallet)).called(1); + expect(find.byType(ConnectBitboxPage), findsNothing); + + 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 00000000..6a9fd277 --- /dev/null +++ b/test/screens/migrate_bitbox/migrate_bitbox_responsive_matrix_test.dart @@ -0,0 +1,294 @@ +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/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/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'; +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'; + +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 {} + +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', + 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()); + registerFallbackValue(_MockWalletAccount()); + 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); + GetIt.instance.registerSingleton( + _MockRegistrationService(), + ); + }); + + 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.draftAccount).thenReturn(_MockWalletAccount()); + when(() => cubit.linkedJwt).thenReturn('linked-jwt'); + when(() => cubit.startTransfer()).thenReturn(null); + when(() => cubit.retry()).thenAnswer((_) async {}); + 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, + 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', + () => 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( + fromAddress: '0x1234567890abcdef1234567890abcdef12345678', + toAddress: '0xabcdef1234567890abcdef1234567890abcdef12', + amount: 999999999, + ), + MigrateTransferReadyView, + ), + ( + 'registration-pending', + () => const MigrateBitboxRegistrationPendingPage(), + MigrateBitboxRegistrationPendingPage, + ), + ( + 'settling-timeout', + () => const MigrateBitboxSettlingTimeoutPage(), + MigrateBitboxSettlingTimeoutPage, + ), + ( + 'success', + () => const MigrateBitboxSuccessPage(), + MigrateBitboxSuccessPage, + ), + ( + 'failure-retryable', + () => const MigrateBitboxFailurePage( + reason: MigrateBitboxFailureReason.registrationMissing, + canRetry: true, + ), + MigrateBitboxFailurePage, + ), + ( + 'failure-terminal', + () => const MigrateBitboxFailurePage( + reason: MigrateBitboxFailureReason.addressAlreadyLinked, + canRetry: false, + ), + MigrateBitboxFailurePage, + ), + ( + 'transfer-failure-retryable', + () => MigrateTransferFailureView( + reason: SendProcessFailureReason.generic, + canRetry: true, + onRetry: () {}, + ), + MigrateTransferFailureView, + ), + ]; + + 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(); + 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_intro_view_test.dart b/test/screens/migrate_bitbox/widgets/migrate_intro_view_test.dart new file mode 100644 index 00000000..dbff18f2 --- /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 00000000..c7881605 --- /dev/null +++ b/test/screens/migrate_bitbox/widgets/migrate_register_view_test.dart @@ -0,0 +1,226 @@ +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'; + +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', + 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 parentCubit; + late _MockRegistrationService registrationService; + late _MockWalletAccount account; + + setUpAll(() { + registerFallbackValue(_MockWalletAccount()); + registerFallbackValue(_userData); + }); + + setUp(() { + parentCubit = _MockMigrateBitboxCubit(); + registrationService = _MockRegistrationService(); + account = _MockWalletAccount(); + when(() => parentCubit.state).thenReturn( + const MigrateBitboxRegisterReady(_userData, '0x1234567890abcdef'), + ); + whenListen( + parentCubit, + const Stream.empty(), + initialState: const MigrateBitboxRegisterReady( + _userData, + '0x1234567890abcdef', + ), + ); + 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: parentCubit, + child: MigrateRegisterView( + account: account, + bearerToken: 'linked-jwt', + userData: _userData, + bitboxAddress: address, + ), + ), + ); + + 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( + () => registrationService.registerWalletFor( + account, + _userData, + 'linked-jwt', + ), + ).called(1); + }); + + testWidgets('keeps a short address unchanged', (tester) async { + await pumpView(tester, '0x1234'); + + 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 = [ + const SigningCancelledException(), + const BitboxNotConnectedException(), + Exception('registration failed'), + ]; + + for (final error 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(); + + 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); + + 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_result_views_test.dart b/test/screens/migrate_bitbox/widgets/migrate_result_views_test.dart new file mode 100644 index 00000000..602f3dc7 --- /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 00000000..d8f3dd48 --- /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.onTransferBroadcast()).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 starts settling 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.onTransferBroadcast()).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.onTransferBroadcast()).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 00000000..188e4f22 --- /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); + }); +}