dev
dart 715 lines 19.3 KB
Raw
1 import 'dart:async';
2
3 import 'package:cake_wallet/core/amount_parsing_proxy.dart';
4 import 'package:cake_wallet/core/amount_validator.dart';
5 import 'package:cake_wallet/core/fiat_conversion_service.dart';
6 import 'package:cake_wallet/core/utilities.dart';
7 import 'package:cake_wallet/core/wallet_change_listener_view_model.dart';
8 import 'package:cake_wallet/entities/calculate_fiat_amount.dart';
9 import 'package:cake_wallet/entities/fiat_api_mode.dart';
10 import 'package:cake_wallet/view_model/bridge/bridge_receiving_wallet_option.dart';
11 import 'package:cake_wallet/entities/bridge_transfer.dart';
12 import 'package:cake_wallet/entities/wallet_manager.dart';
13 import 'package:cake_wallet/evm/evm.dart';
14 import 'package:cake_wallet/reactions/wallet_connect.dart';
15 import 'package:cake_wallet/core/layerzero_scan_service.dart';
16 import 'package:cake_wallet/store/app_store.dart';
17 import 'package:cake_wallet/store/bridge_transfers_store.dart';
18 import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart';
19 import 'package:cake_wallet/store/settings_store.dart';
20 import 'package:cw_core/amount/amount_sanitizer.dart';
21 import 'package:cw_core/amount/money.dart';
22 import 'package:cw_core/crypto_amount_format.dart';
23 import 'package:cw_core/crypto_currency.dart';
24 import 'package:cw_core/erc20_token.dart';
25 import 'package:cw_core/wallet_base.dart';
26 import 'package:cw_core/wallet_info.dart';
27 import 'package:cw_core/utils/print_verbose.dart';
28 import 'package:mobx/mobx.dart';
29
30 part 'bridge_view_model.g.dart';
31
32 class BridgeViewModel = BridgeViewModelBase with _$BridgeViewModel;
33
34 abstract class BridgeViewModelBase extends WalletChangeListenerViewModel with Store {
35 BridgeViewModelBase({
36 required AppStore appStore,
37 required this.bridgeTransfersStore,
38 required this.walletManager,
39 required this.fiatConversionStore,
40 required this.settingsStore,
41 }) : _appStore = appStore,
42 super(appStore: appStore);
43
44 final AppStore _appStore;
45
46 AmountParsingProxy get amountParsingProxy => _appStore.amountParsingProxy;
47
48 void Function()? onBridgeSuccess;
49 final WalletManager walletManager;
50 final SettingsStore settingsStore;
51 final FiatConversionStore fiatConversionStore;
52 final BridgeTransfersStore bridgeTransfersStore;
53 final Map<String, Completer<void>> _pollingCancellers = {};
54
55 static const _pollInterval = Duration(seconds: 2);
56 static const _pollTimeout = Duration(minutes: 5);
57 static const _destinationPollInterval = Duration(seconds: 5);
58 static const _destinationPollTimeout = Duration(minutes: 10);
59
60 @observable
61 ObservableList<BridgeReceivingWalletOption> bridgeReceivingWalletOptions =
62 ObservableList<BridgeReceivingWalletOption>();
63
64 @observable
65 bool isBridgeReceivingWalletListLoading = false;
66
67 @observable
68 CryptoCurrency? selectedToken;
69
70 @observable
71 int? destinationChainId;
72
73 @observable
74 String amount = '';
75
76 @observable
77 String recipientAddress = '';
78
79 @observable
80 String? destinationWalletName;
81
82 @observable
83 BridgeQuote? quote;
84
85 @observable
86 bool isQuoteLoading = false;
87
88 @observable
89 String? quoteError;
90
91 @observable
92 bool isExecuting = false;
93
94 @observable
95 String? executeError;
96
97 @observable
98 bool bridgeSuccess = false;
99
100 @observable
101 BridgeTransfer? lastCreatedBridgeTransfer;
102
103 @computed
104 int? get sourceChainId => evm!.getSelectedChainId(wallet);
105
106 @computed
107 String get sourceAddress => wallet.walletAddresses.address;
108
109 @computed
110 String get fiatCurrencyTitle => settingsStore.fiatCurrency.title;
111
112 @computed
113 List<ChainInfo> get availableDestinationChains {
114 if (!isEVMCompatibleChain(wallet.type)) return [];
115
116 return evm!.getUSDT0DestinationChains(wallet);
117 }
118
119 @computed
120 List<Erc20Token> get availableUSDT0Tokens {
121 if (!isEVMCompatibleChain(wallet.type)) return [];
122
123 final tokens = wallet.balance.keys.whereType<Erc20Token>();
124 return tokens.where((token) => evm!.isUSDT0Token(wallet, token)).toList(growable: false);
125 }
126
127 @computed
128 ChainInfo? get destinationChainInfo {
129 if (destinationChainId == null) return null;
130
131 return availableDestinationChains.firstWhereOrNull((c) => c.chainId == destinationChainId);
132 }
133
134 @computed
135 String get tokenBalanceFormatted {
136 if (selectedToken == null) return "0.00";
137
138 return amountParsingProxy.asDisplayString(
139 Money(selectedTokenBalance, selectedToken!),
140 );
141 }
142
143 @computed
144 String get amountDisplayFormatted {
145 if (selectedToken == null) return "0.00";
146
147 return amountParsingProxy.getDisplayCryptoAmount(
148 amount.replaceAll(',', '.'),
149 selectedToken!,
150 );
151 }
152
153 DecimalAmountValidator get decimalAmountValidator => DecimalAmountValidator(
154 currency: selectedToken!,
155 isAutovalidate: true,
156 );
157
158 @computed
159 String get fiatAmountFormatted {
160 try {
161 if (amount.isEmpty) return '';
162
163 final price = fiatConversionStore.prices[selectedToken!];
164 if (price == null) return '';
165
166 final forFiat =
167 amountParsingProxy.getDisplayCryptoAmount(amount.replaceAll(',', '.'), selectedToken!);
168
169 return calculateFiatAmount(price: price, cryptoAmount: forFiat);
170 } catch (_) {
171 return '';
172 }
173 }
174
175 @computed
176 String get quoteNativeFee {
177 if (quote == null) return '—';
178
179 return amountParsingProxy.asDisplayString(Money(quote!.nativeFee, wallet.currency));
180 }
181
182 @computed
183 String get quoteNativeFeeFormattedForDisplay {
184 if (quoteNativeFee.isEmpty) return '';
185
186 return '${quoteNativeFee.withMaxDecimals(8)} ${wallet.currency.title}';
187 }
188
189 @computed
190 String get quoteNativeFiatFeeFormattedForDisplay {
191 try {
192 if (quote == null || quoteNativeFee.isEmpty) return '';
193
194 final price = fiatConversionStore.prices[wallet.currency];
195 if (price == null) return '';
196
197 final fiatFeeFormatted = calculateFiatAmount(
198 price: price,
199 cryptoAmount: amountParsingProxy.getDisplayCryptoAmount(
200 quoteNativeFee.replaceAll(',', '.'),
201 wallet.currency,
202 ),
203 );
204
205 return '(${fiatCurrencyTitle} $fiatFeeFormatted)';
206 } catch (_) {
207 return '';
208 }
209 }
210
211 @computed
212 bool get canProceedToDestinationNetwork {
213 if (amount.isEmpty) return false;
214
215 if (selectedToken.isNotErc20) return false;
216
217 if (amountError != null) return false;
218
219 final validAmount = amountParsingProxy.tryParseCryptoString(
220 amount.sanitized(),
221 selectedToken!,
222 );
223 return validAmount != null && validAmount > Money.zero(selectedToken!);
224 }
225
226 @computed
227 BigInt get selectedTokenBalance {
228 if (selectedToken == null) return BigInt.zero;
229
230 try {
231 final bal = wallet.balance[selectedToken!];
232
233 return bal?.available.amount ?? BigInt.zero;
234 } catch (e) {
235 return BigInt.zero;
236 }
237 }
238
239 @computed
240 String? get amountError {
241 if (selectedToken == null) return null;
242
243 final amountBigInt = amountParsingProxy.tryParseCryptoString(
244 amount.replaceAll(',', '.'),
245 selectedToken!,
246 );
247
248 if (amountBigInt == null || amountBigInt == BigInt.zero) return null;
249 if (amountBigInt.amount > selectedTokenBalance) {
250 return 'Insufficient balance for ${selectedToken!.title} token.';
251 }
252
253 return null;
254 }
255
256 @action
257 void applyInitialBridgeToken(CryptoCurrency asset) {
258 final token = availableUSDT0Tokens.firstWhereOrNull((t) => t == asset);
259 if (token != null) {
260 setSelectedToken(token);
261 }
262 }
263
264 @action
265 void setDestinationChain(int chainId) => destinationChainId = chainId;
266
267 @action
268 void setSelectedToken(CryptoCurrency token) => selectedToken = token;
269
270 @action
271 void setAmount(String value) => amount = value;
272
273 @action
274 void setMaxAmount() {
275 final token = selectedToken.asErc20;
276 if (token == null) return;
277
278 if (selectedTokenBalance == BigInt.zero) {
279 setAmount('');
280 return;
281 }
282 setAmount(
283 amountParsingProxy.asDisplayString(Money(selectedTokenBalance, token)),
284 );
285 }
286
287 @action
288 void setRecipientAddress(String value, {String? destWalletName}) {
289 recipientAddress = value;
290 destinationWalletName = destWalletName;
291 }
292
293 Future<void> _ensureFiatPriceFor(CryptoCurrency crypto) async {
294 if (fiatConversionStore.prices[crypto] != null) return;
295
296 try {
297 final p = await FiatConversionService.fetchPrice(
298 crypto: crypto,
299 fiat: settingsStore.fiatCurrency,
300 torOnly: settingsStore.fiatApiMode == FiatApiMode.torOnly,
301 );
302
303 runInAction(() {
304 fiatConversionStore.prices[crypto] = p;
305 });
306 } catch (e) {
307 printV('Error ensuring fiat price for $crypto: $e');
308 }
309 }
310
311 @action
312 Future<void> ensureFiatPriceForSelectedToken() async {
313 await _ensureFiatPriceFor(selectedToken!);
314 }
315
316 @action
317 Future<void> ensureFiatPriceForNativeCurrency() async {
318 await _ensureFiatPriceFor(wallet.currency);
319 }
320
321 @action
322 Future<void> loadReceivingWalletOptions() async {
323 if (!isEVMCompatibleChain(wallet.type)) return;
324
325 if (destinationChainId == null) return;
326
327 final destWalletType = evm!.getWalletTypeByChainId(destinationChainId!);
328
329 isBridgeReceivingWalletListLoading = true;
330 try {
331 await walletManager.updateWalletGroups();
332 final all = await WalletInfo.getAll();
333
334 if (destWalletType == null) {
335 bridgeReceivingWalletOptions.clear();
336 return;
337 }
338
339 final filtered =
340 all.where((w) => w.type == destWalletType && w.hardwareWalletType == null).toList();
341
342 final options = <BridgeReceivingWalletOption>[];
343
344 for (final wi in filtered) {
345 final isCurrent = wi.name == wallet.name;
346 options.add(
347 BridgeReceivingWalletOption(
348 walletInfo: wi,
349 isCurrent: isCurrent,
350 groupLabel: walletManager.getGroupName(wi),
351 ),
352 );
353 }
354
355 bridgeReceivingWalletOptions
356 ..clear()
357 ..addAll(options);
358 } catch (e) {
359 printV('Error loading receiving wallet options: $e');
360 } finally {
361 isBridgeReceivingWalletListLoading = false;
362 }
363 }
364
365 String? missingBridgeFieldsMessage() {
366 final src = sourceChainId;
367 final dst = destinationChainId;
368 final token = selectedToken;
369
370 if (src == null ||
371 dst == null ||
372 token == null ||
373 amount.isEmpty ||
374 recipientAddress.trim().isEmpty) {
375 return 'Fill all fields';
376 }
377 return null;
378 }
379
380 ({String? error, BigInt? parsedAmount}) _parseAndValidateAmount(Erc20Token token) {
381 final parsedAmount = amountParsingProxy.tryParseCryptoString(
382 amount.replaceAll(',', '.'),
383 token,
384 );
385
386 if (parsedAmount == null || parsedAmount == Money(BigInt.zero, token)) {
387 return (error: 'Invalid amount', parsedAmount: null);
388 }
389
390 if (parsedAmount.amount > selectedTokenBalance) {
391 return (
392 error: 'Insufficient balance for ${token.title} token.',
393 parsedAmount: null,
394 );
395 }
396
397 return (error: null, parsedAmount: parsedAmount.amount);
398 }
399
400 @action
401 Future<void> loadQuote() async {
402 final missing = missingBridgeFieldsMessage();
403 if (missing != null) {
404 quoteError = missing;
405 return;
406 }
407
408 final token = selectedToken.asErc20;
409 if (token == null) return;
410
411 final check = _parseAndValidateAmount(token);
412 if (check.error != null) {
413 quoteError = check.error;
414 return;
415 }
416
417 final src = sourceChainId!;
418 final dst = destinationChainId!;
419 final amountBigInt = check.parsedAmount!;
420
421 isQuoteLoading = true;
422 _clearQuoteState();
423
424 try {
425 quote = await evm!.quoteUSDT0Transfer(
426 wallet: wallet,
427 sourceChainId: src,
428 destinationChainId: dst,
429 amount: amountBigInt,
430 recipientAddress: recipientAddress.trim(),
431 );
432 } catch (e) {
433 quoteError = e.toString();
434 } finally {
435 isQuoteLoading = false;
436 }
437 }
438
439 @action
440 Future<void> executeBridge() async {
441 if (quote == null) {
442 executeError = 'Get a quote first';
443 return;
444 }
445
446 final missing = missingBridgeFieldsMessage();
447 if (missing != null) {
448 executeError = missing;
449 return;
450 }
451
452 final token = selectedToken.asErc20;
453 if (token == null) return;
454
455 final check = _parseAndValidateAmount(token);
456 if (check.error != null) {
457 executeError = check.error;
458 return;
459 }
460
461 final src = sourceChainId!;
462 final dst = destinationChainId!;
463 final amountBigInt = check.parsedAmount!;
464
465 isExecuting = true;
466 executeError = null;
467 try {
468 final priority = evm!.getDefaultTransactionPriority();
469 final pending = await evm!.executeUSDT0Transfer(
470 wallet: wallet,
471 token: token,
472 sourceChainId: src,
473 destinationChainId: dst,
474 amount: amountBigInt,
475 recipientAddress: recipientAddress.trim(),
476 quote: quote!,
477 priority: priority,
478 useBlinkProtection: canSupportBlinkProtection(src),
479 );
480
481 final sourceTxHash = pending.evmTxHashFromRawHex ?? pending.id;
482 await pending.commit();
483
484 final record = BridgeTransfer(
485 id: '${sourceTxHash}_${DateTime.now().millisecondsSinceEpoch}',
486 walletId: wallet.name,
487 sourceChainId: src,
488 destinationChainId: dst,
489 tokenSymbol: token.title,
490 tokenContract: token.contractAddress,
491 amount: amount,
492 recipientAddress: recipientAddress.trim(),
493 sourceTxHash: sourceTxHash,
494 status: 'submitted',
495 createdAt: DateTime.now(),
496 );
497
498 await bridgeTransfersStore.addTransfer(record);
499 runInAction(() {
500 quote = null;
501 bridgeSuccess = true;
502 lastCreatedBridgeTransfer = record;
503 });
504 onBridgeSuccess?.call();
505 _pollForConfirmation(record, wallet, isSource: true);
506 } catch (e) {
507 executeError = e.toString();
508 } finally {
509 isExecuting = false;
510 }
511 }
512
513 @override
514 void onWalletChange(WalletBase wallet) {
515 _cancelAllPolling();
516 _resumePollingForActiveTransfers(wallet);
517 }
518
519 void _resumePollingForActiveTransfers(WalletBase wallet) {
520 if (!isEVMCompatibleChain(wallet.type)) return;
521
522 final activeTransfers = bridgeTransfersStore.bridgeTransfers
523 .where((t) => t.walletId == wallet.name && t.isActive)
524 .toList();
525
526 for (final transfer in activeTransfers) {
527 if (transfer.status == 'submitted' || transfer.status == 'confirming') {
528 _pollForConfirmation(transfer, wallet, isSource: true);
529 } else if (transfer.status == 'initiated') {
530 _pollForConfirmation(transfer, wallet, isSource: false);
531 }
532 }
533 }
534
535 bool _isValidWalletContext(String expectedWalletId) {
536 return wallet.name == expectedWalletId &&
537 isEVMCompatibleChain(wallet.type) &&
538 !_pollingCancellers.values.any((c) => c.isCompleted);
539 }
540
541 Future<void> _updateTransferStatus(
542 BridgeTransfer record,
543 String status, {
544 String? errorMessage,
545 String? statusMessage,
546 DateTime? confirmedAt,
547 }) async {
548 if (!_isValidWalletContext(record.walletId)) return;
549
550 runInAction(() {
551 record.updatedAt = DateTime.now();
552 record.status = status;
553 if (errorMessage != null) record.errorMessage = errorMessage;
554 if (statusMessage != null) record.statusMessage = statusMessage;
555 if (confirmedAt != null) record.confirmedAt = confirmedAt;
556 });
557
558 try {
559 await bridgeTransfersStore.updateTransfer(record);
560 } catch (e) {
561 printV('USDT0 bridge: Error updating transfer status: $e');
562 }
563 }
564
565 Future<void> _pollForConfirmation(
566 BridgeTransfer record,
567 WalletBase wallet, {
568 required bool isSource,
569 }) async {
570 final canceller = Completer<void>();
571 final recordId = isSource ? record.id : '${record.id}_dest';
572 final pollInterval = isSource ? _pollInterval : _destinationPollInterval;
573 final pollTimeout = isSource ? _pollTimeout : _destinationPollTimeout;
574 final walletId = wallet.name;
575 final deadline = DateTime.now().add(pollTimeout);
576
577 _pollingCancellers[recordId] = canceller;
578
579 try {
580 while (DateTime.now().isBefore(deadline)) {
581 await Future.any([
582 Future.delayed(pollInterval),
583 canceller.future,
584 ]);
585
586 if (canceller.isCompleted || !_isValidWalletContext(walletId)) return;
587
588 if (isSource) {
589 final receipt = await _fetchTransactionReceipt(record, wallet);
590
591 if (receipt != null) {
592 final isTransactionSuccessful = receipt == true;
593 await _updateTransferStatus(
594 record,
595 isTransactionSuccessful ? 'initiated' : 'failed',
596 confirmedAt: isTransactionSuccessful ? DateTime.now() : null,
597 errorMessage: !isTransactionSuccessful ? 'Transaction reverted' : null,
598 );
599
600 if (isTransactionSuccessful) {
601 await Future.delayed(const Duration(seconds: 1));
602 if (_isValidWalletContext(walletId)) {
603 _pollForConfirmation(record, wallet, isSource: false);
604 }
605 }
606
607 return;
608 }
609
610 continue;
611 } else {
612 final status = await _fetchLayerZeroMessageStatus(record, wallet);
613
614 if (status != null) {
615 final statusMessage = _getStatusMessage(status, record);
616 await _updateTransferStatus(
617 record,
618 statusMessage,
619 errorMessage:
620 status.isFailed ? status.status?.message ?? 'Bridge message failed' : null,
621 statusMessage: status.status?.message,
622 );
623
624 if (status.isDelivered || status.isFailed) return;
625 }
626
627 continue;
628 }
629 }
630
631 if (isSource && _isValidWalletContext(walletId)) {
632 await _updateTransferStatus(
633 record,
634 'failed',
635 errorMessage: 'Source confirmation timed out',
636 );
637 }
638 } catch (e) {
639 printV('USDT0 bridge: Error polling for confirmation: $e');
640 } finally {
641 _pollingCancellers.remove(recordId);
642 }
643 }
644
645 String _getStatusMessage(LayerZeroMessageStatus status, BridgeTransfer record) {
646 if (status.isDelivered) {
647 return 'completed';
648 }
649
650 if (status.isFailed) {
651 return 'failed';
652 }
653
654 return record.status;
655 }
656
657 Future<bool?> _fetchTransactionReceipt(BridgeTransfer record, WalletBase wallet) async {
658 try {
659 return await evm!.getTransactionReceipt(wallet, record.sourceTxHash);
660 } catch (e) {
661 printV('USDT0 bridge: Error fetching receipt: $e');
662 return null;
663 }
664 }
665
666 Future<LayerZeroMessageStatus?> _fetchLayerZeroMessageStatus(
667 BridgeTransfer record, WalletBase wallet) async {
668 try {
669 return await LayerZeroScanService.getMessageStatus(record.sourceTxHash);
670 } catch (e) {
671 printV('USDT0 bridge: Error fetching LayerZero status: $e');
672 return null;
673 }
674 }
675
676 @action
677 void _clearQuoteState() {
678 quote = null;
679 quoteError = null;
680 executeError = null;
681 }
682
683 @action
684 void clearOnBridgeSuccess() {
685 amount = '';
686 recipientAddress = '';
687 destinationWalletName = null;
688 destinationChainId = null;
689 bridgeSuccess = false;
690 lastCreatedBridgeTransfer = null;
691 _clearQuoteState();
692 }
693
694 void _cancelAllPolling() {
695 for (final canceller in _pollingCancellers.values) {
696 if (!canceller.isCompleted) {
697 canceller.complete();
698 }
699 }
700 _pollingCancellers.clear();
701 }
702
703 void dispose() {
704 _cancelAllPolling();
705 }
706 }
707
708 extension CryptoCurrencyX on CryptoCurrency? {
709 Erc20Token? get asErc20 {
710 final token = this;
711 return token is Erc20Token ? token : null;
712 }
713
714 bool get isNotErc20 => this == null || this is! Erc20Token;
715 }