From c51c2dc1057bb55f4f862743793d23123f5b8294 Mon Sep 17 00:00:00 2001 From: David Adegoke <64401859+Blazebrain@users.noreply.github.com> Date: Wed, 24 Sep 2025 18:24:52 +0100 Subject: [PATCH] Pay Anything Enhancements (#2529) * fix: update Bitcoin and Litecoin Bech32 address patterns for improved detection for pay anything flow * feat: add Litecoin MWEB address detection patterns for pay anything flow * feat: Add support for ERC-681 payment scheme to QR and link to exchange send from external QR * feat: Introduce TokenUtilities to centralize token management across viewmodels - Added TokenUtilities class to centralize token-related operations for EVM, Solana, and Tron tokens. - Refactored existing methods in ExchangeTradeViewModel and SendViewModel to utilize TokenUtilities. - Updated PaymentRequest to include contractAddress. - Enhanced payment QR scanning to fetch token details for QRs with contract addresses - ERC-681 QR scheme * fix: Prefill amount with other data when app is brought up via payment deeplink on QR scan * fix: Reduce trade not created errors WIP * feat: Add exchange provider logging functionality * feat: Enhance ERC681URI with amount normalization, prevents parsing issues with various input formats for amounts * - Use regex from address validator for btc/ltc manual checks - Add check for solana addresses - Handle failures in normalizeToInteger method - fix issue with address only being added when there's an amount * wrap in try/catch just in case [skip ci] * Update lib/utils/token_utilities.dart --------- Co-authored-by: Omar Hatem --- lib/core/payment_uris.dart | 410 +++++++++++++ lib/core/universal_address_detector.dart | 48 +- lib/di.dart | 6 + .../provider/chainflip_exchange_provider.dart | 80 ++- .../provider/changenow_exchange_provider.dart | 40 +- .../provider/exolix_exchange_provider.dart | 125 +++- .../letsexchange_exchange_provider.dart | 116 +++- .../provider/sideshift_exchange_provider.dart | 130 +++- .../simpleswap_exchange_provider.dart | 124 +++- .../stealth_ex_exchange_provider.dart | 166 +++++- .../provider/swaptrade_exchange_provider.dart | 141 ++++- .../provider/thorchain_exchange.provider.dart | 80 ++- .../provider/trocador_exchange_provider.dart | 149 ++++- .../provider/xoswap_exchange_provider.dart | 143 ++++- lib/router.dart | 6 + lib/routes.dart | 1 + .../dev/exchange_provider_logs_page.dart | 562 ++++++++++++++++++ .../exchange_trade_external_send_page.dart | 6 +- .../screens/receive/widgets/qr_widget.dart | 1 + lib/src/screens/send/widgets/send_card.dart | 12 +- .../screens/settings/other_settings_page.dart | 6 + lib/utils/exchange_provider_logger.dart | 189 ++++++ lib/utils/payment_request.dart | 60 +- lib/utils/token_utilities.dart | 226 +++++++ .../exchange_provider_logs_view_model.dart | 84 +++ .../exchange/exchange_trade_view_model.dart | 76 +++ .../exchange/exchange_view_model.dart | 146 ++--- lib/view_model/send/send_view_model.dart | 36 +- .../wallet_address_list_view_model.dart | 223 +------ 29 files changed, 2919 insertions(+), 473 deletions(-) create mode 100644 lib/core/payment_uris.dart create mode 100644 lib/src/screens/dev/exchange_provider_logs_page.dart create mode 100644 lib/utils/exchange_provider_logger.dart create mode 100644 lib/utils/token_utilities.dart create mode 100644 lib/view_model/dev/exchange_provider_logs_view_model.dart diff --git a/lib/core/payment_uris.dart b/lib/core/payment_uris.dart new file mode 100644 index 000000000..8aa2616cf --- /dev/null +++ b/lib/core/payment_uris.dart @@ -0,0 +1,410 @@ +import 'package:cw_core/format_fixed.dart'; + +abstract class PaymentURI { + PaymentURI({required this.amount, required this.address}); + + final String amount; + final String address; +} + +class MoneroURI extends PaymentURI { + MoneroURI({required super.amount, required super.address}); + + @override + String toString() { + var base = 'monero:$address'; + + if (amount.isNotEmpty) { + base += '?tx_amount=${amount.replaceAll(',', '.')}'; + } + + return base; + } +} + +class HavenURI extends PaymentURI { + HavenURI({required super.amount, required super.address}); + + @override + String toString() { + var base = 'haven:$address'; + + if (amount.isNotEmpty) { + base += '?tx_amount=${amount.replaceAll(',', '.')}'; + } + + return base; + } +} + +class BitcoinURI extends PaymentURI { + BitcoinURI({required super.amount, required super.address, this.pjUri = ''}); + + final String pjUri; + + @override + String toString() { + final qp = {}; + + if (amount.isNotEmpty) qp['amount'] = amount.replaceAll(',', '.'); + if (pjUri.isNotEmpty && !address.startsWith("sp")) { + qp['pjos'] = '0'; + qp['pj'] = pjUri; + } + + return Uri(scheme: 'bitcoin', path: address, queryParameters: qp).toString(); + } +} + +class LitecoinURI extends PaymentURI { + LitecoinURI({required super.amount, required super.address}); + + @override + String toString() { + var base = 'litecoin:$address'; + + if (amount.isNotEmpty) { + base += '?amount=${amount.replaceAll(',', '.')}'; + } + + return base; + } +} + +class EthereumURI extends PaymentURI { + EthereumURI({required super.amount, required super.address}); + + @override + String toString() { + var base = 'ethereum:$address'; + + if (amount.isNotEmpty) { + base += '?amount=${amount.replaceAll(',', '.')}'; + } + + return base; + } +} + +class BitcoinCashURI extends PaymentURI { + BitcoinCashURI({required super.amount, required super.address}); + + @override + String toString() { + var base = address; + + if (amount.isNotEmpty) { + base += '?amount=${amount.replaceAll(',', '.')}'; + } + + return base; + } +} + +class NanoURI extends PaymentURI { + NanoURI({required super.amount, required super.address}); + + @override + String toString() { + var base = 'nano:$address'; + if (amount.isNotEmpty) { + base += '?amount=${amount.replaceAll(',', '.')}'; + } + + return base; + } +} + +class PolygonURI extends PaymentURI { + PolygonURI({required super.amount, required super.address}); + + @override + String toString() { + var base = 'polygon:$address'; + + if (amount.isNotEmpty) { + base += '?amount=${amount.replaceAll(',', '.')}'; + } + + return base; + } +} + +class SolanaURI extends PaymentURI { + SolanaURI({required super.amount, required super.address}); + + @override + String toString() { + var base = 'solana:$address'; + + if (amount.isNotEmpty) { + base += '?amount=${amount.replaceAll(',', '.')}'; + } + + return base; + } +} + +class TronURI extends PaymentURI { + TronURI({required super.amount, required super.address}); + + @override + String toString() { + var base = 'tron:$address'; + + if (amount.isNotEmpty) { + base += '?amount=${amount.replaceAll(',', '.')}'; + } + + return base; + } +} + +class WowneroURI extends PaymentURI { + WowneroURI({required super.amount, required super.address}); + + @override + String toString() { + var base = 'wownero:$address'; + + if (amount.isNotEmpty) { + base += '?tx_amount=${amount.replaceAll(',', '.')}'; + } + + return base; + } +} + +class ZanoURI extends PaymentURI { + ZanoURI({required String amount, required String address}) + : super(amount: amount, address: address); + + @override + String toString() { + var base = 'zano:' + address; + + if (amount.isNotEmpty) { + base += '?amount=${amount.replaceAll(',', '.')}'; + } + + return base; + } +} + +class DecredURI extends PaymentURI { + DecredURI({required String amount, required String address}) + : super(amount: amount, address: address); + + @override + String toString() { + var base = 'decred:' + address; + + if (amount.isNotEmpty) { + base += '?amount=${amount.replaceAll(',', '.')}'; + } + + return base; + } +} + +class DogeURI extends PaymentURI { + DogeURI({required String amount, required String address}) + : super(amount: amount, address: address); + + @override + String toString() { + var base = 'doge:' + address; + + if (amount.isNotEmpty) { + base += '?amount=${amount.replaceAll(',', '.')}'; + } + + return base; + } +} + +class ERC681URI extends PaymentURI { + final int chainId; + final String? contractAddress; + + ERC681URI({ + required this.chainId, + required super.address, + required super.amount, + required this.contractAddress, + }); + + @override + String toString() { + var uri = 'ethereum:'; + + final targetAddress = contractAddress ?? address; + uri += targetAddress; + + if (chainId != 1) { + uri += '@$chainId'; + } + + if (contractAddress != null) { + uri += '/transfer'; + } + + final params = {}; + + if (contractAddress != null) { + params['address'] = address; + if (amount.isNotEmpty) { + params['uint256'] = _formatAmountForERC20(amount); + } + } else { + if (amount.isNotEmpty) { + params['value'] = _formatAmountForNative(amount); + } + } + + if (params.isNotEmpty) { + uri += '?'; + uri += params.entries.map((e) => '${e.key}=${e.value}').join('&'); + } + + return uri; + } + + /// Formats amount for ERC-20 token transfers (in atomic units) + String _formatAmountForERC20(String amount) { + try { + // Convert decimal amount to BigInt (assuming 18 decimals) + final amountDouble = double.parse(amount.replaceAll(',', '.')); + final amountBigInt = BigInt.from(amountDouble * 1e18); + return amountBigInt.toString(); + } catch (e) { + // Fallback to original amount if parsing fails + return amount.replaceAll(',', '.'); + } + } + + /// Formats amount for native ETH payments (in wei using scientific notation) + String _formatAmountForNative(String amount) { + try { + // Convert decimal amount to double for scientific notation + final amountDouble = double.parse(amount.replaceAll(',', '.')); + + // Use scientific notation as recommended by ERC-681 + return '${amountDouble}e18'; + } catch (e) { + // Fallback to original amount if parsing fails + return amount.replaceAll(',', '.'); + } + } + + factory ERC681URI.fromUri(Uri uri) { + final (isContract, targetAddress) = _getTargetAddress(uri.path); + final chainId = _getChainID(uri.path); + + final address = isContract ? uri.queryParameters["address"] ?? '' : targetAddress; + final amountParam = isContract ? uri.queryParameters["uint256"] : uri.queryParameters["value"]; + + var formatedAmount = ""; + + if (amountParam != null) { + final normalized = _normalizeToIntegerWei(amountParam); + formatedAmount = formatFixed(BigInt.parse(normalized), 18); + } else { + formatedAmount = uri.queryParameters["amount"] ?? ""; + } + + return ERC681URI( + chainId: chainId, + address: address, + amount: formatedAmount, + contractAddress: isContract ? targetAddress : null, + ); + } + + static int _getChainID(String path) { + return int.parse(RegExp( + r'@\d*', + ).firstMatch(path)?.group(0)?.replaceAll("@", "") ?? + "1"); + } + + static (bool, String) _getTargetAddress(String path) { + final targetAddress = + RegExp(r'^(0x)?[0-9a-f]{40}', caseSensitive: false).firstMatch(path)!.group(0)!; + return (path.contains("/"), targetAddress); + } + + /// Normalize an input amount into an integer wei string. + /// + /// Accepts the following forms: + /// - Integer string: "123000000000000000" → unchanged + /// - Scientific notation: "0.123e18", "1e6" → expanded to integer + /// - Decimal ETH: "0.123456" → shifted by 18 decimals + static String _normalizeToIntegerWei(String input) { + final raw = input.replaceAll(',', '.').trim(); + + // First we check if it's already a plain integer (basically just a number with no dot, no exponent) + try { + final isPlainInteger = RegExp(r'^[+-]?\d+$').hasMatch(raw) && + !raw.contains('.') && + !raw.toLowerCase().contains('e'); + if (isPlainInteger) return raw.replaceFirst(RegExp(r'^\+'), ''); + + // Then we check if it's a scientific notation + final sci = RegExp(r'^[+-]?(\d+\.?\d*|\d*\.?\d+)[eE][+-]?\d+$'); + if (sci.hasMatch(raw)) { + final mantissaStr = raw.toLowerCase().split('e')[0]; + final exp = int.parse(raw.toLowerCase().split('e')[1]); + return _expandDecimal(mantissaStr, exp); + } + + // Lastly, we check if it's a fixed decimal ETH amount, here we shift by 18 to get wei for the amount + if (raw.contains('.')) { + return _expandDecimal(raw, 18); + } + return raw; + } catch (e) { + return raw; + } + + // If none of these checks work, we return the raw input + } + + /// Expands a decimal string by shifting the decimal point `expShift` places + /// to the right and returns an integer string (digits only, optional leading minus). + /// Examples: + /// _expandDecimal('0.123456', 18) -> '123456000000000000' + /// _expandDecimal('1.2', 3) -> '1200' + static String _expandDecimal(String decimalStr, int expShift) { + var s = decimalStr.trim(); + var sign = ''; + if (s.startsWith('-') || s.startsWith('+')) { + sign = s[0] == '-' ? '-' : ''; + s = s.substring(1); + } + + // First we split the integer and fractional parts + final parts = s.split('.'); + final intPart = parts[0].isEmpty ? '0' : parts[0]; + final fracPart = parts.length > 1 ? parts[1] : ''; + final digits = (intPart + fracPart).replaceFirst(RegExp(r'^0+'), ''); + final fracLen = fracPart.length; + + // Then we calculate the effective shift = desired shift minus existing fractional digits + final shift = expShift - fracLen; + if (shift >= 0) { + final head = digits.isEmpty ? '0' : digits; + final zeros = List.filled(shift, '0').join(); + final res = head + zeros; + return sign + (res.isEmpty ? '0' : res); + } else { + // Need to insert a decimal point within digits; return integer by truncating + final cut = digits.length + shift; + if (cut <= 0) { + return '0'; + } + final res = digits.substring(0, cut); + return sign + (res.isEmpty ? '0' : res); + } + } +} diff --git a/lib/core/universal_address_detector.dart b/lib/core/universal_address_detector.dart index 40c64cd16..129e7d313 100644 --- a/lib/core/universal_address_detector.dart +++ b/lib/core/universal_address_detector.dart @@ -1,4 +1,5 @@ import 'package:cake_wallet/utils/payment_request.dart'; +import 'package:cake_wallet/core/address_validator.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/wallet_type.dart'; @@ -104,15 +105,30 @@ class UniversalAddressDetector { currency: CryptoCurrency.btcln, ), - // Bitcoin Bech32 + // Bitcoin (P2PKH, P2SH, Bech32, Silent Payments) _DetectionPattern( - pattern: RegExp(r'^bc1[a-km-zA-HJ-NP-Z1-9]{25,39}$'), + pattern: RegExp('^(?:' + '1[a-km-zA-HJ-NP-Z1-9]{25,34}' + '|3[a-km-zA-HJ-NP-Z1-9]{25,34}' + '|(?:bc|tb)1q[ac-hj-np-z02-9]{25,39}' + '|(?:bc|tb)1p(?:[ac-hj-np-z02-9]{39}|[ac-hj-np-z02-9]{59}|[ac-hj-np-z02-9]{8,89})' + '|(?:bc|tb)1q[ac-hj-np-z02-9]{40,80}' + '|${AddressValidator.silentPaymentAddressPatternMainnet}' + '|${AddressValidator.silentPaymentAddressPatternTestnet}' + ')\$'), currency: CryptoCurrency.btc, ), - // Litecoin Bech32 + // Litecoin (Legacy, Bech32, MWEB) _DetectionPattern( - pattern: RegExp(r'^ltc1[a-z0-9]{25,50}$'), + pattern: RegExp('^(?:' + 'L[a-km-zA-HJ-NP-Z1-9]{25,34}' + '|[3M][a-km-zA-HJ-NP-Z1-9]{25,34}' + '|ltc1q[ac-hj-np-z02-9]{25,39}' + '|ltc1p(?:[ac-hj-np-z02-9]{39}|[ac-hj-np-z02-9]{59}|[ac-hj-np-z02-9]{8,89})' + '|ltc1q[ac-hj-np-z02-9]{40,80}' + '|${AddressValidator.mWebAddressPattern}' + ')\$'), currency: CryptoCurrency.ltc, ), @@ -182,31 +198,17 @@ class UniversalAddressDetector { currency: CryptoCurrency.dcr, ), - // Bitcoin P2PKH/P2SH (legacy formats) - _DetectionPattern( - pattern: RegExp(r'^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$'), - currency: CryptoCurrency.btc, - ), - - // Litecoin P2PKH/P2SH - _DetectionPattern( - pattern: RegExp(r'^(L|M|3)[a-km-zA-HJ-NP-Z1-9]{25,34}$'), - currency: CryptoCurrency.ltc, - ), - // Dogecoin P2PKH _DetectionPattern( pattern: RegExp(r'^D[a-km-zA-HJ-NP-Z1-9]{25,34}$'), currency: CryptoCurrency.doge, ), - // TODO: commented out until a better approach is implemented. - // as this will consider most addresses to be a valid Solana address - // Solana (Base58 format, 32-44 chars) - // _DetectionPattern( - // pattern: RegExp(r'^[1-9A-HJ-NP-Za-km-z]{32,44}$'), - // currency: CryptoCurrency.sol, - // ), + // Solana (Base58 format) + _DetectionPattern( + pattern: RegExp(r'^[1-9A-HJ-NP-Za-km-z]{43,44}$'), + currency: CryptoCurrency.sol, + ), ]; // Test each pattern in order of specificity diff --git a/lib/di.dart b/lib/di.dart index 8aadf9675..e00bc5c65 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -36,6 +36,7 @@ import 'package:cake_wallet/src/screens/dev/monero_background_sync.dart'; import 'package:cake_wallet/src/screens/dev/moneroc_cache_debug.dart'; import 'package:cake_wallet/src/screens/dev/moneroc_call_profiler.dart'; import 'package:cake_wallet/src/screens/dev/network_requests.dart'; +import 'package:cake_wallet/src/screens/dev/exchange_provider_logs_page.dart'; import 'package:cake_wallet/src/screens/dev/secure_preferences_page.dart'; import 'package:cake_wallet/src/screens/dev/shared_preferences_page.dart'; import 'package:cake_wallet/src/screens/integrations/deuro/savings_page.dart'; @@ -287,6 +288,7 @@ import 'package:cake_wallet/core/trade_monitor.dart'; import 'package:cake_wallet/core/reset_service.dart'; import 'package:cake_wallet/view_model/dev/socket_health_logs_view_model.dart'; import 'package:cake_wallet/src/screens/dev/socket_health_logs_page.dart'; +import 'package:cake_wallet/view_model/dev/exchange_provider_logs_view_model.dart'; import 'package:cake_wallet/view_model/payment/payment_view_model.dart'; import 'package:cake_wallet/view_model/wallet_switcher_view_model.dart'; @@ -818,6 +820,7 @@ Future setup({ coinTypeToSpendFrom: coinTypeToSpendFrom ?? UnspentCoinType.nonMweb, getIt.get(param1: coinTypeToSpendFrom), getIt.get(), + _walletInfoSource, ), ); @@ -1566,6 +1569,9 @@ Future setup({ getIt.registerFactory(() => DevNetworkRequests()); + getIt.registerFactory(() => ExchangeProviderLogsViewModel()); + getIt.registerFactory(() => DevExchangeProviderLogsPage(getIt.get())); + getIt.registerFactory(() => StartTorPage(StartTorViewModel(),)); getIt.registerFactory(() => DEuroViewModel( diff --git a/lib/exchange/provider/chainflip_exchange_provider.dart b/lib/exchange/provider/chainflip_exchange_provider.dart index 5e9b85f29..de1642a96 100644 --- a/lib/exchange/provider/chainflip_exchange_provider.dart +++ b/lib/exchange/provider/chainflip_exchange_provider.dart @@ -13,6 +13,7 @@ import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:hive/hive.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:cake_wallet/utils/exchange_provider_logger.dart'; class ChainflipExchangeProvider extends ExchangeProvider { ChainflipExchangeProvider({required this.tradesStore}) @@ -114,8 +115,41 @@ class ChainflipExchangeProvider extends ExchangeProvider { final expectedAmountOut = quoteResponse['egressAmountNative'] as String? ?? '0'; - return _amountFromNative(expectedAmountOut, to) / amount; - } catch (e) { + final rate = _amountFromNative(expectedAmountOut, to) / amount; + + ExchangeProviderLogger.logSuccess( + provider: description, + function: 'fetchRate', + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + 'quoteParams': quoteParams, + }, + responseData: { + 'expectedAmountOut': expectedAmountOut, + 'rate': rate, + 'quoteResponse': quoteResponse, + }, + ); + + return rate; + } catch (e, s) { + ExchangeProviderLogger.logError( + provider: description, + function: 'fetchRate', + error: e, + stackTrace: s, + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + }, + ); printV(e.toString()); return 0.0; } @@ -164,6 +198,30 @@ class ChainflipExchangeProvider extends ExchangeProvider { final id = '${swapResponse['issuedBlock']}-${swapResponse['network'].toString().toUpperCase()}-${swapResponse['channelId']}'; + ExchangeProviderLogger.logSuccess( + provider: description, + function: 'createTrade', + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'quoteParams': quoteParams, + 'swapParams': swapParams, + }, + responseData: { + 'id': id, + 'inputAddress': swapResponse['address'].toString(), + 'estimatedPrice': estimatedPrice, + 'minimumPrice': minimumPrice, + 'swapResponse': swapResponse, + }, + ); + return Trade( id: id, from: request.fromCurrency, @@ -178,7 +236,23 @@ class ChainflipExchangeProvider extends ExchangeProvider { userCurrencyFromRaw: '${request.fromCurrency.title}_${request.fromCurrency.tag ?? ''}', userCurrencyToRaw: '${request.toCurrency.title}_${request.toCurrency.tag ?? ''}', isSendAll: isSendAll); - } catch (e) { + } catch (e, s) { + ExchangeProviderLogger.logError( + provider: description, + function: 'createTrade', + error: e, + stackTrace: s, + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + }, + ); printV(e.toString()); rethrow; } diff --git a/lib/exchange/provider/changenow_exchange_provider.dart b/lib/exchange/provider/changenow_exchange_provider.dart index 562f9e0c9..5a01f38ba 100644 --- a/lib/exchange/provider/changenow_exchange_provider.dart +++ b/lib/exchange/provider/changenow_exchange_provider.dart @@ -16,6 +16,7 @@ import 'package:cw_core/utils/proxy_wrapper.dart'; import 'package:cake_wallet/wallet_type_utils.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cake_wallet/utils/exchange_provider_logger.dart'; class ChangeNowExchangeProvider extends ExchangeProvider { ChangeNowExchangeProvider({required SettingsStore settingsStore}) @@ -126,8 +127,43 @@ class ChangeNowExchangeProvider extends ExchangeProvider { if (rateId.isNotEmpty) _lastUsedRateId = rateId; - return isReverse ? (amount / fromAmount) : (toAmount / amount); - } catch (e) { + final rate = isReverse ? (amount / fromAmount) : (toAmount / amount); + + ExchangeProviderLogger.logSuccess( + provider: description, + function: 'fetchRate', + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + 'type': type, + 'flow': _getFlow(isFixedRateMode), + }, + responseData: { + 'fromAmount': fromAmount, + 'toAmount': toAmount, + 'rateId': rateId, + 'rate': rate, + }, + ); + + return rate; + } catch (e, s) { + ExchangeProviderLogger.logError( + provider: description, + function: 'fetchRate', + error: e, + stackTrace: s, + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + }, + ); printV(e.toString()); return 0.0; } diff --git a/lib/exchange/provider/exolix_exchange_provider.dart b/lib/exchange/provider/exolix_exchange_provider.dart index e4e009528..fa0c16f96 100644 --- a/lib/exchange/provider/exolix_exchange_provider.dart +++ b/lib/exchange/provider/exolix_exchange_provider.dart @@ -13,6 +13,7 @@ import 'package:cake_wallet/wallet_type_utils.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cake_wallet/utils/exchange_provider_logger.dart'; class ExolixExchangeProvider extends ExchangeProvider { ExolixExchangeProvider() : super(pairList: supportedPairs(_notSupported)); @@ -141,11 +142,62 @@ class ExolixExchangeProvider extends ExchangeProvider { if (response.statusCode != 200) { final message = responseJSON['message'] as String?; + + ExchangeProviderLogger.logError( + provider: description, + function: 'fetchRate', + error: Exception(message ?? 'Unknown error'), + stackTrace: StackTrace.current, + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + 'params': params, + 'url': uri.toString(), + }, + ); + throw Exception(message); } - return responseJSON['rate'] as double; - } catch (e) { + final rate = responseJSON['rate'] as double; + + ExchangeProviderLogger.logSuccess( + provider: description, + function: 'fetchRate', + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + 'params': params, + 'url': uri.toString(), + }, + responseData: { + 'rate': rate, + 'statusCode': response.statusCode, + 'responseJSON': responseJSON, + }, + ); + + return rate; + } catch (e, s) { + ExchangeProviderLogger.logError( + provider: description, + function: 'fetchRate', + error: e, + stackTrace: s, + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + }, + ); printV(e.toString()); return 0.0; } @@ -186,11 +238,50 @@ class ExolixExchangeProvider extends ExchangeProvider { final responseJSON = json.decode(response.body) as Map; final errors = responseJSON['errors'] as Map; final errorMessage = errors.values.join(', '); + + ExchangeProviderLogger.logError( + provider: description, + function: 'createTrade', + error: Exception(errorMessage), + stackTrace: StackTrace.current, + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'body': body, + 'url': uri.toString(), + }, + ); + throw Exception(errorMessage); } - if (response.statusCode != 200 && response.statusCode != 201) + if (response.statusCode != 200 && response.statusCode != 201) { + ExchangeProviderLogger.logError( + provider: description, + function: 'createTrade', + error: Exception('Unexpected http status: ${response.statusCode}'), + stackTrace: StackTrace.current, + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'body': body, + 'url': uri.toString(), + }, + ); throw Exception('Unexpected http status: ${response.statusCode}'); + } final responseJSON = json.decode(response.body) as Map; final id = responseJSON['id'] as String; @@ -201,6 +292,34 @@ class ExolixExchangeProvider extends ExchangeProvider { final amount = responseJSON['amount'].toString(); final receiveAmount = responseJSON['amountTo']?.toString(); + ExchangeProviderLogger.logSuccess( + provider: description, + function: 'createTrade', + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'body': body, + 'url': uri.toString(), + }, + responseData: { + 'id': id, + 'inputAddress': inputAddress, + 'refundAddress': refundAddress, + 'extraId': extraId, + 'payoutAddress': payoutAddress, + 'amount': amount, + 'receiveAmount': receiveAmount, + 'statusCode': response.statusCode, + 'responseJSON': responseJSON, + }, + ); + return Trade( id: id, from: request.fromCurrency, diff --git a/lib/exchange/provider/letsexchange_exchange_provider.dart b/lib/exchange/provider/letsexchange_exchange_provider.dart index ee54c6430..1841dffed 100644 --- a/lib/exchange/provider/letsexchange_exchange_provider.dart +++ b/lib/exchange/provider/letsexchange_exchange_provider.dart @@ -13,6 +13,7 @@ import 'package:cake_wallet/exchange/utils/currency_pairs_utils.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cake_wallet/utils/exchange_provider_logger.dart'; class LetsExchangeExchangeProvider extends ExchangeProvider { LetsExchangeExchangeProvider() : super(pairList: supportedPairs(_notSupported)); @@ -102,8 +103,45 @@ class LetsExchangeExchangeProvider extends ExchangeProvider { if (amountToGet == 0.0) return 0.0; - return isFixedRateMode ? amount / amountToGet : amountToGet / amount; - } catch (e) { + final rate = isFixedRateMode ? amount / amountToGet : amountToGet / amount; + + ExchangeProviderLogger.logSuccess( + provider: description, + function: 'fetchRate', + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + 'networkFrom': networkFrom, + 'networkTo': networkTo, + 'params': params, + }, + responseData: { + 'amountToGet': amountToGet, + 'rate': rate, + 'responseJSON': responseJSON, + }, + ); + + return rate; + } catch (e, s) { + ExchangeProviderLogger.logError( + provider: description, + function: 'fetchRate', + error: e, + stackTrace: s, + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + 'networkFrom': networkFrom, + 'networkTo': networkTo, + }, + ); printV(e.toString()); return 0.0; } @@ -164,6 +202,26 @@ class LetsExchangeExchangeProvider extends ExchangeProvider { if (response.statusCode != 200) { + ExchangeProviderLogger.logError( + provider: description, + function: 'createTrade', + error: Exception('LetsExchange create trade failed: ${response.body}'), + stackTrace: StackTrace.current, + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'networkFrom': networkFrom, + 'networkTo': networkTo, + 'tradeParams': tradeParams, + 'url': uri.toString(), + }, + ); throw Exception('LetsExchange create trade failed: ${response.body}'); } final responseJSON = json.decode(response.body) as Map; @@ -197,6 +255,40 @@ class LetsExchangeExchangeProvider extends ExchangeProvider { toCurrency = CryptoCurrency.fromString(to); } + ExchangeProviderLogger.logSuccess( + provider: description, + function: 'createTrade', + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'networkFrom': networkFrom, + 'networkTo': networkTo, + 'tradeParams': tradeParams, + 'url': uri.toString(), + }, + responseData: { + 'id': id, + 'from': from, + 'to': to, + 'depositAddress': depositAddress, + 'payoutAddress': payoutAddress, + 'refundAddress': refundAddress, + 'depositAmount': depositAmount, + 'receiveAmount': receiveAmount, + 'status': status, + 'createdAt': createdAtString, + 'expiredAt': expiredAtTimestamp, + 'extraId': extraId, + 'statusCode': response.statusCode, + }, + ); + return Trade( id: id, from: fromCurrency, @@ -215,7 +307,25 @@ class LetsExchangeExchangeProvider extends ExchangeProvider { userCurrencyToRaw: '${request.toCurrency.title}_${request.toCurrency.tag ?? ''}', isSendAll: isSendAll, ); - } catch (e) { + } catch (e, s) { + ExchangeProviderLogger.logError( + provider: description, + function: 'createTrade', + error: e, + stackTrace: s, + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'networkFrom': networkFrom, + 'networkTo': networkTo, + }, + ); log(e.toString()); throw TradeNotCreatedException(description); } diff --git a/lib/exchange/provider/sideshift_exchange_provider.dart b/lib/exchange/provider/sideshift_exchange_provider.dart index 33cd2af3d..7727f4809 100644 --- a/lib/exchange/provider/sideshift_exchange_provider.dart +++ b/lib/exchange/provider/sideshift_exchange_provider.dart @@ -13,6 +13,7 @@ import 'package:cake_wallet/exchange/utils/currency_pairs_utils.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cake_wallet/utils/exchange_provider_logger.dart'; class SideShiftExchangeProvider extends ExchangeProvider { SideShiftExchangeProvider() : super(pairList: supportedPairs(_notSupported)); @@ -145,15 +146,77 @@ class SideShiftExchangeProvider extends ExchangeProvider { final responseJSON = json.decode(response.body) as Map; final error = responseJSON['error']['message'] as String; + ExchangeProviderLogger.logError( + provider: description, + function: 'fetchRate', + error: Exception('SideShift Internal Server Error: $error'), + stackTrace: StackTrace.current, + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + 'url': url, + }, + ); + throw Exception('SideShift Internal Server Error: $error'); } if (response.statusCode != 200) { + ExchangeProviderLogger.logError( + provider: description, + function: 'fetchRate', + error: Exception('Unexpected http status: ${response.statusCode}'), + stackTrace: StackTrace.current, + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + 'url': url, + }, + ); + throw Exception('Unexpected http status: ${response.statusCode}'); } - return double.parse(responseJSON['rate'] as String); - } catch (e) { + final rate = double.parse(responseJSON['rate'] as String); + + ExchangeProviderLogger.logSuccess( + provider: description, + function: 'fetchRate', + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + 'url': url, + }, + responseData: { + 'rate': rate, + 'statusCode': response.statusCode, + }, + ); + + return rate; + } catch (e, s) { + ExchangeProviderLogger.logError( + provider: description, + function: 'fetchRate', + error: e, + stackTrace: s, + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + }, + ); printV(e.toString()); return 0.00; } @@ -199,9 +262,47 @@ class SideShiftExchangeProvider extends ExchangeProvider { final responseJSON = json.decode(response.body) as Map; final error = responseJSON['error']['message'] as String; + ExchangeProviderLogger.logError( + provider: description, + function: 'createTrade', + error: TradeNotCreatedException(description, description: error), + stackTrace: StackTrace.current, + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'url': url, + 'body': body, + }, + ); + throw TradeNotCreatedException(description, description: error); } + ExchangeProviderLogger.logError( + provider: description, + function: 'createTrade', + error: TradeNotCreatedException(description), + stackTrace: StackTrace.current, + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'url': url, + 'body': body, + }, + ); + throw TradeNotCreatedException(description); } @@ -212,6 +313,31 @@ class SideShiftExchangeProvider extends ExchangeProvider { final depositAmount = responseJSON['depositAmount'] as String?; final depositMemo = responseJSON['depositMemo'] as String?; + ExchangeProviderLogger.logSuccess( + provider: description, + function: 'createTrade', + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'url': url, + 'body': body, + }, + responseData: { + 'id': id, + 'inputAddress': inputAddress, + 'settleAddress': settleAddress, + 'depositAmount': depositAmount, + 'depositMemo': depositMemo, + 'statusCode': response.statusCode, + }, + ); + return Trade( id: id, provider: description, diff --git a/lib/exchange/provider/simpleswap_exchange_provider.dart b/lib/exchange/provider/simpleswap_exchange_provider.dart index 73e644f91..e88562baa 100644 --- a/lib/exchange/provider/simpleswap_exchange_provider.dart +++ b/lib/exchange/provider/simpleswap_exchange_provider.dart @@ -13,6 +13,7 @@ import 'package:cake_wallet/exchange/utils/currency_pairs_utils.dart'; import 'package:cake_wallet/utils/device_info.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; import 'package:cw_core/crypto_currency.dart'; +import 'package:cake_wallet/utils/exchange_provider_logger.dart'; class SimpleSwapExchangeProvider extends ExchangeProvider { SimpleSwapExchangeProvider() : super(pairList: supportedPairs(_notSupported)); @@ -108,11 +109,63 @@ class SimpleSwapExchangeProvider extends ExchangeProvider { final response = await ProxyWrapper().get(clearnetUri: uri); - if (response.body == "null") return 0.00; + if (response.body == "null") { + ExchangeProviderLogger.logError( + provider: description, + function: 'fetchRate', + error: Exception('Null response body'), + stackTrace: StackTrace.current, + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + 'params': params, + 'url': uri.toString(), + }, + ); + return 0.00; + } + final data = json.decode(response.body) as String; + final rate = double.parse(data) / amount; - return double.parse(data) / amount; - } catch (_) { + ExchangeProviderLogger.logSuccess( + provider: description, + function: 'fetchRate', + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + 'params': params, + 'url': uri.toString(), + }, + responseData: { + 'data': data, + 'rate': rate, + 'statusCode': response.statusCode, + 'responseBody': response.body, + }, + ); + + return rate; + } catch (e, s) { + ExchangeProviderLogger.logError( + provider: description, + function: 'fetchRate', + error: e, + stackTrace: s, + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + }, + ); return 0.00; } } @@ -147,9 +200,47 @@ class SimpleSwapExchangeProvider extends ExchangeProvider { final responseJSON = json.decode(response.body) as Map; final error = responseJSON['message'] as String; + ExchangeProviderLogger.logError( + provider: description, + function: 'createTrade', + error: TradeNotCreatedException(description, description: error), + stackTrace: StackTrace.current, + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'body': body, + 'url': uri.toString(), + }, + ); + throw TradeNotCreatedException(description, description: error); } + ExchangeProviderLogger.logError( + provider: description, + function: 'createTrade', + error: TradeNotCreatedException(description), + stackTrace: StackTrace.current, + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'body': body, + 'url': uri.toString(), + }, + ); + throw TradeNotCreatedException(description); } @@ -161,6 +252,33 @@ class SimpleSwapExchangeProvider extends ExchangeProvider { final extraId = responseJSON['extra_id_from'] as String?; final receiveAmount = responseJSON['amount_to'] as String?; + ExchangeProviderLogger.logSuccess( + provider: description, + function: 'createTrade', + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'body': body, + 'url': uri.toString(), + }, + responseData: { + 'id': id, + 'inputAddress': inputAddress, + 'payoutAddress': payoutAddress, + 'settleAddress': settleAddress, + 'extraId': extraId, + 'receiveAmount': receiveAmount, + 'statusCode': response.statusCode, + 'responseJSON': responseJSON, + }, + ); + return Trade( id: id, provider: description, diff --git a/lib/exchange/provider/stealth_ex_exchange_provider.dart b/lib/exchange/provider/stealth_ex_exchange_provider.dart index 980f4b217..d23c0999c 100644 --- a/lib/exchange/provider/stealth_ex_exchange_provider.dart +++ b/lib/exchange/provider/stealth_ex_exchange_provider.dart @@ -12,6 +12,7 @@ import 'package:cake_wallet/exchange/trade_state.dart'; import 'package:cake_wallet/exchange/utils/currency_pairs_utils.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; import 'package:cw_core/crypto_currency.dart'; +import 'package:cake_wallet/utils/exchange_provider_logger.dart'; class StealthExExchangeProvider extends ExchangeProvider { StealthExExchangeProvider() : super(pairList: supportedPairs(_notSupported)); @@ -68,7 +69,7 @@ class StealthExExchangeProvider extends ExchangeProvider { headers: headers, body: json.encode(body), ); - + if (response.statusCode != 200) { throw Exception('StealthEx fetch limits failed: ${response.body}'); } @@ -89,14 +90,68 @@ class StealthExExchangeProvider extends ExchangeProvider { required double amount, required bool isFixedRateMode, required bool isReceiveAmount}) async { - final response = await getEstimatedExchangeAmount( - from: from, to: to, amount: amount, isFixedRateMode: isFixedRateMode); - final estimatedAmount = response['estimated_amount'] as double? ?? 0.0; - return estimatedAmount > 0.0 - ? isFixedRateMode - ? amount / estimatedAmount - : estimatedAmount / amount - : 0.0; + try { + final response = await getEstimatedExchangeAmount( + from: from, + to: to, + amount: amount, + isFixedRateMode: isFixedRateMode, + ); + final estimatedAmount = response['estimated_amount'] as double? ?? 0.0; + + if (estimatedAmount <= 0.0) { + ExchangeProviderLogger.logError( + provider: description, + function: 'fetchRate', + error: Exception('Invalid estimated amount: $estimatedAmount'), + stackTrace: StackTrace.current, + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + }, + ); + return 0.0; + } + + final rate = isFixedRateMode ? amount / estimatedAmount : estimatedAmount / amount; + + ExchangeProviderLogger.logSuccess( + provider: description, + function: 'fetchRate', + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + }, + responseData: { + 'estimatedAmount': estimatedAmount, + 'rate': rate, + 'response': response, + }, + ); + + return rate; + } catch (e, s) { + ExchangeProviderLogger.logError( + provider: description, + function: 'fetchRate', + error: e, + stackTrace: s, + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + }, + ); + return 0.0; + } } @override @@ -143,9 +198,26 @@ class StealthExExchangeProvider extends ExchangeProvider { headers: headers, body: json.encode(body), ); - if (response.statusCode != 201) { + ExchangeProviderLogger.logError( + provider: description, + function: 'createTrade', + error: Exception('StealthEx create trade failed: ${response.body}'), + stackTrace: StackTrace.current, + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'body': body, + 'rateId': rateId, + }, + ); throw Exception('StealthEx create trade failed: ${response.body}'); } final responseJSON = json.decode(response.body) as Map; @@ -169,20 +241,51 @@ class StealthExExchangeProvider extends ExchangeProvider { ? DateTime.parse(validUntil).toLocal() : DateTime.now().add(Duration(minutes: 5)); - CryptoCurrency fromCurrency; if (request.fromCurrency.tag != null && request.fromCurrency.title.toLowerCase() == from) { - fromCurrency = request.fromCurrency; - } else { - fromCurrency = CryptoCurrency.fromString(from); - } + fromCurrency = request.fromCurrency; + } else { + fromCurrency = CryptoCurrency.fromString(from); + } CryptoCurrency toCurrency; if (request.toCurrency.tag != null && request.toCurrency.title.toLowerCase() == to) { - toCurrency = request.toCurrency; - } else { - toCurrency = CryptoCurrency.fromString(to); - } + toCurrency = request.toCurrency; + } else { + toCurrency = CryptoCurrency.fromString(to); + } + + ExchangeProviderLogger.logSuccess( + provider: description, + function: 'createTrade', + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'body': body, + 'rateId': rateId, + }, + responseData: { + 'id': id, + 'from': from, + 'to': to, + 'depositAddress': depositAddress, + 'payoutAddress': payoutAddress, + 'refundAddress': refundAddress, + 'depositAmount': depositAmount, + 'receiveAmount': receiveAmount, + 'status': status, + 'createdAt': createdAtString, + 'extraId': extraId, + 'statusCode': response.statusCode, + 'responseJSON': responseJSON, + }, + ); return Trade( id: id, @@ -202,7 +305,23 @@ class StealthExExchangeProvider extends ExchangeProvider { userCurrencyToRaw: '${request.toCurrency.title}_${request.toCurrency.tag ?? ''}', isSendAll: isSendAll, ); - } catch (e) { + } catch (e, s) { + ExchangeProviderLogger.logError( + provider: description, + function: 'createTrade', + error: e, + stackTrace: s, + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + }, + ); log(e.toString()); throw TradeNotCreatedException(description); } @@ -214,8 +333,7 @@ class StealthExExchangeProvider extends ExchangeProvider { final uri = Uri.parse('$_baseUrl$_exchangesPath/$id'); final response = await ProxyWrapper().get(clearnetUri: uri, headers: headers); - - + if (response.statusCode != 200) { throw Exception('StealthEx fetch trade failed: ${response.body}'); } @@ -272,7 +390,7 @@ class StealthExExchangeProvider extends ExchangeProvider { 'estimation': isFixedRateMode ? 'reversed' : 'direct', 'rate': isFixedRateMode ? 'fixed' : 'floating', 'amount': amount, - 'additional_fee_percent': _additionalFeePercent, + 'additional_fee_percent': _additionalFeePercent, }; try { @@ -281,7 +399,7 @@ class StealthExExchangeProvider extends ExchangeProvider { headers: headers, body: json.encode(body), ); - + if (response.statusCode != 200) return {}; final responseJSON = json.decode(response.body) as Map; final rate = responseJSON['rate'] as Map?; diff --git a/lib/exchange/provider/swaptrade_exchange_provider.dart b/lib/exchange/provider/swaptrade_exchange_provider.dart index 3c2a9d271..a7e50459a 100644 --- a/lib/exchange/provider/swaptrade_exchange_provider.dart +++ b/lib/exchange/provider/swaptrade_exchange_provider.dart @@ -13,6 +13,7 @@ import 'package:cake_wallet/exchange/utils/currency_pairs_utils.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cake_wallet/utils/exchange_provider_logger.dart'; class SwapTradeExchangeProvider extends ExchangeProvider { SwapTradeExchangeProvider() : super(pairList: supportedPairs(_notSupported)); @@ -126,13 +127,64 @@ class SwapTradeExchangeProvider extends ExchangeProvider { final responseBody = json.decode(response.body) as Map; - if (response.statusCode != 200) + if (response.statusCode != 200) { + ExchangeProviderLogger.logError( + provider: description, + function: 'fetchRate', + error: Exception('Unexpected http status: ${response.statusCode}'), + stackTrace: StackTrace.current, + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + 'body': body, + 'url': uri.toString(), + }, + ); throw Exception('Unexpected http status: ${response.statusCode}'); + } final data = responseBody['data'] as Map; double rate = double.parse(data['price'].toString()); - return rate > 0 ? isFixedRateMode ? amount / rate : rate / amount : 0.0; - } catch (e) { + final calculatedRate = rate > 0 ? isFixedRateMode ? amount / rate : rate / amount : 0.0; + + ExchangeProviderLogger.logSuccess( + provider: description, + function: 'fetchRate', + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + 'body': body, + 'url': uri.toString(), + }, + responseData: { + 'rate': rate, + 'calculatedRate': calculatedRate, + 'statusCode': response.statusCode, + 'responseBody': responseBody, + }, + ); + + return calculatedRate; + } catch (e, s) { + ExchangeProviderLogger.logError( + provider: description, + function: 'fetchRate', + error: e, + stackTrace: s, + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + }, + ); printV("error fetching rate: ${e.toString()}"); return 0.0; } @@ -169,15 +221,78 @@ class SwapTradeExchangeProvider extends ExchangeProvider { if (response.statusCode == 400 || responseBody["success"] == false) { final error = responseBody['errors'][0]['msg'] as String; + + ExchangeProviderLogger.logError( + provider: description, + function: 'createTrade', + error: TradeNotCreatedException(description, description: error), + stackTrace: StackTrace.current, + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'body': body, + 'url': uri.toString(), + }, + ); + throw TradeNotCreatedException(description, description: error); } - if (response.statusCode != 200) + if (response.statusCode != 200) { + ExchangeProviderLogger.logError( + provider: description, + function: 'createTrade', + error: Exception('Unexpected http status: ${response.statusCode}'), + stackTrace: StackTrace.current, + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'body': body, + 'url': uri.toString(), + }, + ); throw Exception('Unexpected http status: ${response.statusCode}'); + } final responseData = responseBody['data'] as Map; final receiveAmount = responseData["amount_receive"]?.toString(); + ExchangeProviderLogger.logSuccess( + provider: description, + function: 'createTrade', + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'body': body, + 'url': uri.toString(), + }, + responseData: { + 'id': responseData["order_id"] as String, + 'inputAddress': responseData["server_address"] as String, + 'receiveAmount': receiveAmount, + 'statusCode': response.statusCode, + 'responseBody': responseBody, + }, + ); + return Trade( id: responseData["order_id"] as String, inputAddress: responseData["server_address"] as String, @@ -193,7 +308,23 @@ class SwapTradeExchangeProvider extends ExchangeProvider { userCurrencyFromRaw: '${request.fromCurrency.title}_${request.fromCurrency.tag ?? ''}', userCurrencyToRaw: '${request.toCurrency.title}_${request.toCurrency.tag ?? ''}', ); - } catch (e) { + } catch (e, s) { + ExchangeProviderLogger.logError( + provider: description, + function: 'createTrade', + error: e, + stackTrace: s, + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + }, + ); printV("error creating trade: ${e.toString()}"); throw TradeNotCreatedException(description, description: e.toString()); } diff --git a/lib/exchange/provider/thorchain_exchange.provider.dart b/lib/exchange/provider/thorchain_exchange.provider.dart index 3557a847b..d3673eb2c 100644 --- a/lib/exchange/provider/thorchain_exchange.provider.dart +++ b/lib/exchange/provider/thorchain_exchange.provider.dart @@ -11,6 +11,7 @@ import 'package:cw_core/utils/proxy_wrapper.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:hive/hive.dart'; +import 'package:cake_wallet/utils/exchange_provider_logger.dart'; class ThorChainExchangeProvider extends ExchangeProvider { ThorChainExchangeProvider({required this.tradesStore}) @@ -85,9 +86,41 @@ class ThorChainExchangeProvider extends ExchangeProvider { final responseJSON = await _getSwapQuote(params); final expectedAmountOut = responseJSON['expected_amount_out'] as String? ?? '0.0'; + final rate = _thorChainAmountToDouble(expectedAmountOut) / amount; - return _thorChainAmountToDouble(expectedAmountOut) / amount; - } catch (e) { + ExchangeProviderLogger.logSuccess( + provider: description, + function: 'fetchRate', + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + 'params': params, + }, + responseData: { + 'expectedAmountOut': expectedAmountOut, + 'rate': rate, + 'responseJSON': responseJSON, + }, + ); + + return rate; + } catch (e, s) { + ExchangeProviderLogger.logError( + provider: description, + function: 'fetchRate', + error: e, + stackTrace: s, + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + }, + ); printV(e.toString()); return 0.0; } @@ -144,6 +177,29 @@ class ThorChainExchangeProvider extends ExchangeProvider { receiveAmount = _thorChainAmountToDouble(directAmountOutResponse).toString(); } + ExchangeProviderLogger.logSuccess( + provider: description, + function: 'createTrade', + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'params': params, + }, + responseData: { + 'inputAddress': inputAddress, + 'memo': memo, + 'directAmountOutResponse': directAmountOutResponse, + 'receiveAmount': receiveAmount, + 'responseJSON': responseJSON, + }, + ); + return Trade( id: '', from: request.fromCurrency, @@ -253,10 +309,30 @@ class ThorChainExchangeProvider extends ExchangeProvider { final response = await ProxyWrapper().get(clearnetUri: uri); if (response.statusCode != 200) { + ExchangeProviderLogger.logError( + provider: description, + function: '_getSwapQuote', + error: Exception('Unexpected HTTP status: ${response.statusCode}'), + stackTrace: StackTrace.current, + requestData: { + 'params': params, + 'url': uri.toString(), + }, + ); throw Exception('Unexpected HTTP status: ${response.statusCode}'); } if (response.body.contains('error')) { + ExchangeProviderLogger.logError( + provider: description, + function: '_getSwapQuote', + error: Exception('Unexpected response: ${response.body}'), + stackTrace: StackTrace.current, + requestData: { + 'params': params, + 'url': uri.toString(), + }, + ); throw Exception('Unexpected response: ${response.body}'); } diff --git a/lib/exchange/provider/trocador_exchange_provider.dart b/lib/exchange/provider/trocador_exchange_provider.dart index 5bb577b17..b9ac09456 100644 --- a/lib/exchange/provider/trocador_exchange_provider.dart +++ b/lib/exchange/provider/trocador_exchange_provider.dart @@ -12,6 +12,7 @@ import 'package:cake_wallet/wallet_type_utils.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cake_wallet/utils/exchange_provider_logger.dart'; class TrocadorExchangeProvider extends ExchangeProvider { TrocadorExchangeProvider({this.useTorOnly = false, this.providerStates = const {}}) @@ -156,13 +157,66 @@ class TrocadorExchangeProvider extends ExchangeProvider { .toList(); if (_provider.isEmpty) { + ExchangeProviderLogger.logError( + provider: description, + function: 'fetchRate', + error: Exception('No enabled providers found for the selected trade.'), + stackTrace: StackTrace.current, + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + 'params': params, + 'url': uri.toString(), + }, + ); throw Exception('No enabled providers found for the selected trade.'); } if (rateId.isNotEmpty) _lastUsedRateId = rateId; - return isReceiveAmount ? (amount / fromAmount) : (toAmount / amount); - } catch (e) { + final rate = isReceiveAmount ? (amount / fromAmount) : (toAmount / amount); + + ExchangeProviderLogger.logSuccess( + provider: description, + function: 'fetchRate', + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + 'params': params, + 'url': uri.toString(), + }, + responseData: { + 'fromAmount': fromAmount, + 'toAmount': toAmount, + 'rate': rate, + 'rateId': rateId, + 'provider': _provider.first, + 'statusCode': response.statusCode, + 'responseJSON': responseJSON, + }, + ); + + return rate; + } catch (e, s) { + ExchangeProviderLogger.logError( + provider: description, + function: 'fetchRate', + error: e, + stackTrace: s, + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + }, + ); printV(e.toString()); return 0.0; } @@ -201,6 +255,23 @@ class TrocadorExchangeProvider extends ExchangeProvider { } if (_provider.isEmpty) { + ExchangeProviderLogger.logError( + provider: description, + function: 'createTrade', + error: Exception('No available provider is enabled'), + stackTrace: StackTrace.current, + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'params': params, + }, + ); throw Exception('No available provider is enabled'); } @@ -214,11 +285,50 @@ class TrocadorExchangeProvider extends ExchangeProvider { final responseJSON = json.decode(response.body) as Map; final error = responseJSON['error'] as String; final message = responseJSON['message'] as String; + + ExchangeProviderLogger.logError( + provider: description, + function: 'createTrade', + error: Exception('${error}\n$message'), + stackTrace: StackTrace.current, + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'params': params, + 'url': uri.toString(), + }, + ); + throw Exception('${error}\n$message'); } - if (response.statusCode != 200) + if (response.statusCode != 200) { + ExchangeProviderLogger.logError( + provider: description, + function: 'createTrade', + error: Exception('Unexpected http status: ${response.statusCode}'), + stackTrace: StackTrace.current, + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'params': params, + 'url': uri.toString(), + }, + ); throw Exception('Unexpected http status: ${response.statusCode}'); + } final responseJSON = json.decode(response.body) as Map; final id = responseJSON['trade_id'] as String; @@ -234,6 +344,39 @@ class TrocadorExchangeProvider extends ExchangeProvider { final receiveAmount = responseJSON['amount_to']?.toString(); final addressProviderMemo = responseJSON['address_provider_memo'] as String?; + ExchangeProviderLogger.logSuccess( + provider: description, + function: 'createTrade', + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'params': params, + 'url': uri.toString(), + }, + responseData: { + 'id': id, + 'inputAddress': inputAddress, + 'refundAddress': refundAddress, + 'status': status, + 'payoutAddress': payoutAddress, + 'date': date, + 'password': password, + 'providerId': providerId, + 'providerName': providerName, + 'amount': amount, + 'receiveAmount': receiveAmount, + 'addressProviderMemo': addressProviderMemo, + 'statusCode': response.statusCode, + 'responseJSON': responseJSON, + }, + ); + return Trade( id: id, from: request.fromCurrency, diff --git a/lib/exchange/provider/xoswap_exchange_provider.dart b/lib/exchange/provider/xoswap_exchange_provider.dart index 3ef36996f..6d2517140 100644 --- a/lib/exchange/provider/xoswap_exchange_provider.dart +++ b/lib/exchange/provider/xoswap_exchange_provider.dart @@ -11,6 +11,7 @@ import 'package:cake_wallet/exchange/utils/currency_pairs_utils.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/utils/proxy_wrapper.dart'; +import 'package:cake_wallet/utils/exchange_provider_logger.dart'; class XOSwapExchangeProvider extends ExchangeProvider { XOSwapExchangeProvider() : super(pairList: supportedPairs(_notSupported)); @@ -150,8 +151,24 @@ class XOSwapExchangeProvider extends ExchangeProvider { }) async { try { final rates = await getRatesForPair(from: from, to: to); - if (rates.isEmpty) return 0; + if (rates.isEmpty) { + ExchangeProviderLogger.logError( + provider: description, + function: 'fetchRate', + error: Exception('No rates found for $from to $to'), + stackTrace: StackTrace.current, + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + }, + ); + return 0; + } + double result; if (!isFixedRateMode) { double bestOutput = 0.0; for (var rate in rates) { @@ -166,7 +183,7 @@ class XOSwapExchangeProvider extends ExchangeProvider { } } } - return bestOutput > 0 ? (bestOutput / amount) : 0; + result = bestOutput > 0 ? (bestOutput / amount) : 0; } else { double bestInput = double.infinity; for (var rate in rates) { @@ -181,9 +198,41 @@ class XOSwapExchangeProvider extends ExchangeProvider { } } } - return bestInput < double.infinity ? amount / bestInput : 0; + result = bestInput < double.infinity ? amount / bestInput : 0; } - } catch (e) { + + ExchangeProviderLogger.logSuccess( + provider: description, + function: 'fetchRate', + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + }, + responseData: { + 'result': result, + 'ratesCount': rates.length, + 'rates': rates, + }, + ); + + return result; + } catch (e, s) { + ExchangeProviderLogger.logError( + provider: description, + function: 'fetchRate', + error: e, + stackTrace: s, + requestData: { + 'from': from.title, + 'to': to.title, + 'amount': amount, + 'isFixedRateMode': isFixedRateMode, + 'isReceiveAmount': isReceiveAmount, + }, + ); printV(e.toString()); return 0; } @@ -202,6 +251,24 @@ class XOSwapExchangeProvider extends ExchangeProvider { final curTo = await _getAssets(request.toCurrency); if (curFrom == null || curTo == null) { + ExchangeProviderLogger.logError( + provider: description, + function: 'createTrade', + error: TradeNotCreatedException(description), + stackTrace: StackTrace.current, + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'curFrom': curFrom, + 'curTo': curTo, + }, + ); throw TradeNotCreatedException(description); } @@ -225,6 +292,26 @@ class XOSwapExchangeProvider extends ExchangeProvider { final responseJSON = json.decode(response.body) as Map; final error = responseJSON['error'] ?? 'Unknown error'; final message = responseJSON['message'] ?? ''; + + ExchangeProviderLogger.logError( + provider: description, + function: 'createTrade', + error: Exception('$error\n$message'), + stackTrace: StackTrace.current, + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'payload': payload, + 'url': uri.toString(), + }, + ); + throw Exception('$error\n$message'); } final responseJSON = json.decode(response.body) as Map; @@ -245,6 +332,36 @@ class XOSwapExchangeProvider extends ExchangeProvider { final createdAt = DateTime.parse(createdAtString).toLocal(); + ExchangeProviderLogger.logSuccess( + provider: description, + function: 'createTrade', + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + 'payload': payload, + 'url': uri.toString(), + }, + responseData: { + 'orderId': orderId, + 'depositAddress': depositAddress, + 'payoutAddress': payoutAddress, + 'refundAddress': refundAddress, + 'depositAmount': depositAmount, + 'receiveAmount': receiveAmount, + 'status': status, + 'createdAt': createdAtString, + 'extraId': extraId, + 'statusCode': response.statusCode, + 'responseJSON': responseJSON, + }, + ); + return Trade( id: orderId, from: from, @@ -262,7 +379,23 @@ class XOSwapExchangeProvider extends ExchangeProvider { userCurrencyToRaw: '${request.toCurrency.title}_${request.toCurrency.tag ?? ''}', isSendAll: isSendAll, ); - } catch (e) { + } catch (e, s) { + ExchangeProviderLogger.logError( + provider: description, + function: 'createTrade', + error: e, + stackTrace: s, + requestData: { + 'from': request.fromCurrency.title, + 'to': request.toCurrency.title, + 'fromAmount': request.fromAmount, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'refundAddress': request.refundAddress, + 'isFixedRateMode': isFixedRateMode, + 'isSendAll': isSendAll, + }, + ); printV(e.toString()); throw TradeNotCreatedException(description); } diff --git a/lib/router.dart b/lib/router.dart index ad43badf2..ad9c1fa9b 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -34,6 +34,7 @@ import 'package:cake_wallet/src/screens/dashboard/pages/address_page.dart'; import 'package:cake_wallet/src/screens/dashboard/pages/nft_details_page.dart'; import 'package:cake_wallet/src/screens/dashboard/pages/transactions_page.dart'; import 'package:cake_wallet/src/screens/dashboard/sign_page.dart'; +import 'package:cake_wallet/src/screens/dev/exchange_provider_logs_page.dart'; import 'package:cake_wallet/src/screens/dev/monero_background_sync.dart'; import 'package:cake_wallet/src/screens/dev/moneroc_cache_debug.dart'; import 'package:cake_wallet/src/screens/dev/moneroc_call_profiler.dart'; @@ -924,6 +925,11 @@ Route createRoute(RouteSettings settings) { builder: (_) => getIt.get(), ); + case Routes.devExchangeProviderLogs: + return MaterialPageRoute( + builder: (_) => getIt.get(), + ); + case Routes.devMoneroCallProfiler: return MaterialPageRoute( builder: (_) => getIt.get(), diff --git a/lib/routes.dart b/lib/routes.dart index 3f9050de2..489495664 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -123,6 +123,7 @@ class Routes { static const devBackgroundSyncLogs = '/dev/background_sync_logs'; static const devSocketHealthLogs = '/dev/socket_health_logs'; static const devNetworkRequests = '/dev/network_requests'; + static const devExchangeProviderLogs = '/dev/exchange_provider_logs'; static const signPage = '/sign_page'; static const connectDevices = '/device/connect'; diff --git a/lib/src/screens/dev/exchange_provider_logs_page.dart b/lib/src/screens/dev/exchange_provider_logs_page.dart new file mode 100644 index 000000000..1b3d84dca --- /dev/null +++ b/lib/src/screens/dev/exchange_provider_logs_page.dart @@ -0,0 +1,562 @@ +import 'package:cake_wallet/src/screens/base_page.dart'; +import 'package:cake_wallet/view_model/dev/exchange_provider_logs_view_model.dart'; +import 'package:cake_wallet/utils/exchange_provider_logger.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:intl/intl.dart'; + +class DevExchangeProviderLogsPage extends BasePage { + final ExchangeProviderLogsViewModel viewModel; + + DevExchangeProviderLogsPage(this.viewModel) { + viewModel.loadLogs(); + } + + @override + String? get title => "[dev] exchange provider logs"; + + @override + Widget? trailing(BuildContext context) { + return IconButton( + icon: Icon(Icons.refresh), + onPressed: () => viewModel.refreshLogs(), + ); + } + + @override + Widget body(BuildContext context) { + return Observer( + builder: (_) { + if (viewModel.isLoading) { + return Center(child: CircularProgressIndicator()); + } + + if (viewModel.error != null) { + return Center(child: Text("Error: ${viewModel.error}")); + } + + if (viewModel.logs.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("No exchange provider logs available"), + SizedBox(height: 16), + ElevatedButton( + onPressed: () => viewModel.loadLogs(), + child: Text("Load Logs"), + ), + ], + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _StatsCard(viewModel), + _LogsHeader(viewModel), + Expanded( + child: _LogsList(viewModel), + ), + _ActionButtons(viewModel), + ], + ); + }, + ); + } +} + +class _StatsCard extends StatefulWidget { + final ExchangeProviderLogsViewModel viewModel; + + const _StatsCard(this.viewModel); + + @override + State<_StatsCard> createState() => _StatsCardState(); +} + +class _StatsCardState extends State<_StatsCard> { + bool _isExpanded = false; + + @override + Widget build(BuildContext context) { + return Card( + margin: EdgeInsets.all(16), + child: Column( + children: [ + ListTile( + title: Text( + "Stats", + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontSize: 18, + ), + ), + trailing: Icon( + _isExpanded ? Icons.expand_less : Icons.expand_more, + ), + onTap: () { + setState(() { + _isExpanded = !_isExpanded; + }); + }, + ), + if (_isExpanded) ...[ + Observer( + builder: (_) => Padding( + padding: EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _StatItem("Total", widget.viewModel.totalLogs.toString()), + _StatItem("Success", widget.viewModel.successLogs.toString()), + _StatItem("Errors", widget.viewModel.errorLogs.toString()), + ], + ), + SizedBox(height: 16), + Text( + "Logs by Provider:", + style: Theme.of(context).textTheme.titleMedium, + ), + SizedBox(height: 8), + ...widget.viewModel.logsByProvider.entries.map( + (entry) => Padding( + padding: EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(entry.key.title), + Text(entry.value.toString()), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ], + ), + ); + } +} + +class _LogsHeader extends StatelessWidget { + final ExchangeProviderLogsViewModel viewModel; + + const _LogsHeader(this.viewModel); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "Logs", + style: Theme.of(context).textTheme.titleMedium, + ), + Observer( + builder: (_) => PopupMenuButton( + tooltip: "Filter logs", + onSelected: (LogFilter filter) { + viewModel.setFilter(filter); + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: LogFilter.all, + child: Row( + children: [ + Icon(Icons.list, size: 18), + SizedBox(width: 8), + Text("All Logs"), + ], + ), + ), + PopupMenuItem( + value: LogFilter.success, + child: Row( + children: [ + Icon(Icons.check_circle, size: 18, color: Colors.green), + SizedBox(width: 8), + Text("Success Only"), + ], + ), + ), + PopupMenuItem( + value: LogFilter.error, + child: Row( + children: [ + Icon(Icons.error, size: 18, color: Colors.red), + SizedBox(width: 8), + Text("Errors Only"), + ], + ), + ), + ], + child: Container( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.filter_list, size: 16), + SizedBox(width: 4), + Text(_getFilterText(viewModel.currentFilter)), + Icon(Icons.arrow_drop_down, size: 16), + ], + ), + ), + ), + ), + ], + ), + ); + } + + String _getFilterText(LogFilter filter) { + switch (filter) { + case LogFilter.all: + return "All"; + case LogFilter.success: + return "Success"; + case LogFilter.error: + return "Errors"; + } + } +} + +class _StatItem extends StatelessWidget { + final String label; + final String value; + + const _StatItem(this.label, this.value); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text( + value, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + Text(label), + ], + ); + } +} + +class _LogsList extends StatelessWidget { + final ExchangeProviderLogsViewModel viewModel; + + const _LogsList(this.viewModel); + + @override + Widget build(BuildContext context) { + return Observer( + builder: (_) => ListView.builder( + itemCount: viewModel.filteredLogs.length, + itemBuilder: (context, index) { + final log = viewModel.filteredLogs[index]; + return _LogEntryCard(log); + }, + ), + ); + } +} + +class _LogEntryCard extends StatelessWidget { + final ExchangeProviderLogEntry log; + + const _LogEntryCard(this.log); + + void _copyToClipboard(BuildContext context, String text) { + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Copied to clipboard"), + duration: Duration(seconds: 2), + ), + ); + } + + @override + Widget build(BuildContext context) { + final dateFormat = DateFormat('yyyy-MM-dd HH:mm:ss.SSS'); + + return Card( + margin: EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: ExpansionTile( + title: Row( + children: [ + Icon( + log.isSuccess ? Icons.check_circle : Icons.error, + color: log.isSuccess ? Colors.green : Colors.red, + size: 20, + ), + SizedBox(width: 8), + Expanded( + child: Text( + "${log.provider.title} - ${log.function}", + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + subtitle: Text( + dateFormat.format(log.timestamp), + style: Theme.of(context).textTheme.bodySmall, + ), + children: [ + Padding( + padding: EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (log.error != null) ...[ + Text( + "Error:", + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Colors.red, + ), + ), + SizedBox(height: 4), + Container( + width: double.infinity, + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.red.shade200), + ), + child: GestureDetector( + onTap: () => _copyToClipboard(context, log.error!), + child: Text( + log.error!, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: Colors.black, + ), + ), + ), + ), + SizedBox(height: 16), + ], + if (log.stackTrace != null) ...[ + Text( + "Stack Trace:", + style: Theme.of(context).textTheme.titleSmall, + ), + SizedBox(height: 4), + Container( + width: double.infinity, + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.grey.shade300), + ), + child: GestureDetector( + onTap: () => _copyToClipboard(context, log.stackTrace!), + child: Text( + log.stackTrace!, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 10, + color: Colors.black, + ), + ), + ), + ), + SizedBox(height: 16), + ], + if (log.callStack != null) ...[ + Text( + "Call Stack:", + style: Theme.of(context).textTheme.titleSmall, + ), + SizedBox(height: 4), + Container( + width: double.infinity, + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.blue.shade200), + ), + child: GestureDetector( + onTap: () => _copyToClipboard(context, log.callStack!), + child: Text( + log.callStack!, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 10, + color: Colors.black, + ), + ), + ), + ), + SizedBox(height: 16), + ], + if (log.requestData != null) ...[ + Text( + "Request Data:", + style: Theme.of(context).textTheme.titleSmall, + ), + SizedBox(height: 4), + Container( + width: double.infinity, + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.green.shade50, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.green.shade200), + ), + child: GestureDetector( + onTap: () => _copyToClipboard(context, log.requestData.toString()), + child: Text( + log.requestData.toString(), + style: TextStyle( + fontFamily: 'monospace', + fontSize: 10, + color: Colors.black, + ), + ), + ), + ), + SizedBox(height: 16), + ], + if (log.responseData != null) ...[ + Text( + "Response Data:", + style: Theme.of(context).textTheme.titleSmall, + ), + SizedBox(height: 4), + Container( + width: double.infinity, + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.orange.shade50, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.orange.shade200), + ), + child: GestureDetector( + onTap: () => _copyToClipboard(context, log.responseData.toString()), + child: Text( + log.responseData.toString(), + style: TextStyle( + fontFamily: 'monospace', + fontSize: 10, + color: Colors.black, + ), + ), + ), + ), + ], + ], + ), + ), + ], + ), + ); + } +} + +class _ActionButtons extends StatelessWidget { + final ExchangeProviderLogsViewModel viewModel; + + const _ActionButtons(this.viewModel); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.all(16), + child: Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + children: [ + ElevatedButton.icon( + onPressed: () => _copyLogsAsText(context), + icon: Icon(Icons.copy, size: 18), + label: Text("Copy Text"), + style: ElevatedButton.styleFrom( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + ), + ElevatedButton.icon( + onPressed: () => _copyLogsAsJson(context), + icon: Icon(Icons.code, size: 18), + label: Text("Copy JSON"), + style: ElevatedButton.styleFrom( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + ), + ElevatedButton.icon( + onPressed: () => _showClearDialog(context), + icon: Icon(Icons.clear, size: 18), + label: Text("Clear"), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + ), + ], + ), + ); + } + + void _copyLogsAsText(BuildContext context) { + final logsText = viewModel.getLogsAsText(); + Clipboard.setData(ClipboardData(text: logsText)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Logs copied to clipboard")), + ); + } + + void _copyLogsAsJson(BuildContext context) { + final logsJson = viewModel.getLogsAsJson(); + Clipboard.setData(ClipboardData(text: logsJson)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Logs JSON copied to clipboard")), + ); + } + + void _showClearDialog(BuildContext context) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text("Clear Exchange Provider Logs"), + content: Text( + "Are you sure you want to clear all exchange provider logs? This action cannot be undone."), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text("Cancel"), + ), + TextButton( + onPressed: () { + viewModel.clearLogs(); + Navigator.of(context).pop(); + }, + child: Text("Clear", style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } +} diff --git a/lib/src/screens/exchange_trade/exchange_trade_external_send_page.dart b/lib/src/screens/exchange_trade/exchange_trade_external_send_page.dart index 92180bb9e..610d67ecb 100644 --- a/lib/src/screens/exchange_trade/exchange_trade_external_send_page.dart +++ b/lib/src/screens/exchange_trade/exchange_trade_external_send_page.dart @@ -73,7 +73,8 @@ class ExchangeTradeExternalSendPage extends BasePage { Routes.fullscreenQR, arguments: QrViewData( embeddedImagePath: exchangeTradeViewModel.qrImage, - data: exchangeTradeViewModel.trade.inputAddress ?? + data: exchangeTradeViewModel.paymentUri?.toString() ?? + exchangeTradeViewModel.trade.inputAddress ?? fetchingLabel, ), ); @@ -92,7 +93,8 @@ class ExchangeTradeExternalSendPage extends BasePage { ), ), child: QrImage( - data: exchangeTradeViewModel.trade.inputAddress ?? + data: exchangeTradeViewModel.paymentUri?.toString() ?? + exchangeTradeViewModel.trade.inputAddress ?? fetchingLabel, embeddedImagePath: exchangeTradeViewModel.qrImage, size: 230, diff --git a/lib/src/screens/receive/widgets/qr_widget.dart b/lib/src/screens/receive/widgets/qr_widget.dart index 140c5619f..fcb34df18 100644 --- a/lib/src/screens/receive/widgets/qr_widget.dart +++ b/lib/src/screens/receive/widgets/qr_widget.dart @@ -1,3 +1,4 @@ +import 'package:cake_wallet/core/payment_uris.dart'; import 'package:cake_wallet/entities/qr_view_data.dart'; import 'package:cake_wallet/src/widgets/primary_button.dart'; import 'package:cake_wallet/routes.dart'; diff --git a/lib/src/screens/send/widgets/send_card.dart b/lib/src/screens/send/widgets/send_card.dart index c12f0c1b0..217e84db7 100644 --- a/lib/src/screens/send/widgets/send_card.dart +++ b/lib/src/screens/send/widgets/send_card.dart @@ -121,8 +121,9 @@ class SendCardState extends State with AutomaticKeepAliveClientMixin with AutomaticKeepAliveClientMixin with AutomaticKeepAliveClientMixin Navigator.of(context).pushNamed(Routes.devNetworkRequests), ), + if (FeatureFlag.hasDevOptions) + SettingsCellWithArrow( + title: '[dev] exchange provider logs', + handler: (BuildContext context) => + Navigator.of(context).pushNamed(Routes.devExchangeProviderLogs), + ), Spacer(), SettingsVersionCell( title: S.of(context).version(_otherSettingsViewModel.currentVersion)), diff --git a/lib/utils/exchange_provider_logger.dart b/lib/utils/exchange_provider_logger.dart new file mode 100644 index 000000000..6e7c5533b --- /dev/null +++ b/lib/utils/exchange_provider_logger.dart @@ -0,0 +1,189 @@ +import 'dart:convert'; +import 'package:cake_wallet/exchange/exchange_provider_description.dart'; + +class ExchangeProviderLogEntry { + final DateTime timestamp; + final ExchangeProviderDescription provider; + final String function; + final String? error; + final String? stackTrace; + final String? callStack; + final Map? requestData; + final Map? responseData; + final bool isSuccess; + + ExchangeProviderLogEntry({ + required this.timestamp, + required this.provider, + required this.function, + this.error, + this.stackTrace, + this.callStack, + this.requestData, + this.responseData, + required this.isSuccess, + }); + + String toLogString() { + final buffer = StringBuffer(); + buffer.writeln('Provider: ${provider.title}'); + buffer.writeln('Function: $function'); + buffer.writeln('Timestamp: ${timestamp.toIso8601String()}'); + buffer.writeln('Success: $isSuccess'); + + if (error != null) { + buffer.writeln('Error: $error'); + } + + if (stackTrace != null) { + buffer.writeln('StackTrace: $stackTrace'); + } + + if (callStack != null) { + buffer.writeln('CallStack: $callStack'); + } + + if (requestData != null) { + buffer.writeln('Request: ${json.encode(requestData)}'); + } + + if (responseData != null) { + buffer.writeln('Response: ${json.encode(responseData)}'); + } + + buffer.writeln('---'); + return buffer.toString(); + } + + Map toJson() { + return { + 'timestamp': timestamp.toIso8601String(), + 'provider': provider.title, + 'function': function, + 'error': error, + 'stackTrace': stackTrace, + 'callStack': callStack, + 'requestData': requestData, + 'responseData': responseData, + 'isSuccess': isSuccess, + }; + } + + factory ExchangeProviderLogEntry.fromJson(Map json) { + final allProviders = [ + ExchangeProviderDescription.xmrto, + ExchangeProviderDescription.changeNow, + ExchangeProviderDescription.morphToken, + ExchangeProviderDescription.sideShift, + ExchangeProviderDescription.simpleSwap, + ExchangeProviderDescription.trocador, + ExchangeProviderDescription.exolix, + ExchangeProviderDescription.all, + ExchangeProviderDescription.thorChain, + ExchangeProviderDescription.swapTrade, + ExchangeProviderDescription.letsExchange, + ExchangeProviderDescription.stealthEx, + ExchangeProviderDescription.chainflip, + ExchangeProviderDescription.xoSwap, + ]; + + return ExchangeProviderLogEntry( + timestamp: DateTime.parse(json['timestamp'] as String), + provider: allProviders.firstWhere( + (p) => p.title == json['provider'] as String, + orElse: () => ExchangeProviderDescription.changeNow, + ), + function: json['function'] as String, + error: json['error'] as String?, + stackTrace: json['stackTrace'] as String?, + callStack: json['callStack'] as String?, + requestData: json['requestData'] as Map?, + responseData: json['responseData'] as Map?, + isSuccess: json['isSuccess'] as bool, + ); + } +} + +class ExchangeProviderLogger { + static final List _logs = []; + static const int maxLogs = 100; + + static List get logs => List.unmodifiable(_logs); + + static void logSuccess({ + required ExchangeProviderDescription provider, + required String function, + Map? requestData, + Map? responseData, + String? callStack, + }) { + final entry = ExchangeProviderLogEntry( + timestamp: DateTime.now(), + provider: provider, + function: function, + requestData: requestData, + responseData: responseData, + callStack: callStack, + isSuccess: true, + ); + + _addLog(entry); + } + + static void logError({ + required ExchangeProviderDescription provider, + required String function, + required dynamic error, + StackTrace? stackTrace, + Map? requestData, + String? callStack, + }) { + final entry = ExchangeProviderLogEntry( + timestamp: DateTime.now(), + provider: provider, + function: function, + error: error.toString(), + stackTrace: stackTrace?.toString(), + requestData: requestData, + callStack: callStack, + isSuccess: false, + ); + + _addLog(entry); + } + + static void _addLog(ExchangeProviderLogEntry entry) { + _logs.insert(0, entry); + + if (_logs.length > maxLogs * 2) { + final excessCount = _logs.length - maxLogs; + _logs.removeRange(0, excessCount); + } + } + + static void clearLogs() { + _logs.clear(); + } + + static String getLogsAsText() { + if (_logs.isEmpty) return 'No exchange provider logs available'; + + final buffer = StringBuffer(); + buffer.writeln('Exchange Provider Logs'); + buffer.writeln('Generated: ${DateTime.now().toIso8601String()}'); + buffer.writeln('Total logs: ${_logs.length}'); + buffer.writeln('Success logs: ${_logs.where((log) => log.isSuccess).length}'); + buffer.writeln('Error logs: ${_logs.where((log) => !log.isSuccess).length}'); + buffer.writeln(''); + + for (final log in _logs) { + buffer.writeln(log.toLogString()); + } + + return buffer.toString(); + } + + static String getLogsAsJson() { + return json.encode(_logs.map((log) => log.toJson()).toList()); + } +} diff --git a/lib/utils/payment_request.dart b/lib/utils/payment_request.dart index 0fc47b6dc..c6519897c 100644 --- a/lib/utils/payment_request.dart +++ b/lib/utils/payment_request.dart @@ -1,10 +1,9 @@ +import 'package:cake_wallet/core/payment_uris.dart'; import 'package:cake_wallet/nano/nano.dart'; -import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_view_model.dart'; -import 'package:cw_core/format_fixed.dart'; class PaymentRequest { PaymentRequest(this.address, this.amount, this.note, this.scheme, this.pjUri, - {this.callbackUrl, this.callbackMessage}); + {this.callbackUrl, this.callbackMessage, this.contractAddress}); factory PaymentRequest.fromUri(Uri? uri) { var address = ""; @@ -15,6 +14,7 @@ class PaymentRequest { String? callbackUrl; String? callbackMessage; String? pjUri; + String? contractAddress; if (uri != null) { if (uri.queryParameters['pj'] != null) { @@ -34,6 +34,7 @@ class PaymentRequest { address = paymentUri.address; amount = paymentUri.amount; + contractAddress = paymentUri.contractAddress; } } @@ -41,8 +42,6 @@ class PaymentRequest { scheme = walletType ?? "nano"; } - - if (nano != null) { if (amount.isNotEmpty) { if (address.contains("nano")) { @@ -61,6 +60,7 @@ class PaymentRequest { pjUri, callbackUrl: callbackUrl, callbackMessage: callbackMessage, + contractAddress: contractAddress, ); } @@ -71,55 +71,5 @@ class PaymentRequest { final String? pjUri; final String? callbackUrl; final String? callbackMessage; -} - -class ERC681URI extends PaymentURI { - final int chainId; final String? contractAddress; - - ERC681URI({ - required this.chainId, - required super.address, - required super.amount, - required this.contractAddress, - }); - - factory ERC681URI.fromUri(Uri uri) { - final (isContract, targetAddress) = _getTargetAddress(uri.path); - final chainId = _getChainID(uri.path); - - final address = isContract ? uri.queryParameters["address"] ?? '' : targetAddress; - final amount = isContract - ? uri.queryParameters["uint256"] - : uri.queryParameters["value"]; - - var formatedAmount = ""; - - if (amount != null) { - formatedAmount = formatFixed(BigInt.parse(amount), 18); - } else { - formatedAmount = uri.queryParameters["amount"] ?? ""; - } - - return ERC681URI( - chainId: chainId, - address: address, - amount: formatedAmount, - contractAddress: isContract ? targetAddress : null, - ); - } - - static int _getChainID(String path) { - return int.parse(RegExp( - r'@\d*', - ).firstMatch(path)?.group(0)?.replaceAll("@", "") ?? - "1"); - } - - static (bool, String) _getTargetAddress(String path) { - final targetAddress = RegExp(r'^(0x)?[0-9a-f]{40}', caseSensitive: false) - .firstMatch(path)! - .group(0)!; - return (path.contains("/"), targetAddress); - } } diff --git a/lib/utils/token_utilities.dart b/lib/utils/token_utilities.dart new file mode 100644 index 000000000..969b59687 --- /dev/null +++ b/lib/utils/token_utilities.dart @@ -0,0 +1,226 @@ +import 'package:cw_core/cake_hive.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/erc20_token.dart'; +import 'package:cw_core/spl_token.dart'; +import 'package:cw_core/tron_token.dart'; +import 'package:cw_core/wallet_base.dart'; +import 'package:cw_core/wallet_info.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:hive/hive.dart'; + +class TokenUtilities { + static Future> loadAllUniqueEvmTokens( + Box walletInfoSource, + ) async { + final evmWallets = walletInfoSource.values.where( + (w) => w.type == WalletType.ethereum || w.type == WalletType.polygon, + ); + + final seen = {}; + final unique = []; + + for (final wallet in evmWallets) { + final chain = wallet.type == WalletType.ethereum ? 'ETH' : 'POL'; + final box = await _openEvmTokensBoxFor(wallet); + + for (final t in box.values.where((t) => t.enabled)) { + final key = '$chain|${t.contractAddress.toLowerCase()}'; + if (seen.add(key)) { + unique.add(t); + } + } + } + + return unique; + } + + static Future> loadAllUniqueSolTokens( + Box walletInfoSource, + ) async { + final solWallets = walletInfoSource.values.where( + (w) => w.type == WalletType.solana, + ); + + final tokens = []; + for (final wallet in solWallets) { + final box = await _openSolTokensBoxFor(wallet); + tokens.addAll(box.values.where((t) => t.enabled)); + } + + final seen = {}; + final unique = []; + for (final token in tokens) { + final key = token.mintAddress.toLowerCase(); + if (seen.add(key)) unique.add(token); + } + return unique; + } + + static Future> loadAllUniqueTronTokens( + Box walletInfoSource, + ) async { + final tronWallets = walletInfoSource.values.where( + (w) => w.type == WalletType.tron, + ); + + final seen = {}; + final unique = []; + for (final wallet in tronWallets) { + final box = await _openTronTokensBoxFor(wallet); + for (final t in box.values.where((t) => t.enabled)) { + final key = t.contractAddress.toLowerCase(); + if (seen.add(key)) unique.add(t); + } + } + return unique; + } + + /// Finds a token by address across wallets depending on [walletType] + /// - EVM chains: match by contractAddress + /// - Solana: match by mintAddress + /// - Tron: match by contractAddress + static Future findTokenByAddress({ + required WalletType walletType, + required Box walletInfoSource, + required String address, + }) async { + final lower = address.toLowerCase(); + switch (walletType) { + case WalletType.ethereum: + case WalletType.polygon: + final tokens = await loadAllUniqueEvmTokens(walletInfoSource); + for (final t in tokens) { + if (t.contractAddress.toLowerCase() == lower) return t; + } + return null; + case WalletType.solana: + final solTokens = await loadAllUniqueSolTokens(walletInfoSource); + for (final t in solTokens) { + if (t.mintAddress.toLowerCase() == lower) return t; + } + return null; + case WalletType.tron: + final tronTokens = await loadAllUniqueTronTokens(walletInfoSource); + for (final t in tronTokens) { + if (t.contractAddress.toLowerCase() == lower) return t; + } + return null; + default: + return null; + } + } + + static Future> _openEvmTokensBoxFor( + WalletInfo walletInfo, + ) async { + final walletKey = walletInfo.name.replaceAll(' ', '_'); + final boxName = switch (walletInfo.type) { + WalletType.ethereum => '${walletKey}_${Erc20Token.ethereumBoxName}', + WalletType.polygon => '${walletKey}_${Erc20Token.polygonBoxName}', + _ => '${walletKey}_${Erc20Token.ethereumBoxName}', + }; + + if (CakeHive.isBoxOpen(boxName)) { + return CakeHive.box(boxName); + } + return CakeHive.openBox(boxName); + } + + static Future> _openSolTokensBoxFor(WalletInfo wallet) async { + final boxName = '${wallet.name.replaceAll(' ', '_')}_${SPLToken.boxName}'; + if (CakeHive.isBoxOpen(boxName)) { + return CakeHive.box(boxName); + } + return CakeHive.openBox(boxName); + } + + static Future> _openTronTokensBoxFor( + WalletInfo walletInfo, + ) async { + final boxName = '${walletInfo.name.replaceAll(' ', '_')}_${TronToken.boxName}'; + if (CakeHive.isBoxOpen(boxName)) { + return CakeHive.box(boxName); + } + return CakeHive.openBox(boxName); + } + + static Erc20Token? findErc20Token(CryptoCurrency currency, WalletBase wallet) { + if (currency is Erc20Token) return currency; + + // More of a fallback for us + for (final balanceCurrency in wallet.balance.keys) { + if (balanceCurrency is Erc20Token && _matchesToken(balanceCurrency, currency)) { + return balanceCurrency; + } + } + + return null; + } + + static bool isNativeToken(CryptoCurrency currency) { + final title = currency.title.toLowerCase(); + final tag = currency.tag?.toLowerCase(); + + return title == 'eth' || + title == 'ethereum' || + title == 'matic' || + title == 'polygon' || + title == 'bnb' || + title == 'bsc' || + title == 'avax' || + title == 'avalanche' || + tag == 'polygon' || + tag == 'bsc' || + tag == 'avalanche'; + } + + static int getChainId(CryptoCurrency currency) { + final title = currency.title.toLowerCase(); + final tag = currency.tag?.toLowerCase(); + + // Polygon + if (title == 'polygon' || title == 'matic' || tag == 'polygon') { + return 137; + } + + // BSC (Binance Smart Chain) + if (title == 'bsc' || title == 'bnb' || tag == 'bsc') { + return 56; + } + + // Avalanche C-Chain + if (title == 'avalanche' || title == 'avax' || tag == 'avalanche') { + return 43114; + } + + // Arbitrum One + if (title == 'arbitrum' || title == 'arb' || tag == 'arbitrum') { + return 42161; + } + + // Optimism + if (title == 'optimism' || title == 'op' || tag == 'optimism') { + return 10; + } + + // Base + if (title == 'base' || tag == 'base') { + return 8453; + } + + // Fantom Opera + if (title == 'fantom' || title == 'ftm' || tag == 'fantom') { + return 250; + } + + // Default to Ethereum mainnet + return 1; + } + + /// Checks if two currencies match (by title and tag) + static bool _matchesToken(Erc20Token token, CryptoCurrency currency) { + return token.title.toLowerCase() == currency.title.toLowerCase() && + (token.tag?.toLowerCase() == currency.tag?.toLowerCase() || + (token.tag == null && currency.tag == null)); + } +} diff --git a/lib/view_model/dev/exchange_provider_logs_view_model.dart b/lib/view_model/dev/exchange_provider_logs_view_model.dart new file mode 100644 index 000000000..7c4ac16fd --- /dev/null +++ b/lib/view_model/dev/exchange_provider_logs_view_model.dart @@ -0,0 +1,84 @@ +import 'package:cake_wallet/utils/exchange_provider_logger.dart'; +import 'package:cake_wallet/exchange/exchange_provider_description.dart'; +import 'package:mobx/mobx.dart'; + +part 'exchange_provider_logs_view_model.g.dart'; + +enum LogFilter { all, success, error } + +class ExchangeProviderLogsViewModel = ExchangeProviderLogsViewModelBase with _$ExchangeProviderLogsViewModel; + +abstract class ExchangeProviderLogsViewModelBase with Store { + @observable + bool isLoading = false; + + @observable + String? error; + + @observable + ObservableList logs = ObservableList(); + + @observable + LogFilter currentFilter = LogFilter.all; + + @computed + ObservableList get filteredLogs { + switch (currentFilter) { + case LogFilter.all: + return logs; + case LogFilter.success: + return ObservableList.of(logs.where((log) => log.isSuccess).toList()); + case LogFilter.error: + return ObservableList.of(logs.where((log) => !log.isSuccess).toList()); + } + } + + @computed + int get totalLogs => logs.length; + + @computed + int get successLogs => logs.where((log) => log.isSuccess).length; + + @computed + int get errorLogs => logs.where((log) => !log.isSuccess).length; + + @computed + Map get logsByProvider { + final Map counts = {}; + for (final log in logs) { + counts[log.provider] = (counts[log.provider] ?? 0) + 1; + } + return counts; + } + + String getLogsAsText() => ExchangeProviderLogger.getLogsAsText(); + + String getLogsAsJson() => ExchangeProviderLogger.getLogsAsJson(); + + @action + void loadLogs() { + isLoading = true; + error = null; + + try { + logs.clear(); + logs.addAll(ExchangeProviderLogger.logs); + } catch (e) { + error = e.toString(); + } finally { + isLoading = false; + } + } + + @action + void clearLogs() { + ExchangeProviderLogger.clearLogs(); + logs.clear(); + } + + @action + void refreshLogs() => loadLogs(); + + @action + void setFilter(LogFilter filter) => currentFilter = filter; +} diff --git a/lib/view_model/exchange/exchange_trade_view_model.dart b/lib/view_model/exchange/exchange_trade_view_model.dart index c35519083..3ff7037be 100644 --- a/lib/view_model/exchange/exchange_trade_view_model.dart +++ b/lib/view_model/exchange/exchange_trade_view_model.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:cake_wallet/core/payment_uris.dart'; import 'package:cake_wallet/entities/calculate_fiat_amount.dart'; import 'package:cake_wallet/entities/fiat_currency.dart'; import 'package:cake_wallet/exchange/exchange_provider_description.dart'; @@ -20,12 +21,14 @@ import 'package:cake_wallet/src/screens/exchange_trade/exchange_trade_item.dart' import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart'; import 'package:cake_wallet/store/dashboard/trades_store.dart'; import 'package:cake_wallet/utils/qr_util.dart'; +import 'package:cake_wallet/utils/token_utilities.dart'; import 'package:cake_wallet/view_model/send/fees_view_model.dart'; import 'package:cake_wallet/view_model/send/output.dart'; import 'package:cake_wallet/view_model/send/send_view_model.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/wallet_base.dart'; +import 'package:cw_core/wallet_type.dart'; import 'package:hive/hive.dart'; import 'package:mobx/mobx.dart'; @@ -317,6 +320,79 @@ abstract class ExchangeTradeViewModelBase with Store { _isTronToken(); } + PaymentURI? get paymentUri { + final inputAddress = trade.inputAddress; + final amount = trade.amount; + final fromCurrency = trade.from ?? trade.userCurrencyFrom; + + if (inputAddress == null || inputAddress.isEmpty || fromCurrency == null) { + return null; + } + + switch (wallet.type) { + case WalletType.bitcoin: + return BitcoinURI(amount: amount, address: inputAddress); + case WalletType.litecoin: + return LitecoinURI(amount: amount, address: inputAddress); + case WalletType.bitcoinCash: + return BitcoinCashURI(amount: amount, address: inputAddress); + case WalletType.dogecoin: + return DogeURI(amount: amount, address: inputAddress); + case WalletType.ethereum: + return _createERC681URI(fromCurrency, inputAddress, amount); + // TODO: Expand ERC681URI support to Polygon(modify decoding flow for QRs, pay anything, and deep link handling) + case WalletType.polygon: + return PolygonURI(amount: amount, address: inputAddress); + case WalletType.solana: + return SolanaURI(amount: amount, address: inputAddress); + case WalletType.tron: + return TronURI(amount: amount, address: inputAddress); + case WalletType.monero: + return MoneroURI(amount: amount, address: inputAddress); + case WalletType.wownero: + return WowneroURI(amount: amount, address: inputAddress); + case WalletType.zano: + return ZanoURI(amount: amount, address: inputAddress); + case WalletType.decred: + return DecredURI(amount: amount, address: inputAddress); + case WalletType.haven: + return HavenURI(amount: amount, address: inputAddress); + case WalletType.nano: + return NanoURI(amount: amount, address: inputAddress); + default: + return null; + } + } + + @action + PaymentURI? _createERC681URI(CryptoCurrency currency, String address, String amount) { + final chainId = TokenUtilities.getChainId(currency); + final isNativeToken = TokenUtilities.isNativeToken(currency); + + if (isNativeToken) { + return ERC681URI( + chainId: chainId, + address: address, + amount: amount, + contractAddress: null, + ); + } else { + if (wallet.type == WalletType.polygon || wallet.type == WalletType.ethereum) { + final erc20Token = TokenUtilities.findErc20Token(currency, wallet); + + if (erc20Token != null) { + return ERC681URI( + chainId: chainId, + address: address, + amount: amount, + contractAddress: erc20Token.contractAddress, + ); + } + } + return null; + } + } + @computed String get qrImage => getQrImage(wallet.type); } diff --git a/lib/view_model/exchange/exchange_view_model.dart b/lib/view_model/exchange/exchange_view_model.dart index abb56ce15..c1f30c621 100644 --- a/lib/view_model/exchange/exchange_view_model.dart +++ b/lib/view_model/exchange/exchange_view_model.dart @@ -39,10 +39,10 @@ import 'package:cake_wallet/store/dashboard/trades_store.dart'; import 'package:cake_wallet/store/settings_store.dart'; import 'package:cake_wallet/store/templates/exchange_template_store.dart'; import 'package:cake_wallet/utils/feature_flag.dart'; +import 'package:cake_wallet/utils/token_utilities.dart'; import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart'; import 'package:cake_wallet/view_model/send/fees_view_model.dart'; import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_list_view_model.dart'; -import 'package:cw_core/cake_hive.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/erc20_token.dart'; import 'package:cw_core/spl_token.dart'; @@ -142,7 +142,11 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with } }); - bestRateSync = Timer.periodic(Duration(seconds: 10), (timer) => calculateBestRate()); + bestRateSync = Timer.periodic(Duration(seconds: 10), (timer) { + if (tradeState is! TradeIsCreating) { + calculateBestRate(); + } + }); isDepositAddressEnabled = !(depositCurrency == wallet.currency); depositAmount = ''; @@ -536,19 +540,27 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with ), ), ); - _sortedAvailableProviders.clear(); + + // We'll use a new SplayTreeMap to avoid concurrent modification issues + final newSortedProviders = + SplayTreeMap((double a, double b) => b.compareTo(a)); for (int i = 0; i < result.length; i++) { if (result[i] != 0) { /// add this provider as its valid for this trade try { - _sortedAvailableProviders[result[i]] = _providers[i]; + newSortedProviders[result[i]] = _providers[i]; } catch (e) { // will throw "Concurrent modification during iteration" error if modified at the same // time [createTrade] is called, as this is not a normal map, but a sorted map } } } + + // Replace the old map with the new one + _sortedAvailableProviders.clear(); + _sortedAvailableProviders.addAll(newSortedProviders); + if (_sortedAvailableProviders.isNotEmpty) bestRate = _sortedAvailableProviders.keys.first; } @@ -637,10 +649,26 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with } } + // Ensure we have providers available before attempting to create trade + if (_sortedAvailableProviders.isEmpty) { + await calculateBestRate(); + + if (_sortedAvailableProviders.isEmpty) { + tradeState = TradeIsCreatedFailure( + title: S.current.trade_not_created, + error: S.current.none_of_selected_providers_can_exchange); + return; + } + } + try { - for (var i = 0; i < _sortedAvailableProviders.values.length; i++) { - final provider = _sortedAvailableProviders.values.toList()[i]; - final providerRate = _sortedAvailableProviders.keys.toList()[i]; + // snapshot of providers to avoid concurrent modification issues + final providersSnapshot = _sortedAvailableProviders.values.toList(); + final ratesSnapshot = _sortedAvailableProviders.keys.toList(); + + for (var i = 0; i < providersSnapshot.length; i++) { + final provider = providersSnapshot[i]; + final providerRate = ratesSnapshot[i]; if (!(await provider.checkIsAvailable())) continue; @@ -675,8 +703,10 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with } else { try { if (provider is SwapTradeExchangeProvider) { - final destinationAmount = (fiatConversionStore.prices[request.toCurrency] ?? 0.00) * (double.tryParse(request.toAmount) ?? 0.00); - final sendingAmount = (fiatConversionStore.prices[request.fromCurrency] ?? 0.00) * (double.tryParse(request.fromAmount) ?? 0.00); + final destinationAmount = (fiatConversionStore.prices[request.toCurrency] ?? 0.00) * + (double.tryParse(request.toAmount) ?? 0.00); + final sendingAmount = (fiatConversionStore.prices[request.fromCurrency] ?? 0.00) * + (double.tryParse(request.fromAmount) ?? 0.00); if (destinationAmount > 2000 || sendingAmount > 2000) { continue; @@ -1066,47 +1096,9 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with // Adding user's Erc20 tokens to the list of currencies - Future> _openEvmTokensBoxFor(WalletInfo walletInfo) async { - final walletKey = walletInfo.name.replaceAll(" ", "_"); - - final boxName = switch (walletInfo.type) { - WalletType.ethereum => '${walletKey}_${Erc20Token.ethereumBoxName}', - WalletType.polygon => '${walletKey}_${Erc20Token.polygonBoxName}', - _ => '${walletKey}_${Erc20Token.ethereumBoxName}', - }; - - if (CakeHive.isBoxOpen(boxName)) { - return CakeHive.box(boxName); - } - return CakeHive.openBox(boxName); - } - - Future> _loadAllUniqueEvmTokens() async { - final evmWallets = walletInfoSource.values.where( - (w) => w.type == WalletType.ethereum || w.type == WalletType.polygon, - ); - - final seen = {}; - final unique = []; - - for (final wallet in evmWallets) { - final chain = wallet.type == WalletType.ethereum ? 'ETH' : 'POL'; - final box = await _openEvmTokensBoxFor(wallet); - - for (final t in box.values.where((t) => t.enabled)) { - final key = '$chain|${t.contractAddress.toLowerCase()}'; - if (seen.add(key)) { - unique.add(t); - } - } - } - - return unique; - } - @action Future _injectUserEthTokensIntoCurrencyLists() async { - final userTokens = await _loadAllUniqueEvmTokens(); + final userTokens = await TokenUtilities.loadAllUniqueEvmTokens(walletInfoSource); final toAddReceive = []; final toAddDeposit = []; @@ -1132,35 +1124,6 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with // Adding user's Solana tokens to the list of currencies - Future> _openSolTokensBoxFor(WalletInfo wallet) async { - final boxName = '${wallet.name.replaceAll(" ", "_")}_${SPLToken.boxName}'; - if (CakeHive.isBoxOpen(boxName)) { - return CakeHive.box(boxName); - } - return CakeHive.openBox(boxName); - } - - Future> _loadAllUniqueSolTokens() async { - final solWallets = walletInfoSource.values.where((wallet) => wallet.type == WalletType.solana); - final tokens = []; - - for (final wallet in solWallets) { - final box = await _openSolTokensBoxFor(wallet); - tokens.addAll(box.values.where((t) => t.enabled)); - } - - final seen = {}; - final unique = []; - for (final token in tokens) { - final key = token.mintAddress.toLowerCase(); - if (!seen.contains(key)) { - seen.add(key); - unique.add(token); - } - } - return unique; - } - bool _listContainsSplToken(List list, SPLToken token) { return list.any((item) { if (item is SPLToken) { @@ -1173,7 +1136,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with @action Future _injectUserSplTokensIntoCurrencyLists() async { - final userTokens = await _loadAllUniqueSolTokens(); + final userTokens = await TokenUtilities.loadAllUniqueSolTokens(walletInfoSource); final toAddReceive = []; final toAddDeposit = []; @@ -1189,31 +1152,6 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with // Adding user's Tron tokens to the list of currencies - Future> _openTronTokensBoxFor(WalletInfo walletInfo) async { - final boxName = '${walletInfo.name.replaceAll(" ", "_")}_${TronToken.boxName}'; - if (CakeHive.isBoxOpen(boxName)) { - return CakeHive.box(boxName); - } - return CakeHive.openBox(boxName); - } - - Future> _loadAllUniqueTronTokens() async { - final tronWallets = walletInfoSource.values.where((w) => w.type == WalletType.tron); - - final seen = {}; - final unique = []; - - for (final wallet in tronWallets) { - final box = await _openTronTokensBoxFor(wallet); - for (final t in box.values.where((t) => t.enabled)) { - final key = t.contractAddress.toLowerCase(); - if (seen.add(key)) unique.add(t); - } - } - - return unique; - } - bool _listContainsTronToken(List list, TronToken token) { return list.any((item) { if (item is TronToken) { @@ -1226,7 +1164,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with @action Future _injectUserTronTokensIntoCurrencyLists() async { - final userTokens = await _loadAllUniqueTronTokens(); + final userTokens = await TokenUtilities.loadAllUniqueTronTokens(walletInfoSource); final toAddReceive = []; final toAddDeposit = []; diff --git a/lib/view_model/send/send_view_model.dart b/lib/view_model/send/send_view_model.dart index 841673d16..2a956a5e2 100644 --- a/lib/view_model/send/send_view_model.dart +++ b/lib/view_model/send/send_view_model.dart @@ -37,9 +37,7 @@ import 'package:cake_wallet/utils/payment_request.dart'; import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/balance_view_model.dart'; import 'package:cake_wallet/view_model/hardware_wallet/ledger_view_model.dart'; -import 'package:cake_wallet/view_model/payment/payment_view_model.dart'; import 'package:cake_wallet/view_model/send/fees_view_model.dart'; -import 'package:cake_wallet/view_model/wallet_switcher_view_model.dart'; import 'package:cake_wallet/view_model/send/output.dart'; import 'package:cake_wallet/view_model/send/send_template_view_model.dart'; import 'package:cake_wallet/view_model/send/send_view_model_state.dart'; @@ -53,10 +51,12 @@ import 'package:cw_core/sync_status.dart'; import 'package:cw_core/transaction_info.dart'; import 'package:cw_core/unspent_coin_type.dart'; import 'package:cw_core/utils/print_verbose.dart'; +import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; import 'package:mobx/mobx.dart'; +import 'package:cake_wallet/utils/token_utilities.dart'; import 'package:shared_preferences/shared_preferences.dart'; part 'send_view_model.g.dart'; @@ -72,14 +72,14 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor wallet.type == WalletType.solana || wallet.type == WalletType.tron || wallet.type == WalletType.zano; - + for (final output in outputs) { output.updateWallet(wallet); } - + // Update unspent coins list view model with the new wallet reference unspentCoinsListViewModel.updateWallet(wallet); - + // Update sending balance to reflect the new wallet's balance updateSendingBalance(); } @@ -95,7 +95,8 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor this.transactionDescriptionBox, this.ledgerViewModel, this.unspentCoinsListViewModel, - this.feesViewModel, { + this.feesViewModel, + this.walletInfoSource, { this.coinTypeToSpendFrom = UnspentCoinType.nonMweb, }) : state = InitialExecutionState(), currencies = appStore.wallet!.balance.keys.toList(), @@ -121,6 +122,8 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor ObservableList outputs; + final Box walletInfoSource; + @observable UnspentCoinType coinTypeToSpendFrom; @@ -337,8 +340,12 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor ].contains(wallet.type); @computed - bool get isElectrumWallet => - [WalletType.bitcoin, WalletType.litecoin, WalletType.bitcoinCash, WalletType.dogecoin].contains(wallet.type); + bool get isElectrumWallet => [ + WalletType.bitcoin, + WalletType.litecoin, + WalletType.bitcoinCash, + WalletType.dogecoin + ].contains(wallet.type); @observable CryptoCurrency selectedCryptoCurrency; @@ -924,4 +931,17 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor @observable String? payjoinUri; + + @action + Future fetchTokenForContractAddress(String contractAddress) async { + final token = await TokenUtilities.findTokenByAddress( + walletType: wallet.type, + walletInfoSource: walletInfoSource, + address: contractAddress, + ); + + if (token != null) { + selectedCryptoCurrency = token as CryptoCurrency; + } + } } diff --git a/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart b/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart index 7e55137a6..5e0525ce0 100644 --- a/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart +++ b/lib/view_model/wallet_address_list/wallet_address_list_view_model.dart @@ -3,6 +3,7 @@ import 'dart:core'; import 'package:cake_wallet/bitcoin/bitcoin.dart'; import 'package:cake_wallet/core/fiat_conversion_service.dart'; +import 'package:cake_wallet/core/payment_uris.dart'; import 'package:cake_wallet/core/wallet_change_listener_view_model.dart'; import 'package:cake_wallet/entities/auto_generate_subaddress_status.dart'; import 'package:cake_wallet/entities/fiat_api_mode.dart'; @@ -37,228 +38,6 @@ part 'wallet_address_list_view_model.g.dart'; class WalletAddressListViewModel = WalletAddressListViewModelBase with _$WalletAddressListViewModel; -abstract class PaymentURI { - PaymentURI({required this.amount, required this.address}); - - final String amount; - final String address; -} - -class MoneroURI extends PaymentURI { - MoneroURI({required super.amount, required super.address}); - - @override - String toString() { - var base = 'monero:$address'; - - if (amount.isNotEmpty) { - base += '?tx_amount=${amount.replaceAll(',', '.')}'; - } - - return base; - } -} - -class HavenURI extends PaymentURI { - HavenURI({required super.amount, required super.address}); - - @override - String toString() { - var base = 'haven:$address'; - - if (amount.isNotEmpty) { - base += '?tx_amount=${amount.replaceAll(',', '.')}'; - } - - return base; - } -} - -class BitcoinURI extends PaymentURI { - BitcoinURI({required super.amount, required super.address, this.pjUri = ''}); - - final String pjUri; - - @override - String toString() { - final qp = {}; - - if (amount.isNotEmpty) qp['amount'] = amount.replaceAll(',', '.'); - if (pjUri.isNotEmpty && !address.startsWith("sp")) { - qp['pjos'] = '0'; - qp['pj'] = pjUri; - } - - return Uri(scheme: 'bitcoin', path: address, queryParameters: qp).toString(); - } -} - -class LitecoinURI extends PaymentURI { - LitecoinURI({required super.amount, required super.address}); - - @override - String toString() { - var base = 'litecoin:$address'; - - if (amount.isNotEmpty) { - base += '?amount=${amount.replaceAll(',', '.')}'; - } - - return base; - } -} - -class EthereumURI extends PaymentURI { - EthereumURI({required super.amount, required super.address}); - - @override - String toString() { - var base = 'ethereum:$address'; - - if (amount.isNotEmpty) { - base += '?amount=${amount.replaceAll(',', '.')}'; - } - - return base; - } -} - -class BitcoinCashURI extends PaymentURI { - BitcoinCashURI({required super.amount, required super.address}); - - @override - String toString() { - var base = address; - - if (amount.isNotEmpty) { - base += '?amount=${amount.replaceAll(',', '.')}'; - } - - return base; - } -} - -class NanoURI extends PaymentURI { - NanoURI({required super.amount, required super.address}); - - @override - String toString() { - var base = 'nano:$address'; - if (amount.isNotEmpty) { - base += '?amount=${amount.replaceAll(',', '.')}'; - } - - return base; - } -} - -class PolygonURI extends PaymentURI { - PolygonURI({required super.amount, required super.address}); - - @override - String toString() { - var base = 'polygon:$address'; - - if (amount.isNotEmpty) { - base += '?amount=${amount.replaceAll(',', '.')}'; - } - - return base; - } -} - -class SolanaURI extends PaymentURI { - SolanaURI({required super.amount, required super.address}); - - @override - String toString() { - var base = 'solana:$address'; - - if (amount.isNotEmpty) { - base += '?amount=${amount.replaceAll(',', '.')}'; - } - - return base; - } -} - -class TronURI extends PaymentURI { - TronURI({required super.amount, required super.address}); - - @override - String toString() { - var base = 'tron:$address'; - - if (amount.isNotEmpty) { - base += '?amount=${amount.replaceAll(',', '.')}'; - } - - return base; - } -} - -class WowneroURI extends PaymentURI { - WowneroURI({required super.amount, required super.address}); - - @override - String toString() { - var base = 'wownero:$address'; - - if (amount.isNotEmpty) { - base += '?tx_amount=${amount.replaceAll(',', '.')}'; - } - - return base; - } -} - -class ZanoURI extends PaymentURI { - ZanoURI({required String amount, required String address}) - : super(amount: amount, address: address); - - @override - String toString() { - var base = 'zano:' + address; - - if (amount.isNotEmpty) { - base += '?amount=${amount.replaceAll(',', '.')}'; - } - - return base; - } -} - -class DecredURI extends PaymentURI { - DecredURI({required String amount, required String address}) - : super(amount: amount, address: address); - - @override - String toString() { - var base = 'decred:' + address; - - if (amount.isNotEmpty) { - base += '?amount=${amount.replaceAll(',', '.')}'; - } - - return base; - } -} - -class DogeURI extends PaymentURI { - DogeURI({required String amount, required String address}) - : super(amount: amount, address: address); - - @override - String toString() { - var base = 'doge:' + address; - - if (amount.isNotEmpty) { - base += '?amount=${amount.replaceAll(',', '.')}'; - } - - return base; - } -} abstract class WalletAddressListViewModelBase extends WalletChangeListenerViewModel with Store { WalletAddressListViewModelBase({