refactor: Improve bridge view model (#3184)
What changed, and why it matters
This is a routine code cleanup for the app's cross-chain 'bridge' feature. It renames and moves view-model files, swaps an internal USDT0 quote type for a more generic BridgeQuote, tightens some balance/amount checks, and fixes build issues for the monero.com flavor. There is no obvious security vulnerability being introduced or fixed; it reads like normal maintenance.
No immediate security action required. Treat as normal refactor; verify through standard QA that bridge quotes and transfers still behave correctly.
Security signals we found
No explicit security fix or advisory language in commit title/message
Refactoring only: file moves, renames, type abstraction, null-safety helpers
Added defensive try/catch around network-dependent operations (price fetch, polling, transfer update)
Removed wownero seed verification from integration tests (build/test maintenance, not a vulnerability)
No changes to cryptography, key storage, authentication, or network trust boundaries
Evidence from the diff
The commit refactors bridge-related view models into a single folder, renames clearBridgeSuccess to clearOnBridgeSuccess, changes quoteUSDT0Transfer return type from USDT0Quote to BridgeQuote, and adds null-safe helpers (CryptoCurrencyX.asErc20/isNotErc20). It also removes wownero-specific integration-test code, updates a USDT0 documentation URL, and adds try/catch guards around price fetching, polling, and transfer updates. The polling logic is consolidated into _pollForConfirmation with separate timeouts (source 5 min, destination 10 min).
Changed components
lib/view_model/bridge/*lib/new-ui/pages/bridge/*lib/evm/cw_evm.darttool/configure.dartcw_evm/lib/usdt0/usdt0_config.dartintegration_test/robots/wallet_keys_robot.dartInspect captured patch +492 / −488
diff --git a/cw_evm/lib/usdt0/usdt0_config.dart b/cw_evm/lib/usdt0/usdt0_config.dart
index e1978b7a..292a2775 100644
--- a/cw_evm/lib/usdt0/usdt0_config.dart
+++ b/cw_evm/lib/usdt0/usdt0_config.dart
@@ -1,7 +1,7 @@
import 'package:cw_core/erc20_token.dart';
/// USDT0 (Omnichain USDT) config.
-/// Addresses and EIDs from https://docs.usdt0.to/technical-documentation/developer/usdt0-deployments
+/// Addresses and EIDs from https://docs.usdt0.to/technical-documentation/deployments
class USDT0Config {
USDT0Config._();
diff --git a/integration_test/robots/wallet_keys_robot.dart b/integration_test/robots/wallet_keys_robot.dart
index 54887403..6ec9f9df 100644
--- a/integration_test/robots/wallet_keys_robot.dart
+++ b/integration_test/robots/wallet_keys_robot.dart
@@ -5,7 +5,6 @@ import 'package:cake_wallet/store/app_store.dart';
import 'package:cw_core/monero_wallet_keys.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:cw_monero/monero_wallet.dart';
-import 'package:cw_wownero/wownero_wallet.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:polyseed/polyseed.dart';
@@ -55,19 +54,6 @@ class WalletKeysAndSeedPageRobot {
);
}
- if (walletType == WalletType.wownero) {
- final wowneroWallet = appStore.wallet as WowneroWallet;
- final lang = PolyseedLang.getByPhrase(wowneroWallet.seed);
- final legacySeed = wowneroWallet.seedLegacy(lang.nameEnglish);
-
- await _confirmMoneroWalletCredentials(
- appStore,
- walletName,
- wowneroWallet.seed,
- legacySeed,
- );
- }
-
if (walletType == WalletType.bitcoin ||
walletType == WalletType.litecoin ||
walletType == WalletType.bitcoinCash) {
diff --git a/lib/di.dart b/lib/di.dart
index 7d386e58..a4b3c0fd 100644
--- a/lib/di.dart
+++ b/lib/di.dart
@@ -209,8 +209,8 @@ import 'package:cake_wallet/view_model/anon_invoice_page_view_model.dart';
import 'package:cake_wallet/view_model/anonpay_details_view_model.dart';
import 'package:cake_wallet/view_model/auth_view_model.dart';
import 'package:cake_wallet/view_model/backup_view_model.dart';
-import 'package:cake_wallet/view_model/bridge_details_view_model.dart';
-import 'package:cake_wallet/view_model/bridge_history_view_model.dart';
+import 'package:cake_wallet/view_model/bridge/bridge_details_view_model.dart';
+import 'package:cake_wallet/view_model/bridge/bridge_history_view_model.dart';
import 'package:cake_wallet/view_model/buy/buy_amount_view_model.dart';
import 'package:cake_wallet/view_model/buy/buy_sell_view_model.dart';
import 'package:cake_wallet/view_model/buy/buy_view_model.dart';
diff --git a/lib/evm/cw_evm.dart b/lib/evm/cw_evm.dart
index 1462dbe3..7dce83c7 100644
--- a/lib/evm/cw_evm.dart
+++ b/lib/evm/cw_evm.dart
@@ -517,7 +517,7 @@ class CWEVM extends EVM {
ChainInfo? getChainInfoByChainId(int chainId) {
final config = _registry.getChainConfig(chainId);
if (config == null) return null;
-
+
return ChainInfo(
chainId: config.chainId,
name: config.name,
@@ -602,26 +602,30 @@ class CWEVM extends EVM {
}
@override
- Future<USDT0Quote> quoteUSDT0Transfer({
+ Future<BridgeQuote> quoteUSDT0Transfer({
required WalletBase wallet,
required int sourceChainId,
required int destinationChainId,
required BigInt amount,
required String recipientAddress,
- }) {
+ }) async {
final evmWallet = wallet as EVMChainWallet;
final client = evmWallet.getWeb3Client();
if (client == null) {
throw StateError('Wallet not connected');
}
- return USDT0Service.quoteCrossChainTransfer(
+ final quote = await USDT0Service.quoteCrossChainTransfer(
client: client,
sourceChainId: sourceChainId,
destinationChainId: destinationChainId,
amount: amount,
recipientAddress: recipientAddress,
);
+ return BridgeQuote(
+ nativeFee: quote.nativeFee,
+ lzTokenFee: quote.lzTokenFee,
+ );
}
@override
@@ -632,7 +636,7 @@ class CWEVM extends EVM {
required int destinationChainId,
required BigInt amount,
required String recipientAddress,
- required USDT0Quote quote,
+ required BridgeQuote quote,
required TransactionPriority priority,
bool useBlinkProtection = true,
}) {
@@ -645,13 +649,13 @@ class CWEVM extends EVM {
destinationChainId: destinationChainId,
amount: amount,
recipientAddress: recipientAddress,
- quote: quote,
+ quote: USDT0Quote(nativeFee: quote.nativeFee, lzTokenFee: quote.lzTokenFee),
token: tokenErc20,
priority: priority as EVMChainTransactionPriority,
useBlinkProtection: useBlinkProtection,
-
- );
+ );
}
+
Future<EvmWalletConnectFeeQuote?> getWCBufferedFeeQuote(
WalletBase wallet,
TransactionPriority priority,
diff --git a/lib/new-ui/pages/bridge/bridge_amount_page.dart b/lib/new-ui/pages/bridge/bridge_amount_page.dart
index 4e953591..757a9579 100644
--- a/lib/new-ui/pages/bridge/bridge_amount_page.dart
+++ b/lib/new-ui/pages/bridge/bridge_amount_page.dart
@@ -6,7 +6,7 @@ import 'package:cake_wallet/src/widgets/bottom_sheet/info_bottom_sheet_widget.da
import 'package:cake_wallet/utils/request_review_handler.dart';
import 'package:cake_wallet/new-ui/widgets/modern_button.dart';
import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
-import 'package:cake_wallet/view_model/bridge_history_view_model.dart';
+import 'package:cake_wallet/view_model/bridge/bridge_history_view_model.dart';
import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:flutter/material.dart';
@@ -72,12 +72,12 @@ class _BridgeAmountPageState extends State<BridgeAmountPage> {
doubleActionLeftButtonText: S.of(bottomSheetContext).close,
doubleActionRightButtonText: 'View history',
onLeftActionButtonPressed: () {
- bridgeViewModel.clearBridgeSuccess();
+ bridgeViewModel.clearOnBridgeSuccess();
Navigator.of(context, rootNavigator: true).pop();
RequestReviewHandler.requestReview();
},
onRightActionButtonPressed: () {
- bridgeViewModel.clearBridgeSuccess();
+ bridgeViewModel.clearOnBridgeSuccess();
Navigator.of(context).popUntil((route) => route.isFirst);
showMaterialModalBottomSheet(
context: context,
@@ -96,6 +96,7 @@ class _BridgeAmountPageState extends State<BridgeAmountPage> {
bridgeViewModel.onBridgeSuccess = null;
bridgeViewModel.setAmount('');
_amountController.dispose();
+ bridgeViewModel.dispose();
super.dispose();
}
@@ -165,7 +166,9 @@ class _BridgeAmountPageState extends State<BridgeAmountPage> {
hoverColor: Colors.transparent,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
- hintText: _amountFocused || _amountController.text.isNotEmpty ? null : "0.00",
+ hintText: _amountFocused || _amountController.text.isNotEmpty
+ ? null
+ : "0.00",
hintStyle: theme.textTheme.displayMedium?.copyWith(
fontWeight: FontWeight.w400,
color: theme.colorScheme.onSurfaceVariant,
@@ -273,16 +276,16 @@ class _BridgeAmountPageState extends State<BridgeAmountPage> {
icon: Icon(Icons.arrow_forward, size: 20),
onPressed: () {
Navigator.pushNamed(
- context, Routes.bridgeDestinationNetworkPage,
- arguments: bridgeViewModel);
+ context,
+ Routes.bridgeDestinationNetworkPage,
+ arguments: bridgeViewModel,
+ );
},
),
),
],
),
- SizedBox(
- height: 24,
- )
+ SizedBox(height: 24)
],
);
},
diff --git a/lib/new-ui/pages/bridge/bridge_detail_page.dart b/lib/new-ui/pages/bridge/bridge_detail_page.dart
index a5076ad1..64cc457f 100644
--- a/lib/new-ui/pages/bridge/bridge_detail_page.dart
+++ b/lib/new-ui/pages/bridge/bridge_detail_page.dart
@@ -8,7 +8,7 @@ import 'package:cake_wallet/src/widgets/new_list_row/new_list_section.dart';
import 'package:cake_wallet/themes/core/custom_theme_colors.dart';
import 'package:cake_wallet/utils/address_formatter.dart';
import 'package:cake_wallet/utils/show_bar.dart';
-import 'package:cake_wallet/view_model/bridge_details_view_model.dart';
+import 'package:cake_wallet/view_model/bridge/bridge_details_view_model.dart';
import 'package:cw_core/generate_name.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
diff --git a/lib/new-ui/pages/bridge/bridge_history_page.dart b/lib/new-ui/pages/bridge/bridge_history_page.dart
index a50cc564..5dff00f3 100644
--- a/lib/new-ui/pages/bridge/bridge_history_page.dart
+++ b/lib/new-ui/pages/bridge/bridge_history_page.dart
@@ -1,7 +1,7 @@
import 'package:cake_wallet/di.dart';
import 'package:cake_wallet/new-ui/pages/bridge/bridge_detail_page.dart';
import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart';
-import 'package:cake_wallet/view_model/bridge_history_view_model.dart';
+import 'package:cake_wallet/view_model/bridge/bridge_history_view_model.dart';
import 'package:cake_wallet/new-ui/widgets/bridge/transfer_history_row.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
diff --git a/lib/router.dart b/lib/router.dart
index 7af4f85f..a5af558e 100644
--- a/lib/router.dart
+++ b/lib/router.dart
@@ -140,7 +140,7 @@ import 'package:cake_wallet/store/settings_store.dart';
import 'package:cake_wallet/utils/payment_request.dart';
import 'package:cake_wallet/view_model/advanced_privacy_settings_view_model.dart';
import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart';
-import 'package:cake_wallet/view_model/bridge_history_view_model.dart';
+import 'package:cake_wallet/view_model/bridge/bridge_history_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/sign_view_model.dart';
diff --git a/lib/view_model/bridge/bridge_details_view_model.dart b/lib/view_model/bridge/bridge_details_view_model.dart
new file mode 100644
index 00000000..3a2b7452
--- /dev/null
+++ b/lib/view_model/bridge/bridge_details_view_model.dart
@@ -0,0 +1,181 @@
+import 'dart:async';
+
+import 'package:cake_wallet/entities/bridge_transfer.dart';
+import 'package:cake_wallet/evm/evm.dart';
+import 'package:cake_wallet/generated/i18n.dart';
+import 'package:cake_wallet/src/screens/transaction_details/address_list_item.dart';
+import 'package:cake_wallet/src/screens/transaction_details/standart_list_item.dart';
+import 'package:cake_wallet/src/screens/trade_details/trade_details_status_item.dart';
+import 'package:cake_wallet/src/screens/trade_details/track_trade_list_item.dart';
+import 'package:cake_wallet/src/screens/transaction_details/transaction_details_list_item.dart';
+import 'package:cake_wallet/store/bridge_transfers_store.dart';
+import 'package:mobx/mobx.dart';
+import 'package:url_launcher/url_launcher.dart';
+
+part 'bridge_details_view_model.g.dart';
+
+class BridgeDetailsViewModel = BridgeDetailsViewModelBase with _$BridgeDetailsViewModel;
+
+abstract class BridgeDetailsViewModelBase with Store {
+ BridgeDetailsViewModelBase({
+ required BridgeTransfer transferForDetails,
+ required this.bridgeTransfersStore,
+ required this.walletId,
+ }) : items = ObservableList<TransactionDetailsListItem>(),
+ transfer = _findTransferInStore(
+ bridgeTransfersStore.bridgeTransfers, transferForDetails.id, walletId) ??
+ transferForDetails {
+ _updateItems();
+ _setupReaction();
+ }
+
+ static BridgeTransfer? _findTransferInStore(
+ List<BridgeTransfer> transfers,
+ String transferId,
+ String walletId,
+ ) {
+ try {
+ return transfers.firstWhere(
+ (t) => t.id == transferId && t.walletId == walletId,
+ );
+ } catch (_) {
+ return null;
+ }
+ }
+
+ final BridgeTransfersStore bridgeTransfersStore;
+ final String walletId;
+ ReactionDisposer? _reactionDisposer;
+
+ @observable
+ BridgeTransfer transfer;
+
+ @observable
+ ObservableList<TransactionDetailsListItem> items;
+
+ Timer? timer;
+
+ void _setupReaction() {
+ _reactionDisposer = reaction(
+ (_) => bridgeTransfersStore.bridgeTransfers,
+ (_) => updateTransfer(),
+ );
+ updateTransfer();
+ }
+
+ @action
+ void updateTransfer() {
+ final updatedTransfer = _findTransferInStore(
+ bridgeTransfersStore.bridgeTransfers,
+ transfer.id,
+ walletId,
+ );
+ if (updatedTransfer != null) {
+ transfer = updatedTransfer;
+ _updateItems();
+ }
+ }
+
+ void dispose() {
+ _reactionDisposer?.call();
+ timer?.cancel();
+ }
+
+ void _updateItems() {
+ items.clear();
+
+ final statusText = transfer.statusMessage?.isNotEmpty == true
+ ? '${_statusLabel(transfer.status)} · ${transfer.statusMessage}'
+ : _statusLabel(transfer.status);
+
+ items.add(
+ DetailsListStatusItem(
+ title: "Status",
+ value: statusText,
+ status: transfer.status,
+ ),
+ );
+
+ final sourceName =
+ evm?.getChainInfoByChainId(transfer.sourceChainId)?.name ?? '${transfer.sourceChainId}';
+ final destName =
+ evm?.getChainInfoByChainId(transfer.destinationChainId)?.name ?? '${transfer.destinationChainId}';
+
+ items.add(
+ StandartListItem(
+ title: "Source chain",
+ value: sourceName,
+ ),
+ );
+
+ items.add(
+ StandartListItem(
+ title: "Destination chain",
+ value: destName,
+ ),
+ );
+
+ items.add(
+ StandartListItem(
+ title: "Amount",
+ value: '${transfer.amount} ${transfer.tokenSymbol}',
+ ),
+ );
+
+ items.add(
+ AddressListItem(
+ title: "Recipient",
+ value: transfer.recipientAddress,
+ ),
+ );
+
+ final sourceExplorerUrl = evm?.getExplorerUrlForChainId(transfer.sourceChainId);
+ final sourceTxUrl = sourceExplorerUrl != null && sourceExplorerUrl.isNotEmpty
+ ? '$sourceExplorerUrl/tx/${transfer.sourceTxHash}'
+ : null;
+
+ if (sourceTxUrl != null) {
+ final explorerDescription = S.current.view_transaction_on + Uri.parse(sourceTxUrl).host;
+ items.add(
+ TrackTradeListItem(
+ title: explorerDescription,
+ value: sourceTxUrl,
+ onTap: () => _launchUrl(sourceTxUrl),
+ ),
+ );
+ }
+
+ if (transfer.errorMessage != null && transfer.errorMessage!.isNotEmpty) {
+ items.add(
+ StandartListItem(
+ title: "Error",
+ value: transfer.errorMessage!,
+ ),
+ );
+ }
+ }
+
+ String _statusLabel(String status) {
+ switch (status) {
+ case 'submitted':
+ return "Submitted";
+ case 'confirming':
+ return "Confirming on source";
+ case 'initiated':
+ return "Bridge initiated";
+ case 'completed':
+ return "Completed";
+ case 'failed':
+ return "Failed";
+ default:
+ return status;
+ }
+ }
+
+ void _launchUrl(String url) {
+ final uri = Uri.parse(url);
+ try {
+ launchUrl(uri, mode: LaunchMode.externalApplication);
+ } catch (_) {}
+ }
+}
diff --git a/lib/view_model/bridge/bridge_history_view_model.dart b/lib/view_model/bridge/bridge_history_view_model.dart
new file mode 100644
index 00000000..2d279804
--- /dev/null
+++ b/lib/view_model/bridge/bridge_history_view_model.dart
@@ -0,0 +1,37 @@
+import 'package:cake_wallet/entities/bridge_transfer.dart';
+import 'package:cake_wallet/store/app_store.dart';
+import 'package:cake_wallet/store/bridge_transfers_store.dart';
+import 'package:mobx/mobx.dart';
+
+part 'bridge_history_view_model.g.dart';
+
+class BridgeHistoryViewModel = BridgeHistoryViewModelBase with _$BridgeHistoryViewModel;
+
+abstract class BridgeHistoryViewModelBase with Store {
+ BridgeHistoryViewModelBase({
+ required this.bridgeTransfersStore,
+ required this.appStore,
+ });
+
+ final BridgeTransfersStore bridgeTransfersStore;
+ final AppStore appStore;
+
+ @computed
+ List<BridgeTransfer> get walletTransfers {
+ final wallet = appStore.wallet;
+ if (wallet == null) return [];
+
+ return bridgeTransfersStore.bridgeTransfers.where((t) => t.walletId == wallet.name).toList();
+ }
+
+ @computed
+ List<BridgeTransfer> get activeTransfers =>
+ walletTransfers.where((b) => b.isActive).toList(growable: false);
+
+ @computed
+ List<BridgeTransfer> get pastTransfers =>
+ walletTransfers.where((b) => !b.isActive).toList(growable: false);
+
+ @computed
+ bool get isEmpty => walletTransfers.isEmpty;
+}
diff --git a/lib/view_model/bridge/bridge_view_model.dart b/lib/view_model/bridge/bridge_view_model.dart
index 2469fa1b..b178d8e1 100644
--- a/lib/view_model/bridge/bridge_view_model.dart
+++ b/lib/view_model/bridge/bridge_view_model.dart
@@ -44,11 +44,16 @@ abstract class BridgeViewModelBase extends WalletChangeListenerViewModel with St
AmountParsingProxy get amountParsingProxy => _appStore.amountParsingProxy;
void Function()? onBridgeSuccess;
- final Map<String, Completer<void>> _pollingCancellers = {};
- final BridgeTransfersStore bridgeTransfersStore;
final WalletManager walletManager;
- final FiatConversionStore fiatConversionStore;
final SettingsStore settingsStore;
+ final FiatConversionStore fiatConversionStore;
+ final BridgeTransfersStore bridgeTransfersStore;
+ final Map<String, Completer<void>> _pollingCancellers = {};
+
+ static const _pollInterval = Duration(seconds: 2);
+ static const _pollTimeout = Duration(minutes: 5);
+ static const _destinationPollInterval = Duration(seconds: 5);
+ static const _destinationPollTimeout = Duration(minutes: 10);
@observable
ObservableList<BridgeReceivingWalletOption> bridgeReceivingWalletOptions =
@@ -73,7 +78,7 @@ abstract class BridgeViewModelBase extends WalletChangeListenerViewModel with St
String? destinationWalletName;
@observable
- USDT0Quote? quote;
+ BridgeQuote? quote;
@observable
bool isQuoteLoading = false;
@@ -99,6 +104,9 @@ abstract class BridgeViewModelBase extends WalletChangeListenerViewModel with St
@computed
String get sourceAddress => wallet.walletAddresses.address;
+ @computed
+ String get fiatCurrencyTitle => settingsStore.fiatCurrency.title;
+
@computed
List<ChainInfo> get availableDestinationChains {
if (!isEVMCompatibleChain(wallet.type)) return [];
@@ -122,28 +130,23 @@ abstract class BridgeViewModelBase extends WalletChangeListenerViewModel with St
}
@computed
- String get tokenBalanceFormatted {
- final token = selectedToken;
- if (token is! Erc20Token) return '0.00';
-
- return amountParsingProxy.getDisplayCryptoStringFromBigInt(
- selectedTokenBalance,
- token,
- );
- }
+ String get tokenBalanceFormatted {
+ if (selectedToken == null) return "0.00";
+
+ return amountParsingProxy.getDisplayCryptoStringFromBigInt(
+ selectedTokenBalance, selectedToken!,
+ );
+}
@computed
String get amountDisplayFormatted {
- if (amount.isEmpty) return '';
- final token = selectedToken;
- if (token is! Erc20Token) return amount.replaceAll(',', '.');
-
- return amountParsingProxy.getDisplayCryptoAmount(
- amount.replaceAll(',', '.'),
- token,
- );
- }
-
+ if (selectedToken == null) return "0.00";
+
+ return amountParsingProxy.getDisplayCryptoAmount(
+ amount.replaceAll(',', '.'),
+ selectedToken!,
+ );
+ }
DecimalAmountValidator get decimalAmountValidator => DecimalAmountValidator(
currency: selectedToken!,
@@ -152,35 +155,28 @@ abstract class BridgeViewModelBase extends WalletChangeListenerViewModel with St
@computed
String get fiatAmountFormatted {
- if (amount.isEmpty) return '';
- final token = selectedToken;
- if (token is! Erc20Token) return '';
+ try {
+ if (amount.isEmpty) return '';
- final price = fiatConversionStore.prices[token];
- if (price == null) return '';
+ final price = fiatConversionStore.prices[selectedToken!];
+ if (price == null) return '';
- final forFiat = amountParsingProxy.getDisplayCryptoAmount(
- amount.replaceAll(',', '.'),
- token,
- );
+ final forFiat =
+ amountParsingProxy.getDisplayCryptoAmount(amount.replaceAll(',', '.'), selectedToken!);
- return calculateFiatAmount(
- price: price,
- cryptoAmount: forFiat,
- );
+ return calculateFiatAmount(price: price, cryptoAmount: forFiat);
+ } catch (_) {
+ return '';
+ }
}
- @computed
- String get fiatCurrencyTitle => settingsStore.fiatCurrency.title;
-
@computed
String get quoteNativeFee {
if (quote == null) return '—';
- final cur = wallet.currency;
return amountParsingProxy.getDisplayCryptoStringFromBigInt(
quote!.nativeFee,
- cur,
+ wallet.currency,
);
}
@@ -193,70 +189,93 @@ abstract class BridgeViewModelBase extends WalletChangeListenerViewModel with St
@computed
String get quoteNativeFiatFeeFormattedForDisplay {
- if (quote == null || quoteNativeFee.isEmpty) return '';
+ try {
+ if (quote == null || quoteNativeFee.isEmpty) return '';
- final price = fiatConversionStore.prices[wallet.currency];
- if (price == null) return '';
+ final price = fiatConversionStore.prices[wallet.currency];
+ if (price == null) return '';
- final fiatFeeFormatted = calculateFiatAmount(
- price: price,
- cryptoAmount: amountParsingProxy.getDisplayCryptoAmount(
- quoteNativeFee.replaceAll(',', '.'),
- wallet.currency,
- ),
- );
+ final fiatFeeFormatted = calculateFiatAmount(
+ price: price,
+ cryptoAmount: amountParsingProxy.getDisplayCryptoAmount(
+ quoteNativeFee.replaceAll(',', '.'),
+ wallet.currency,
+ ),
+ );
- return '(${settingsStore.fiatCurrency.title} $fiatFeeFormatted)';
+ return '(${fiatCurrencyTitle} $fiatFeeFormatted)';
+ } catch (_) {
+ return '';
+ }
}
@computed
bool get canProceedToDestinationNetwork {
if (amount.isEmpty) return false;
- if (selectedToken == null || selectedToken is! Erc20Token) return false;
+ if (selectedToken.isNotErc20) return false;
if (amountError != null) return false;
- final token = selectedToken as Erc20Token;
final validAmount = amountParsingProxy.tryParseCryptoString(
amount.replaceAll(',', '.'),
- token,
+ selectedToken!,
);
return validAmount != null && validAmount > BigInt.zero;
}
+ @computed
+ BigInt get selectedTokenBalance {
+ if (selectedToken == null) return BigInt.zero;
+
+ try {
+ final bal = wallet.balance[selectedToken!];
+
+ return bal?.fullAvailableBalance ?? BigInt.zero;
+ } catch (e) {
+ return BigInt.zero;
+ }
+ }
+
+ @computed
+ String? get amountError {
+ if (selectedToken == null) return null;
+
+ final amountBigInt = amountParsingProxy.tryParseCryptoString(
+ amount.replaceAll(',', '.'),
+ selectedToken!,
+ );
+
+ if (amountBigInt == null || amountBigInt == BigInt.zero) return null;
+ if (amountBigInt > selectedTokenBalance) {
+ return 'Insufficient balance for ${selectedToken!.title} token.';
+ }
+
+ return null;
+ }
+
@action
void applyInitialBridgeToken(CryptoCurrency asset) {
- for (final t in availableUSDT0Tokens) {
- if (t == asset) {
- setSelectedToken(t);
- break;
- }
+ final token = availableUSDT0Tokens.firstWhereOrNull((t) => t == asset);
+ if (token != null) {
+ setSelectedToken(token);
}
}
@action
- void setDestinationChain(int chainId) {
- destinationChainId = chainId;
- _clearQuoteState();
- }
+ void setDestinationChain(int chainId) => destinationChainId = chainId;
@action
- void setSelectedToken(CryptoCurrency token) {
- selectedToken = token;
- _clearQuoteState();
- }
+ void setSelectedToken(CryptoCurrency token) => selectedToken = token;
@action
- void setAmount(String value) {
- amount = value;
- _clearQuoteState();
- }
+ void setAmount(String value) => amount = value;
@action
void setMaxAmount() {
- final token = selectedToken;
- if (token is! Erc20Token) return;
+ final token = selectedToken.asErc20;
+ if (token == null) return;
+
if (selectedTokenBalance == BigInt.zero) {
setAmount('');
return;
@@ -273,27 +292,24 @@ abstract class BridgeViewModelBase extends WalletChangeListenerViewModel with St
void setRecipientAddress(String value, {String? destWalletName}) {
recipientAddress = value;
destinationWalletName = destWalletName;
- _clearQuoteState();
- }
-
- void _clearQuoteState() {
- quote = null;
- quoteError = null;
- executeError = null;
}
Future<void> _ensureFiatPriceFor(CryptoCurrency crypto) async {
if (fiatConversionStore.prices[crypto] != null) return;
- final p = await FiatConversionService.fetchPrice(
- crypto: crypto,
- fiat: settingsStore.fiatCurrency,
- torOnly: settingsStore.fiatApiMode == FiatApiMode.torOnly,
- );
+ try {
+ final p = await FiatConversionService.fetchPrice(
+ crypto: crypto,
+ fiat: settingsStore.fiatCurrency,
+ torOnly: settingsStore.fiatApiMode == FiatApiMode.torOnly,
+ );
- runInAction(() {
- fiatConversionStore.prices[crypto] = p;
- });
+ runInAction(() {
+ fiatConversionStore.prices[crypto] = p;
+ });
+ } catch (e) {
+ printV('Error ensuring fiat price for $crypto: $e');
+ }
}
@action
@@ -306,35 +322,12 @@ abstract class BridgeViewModelBase extends WalletChangeListenerViewModel with St
await _ensureFiatPriceFor(wallet.currency);
}
- @computed
- BigInt get selectedTokenBalance {
- final bal = wallet.balance[selectedToken];
-
- return bal?.available ?? BigInt.zero;
- }
-
- @computed
- String? get amountError {
- if (selectedToken == null || amount.isEmpty) return null;
- if (selectedToken is! Erc20Token) return null;
-
- final token = selectedToken as Erc20Token;
- final amountBigInt = amountParsingProxy.tryParseCryptoString(
- amount.replaceAll(',', '.'),
- token,
- );
- if (amountBigInt == null || amountBigInt == BigInt.zero) return null;
- if (amountBigInt > selectedTokenBalance) {
- return 'Insufficient balance for ${token.title} token.';
- }
-
- return null;
- }
-
@action
Future<void> loadReceivingWalletOptions() async {
if (!isEVMCompatibleChain(wallet.type)) return;
+ if (destinationChainId == null) return;
+
final destWalletType = evm!.getWalletTypeByChainId(destinationChainId!);
isBridgeReceivingWalletListLoading = true;
@@ -366,6 +359,8 @@ abstract class BridgeViewModelBase extends WalletChangeListenerViewModel with St
bridgeReceivingWalletOptions
..clear()
..addAll(options);
+ } catch (e) {
+ printV('Error loading receiving wallet options: $e');
} finally {
isBridgeReceivingWalletListLoading = false;
}
@@ -414,8 +409,8 @@ abstract class BridgeViewModelBase extends WalletChangeListenerViewModel with St
return;
}
- final token = selectedToken!;
- if (token is! Erc20Token) return;
+ final token = selectedToken.asErc20;
+ if (token == null) return;
final check = _parseAndValidateAmount(token);
if (check.error != null) {
@@ -428,9 +423,7 @@ abstract class BridgeViewModelBase extends WalletChangeListenerViewModel with St
final amountBigInt = check.parsedAmount!;
isQuoteLoading = true;
- quoteError = null;
- quote = null;
- executeError = null;
+ _clearQuoteState();
try {
quote = await evm!.quoteUSDT0Transfer(
@@ -460,8 +453,8 @@ abstract class BridgeViewModelBase extends WalletChangeListenerViewModel with St
return;
}
- final token = selectedToken!;
- if (token is! Erc20Token) return;
+ final token = selectedToken.asErc20;
+ if (token == null) return;
final check = _parseAndValidateAmount(token);
if (check.error != null) {
@@ -513,7 +506,7 @@ abstract class BridgeViewModelBase extends WalletChangeListenerViewModel with St
lastCreatedBridgeTransfer = record;
});
onBridgeSuccess?.call();
- _pollForSourceConfirmation(record, wallet);
+ _pollForConfirmation(record, wallet, isSource: true);
} catch (e) {
executeError = e.toString();
} finally {
@@ -521,26 +514,12 @@ abstract class BridgeViewModelBase extends WalletChangeListenerViewModel with St
}
}
- static const _pollInterval = Duration(seconds: 2);
- static const _pollTimeout = Duration(minutes: 3);
- static const _destinationPollInterval = Duration(seconds: 5);
- static const _destinationPollTimeout = Duration(minutes: 10);
-
@override
void onWalletChange(WalletBase wallet) {
_cancelAllPolling();
_resumePollingForActiveTransfers(wallet);
}
- void _cancelAllPolling() {
- for (final canceller in _pollingCancellers.values) {
- if (!canceller.isCompleted) {
- canceller.complete();
- }
- }
- _pollingCancellers.clear();
- }
-
void _resumePollingForActiveTransfers(WalletBase wallet) {
if (!isEVMCompatibleChain(wallet.type)) return;
@@ -550,9 +529,9 @@ abstract class BridgeViewModelBase extends WalletChangeListenerViewModel with St
for (final transfer in activeTransfers) {
if (transfer.status == 'submitted' || transfer.status == 'confirming') {
- _pollForSourceConfirmation(transfer, wallet);
+ _pollForConfirmation(transfer, wallet, isSource: true);
} else if (transfer.status == 'initiated') {
- _pollForDestinationCompletion(transfer, wallet);
+ _pollForConfirmation(transfer, wallet, isSource: false);
}
}
}
@@ -579,130 +558,134 @@ abstract class BridgeViewModelBase extends WalletChangeListenerViewModel with St
if (statusMessage != null) record.statusMessage = statusMessage;
if (confirmedAt != null) record.confirmedAt = confirmedAt;
});
- await bridgeTransfersStore.updateTransfer(record);
- }
-
- Future<void> _pollForSourceConfirmation(
- BridgeTransfer record,
- WalletBase wallet,
- ) async {
- final canceller = Completer<void>();
- _pollingCancellers[record.id] = canceller;
- final walletId = wallet.name;
- final deadline = DateTime.now().add(_pollTimeout);
try {
- while (DateTime.now().isBefore(deadline)) {
- await Future.any([
- Future.delayed(_pollInterval),
- canceller.future,
- ]);
-
- if (canceller.isCompleted || !_isValidWalletContext(walletId)) return;
-
- bool? receipt;
- try {
- receipt = await evm!.getTransactionReceipt(wallet, record.sourceTxHash);
- } catch (e) {
- printV('USDT0 bridge: Error fetching receipt: $e');
- continue;
- }
-
- if (receipt == null) continue;
-
- if (receipt == true) {
- await _updateTransferStatus(
- record,
- 'confirming',
- confirmedAt: DateTime.now(),
- );
-
- await Future.delayed(const Duration(seconds: 1));
- if (canceller.isCompleted || !_isValidWalletContext(walletId)) return;
-
- await _updateTransferStatus(record, 'initiated');
- _pollForDestinationCompletion(record, wallet);
-
- return;
- } else if (receipt == false) {
- await _updateTransferStatus(
- record,
- 'failed',
- errorMessage: 'Transaction reverted',
- );
- return;
- }
- }
-
- if (!_isValidWalletContext(walletId)) return;
-
- await _updateTransferStatus(record, 'initiated');
- _pollForDestinationCompletion(record, wallet);
- } finally {
- _pollingCancellers.remove(record.id);
+ await bridgeTransfersStore.updateTransfer(record);
+ } catch (e) {
+ printV('USDT0 bridge: Error updating transfer status: $e');
}
}
- Future<void> _pollForDestinationCompletion(
+ Future<void> _pollForConfirmation(
BridgeTransfer record,
- WalletBase wallet,
- ) async {
+ WalletBase wallet, {
+ required bool isSource,
+ }) async {
final canceller = Completer<void>();
- _pollingCancellers['${record.id}_dest'] = canceller;
+ final recordId = isSource ? record.id : '${record.id}_dest';
+ final pollInterval = isSource ? _pollInterval : _destinationPollInterval;
+ final pollTimeout = isSource ? _pollTimeout : _destinationPollTimeout;
final walletId = wallet.name;
- final deadline = DateTime.now().add(_destinationPollTimeout);
+ final deadline = DateTime.now().add(pollTimeout);
+
+ _pollingCancellers[recordId] = canceller;
try {
while (DateTime.now().isBefore(deadline)) {
await Future.any([
- Future.delayed(_destinationPollInterval),
+ Future.delayed(pollInterval),
canceller.future,
]);
if (canceller.isCompleted || !_isValidWalletContext(walletId)) return;
- LayerZeroMessageStatus? status;
- try {
- status = await LayerZeroScanService.getMessageStatus(record.sourceTxHash);
- } catch (e) {
- printV('USDT0 bridge: Error fetching LayerZero status: $e');
- continue;
- }
+ if (isSource) {
+ final receipt = await _fetchTransactionReceipt(record, wallet);
- if (status == null) continue;
+ if (receipt != null) {
+ final isTransactionSuccessful = receipt == true;
+ await _updateTransferStatus(
+ record,
+ isTransactionSuccessful ? 'initiated' : 'failed',
+ confirmedAt: isTransactionSuccessful ? DateTime.now() : null,
+ errorMessage: !isTransactionSuccessful ? 'Transaction reverted' : null,
+ );
- if (status.isDelivered) {
- await _updateTransferStatus(
- record,
- 'completed',
- statusMessage: status.status?.message,
- );
- return;
- }
+ if (isTransactionSuccessful) {
+ await Future.delayed(const Duration(seconds: 1));
+ if (_isValidWalletContext(walletId)) {
+ _pollForConfirmation(record, wallet, isSource: false);
+ }
+ }
- if (status.isFailed) {
- await _updateTransferStatus(
- record,
- 'failed',
- errorMessage: status.status?.message ?? 'Bridge message failed',
- statusMessage: status.status?.message,
- );
- return;
+ return;
+ }
+
+ continue;
+ } else {
+ final status = await _fetchLayerZeroMessageStatus(record, wallet);
+
+ if (status != null) {
+ final statusMessage = _getStatusMessage(status, record);
+ await _updateTransferStatus(
+ record,
+ statusMessage,
+ errorMessage:
+ status.isFailed ? status.status?.message ?? 'Bridge message failed' : null,
+ statusMessage: status.status?.message,
+ );
+
+ if (status.isDelivered || status.isFailed) return;
+ }
+
+ continue;
}
+ }
+ if (isSource && _isValidWalletContext(walletId)) {
await _updateTransferStatus(
record,
- record.status,
- statusMessage: status.status?.message,
+ 'failed',
+ errorMessage: 'Source confirmation timed out',
);
}
+ } catch (e) {
+ printV('USDT0 bridge: Error polling for confirmation: $e');
} finally {
- _pollingCancellers.remove('${record.id}_dest');
+ _pollingCancellers.remove(recordId);
}
}
+ String _getStatusMessage(LayerZeroMessageStatus status, BridgeTransfer record) {
+ if (status.isDelivered) {
+ return 'completed';
+ }
+
+ if (status.isFailed) {
+ return 'failed';
+ }
+
+ return record.status;
+ }
+
+ Future<bool?> _fetchTransactionReceipt(BridgeTransfer record, WalletBase wallet) async {
+ try {
+ return await evm!.getTransactionReceipt(wallet, record.sourceTxHash);
+ } catch (e) {
+ printV('USDT0 bridge: Error fetching receipt: $e');
+ return null;
+ }
+ }
+
+ Future<LayerZeroMessageStatus?> _fetchLayerZeroMessageStatus(
+ BridgeTransfer record, WalletBase wallet) async {
+ try {
+ return await LayerZeroScanService.getMessageStatus(record.sourceTxHash);
+ } catch (e) {
+ printV('USDT0 bridge: Error fetching LayerZero status: $e');
+ return null;
+ }
+ }
+
+ @action
+ void _clearQuoteState() {
+ quote = null;
+ quoteError = null;
+ executeError = null;
+ }
+
@action
- void clearBridgeSuccess() {
+ void clearOnBridgeSuccess() {
amount = '';
recipientAddress = '';
destinationWalletName = null;
@@ -712,7 +695,25 @@ abstract class BridgeViewModelBase extends WalletChangeListenerViewModel with St
_clearQuoteState();
}
+ void _cancelAllPolling() {
+ for (final canceller in _pollingCancellers.values) {
+ if (!canceller.isCompleted) {
+ canceller.complete();
+ }
+ }
+ _pollingCancellers.clear();
+ }
+
void dispose() {
_cancelAllPolling();
}
}
+
+extension CryptoCurrencyX on CryptoCurrency? {
+ Erc20Token? get asErc20 {
+ final token = this;
+ return token is Erc20Token ? token : null;
+ }
+
+ bool get isNotErc20 => this == null || this is! Erc20Token;
+}
diff --git a/lib/view_model/bridge_details_view_model.dart b/lib/view_model/bridge_details_view_model.dart
deleted file mode 100644
index 3a2b7452..00000000
--- a/lib/view_model/bridge_details_view_model.dart
+++ /dev/null
@@ -1,181 +0,0 @@
-import 'dart:async';
-
-import 'package:cake_wallet/entities/bridge_transfer.dart';
-import 'package:cake_wallet/evm/evm.dart';
-import 'package:cake_wallet/generated/i18n.dart';
-import 'package:cake_wallet/src/screens/transaction_details/address_list_item.dart';
-import 'package:cake_wallet/src/screens/transaction_details/standart_list_item.dart';
-import 'package:cake_wallet/src/screens/trade_details/trade_details_status_item.dart';
-import 'package:cake_wallet/src/screens/trade_details/track_trade_list_item.dart';
-import 'package:cake_wallet/src/screens/transaction_details/transaction_details_list_item.dart';
-import 'package:cake_wallet/store/bridge_transfers_store.dart';
-import 'package:mobx/mobx.dart';
-import 'package:url_launcher/url_launcher.dart';
-
-part 'bridge_details_view_model.g.dart';
-
-class BridgeDetailsViewModel = BridgeDetailsViewModelBase with _$BridgeDetailsViewModel;
-
-abstract class BridgeDetailsViewModelBase with Store {
- BridgeDetailsViewModelBase({
- required BridgeTransfer transferForDetails,
- required this.bridgeTransfersStore,
- required this.walletId,
- }) : items = ObservableList<TransactionDetailsListItem>(),
- transfer = _findTransferInStore(
- bridgeTransfersStore.bridgeTransfers, transferForDetails.id, walletId) ??
- transferForDetails {
- _updateItems();
- _setupReaction();
- }
-
- static BridgeTransfer? _findTransferInStore(
- List<BridgeTransfer> transfers,
- String transferId,
- String walletId,
- ) {
- try {
- return transfers.firstWhere(
- (t) => t.id == transferId && t.walletId == walletId,
- );
- } catch (_) {
- return null;
- }
- }
-
- final BridgeTransfersStore bridgeTransfersStore;
- final String walletId;
- ReactionDisposer? _reactionDisposer;
-
- @observable
- BridgeTransfer transfer;
-
- @observable
- ObservableList<TransactionDetailsListItem> items;
-
- Timer? timer;
-
- void _setupReaction() {
- _reactionDisposer = reaction(
- (_) => bridgeTransfersStore.bridgeTransfers,
- (_) => updateTransfer(),
- );
- updateTransfer();
- }
-
- @action
- void updateTransfer() {
- final updatedTransfer = _findTransferInStore(
- bridgeTransfersStore.bridgeTransfers,
- transfer.id,
- walletId,
- );
- if (updatedTransfer != null) {
- transfer = updatedTransfer;
- _updateItems();
- }
- }
-
- void dispose() {
- _reactionDisposer?.call();
- timer?.cancel();
- }
-
- void _updateItems() {
- items.clear();
-
- final statusText = transfer.statusMessage?.isNotEmpty == true
- ? '${_statusLabel(transfer.status)} · ${transfer.statusMessage}'
- : _statusLabel(transfer.status);
-
- items.add(
- DetailsListStatusItem(
- title: "Status",
- value: statusText,
- status: transfer.status,
- ),
- );
-
- final sourceName =
- evm?.getChainInfoByChainId(transfer.sourceChainId)?.name ?? '${transfer.sourceChainId}';
- final destName =
- evm?.getChainInfoByChainId(transfer.destinationChainId)?.name ?? '${transfer.destinationChainId}';
-
- items.add(
- StandartListItem(
- title: "Source chain",
- value: sourceName,
- ),
- );
-
- items.add(
- StandartListItem(
- title: "Destination chain",
- value: destName,
- ),
- );
-
- items.add(
- StandartListItem(
- title: "Amount",
- value: '${transfer.amount} ${transfer.tokenSymbol}',
- ),
- );
-
- items.add(
- AddressListItem(
- title: "Recipient",
- value: transfer.recipientAddress,
- ),
- );
-
- final sourceExplorerUrl = evm?.getExplorerUrlForChainId(transfer.sourceChainId);
- final sourceTxUrl = sourceExplorerUrl != null && sourceExplorerUrl.isNotEmpty
- ? '$sourceExplorerUrl/tx/${transfer.sourceTxHash}'
- : null;
-
- if (sourceTxUrl != null) {
- final explorerDescription = S.current.view_transaction_on + Uri.parse(sourceTxUrl).host;
- items.add(
- TrackTradeListItem(
- title: explorerDescription,
- value: sourceTxUrl,
- onTap: () => _launchUrl(sourceTxUrl),
- ),
- );
- }
-
- if (transfer.errorMessage != null && transfer.errorMessage!.isNotEmpty) {
- items.add(
- StandartListItem(
- title: "Error",
- value: transfer.errorMessage!,
- ),
- );
- }
- }
-
- String _statusLabel(String status) {
- switch (status) {
- case 'submitted':
- return "Submitted";
- case 'confirming':
- return "Confirming on source";
- case 'initiated':
- return "Bridge initiated";
- case 'completed':
- return "Completed";
- case 'failed':
- return "Failed";
- default:
- return status;
- }
- }
-
- void _launchUrl(String url) {
- final uri = Uri.parse(url);
- try {
- launchUrl(uri, mode: LaunchMode.externalApplication);
- } catch (_) {}
- }
-}
diff --git a/lib/view_model/bridge_history_view_model.dart b/lib/view_model/bridge_history_view_model.dart
deleted file mode 100644
index 2d279804..00000000
--- a/lib/view_model/bridge_history_view_model.dart
+++ /dev/null
@@ -1,37 +0,0 @@
-import 'package:cake_wallet/entities/bridge_transfer.dart';
-import 'package:cake_wallet/store/app_store.dart';
-import 'package:cake_wallet/store/bridge_transfers_store.dart';
-import 'package:mobx/mobx.dart';
-
-part 'bridge_history_view_model.g.dart';
-
-class BridgeHistoryViewModel = BridgeHistoryViewModelBase with _$BridgeHistoryViewModel;
-
-abstract class BridgeHistoryViewModelBase with Store {
- BridgeHistoryViewModelBase({
- required this.bridgeTransfersStore,
- required this.appStore,
- });
-
- final BridgeTransfersStore bridgeTransfersStore;
- final AppStore appStore;
-
- @computed
- List<BridgeTransfer> get walletTransfers {
- final wallet = appStore.wallet;
- if (wallet == null) return [];
-
- return bridgeTransfersStore.bridgeTransfers.where((t) => t.walletId == wallet.name).toList();
- }
-
- @computed
- List<BridgeTransfer> get activeTransfers =>
- walletTransfers.where((b) => b.isActive).toList(growable: false);
-
- @computed
- List<BridgeTransfer> get pastTransfers =>
- walletTransfers.where((b) => !b.isActive).toList(growable: false);
-
- @computed
- bool get isEmpty => walletTransfers.isEmpty;
-}
diff --git a/tool/configure.dart b/tool/configure.dart
index afbdae4f..507b6900 100644
--- a/tool/configure.dart
+++ b/tool/configure.dart
@@ -1570,7 +1570,7 @@ abstract class EVM {
bool isUSDT0Token(WalletBase wallet, CryptoCurrency token);
List<ChainInfo> getUSDT0DestinationChains(WalletBase wallet);
- Future<USDT0Quote> quoteUSDT0Transfer({
+ Future<BridgeQuote> quoteUSDT0Transfer({
required WalletBase wallet,
required int sourceChainId,
required int destinationChainId,
@@ -1585,7 +1585,7 @@ abstract class EVM {
required int destinationChainId,
required BigInt amount,
required String recipientAddress,
- required USDT0Quote quote,
+ required BridgeQuote quote,
required TransactionPriority priority,
bool useBlinkProtection = true,
});
@@ -1631,6 +1631,16 @@ class EvmWalletConnectFeeQuote {
final int maxPriorityFeePerGasWei;
final int? latestBaseFeeWei;
}
+
+class BridgeQuote {
+ const BridgeQuote({
+ required this.nativeFee,
+ required this.lzTokenFee,
+ });
+
+ final BigInt nativeFee;
+ final BigInt lzTokenFee;
+}
""";
const evmEmptyDefinition = 'EVM? evm;\n';
Why this scored 17/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.