import 'dart:async'; import 'dart:collection'; import 'dart:convert'; import 'package:cake_wallet/entities/exchange_api_mode.dart'; import 'package:cake_wallet/entities/preferences_key.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:cw_core/transaction_priority.dart'; import 'package:cw_core/wallet_base.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 ExchangeViewModel = ExchangeViewModelBase with _$ExchangeViewModel; abstract class ExchangeViewModelBase with Store { ExchangeViewModelBase(this.wallet, this.trades, this._exchangeTemplateStore, this.tradesStore, this._settingsStore, this.sharedPreferences) : _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 = wallet.currency, depositCurrency = wallet.currency, providerList = [], selectedProviders = ObservableList() { _useTorOnly = _settingsStore.exchangeStatus == ExchangeApiMode.torOnly; _setProviders(); const excludeDepositCurrencies = [CryptoCurrency.btt, CryptoCurrency.nano]; const excludeReceiveCurrencies = [CryptoCurrency.xlm, CryptoCurrency.xrp, CryptoCurrency.bnb, CryptoCurrency.btt, CryptoCurrency.nano]; _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 WalletBase wallet; final Box trades; final ExchangeTemplateStore _exchangeTemplateStore; final TradesStore tradesStore; final SharedPreferences sharedPreferences; List get _allProviders => [ ChangeNowExchangeProvider(), SideShiftExchangeProvider(), SimpleSwapExchangeProvider(), TrocadorExchangeProvider(useTorOnly: _useTorOnly), ]; @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 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 && 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(); default: return false; } } List receiveCurrencies; List depositCurrencies; final NumberFormat _cryptoNumberFormat; final SettingsStore _settingsStore; 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 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; } amount = amount.replaceAll(',', '.'); if (limitsState is LimitsLoadedSuccessfully) { 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) { 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.haven: depositCurrency = CryptoCurrency.xhv; receiveCurrency = CryptoCurrency.btc; break; case WalletType.ethereum: depositCurrency = CryptoCurrency.eth; 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; 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; }