diff --git a/cw_zano/lib/zano_wallet.dart b/cw_zano/lib/zano_wallet.dart index c5534f6c89..3a884fc1a4 100644 --- a/cw_zano/lib/zano_wallet.dart +++ b/cw_zano/lib/zano_wallet.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert' as convert; import 'dart:core'; import 'dart:io'; import 'dart:math'; @@ -58,11 +59,6 @@ abstract class ZanoWalletBase @override String get password => _password; - @override - Future signMessage(String message, {String? address = null}) { - throw UnimplementedError(); - } - @override Future verifyMessage(String message, String signature, {String? address = null}) { throw UnimplementedError(); diff --git a/cw_zano/lib/zano_wallet_api.dart b/cw_zano/lib/zano_wallet_api.dart index f10e0b2d6c..e81103388f 100644 --- a/cw_zano/lib/zano_wallet_api.dart +++ b/cw_zano/lib/zano_wallet_api.dart @@ -3,6 +3,7 @@ import 'dart:ffi'; import 'dart:io'; import 'dart:isolate'; +import 'package:cake_wallet/core/logger_service.dart'; import 'package:cw_core/transaction_priority.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/zano_asset.dart'; @@ -46,10 +47,10 @@ mixin ZanoWalletApi { void setPassword(String password) => zano.PlainWallet_resetWalletPassword(hWallet, password); void closeWallet(int? walletToClose, {bool force = false}) async { - printV('close_wallet ${walletToClose ?? hWallet}: $force'); + LoggerService.debug('close_wallet ${walletToClose ?? hWallet}: $force', tag: 'Zano'); if (Platform.isWindows || force) { final result = await _closeWallet(walletToClose ?? hWallet); - printV('close_wallet result $result'); + LoggerService.debug('close_wallet result $result', tag: 'Zano'); openWalletCache.removeWhere((_, cwr) => cwr.walletId == (walletToClose ?? hWallet)); } } @@ -133,14 +134,14 @@ mixin ZanoWalletApi { Future getWalletInfo() async { final json = await _getWalletInfo(hWallet); final result = GetWalletInfoResult.fromJson(jsonDecode(json)); - printV('get_wallet_info got ${result.wi.balances.length} balances: ${result.wi.balances}'); + LoggerService.debug('get_wallet_info got ${result.wi.balances.length} balances: ${result.wi.balances}', tag: 'Zano'); return result; } Future getWalletStatus() async { final json = await _getWalletStatus(hWallet); if (json == Consts.errorWalletWrongId) { - printV('wrong wallet id'); + LoggerService.error('wrong wallet id', tag: 'Zano'); throw ZanoWalletException('Wrong wallet id'); } final status = GetWalletStatusResult.fromJson(jsonDecode(json)); @@ -160,7 +161,7 @@ mixin ZanoWalletApi { jsonDecode(invokeResult); } catch (e) { if (invokeResult.contains(Consts.errorWalletWrongId)) throw ZanoWalletException('Wrong wallet id'); - printV('exception in parsing json in invokeMethod: $invokeResult'); + LoggerService.error('exception in parsing json in invokeMethod: $invokeResult', tag: 'Zano'); rethrow; } return invokeResult; @@ -230,6 +231,53 @@ mixin ZanoWalletApi { return ProxyToDaemonResult.fromJson(map!['result'] as Map); } + @override + Future signMessage(String message, {String? address}) async { + try { + final messageBase64 = convert.base64.encode(convert.utf8.encode(message)); + + final response = await invokeMethod('sign_message', { + 'buff': messageBase64 + }); + + final responseData = jsonDecode(response) as Map; + + // Check for top-level errors first + if (responseData['error'] != null) { + final error = responseData['error']; + final code = error['code'] ?? ''; + final message = error['message'] ?? 'Unknown error'; + throw ZanoWalletException('Sign message failed: $message ($code)'); + } + + final result = responseData['result'] as Map?; + if (result == null) { + throw ZanoWalletException('Invalid response from sign_message: no result'); + } + + final signature = result['sig'] as String?; + if (signature == null) { + throw ZanoWalletException('No signature in response'); + } + + // Basic validation: signature should be hex and have expected length + if (!RegExp(r'^[0-9a-fA-F]+$').hasMatch(signature)) { + throw ZanoWalletException('Invalid signature format: not hexadecimal'); + } + + // Validate signature length (64 bytes = 128 hex chars) + const int expectedSignatureLength = 128; + if (signature.length != expectedSignatureLength) { + LoggerService.warning('Unexpected signature length', tag: 'Zano'); + } + + return signature; + } catch (e) { + if (e is ZanoWalletException) rethrow; + throw ZanoWalletException('Failed to sign message: $e'); + } + } + Future getAssetInfo(String assetId) async { final methodName = 'get_asset_info'; final params = AssetIdParams(assetId: assetId); diff --git a/lib/buy/buy_provider_config.dart b/lib/buy/buy_provider_config.dart new file mode 100644 index 0000000000..c9a9c8c3f6 --- /dev/null +++ b/lib/buy/buy_provider_config.dart @@ -0,0 +1,200 @@ +import 'package:cake_wallet/entities/fiat_currency.dart'; + +/// Configuration for buy/sell provider features +class BuyProviderConfig { + // Singleton instance + static BuyProviderConfig? _instance; + + factory BuyProviderConfig() { + _instance ??= BuyProviderConfig._internal(); + return _instance!; + } + + BuyProviderConfig._internal(); + + /// Dispose of the singleton instance and free resources + static void dispose() { + _instance?._cleanup(); + _instance = null; + } + + void _cleanup() { + // Clear all cached data + _currencyFallbacks.clear(); + // Reset to defaults to free any held references + _quoteTimeoutSeconds = 10; + _authenticationTimeoutSeconds = 30; + _providerLaunchTimeoutSeconds = 60; + _maxRetryAttempts = 3; + _retryDelayMilliseconds = 1000; + _quoteCacheDurationSeconds = 30; + _enableQuoteCache = true; + } + + // Currency fallback configuration + final Map> _currencyFallbacks = { + FiatCurrency.usd: [FiatCurrency.eur, FiatCurrency.gbp], + FiatCurrency.cad: [FiatCurrency.usd, FiatCurrency.eur], + FiatCurrency.aud: [FiatCurrency.usd, FiatCurrency.eur], + FiatCurrency.chf: [FiatCurrency.eur, FiatCurrency.usd], + FiatCurrency.jpy: [FiatCurrency.usd, FiatCurrency.eur], + }; + + // Timeout configuration (in seconds) + int _quoteTimeoutSeconds = 10; + int _authenticationTimeoutSeconds = 30; + int _providerLaunchTimeoutSeconds = 60; + + // Retry configuration + int _maxRetryAttempts = 3; + int _retryDelayMilliseconds = 1000; + + // Cache configuration + int _quoteCacheDurationSeconds = 30; + bool _enableQuoteCache = true; + + // Getters + List getFallbackCurrencies(FiatCurrency from) { + return _currencyFallbacks[from] ?? []; + } + + Duration get quoteTimeout => Duration(seconds: _quoteTimeoutSeconds); + Duration get authenticationTimeout => Duration(seconds: _authenticationTimeoutSeconds); + Duration get providerLaunchTimeout => Duration(seconds: _providerLaunchTimeoutSeconds); + + int get maxRetryAttempts => _maxRetryAttempts; + Duration get retryDelay => Duration(milliseconds: _retryDelayMilliseconds); + + Duration get quoteCacheDuration => Duration(seconds: _quoteCacheDurationSeconds); + bool get isQuoteCacheEnabled => _enableQuoteCache; + + // Setters for runtime configuration + void setQuoteTimeout(int seconds) { + if (seconds > 0 && seconds <= 120) { + _quoteTimeoutSeconds = seconds; + } + } + + void setAuthenticationTimeout(int seconds) { + if (seconds > 0 && seconds <= 120) { + _authenticationTimeoutSeconds = seconds; + } + } + + void setMaxRetryAttempts(int attempts) { + if (attempts >= 0 && attempts <= 10) { + _maxRetryAttempts = attempts; + } + } + + void setRetryDelay(int milliseconds) { + if (milliseconds >= 0 && milliseconds <= 10000) { + _retryDelayMilliseconds = milliseconds; + } + } + + void setQuoteCacheDuration(int seconds) { + if (seconds >= 0 && seconds <= 300) { + _quoteCacheDurationSeconds = seconds; + } + } + + void setQuoteCacheEnabled(bool enabled) { + _enableQuoteCache = enabled; + } + + void addCurrencyFallback(FiatCurrency from, FiatCurrency to) { + if (!_currencyFallbacks.containsKey(from)) { + _currencyFallbacks[from] = []; + } + if (!_currencyFallbacks[from]!.contains(to)) { + _currencyFallbacks[from]!.add(to); + } + } + + void removeCurrencyFallback(FiatCurrency from, FiatCurrency to) { + _currencyFallbacks[from]?.remove(to); + } + + void clearCurrencyFallbacks(FiatCurrency from) { + _currencyFallbacks[from]?.clear(); + } + + // Reset to defaults + void resetToDefaults() { + _quoteTimeoutSeconds = 10; + _authenticationTimeoutSeconds = 30; + _providerLaunchTimeoutSeconds = 60; + _maxRetryAttempts = 3; + _retryDelayMilliseconds = 1000; + _quoteCacheDurationSeconds = 30; + _enableQuoteCache = true; + + _currencyFallbacks.clear(); + _currencyFallbacks[FiatCurrency.usd] = [FiatCurrency.eur, FiatCurrency.gbp]; + _currencyFallbacks[FiatCurrency.cad] = [FiatCurrency.usd, FiatCurrency.eur]; + _currencyFallbacks[FiatCurrency.aud] = [FiatCurrency.usd, FiatCurrency.eur]; + _currencyFallbacks[FiatCurrency.chf] = [FiatCurrency.eur, FiatCurrency.usd]; + _currencyFallbacks[FiatCurrency.jpy] = [FiatCurrency.usd, FiatCurrency.eur]; + } + + // Load from JSON (for persistence) + void loadFromJson(Map json) { + if (json['quoteTimeoutSeconds'] != null) { + setQuoteTimeout(json['quoteTimeoutSeconds'] as int); + } + if (json['authenticationTimeoutSeconds'] != null) { + setAuthenticationTimeout(json['authenticationTimeoutSeconds'] as int); + } + if (json['maxRetryAttempts'] != null) { + setMaxRetryAttempts(json['maxRetryAttempts'] as int); + } + if (json['retryDelayMilliseconds'] != null) { + setRetryDelay(json['retryDelayMilliseconds'] as int); + } + if (json['quoteCacheDurationSeconds'] != null) { + setQuoteCacheDuration(json['quoteCacheDurationSeconds'] as int); + } + if (json['enableQuoteCache'] != null) { + setQuoteCacheEnabled(json['enableQuoteCache'] as bool); + } + + // Load currency fallbacks + if (json['currencyFallbacks'] != null) { + _currencyFallbacks.clear(); + final fallbacks = json['currencyFallbacks'] as Map; + fallbacks.forEach((key, value) { + final fromCurrency = FiatCurrency.all.firstWhere( + (e) => e.toString() == key, + orElse: () => FiatCurrency.usd, + ); + final toCurrencies = (value as List).map((e) { + return FiatCurrency.all.firstWhere( + (c) => c.toString() == e.toString(), + orElse: () => FiatCurrency.eur, + ); + }).toList(); + _currencyFallbacks[fromCurrency] = toCurrencies; + }); + } + } + + // Save to JSON (for persistence) + Map toJson() { + final fallbacksJson = {}; + _currencyFallbacks.forEach((key, value) { + fallbacksJson[key.toString()] = value.map((e) => e.toString()).toList(); + }); + + return { + 'quoteTimeoutSeconds': _quoteTimeoutSeconds, + 'authenticationTimeoutSeconds': _authenticationTimeoutSeconds, + 'providerLaunchTimeoutSeconds': _providerLaunchTimeoutSeconds, + 'maxRetryAttempts': _maxRetryAttempts, + 'retryDelayMilliseconds': _retryDelayMilliseconds, + 'quoteCacheDurationSeconds': _quoteCacheDurationSeconds, + 'enableQuoteCache': _enableQuoteCache, + 'currencyFallbacks': fallbacksJson, + }; + } +} \ No newline at end of file diff --git a/lib/buy/currency_fallback_handler.dart b/lib/buy/currency_fallback_handler.dart new file mode 100644 index 0000000000..19084b1a87 --- /dev/null +++ b/lib/buy/currency_fallback_handler.dart @@ -0,0 +1,142 @@ +import 'package:cw_core/crypto_currency.dart'; +import 'package:cake_wallet/entities/fiat_currency.dart'; +import 'package:cake_wallet/core/logger_service.dart'; +import 'package:cake_wallet/buy/buy_provider.dart'; +import 'package:cake_wallet/buy/buy_provider_config.dart'; +import 'package:cake_wallet/buy/buy_quote.dart'; +import 'package:cake_wallet/buy/payment_method.dart'; + +class CurrencyFallbackHandler { + final BuyProviderConfig _config = BuyProviderConfig(); + + final List providers; + final CryptoCurrency cryptoCurrency; + final double amount; + final String walletAddress; + final bool isBuyAction; + final PaymentMethod? paymentMethod; + + CurrencyFallbackHandler({ + required this.providers, + required this.cryptoCurrency, + required this.amount, + required this.walletAddress, + required this.isBuyAction, + this.paymentMethod, + }); + + Future tryAllConfiguredFallbacks(FiatCurrency fromCurrency) async { + final fallbackCurrencies = _config.getFallbackCurrencies(fromCurrency); + + if (fallbackCurrencies.isEmpty) { + LoggerService.debug('No fallback currencies configured for $fromCurrency', tag: 'CurrencyFallback'); + return null; + } + + for (final fallbackCurrency in fallbackCurrencies) { + LoggerService.info('Trying fallback from $fromCurrency to $fallbackCurrency', tag: 'CurrencyFallback'); + + final result = await tryFallbackCurrency( + fromCurrency: fromCurrency, + toCurrency: fallbackCurrency, + ); + + if (result != null) { + return result; + } + } + + LoggerService.info('All fallback currencies failed for $fromCurrency', tag: 'CurrencyFallback'); + return null; + } + + Future tryFallbackCurrency({ + required FiatCurrency fromCurrency, + required FiatCurrency toCurrency, + }) async { + LoggerService.info('Attempting currency fallback from $fromCurrency to $toCurrency for $cryptoCurrency', tag: 'CurrencyFallback'); + + final eligibleProviders = _getEligibleProviders(toCurrency); + + if (eligibleProviders.isEmpty) { + LoggerService.debug('No eligible providers found for $toCurrency', tag: 'CurrencyFallback'); + return null; + } + + final quotes = await _fetchQuotes(eligibleProviders, toCurrency); + + if (quotes.isEmpty) { + LoggerService.debug('No valid quotes received for $toCurrency', tag: 'CurrencyFallback'); + return null; + } + + LoggerService.info('Successfully obtained ${quotes.length} quotes for $toCurrency', tag: 'CurrencyFallback'); + return CurrencyFallbackResult( + currency: toCurrency, + quotes: quotes, + message: 'Automatically switched from $fromCurrency to $toCurrency for better provider support', + ); + } + + List _getEligibleProviders(FiatCurrency targetCurrency) { + return providers.where((provider) { + if (isBuyAction) { + return provider.supportedCryptoList.any((pair) => + pair.from == cryptoCurrency && pair.to == targetCurrency); + } else { + return provider.supportedFiatList.any((pair) => + pair.from == targetCurrency && pair.to == cryptoCurrency); + } + }).toList(); + } + + Future> _fetchQuotes( + List providers, + FiatCurrency currency, + ) async { + final quotesFutures = providers.map((provider) => + _fetchQuoteWithTimeout(provider, currency) + ); + + final results = await Future.wait(quotesFutures); + + return results + .where((quotes) => quotes != null && quotes.isNotEmpty) + .expand((quotes) => quotes!) + .toList(); + } + + Future?> _fetchQuoteWithTimeout( + BuyProvider provider, + FiatCurrency currency, + ) async { + try { + return await provider + .fetchQuote( + cryptoCurrency: cryptoCurrency, + fiatCurrency: currency, + amount: amount, + paymentType: paymentMethod?.paymentMethodType, + isBuyAction: isBuyAction, + walletAddress: walletAddress, + customPaymentMethodType: paymentMethod?.customPaymentMethodType, + ) + .timeout(_config.quoteTimeout, onTimeout: () => null); + } catch (e) { + LoggerService.warning('Error fetching quote from ${provider.title}', error: e, tag: 'CurrencyFallback'); + return null; + } + } +} + +class CurrencyFallbackResult { + final FiatCurrency currency; + final List quotes; + final String message; + + CurrencyFallbackResult({ + required this.currency, + required this.quotes, + required this.message, + }); +} \ No newline at end of file diff --git a/lib/buy/dfx/dfx_authentication_service.dart b/lib/buy/dfx/dfx_authentication_service.dart new file mode 100644 index 0000000000..1fc547b450 --- /dev/null +++ b/lib/buy/dfx/dfx_authentication_service.dart @@ -0,0 +1,143 @@ +import 'dart:convert'; +import 'package:cake_wallet/buy/dfx/dfx_signature_validator.dart'; +import 'package:cake_wallet/core/logger_service.dart'; +import 'package:cw_core/wallet_base.dart'; +import 'package:cw_core/wallet_type.dart'; + +class DfxAuthenticationService { + static const _signaturePrefix = '\x18Bitcoin Signed Message:\n'; + + Future authenticate({ + required WalletBase wallet, + required String walletAddress, + required String message, + }) async { + final walletType = wallet.type; + + if (!_isWalletSupported(walletType)) { + throw UnsupportedWalletException(walletType); + } + + try { + final signature = await _signMessage(wallet, message, walletAddress); + final formattedSignature = _formatSignature(signature, walletType); + + // Validate the signature structure + final validationResult = DfxSignatureValidator.validateStructure( + signature: formattedSignature, + walletType: walletType, + ); + + if (!validationResult.isValid) { + LoggerService.error('Invalid signature structure: ${validationResult.error}', tag: 'DFX'); + throw AuthenticationException('Invalid signature: ${validationResult.error}'); + } + + // Signature validation successful + return formattedSignature; + } catch (e) { + if (e is AuthenticationException) rethrow; + LoggerService.error('DFX Authentication failed for ${walletType}', error: e, tag: 'DFX'); + throw AuthenticationException('Failed to authenticate: $e'); + } + } + + bool _isWalletSupported(WalletType type) { + return const [ + WalletType.ethereum, + WalletType.polygon, + WalletType.litecoin, + WalletType.bitcoin, + WalletType.bitcoinCash, + WalletType.zano, + ].contains(type); + } + + Future _signMessage( + WalletBase wallet, + String message, + String walletAddress, + ) async { + // Some wallets don't need address parameter, others do + switch (wallet.type) { + case WalletType.ethereum: + case WalletType.polygon: + case WalletType.solana: + case WalletType.tron: + return await wallet.signMessage(message); + case WalletType.litecoin: + case WalletType.bitcoin: + case WalletType.bitcoinCash: + case WalletType.zano: + return await wallet.signMessage(message, address: walletAddress); + default: + return await wallet.signMessage(message, address: walletAddress); + } + } + + String _formatSignature(String signature, WalletType walletType) { + switch (walletType) { + case WalletType.ethereum: + case WalletType.polygon: + return signature.startsWith('0x') ? signature : '0x$signature'; + case WalletType.litecoin: + case WalletType.bitcoin: + case WalletType.bitcoinCash: + return _formatBitcoinSignature(signature); + case WalletType.zano: + return signature; + default: + return signature; + } + } + + String _formatBitcoinSignature(String signature) { + try { + final bytes = base64.decode(signature); + final hexString = bytes.sublist(1).map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); + return '$hexString${bytes[0].toRadixString(16).padLeft(2, '0')}'; + } catch (e) { + LoggerService.warning('Failed to format Bitcoin signature', error: e, tag: 'DFX'); + return signature; + } + } + + String getBlockchainName(WalletType walletType) { + switch (walletType) { + case WalletType.ethereum: + return 'Ethereum'; + case WalletType.polygon: + return 'Polygon'; + case WalletType.bitcoinCash: + case WalletType.litecoin: + case WalletType.bitcoin: + return 'Bitcoin'; + case WalletType.zano: + return 'Zano'; + default: + return walletTypeToString(walletType); + } + } +} + +class UnsupportedWalletException implements Exception { + final WalletType walletType; + + UnsupportedWalletException(this.walletType); + + @override + String toString() => 'WalletType ${walletType} is not supported for DFX'; +} + +class AuthenticationException implements Exception { + final String message; + + AuthenticationException(this.message); + + @override + String toString() => message; +} + +String walletTypeToString(WalletType type) { + return type.toString().split('.').last; +} \ No newline at end of file diff --git a/lib/buy/dfx/dfx_buy_provider.dart b/lib/buy/dfx/dfx_buy_provider.dart index bef250f05a..a673ed5c16 100644 --- a/lib/buy/dfx/dfx_buy_provider.dart +++ b/lib/buy/dfx/dfx_buy_provider.dart @@ -1,8 +1,9 @@ import 'dart:convert'; -import 'dart:developer'; import 'package:cake_wallet/buy/buy_provider.dart'; import 'package:cake_wallet/buy/buy_quote.dart'; +import 'package:cake_wallet/buy/dfx/dfx_authentication_service.dart'; +import 'package:cake_wallet/core/logger_service.dart'; import 'package:cake_wallet/buy/pairs_utils.dart'; import 'package:cake_wallet/buy/payment_method.dart'; import 'package:cake_wallet/entities/fiat_currency.dart'; @@ -60,14 +61,8 @@ class DFXBuyProvider extends BuyProvider { bool get isAggregator => false; String get blockchain { - switch (wallet.type) { - case WalletType.bitcoin: - case WalletType.bitcoinCash: - case WalletType.litecoin: - return 'Bitcoin'; - default: - return walletTypeToString(wallet.type); - } + final authService = DfxAuthenticationService(); + return authService.getBlockchainName(wallet.type); } @@ -120,21 +115,12 @@ class DFXBuyProvider extends BuyProvider { } Future getSignature(String message, String walletAddress) async { - switch (wallet.type) { - case WalletType.ethereum: - case WalletType.polygon: - case WalletType.solana: - case WalletType.tron: - final r = await wallet.signMessage(message); - return r; - case WalletType.monero: - case WalletType.litecoin: - case WalletType.bitcoin: - case WalletType.bitcoinCash: - return await wallet.signMessage(message, address: walletAddress); - default: - throw Exception("WalletType is not available for DFX ${wallet.type}"); - } + final authService = DfxAuthenticationService(); + return await authService.authenticate( + wallet: wallet, + walletAddress: walletAddress, + message: message, + ); } Future> fetchFiatCredentials(String fiatCurrency) async { @@ -150,44 +136,51 @@ class DFXBuyProvider extends BuyProvider { for (final item in data) { if (item['name'] == fiatCurrency) return item as Map; } - log('DFX does not support fiat: $fiatCurrency'); + LoggerService.warning('DFX does not support fiat: $fiatCurrency', tag: 'DFX'); return {}; } else { - log('DFX Failed to fetch fiat currencies: ${response.statusCode}'); + LoggerService.warning('DFX Failed to fetch fiat currencies: ${response.statusCode}', tag: 'DFX'); return {}; } } catch (e) { - printV('DFX Error fetching fiat currencies: $e'); + LoggerService.error('Error fetching fiat currencies', error: e, tag: 'DFX'); return {}; } } Future> fetchAssetCredential(String assetsName) async { final url = Uri.https(_baseUrl, '/v1/asset', {'blockchains': blockchain}); + LoggerService.debug('Fetching asset credential for: $assetsName, blockchain: $blockchain, URL: $url', tag: 'DFX'); try { final response = await ProxyWrapper().get(clearnetUri: url, headers: {'accept': 'application/json'}); if (response.statusCode == 200) { final responseData = jsonDecode(response.body); + LoggerService.debug('Asset API response: $responseData', tag: 'DFX'); if (responseData is List && responseData.isNotEmpty) { + LoggerService.debug('Found ${responseData.length} assets', tag: 'DFX'); for (final i in responseData) { + LoggerService.debug('Checking asset: ${i["dexName"]} (buyable: ${i["buyable"]}, sellable: ${i["sellable"]})', tag: 'DFX'); if (assetsName.toLowerCase() == i["dexName"].toString().toLowerCase()) { + LoggerService.debug('Matched asset: $i', tag: 'DFX'); return i as Map; } } + LoggerService.debug('Asset not found, returning first available: ${responseData.first}', tag: 'DFX'); return responseData.first as Map; } else if (responseData is Map) { + LoggerService.debug('Single asset response: $responseData', tag: 'DFX'); return responseData; } else { - log('DFX: Does not support this asset name : ${blockchain}'); + LoggerService.debug('Does not support this asset name : ${blockchain}', tag: 'DFX'); } } else { - log('DFX: Failed to fetch assets: ${response.statusCode}'); + LoggerService.debug('Failed to fetch assets: ${response.statusCode}, body: ${response.body}', tag: 'DFX'); } } catch (e) { - log('DFX: Error fetching assets: $e'); + LoggerService.debug('Error fetching assets: $e', tag: 'DFX'); } return {}; } @@ -265,8 +258,17 @@ class DFXBuyProvider extends BuyProvider { final assetCredentials = await fetchAssetCredential(cryptoCurrency.title.toString()); if (assetCredentials['id'] == null) return null; + + // Check if asset is buyable/sellable for this action + final actionKey = isBuyAction ? 'buyable' : 'sellable'; + if (assetCredentials[actionKey] != true) { + LoggerService.debug('Asset ${cryptoCurrency.title} is not ${actionKey} (${actionKey}: ${assetCredentials[actionKey]})', tag: 'DFX'); + return null; + } - log('DFX: Fetching $action quote: ${isBuyAction ? cryptoCurrency : fiatCurrency} -> ${isBuyAction ? fiatCurrency : cryptoCurrency}, amount: $amount, paymentMethod: $paymentMethod'); + LoggerService.debug('Fetching $action quote: ${isBuyAction ? cryptoCurrency : fiatCurrency} -> ${isBuyAction ? fiatCurrency : cryptoCurrency}, amount: $amount, paymentMethod: $paymentMethod', tag: 'DFX'); + LoggerService.debug('FiatCredentials: $fiatCredentials', tag: 'DFX'); + LoggerService.debug('AssetCredentials: $assetCredentials', tag: 'DFX'); final url = Uri.https(_baseUrl, '/v1/$action/quote'); final headers = {'accept': 'application/json', 'Content-Type': 'application/json'}; @@ -278,6 +280,8 @@ class DFXBuyProvider extends BuyProvider { 'paymentMethod': paymentMethod, 'discountCode': '' }); + LoggerService.debug('Request URL: $url', tag: 'DFX'); + LoggerService.debug('Request body: $body', tag: 'DFX'); try { final response = await ProxyWrapper().put( @@ -296,19 +300,21 @@ class DFXBuyProvider extends BuyProvider { quote.setCryptoCurrency = cryptoCurrency; return [quote]; } else { - printV('DFX: Unexpected data type: ${responseData.runtimeType}'); + LoggerService.warning('Unexpected data type: ${responseData.runtimeType}', tag: 'DFX'); return null; } } else { + LoggerService.debug('HTTP Status Code: ${response.statusCode}', tag: 'DFX'); + LoggerService.debug('Response body: ${response.body}', tag: 'DFX'); if (responseData is Map && responseData.containsKey('message')) { - printV('DFX Error: ${responseData['message']}'); + LoggerService.warning('Error: ${responseData['message']}', tag: 'DFX'); } else { - printV('DFX Failed to fetch buy quote: ${response.statusCode}'); + LoggerService.warning('Failed to fetch buy quote: ${response.statusCode}', tag: 'DFX'); } return null; } } catch (e) { - printV('DFX Error fetching buy quote: $e'); + LoggerService.error('Error fetching buy quote', error: e, tag: 'DFX'); return null; } } diff --git a/lib/buy/dfx/dfx_signature_validator.dart b/lib/buy/dfx/dfx_signature_validator.dart new file mode 100644 index 0000000000..960bd9b128 --- /dev/null +++ b/lib/buy/dfx/dfx_signature_validator.dart @@ -0,0 +1,171 @@ +import 'dart:convert'; +import 'package:cake_wallet/core/logger_service.dart'; +import 'package:cw_core/wallet_type.dart'; + +/// Validates signatures for DFX authentication +class DfxSignatureValidator { + /// Validates that a signature is properly formatted and non-empty + static bool validateSignature({ + required String signature, + required WalletType walletType, + required String originalMessage, + }) { + try { + // Basic validation - signature should not be empty + if (signature.isEmpty) { + LoggerService.warning('Empty signature received', tag: 'DFX'); + return false; + } + + // Wallet-specific validation + switch (walletType) { + case WalletType.ethereum: + case WalletType.polygon: + return _validateEthereumSignature(signature); + + case WalletType.bitcoin: + case WalletType.litecoin: + case WalletType.bitcoinCash: + return _validateBitcoinSignature(signature); + + case WalletType.zano: + return _validateZanoSignature(signature); + + default: + LoggerService.debug('No specific validation for ${walletType}', tag: 'DFX'); + return signature.length > 10; // Basic length check + } + } catch (e) { + LoggerService.error('Signature validation failed', error: e, tag: 'DFX'); + return false; + } + } + + static bool _validateEthereumSignature(String signature) { + // Ethereum signatures should be hex strings (with or without 0x prefix) + // Standard length is 132 characters (0x + 130 hex chars) + final cleanSig = signature.startsWith('0x') ? signature.substring(2) : signature; + + // Check if it's a valid hex string + if (!RegExp(r'^[0-9a-fA-F]+$').hasMatch(cleanSig)) { + LoggerService.warning('Invalid hex format in Ethereum signature', tag: 'DFX'); + return false; + } + + // Standard Ethereum signature is 65 bytes = 130 hex characters + if (cleanSig.length != 130) { + LoggerService.warning('Invalid Ethereum signature length: ${cleanSig.length}', tag: 'DFX'); + return false; + } + + return true; + } + + static bool _validateBitcoinSignature(String signature) { + // Bitcoin signatures can be in different formats + // Base64 format check + if (_isBase64(signature)) { + // Typical Bitcoin signature in base64 is around 88-90 characters + if (signature.length < 85 || signature.length > 95) { + LoggerService.warning('Unusual Bitcoin base64 signature length: ${signature.length}', tag: 'DFX'); + // Still allow it, but log warning + } + return true; + } + + // Hex format check (after our conversion) + if (RegExp(r'^[0-9a-fA-F]+$').hasMatch(signature)) { + // Bitcoin signatures are typically 64-72 bytes = 128-144 hex characters + if (signature.length < 128 || signature.length > 146) { + LoggerService.warning('Invalid Bitcoin hex signature length: ${signature.length}', tag: 'DFX'); + return false; + } + return true; + } + + LoggerService.warning('Bitcoin signature format not recognized', tag: 'DFX'); + return false; + } + + static bool _validateZanoSignature(String signature) { + // Zano signatures are typically hex strings + // Check if it's a valid hex string + if (!RegExp(r'^[0-9a-fA-F]+$').hasMatch(signature)) { + LoggerService.warning('Invalid hex format in Zano signature', tag: 'DFX'); + return false; + } + + // Zano signatures should be 64 bytes = 128 hex characters + if (signature.length != 128) { + LoggerService.warning('Invalid Zano signature length: ${signature.length}', tag: 'DFX'); + return false; + } + + return true; + } + + static bool _isBase64(String str) { + try { + base64.decode(str); + return true; + } catch (e) { + return false; + } + } + + /// Validates signature structure without cryptographic verification + /// Returns detailed validation result + static SignatureValidationResult validateStructure({ + required String signature, + required WalletType walletType, + }) { + if (signature.isEmpty) { + return SignatureValidationResult( + isValid: false, + error: 'Signature is empty', + ); + } + + try { + final isValid = validateSignature( + signature: signature, + walletType: walletType, + originalMessage: '', // Not needed for structure validation + ); + + return SignatureValidationResult( + isValid: isValid, + walletType: walletType, + signatureLength: signature.length, + ); + } catch (e) { + return SignatureValidationResult( + isValid: false, + error: e.toString(), + ); + } + } +} + +class SignatureValidationResult { + final bool isValid; + final String? error; + final WalletType? walletType; + final int? signatureLength; + + SignatureValidationResult({ + required this.isValid, + this.error, + this.walletType, + this.signatureLength, + }); + + @override + String toString() { + if (isValid) { + return 'Valid signature for $walletType (length: $signatureLength)'; + } else { + return 'Invalid signature: $error'; + } + } +} \ No newline at end of file diff --git a/lib/buy/provider_wallet_address_manager.dart b/lib/buy/provider_wallet_address_manager.dart new file mode 100644 index 0000000000..c47894c173 --- /dev/null +++ b/lib/buy/provider_wallet_address_manager.dart @@ -0,0 +1,45 @@ +import 'package:cake_wallet/buy/buy_provider.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/wallet_base.dart'; + +/// Manages wallet address requirements for different buy/sell providers +class ProviderWalletAddressManager { + final WalletBase wallet; + final CryptoCurrency cryptoCurrency; + final BuyProvider? provider; + + ProviderWalletAddressManager({ + required this.wallet, + required this.cryptoCurrency, + this.provider, + }); + + /// Returns the appropriate wallet address based on provider requirements + String getWalletAddress() { + // DFX always needs wallet address for authentication + if (_isDfxProvider()) { + return wallet.walletAddresses.address; + } + + // For other providers, only provide address when buying the wallet's native currency + if (cryptoCurrency == wallet.currency) { + return wallet.walletAddresses.address; + } + + // Return empty for cross-currency purchases on non-DFX providers + return ''; + } + + bool _isDfxProvider() { + // Check by type name for more reliable detection + final currentProvider = provider; + if (currentProvider == null) return false; + final providerType = currentProvider.runtimeType.toString().toLowerCase(); + return providerType.contains('dfx') || (currentProvider.title?.toLowerCase().contains('dfx') ?? false); + } + + /// Checks if wallet address is required for the current transaction + bool isWalletAddressRequired() { + return _isDfxProvider() || cryptoCurrency == wallet.currency; + } +} \ No newline at end of file diff --git a/lib/core/logger_service.dart b/lib/core/logger_service.dart new file mode 100644 index 0000000000..ccdd33235d --- /dev/null +++ b/lib/core/logger_service.dart @@ -0,0 +1,128 @@ +import 'dart:developer' as developer; +import 'package:cw_core/utils/print_verbose.dart'; + +enum LogLevel { + debug, + info, + warning, + error, +} + +class LoggerService { + static const String _defaultTag = 'CakeWallet'; + static bool _useVerbose = true; + static LogLevel _minLevel = LogLevel.debug; + static final List _logBuffer = []; + static const int _maxBufferSize = 1000; + + static void configure({ + bool useVerbose = true, + LogLevel minLevel = LogLevel.debug, + }) { + _useVerbose = useVerbose; + _minLevel = minLevel; + } + + static void debug(String message, {String? tag}) { + _log(LogLevel.debug, message, tag: tag); + } + + static void info(String message, {String? tag}) { + _log(LogLevel.info, message, tag: tag); + } + + static void warning(String message, {Object? error, String? tag}) { + _log(LogLevel.warning, message, error: error, tag: tag); + } + + static void error(String message, {Object? error, StackTrace? stackTrace, String? tag}) { + _log(LogLevel.error, message, error: error, stackTrace: stackTrace, tag: tag); + } + + static void _log( + LogLevel level, + String message, { + String? tag, + Object? error, + StackTrace? stackTrace, + }) { + if (level.index < _minLevel.index) return; + + final logTag = tag ?? _defaultTag; + final prefix = _getPrefix(level); + final fullMessage = '$prefix$message'; + final timestamp = DateTime.now().toIso8601String(); + + // Add to buffer for potential debugging + _addToBuffer('$timestamp [$logTag] $fullMessage'); + + if (_useVerbose) { + // Use existing printV for backwards compatibility + printV('[$logTag] $fullMessage'); + if (error != null) { + printV('[$logTag] Error: $error'); + } + if (stackTrace != null && level == LogLevel.error) { + printV('[$logTag] StackTrace: $stackTrace'); + } + } else { + // Use dart:developer log for production + developer.log( + fullMessage, + name: logTag, + level: _getLevelValue(level), + error: error, + stackTrace: stackTrace, + ); + } + } + + static void _addToBuffer(String logEntry) { + if (_logBuffer.length >= _maxBufferSize) { + _logBuffer.removeAt(0); // Remove oldest entry + } + _logBuffer.add(logEntry); + } + + /// Get recent logs for debugging + static List getRecentLogs({int? limit}) { + final count = limit ?? _logBuffer.length; + if (count >= _logBuffer.length) { + return List.from(_logBuffer); + } + return _logBuffer.sublist(_logBuffer.length - count); + } + + /// Clear all buffered logs and reset configuration + static void dispose() { + _logBuffer.clear(); + _useVerbose = true; + _minLevel = LogLevel.debug; + } + + static String _getPrefix(LogLevel level) { + switch (level) { + case LogLevel.debug: + return '[DEBUG] '; + case LogLevel.info: + return '[INFO] '; + case LogLevel.warning: + return '[WARN] '; + case LogLevel.error: + return '[ERROR] '; + } + } + + static int _getLevelValue(LogLevel level) { + switch (level) { + case LogLevel.debug: + return 500; + case LogLevel.info: + return 800; + case LogLevel.warning: + return 900; + case LogLevel.error: + return 1000; + } + } +} \ No newline at end of file diff --git a/lib/core/resource_manager.dart b/lib/core/resource_manager.dart new file mode 100644 index 0000000000..1d74a5ccf4 --- /dev/null +++ b/lib/core/resource_manager.dart @@ -0,0 +1,103 @@ +import 'package:cake_wallet/buy/buy_provider_config.dart'; +import 'package:cake_wallet/core/logger_service.dart'; +import 'package:cw_core/utils/print_verbose.dart'; + +/// Central resource management for cleanup and disposal +class ResourceManager { + static final ResourceManager _instance = ResourceManager._internal(); + factory ResourceManager() => _instance; + ResourceManager._internal(); + + final List _cleanupCallbacks = []; + bool _isDisposed = false; + + /// Register a cleanup callback + void registerCleanup(Function() callback) { + if (!_isDisposed) { + _cleanupCallbacks.add(callback); + } + } + + /// Unregister a cleanup callback + void unregisterCleanup(Function() callback) { + _cleanupCallbacks.remove(callback); + } + + /// Perform all cleanup operations + void disposeAll() { + if (_isDisposed) return; + + try { + // Dispose singletons + BuyProviderConfig.dispose(); + LoggerService.dispose(); + + // Call all registered cleanup callbacks + for (final callback in _cleanupCallbacks) { + try { + callback(); + } catch (e) { + printV('Error during cleanup: $e'); + } + } + + _cleanupCallbacks.clear(); + _isDisposed = true; + } catch (e) { + printV('Error during resource disposal: $e'); + } + } + + /// Reset the manager (mainly for testing) + void reset() { + _cleanupCallbacks.clear(); + _isDisposed = false; + } + + /// Check if resources have been disposed + bool get isDisposed => _isDisposed; + + /// Perform cleanup on app lifecycle events + void handleAppLifecycleChange(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.detached: + case AppLifecycleState.paused: + // Optionally clear some caches when app is paused + _performPartialCleanup(); + break; + case AppLifecycleState.resumed: + // Re-initialize if needed + if (_isDisposed) { + reset(); + } + break; + default: + break; + } + } + + void _performPartialCleanup() { + // Clear non-essential caches but keep singletons + try { + // Clear old logs to free memory + final recentLogs = LoggerService.getRecentLogs(limit: 100); + LoggerService.dispose(); + LoggerService.configure(); // Reconfigure with empty buffer + + // Log that we kept recent logs for debugging + for (final log in recentLogs) { + LoggerService.debug('Restored: $log', tag: 'ResourceManager'); + } + } catch (e) { + printV('Error during partial cleanup: $e'); + } + } +} + +/// Enum for app lifecycle states +enum AppLifecycleState { + resumed, + inactive, + paused, + detached, +} \ No newline at end of file diff --git a/lib/view_model/buy/buy_sell_view_model.dart b/lib/view_model/buy/buy_sell_view_model.dart index 1874fe39ef..928d55d79b 100644 --- a/lib/view_model/buy/buy_sell_view_model.dart +++ b/lib/view_model/buy/buy_sell_view_model.dart @@ -2,8 +2,10 @@ import 'dart:async'; import 'package:cake_wallet/buy/buy_provider.dart'; import 'package:cake_wallet/buy/buy_quote.dart'; +import 'package:cake_wallet/buy/currency_fallback_handler.dart'; import 'package:cake_wallet/buy/onramper/onramper_buy_provider.dart'; import 'package:cake_wallet/buy/payment_method.dart'; +import 'package:cake_wallet/buy/provider_wallet_address_manager.dart'; import 'package:cake_wallet/buy/sell_buy_states.dart'; import 'package:cake_wallet/core/selectable_option.dart'; import 'package:cake_wallet/core/wallet_change_listener_view_model.dart'; @@ -126,6 +128,9 @@ abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with S @observable String fiatAmount; + + @observable + String? currencyChangeMessage; @observable String cryptoCurrencyAddress; @@ -166,6 +171,7 @@ abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with S cryptoCurrency = wallet.currency; fiatCurrency = _appStore.settingsStore.fiatCurrency; isCryptoCurrencyAddressEnabled = !(cryptoCurrency == wallet.currency); + currencyChangeMessage = null; _initialize(); } @@ -345,9 +351,15 @@ abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with S } String _getInitialCryptoCurrencyAddress() { - return cryptoCurrency == wallet.currency ? wallet.walletAddresses.address : ''; + final addressManager = ProviderWalletAddressManager( + wallet: wallet, + cryptoCurrency: cryptoCurrency, + // Provider will be determined at quote time + ); + return addressManager.getWalletAddress(); } + @action Future _getAvailablePaymentTypes() async { paymentMethodState = PaymentMethodLoading(); @@ -387,16 +399,19 @@ abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with S } @action - Future calculateBestRate() async { + Future calculateBestRate({FiatCurrency? overrideFiatCurrency}) async { buySellQuotState = BuySellQuotLoading(); + + // Use override currency if provided (for fallback), otherwise use the selected one + final effectiveFiatCurrency = overrideFiatCurrency ?? fiatCurrency; final List validProviders = providerList.where((provider) { if (isBuyAction) { return provider.supportedCryptoList.any((pair) => - pair.from == cryptoCurrency && pair.to == fiatCurrency); + pair.from == cryptoCurrency && pair.to == effectiveFiatCurrency); } else { return provider.supportedFiatList.any((pair) => - pair.from == fiatCurrency && pair.to == cryptoCurrency); + pair.from == effectiveFiatCurrency && pair.to == cryptoCurrency); } }).toList(); @@ -408,7 +423,7 @@ abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with S final result = await Future.wait?>(validProviders.map((element) => element .fetchQuote( cryptoCurrency: cryptoCurrency, - fiatCurrency: fiatCurrency, + fiatCurrency: effectiveFiatCurrency, amount: amount, paymentType: selectedPaymentMethod?.paymentMethodType, isBuyAction: isBuyAction, @@ -429,6 +444,32 @@ abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with S .toList(); if (validQuotes.isEmpty) { + // Try currency fallback if no quotes found (only if not already in a fallback attempt) + if (overrideFiatCurrency == null) { // We're not already in a fallback + final fallbackHandler = CurrencyFallbackHandler( + providers: providerList, + cryptoCurrency: cryptoCurrency, + amount: amount, + walletAddress: wallet.walletAddresses.address, + isBuyAction: isBuyAction, + paymentMethod: selectedPaymentMethod, + ); + + final fallbackResult = await fallbackHandler.tryAllConfiguredFallbacks(effectiveFiatCurrency); + + if (fallbackResult != null) { + // Show message to user about currency switch + currencyChangeMessage = fallbackResult.message; + + // Update the actual fiat currency to reflect the fallback + fiatCurrency = fallbackResult.currency; + + // Recursively call with the fallback currency + await calculateBestRate(overrideFiatCurrency: fallbackResult.currency); + return; + } + } + buySellQuotState = BuySellQuotFailed(); return; } @@ -488,4 +529,23 @@ abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with S cryptoCurrencyAddress: cryptoCurrencyAddress, ); } + + /// Dispose method to clean up resources + void dispose() { + // Clear observables + sortedRecommendedQuotes.clear(); + sortedQuotes.clear(); + paymentMethods.clear(); + providerList.clear(); + + // Reset state + selectedQuote = null; + bestRateQuote = null; + selectedPaymentMethod = null; + currencyChangeMessage = null; + + // Reset to initial states + paymentMethodState = InitialPaymentMethod(); + buySellQuotState = InitialBuySellQuotState(); + } }