dev
dart 527 lines 15.7 KB
Raw
1 import 'dart:async';
2
3 import 'package:cake_wallet/buy/buy_provider.dart';
4 import 'package:cake_wallet/buy/buy_quote.dart';
5 import 'package:cake_wallet/buy/onramper/onramper_buy_provider.dart';
6 import 'package:cake_wallet/buy/payment_method.dart';
7 import 'package:cake_wallet/buy/sell_buy_states.dart';
8 import 'package:cake_wallet/core/amount_parsing_proxy.dart';
9 import 'package:cake_wallet/core/selectable_option.dart';
10 import 'package:cake_wallet/core/wallet_change_listener_view_model.dart';
11 import 'package:cake_wallet/entities/fiat_currency.dart';
12 import 'package:cake_wallet/entities/provider_types.dart';
13 import 'package:cake_wallet/generated/i18n.dart';
14 import 'package:cake_wallet/routes.dart';
15 import 'package:cake_wallet/store/app_store.dart';
16 import 'package:cw_core/crypto_amount_format.dart';
17 import 'package:cw_core/crypto_currency.dart';
18 import 'package:flutter/cupertino.dart';
19 import 'package:mobx/mobx.dart';
20
21 part 'buy_sell_view_model.g.dart';
22
23 class BuySellViewModel = BuySellViewModelBase with _$BuySellViewModel;
24
25 abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with Store {
26 BuySellViewModelBase(
27 AppStore appStore,
28 ) : _cryptoAmount = '',
29 fiatAmount = '',
30 cryptoCurrencyAddress = '',
31 isCryptoCurrencyAddressEnabled = false,
32 cryptoCurrencies = <CryptoCurrency>[],
33 fiatCurrencies = <FiatCurrency>[],
34 paymentMethodState = InitialPaymentMethod(),
35 buySellQuotState = InitialBuySellQuotState(),
36 cryptoCurrency = appStore.wallet!.currency,
37 fiatCurrency = appStore.settingsStore.fiatCurrency,
38 providerList = [],
39 sortedRecommendedQuotes = ObservableList<Quote>(),
40 sortedQuotes = ObservableList<Quote>(),
41 paymentMethods = ObservableList<PaymentMethod>(),
42 _appStore = appStore,
43 super(appStore: appStore) {
44 const excludeFiatCurrencies = [];
45 const excludeCryptoCurrencies = [];
46
47 fiatCurrencies =
48 FiatCurrency.all.where((currency) => !excludeFiatCurrencies.contains(currency)).toList();
49 cryptoCurrencies = CryptoCurrency.all
50 .where((currency) => !excludeCryptoCurrencies.contains(currency))
51 .toList();
52 _initialize();
53
54 isCryptoCurrencyAddressEnabled = !(cryptoCurrency == wallet.currency);
55 }
56
57 late Timer bestRateSync;
58
59 List<BuyProvider> get availableBuyProviders {
60 final providerTypes = ProvidersHelper.getAvailableBuyProviderTypes();
61 return providerTypes
62 .map((type) => ProvidersHelper.getProviderByType(type))
63 .cast<BuyProvider>()
64 .toList();
65 }
66
67 List<BuyProvider> get availableSellProviders {
68 final providerTypes = ProvidersHelper.getAvailableSellProviderTypes();
69 return providerTypes
70 .map((type) => ProvidersHelper.getProviderByType(type))
71 .cast<BuyProvider>()
72 .toList();
73 }
74
75 @override
76 void onWalletChange(wallet) {
77 cryptoCurrency = wallet.currency;
78 }
79
80 double get amount {
81 final formattedFiatAmount = double.tryParse(fiatAmount);
82 final formattedCryptoAmount = double.tryParse(_cryptoAmount);
83
84 return isBuyAction
85 ? formattedFiatAmount ?? 200.0
86 : formattedCryptoAmount ?? (cryptoCurrency == CryptoCurrency.btc ? 0.001 : 1);
87 }
88
89 final AppStore _appStore;
90
91 AmountParsingProxy get amountParsingProxy => _appStore.amountParsingProxy;
92
93 Quote? bestRateQuote;
94
95 Quote? selectedQuote;
96
97 @observable
98 List<CryptoCurrency> cryptoCurrencies;
99
100 @observable
101 List<FiatCurrency> fiatCurrencies;
102
103 @observable
104 bool isBuyAction = true;
105
106 @observable
107 List<BuyProvider> providerList;
108
109 @observable
110 ObservableList<Quote> sortedRecommendedQuotes;
111
112 @observable
113 ObservableList<Quote> sortedQuotes;
114
115 @observable
116 ObservableList<PaymentMethod> paymentMethods;
117
118 @observable
119 FiatCurrency fiatCurrency;
120
121 @observable
122 CryptoCurrency cryptoCurrency;
123
124 @observable
125 String _cryptoAmount;
126
127 @computed
128 String get cryptoAmount =>
129 _appStore.amountParsingProxy.getDisplayCryptoAmount(_cryptoAmount, cryptoCurrency);
130
131 @observable
132 String fiatAmount;
133
134 @observable
135 String cryptoCurrencyAddress;
136
137 @observable
138 bool isCryptoCurrencyAddressEnabled;
139
140 @observable
141 PaymentMethod? selectedPaymentMethod;
142
143 @observable
144 PaymentMethodLoadingState paymentMethodState;
145
146 @observable
147 BuySellQuotLoadingState buySellQuotState;
148
149 @observable
150 bool skipIsReadyToTradeReaction = false;
151
152 @computed
153 bool get isReadyToTrade {
154 final hasSelectedQuote = selectedQuote != null;
155 final hasSelectedPaymentMethod = selectedPaymentMethod != null;
156 final isPaymentMethodLoaded = paymentMethodState is PaymentMethodLoaded;
157 final isBuySellQuotLoaded = buySellQuotState is BuySellQuotLoaded;
158
159 return hasSelectedQuote &&
160 hasSelectedPaymentMethod &&
161 isPaymentMethodLoaded &&
162 isBuySellQuotLoaded;
163 }
164
165 @computed
166 bool get isBuySellQuoteFailed => buySellQuotState is BuySellQuotFailed;
167
168 @computed
169 String? get buySellQuoteFailedError => buySellQuotState is BuySellQuotFailed
170 ? (buySellQuotState as BuySellQuotFailed).errorMessage
171 : null;
172
173 @computed
174 bool get useSatoshi => _appStore.amountParsingProxy.useSatoshi(cryptoCurrency);
175
176 @action
177 void reset() {
178 cryptoCurrency = wallet.currency;
179 fiatCurrency = _appStore.settingsStore.fiatCurrency;
180 isCryptoCurrencyAddressEnabled = !(cryptoCurrency == wallet.currency);
181 _initialize();
182 }
183
184 @action
185 void changeBuySellAction() {
186 isBuyAction = !isBuyAction;
187 _initialize();
188 }
189
190 @action
191 void changeFiatCurrency({required FiatCurrency currency}) {
192 fiatCurrency = currency;
193 _onPairChange();
194 }
195
196 @action
197 void changeCryptoCurrency({required CryptoCurrency currency}) {
198 cryptoCurrency = currency;
199 _onPairChange();
200 isCryptoCurrencyAddressEnabled = !(cryptoCurrency == wallet.currency);
201 }
202
203 @action
204 void changeCryptoCurrencyAddress(String address) => cryptoCurrencyAddress = address;
205
206 @action
207 Future<void> changeFiatAmount({required String amount}) async {
208 fiatAmount = amount;
209
210 if (amount.isEmpty) {
211 fiatAmount = '';
212 _cryptoAmount = '';
213 return;
214 }
215
216 if (!isReadyToTrade && !isBuySellQuoteFailed) {
217 _cryptoAmount = S.current.fetching;
218 return;
219 } else if (isBuySellQuoteFailed) {
220 _cryptoAmount = '';
221 return;
222 }
223
224 if (bestRateQuote != null) {
225 final enteredAmount = double.tryParse(fiatAmount.replaceAll(',', '.')) ?? 0;
226 final amount = enteredAmount / bestRateQuote!.rate;
227
228 _cryptoAmount = amount.toString().withMaxDecimals(cryptoCurrency.decimals);
229 } else {
230 await calculateBestRate();
231 }
232 }
233
234 @action
235 Future<void> changeCryptoAmount({required String amount}) async {
236 _cryptoAmount = _appStore.amountParsingProxy.getCanonicalCryptoAmount(amount, cryptoCurrency);
237
238 if (amount.isEmpty) {
239 fiatAmount = '';
240 _cryptoAmount = '';
241 return;
242 }
243
244 if (!isReadyToTrade && !isBuySellQuoteFailed) {
245 fiatAmount = S.current.fetching;
246 return;
247 } else if (isBuySellQuoteFailed) {
248 fiatAmount = '';
249 return;
250 }
251
252 if (bestRateQuote != null) {
253 final enteredAmount = double.tryParse(_cryptoAmount.replaceAll(',', '.')) ?? 0;
254
255 fiatAmount =
256 (enteredAmount * bestRateQuote!.rate).toString().withMaxDecimals(fiatCurrency.decimals);
257 } else {
258 await calculateBestRate();
259 }
260 }
261
262 @action
263 void changeOption(SelectableOption option) {
264 if (option is Quote) {
265 sortedRecommendedQuotes.forEach((element) => element.setIsSelected = false);
266 sortedQuotes.forEach((element) => element.setIsSelected = false);
267 option.setIsSelected = true;
268 selectedQuote = option;
269 } else if (option is PaymentMethod) {
270 paymentMethods.forEach((element) => element.isSelected = false);
271 option.isSelected = true;
272 selectedPaymentMethod = option;
273 } else {
274 throw ArgumentError('Unknown option type');
275 }
276 }
277
278 void onTapChoseProvider(BuildContext context) async {
279 skipIsReadyToTradeReaction = true;
280 final initialQuotes = List<Quote>.from(sortedRecommendedQuotes + sortedQuotes);
281 await calculateBestRate();
282 final newQuotes = (sortedRecommendedQuotes + sortedQuotes);
283
284 for (var quote in newQuotes) quote.limits = null;
285
286 final newQuoteProviders = newQuotes
287 .map((quote) => quote.provider.isAggregator ? quote.rampName : quote.provider.title)
288 .toSet();
289
290 final outOfLimitQuotes = initialQuotes.where((initialQuote) {
291 return !newQuoteProviders.contains(
292 initialQuote.provider.isAggregator ? initialQuote.rampName : initialQuote.provider.title);
293 }).map((missingQuote) {
294 final quote = Quote(
295 rate: missingQuote.rate,
296 feeAmount: missingQuote.feeAmount,
297 networkFee: missingQuote.networkFee,
298 transactionFee: missingQuote.transactionFee,
299 payout: missingQuote.payout,
300 rampId: missingQuote.rampId,
301 rampName: missingQuote.rampName,
302 rampIconPath: missingQuote.rampIconPath,
303 paymentType: missingQuote.paymentType,
304 quoteId: missingQuote.quoteId,
305 recommendations: missingQuote.recommendations,
306 provider: missingQuote.provider,
307 isBuyAction: missingQuote.isBuyAction,
308 limits: missingQuote.limits,
309 );
310 quote.setFiatCurrency = missingQuote.fiatCurrency;
311 quote.setCryptoCurrency = missingQuote.cryptoCurrency;
312 return quote;
313 }).toList();
314
315 final updatedQuoteOptions = List<SelectableItem>.from([
316 OptionTitle(title: 'Recommended'),
317 ...sortedRecommendedQuotes,
318 if (sortedQuotes.isNotEmpty) OptionTitle(title: 'All Providers'),
319 ...sortedQuotes,
320 if (outOfLimitQuotes.isNotEmpty) OptionTitle(title: 'Out of Limits'),
321 ...outOfLimitQuotes,
322 ]);
323
324 if (context.mounted) {
325 await Navigator.of(context).pushNamed(
326 Routes.buyOptionsPage,
327 arguments: [
328 updatedQuoteOptions,
329 changeOption,
330 launchTrade,
331 ],
332 ).then((value) => calculateBestRate());
333 }
334 }
335
336 void _onPairChange() {
337 _initialize();
338 }
339
340 void _setProviders() =>
341 providerList = isBuyAction ? availableBuyProviders : availableSellProviders;
342
343 Future<void> _initialize() async {
344 _setProviders();
345 _cryptoAmount = '';
346 fiatAmount = '';
347 cryptoCurrencyAddress = _getInitialCryptoCurrencyAddress();
348 paymentMethodState = InitialPaymentMethod();
349 buySellQuotState = InitialBuySellQuotState();
350 await _getAvailablePaymentTypes();
351 await calculateBestRate();
352 }
353
354 String _getInitialCryptoCurrencyAddress() {
355 if (cryptoCurrency == wallet.currency) {
356 if ([CryptoCurrency.zec, CryptoCurrency.btc].contains(cryptoCurrency)) {
357 return wallet.walletAddresses.addressForBuy;
358 }
359
360 return wallet.walletAddresses.address;
361 }
362 return '';
363 }
364
365 @action
366 Future<void> _getAvailablePaymentTypes() async {
367 paymentMethodState = PaymentMethodLoading();
368 selectedPaymentMethod = null;
369 final result = await Future.wait(providerList.map((element) =>
370 element.getAvailablePaymentTypes(fiatCurrency.title, cryptoCurrency, isBuyAction).timeout(
371 Duration(seconds: 10),
372 onTimeout: () => [],
373 )));
374
375 final List<PaymentMethod> tempPaymentMethods = [];
376
377 for (var methods in result) {
378 for (var method in methods) {
379 final alreadyExists = tempPaymentMethods.any((m) {
380 return m.paymentMethodType == method.paymentMethodType;
381 });
382
383 if (!alreadyExists) {
384 tempPaymentMethods.add(method);
385 }
386 }
387 }
388
389 paymentMethods = ObservableList<PaymentMethod>.of(tempPaymentMethods);
390
391 if (paymentMethods.isNotEmpty) {
392 paymentMethods.insert(0, PaymentMethod.all());
393 selectedPaymentMethod = paymentMethods.first;
394 selectedPaymentMethod!.isSelected = true;
395 paymentMethodState = PaymentMethodLoaded();
396 } else {
397 paymentMethodState = PaymentMethodFailed();
398 }
399 }
400
401 @action
402 Future<void> calculateBestRate() async {
403 buySellQuotState = BuySellQuotLoading();
404
405 final List<BuyProvider> validProviders = providerList.where((provider) {
406 if (isBuyAction) {
407 return provider.supportedCryptoList
408 .any((pair) => pair.from == cryptoCurrency && pair.to == fiatCurrency);
409 } else {
410 return provider.supportedFiatList
411 .any((pair) => pair.from == fiatCurrency && pair.to == cryptoCurrency);
412 }
413 }).toList();
414
415 if (validProviders.isEmpty) {
416 buySellQuotState = BuySellQuotFailed();
417 return;
418 }
419
420 final result = await Future.wait<List<Quote>?>(validProviders.map((element) => element
421 .fetchQuote(
422 cryptoCurrency: cryptoCurrency,
423 fiatCurrency: fiatCurrency,
424 amount: amount,
425 paymentType: selectedPaymentMethod?.paymentMethodType,
426 isBuyAction: isBuyAction,
427 walletAddress: wallet.walletAddresses.address,
428 customPaymentMethodType: selectedPaymentMethod?.customPaymentMethodType,
429 )
430 .timeout(
431 Duration(seconds: 10),
432 onTimeout: () => null,
433 )));
434
435 sortedRecommendedQuotes.clear();
436 sortedQuotes.clear();
437
438 final validQuotes = result
439 .where((element) => element != null && element.isNotEmpty)
440 .expand((element) => element!)
441 .toList();
442
443 if (validQuotes.isEmpty) {
444 buySellQuotState = BuySellQuotFailed();
445 return;
446 }
447
448 if (isBuyAction) {
449 validQuotes.sort((a, b) => b.payout.compareTo(a.payout));
450 } else {
451 validQuotes.sort((a, b) => a.payout.compareTo(b.payout));
452 }
453
454 final Set<String> addedProviders = {};
455 final List<Quote> uniqueProviderQuotes = validQuotes.where((element) {
456 if (addedProviders.contains(element.provider.title)) return false;
457 addedProviders.add(element.provider.title);
458 return true;
459 }).toList();
460
461 final List<Quote> successRateQuotes = validQuotes
462 .where((element) =>
463 element.provider is OnRamperBuyProvider &&
464 element.recommendations.contains(ProviderRecommendation.successRate))
465 .toList();
466
467 for (final quote in successRateQuotes) {
468 if (!uniqueProviderQuotes.contains(quote)) {
469 uniqueProviderQuotes.add(quote);
470 }
471 }
472
473 sortedRecommendedQuotes.addAll(uniqueProviderQuotes);
474
475 sortedQuotes = ObservableList.of(
476 validQuotes.where((element) => !uniqueProviderQuotes.contains(element)).toList());
477
478 if (sortedRecommendedQuotes.isNotEmpty) {
479 sortedRecommendedQuotes.first..setIsBestRate = true;
480 bestRateQuote = sortedRecommendedQuotes.first;
481
482 sortedRecommendedQuotes.sort((a, b) {
483 if (a.provider is OnRamperBuyProvider) return -1;
484 if (b.provider is OnRamperBuyProvider) return 1;
485 return 0;
486 });
487
488 final Quote effectiveBestRateQuote = sortedRecommendedQuotes.reduce((a, b) {
489 return isBuyAction
490 ? a.rate < b.rate
491 ? a
492 : b
493 : a.rate > b.rate
494 ? a
495 : b;
496 });
497
498 effectiveBestRateQuote.recommendations.add(ProviderRecommendation.bestRate);
499
500 selectedQuote = sortedRecommendedQuotes.first;
501 sortedRecommendedQuotes.first.setIsSelected = true;
502 }
503
504 buySellQuotState = BuySellQuotLoaded();
505 }
506
507 @action
508 Future<void> launchTrade(BuildContext context) async {
509 final provider = selectedQuote!.provider;
510 try {
511 await provider.launchProvider(
512 context: context,
513 quote: selectedQuote!,
514 amount: amount,
515 isBuyAction: isBuyAction,
516 cryptoCurrencyAddress: cryptoCurrencyAddress,
517 );
518 } catch (e) {
519 if (e.toString().contains("403")) {
520 buySellQuotState = BuySellQuotFailed(errorMessage: "Using Tor is not supported");
521 } else {
522 buySellQuotState =
523 BuySellQuotFailed(errorMessage: "Something went wrong please try again later");
524 }
525 }
526 }
527 }