import 'dart:convert'; import 'package:cake_wallet/.secrets.g.dart' as secrets; import 'package:cake_wallet/core/lightning_invoice_service.dart'; import 'package:cake_wallet/exchange/exchange_provider_description.dart'; import 'package:cake_wallet/exchange/limits.dart'; import 'package:cake_wallet/exchange/provider/exchange_provider.dart'; import 'package:cake_wallet/exchange/trade.dart'; import 'package:cake_wallet/exchange/trade_not_found_exception.dart'; import 'package:cake_wallet/exchange/trade_request.dart'; import 'package:cake_wallet/exchange/trade_state.dart'; 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(); static final apiKey = isMoneroOnly ? secrets.exolixMoneroApiKey : secrets.exolixCakeWalletApiKey; static const apiBaseUrl = 'exolix.com'; static const transactionsPath = '/api/v2/transactions'; static const ratePath = '/api/v2/rate'; @override String get title => 'Exolix'; @override bool get isAvailable => true; @override bool get isEnabled => true; @override bool get supportsFixedRate => true; @override ExchangeProviderDescription get description => ExchangeProviderDescription.exolix; @override Future checkIsAvailable() async => true; @override Future fetchLimits({ required CryptoCurrency from, required CryptoCurrency to, required bool isFixedRateMode, }) async { final params = { 'rateType': _getRateType(isFixedRateMode), 'amount': '1', 'apiToken': apiKey, }; if (isFixedRateMode) { params['coinFrom'] = _normalizeCurrency(to); params['coinTo'] = _normalizeCurrency(_overrideFromCryptoCurrency(from)); params['networkFrom'] = _networkFor(to); params['networkTo'] = _networkFor(from); } else { params['coinFrom'] = _normalizeCurrency(_overrideFromCryptoCurrency(from)); params['coinTo'] = _normalizeCurrency(to); params['networkFrom'] = _networkFor(from); params['networkTo'] = _networkFor(to); } // Maximum of 2 attempts to fetch limits for (int i = 0; i < 2; i++) { final uri = Uri.https(apiBaseUrl, ratePath, params); final response = await ProxyWrapper().get(clearnetUri: uri); if (response.statusCode == 200) { final responseJSON = json.decode(response.body) as Map; final minAmount = responseJSON['minAmount']; final maxAmount = responseJSON['maxAmount']; return Limits(min: _toDouble(minAmount), max: _toDouble(maxAmount)); } else if (response.statusCode == 422) { final errorResponse = json.decode(response.body) as Map; if (errorResponse.containsKey('minAmount')) { params['amount'] = errorResponse['minAmount'].toString(); continue; } throw Exception('Error 422: ${errorResponse['message'] ?? 'Unknown error'}'); } else { throw Exception('Unexpected HTTP status: ${response.statusCode}'); } } throw Exception('Failed to fetch limits after retrying.'); } @override Future fetchRate({ required CryptoCurrency from, required CryptoCurrency to, required double amount, required bool isFixedRateMode, required bool isReceiveAmount }) async { try { if (amount == 0) return 0.0; final params = { 'coinFrom': _normalizeCurrency(_overrideFromCryptoCurrency(from)), 'coinTo': _normalizeCurrency(to), 'networkFrom': _networkFor(from), 'networkTo': _networkFor(to), 'rateType': _getRateType(isFixedRateMode), 'apiToken': apiKey, }; if (isReceiveAmount) params['withdrawalAmount'] = amount.toString(); else params['amount'] = amount.toString(); final uri = Uri.https(apiBaseUrl, ratePath, params); final response = await ProxyWrapper().get(clearnetUri: uri); final responseJSON = json.decode(response.body) as Map; 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); } final rate = double.tryParse(responseJSON['rate']?.toString() ?? '') ?? 0.0; 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()); printV(s.toString()); return 0.0; } } @override Future createTrade({ required TradeRequest request, required bool isFixedRateMode, required bool isSendAll, }) async { final headers = {'Content-Type': 'application/json'}; final body = { 'coinFrom': _normalizeCurrency(_overrideFromCryptoCurrency(request.fromCurrency)), 'coinTo': _normalizeCurrency(_overrideToCryptoCurrency(request.toCurrency, request.toAddress)), 'networkFrom': _networkFor(request.fromCurrency), 'networkTo': _networkFor(request.toCurrency), 'withdrawalAddress': await _normalizeAddress(request.toAddress), 'refundAddress': await _normalizeAddress(request.refundAddress), 'rateType': _getRateType(isFixedRateMode), 'apiToken': apiKey, }; if (isFixedRateMode) body['withdrawalAmount'] = request.toAmount; else body['amount'] = request.fromAmount; final uri = Uri.https(apiBaseUrl, transactionsPath); final response = await ProxyWrapper().post( clearnetUri: uri, headers: headers, body: json.encode(body), ); if (response.statusCode == 400) { final responseJSON = json.decode(response.body) as Map; final errors = responseJSON['error'] 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) { 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; final inputAddress = responseJSON['depositAddress'] as String; final refundAddress = responseJSON['refundAddress'] as String?; final extraId = responseJSON['depositExtraId'] as String?; final payoutAddress = responseJSON['withdrawalAddress'] as String; 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, to: request.toCurrency, provider: description, inputAddress: inputAddress, refundAddress: refundAddress, extraId: extraId, createdAt: DateTime.now(), amount: amount, receiveAmount: receiveAmount ?? request.toAmount, state: TradeState.created, payoutAddress: payoutAddress, userCurrencyFromRaw: '${request.fromCurrency.title}_${request.fromCurrency.tag ?? ''}', userCurrencyToRaw: '${request.toCurrency.title}_${request.toCurrency.tag ?? ''}', isSendAll: isSendAll, ); } @override Future findTradeById({required String id}) async { final findTradeByIdPath = '$transactionsPath/$id'; final uri = Uri.https(apiBaseUrl, findTradeByIdPath); final response = await ProxyWrapper().get(clearnetUri: uri); if (response.statusCode == 404) throw TradeNotFoundException(id, provider: description); if (response.statusCode == 400) { final responseJSON = json.decode(response.body) as Map; final errors = responseJSON['errors'] as Map; final errorMessage = errors.values.join(', '); throw TradeNotFoundException(id, provider: description, description: errorMessage); } if (response.statusCode != 200) throw Exception('Unexpected http status: ${response.statusCode}'); final responseJSON = json.decode(response.body) as Map; // Parsing 'from' currency final coinFrom = responseJSON['coinFrom']['coinCode'] as String; final coinFromNetwork = responseJSON['coinFrom']['network'] as String?; final _normalizedFromNetwork = _normalizeNetworkType(coinFromNetwork ?? ''); final fromTag = coinFrom.toUpperCase() == _normalizedFromNetwork.toUpperCase() ? null : coinFromNetwork; final from = CryptoCurrency.safeParseCurrencyFromString(coinFrom, tag: fromTag); // Parsing 'to' currency final coinTo = responseJSON['coinTo']['coinCode'] as String; final coinToNetwork = responseJSON['coinTo']['network'] as String?; final _normalizedToNetwork = _normalizeNetworkType(coinToNetwork ?? ''); final toTag = coinTo.toUpperCase() == _normalizedToNetwork.toUpperCase() ? null : coinToNetwork; final to = CryptoCurrency.safeParseCurrencyFromString(coinTo, tag: toTag); final inputAddress = responseJSON['depositAddress'] as String; final amount = responseJSON['amount'].toString(); final status = responseJSON['status'] as String; final extraId = responseJSON['depositExtraId'] as String?; final outputTransaction = responseJSON['hashOut']['hash'] as String?; final payoutAddress = responseJSON['withdrawalAddress'] as String; return Trade( id: id, from: from, to: to, provider: description, inputAddress: inputAddress, amount: amount, state: TradeState.deserialize(raw: _prepareStatus(status)), extraId: extraId, outputTransaction: outputTransaction, payoutAddress: payoutAddress, userCurrencyFromRaw: '${coinFrom.toUpperCase()}' + '_' + '${fromTag?.toUpperCase() ?? ''}', userCurrencyToRaw: '${coinTo.toUpperCase()}' + '_' + '${toTag?.toUpperCase() ?? ''}', ); } String _getRateType(bool isFixedRate) => isFixedRate ? 'fixed' : 'float'; String _prepareStatus(String status) { switch (status) { case 'deleted': case 'error': return 'overdue'; default: return status; } } String _networkFor(CryptoCurrency currency) { switch (currency) { case CryptoCurrency.arb: return 'ARBITRUM'; case CryptoCurrency.btcln: return 'LIGHTNING'; default: return currency.tag != null ? _normalizeTag(currency.tag!) : currency.title; } } String _normalizeNetworkType(String network) { return switch (network.toUpperCase()) { 'ARBITRUM' => 'ARB', _ => network, }; } CryptoCurrency _overrideFromCryptoCurrency(CryptoCurrency currency) { if (currency == CryptoCurrency.zec) return CryptoCurrency.zaddr; // Sending is always shielded zcash return currency; } CryptoCurrency _overrideToCryptoCurrency(CryptoCurrency currency, String address) { if (RegExp(r'u1[a-zA-Z0-9]{100,300}').hasMatch(address) && currency == CryptoCurrency.zec) return CryptoCurrency.zaddr; // If the user pastes a unified address use shielded zcash return currency; } String _normalizeCurrency(CryptoCurrency currency) { switch (currency) { case CryptoCurrency.nano: return 'XNO'; case CryptoCurrency.bttc: return 'BTT'; case CryptoCurrency.zec: return 'ZEC'; case CryptoCurrency.zaddr: return 'ZEC-SHIELDED'; default: return currency.title; } } String _normalizeTag(String tag) { switch (tag) { case 'POLY': return 'Polygon'; case 'ARB': return 'Arbitrum'; default: return tag; } } Future _normalizeAddress(String address) async { if (address.startsWith('bitcoincash:')) return address.replaceFirst('bitcoincash:', ''); // Lightning addresses if(address.contains("@")) return await getBolt11FromLightingAddress(address) ?? address; return address; } static double? _toDouble(dynamic value) { if (value is int) { return value.toDouble(); } else if (value is double) { return value; } else if (value is String) { return double.tryParse(value); } return null; } }