import 'dart:convert'; import 'dart:math'; import 'package:cake_wallet/.secrets.g.dart' as secrets; 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_request.dart'; import 'package:cake_wallet/exchange/trade_state.dart'; 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: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}); static final List _supported = [ CryptoCurrency.btc, CryptoCurrency.eth, CryptoCurrency.usdc, CryptoCurrency.usdterc20, CryptoCurrency.flip, CryptoCurrency.wbtc, CryptoCurrency.sol, CryptoCurrency.usdcsol, CryptoCurrency.usdtSol, CryptoCurrency.arbEth, CryptoCurrency.usdcArb, CryptoCurrency.usdtArb, ]; static const _baseURL = 'chainflip-broker.io'; static const _assetsPath = '/assets'; static const _quotePath = '/quotes-native'; static const _swapPath = '/swap'; static const _txInfoPath = '/status-by-deposit-channel'; static const _affiliateBps = secrets.chainflipAffiliateFee; static const _affiliateKey = secrets.chainflipApiKey; final Box tradesStore; @override String get title => 'Chainflip'; @override bool get isAvailable => true; @override bool get isEnabled => true; @override bool get supportsFixedRate => false; @override ExchangeProviderDescription get description => ExchangeProviderDescription.chainflip; @override Future checkIsAvailable() async => true; @override Future fetchLimits( {required CryptoCurrency from, required CryptoCurrency to, required bool isFixedRateMode}) async { try { if(!_supported.contains(from) || !_supported.contains(to)) { throw Exception('No rates found for $from to $to'); } final assetId = _normalizeCurrency(from); final assetsResponse = await _getAssets(); final assets = assetsResponse['assets'] as List; final minAmount = assets.firstWhere( (asset) => asset['id'] == assetId, orElse: () => null)?['minimalAmountNative'] ?? '0'; if (minAmount == '0') throw Exception('No rates found for $from to $to'); return Limits(min: _amountFromNative(minAmount.toString(), from)); } catch (e) { printV(e.toString()); throw Exception('Chainflip failed to fetch limits'); } } @override Future fetchRate({ required CryptoCurrency from, required CryptoCurrency to, required double amount, required bool isFixedRateMode, required bool isReceiveAmount }) async { // TODO: It seems this rate is getting cached, and re-used for different amounts, can we not do this? try { if (amount == 0) return 0.0; if(!_supported.contains(from) || !_supported.contains(to)) return 0.0; final quoteParams = { 'apiKey': _affiliateKey, 'sourceAsset': _normalizeCurrency(from), 'destinationAsset': _normalizeCurrency(to), 'amount': _amountToNative(amount, from), 'commissionBps': _affiliateBps }; final quoteResponse = await _getSwapQuote(quoteParams); final expectedAmountOut = quoteResponse['egressAmountNative'] as String? ?? '0'; 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; } } @override Future createTrade( {required TradeRequest request, required bool isFixedRateMode, required bool isSendAll}) async { try { final maxSlippage = 2; final quoteParams = { 'apiKey': _affiliateKey, 'sourceAsset': _normalizeCurrency(request.fromCurrency), 'destinationAsset': _normalizeCurrency(request.toCurrency), 'amount': _amountToNative(double.parse(request.fromAmount), request.fromCurrency), 'commissionBps': _affiliateBps }; final quoteResponse = await _getSwapQuote(quoteParams); final estimatedPrice = quoteResponse['estimatedPrice'] as double; final minimumPrice = estimatedPrice * (100 - maxSlippage) / 100; final swapParams = { 'apiKey': _affiliateKey, 'sourceAsset': _normalizeCurrency(request.fromCurrency), 'destinationAsset': _normalizeCurrency(request.toCurrency), 'destinationAddress': request.toAddress, 'commissionBps': _affiliateBps, 'minimumPrice': minimumPrice.toString(), 'refundAddress': request.refundAddress, 'boostFee': '6', 'retryDurationInBlocks': '150' }; if (quoteResponse.containsKey('numberOfChunks') && quoteResponse.containsKey('chunkIntervalBlocks')) { swapParams.addAll({ 'numberOfChunks': quoteResponse['numberOfChunks'].toString(), 'chunkIntervalBlocks': quoteResponse['chunkIntervalBlocks'].toString(), }); } final swapResponse = await _openDepositChannel(swapParams); final id = '${swapResponse['issuedBlock']}-${swapResponse['network'].toString()}-${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, to: request.toCurrency, provider: description, inputAddress: swapResponse['address'].toString(), createdAt: DateTime.now(), amount: request.fromAmount, receiveAmount: request.toAmount, state: TradeState.waiting, payoutAddress: request.toAddress, userCurrencyFromRaw: '${request.fromCurrency.title}_${request.fromCurrency.tag ?? ''}', userCurrencyToRaw: '${request.toCurrency.title}_${request.toCurrency.tag ?? ''}', isSendAll: isSendAll); } 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; } } @override Future findTradeById({required String id}) async { try { final channelParts = id.split('-'); final network = channelParts[1]; final normalizedNetwork = _normalizeNetworkName(network); final statusParams = { 'apiKey': _affiliateKey, 'issuedBlock': channelParts[0], 'network': normalizedNetwork, 'channelId': channelParts[2] }; final statusResponse = await _getStatus(statusParams); if (statusResponse == null) throw Exception('Trade not found for id: $id'); final status = statusResponse['status']; final currentState = _determineState(status['state'].toString()); final depositAmount = status['deposit']?['amount']?.toString() ?? '0.0'; final receiveAmount = status['swapEgress']?['amount']?.toString() ?? '0.0'; final refundAmount = status['refundEgress']?['amount']?.toString() ?? '0.0'; final isRefund = status['refundEgress'] != null; final amount = isRefund ? refundAmount : receiveAmount; final from = status['sourceAsset'].toString(); final to = status['destinationAsset'].toString(); final newTrade = Trade( id: id, from: _toCurrency(status['sourceAsset'].toString()), to: _toCurrency(status['destinationAsset'].toString()), provider: description, amount: depositAmount, receiveAmount: amount, state: currentState, payoutAddress: status['destinationAddress'].toString(), outputTransaction: status['swapEgress']?['transactionReference']?.toString(), isRefund: isRefund, userCurrencyFromRaw: '${from.toUpperCase()}' + '_', userCurrencyToRaw: '${to.toUpperCase()}' + '_', ); // Find trade and update receiveAmount with the real value received final storedTrade = _getStoredTrade(id); if (storedTrade != null) { storedTrade.$2.receiveAmount = newTrade.receiveAmount; storedTrade.$2.outputTransaction = newTrade.outputTransaction; tradesStore.put(storedTrade.$1, storedTrade.$2); } return newTrade; } catch (e) { printV(e.toString()); rethrow; } } String _normalizeCurrency(CryptoCurrency currency) { final tag = currency.tag?.toLowerCase(); final title = currency.title.toLowerCase(); // Naive assets without network tag if (tag == null) return '$title.$title'; return '$title.$tag'; } String _normalizeNetworkName (String name) { final networkName = switch (name) { 'BITCOIN' => 'Bitcoin', 'ETHEREUM' => 'Ethereum', 'ARBITRUM' => 'Arbitrum', 'SOLANA' => 'Solana', _ => name }; return networkName; } CryptoCurrency? _toCurrency(String name) { final currency = switch (name) { 'btc.btc' => CryptoCurrency.btc, 'eth.eth' => CryptoCurrency.eth, 'usdc.eth' => CryptoCurrency.usdc, 'usdt.eth' => CryptoCurrency.usdterc20, 'flip.eth' => CryptoCurrency.flip, 'wbtc.eth' => CryptoCurrency.wbtc, 'sol.sol' => CryptoCurrency.sol, 'usdc.sol' => CryptoCurrency.usdcsol, 'eth.arb' => CryptoCurrency.arbEth, 'usdc.arb' => CryptoCurrency.usdcArb, 'usdt.arb' => CryptoCurrency.usdtArb, _ => null }; return currency; } (dynamic, Trade)? _getStoredTrade(String id) { for (var i = tradesStore.length -1; i >= 0; i--) { Trade? t = tradesStore.getAt(i); if (t != null && t.id == id) return (i, t); } return null; } String _amountToNative(double amount, CryptoCurrency currency) => (amount * pow(10, currency.decimals)).toInt().toString(); double _amountFromNative(String amount, CryptoCurrency currency) => double.parse(amount) / pow(10, currency.decimals); Future> _getAssets() async => _getRequest(_assetsPath, {}); Future> _openDepositChannel(Map params) async => _getRequest(_swapPath, params); Future> _getRequest(String path, Map params) async { final uri = Uri.https(_baseURL, path, params); final response = await ProxyWrapper().get(clearnetUri: uri); if ((response.statusCode != 200) || (response.body.contains('error'))) { throw Exception('Unexpected response: ${response.statusCode} / ${uri.toString()} / ${response.body}'); } return json.decode(response.body) as Map; } Future> _getSwapQuote(Map params) async { final uri = Uri.https(_baseURL, _quotePath, params); final response = await ProxyWrapper().get(clearnetUri: uri); if ((response.statusCode != 200) || (response.body.contains('error'))) { throw Exception('Unexpected response: ${response.statusCode} / ${uri.toString()} / ${response.body}'); } final List jsonList = json.decode(response.body) as List; final List> quotes = jsonList.map((e) => e as Map).toList(); Map highestQuote = quotes.reduce((current, next) { double currentAmount = current['egressAmount'] as double; double nextAmount = next['egressAmount'] as double; return currentAmount > nextAmount ? current : next; }); return highestQuote; } Future?> _getStatus(Map params) async { final uri = Uri.https(_baseURL, _txInfoPath, params); final response = await ProxyWrapper().get(clearnetUri: uri); if (response.statusCode == 404) return null; if ((response.statusCode != 200) || (response.body.contains('error'))) { throw Exception('Unexpected response: ${response.statusCode} / ${uri.toString()} / ${response.body}'); } return json.decode(response.body) as Map; } TradeState _determineState(String state) { final swapState = switch (state) { 'waiting' => TradeState.waiting, 'receiving' => TradeState.processing, 'swapping' => TradeState.processing, 'sending' => TradeState.processing, 'sent' => TradeState.processing, 'completed' => TradeState.success, 'failed' => TradeState.failed, _ => TradeState.notFound }; return swapState; } }