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