From 1c29be7993e397a3dda51008c1a42fef599fb569 Mon Sep 17 00:00:00 2001 From: Serhii Date: Fri, 14 Mar 2025 03:02:39 +0200 Subject: [PATCH] CW-961-Integrate-xoswap (#2060) * integrate xoswap * fix network id * fix calculating amount with rate twice * minor: move xoswap a bit up [skip ci] * minor fix [skip ci] * add tracking url * improve fetching exchange rate * Update trade_filter_store.dart --------- Co-authored-by: OmarHatem --- assets/images/xoswap.svg | 26 ++ .../exchange_provider_description.dart | 4 + .../provider/xoswap_exchange_provider.dart | 309 ++++++++++++++++++ lib/exchange/trade_state.dart | 1 + .../widgets/sync_indicator_icon.dart | 2 + lib/store/dashboard/trade_filter_store.dart | 18 +- .../dashboard/dashboard_view_model.dart | 5 + .../exchange/exchange_trade_view_model.dart | 3 + .../exchange/exchange_view_model.dart | 2 + lib/view_model/trade_details_view_model.dart | 6 + 10 files changed, 373 insertions(+), 3 deletions(-) create mode 100644 assets/images/xoswap.svg create mode 100644 lib/exchange/provider/xoswap_exchange_provider.dart diff --git a/assets/images/xoswap.svg b/assets/images/xoswap.svg new file mode 100644 index 000000000..ef83c58b1 --- /dev/null +++ b/assets/images/xoswap.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lib/exchange/exchange_provider_description.dart b/lib/exchange/exchange_provider_description.dart index 8d1c75211..b3761ab73 100644 --- a/lib/exchange/exchange_provider_description.dart +++ b/lib/exchange/exchange_provider_description.dart @@ -34,6 +34,8 @@ class ExchangeProviderDescription extends EnumerableItem with Serializable< ExchangeProviderDescription(title: 'StealthEx', raw: 11, image: 'assets/images/stealthex.png'); static const chainflip = ExchangeProviderDescription(title: 'Chainflip', raw: 12, image: 'assets/images/chainflip.png'); + static const xoSwap = + ExchangeProviderDescription(title: 'XOSwap', raw: 13, image: 'assets/images/xoswap.svg'); static ExchangeProviderDescription deserialize({required int raw}) { switch (raw) { @@ -63,6 +65,8 @@ class ExchangeProviderDescription extends EnumerableItem with Serializable< return stealthEx; case 12: return chainflip; + case 13: + return xoSwap; default: throw Exception('Unexpected token: $raw for ExchangeProviderDescription deserialize'); } diff --git a/lib/exchange/provider/xoswap_exchange_provider.dart b/lib/exchange/provider/xoswap_exchange_provider.dart new file mode 100644 index 000000000..0b3826aa9 --- /dev/null +++ b/lib/exchange/provider/xoswap_exchange_provider.dart @@ -0,0 +1,309 @@ +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_not_created_exception.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:http/http.dart' as http; + +class XOSwapExchangeProvider extends ExchangeProvider { + XOSwapExchangeProvider() : super(pairList: supportedPairs(_notSupported)); + + static const List _notSupported = []; + + static const _apiAuthority = 'exchange.exodus.io'; + static const _apiPath = '/v3'; + static const _pairsPath = '/pairs'; + static const _ratePath = '/rates'; + static const _orders = '/orders'; + static const _assets = '/assets'; + + static const _headers = {'Content-Type': 'application/json', 'App-Name': 'cake-labs'}; + + final _networks = { + 'POL': 'matic', + 'ETH': 'ethereum', + 'BTC': 'bitcoin', + 'BSC': 'bsc', + 'SOL': 'solana', + 'TRX': 'tronmainnet', + 'ZEC': 'zcash', + 'ADA': 'cardano', + 'DOGE': 'dogecoin', + 'XMR': 'monero', + 'BCH': 'bcash', + 'BSV': 'bitcoinsv', + 'XRP': 'ripple', + 'LTC': 'litecoin', + 'EOS': 'eosio', + 'XLM': 'stellar', + }; + + @override + String get title => 'XOSwap'; + + @override + bool get isAvailable => true; + + @override + bool get isEnabled => true; + + @override + bool get supportsFixedRate => true; + + @override + ExchangeProviderDescription get description => ExchangeProviderDescription.xoSwap; + + @override + Future checkIsAvailable() async => true; + + Future _getAssets(CryptoCurrency currency) async { + if (currency.tag == null) return currency.title; + try { + final normalizedNetwork = _networks[currency.tag]; + if (normalizedNetwork == null) return null; + + final uri = Uri.https(_apiAuthority, _apiPath + _assets, + {'networks': normalizedNetwork, 'query': currency.title}); + + final response = await http.get(uri, headers: _headers); + if (response.statusCode != 200) { + throw Exception('Failed to fetch assets for ${currency.title} on ${currency.tag}'); + } + final assets = json.decode(response.body) as List; + + final asset = assets.firstWhere( + (asset) { + final assetSymbol = (asset['symbol'] as String).toUpperCase(); + return assetSymbol == currency.title.toUpperCase(); + }, + orElse: () => null, + ); + return asset != null ? asset['id'] as String : null; + } catch (e) { + printV(e.toString()); + return null; + } + } + + Future> getRatesForPair({ + required CryptoCurrency from, + required CryptoCurrency to, + }) async { + try { + final curFrom = await _getAssets(from); + final curTo = await _getAssets(to); + if (curFrom == null || curTo == null) return []; + final pairId = curFrom + '_' + curTo; + final uri = Uri.https(_apiAuthority, '$_apiPath$_pairsPath/$pairId$_ratePath'); + final response = await http.get(uri, headers: _headers); + if (response.statusCode != 200) return []; + return json.decode(response.body) as List; + } catch (e) { + printV(e.toString()); + return []; + } + } + + Future fetchLimits({ + required CryptoCurrency from, + required CryptoCurrency to, + required bool isFixedRateMode, + }) async { + final rates = await getRatesForPair(from: from, to: to); + if (rates.isEmpty) return Limits(min: 0, max: 0); + + double minLimit = double.infinity; + double maxLimit = 0; + + for (var rate in rates) { + final double currentMin = double.parse(rate['min']['value'].toString()); + final double currentMax = double.parse(rate['max']['value'].toString()); + if (currentMin < minLimit) minLimit = currentMin; + if (currentMax > maxLimit) maxLimit = currentMax; + } + return Limits(min: minLimit, max: maxLimit); + } + + Future fetchRate({ + required CryptoCurrency from, + required CryptoCurrency to, + required double amount, + required bool isFixedRateMode, + required bool isReceiveAmount, + }) async { + try { + final rates = await getRatesForPair(from: from, to: to); + if (rates.isEmpty) return 0; + + if (!isFixedRateMode) { + double bestOutput = 0.0; + for (var rate in rates) { + final double minVal = double.parse(rate['min']['value'].toString()); + final double maxVal = double.parse(rate['max']['value'].toString()); + if (amount >= minVal && amount <= maxVal) { + final double rateMultiplier = double.parse(rate['amount']['value'].toString()); + final double minerFee = double.parse(rate['minerFee']['value'].toString()); + final double outputAmount = (amount * rateMultiplier) - minerFee; + if (outputAmount > bestOutput) { + bestOutput = outputAmount; + } + } + } + return bestOutput > 0 ? (bestOutput / amount) : 0; + } else { + double bestInput = double.infinity; + for (var rate in rates) { + final double rateMultiplier = double.parse(rate['amount']['value'].toString()); + final double minerFee = double.parse(rate['minerFee']['value'].toString()); + final double minVal = double.parse(rate['min']['value'].toString()); + final double maxVal = double.parse(rate['max']['value'].toString()); + final double requiredSend = (amount + minerFee) / rateMultiplier; + if (requiredSend >= minVal && requiredSend <= maxVal) { + if (requiredSend < bestInput) { + bestInput = requiredSend; + } + } + } + return bestInput < double.infinity ? amount / bestInput : 0; + } + } catch (e) { + printV(e.toString()); + return 0; + } + } + + @override + Future createTrade({ + required TradeRequest request, + required bool isFixedRateMode, + required bool isSendAll, + }) async { + try { + final uri = Uri.https(_apiAuthority, '$_apiPath$_orders'); + + final payload = { + 'fromAmount': request.fromAmount, + 'fromAddress': request.refundAddress, + 'toAmount': request.toAmount, + 'toAddress': request.toAddress, + 'pairId': '${request.fromCurrency.title}_${request.toCurrency.title}', + }; + + final response = await http.post(uri, headers: _headers, body: json.encode(payload)); + if (response.statusCode != 201) { + final responseJSON = json.decode(response.body) as Map; + final error = responseJSON['error'] ?? 'Unknown error'; + final message = responseJSON['message'] ?? ''; + throw Exception('$error\n$message'); + } + final responseJSON = json.decode(response.body) as Map; + + final amount = responseJSON['amount'] as Map; + final toAmount = responseJSON['toAmount'] as Map; + final orderId = responseJSON['id'] as String; + final from = request.fromCurrency; + final to = request.toCurrency; + final payoutAddress = responseJSON['toAddress'] as String; + final depositAddress = responseJSON['payInAddress'] as String; + final refundAddress = responseJSON['fromAddress'] as String; + final depositAmount = _toDouble(amount['value']); + final receiveAmount = toAmount['value'] as String; + final status = responseJSON['status'] as String; + final createdAtString = responseJSON['createdAt'] as String; + final extraId = responseJSON['payInAddressTag'] as String?; + + final createdAt = DateTime.parse(createdAtString).toLocal(); + + return Trade( + id: orderId, + from: from, + to: to, + provider: description, + inputAddress: depositAddress, + refundAddress: refundAddress, + state: TradeState.deserialize(raw: status), + createdAt: createdAt, + amount: depositAmount.toString(), + receiveAmount: receiveAmount.toString(), + payoutAddress: payoutAddress, + extraId: extraId, + ); + } catch (e) { + printV(e.toString()); + throw TradeNotCreatedException(description); + } + } + + @override + Future findTradeById({required String id}) async { + try { + final uri = Uri.https(_apiAuthority, '$_apiPath$_orders/$id'); + final response = await http.get(uri, headers: _headers); + if (response.statusCode != 200) { + final responseJSON = json.decode(response.body) as Map; + if (responseJSON.containsKey('code') && responseJSON['code'] == 'NOT_FOUND') { + throw Exception('Trade not found'); + } + final error = responseJSON['error'] ?? 'Unknown error'; + final message = responseJSON['message'] ?? responseJSON['details'] ?? ''; + throw Exception('$error\n$message'); + } + final responseJSON = json.decode(response.body) as Map; + + final pairId = responseJSON['pairId'] as String; + final pairParts = pairId.split('_'); + final CryptoCurrency fromCurrency = + CryptoCurrency.fromString(pairParts.isNotEmpty ? pairParts[0] : ""); + final CryptoCurrency toCurrency = + CryptoCurrency.fromString(pairParts.length > 1 ? pairParts[1] : ""); + + final amount = responseJSON['amount'] as Map; + final toAmount = responseJSON['toAmount'] as Map; + final orderId = responseJSON['id'] as String; + final depositAmount = amount['value'] as String; + final receiveAmount = toAmount['value'] as String; + final depositAddress = responseJSON['payInAddress'] as String; + final payoutAddress = responseJSON['toAddress'] as String; + final refundAddress = responseJSON['fromAddress'] as String; + final status = responseJSON['status'] as String; + final createdAtString = responseJSON['createdAt'] as String; + final createdAt = DateTime.parse(createdAtString).toLocal(); + final extraId = responseJSON['payInAddressTag'] as String?; + + return Trade( + id: orderId, + from: fromCurrency, + to: toCurrency, + provider: description, + inputAddress: depositAddress, + refundAddress: refundAddress, + state: TradeState.deserialize(raw: status), + createdAt: createdAt, + amount: depositAmount, + receiveAmount: receiveAmount, + payoutAddress: payoutAddress, + extraId: extraId, + ); + } catch (e) { + printV(e.toString()); + throw TradeNotCreatedException(description); + } + } + + double _toDouble(dynamic value) { + if (value is int) { + return value.toDouble(); + } else if (value is double) { + return value; + } else { + return 0.0; + } + } +} diff --git a/lib/exchange/trade_state.dart b/lib/exchange/trade_state.dart index 7bce8c7e7..f63b41e5d 100644 --- a/lib/exchange/trade_state.dart +++ b/lib/exchange/trade_state.dart @@ -100,6 +100,7 @@ class TradeState extends EnumerableItem with Serializable { case 'waiting': return waiting; case 'processing': + case 'inProgress': return processing; case 'waitingPayment': return waitingPayment; diff --git a/lib/src/screens/dashboard/widgets/sync_indicator_icon.dart b/lib/src/screens/dashboard/widgets/sync_indicator_icon.dart index aca3231ec..c1202d3ff 100644 --- a/lib/src/screens/dashboard/widgets/sync_indicator_icon.dart +++ b/lib/src/screens/dashboard/widgets/sync_indicator_icon.dart @@ -21,6 +21,7 @@ class SyncIndicatorIcon extends StatelessWidget { static const String fetching = 'fetching'; static const String finished = 'finished'; static const String success = 'success'; + static const String complete = 'complete'; @override Widget build(BuildContext context) { @@ -47,6 +48,7 @@ class SyncIndicatorIcon extends StatelessWidget { break; case finished: case success: + case complete: indicatorColor = PaletteDark.brightGreen; break; default: diff --git a/lib/store/dashboard/trade_filter_store.dart b/lib/store/dashboard/trade_filter_store.dart index 6a98329d6..e3212fd11 100644 --- a/lib/store/dashboard/trade_filter_store.dart +++ b/lib/store/dashboard/trade_filter_store.dart @@ -20,6 +20,7 @@ abstract class TradeFilterStoreBase with Store { displayThorChain = true, displayLetsExchange = true, displayStealthEx = true, + displayXOSwap = true, displaySwapTrade = true; @observable @@ -45,7 +46,7 @@ abstract class TradeFilterStoreBase with Store { @observable bool displayChainflip; - + @observable bool displayThorChain; @@ -55,6 +56,9 @@ abstract class TradeFilterStoreBase with Store { @observable bool displayStealthEx; + @observable + bool displayXOSwap; + @observable bool displaySwapTrade; @@ -64,11 +68,12 @@ abstract class TradeFilterStoreBase with Store { displaySideShift && displaySimpleSwap && displayTrocador && - displayExolix && + displayExolix && displayChainflip && displayThorChain && displayLetsExchange && displayStealthEx && + displayXOSwap && displaySwapTrade; @action @@ -107,8 +112,12 @@ abstract class TradeFilterStoreBase with Store { case ExchangeProviderDescription.stealthEx: displayStealthEx = !displayStealthEx; break; - case ExchangeProviderDescription.swapTrade: + case ExchangeProviderDescription.xoSwap: + displayXOSwap = !displayXOSwap; + break; + case ExchangeProviderDescription.swapTrade: displaySwapTrade = !displaySwapTrade; + break; case ExchangeProviderDescription.all: if (displayAllTrades) { displayChangeNow = false; @@ -122,6 +131,7 @@ abstract class TradeFilterStoreBase with Store { displayThorChain = false; displayLetsExchange = false; displayStealthEx = false; + displayXOSwap = false; displaySwapTrade = false; } else { displayChangeNow = true; @@ -135,6 +145,7 @@ abstract class TradeFilterStoreBase with Store { displayThorChain = true; displayLetsExchange = true; displayStealthEx = true; + displayXOSwap = true; displaySwapTrade = true; } break; @@ -168,6 +179,7 @@ abstract class TradeFilterStoreBase with Store { (displayLetsExchange && item.trade.provider == ExchangeProviderDescription.letsExchange) || (displayStealthEx && item.trade.provider == ExchangeProviderDescription.stealthEx) || + (displayXOSwap && item.trade.provider == ExchangeProviderDescription.xoSwap) || (displaySwapTrade && item.trade.provider == ExchangeProviderDescription.swapTrade)) .toList() : _trades; diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index 575193027..ee9c57b65 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -152,6 +152,11 @@ abstract class DashboardViewModelBase with Store { caption: ExchangeProviderDescription.stealthEx.title, onChanged: () => tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.stealthEx)), + FilterItem( + value: () => tradeFilterStore.displayXOSwap, + caption: ExchangeProviderDescription.xoSwap.title, + onChanged: () => + tradeFilterStore.toggleDisplayExchange(ExchangeProviderDescription.xoSwap)), FilterItem( value: () => tradeFilterStore.displaySwapTrade, caption: ExchangeProviderDescription.swapTrade.title, diff --git a/lib/view_model/exchange/exchange_trade_view_model.dart b/lib/view_model/exchange/exchange_trade_view_model.dart index fd8c29f1f..d488447e2 100644 --- a/lib/view_model/exchange/exchange_trade_view_model.dart +++ b/lib/view_model/exchange/exchange_trade_view_model.dart @@ -11,6 +11,7 @@ import 'package:cake_wallet/exchange/provider/simpleswap_exchange_provider.dart' import 'package:cake_wallet/exchange/provider/stealth_ex_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/thorchain_exchange.provider.dart'; import 'package:cake_wallet/exchange/provider/trocador_exchange_provider.dart'; +import 'package:cake_wallet/exchange/provider/xoswap_exchange_provider.dart'; import 'package:cake_wallet/exchange/trade.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/src/screens/exchange_trade/exchange_trade_item.dart'; @@ -64,6 +65,8 @@ abstract class ExchangeTradeViewModelBase with Store { case ExchangeProviderDescription.chainflip: _provider = ChainflipExchangeProvider(tradesStore: trades); break; + case ExchangeProviderDescription.xoSwap: + _provider = XOSwapExchangeProvider(); } _updateItems(); diff --git a/lib/view_model/exchange/exchange_view_model.dart b/lib/view_model/exchange/exchange_view_model.dart index 3a0ca3f1f..d3871ffbd 100644 --- a/lib/view_model/exchange/exchange_view_model.dart +++ b/lib/view_model/exchange/exchange_view_model.dart @@ -7,6 +7,7 @@ import 'package:cake_wallet/core/create_trade_result.dart'; import 'package:cake_wallet/exchange/provider/chainflip_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/letsexchange_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/stealth_ex_exchange_provider.dart'; +import 'package:cake_wallet/exchange/provider/xoswap_exchange_provider.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/sync_status.dart'; import 'package:cw_core/transaction_priority.dart'; @@ -187,6 +188,7 @@ abstract class ExchangeViewModelBase extends WalletChangeListenerViewModel with SwapTradeExchangeProvider(), LetsExchangeExchangeProvider(), StealthExExchangeProvider(), + XOSwapExchangeProvider(), TrocadorExchangeProvider( useTorOnly: _useTorOnly, providerStates: _settingsStore.trocadorProviderStates), ]; diff --git a/lib/view_model/trade_details_view_model.dart b/lib/view_model/trade_details_view_model.dart index 6b509dc22..a9a77fb54 100644 --- a/lib/view_model/trade_details_view_model.dart +++ b/lib/view_model/trade_details_view_model.dart @@ -12,6 +12,7 @@ import 'package:cake_wallet/exchange/provider/simpleswap_exchange_provider.dart' import 'package:cake_wallet/exchange/provider/stealth_ex_exchange_provider.dart'; import 'package:cake_wallet/exchange/provider/thorchain_exchange.provider.dart'; import 'package:cake_wallet/exchange/provider/trocador_exchange_provider.dart'; +import 'package:cake_wallet/exchange/provider/xoswap_exchange_provider.dart'; import 'package:cake_wallet/exchange/trade.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/src/screens/trade_details/track_trade_list_item.dart'; @@ -72,6 +73,9 @@ abstract class TradeDetailsViewModelBase with Store { case ExchangeProviderDescription.chainflip: _provider = ChainflipExchangeProvider(tradesStore: trades); break; + case ExchangeProviderDescription.xoSwap: + _provider = XOSwapExchangeProvider(); + break; } _updateItems(); @@ -104,6 +108,8 @@ abstract class TradeDetailsViewModelBase with Store { return 'https://stealthex.io/exchange/?id=${trade.id}'; case ExchangeProviderDescription.chainflip: return 'https://scan.chainflip.io/channels/${trade.id}'; + case ExchangeProviderDescription.xoSwap: + return 'https://orders.xoswap.com/${trade.id}'; } return null; }