mirror of
https://github.com/Such-Software/hash-wallet.git
synced 2026-10-17 05:41:29 +02:00
CW-1273: Implement USDT0 Bridge (#2855)
* feat: Implement USDT0 Bridging using OFT Standard by Layer Zero. POC available in apps menu. * feat: Implement USDT0 Bridging using OFT Standard by Layer Zero. POC available in apps menu. * feat: Implement USDT0 Bridging using OFT Standard by Layer Zero. POC available in apps menu. * Add transaction history and status polling for USDT0 bridging * Add Transaction history and status polling for USDT0 Bridging * USDT0 Bridging Implementation * Update Arbitrum USDT token symbol * fix: Merge conflicts * fix: Merge conflicts * chore: Rever formatting * feat: Implement new flow * feat: Implement new ui flow * Add currency to configure * feat: new ui flow * Update USDT0 implementation - Switch to sqlite for storage instead of hive - Switch bridge history and details to modals - Switch bridge transfer details page to new tx details ui * refactor: rearrange modal action buttons * enhance usdt0 bridge flows * Update lib/entities/wallet_manager.dart [skip ci] --------- Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>
This commit is contained in:
co-authored by
Omar Hatem
parent
162e0a5300
commit
586a040e4a
@@ -0,0 +1,17 @@
|
||||
import 'package:cw_core/wallet_info.dart';
|
||||
|
||||
class BridgeReceivingWalletOption {
|
||||
const BridgeReceivingWalletOption({
|
||||
required this.walletInfo,
|
||||
required this.isCurrent,
|
||||
this.groupLabel,
|
||||
});
|
||||
|
||||
final WalletInfo walletInfo;
|
||||
final bool isCurrent;
|
||||
|
||||
final String? groupLabel;
|
||||
|
||||
String get name => walletInfo.name;
|
||||
String get address => walletInfo.address;
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cake_wallet/core/amount_parsing_proxy.dart';
|
||||
import 'package:cake_wallet/core/amount_validator.dart';
|
||||
import 'package:cake_wallet/core/fiat_conversion_service.dart';
|
||||
import 'package:cake_wallet/core/utilities.dart';
|
||||
import 'package:cake_wallet/core/wallet_change_listener_view_model.dart';
|
||||
import 'package:cake_wallet/entities/calculate_fiat_amount.dart';
|
||||
import 'package:cake_wallet/entities/fiat_api_mode.dart';
|
||||
import 'package:cake_wallet/view_model/bridge/bridge_receiving_wallet_option.dart';
|
||||
import 'package:cake_wallet/entities/bridge_transfer.dart';
|
||||
import 'package:cake_wallet/entities/wallet_manager.dart';
|
||||
import 'package:cake_wallet/evm/evm.dart';
|
||||
import 'package:cake_wallet/reactions/wallet_connect.dart';
|
||||
import 'package:cake_wallet/core/layerzero_scan_service.dart';
|
||||
import 'package:cake_wallet/store/app_store.dart';
|
||||
import 'package:cake_wallet/store/bridge_transfers_store.dart';
|
||||
import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart';
|
||||
import 'package:cake_wallet/store/settings_store.dart';
|
||||
import 'package:cw_core/crypto_currency.dart';
|
||||
import 'package:cw_core/erc20_token.dart';
|
||||
import 'package:cw_core/transaction_priority.dart';
|
||||
import 'package:cw_core/wallet_base.dart';
|
||||
import 'package:cw_core/wallet_info.dart';
|
||||
import 'package:cw_core/utils/print_verbose.dart';
|
||||
import 'package:mobx/mobx.dart';
|
||||
|
||||
part 'bridge_view_model.g.dart';
|
||||
|
||||
class BridgeViewModel = BridgeViewModelBase with _$BridgeViewModel;
|
||||
|
||||
abstract class BridgeViewModelBase extends WalletChangeListenerViewModel with Store {
|
||||
BridgeViewModelBase({
|
||||
required AppStore appStore,
|
||||
required this.bridgeTransfersStore,
|
||||
required this.walletManager,
|
||||
required this.fiatConversionStore,
|
||||
required this.settingsStore,
|
||||
}) : _appStore = appStore,
|
||||
super(appStore: appStore);
|
||||
|
||||
final AppStore _appStore;
|
||||
|
||||
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;
|
||||
|
||||
@observable
|
||||
ObservableList<BridgeReceivingWalletOption> bridgeReceivingWalletOptions =
|
||||
ObservableList<BridgeReceivingWalletOption>();
|
||||
|
||||
@observable
|
||||
bool isBridgeReceivingWalletListLoading = false;
|
||||
|
||||
@observable
|
||||
CryptoCurrency? selectedToken;
|
||||
|
||||
@observable
|
||||
int? destinationChainId;
|
||||
|
||||
@observable
|
||||
String amount = '';
|
||||
|
||||
@observable
|
||||
String recipientAddress = '';
|
||||
|
||||
@observable
|
||||
String? destinationWalletName;
|
||||
|
||||
@observable
|
||||
USDT0Quote? quote;
|
||||
|
||||
@observable
|
||||
bool isQuoteLoading = false;
|
||||
|
||||
@observable
|
||||
String? quoteError;
|
||||
|
||||
@observable
|
||||
bool isExecuting = false;
|
||||
|
||||
@observable
|
||||
String? executeError;
|
||||
|
||||
@observable
|
||||
bool bridgeSuccess = false;
|
||||
|
||||
@observable
|
||||
BridgeTransfer? lastCreatedBridgeTransfer;
|
||||
|
||||
@computed
|
||||
int? get sourceChainId => evm!.getSelectedChainId(wallet);
|
||||
|
||||
@computed
|
||||
String get sourceAddress => wallet.walletAddresses.address;
|
||||
|
||||
@computed
|
||||
List<ChainInfo> get availableDestinationChains {
|
||||
if (!isEVMCompatibleChain(wallet.type)) return [];
|
||||
|
||||
return evm!.getUSDT0DestinationChains(wallet);
|
||||
}
|
||||
|
||||
@computed
|
||||
List<Erc20Token> get availableUSDT0Tokens {
|
||||
if (!isEVMCompatibleChain(wallet.type)) return [];
|
||||
|
||||
final tokens = wallet.balance.keys.whereType<Erc20Token>();
|
||||
return tokens.where((token) => evm!.isUSDT0Token(wallet, token)).toList(growable: false);
|
||||
}
|
||||
|
||||
@computed
|
||||
ChainInfo? get destinationChainInfo {
|
||||
if (destinationChainId == null) return null;
|
||||
|
||||
return availableDestinationChains.firstWhereOrNull((c) => c.chainId == destinationChainId);
|
||||
}
|
||||
|
||||
@computed
|
||||
String get tokenBalanceFormatted {
|
||||
final token = selectedToken;
|
||||
if (token is! Erc20Token) return '0.00';
|
||||
|
||||
return amountParsingProxy.getDisplayCryptoStringFromBigInt(
|
||||
selectedTokenBalance,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
String get amountDisplayFormatted {
|
||||
if (amount.isEmpty) return '';
|
||||
final token = selectedToken;
|
||||
if (token is! Erc20Token) return amount.replaceAll(',', '.');
|
||||
|
||||
return amountParsingProxy.getDisplayCryptoAmount(
|
||||
amount.replaceAll(',', '.'),
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
DecimalAmountValidator get decimalAmountValidator => DecimalAmountValidator(
|
||||
currency: selectedToken!,
|
||||
isAutovalidate: true,
|
||||
);
|
||||
|
||||
@computed
|
||||
String get fiatAmountFormatted {
|
||||
if (amount.isEmpty) return '';
|
||||
final token = selectedToken;
|
||||
if (token is! Erc20Token) return '';
|
||||
|
||||
final price = fiatConversionStore.prices[token];
|
||||
if (price == null) return '';
|
||||
|
||||
final forFiat = amountParsingProxy.getDisplayCryptoAmount(
|
||||
amount.replaceAll(',', '.'),
|
||||
token,
|
||||
);
|
||||
|
||||
return calculateFiatAmount(
|
||||
price: price,
|
||||
cryptoAmount: forFiat,
|
||||
);
|
||||
}
|
||||
|
||||
@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,
|
||||
);
|
||||
}
|
||||
|
||||
@computed
|
||||
String get quoteNativeFeeFormattedForDisplay {
|
||||
if (quoteNativeFee.isEmpty) return '';
|
||||
|
||||
return '${quoteNativeFee} ${wallet.currency.title}';
|
||||
}
|
||||
|
||||
@computed
|
||||
String get quoteNativeFiatFeeFormattedForDisplay {
|
||||
if (quote == null || quoteNativeFee.isEmpty) return '';
|
||||
|
||||
final price = fiatConversionStore.prices[wallet.currency];
|
||||
if (price == null) return '';
|
||||
|
||||
final fiatFeeFormatted = calculateFiatAmount(
|
||||
price: price,
|
||||
cryptoAmount: amountParsingProxy.getDisplayCryptoAmount(
|
||||
quoteNativeFee.replaceAll(',', '.'),
|
||||
wallet.currency,
|
||||
),
|
||||
);
|
||||
|
||||
return '(${settingsStore.fiatCurrency.title} $fiatFeeFormatted)';
|
||||
}
|
||||
|
||||
@computed
|
||||
bool get canProceedToDestinationNetwork {
|
||||
if (amount.isEmpty) return false;
|
||||
|
||||
if (selectedToken == null || selectedToken is! Erc20Token) return false;
|
||||
|
||||
if (amountError != null) return false;
|
||||
|
||||
final token = selectedToken as Erc20Token;
|
||||
final validAmount = amountParsingProxy.tryParseCryptoString(
|
||||
amount.replaceAll(',', '.'),
|
||||
token,
|
||||
);
|
||||
return validAmount != null && validAmount > BigInt.zero;
|
||||
}
|
||||
|
||||
@action
|
||||
void applyInitialBridgeToken(CryptoCurrency asset) {
|
||||
for (final t in availableUSDT0Tokens) {
|
||||
if (t == asset) {
|
||||
setSelectedToken(t);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
void setDestinationChain(int chainId) {
|
||||
destinationChainId = chainId;
|
||||
_clearQuoteState();
|
||||
}
|
||||
|
||||
@action
|
||||
void setSelectedToken(CryptoCurrency token) {
|
||||
selectedToken = token;
|
||||
_clearQuoteState();
|
||||
}
|
||||
|
||||
@action
|
||||
void setAmount(String value) {
|
||||
amount = value;
|
||||
_clearQuoteState();
|
||||
}
|
||||
|
||||
@action
|
||||
void setMaxAmount() {
|
||||
final token = selectedToken;
|
||||
if (token is! Erc20Token) return;
|
||||
if (selectedTokenBalance == BigInt.zero) {
|
||||
setAmount('');
|
||||
return;
|
||||
}
|
||||
setAmount(
|
||||
amountParsingProxy.getDisplayCryptoStringFromBigInt(
|
||||
selectedTokenBalance,
|
||||
token,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
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,
|
||||
);
|
||||
|
||||
runInAction(() {
|
||||
fiatConversionStore.prices[crypto] = p;
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
Future<void> ensureFiatPriceForSelectedToken() async {
|
||||
await _ensureFiatPriceFor(selectedToken!);
|
||||
}
|
||||
|
||||
@action
|
||||
Future<void> ensureFiatPriceForNativeCurrency() async {
|
||||
await _ensureFiatPriceFor(wallet.currency);
|
||||
}
|
||||
|
||||
@computed
|
||||
BigInt get selectedTokenBalance {
|
||||
final bal = wallet.balance[selectedToken];
|
||||
|
||||
if (bal is EVMChainERC20Balance) return bal.balance;
|
||||
return 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;
|
||||
|
||||
final destWalletType = evm!.getWalletTypeByChainId(destinationChainId!);
|
||||
|
||||
isBridgeReceivingWalletListLoading = true;
|
||||
try {
|
||||
await walletManager.updateWalletGroups();
|
||||
final all = await WalletInfo.getAll();
|
||||
|
||||
if (destWalletType == null) {
|
||||
bridgeReceivingWalletOptions.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
final filtered =
|
||||
all.where((w) => w.type == destWalletType && w.hardwareWalletType == null).toList();
|
||||
|
||||
final options = <BridgeReceivingWalletOption>[];
|
||||
|
||||
for (final wi in filtered) {
|
||||
final isCurrent = wi.name == wallet.name;
|
||||
options.add(
|
||||
BridgeReceivingWalletOption(
|
||||
walletInfo: wi,
|
||||
isCurrent: isCurrent,
|
||||
groupLabel: walletManager.getGroupName(wi),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bridgeReceivingWalletOptions
|
||||
..clear()
|
||||
..addAll(options);
|
||||
} finally {
|
||||
isBridgeReceivingWalletListLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
String? missingBridgeFieldsMessage() {
|
||||
final src = sourceChainId;
|
||||
final dst = destinationChainId;
|
||||
final token = selectedToken;
|
||||
|
||||
if (src == null ||
|
||||
dst == null ||
|
||||
token == null ||
|
||||
amount.isEmpty ||
|
||||
recipientAddress.trim().isEmpty) {
|
||||
return 'Fill all fields';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
({String? error, BigInt? parsedAmount}) _parseAndValidateAmount(Erc20Token token) {
|
||||
final amountBigInt = amountParsingProxy.tryParseCryptoString(
|
||||
amount.replaceAll(',', '.'),
|
||||
token,
|
||||
);
|
||||
|
||||
if (amountBigInt == null || amountBigInt == BigInt.zero) {
|
||||
return (error: 'Invalid amount', parsedAmount: null);
|
||||
}
|
||||
|
||||
if (amountBigInt > selectedTokenBalance) {
|
||||
return (
|
||||
error: 'Insufficient balance for ${token.title} token.',
|
||||
parsedAmount: null,
|
||||
);
|
||||
}
|
||||
|
||||
return (error: null, parsedAmount: amountBigInt);
|
||||
}
|
||||
|
||||
@action
|
||||
Future<void> loadQuote() async {
|
||||
final missing = missingBridgeFieldsMessage();
|
||||
if (missing != null) {
|
||||
quoteError = missing;
|
||||
return;
|
||||
}
|
||||
|
||||
final token = selectedToken!;
|
||||
if (token is! Erc20Token) return;
|
||||
|
||||
final check = _parseAndValidateAmount(token);
|
||||
if (check.error != null) {
|
||||
quoteError = check.error;
|
||||
return;
|
||||
}
|
||||
|
||||
final src = sourceChainId!;
|
||||
final dst = destinationChainId!;
|
||||
final amountBigInt = check.parsedAmount!;
|
||||
|
||||
isQuoteLoading = true;
|
||||
quoteError = null;
|
||||
quote = null;
|
||||
executeError = null;
|
||||
|
||||
try {
|
||||
quote = await evm!.quoteUSDT0Transfer(
|
||||
wallet: wallet,
|
||||
sourceChainId: src,
|
||||
destinationChainId: dst,
|
||||
amount: amountBigInt,
|
||||
recipientAddress: recipientAddress.trim(),
|
||||
);
|
||||
} catch (e) {
|
||||
quoteError = e.toString();
|
||||
} finally {
|
||||
isQuoteLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
Future<void> executeBridge() async {
|
||||
if (quote == null) {
|
||||
executeError = 'Get a quote first';
|
||||
return;
|
||||
}
|
||||
|
||||
final missing = missingBridgeFieldsMessage();
|
||||
if (missing != null) {
|
||||
executeError = missing;
|
||||
return;
|
||||
}
|
||||
|
||||
final token = selectedToken!;
|
||||
if (token is! Erc20Token) return;
|
||||
|
||||
final check = _parseAndValidateAmount(token);
|
||||
if (check.error != null) {
|
||||
executeError = check.error;
|
||||
return;
|
||||
}
|
||||
|
||||
final src = sourceChainId!;
|
||||
final dst = destinationChainId!;
|
||||
final amountBigInt = check.parsedAmount!;
|
||||
|
||||
isExecuting = true;
|
||||
executeError = null;
|
||||
try {
|
||||
final priority = EVMChainTransactionPriority.medium;
|
||||
final pending = await evm!.executeUSDT0Transfer(
|
||||
wallet: wallet,
|
||||
token: token,
|
||||
sourceChainId: src,
|
||||
destinationChainId: dst,
|
||||
amount: amountBigInt,
|
||||
recipientAddress: recipientAddress.trim(),
|
||||
quote: quote!,
|
||||
priority: priority as TransactionPriority,
|
||||
useBlinkProtection: canSupportBlinkProtection(src),
|
||||
);
|
||||
|
||||
final sourceTxHash = pending.evmTxHashFromRawHex ?? pending.id;
|
||||
await pending.commit();
|
||||
|
||||
final record = BridgeTransfer(
|
||||
id: '${sourceTxHash}_${DateTime.now().millisecondsSinceEpoch}',
|
||||
walletId: wallet.name,
|
||||
sourceChainId: src,
|
||||
destinationChainId: dst,
|
||||
tokenSymbol: token.title,
|
||||
tokenContract: token.contractAddress,
|
||||
amount: amount,
|
||||
recipientAddress: recipientAddress.trim(),
|
||||
sourceTxHash: sourceTxHash,
|
||||
status: 'submitted',
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await bridgeTransfersStore.addTransfer(record);
|
||||
runInAction(() {
|
||||
quote = null;
|
||||
bridgeSuccess = true;
|
||||
lastCreatedBridgeTransfer = record;
|
||||
});
|
||||
onBridgeSuccess?.call();
|
||||
_pollForSourceConfirmation(record, wallet);
|
||||
} catch (e) {
|
||||
executeError = e.toString();
|
||||
} finally {
|
||||
isExecuting = false;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
final activeTransfers = bridgeTransfersStore.bridgeTransfers
|
||||
.where((t) => t.walletId == wallet.name && t.isActive)
|
||||
.toList();
|
||||
|
||||
for (final transfer in activeTransfers) {
|
||||
if (transfer.status == 'submitted' || transfer.status == 'confirming') {
|
||||
_pollForSourceConfirmation(transfer, wallet);
|
||||
} else if (transfer.status == 'initiated') {
|
||||
_pollForDestinationCompletion(transfer, wallet);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _isValidWalletContext(String expectedWalletId) {
|
||||
return wallet.name == expectedWalletId &&
|
||||
isEVMCompatibleChain(wallet.type) &&
|
||||
!_pollingCancellers.values.any((c) => c.isCompleted);
|
||||
}
|
||||
|
||||
Future<void> _updateTransferStatus(
|
||||
BridgeTransfer record,
|
||||
String status, {
|
||||
String? errorMessage,
|
||||
String? statusMessage,
|
||||
DateTime? confirmedAt,
|
||||
}) async {
|
||||
if (!_isValidWalletContext(record.walletId)) return;
|
||||
|
||||
runInAction(() {
|
||||
record.updatedAt = DateTime.now();
|
||||
record.status = status;
|
||||
if (errorMessage != null) record.errorMessage = errorMessage;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pollForDestinationCompletion(
|
||||
BridgeTransfer record,
|
||||
WalletBase wallet,
|
||||
) async {
|
||||
final canceller = Completer<void>();
|
||||
_pollingCancellers['${record.id}_dest'] = canceller;
|
||||
final walletId = wallet.name;
|
||||
final deadline = DateTime.now().add(_destinationPollTimeout);
|
||||
|
||||
try {
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
await Future.any([
|
||||
Future.delayed(_destinationPollInterval),
|
||||
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 (status == null) continue;
|
||||
|
||||
if (status.isDelivered) {
|
||||
await _updateTransferStatus(
|
||||
record,
|
||||
'completed',
|
||||
statusMessage: status.status?.message,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status.isFailed) {
|
||||
await _updateTransferStatus(
|
||||
record,
|
||||
'failed',
|
||||
errorMessage: status.status?.message ?? 'Bridge message failed',
|
||||
statusMessage: status.status?.message,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await _updateTransferStatus(
|
||||
record,
|
||||
record.status,
|
||||
statusMessage: status.status?.message,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
_pollingCancellers.remove('${record.id}_dest');
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
void clearBridgeSuccess() {
|
||||
amount = '';
|
||||
recipientAddress = '';
|
||||
destinationWalletName = null;
|
||||
destinationChainId = null;
|
||||
bridgeSuccess = false;
|
||||
lastCreatedBridgeTransfer = null;
|
||||
_clearQuoteState();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_cancelAllPolling();
|
||||
}
|
||||
}
|
||||
@@ -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?.getChainNameByChainId(transfer.sourceChainId) ?? '${transfer.sourceChainId}';
|
||||
final destName =
|
||||
evm?.getChainNameByChainId(transfer.destinationChainId) ?? '${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 (_) {}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -393,6 +393,14 @@ abstract class DashboardViewModelBase with Store {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool showBridge(CryptoCurrency currency) {
|
||||
if(!isEVMCompatibleChain(wallet.type)) return false;
|
||||
|
||||
if(evm!.isUSDT0Token(wallet, currency)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@action
|
||||
Future<void> loadCardDesigns() async {
|
||||
final accountStyleSettings =
|
||||
|
||||
Reference in New Issue
Block a user