import 'dart:convert'; 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:hive/hive.dart'; import 'package:http/http.dart' as http; class ThorChainExchangeProvider extends ExchangeProvider { ThorChainExchangeProvider({required this.tradesStore}) : super(pairList: supportedPairs(_notSupported)); static final List _notSupported = [ ...(CryptoCurrency.all .where((element) => ![ CryptoCurrency.btc, // CryptoCurrency.eth, CryptoCurrency.ltc, CryptoCurrency.bch, // CryptoCurrency.aave, // CryptoCurrency.dai, // CryptoCurrency.gusd, // CryptoCurrency.usdc, // CryptoCurrency.usdterc20, // CryptoCurrency.wbtc, // TODO: temporarily commented until https://github.com/cake-tech/cake_wallet/pull/1436 is merged ].contains(element)) .toList()) ]; static final isRefundAddressSupported = [CryptoCurrency.eth]; static const _baseNodeURL = 'thornode.ninerealms.com'; static const _baseURL = 'midgard.ninerealms.com'; static const _quotePath = '/thorchain/quote/swap'; static const _txInfoPath = '/thorchain/tx/status/'; static const _affiliateName = 'cakewallet'; static const _affiliateBps = '175'; static const _nameLookUpPath = 'v2/thorname/lookup/'; final Box tradesStore; @override String get title => 'THORChain'; @override bool get isAvailable => true; @override bool get isEnabled => true; @override bool get supportsFixedRate => false; @override ExchangeProviderDescription get description => ExchangeProviderDescription.thorChain; @override Future checkIsAvailable() async => true; @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 = { 'from_asset': _normalizeCurrency(from), 'to_asset': _normalizeCurrency(to), 'amount': _doubleToThorChainString(amount), 'affiliate': _affiliateName, 'affiliate_bps': _affiliateBps }; final responseJSON = await _getSwapQuote(params); final expectedAmountOut = responseJSON['expected_amount_out'] as String? ?? '0.0'; return _thorChainAmountToDouble(expectedAmountOut) / amount; } catch (e) { print(e.toString()); return 0.0; } } @override Future fetchLimits( {required CryptoCurrency from, required CryptoCurrency to, required bool isFixedRateMode}) async { final params = { 'from_asset': _normalizeCurrency(from), 'to_asset': _normalizeCurrency(to), 'amount': _doubleToThorChainString(1), 'affiliate': _affiliateName, 'affiliate_bps': _affiliateBps }; final responseJSON = await _getSwapQuote(params); final minAmountIn = responseJSON['recommended_min_amount_in'] as String? ?? '0.0'; return Limits(min: _thorChainAmountToDouble(minAmountIn)); } @override Future createTrade({ required TradeRequest request, required bool isFixedRateMode, required bool isSendAll, }) async { final formattedFromAmount = double.parse(request.fromAmount); final params = { 'from_asset': _normalizeCurrency(request.fromCurrency), 'to_asset': _normalizeCurrency(request.toCurrency), 'amount': _doubleToThorChainString(formattedFromAmount), 'destination': _normalizeAddress(request.toAddress), 'affiliate': _affiliateName, 'affiliate_bps': _affiliateBps, 'refund_address': isRefundAddressSupported.contains(request.fromCurrency) ? _normalizeAddress(request.refundAddress) : '', }; final responseJSON = await _getSwapQuote(params); final inputAddress = responseJSON['inbound_address'] as String?; final memo = responseJSON['memo'] as String?; final directAmountOutResponse = responseJSON['expected_amount_out'] as String?; String? receiveAmount; if (directAmountOutResponse != null) { receiveAmount = _thorChainAmountToDouble(directAmountOutResponse).toString(); } return Trade( id: '', from: request.fromCurrency, to: request.toCurrency, provider: description, inputAddress: inputAddress, createdAt: DateTime.now(), amount: request.fromAmount, receiveAmount: receiveAmount ?? request.toAmount, state: TradeState.notFound, payoutAddress: request.toAddress, memo: memo, isSendAll: isSendAll, ); } @override Future findTradeById({required String id}) async { if (id.isEmpty) throw Exception('Trade id is empty'); final formattedId = id.startsWith('0x') ? id.substring(2) : id; final uri = Uri.https(_baseNodeURL, '$_txInfoPath$formattedId'); final response = await http.get(uri); if (response.statusCode == 404) { throw Exception('Trade not found for id: $formattedId'); } else if (response.statusCode != 200) { throw Exception('Unexpected HTTP status: ${response.statusCode}'); } final responseJSON = json.decode(response.body); final Map stagesJson = responseJSON['stages'] as Map; final inboundObservedStarted = stagesJson['inbound_observed']?['started'] as bool? ?? true; if (!inboundObservedStarted) { throw Exception('Trade has not started for id: $formattedId'); } final currentState = _updateStateBasedOnStages(stagesJson) ?? TradeState.notFound; final tx = responseJSON['tx']; final String fromAddress = tx['from_address'] as String? ?? ''; final String toAddress = tx['to_address'] as String? ?? ''; final List coins = tx['coins'] as List; final String? memo = tx['memo'] as String?; final parts = memo?.split(':') ?? []; final String toChain = parts.length > 1 ? parts[1].split('.')[0] : ''; final String toAsset = parts.length > 1 && parts[1].split('.').length > 1 ? parts[1].split('.')[1].split('-')[0] : ''; final formattedToChain = CryptoCurrency.fromString(toChain); final toAssetWithChain = CryptoCurrency.fromString(toAsset, walletCurrency: formattedToChain); final plannedOutTxs = responseJSON['planned_out_txs'] as List?; final isRefund = plannedOutTxs?.any((tx) => tx['refund'] == true) ?? false; return Trade( id: id, from: CryptoCurrency.fromString(tx['chain'] as String? ?? ''), to: toAssetWithChain, provider: description, inputAddress: fromAddress, payoutAddress: toAddress, amount: coins.first['amount'] as String? ?? '0.0', state: currentState, memo: memo, isRefund: isRefund, ); } static Future?>? lookupAddressByName(String name) async { final uri = Uri.https(_baseURL, '$_nameLookUpPath$name'); final response = await http.get(uri); if (response.statusCode != 200) { return null; } final body = json.decode(response.body) as Map; final entries = body['entries'] as List?; if (entries == null || entries.isEmpty) { return null; } Map chainToAddressMap = {}; for (final entry in entries) { final chain = entry['chain'] as String; final address = entry['address'] as String; chainToAddressMap[chain] = address; } return chainToAddressMap; } Future> _getSwapQuote(Map params) async { Uri uri = Uri.https(_baseNodeURL, _quotePath, params); final response = await http.get(uri); if (response.statusCode != 200) { throw Exception('Unexpected HTTP status: ${response.statusCode}'); } if (response.body.contains('error')) { throw Exception('Unexpected response: ${response.body}'); } return json.decode(response.body) as Map; } String _normalizeCurrency(CryptoCurrency currency) { final networkTitle = currency.tag == 'ETH' ? 'ETH' : currency.title; return '$networkTitle.${currency.title}'; } String _doubleToThorChainString(double amount) => (amount * 1e8).toInt().toString(); double _thorChainAmountToDouble(String amount) => double.parse(amount) / 1e8; TradeState? _updateStateBasedOnStages(Map stages) { TradeState? currentState; if (stages['inbound_observed']['completed'] as bool? ?? false) { currentState = TradeState.confirmation; } if (stages['inbound_confirmation_counted']['completed'] as bool? ?? false) { currentState = TradeState.confirmed; } if (stages['inbound_finalised']['completed'] as bool? ?? false) { currentState = TradeState.processing; } if (stages['swap_finalised']['completed'] as bool? ?? false) { currentState = TradeState.traded; } if (stages['outbound_signed']['completed'] as bool? ?? false) { currentState = TradeState.success; } return currentState; } String _normalizeAddress(String address) => address.startsWith('bitcoincash:') ? address.replaceFirst('bitcoincash:', '') : address; }