From fd1485e42a8a156202edac7abed35f8c7db29460 Mon Sep 17 00:00:00 2001 From: fosse Date: Wed, 18 Oct 2023 11:25:26 -0400 Subject: [PATCH] save --- lib/di.dart | 6 + lib/entities/main_actions.dart | 6 +- .../moonpay/moonpay_exchange_provider.dart | 135 +++ lib/router.dart | 3 +- .../exchange/exchange_options_page.dart | 4 +- .../exchange/moonpay_exchange_view_model.dart | 830 ++++++++++++++++++ .../exchange/normal_exchange_view_model.dart | 830 ++++++++++++++++++ 7 files changed, 1808 insertions(+), 6 deletions(-) create mode 100644 lib/exchange/moonpay/moonpay_exchange_provider.dart create mode 100644 lib/view_model/exchange/moonpay_exchange_view_model.dart create mode 100644 lib/view_model/exchange/normal_exchange_view_model.dart diff --git a/lib/di.dart b/lib/di.dart index 38d045d62..fe671c26a 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -14,6 +14,7 @@ import 'package:cake_wallet/entities/exchange_api_mode.dart'; import 'package:cake_wallet/entities/parse_address_from_domain.dart'; import 'package:cake_wallet/entities/receive_page_option.dart'; import 'package:cake_wallet/ethereum/ethereum.dart'; +import 'package:cake_wallet/exchange/moonpay/moonpay_exchange_provider.dart'; import 'package:cake_wallet/nano/nano.dart'; import 'package:cake_wallet/ionia/ionia_anypay.dart'; import 'package:cake_wallet/ionia/ionia_gift_card.dart'; @@ -778,6 +779,11 @@ Future setup({ wallet: getIt.get().wallet!, )); + getIt.registerFactory(() => MoonPayExchangeProvider( + settingsStore: getIt.get().settingsStore, + wallet: getIt.get().wallet!, + )); + getIt.registerFactoryParam((title, uri) => WebViewPage(title, uri)); getIt.registerFactory(() => PayfuraBuyProvider( diff --git a/lib/entities/main_actions.dart b/lib/entities/main_actions.dart index ab8456306..81087d0c6 100644 --- a/lib/entities/main_actions.dart +++ b/lib/entities/main_actions.dart @@ -4,6 +4,7 @@ import 'package:cake_wallet/buy/robinhood/robinhood_buy_provider.dart'; import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/entities/buy_provider_types.dart'; import 'package:cake_wallet/entities/exchange_provider_types.dart'; +import 'package:cake_wallet/exchange/moonpay/moonpay_exchange_provider.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; @@ -102,8 +103,7 @@ class MainActions { if (!viewModel.isEnabledExchangeAction) return; final defaultExchangeProvider = viewModel.defaultExchangeProvider; final walletType = viewModel.type; - - // TODO: CW-492 this is currently all wallets as the ticket didn't specify which wallets should be supported: + switch (walletType) { case WalletType.bitcoin: case WalletType.litecoin: @@ -117,7 +117,7 @@ class MainActions { Navigator.pushNamed(context, Routes.choose_exchange_provider); break; case ExchangeProviderType.MoonPay: - await getIt.get().launchProvider(context); + await getIt.get().launchProvider(context); break; case ExchangeProviderType.Normal: await Navigator.of(context).pushNamed(Routes.exchange); diff --git a/lib/exchange/moonpay/moonpay_exchange_provider.dart b/lib/exchange/moonpay/moonpay_exchange_provider.dart new file mode 100644 index 000000000..0b6fccc9a --- /dev/null +++ b/lib/exchange/moonpay/moonpay_exchange_provider.dart @@ -0,0 +1,135 @@ +import 'dart:convert'; +import 'package:cake_wallet/di.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/palette.dart'; +import 'package:cake_wallet/routes.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cake_wallet/themes/theme_base.dart'; +import 'package:cake_wallet/utils/device_info.dart'; +import 'package:crypto/crypto.dart'; +import 'package:cw_core/wallet_base.dart'; +import 'package:flutter/widgets.dart'; +import 'package:cake_wallet/.secrets.g.dart' as secrets; +import 'package:cw_core/crypto_currency.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class MoonPayExchangeProvider { + MoonPayExchangeProvider( + {required SettingsStore settingsStore, required WalletBase wallet, this.isTest = true}) + : this._settingsStore = settingsStore, + this._wallet = wallet, + baseUrl = isTest ? _baseTestUrl : _baseProductUrl; + + final SettingsStore _settingsStore; + final WalletBase _wallet; + + static const _baseTestUrl = 'buy-staging.moonpay.com'; + static const _baseProductUrl = 'buy.moonpay.com'; + static const _apiUrl = 'https://api.moonpay.com'; + static const _currenciesSuffix = '/v3/currencies'; + static const _quoteSuffix = '/buy_quote'; + static const _transactionsSuffix = '/v1/transactions'; + static const _ipAddressSuffix = '/v4/ip_address'; + static const _apiKey = secrets.moonPayApiKey; + static const _secretKey = secrets.moonPaySecretKey; + + static String themeToMoonPayTheme(ThemeBase theme) { + switch (theme.type) { + case ThemeType.bright: + return 'light'; + case ThemeType.light: + return 'light'; + case ThemeType.dark: + return 'dark'; + } + } + + final bool isTest; + final String baseUrl; + + Future requestUrl( + {required CryptoCurrency currency, required String refundWalletAddress}) async { + final customParams = { + 'theme': themeToMoonPayTheme(_settingsStore.currentTheme), + 'language': _settingsStore.languageCode, + 'colorCode': _settingsStore.currentTheme.type == ThemeType.dark + ? '#${Palette.blueCraiola.value.toRadixString(16).substring(2, 8)}' + : '#${Palette.moderateSlateBlue.value.toRadixString(16).substring(2, 8)}', + }; + + final originalUri = Uri.https( + baseUrl, + '', + { + // 'apiKey': _apiKey, + 'defaultBaseCurrencyCode': currency.toString().toLowerCase(), + // 'refundWalletAddress': refundWalletAddress + }..addAll(customParams)); + final messageBytes = utf8.encode('?${originalUri.query}'); + final key = utf8.encode(_secretKey); + final hmac = Hmac(sha256, key); + final digest = hmac.convert(messageBytes); + final signature = base64.encode(digest.bytes); + + if (isTest) { + return originalUri; + } + + final query = Map.from(originalUri.queryParameters); + query['signature'] = signature; + final signedUri = originalUri.replace(queryParameters: query); + return signedUri; + } + + // void _initialPairBasedOnWallet() { + // switch (wallet.type) { + // case WalletType.monero: + // depositCurrency = CryptoCurrency.xmr; + // receiveCurrency = CryptoCurrency.btc; + // break; + // case WalletType.bitcoin: + // depositCurrency = CryptoCurrency.btc; + // receiveCurrency = CryptoCurrency.xmr; + // break; + // case WalletType.litecoin: + // depositCurrency = CryptoCurrency.ltc; + // receiveCurrency = CryptoCurrency.xmr; + // break; + // case WalletType.bitcoinCash: + // depositCurrency = CryptoCurrency.bch; + // receiveCurrency = CryptoCurrency.xmr; + // break; + // case WalletType.haven: + // depositCurrency = CryptoCurrency.xhv; + // receiveCurrency = CryptoCurrency.btc; + // break; + // case WalletType.ethereum: + // depositCurrency = CryptoCurrency.eth; + // receiveCurrency = CryptoCurrency.xmr; + // break; + // case WalletType.nano: + // depositCurrency = CryptoCurrency.nano; + // receiveCurrency = CryptoCurrency.xmr; + // break; + // default: + // break; + // } + // } + + Future launchProvider(BuildContext context) async { + // primaryColor = getColorStr(Theme.of(context).primaryColor); + // secondaryColor = getColorStr(Theme.of(context).colorScheme.background); + // primaryTextColor = getColorStr(Theme.of(context).extension()!.titleColor); + // secondaryTextColor = + // getColorStr(Theme.of(context).extension()!.secondaryTextColor); + // containerColor = getColorStr(Theme.of(context).colorScheme.background); + // cardColor = getColorStr(Theme.of(context).cardColor); + + final uri = await requestUrl(currency: CryptoCurrency.xmr, refundWalletAddress: ""); + if (DeviceInfo.instance.isMobile) { + Navigator.of(context).pushNamed(Routes.webViewPage, arguments: [S.of(context).exchange, uri]); + } else { + await launchUrl(uri); + } + } +} diff --git a/lib/router.dart b/lib/router.dart index 86642bd57..490047f09 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -391,8 +391,9 @@ Route createRoute(RouteSettings settings) { fullscreenDialog: true, builder: (_) => getIt.get(param1: args)); case Routes.exchange: + final args = settings.arguments as bool; return CupertinoPageRoute( - fullscreenDialog: true, builder: (_) => getIt.get()); + fullscreenDialog: true, builder: (_) => getIt.get(param1: args)); case Routes.exchangeTemplate: return CupertinoPageRoute(builder: (_) => getIt.get()); diff --git a/lib/src/screens/exchange/exchange_options_page.dart b/lib/src/screens/exchange/exchange_options_page.dart index 1e46bf199..a22d72ab7 100644 --- a/lib/src/screens/exchange/exchange_options_page.dart +++ b/lib/src/screens/exchange/exchange_options_page.dart @@ -1,5 +1,6 @@ import 'package:cake_wallet/buy/robinhood/robinhood_buy_provider.dart'; import 'package:cake_wallet/di.dart'; +import 'package:cake_wallet/exchange/moonpay/moonpay_exchange_provider.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/screens/base_page.dart'; @@ -40,8 +41,7 @@ class ExchangeOptionsPage extends BasePage { image: iconRobinhood, title: "MoonPay Swaps", description: S.of(context).moonpay_exchange_description, - onPressed: () async => - await getIt.get().launchProvider(context), + onPressed: () async => await Navigator.of(context).pushReplacementNamed(Routes.exchange, arguments: ), ), ), Padding( diff --git a/lib/view_model/exchange/moonpay_exchange_view_model.dart b/lib/view_model/exchange/moonpay_exchange_view_model.dart new file mode 100644 index 000000000..7680bfe09 --- /dev/null +++ b/lib/view_model/exchange/moonpay_exchange_view_model.dart @@ -0,0 +1,830 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; + +import 'package:cake_wallet/bitcoin_cash/bitcoin_cash.dart'; +import 'package:cake_wallet/core/wallet_change_listener_view_model.dart'; +import 'package:cake_wallet/entities/exchange_api_mode.dart'; +import 'package:cake_wallet/entities/preferences_key.dart'; +import 'package:cake_wallet/entities/wallet_contact.dart'; +import 'package:cake_wallet/ethereum/ethereum.dart'; +import 'package:cake_wallet/exchange/exolix/exolix_exchange_provider.dart'; +import 'package:cake_wallet/exchange/exolix/exolix_request.dart'; +import 'package:cake_wallet/exchange/sideshift/sideshift_exchange_provider.dart'; +import 'package:cake_wallet/exchange/sideshift/sideshift_request.dart'; +import 'package:cake_wallet/exchange/simpleswap/simpleswap_exchange_provider.dart'; +import 'package:cake_wallet/exchange/simpleswap/simpleswap_request.dart'; +import 'package:cake_wallet/exchange/trocador/trocador_exchange_provider.dart'; +import 'package:cake_wallet/exchange/trocador/trocador_request.dart'; +import 'package:cake_wallet/utils/feature_flag.dart'; +import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart'; +import 'package:cw_core/transaction_priority.dart'; +import 'package:cake_wallet/store/app_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/sync_status.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:cake_wallet/bitcoin/bitcoin.dart'; +import 'package:cake_wallet/monero/monero.dart'; +import 'package:cake_wallet/exchange/exchange_provider.dart'; +import 'package:cake_wallet/exchange/limits.dart'; +import 'package:cake_wallet/exchange/trade.dart'; +import 'package:cake_wallet/exchange/limits_state.dart'; +import 'package:cake_wallet/store/dashboard/trades_store.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:intl/intl.dart'; +import 'package:mobx/mobx.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:hive/hive.dart'; +import 'package:cake_wallet/exchange/exchange_trade_state.dart'; +import 'package:cake_wallet/exchange/changenow/changenow_exchange_provider.dart'; +import 'package:cake_wallet/exchange/changenow/changenow_request.dart'; +import 'package:cake_wallet/exchange/trade_request.dart'; +import 'package:cake_wallet/exchange/xmrto/xmrto_exchange_provider.dart'; +import 'package:cake_wallet/exchange/xmrto/xmrto_trade_request.dart'; +import 'package:cake_wallet/exchange/morphtoken/morphtoken_exchange_provider.dart'; +import 'package:cake_wallet/exchange/morphtoken/morphtoken_request.dart'; +import 'package:cake_wallet/store/templates/exchange_template_store.dart'; +import 'package:cake_wallet/exchange/exchange_template.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +part 'exchange_view_model.g.dart'; + +class MoonPayExchangeViewModel = MoonPayExchangeViewModelBase with _$MoonPayExchangeViewModel; + +abstract class MoonPayExchangeViewModelBase extends WalletChangeListenerViewModel with Store { + @override + void onWalletChange(wallet) { + receiveCurrency = wallet.currency; + depositCurrency = wallet.currency; + } + + MoonPayExchangeViewModelBase( + AppStore appStore, + this.trades, + this._exchangeTemplateStore, + this.tradesStore, + this._settingsStore, + this.sharedPreferences, + this.contactListViewModel, + ) : _cryptoNumberFormat = NumberFormat(), + isFixedRateMode = false, + isReceiveAmountEntered = false, + depositAmount = '', + receiveAmount = '', + receiveAddress = '', + depositAddress = '', + isDepositAddressEnabled = false, + isReceiveAddressEnabled = false, + isReceiveAmountEditable = false, + _useTorOnly = false, + receiveCurrencies = [], + depositCurrencies = [], + limits = Limits(min: 0, max: 0), + tradeState = ExchangeTradeStateInitial(), + limitsState = LimitsInitialState(), + receiveCurrency = appStore.wallet!.currency, + depositCurrency = appStore.wallet!.currency, + providerList = [], + selectedProviders = ObservableList(), + super(appStore: appStore) { + _useTorOnly = _settingsStore.exchangeStatus == ExchangeApiMode.torOnly; + _setProviders(); + const excludeDepositCurrencies = [CryptoCurrency.btt]; + const excludeReceiveCurrencies = [ + CryptoCurrency.xlm, + CryptoCurrency.xrp, + CryptoCurrency.bnb, + CryptoCurrency.btt + ]; + _initialPairBasedOnWallet(); + + final Map exchangeProvidersSelection = + json.decode(sharedPreferences.getString(PreferencesKey.exchangeProvidersSelection) ?? "{}") + as Map; + + /// if the provider is not in the user settings (user's first time or newly added provider) + /// then use its default value decided by us + selectedProviders = ObservableList.of(providersForCurrentPair() + .where((element) => exchangeProvidersSelection[element.title] == null + ? element.isEnabled + : (exchangeProvidersSelection[element.title] as bool)) + .toList()); + + _setAvailableProviders(); + _calculateBestRate(); + + bestRateSync = Timer.periodic(Duration(seconds: 10), (timer) => _calculateBestRate()); + + isDepositAddressEnabled = !(depositCurrency == wallet.currency); + isReceiveAddressEnabled = !(receiveCurrency == wallet.currency); + depositAmount = ''; + receiveAmount = ''; + receiveAddress = ''; + depositAddress = depositCurrency == wallet.currency ? wallet.walletAddresses.address : ''; + provider = providersForCurrentPair().first; + final initialProvider = provider; + provider!.checkIsAvailable().then((bool isAvailable) { + if (!isAvailable && provider == initialProvider) { + provider = providerList.firstWhere((provider) => provider is ChangeNowExchangeProvider, + orElse: () => providerList.last); + _onPairChange(); + } + }); + receiveCurrencies = CryptoCurrency.all + .where((cryptoCurrency) => !excludeReceiveCurrencies.contains(cryptoCurrency)) + .toList(); + depositCurrencies = CryptoCurrency.all + .where((cryptoCurrency) => !excludeDepositCurrencies.contains(cryptoCurrency)) + .toList(); + _defineIsReceiveAmountEditable(); + loadLimits(); + reaction((_) => isFixedRateMode, (Object _) { + loadLimits(); + _bestRate = 0; + _calculateBestRate(); + }); + } + bool _useTorOnly; + final Box trades; + final ExchangeTemplateStore _exchangeTemplateStore; + final TradesStore tradesStore; + final SharedPreferences sharedPreferences; + + List get _allProviders => [ + ChangeNowExchangeProvider(settingsStore: _settingsStore), + SideShiftExchangeProvider(), + SimpleSwapExchangeProvider(), + TrocadorExchangeProvider(useTorOnly: _useTorOnly), + if (FeatureFlag.isExolixEnabled) ExolixExchangeProvider(), + ]; + + @observable + ExchangeProvider? provider; + + /// Maps in dart are not sorted by default + /// SplayTreeMap is a map sorted by keys + /// will use it to sort available providers + /// based on the rate they yield for the current trade + /// + /// + /// initialize with descending comparator + /// since we want largest rate first + final SplayTreeMap _sortedAvailableProviders = + SplayTreeMap((double a, double b) => b.compareTo(a)); + + final List _tradeAvailableProviders = []; + + @observable + ObservableList selectedProviders; + + @observable + List providerList; + + @observable + CryptoCurrency depositCurrency; + + @observable + CryptoCurrency receiveCurrency; + + @observable + LimitsState limitsState; + + @observable + ExchangeTradeState tradeState; + + @observable + String depositAmount; + + @observable + String receiveAmount; + + @observable + String depositAddress; + + @observable + String receiveAddress; + + @observable + bool isDepositAddressEnabled; + + @observable + bool isReceiveAddressEnabled; + + @observable + bool isReceiveAmountEntered; + + @observable + bool isReceiveAmountEditable; + + @observable + bool isFixedRateMode; + + @observable + Limits limits; + + @computed + SyncStatus get status => wallet.syncStatus; + + @computed + ObservableList get templates => _exchangeTemplateStore.templates; + + @computed + List get walletContactsToShow => contactListViewModel.walletContacts + .where((element) => element.type == receiveCurrency) + .toList(); + + @action + bool checkIfWalletIsAnInternalWallet(String address) { + final walletContactList = + walletContactsToShow.where((element) => element.address == address).toList(); + + return walletContactList.isNotEmpty; + } + + @computed + bool get shouldDisplayTOTP2FAForExchangesToInternalWallet => + _settingsStore.shouldRequireTOTP2FAForExchangesToInternalWallets; + + //* Still open to further optimize these checks + //* It works but can be made better + @action + bool shouldDisplayTOTP() { + final isInternalWallet = checkIfWalletIsAnInternalWallet(receiveAddress); + if (isInternalWallet) { + return shouldDisplayTOTP2FAForExchangesToInternalWallet; + } + return false; + } + + @computed + TransactionPriority get transactionPriority { + final priority = _settingsStore.priority[wallet.type]; + + if (priority == null) { + throw Exception('Unexpected type ${wallet.type.toString()}'); + } + + return priority; + } + + bool get hasAllAmount => + (wallet.type == WalletType.bitcoin || + wallet.type == WalletType.litecoin || + wallet.type == WalletType.bitcoinCash) && + depositCurrency == wallet.currency; + + bool get isMoneroWallet => wallet.type == WalletType.monero; + + bool get isLowFee { + switch (wallet.type) { + case WalletType.monero: + case WalletType.haven: + return transactionPriority == monero!.getMoneroTransactionPrioritySlow(); + case WalletType.bitcoin: + return transactionPriority == bitcoin!.getBitcoinTransactionPrioritySlow(); + case WalletType.litecoin: + return transactionPriority == + bitcoin!.getLitecoinTransactionPrioritySlow(); + case WalletType.ethereum: + return transactionPriority == + ethereum!.getEthereumTransactionPrioritySlow(); + case WalletType.bitcoinCash: + return transactionPriority == + bitcoinCash!.getBitcoinCashTransactionPrioritySlow(); + default: + return false; + } + } + + List receiveCurrencies; + + List depositCurrencies; + + final NumberFormat _cryptoNumberFormat; + + final SettingsStore _settingsStore; + + final ContactListViewModel contactListViewModel; + + double _bestRate = 0.0; + + late Timer bestRateSync; + + @action + void changeDepositCurrency({required CryptoCurrency currency}) { + depositCurrency = currency; + isFixedRateMode = false; + _onPairChange(); + isDepositAddressEnabled = !(depositCurrency == wallet.currency); + isReceiveAddressEnabled = !(receiveCurrency == wallet.currency); + } + + @action + void changeReceiveCurrency({required CryptoCurrency currency}) { + receiveCurrency = currency; + isFixedRateMode = false; + _onPairChange(); + isDepositAddressEnabled = !(depositCurrency == wallet.currency); + isReceiveAddressEnabled = !(receiveCurrency == wallet.currency); + } + + @action + Future changeReceiveAmount({required String amount}) async { + receiveAmount = amount; + + if (amount.isEmpty) { + depositAmount = ''; + receiveAmount = ''; + return; + } + + final _enteredAmount = double.tryParse(amount.replaceAll(',', '.')) ?? 0; + + if (_bestRate == 0) { + depositAmount = S.current.fetching; + + await _calculateBestRate(); + } + _cryptoNumberFormat.maximumFractionDigits = depositMaxDigits; + + depositAmount = _cryptoNumberFormat + .format(_enteredAmount / _bestRate) + .toString() + .replaceAll(RegExp('\\,'), ''); + } + + @action + Future changeDepositAmount({required String amount}) async { + depositAmount = amount; + + if (amount.isEmpty) { + depositAmount = ''; + receiveAmount = ''; + return; + } + + final _enteredAmount = double.tryParse(amount.replaceAll(',', '.')) ?? 0; + + /// in case the best rate was not calculated yet + if (_bestRate == 0) { + receiveAmount = S.current.fetching; + + await _calculateBestRate(); + } + _cryptoNumberFormat.maximumFractionDigits = receiveMaxDigits; + + receiveAmount = _cryptoNumberFormat + .format(_bestRate * _enteredAmount) + .toString() + .replaceAll(RegExp('\\,'), ''); + } + + bool checkIfInputMeetsMinOrMaxCondition(String input) { + final _enteredAmount = double.tryParse(input.replaceAll(',', '.')) ?? 0; + double minLimit = limits.min ?? 0; + double? maxLimit = limits.max; + + if (_enteredAmount < minLimit) { + return false; + } + + if (maxLimit != null && _enteredAmount > maxLimit) { + return false; + } + + return true; + } + + Future _calculateBestRate() async { + final amount = double.tryParse(isFixedRateMode ? receiveAmount : depositAmount) ?? 1; + + final _providers = _tradeAvailableProviders + .where((element) => !isFixedRateMode || element.supportsFixedRate) + .toList(); + + final result = await Future.wait(_providers.map((element) => element.fetchRate( + from: depositCurrency, + to: receiveCurrency, + amount: amount, + isFixedRateMode: isFixedRateMode, + isReceiveAmount: isFixedRateMode))); + + _sortedAvailableProviders.clear(); + + 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]; + } 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 + } + } + } + if (_sortedAvailableProviders.isNotEmpty) { + _bestRate = _sortedAvailableProviders.keys.first; + } + } + + @action + Future loadLimits() async { + if (selectedProviders.isEmpty) { + return; + } + + limitsState = LimitsIsLoading(); + + final from = isFixedRateMode ? receiveCurrency : depositCurrency; + final to = isFixedRateMode ? depositCurrency : receiveCurrency; + + double? lowestMin = double.maxFinite; + double? highestMax = 0.0; + + try { + for (var provider in selectedProviders) { + /// if this provider is not valid for the current pair, skip it + if (!providersForCurrentPair().contains(provider)) { + continue; + } + + try { + final tempLimits = + await provider.fetchLimits(from: from, to: to, isFixedRateMode: isFixedRateMode); + + if (lowestMin != null && (tempLimits.min ?? -1) < lowestMin) { + lowestMin = tempLimits.min; + } + if (highestMax != null && (tempLimits.max ?? double.maxFinite) > highestMax) { + highestMax = tempLimits.max; + } + } catch (e) { + continue; + } + } + } on ConcurrentModificationError { + /// if user changed the selected providers while fetching limits + /// then delay the fetching limits a bit and try again + /// + /// this is because the limitation of collections that + /// you can't modify it while iterating through it + Future.delayed(Duration(milliseconds: 200), loadLimits); + } + + if (lowestMin != double.maxFinite) { + limits = Limits(min: lowestMin, max: highestMax); + + limitsState = LimitsLoadedSuccessfully(limits: limits); + } else { + limitsState = LimitsLoadedFailure(error: 'Limits loading failed'); + } + } + + @action + Future createTrade() async { + TradeRequest? request; + String amount = ''; + + try { + for (var provider in _sortedAvailableProviders.values) { + if (!(await provider.checkIsAvailable())) { + continue; + } + + if (provider is SideShiftExchangeProvider) { + request = SideShiftRequest( + depositMethod: depositCurrency, + settleMethod: receiveCurrency, + depositAmount: isFixedRateMode + ? receiveAmount.replaceAll(',', '.') + : depositAmount.replaceAll(',', '.'), + settleAddress: receiveAddress, + refundAddress: depositAddress, + ); + amount = isFixedRateMode ? receiveAmount : depositAmount; + } + + if (provider is SimpleSwapExchangeProvider) { + request = SimpleSwapRequest( + from: depositCurrency, + to: receiveCurrency, + amount: depositAmount.replaceAll(',', '.'), + address: receiveAddress, + refundAddress: depositAddress, + ); + amount = isFixedRateMode ? receiveAmount : depositAmount; + } + + if (provider is XMRTOExchangeProvider) { + request = XMRTOTradeRequest( + from: depositCurrency, + to: receiveCurrency, + amount: depositAmount.replaceAll(',', '.'), + receiveAmount: receiveAmount.replaceAll(',', '.'), + address: receiveAddress, + refundAddress: depositAddress, + isBTCRequest: isReceiveAmountEntered); + amount = isFixedRateMode ? receiveAmount : depositAmount; + } + + if (provider is ChangeNowExchangeProvider) { + request = ChangeNowRequest( + from: depositCurrency, + to: receiveCurrency, + fromAmount: depositAmount.replaceAll(',', '.'), + toAmount: receiveAmount.replaceAll(',', '.'), + refundAddress: depositAddress, + address: receiveAddress, + isReverse: isFixedRateMode); + amount = isFixedRateMode ? receiveAmount : depositAmount; + } + + if (provider is MorphTokenExchangeProvider) { + request = MorphTokenRequest( + from: depositCurrency, + to: receiveCurrency, + amount: depositAmount.replaceAll(',', '.'), + refundAddress: depositAddress, + address: receiveAddress); + amount = isFixedRateMode ? receiveAmount : depositAmount; + } + + if (provider is TrocadorExchangeProvider) { + request = TrocadorRequest( + from: depositCurrency, + to: receiveCurrency, + fromAmount: depositAmount.replaceAll(',', '.'), + toAmount: receiveAmount.replaceAll(',', '.'), + refundAddress: depositAddress, + address: receiveAddress, + isReverse: isFixedRateMode); + amount = isFixedRateMode ? receiveAmount : depositAmount; + } + + if (provider is ExolixExchangeProvider) { + request = ExolixRequest( + from: depositCurrency, + to: receiveCurrency, + fromAmount: depositAmount.replaceAll(',', '.'), + toAmount: receiveAmount.replaceAll(',', '.'), + refundAddress: depositAddress, + address: receiveAddress); + amount = isFixedRateMode ? receiveAmount : depositAmount; + } + + amount = amount.replaceAll(',', '.'); + + if (limitsState is LimitsLoadedSuccessfully) { + if (double.tryParse(amount) == null) { + continue; + } + if (limits.max != null && double.parse(amount) < limits.min!) { + continue; + } else if (limits.max != null && double.parse(amount) > limits.max!) { + continue; + } else { + try { + tradeState = TradeIsCreating(); + final trade = + await provider.createTrade(request: request!, isFixedRateMode: isFixedRateMode); + trade.walletId = wallet.id; + tradesStore.setTrade(trade); + await trades.add(trade); + tradeState = TradeIsCreatedSuccessfully(trade: trade); + + /// return after the first successful trade + return; + } catch (e) { + continue; + } + } + } + } + + /// if the code reached here then none of the providers succeeded + tradeState = TradeIsCreatedFailure( + title: S.current.trade_not_created, + error: S.current.none_of_selected_providers_can_exchange); + } on ConcurrentModificationError { + /// if create trade happened at the exact same time of the scheduled rate update + /// then delay the create trade a bit and try again + /// + /// this is because the limitation of the SplayTreeMap that + /// you can't modify it while iterating through it + Future.delayed(Duration(milliseconds: 200), createTrade); + } + } + + @action + void reset() { + _initialPairBasedOnWallet(); + isReceiveAmountEntered = false; + depositAmount = ''; + receiveAmount = ''; + depositAddress = depositCurrency == wallet.currency ? wallet.walletAddresses.address : ''; + receiveAddress = receiveCurrency == wallet.currency ? wallet.walletAddresses.address : ''; + isDepositAddressEnabled = !(depositCurrency == wallet.currency); + isReceiveAddressEnabled = !(receiveCurrency == wallet.currency); + isFixedRateMode = false; + _onPairChange(); + } + + @action + void calculateDepositAllAmount() { + if (wallet.type == WalletType.bitcoin || wallet.type == WalletType.litecoin || wallet.type == WalletType.bitcoinCash) { + final availableBalance = wallet.balance[wallet.currency]!.available; + final priority = _settingsStore.priority[wallet.type]!; + final fee = wallet.calculateEstimatedFee(priority, null); + + if (availableBalance < fee || availableBalance == 0) { + return; + } + + final amount = availableBalance - fee; + changeDepositAmount(amount: bitcoin!.formatterBitcoinAmountToString(amount: amount)); + } + } + + void updateTemplate() => _exchangeTemplateStore.update(); + + void addTemplate( + {required String amount, + required String depositCurrency, + required String receiveCurrency, + required String provider, + required String depositAddress, + required String receiveAddress, + required String depositCurrencyTitle, + required String receiveCurrencyTitle}) => + _exchangeTemplateStore.addTemplate( + amount: amount, + depositCurrency: depositCurrency, + receiveCurrency: receiveCurrency, + provider: provider, + depositAddress: depositAddress, + receiveAddress: receiveAddress, + depositCurrencyTitle: depositCurrencyTitle, + receiveCurrencyTitle: receiveCurrencyTitle); + + void removeTemplate({required ExchangeTemplate template}) => + _exchangeTemplateStore.remove(template: template); + + List providersForCurrentPair() { + return _providersForPair(from: depositCurrency, to: receiveCurrency); + } + + List _providersForPair( + {required CryptoCurrency from, required CryptoCurrency to}) { + final providers = providerList + .where((provider) => + provider.pairList.where((pair) => pair.from == from && pair.to == to).isNotEmpty) + .toList(); + + return providers; + } + + void _onPairChange() { + depositAmount = ''; + receiveAmount = ''; + loadLimits(); + _setAvailableProviders(); + _bestRate = 0; + _calculateBestRate(); + } + + void _initialPairBasedOnWallet() { + switch (wallet.type) { + case WalletType.monero: + depositCurrency = CryptoCurrency.xmr; + receiveCurrency = CryptoCurrency.btc; + break; + case WalletType.bitcoin: + depositCurrency = CryptoCurrency.btc; + receiveCurrency = CryptoCurrency.xmr; + break; + case WalletType.litecoin: + depositCurrency = CryptoCurrency.ltc; + receiveCurrency = CryptoCurrency.xmr; + break; + case WalletType.bitcoinCash: + depositCurrency = CryptoCurrency.bch; + receiveCurrency = CryptoCurrency.xmr; + break; + case WalletType.haven: + depositCurrency = CryptoCurrency.xhv; + receiveCurrency = CryptoCurrency.btc; + break; + case WalletType.ethereum: + depositCurrency = CryptoCurrency.eth; + receiveCurrency = CryptoCurrency.xmr; + break; + case WalletType.nano: + depositCurrency = CryptoCurrency.nano; + receiveCurrency = CryptoCurrency.xmr; + break; + default: + break; + } + } + + void _defineIsReceiveAmountEditable() { + /*if ((provider is ChangeNowExchangeProvider) + &&(depositCurrency == CryptoCurrency.xmr) + &&(receiveCurrency == CryptoCurrency.btc)) { + isReceiveAmountEditable = true; + } else { + isReceiveAmountEditable = false; + }*/ + //isReceiveAmountEditable = false; + // isReceiveAmountEditable = selectedProviders.any((provider) => provider is ChangeNowExchangeProvider); + // isReceiveAmountEditable = provider is ChangeNowExchangeProvider || provider is SimpleSwapExchangeProvider; + isReceiveAmountEditable = true; + } + + @action + void addExchangeProvider(ExchangeProvider provider) { + selectedProviders.add(provider); + if (providersForCurrentPair().contains(provider)) { + _tradeAvailableProviders.add(provider); + } + } + + @action + void removeExchangeProvider(ExchangeProvider provider) { + selectedProviders.remove(provider); + _tradeAvailableProviders.remove(provider); + } + + @action + void saveSelectedProviders() { + depositAmount = ''; + receiveAmount = ''; + isFixedRateMode = false; + _defineIsReceiveAmountEditable(); + loadLimits(); + _bestRate = 0; + _calculateBestRate(); + + final Map exchangeProvidersSelection = + json.decode(sharedPreferences.getString(PreferencesKey.exchangeProvidersSelection) ?? "{}") + as Map; + + for (var provider in providerList) { + exchangeProvidersSelection[provider.title] = selectedProviders.contains(provider); + } + + sharedPreferences.setString( + PreferencesKey.exchangeProvidersSelection, + json.encode(exchangeProvidersSelection), + ); + } + + bool get isAvailableInSelected { + final providersForPair = providersForCurrentPair(); + return selectedProviders + .any((element) => element.isAvailable && providersForPair.contains(element)); + } + + void _setAvailableProviders() { + _tradeAvailableProviders.clear(); + + _tradeAvailableProviders.addAll( + selectedProviders.where((provider) => providersForCurrentPair().contains(provider))); + } + + @action + void setDefaultTransactionPriority() { + switch (wallet.type) { + case WalletType.monero: + case WalletType.haven: + _settingsStore.priority[wallet.type] = monero!.getMoneroTransactionPriorityAutomatic(); + break; + case WalletType.bitcoin: + _settingsStore.priority[wallet.type] = bitcoin!.getBitcoinTransactionPriorityMedium(); + break; + case WalletType.litecoin: + _settingsStore.priority[wallet.type] = bitcoin!.getLitecoinTransactionPriorityMedium(); + break; + case WalletType.ethereum: + _settingsStore.priority[wallet.type] = ethereum!.getDefaultTransactionPriority(); + break; + case WalletType.bitcoinCash: + _settingsStore.priority[wallet.type] = bitcoinCash!.getDefaultTransactionPriority(); + break; + default: + break; + } + } + + void _setProviders() { + if (_settingsStore.exchangeStatus == ExchangeApiMode.torOnly) { + providerList = _allProviders.where((provider) => provider.supportsOnionAddress).toList(); + } else { + providerList = _allProviders; + } + } + + int get depositMaxDigits => depositCurrency == CryptoCurrency.btc ? 8 : 12; + + int get receiveMaxDigits => receiveCurrency == CryptoCurrency.btc ? 8 : 12; +} diff --git a/lib/view_model/exchange/normal_exchange_view_model.dart b/lib/view_model/exchange/normal_exchange_view_model.dart new file mode 100644 index 000000000..f5df8b9a7 --- /dev/null +++ b/lib/view_model/exchange/normal_exchange_view_model.dart @@ -0,0 +1,830 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; + +import 'package:cake_wallet/bitcoin_cash/bitcoin_cash.dart'; +import 'package:cake_wallet/core/wallet_change_listener_view_model.dart'; +import 'package:cake_wallet/entities/exchange_api_mode.dart'; +import 'package:cake_wallet/entities/preferences_key.dart'; +import 'package:cake_wallet/entities/wallet_contact.dart'; +import 'package:cake_wallet/ethereum/ethereum.dart'; +import 'package:cake_wallet/exchange/exolix/exolix_exchange_provider.dart'; +import 'package:cake_wallet/exchange/exolix/exolix_request.dart'; +import 'package:cake_wallet/exchange/sideshift/sideshift_exchange_provider.dart'; +import 'package:cake_wallet/exchange/sideshift/sideshift_request.dart'; +import 'package:cake_wallet/exchange/simpleswap/simpleswap_exchange_provider.dart'; +import 'package:cake_wallet/exchange/simpleswap/simpleswap_request.dart'; +import 'package:cake_wallet/exchange/trocador/trocador_exchange_provider.dart'; +import 'package:cake_wallet/exchange/trocador/trocador_request.dart'; +import 'package:cake_wallet/utils/feature_flag.dart'; +import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart'; +import 'package:cw_core/transaction_priority.dart'; +import 'package:cake_wallet/store/app_store.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/sync_status.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:cake_wallet/bitcoin/bitcoin.dart'; +import 'package:cake_wallet/monero/monero.dart'; +import 'package:cake_wallet/exchange/exchange_provider.dart'; +import 'package:cake_wallet/exchange/limits.dart'; +import 'package:cake_wallet/exchange/trade.dart'; +import 'package:cake_wallet/exchange/limits_state.dart'; +import 'package:cake_wallet/store/dashboard/trades_store.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:intl/intl.dart'; +import 'package:mobx/mobx.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:hive/hive.dart'; +import 'package:cake_wallet/exchange/exchange_trade_state.dart'; +import 'package:cake_wallet/exchange/changenow/changenow_exchange_provider.dart'; +import 'package:cake_wallet/exchange/changenow/changenow_request.dart'; +import 'package:cake_wallet/exchange/trade_request.dart'; +import 'package:cake_wallet/exchange/xmrto/xmrto_exchange_provider.dart'; +import 'package:cake_wallet/exchange/xmrto/xmrto_trade_request.dart'; +import 'package:cake_wallet/exchange/morphtoken/morphtoken_exchange_provider.dart'; +import 'package:cake_wallet/exchange/morphtoken/morphtoken_request.dart'; +import 'package:cake_wallet/store/templates/exchange_template_store.dart'; +import 'package:cake_wallet/exchange/exchange_template.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +part 'exchange_view_model.g.dart'; + +class NormalExchangeViewModel = NormalExchangeViewModelBase with _$ExchangeViewModel; + +abstract class NormalExchangeViewModelBase extends ExchangeViewModel with Store { + @override + void onWalletChange(wallet) { + receiveCurrency = wallet.currency; + depositCurrency = wallet.currency; + } + + NormalExchangeViewModelBase( + AppStore appStore, + this.trades, + this._exchangeTemplateStore, + this.tradesStore, + this._settingsStore, + this.sharedPreferences, + this.contactListViewModel, + ) : _cryptoNumberFormat = NumberFormat(), + isFixedRateMode = false, + isReceiveAmountEntered = false, + depositAmount = '', + receiveAmount = '', + receiveAddress = '', + depositAddress = '', + isDepositAddressEnabled = false, + isReceiveAddressEnabled = false, + isReceiveAmountEditable = false, + _useTorOnly = false, + receiveCurrencies = [], + depositCurrencies = [], + limits = Limits(min: 0, max: 0), + tradeState = ExchangeTradeStateInitial(), + limitsState = LimitsInitialState(), + receiveCurrency = appStore.wallet!.currency, + depositCurrency = appStore.wallet!.currency, + providerList = [], + selectedProviders = ObservableList(), + super(appStore: appStore) { + _useTorOnly = _settingsStore.exchangeStatus == ExchangeApiMode.torOnly; + _setProviders(); + const excludeDepositCurrencies = [CryptoCurrency.btt]; + const excludeReceiveCurrencies = [ + CryptoCurrency.xlm, + CryptoCurrency.xrp, + CryptoCurrency.bnb, + CryptoCurrency.btt + ]; + _initialPairBasedOnWallet(); + + final Map exchangeProvidersSelection = + json.decode(sharedPreferences.getString(PreferencesKey.exchangeProvidersSelection) ?? "{}") + as Map; + + /// if the provider is not in the user settings (user's first time or newly added provider) + /// then use its default value decided by us + selectedProviders = ObservableList.of(providersForCurrentPair() + .where((element) => exchangeProvidersSelection[element.title] == null + ? element.isEnabled + : (exchangeProvidersSelection[element.title] as bool)) + .toList()); + + _setAvailableProviders(); + _calculateBestRate(); + + bestRateSync = Timer.periodic(Duration(seconds: 10), (timer) => _calculateBestRate()); + + isDepositAddressEnabled = !(depositCurrency == wallet.currency); + isReceiveAddressEnabled = !(receiveCurrency == wallet.currency); + depositAmount = ''; + receiveAmount = ''; + receiveAddress = ''; + depositAddress = depositCurrency == wallet.currency ? wallet.walletAddresses.address : ''; + provider = providersForCurrentPair().first; + final initialProvider = provider; + provider!.checkIsAvailable().then((bool isAvailable) { + if (!isAvailable && provider == initialProvider) { + provider = providerList.firstWhere((provider) => provider is ChangeNowExchangeProvider, + orElse: () => providerList.last); + _onPairChange(); + } + }); + receiveCurrencies = CryptoCurrency.all + .where((cryptoCurrency) => !excludeReceiveCurrencies.contains(cryptoCurrency)) + .toList(); + depositCurrencies = CryptoCurrency.all + .where((cryptoCurrency) => !excludeDepositCurrencies.contains(cryptoCurrency)) + .toList(); + _defineIsReceiveAmountEditable(); + loadLimits(); + reaction((_) => isFixedRateMode, (Object _) { + loadLimits(); + _bestRate = 0; + _calculateBestRate(); + }); + } + bool _useTorOnly; + final Box trades; + final ExchangeTemplateStore _exchangeTemplateStore; + final TradesStore tradesStore; + final SharedPreferences sharedPreferences; + + List get _allProviders => [ + ChangeNowExchangeProvider(settingsStore: _settingsStore), + SideShiftExchangeProvider(), + SimpleSwapExchangeProvider(), + TrocadorExchangeProvider(useTorOnly: _useTorOnly), + if (FeatureFlag.isExolixEnabled) ExolixExchangeProvider(), + ]; + + @observable + ExchangeProvider? provider; + + /// Maps in dart are not sorted by default + /// SplayTreeMap is a map sorted by keys + /// will use it to sort available providers + /// based on the rate they yield for the current trade + /// + /// + /// initialize with descending comparator + /// since we want largest rate first + final SplayTreeMap _sortedAvailableProviders = + SplayTreeMap((double a, double b) => b.compareTo(a)); + + final List _tradeAvailableProviders = []; + + @observable + ObservableList selectedProviders; + + @observable + List providerList; + + @observable + CryptoCurrency depositCurrency; + + @observable + CryptoCurrency receiveCurrency; + + @observable + LimitsState limitsState; + + @observable + ExchangeTradeState tradeState; + + @observable + String depositAmount; + + @observable + String receiveAmount; + + @observable + String depositAddress; + + @observable + String receiveAddress; + + @observable + bool isDepositAddressEnabled; + + @observable + bool isReceiveAddressEnabled; + + @observable + bool isReceiveAmountEntered; + + @observable + bool isReceiveAmountEditable; + + @observable + bool isFixedRateMode; + + @observable + Limits limits; + + @computed + SyncStatus get status => wallet.syncStatus; + + @computed + ObservableList get templates => _exchangeTemplateStore.templates; + + @computed + List get walletContactsToShow => contactListViewModel.walletContacts + .where((element) => element.type == receiveCurrency) + .toList(); + + @action + bool checkIfWalletIsAnInternalWallet(String address) { + final walletContactList = + walletContactsToShow.where((element) => element.address == address).toList(); + + return walletContactList.isNotEmpty; + } + + @computed + bool get shouldDisplayTOTP2FAForExchangesToInternalWallet => + _settingsStore.shouldRequireTOTP2FAForExchangesToInternalWallets; + + //* Still open to further optimize these checks + //* It works but can be made better + @action + bool shouldDisplayTOTP() { + final isInternalWallet = checkIfWalletIsAnInternalWallet(receiveAddress); + if (isInternalWallet) { + return shouldDisplayTOTP2FAForExchangesToInternalWallet; + } + return false; + } + + @computed + TransactionPriority get transactionPriority { + final priority = _settingsStore.priority[wallet.type]; + + if (priority == null) { + throw Exception('Unexpected type ${wallet.type.toString()}'); + } + + return priority; + } + + bool get hasAllAmount => + (wallet.type == WalletType.bitcoin || + wallet.type == WalletType.litecoin || + wallet.type == WalletType.bitcoinCash) && + depositCurrency == wallet.currency; + + bool get isMoneroWallet => wallet.type == WalletType.monero; + + bool get isLowFee { + switch (wallet.type) { + case WalletType.monero: + case WalletType.haven: + return transactionPriority == monero!.getMoneroTransactionPrioritySlow(); + case WalletType.bitcoin: + return transactionPriority == bitcoin!.getBitcoinTransactionPrioritySlow(); + case WalletType.litecoin: + return transactionPriority == + bitcoin!.getLitecoinTransactionPrioritySlow(); + case WalletType.ethereum: + return transactionPriority == + ethereum!.getEthereumTransactionPrioritySlow(); + case WalletType.bitcoinCash: + return transactionPriority == + bitcoinCash!.getBitcoinCashTransactionPrioritySlow(); + default: + return false; + } + } + + List receiveCurrencies; + + List depositCurrencies; + + final NumberFormat _cryptoNumberFormat; + + final SettingsStore _settingsStore; + + final ContactListViewModel contactListViewModel; + + double _bestRate = 0.0; + + late Timer bestRateSync; + + @action + void changeDepositCurrency({required CryptoCurrency currency}) { + depositCurrency = currency; + isFixedRateMode = false; + _onPairChange(); + isDepositAddressEnabled = !(depositCurrency == wallet.currency); + isReceiveAddressEnabled = !(receiveCurrency == wallet.currency); + } + + @action + void changeReceiveCurrency({required CryptoCurrency currency}) { + receiveCurrency = currency; + isFixedRateMode = false; + _onPairChange(); + isDepositAddressEnabled = !(depositCurrency == wallet.currency); + isReceiveAddressEnabled = !(receiveCurrency == wallet.currency); + } + + @action + Future changeReceiveAmount({required String amount}) async { + receiveAmount = amount; + + if (amount.isEmpty) { + depositAmount = ''; + receiveAmount = ''; + return; + } + + final _enteredAmount = double.tryParse(amount.replaceAll(',', '.')) ?? 0; + + if (_bestRate == 0) { + depositAmount = S.current.fetching; + + await _calculateBestRate(); + } + _cryptoNumberFormat.maximumFractionDigits = depositMaxDigits; + + depositAmount = _cryptoNumberFormat + .format(_enteredAmount / _bestRate) + .toString() + .replaceAll(RegExp('\\,'), ''); + } + + @action + Future changeDepositAmount({required String amount}) async { + depositAmount = amount; + + if (amount.isEmpty) { + depositAmount = ''; + receiveAmount = ''; + return; + } + + final _enteredAmount = double.tryParse(amount.replaceAll(',', '.')) ?? 0; + + /// in case the best rate was not calculated yet + if (_bestRate == 0) { + receiveAmount = S.current.fetching; + + await _calculateBestRate(); + } + _cryptoNumberFormat.maximumFractionDigits = receiveMaxDigits; + + receiveAmount = _cryptoNumberFormat + .format(_bestRate * _enteredAmount) + .toString() + .replaceAll(RegExp('\\,'), ''); + } + + bool checkIfInputMeetsMinOrMaxCondition(String input) { + final _enteredAmount = double.tryParse(input.replaceAll(',', '.')) ?? 0; + double minLimit = limits.min ?? 0; + double? maxLimit = limits.max; + + if (_enteredAmount < minLimit) { + return false; + } + + if (maxLimit != null && _enteredAmount > maxLimit) { + return false; + } + + return true; + } + + Future _calculateBestRate() async { + final amount = double.tryParse(isFixedRateMode ? receiveAmount : depositAmount) ?? 1; + + final _providers = _tradeAvailableProviders + .where((element) => !isFixedRateMode || element.supportsFixedRate) + .toList(); + + final result = await Future.wait(_providers.map((element) => element.fetchRate( + from: depositCurrency, + to: receiveCurrency, + amount: amount, + isFixedRateMode: isFixedRateMode, + isReceiveAmount: isFixedRateMode))); + + _sortedAvailableProviders.clear(); + + 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]; + } 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 + } + } + } + if (_sortedAvailableProviders.isNotEmpty) { + _bestRate = _sortedAvailableProviders.keys.first; + } + } + + @action + Future loadLimits() async { + if (selectedProviders.isEmpty) { + return; + } + + limitsState = LimitsIsLoading(); + + final from = isFixedRateMode ? receiveCurrency : depositCurrency; + final to = isFixedRateMode ? depositCurrency : receiveCurrency; + + double? lowestMin = double.maxFinite; + double? highestMax = 0.0; + + try { + for (var provider in selectedProviders) { + /// if this provider is not valid for the current pair, skip it + if (!providersForCurrentPair().contains(provider)) { + continue; + } + + try { + final tempLimits = + await provider.fetchLimits(from: from, to: to, isFixedRateMode: isFixedRateMode); + + if (lowestMin != null && (tempLimits.min ?? -1) < lowestMin) { + lowestMin = tempLimits.min; + } + if (highestMax != null && (tempLimits.max ?? double.maxFinite) > highestMax) { + highestMax = tempLimits.max; + } + } catch (e) { + continue; + } + } + } on ConcurrentModificationError { + /// if user changed the selected providers while fetching limits + /// then delay the fetching limits a bit and try again + /// + /// this is because the limitation of collections that + /// you can't modify it while iterating through it + Future.delayed(Duration(milliseconds: 200), loadLimits); + } + + if (lowestMin != double.maxFinite) { + limits = Limits(min: lowestMin, max: highestMax); + + limitsState = LimitsLoadedSuccessfully(limits: limits); + } else { + limitsState = LimitsLoadedFailure(error: 'Limits loading failed'); + } + } + + @action + Future createTrade() async { + TradeRequest? request; + String amount = ''; + + try { + for (var provider in _sortedAvailableProviders.values) { + if (!(await provider.checkIsAvailable())) { + continue; + } + + if (provider is SideShiftExchangeProvider) { + request = SideShiftRequest( + depositMethod: depositCurrency, + settleMethod: receiveCurrency, + depositAmount: isFixedRateMode + ? receiveAmount.replaceAll(',', '.') + : depositAmount.replaceAll(',', '.'), + settleAddress: receiveAddress, + refundAddress: depositAddress, + ); + amount = isFixedRateMode ? receiveAmount : depositAmount; + } + + if (provider is SimpleSwapExchangeProvider) { + request = SimpleSwapRequest( + from: depositCurrency, + to: receiveCurrency, + amount: depositAmount.replaceAll(',', '.'), + address: receiveAddress, + refundAddress: depositAddress, + ); + amount = isFixedRateMode ? receiveAmount : depositAmount; + } + + if (provider is XMRTOExchangeProvider) { + request = XMRTOTradeRequest( + from: depositCurrency, + to: receiveCurrency, + amount: depositAmount.replaceAll(',', '.'), + receiveAmount: receiveAmount.replaceAll(',', '.'), + address: receiveAddress, + refundAddress: depositAddress, + isBTCRequest: isReceiveAmountEntered); + amount = isFixedRateMode ? receiveAmount : depositAmount; + } + + if (provider is ChangeNowExchangeProvider) { + request = ChangeNowRequest( + from: depositCurrency, + to: receiveCurrency, + fromAmount: depositAmount.replaceAll(',', '.'), + toAmount: receiveAmount.replaceAll(',', '.'), + refundAddress: depositAddress, + address: receiveAddress, + isReverse: isFixedRateMode); + amount = isFixedRateMode ? receiveAmount : depositAmount; + } + + if (provider is MorphTokenExchangeProvider) { + request = MorphTokenRequest( + from: depositCurrency, + to: receiveCurrency, + amount: depositAmount.replaceAll(',', '.'), + refundAddress: depositAddress, + address: receiveAddress); + amount = isFixedRateMode ? receiveAmount : depositAmount; + } + + if (provider is TrocadorExchangeProvider) { + request = TrocadorRequest( + from: depositCurrency, + to: receiveCurrency, + fromAmount: depositAmount.replaceAll(',', '.'), + toAmount: receiveAmount.replaceAll(',', '.'), + refundAddress: depositAddress, + address: receiveAddress, + isReverse: isFixedRateMode); + amount = isFixedRateMode ? receiveAmount : depositAmount; + } + + if (provider is ExolixExchangeProvider) { + request = ExolixRequest( + from: depositCurrency, + to: receiveCurrency, + fromAmount: depositAmount.replaceAll(',', '.'), + toAmount: receiveAmount.replaceAll(',', '.'), + refundAddress: depositAddress, + address: receiveAddress); + amount = isFixedRateMode ? receiveAmount : depositAmount; + } + + amount = amount.replaceAll(',', '.'); + + if (limitsState is LimitsLoadedSuccessfully) { + if (double.tryParse(amount) == null) { + continue; + } + if (limits.max != null && double.parse(amount) < limits.min!) { + continue; + } else if (limits.max != null && double.parse(amount) > limits.max!) { + continue; + } else { + try { + tradeState = TradeIsCreating(); + final trade = + await provider.createTrade(request: request!, isFixedRateMode: isFixedRateMode); + trade.walletId = wallet.id; + tradesStore.setTrade(trade); + await trades.add(trade); + tradeState = TradeIsCreatedSuccessfully(trade: trade); + + /// return after the first successful trade + return; + } catch (e) { + continue; + } + } + } + } + + /// if the code reached here then none of the providers succeeded + tradeState = TradeIsCreatedFailure( + title: S.current.trade_not_created, + error: S.current.none_of_selected_providers_can_exchange); + } on ConcurrentModificationError { + /// if create trade happened at the exact same time of the scheduled rate update + /// then delay the create trade a bit and try again + /// + /// this is because the limitation of the SplayTreeMap that + /// you can't modify it while iterating through it + Future.delayed(Duration(milliseconds: 200), createTrade); + } + } + + @action + void reset() { + _initialPairBasedOnWallet(); + isReceiveAmountEntered = false; + depositAmount = ''; + receiveAmount = ''; + depositAddress = depositCurrency == wallet.currency ? wallet.walletAddresses.address : ''; + receiveAddress = receiveCurrency == wallet.currency ? wallet.walletAddresses.address : ''; + isDepositAddressEnabled = !(depositCurrency == wallet.currency); + isReceiveAddressEnabled = !(receiveCurrency == wallet.currency); + isFixedRateMode = false; + _onPairChange(); + } + + @action + void calculateDepositAllAmount() { + if (wallet.type == WalletType.bitcoin || wallet.type == WalletType.litecoin || wallet.type == WalletType.bitcoinCash) { + final availableBalance = wallet.balance[wallet.currency]!.available; + final priority = _settingsStore.priority[wallet.type]!; + final fee = wallet.calculateEstimatedFee(priority, null); + + if (availableBalance < fee || availableBalance == 0) { + return; + } + + final amount = availableBalance - fee; + changeDepositAmount(amount: bitcoin!.formatterBitcoinAmountToString(amount: amount)); + } + } + + void updateTemplate() => _exchangeTemplateStore.update(); + + void addTemplate( + {required String amount, + required String depositCurrency, + required String receiveCurrency, + required String provider, + required String depositAddress, + required String receiveAddress, + required String depositCurrencyTitle, + required String receiveCurrencyTitle}) => + _exchangeTemplateStore.addTemplate( + amount: amount, + depositCurrency: depositCurrency, + receiveCurrency: receiveCurrency, + provider: provider, + depositAddress: depositAddress, + receiveAddress: receiveAddress, + depositCurrencyTitle: depositCurrencyTitle, + receiveCurrencyTitle: receiveCurrencyTitle); + + void removeTemplate({required ExchangeTemplate template}) => + _exchangeTemplateStore.remove(template: template); + + List providersForCurrentPair() { + return _providersForPair(from: depositCurrency, to: receiveCurrency); + } + + List _providersForPair( + {required CryptoCurrency from, required CryptoCurrency to}) { + final providers = providerList + .where((provider) => + provider.pairList.where((pair) => pair.from == from && pair.to == to).isNotEmpty) + .toList(); + + return providers; + } + + void _onPairChange() { + depositAmount = ''; + receiveAmount = ''; + loadLimits(); + _setAvailableProviders(); + _bestRate = 0; + _calculateBestRate(); + } + + void _initialPairBasedOnWallet() { + switch (wallet.type) { + case WalletType.monero: + depositCurrency = CryptoCurrency.xmr; + receiveCurrency = CryptoCurrency.btc; + break; + case WalletType.bitcoin: + depositCurrency = CryptoCurrency.btc; + receiveCurrency = CryptoCurrency.xmr; + break; + case WalletType.litecoin: + depositCurrency = CryptoCurrency.ltc; + receiveCurrency = CryptoCurrency.xmr; + break; + case WalletType.bitcoinCash: + depositCurrency = CryptoCurrency.bch; + receiveCurrency = CryptoCurrency.xmr; + break; + case WalletType.haven: + depositCurrency = CryptoCurrency.xhv; + receiveCurrency = CryptoCurrency.btc; + break; + case WalletType.ethereum: + depositCurrency = CryptoCurrency.eth; + receiveCurrency = CryptoCurrency.xmr; + break; + case WalletType.nano: + depositCurrency = CryptoCurrency.nano; + receiveCurrency = CryptoCurrency.xmr; + break; + default: + break; + } + } + + void _defineIsReceiveAmountEditable() { + /*if ((provider is ChangeNowExchangeProvider) + &&(depositCurrency == CryptoCurrency.xmr) + &&(receiveCurrency == CryptoCurrency.btc)) { + isReceiveAmountEditable = true; + } else { + isReceiveAmountEditable = false; + }*/ + //isReceiveAmountEditable = false; + // isReceiveAmountEditable = selectedProviders.any((provider) => provider is ChangeNowExchangeProvider); + // isReceiveAmountEditable = provider is ChangeNowExchangeProvider || provider is SimpleSwapExchangeProvider; + isReceiveAmountEditable = true; + } + + @action + void addExchangeProvider(ExchangeProvider provider) { + selectedProviders.add(provider); + if (providersForCurrentPair().contains(provider)) { + _tradeAvailableProviders.add(provider); + } + } + + @action + void removeExchangeProvider(ExchangeProvider provider) { + selectedProviders.remove(provider); + _tradeAvailableProviders.remove(provider); + } + + @action + void saveSelectedProviders() { + depositAmount = ''; + receiveAmount = ''; + isFixedRateMode = false; + _defineIsReceiveAmountEditable(); + loadLimits(); + _bestRate = 0; + _calculateBestRate(); + + final Map exchangeProvidersSelection = + json.decode(sharedPreferences.getString(PreferencesKey.exchangeProvidersSelection) ?? "{}") + as Map; + + for (var provider in providerList) { + exchangeProvidersSelection[provider.title] = selectedProviders.contains(provider); + } + + sharedPreferences.setString( + PreferencesKey.exchangeProvidersSelection, + json.encode(exchangeProvidersSelection), + ); + } + + bool get isAvailableInSelected { + final providersForPair = providersForCurrentPair(); + return selectedProviders + .any((element) => element.isAvailable && providersForPair.contains(element)); + } + + void _setAvailableProviders() { + _tradeAvailableProviders.clear(); + + _tradeAvailableProviders.addAll( + selectedProviders.where((provider) => providersForCurrentPair().contains(provider))); + } + + @action + void setDefaultTransactionPriority() { + switch (wallet.type) { + case WalletType.monero: + case WalletType.haven: + _settingsStore.priority[wallet.type] = monero!.getMoneroTransactionPriorityAutomatic(); + break; + case WalletType.bitcoin: + _settingsStore.priority[wallet.type] = bitcoin!.getBitcoinTransactionPriorityMedium(); + break; + case WalletType.litecoin: + _settingsStore.priority[wallet.type] = bitcoin!.getLitecoinTransactionPriorityMedium(); + break; + case WalletType.ethereum: + _settingsStore.priority[wallet.type] = ethereum!.getDefaultTransactionPriority(); + break; + case WalletType.bitcoinCash: + _settingsStore.priority[wallet.type] = bitcoinCash!.getDefaultTransactionPriority(); + break; + default: + break; + } + } + + void _setProviders() { + if (_settingsStore.exchangeStatus == ExchangeApiMode.torOnly) { + providerList = _allProviders.where((provider) => provider.supportsOnionAddress).toList(); + } else { + providerList = _allProviders; + } + } + + int get depositMaxDigits => depositCurrency == CryptoCurrency.btc ? 8 : 12; + + int get receiveMaxDigits => receiveCurrency == CryptoCurrency.btc ? 8 : 12; +}