Files
hash-wallet/lib/view_model/send/send_view_model.dart
T
jwinterm b57ab7cc06 Nuke Solana/Tron/Zano/Decred/Zcash/Haven from the codebase entirely
94 files changed, -5926 net lines. No stubs, no generators, no dead enum
values, no orphan switch branches. Removed for principled reasons (we
do not ship these chains) and to make the codebase smaller and friendlier
to contribute to.

What's gone:
- lib/{solana,tron,zano,decred,zcash,haven}/ directories
- lib/exchange/provider/jupiter_exchange_provider.dart (Solana-only)
- lib/entities/haven_seed_store.dart
- WalletType.{solana,tron,zano,decred,zcash,haven} enum values
  (HiveField IDs are pinned: Wownero stays at 12, all surviving types
  keep their IDs, so existing user-saved wallets deserialize unchanged)
- All ~70 lib/ files that imported the removed chain stubs or branched
  on the removed WalletType values
- backupSeeds(Box<HavenSeedStore>) abstract method + Wownero implementation
- Jupiter exchange branches in send/exchange view models
- TronGrid usage settings, Zano alias lookups, Zcash transparent address
  handling, Decred default-spv-nodes branches, Haven seed migration

What stays (intentional):
- CryptoCurrency.{xhv,sol,trx,dcr,zano,zec} enum entries in cw_core —
  they're still used as exchange/swap currency identifiers in
  address_validator.dart and elsewhere. The wallet types are gone but
  the *currencies* are still tradeable through Trocador.
- cw_core/spl_token.dart and cw_core/tron_token.dart Hive adapters in
  main.dart — the tokens still register as Hive types for backwards-
  compat with any old box data.
- lib/buy/{moonpay,robinhood,onramper,dfx,kryptonim,meld}/ provider
  source files (just stripped of solana/tron/etc references; providers
  themselves remain dormant per provider_types.dart).

Pinned-ID note: WalletType enum now has gaps in @HiveField IDs (4, 10,
11, 13, 14, 18 removed). This is deliberate — re-using a freed ID later
would mis-deserialize old hive boxes. Don't renumber.

Bundled config / CI hasn't changed; configure.dart was already free of
these chains in commit 4eb7db0b7.
2026-05-17 12:30:25 -04:00

1403 lines
48 KiB
Dart

import 'dart:async';
import 'package:hash_wallet/bitcoin/bitcoin.dart';
import 'package:hash_wallet/core/address_validator.dart';
import 'package:hash_wallet/core/amount_parsing_proxy.dart';
import 'package:hash_wallet/core/amount_validator.dart';
import 'package:hash_wallet/core/execution_state.dart';
import 'package:hash_wallet/core/open_crypto_pay/exceptions.dart';
import 'package:hash_wallet/core/open_crypto_pay/models.dart';
import 'package:hash_wallet/core/open_crypto_pay/open_cryptopay_service.dart';
import 'package:hash_wallet/core/validator.dart';
import 'package:hash_wallet/core/wallet_change_listener_view_model.dart';
import 'package:hash_wallet/entities/calculate_fiat_amount.dart';
import 'package:hash_wallet/entities/contact.dart';
import 'package:hash_wallet/entities/contact_record.dart';
import 'package:hash_wallet/entities/evm_transaction_error_fees_handler.dart';
import 'package:hash_wallet/entities/fiat_currency.dart';
import 'package:hash_wallet/entities/parsed_address.dart';
import 'package:hash_wallet/entities/preferences_key.dart';
import 'package:hash_wallet/entities/template.dart';
import 'package:hash_wallet/entities/transaction_description.dart';
import 'package:hash_wallet/entities/wallet_contact.dart';
import 'package:hash_wallet/evm/evm.dart';
import 'package:hash_wallet/exchange/exchange_provider_description.dart';
import 'package:hash_wallet/exchange/provider/exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/near_Intents_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/swapsxyz_exchange_provider.dart';
import 'package:hash_wallet/exchange/provider/thorchain_exchange.provider.dart';
import 'package:hash_wallet/exchange/trade.dart';
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/monero/monero.dart';
import 'package:hash_wallet/nano/nano.dart';
import 'package:hash_wallet/reactions/wallet_connect.dart';
import 'package:hash_wallet/routes.dart';
import 'package:hash_wallet/store/app_store.dart';
import 'package:hash_wallet/store/dashboard/fiat_conversion_store.dart';
import 'package:hash_wallet/store/settings_store.dart';
import 'package:hash_wallet/utils/payment_request.dart';
import 'package:hash_wallet/view_model/contact_list/contact_list_view_model.dart';
import 'package:hash_wallet/view_model/dashboard/balance_view_model.dart';
import 'package:hash_wallet/view_model/hardware_wallet/hardware_wallet_view_model.dart';
import 'package:hash_wallet/view_model/send/fees_view_model.dart';
import 'package:hash_wallet/view_model/send/output.dart';
import 'package:hash_wallet/view_model/send/send_template_view_model.dart';
import 'package:hash_wallet/view_model/send/send_view_model_state.dart';
import 'package:hash_wallet/view_model/unspent_coins/unspent_coins_list_view_model.dart';
import 'package:hash_wallet/wownero/wownero.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/currency_for_wallet_type.dart';
import 'package:cw_core/erc20_token.dart';
import 'package:cw_core/exceptions.dart';
import 'package:cw_core/lnurl.dart';
import 'package:cw_core/pending_transaction.dart';
import 'package:cw_core/sync_status.dart';
import 'package:cw_core/transaction_info.dart';
import 'package:cw_core/transaction_priority.dart';
import 'package:cw_core/unspent_coin_type.dart';
import 'package:cw_core/utils/print_verbose.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:mobx/mobx.dart';
import 'package:hash_wallet/utils/token_utilities.dart';
import 'package:shared_preferences/shared_preferences.dart';
part 'send_view_model.g.dart';
class SendViewModel = SendViewModelBase with _$SendViewModel;
abstract class SendViewModelBase extends WalletChangeListenerViewModel with Store {
@override
void onWalletChange(wallet) {
currencies = wallet.balance.keys.toList();
selectedCryptoCurrency =
coinTypeToSpendFrom == UnspentCoinType.lightning ? CryptoCurrency.btcln : wallet.currency;
hasMultipleTokens = isEVMWallet;
for (final output in outputs) {
output.updateWallet(wallet);
}
// Update unspent coins list view model with the new wallet reference
unspentCoinsListViewModel.updateWallet(wallet);
// Update sending balance to reflect the new wallet's balance
updateSendingBalance();
}
UnspentCoinsListViewModel unspentCoinsListViewModel;
SendViewModelBase(
this._appStore,
this.sendTemplateViewModel,
this._fiatConversationStore,
this.balanceViewModel,
this.contactListViewModel,
this.transactionDescriptionBox,
this.hardwareWalletViewModel,
this.unspentCoinsListViewModel,
this.feesViewModel, {
this.coinTypeToSpendFrom = UnspentCoinType.nonMweb,
}) : state = InitialExecutionState(),
currencies = _appStore.wallet!.balance.keys.toList(),
selectedCryptoCurrency = coinTypeToSpendFrom == UnspentCoinType.lightning
? CryptoCurrency.btcln
: _appStore.wallet!.currency,
hasMultipleTokens = isEVMCompatibleChain(_appStore.wallet!.type),
selectedChainId = _appStore.wallet!.chainId,
outputs = ObservableList<Output>(),
fiatFromSettings = _appStore.settingsStore.fiatCurrency,
fiatCurrencies = FiatCurrency.all,
super(appStore: _appStore) {
outputs.add(Output(wallet, _appStore, _fiatConversationStore, () => selectedCryptoCurrency));
unspentCoinsListViewModel
.initialSetup()
.then((_) => unspentCoinsListViewModel.resetUnspentCoinsInfoSelections());
reaction((_) {
if (isEVMCompatibleChain(wallet.type)) {
// Access currency which depends on selectedChainId, so MobX tracks the change
return wallet.currency;
}
return null;
}, (_) async {
// When chain changes, update currencies and selected currency
await Future.delayed(const Duration(milliseconds: 100));
currencies = wallet.balance.keys.toList();
selectedCryptoCurrency = wallet.currency;
updateSendingBalance();
});
}
// Store trade and provider references for post-commit updates (e.g., Jupiter trade ID update)
Trade? _currentTrade;
ExchangeProvider? _currentProvider;
@observable
ExecutionState state;
ObservableList<Output> outputs;
@observable
UnspentCoinType coinTypeToSpendFrom;
bool get showAddressBookPopup => _settingsStore.showAddressBookPopupEnabled;
bool get isMwebEnabled => balanceViewModel.mwebEnabled;
bool get isMwebAvailable => wallet.currency == CryptoCurrency.ltc && balanceViewModel.mwebEnabled;
bool get isEVMWallet => isEVMCompatibleChain(walletType);
@action
void setShowAddressBookPopup(bool value) => _settingsStore.showAddressBookPopupEnabled = value;
@action
void addOutput() =>
outputs.add(Output(wallet, _appStore, _fiatConversationStore, () => selectedCryptoCurrency));
@action
void removeOutput(Output output) {
if (isBatchSending) outputs.remove(output);
}
@action
void clearOutputs() {
outputs.clear();
addOutput();
}
@action
void setAllowMwebCoins(bool allow) {
if (wallet.type == WalletType.litecoin) {
coinTypeToSpendFrom = allow ? UnspentCoinType.any : UnspentCoinType.nonMweb;
}
}
@computed
bool get isBatchSending => outputs.length > 1;
bool get shouldDisplaySendALL {
// if (walletType == WalletType.ethereum && selectedCryptoCurrency == CryptoCurrency.eth)
// return false;
// if (walletType == WalletType.polygon && selectedCryptoCurrency == CryptoCurrency.maticpoly)
// return false;
return true;
}
@computed
String get pendingTransactionFiatAmount {
if (pendingTransaction == null) return '0.00';
try {
final selectedCurrency = selectedCryptoCurrency == CryptoCurrency.btcln
? CryptoCurrency.btc
: selectedCryptoCurrency;
var currency = _fiatConversationStore.prices.keys
.firstWhere((k) => k.titleAndTagEqual(selectedCurrency));
final fiat = calculateFiatAmount(
price: _fiatConversationStore.prices[currency],
cryptoAmount: pendingTransaction!.amountFormatted);
return fiat;
} catch (_) {
return '0.00';
}
}
String calculateTransactionFiatAmount(String amountValue) {
try {
final fiat = calculateFiatAmount(
price: _fiatConversationStore.prices[_fiatConversationStore.prices.keys
.firstWhere((k) => k.titleAndTagEqual(selectedCryptoCurrency))],
cryptoAmount: amountValue);
return fiat;
} catch (_) {
return '0.00';
}
}
@computed
String get pendingTransactionFeeFiatAmount {
try {
if (pendingTransaction != null) {
final currency = pendingTransactionFeeCurrency(walletType);
final fiat = calculateFiatAmount(
price: _fiatConversationStore.prices[currency]!,
cryptoAmount: pendingTransaction!.feeFormattedValue,
);
return fiat;
} else {
return '0.00';
}
} catch (_) {
return '0.00';
}
}
CryptoCurrency pendingTransactionFeeCurrency(WalletType type) {
switch (type) {
case WalletType.ethereum:
case WalletType.polygon:
case WalletType.base:
case WalletType.arbitrum:
case WalletType.bsc:
case WalletType.bitcoin:
return wallet.currency;
default:
return selectedCryptoCurrency;
}
}
FiatCurrency get fiat => _settingsStore.fiatCurrency;
CryptoCurrency get currency =>
selectedCryptoCurrency == CryptoCurrency.btcln ? CryptoCurrency.btcln : wallet.currency;
String get currencySymbol => _appStore.amountParsingProxy.getCryptoSymbol(currency);
Validator<String> amountValidator(Output output) => AmountValidator(
currency: wallet.currency,
amountParsingProxy: _appStore.amountParsingProxy,
minValue: isSendToSilentPayments(output)
?
// TODO: get from server
// bitcoin!.silentPaymentsMinAmount
'0.00001'
: null,
);
Validator<String> get allAmountValidator => AllAmountValidator();
Validator<String> get addressValidator =>
AddressValidator(type: selectedCryptoCurrency, isTestnet: wallet.isTestnet);
Validator<String> get textValidator => TextValidator();
final FiatCurrency fiatFromSettings;
@observable
PendingTransaction? pendingTransaction;
@computed
String get balance {
if (walletType == WalletType.litecoin && coinTypeToSpendFrom == UnspentCoinType.mweb) {
return balanceViewModel.balances.values.first.secondAvailableBalance;
} else if (walletType == WalletType.litecoin &&
coinTypeToSpendFrom == UnspentCoinType.nonMweb) {
return balanceViewModel.balances.values.first.availableBalance;
}
// Handle case where balance might not be available yet (e.g., during chain switch)
final balanceForCurrency = wallet.balance[selectedCryptoCurrency];
if (balanceForCurrency == null) {
return _appStore.amountParsingProxy.getDisplayCryptoString(0, selectedCryptoCurrency);
}
return _appStore.amountParsingProxy.getDisplayCryptoStringFromBigInt(
wallet.balance[selectedCryptoCurrency]!.fullAvailableBalance, selectedCryptoCurrency);
}
@action
Future<void> updateSendingBalance() async {
// force the sendingBalance to recompute since unspent coins aren't observable
// or at least mobx can't detect the changes
final currentType = coinTypeToSpendFrom;
if (currentType == UnspentCoinType.any) {
coinTypeToSpendFrom = UnspentCoinType.nonMweb;
} else if (currentType == UnspentCoinType.nonMweb) {
coinTypeToSpendFrom = UnspentCoinType.any;
} else if (currentType == UnspentCoinType.mweb) {
coinTypeToSpendFrom = UnspentCoinType.nonMweb;
}
// set it back to the original value:
coinTypeToSpendFrom = currentType;
}
@computed
Future<String> get sendingBalance async {
// only for electrum, monero, wownero wallets atm:
switch (wallet.type) {
case WalletType.bitcoin:
if (coinTypeToSpendFrom == UnspentCoinType.lightning) return balance;
return _appStore.amountParsingProxy.getDisplayCryptoString(
await unspentCoinsListViewModel.getSendingBalance(coinTypeToSpendFrom),
walletTypeToCryptoCurrency(walletType));
case WalletType.litecoin:
case WalletType.bitcoinCash:
case WalletType.dogecoin:
case WalletType.monero:
case WalletType.wownero:
final sendingBalance =
await unspentCoinsListViewModel.getSendingBalance(coinTypeToSpendFrom);
return walletTypeToCryptoCurrency(walletType).formatAmount(BigInt.from(sendingBalance));
default:
return balance;
}
}
@computed
bool get isFiatDisabled => balanceViewModel.isFiatDisabled;
@computed
String get pendingTransactionFiatAmountFormatted =>
isFiatDisabled ? '' : '$pendingTransactionFiatAmount ${fiat.title}';
@computed
String get pendingTransactionFeeFiatAmountFormatted =>
isFiatDisabled ? '' : '$pendingTransactionFeeFiatAmount ${fiat.title}';
@computed
bool get isReadyForSend =>
wallet.syncStatus is SyncedSyncStatus ||
// If silent payments scanning, can still send payments
(wallet.type == WalletType.bitcoin && wallet.syncStatus is SyncingSyncStatus);
bool isSendToSilentPayments(Output output) =>
wallet.type == WalletType.bitcoin &&
(RegExp(AddressValidator.silentPaymentAddressPatternMainnet).hasMatch(output.address) ||
RegExp(AddressValidator.silentPaymentAddressPatternMainnet)
.hasMatch(output.extractedAddress) ||
(output.parsedAddress.addresses.isNotEmpty &&
RegExp(AddressValidator.silentPaymentAddressPatternMainnet)
.hasMatch(output.parsedAddress.addresses[0])));
@computed
List<Template> get templates => sendTemplateViewModel.templates
.where((template) => _isEqualCurrency(template.cryptoCurrency))
.toList();
@computed
bool get hasCoinControl =>
[
WalletType.bitcoin,
WalletType.litecoin,
WalletType.monero,
WalletType.wownero,
WalletType.bitcoinCash,
WalletType.dogecoin
].contains(wallet.type) &&
coinTypeToSpendFrom != UnspentCoinType.lightning;
@computed
bool get hasFees => feesViewModel.hasFees && coinTypeToSpendFrom != UnspentCoinType.lightning;
@computed
bool get isElectrumWallet => [
WalletType.bitcoin,
WalletType.litecoin,
WalletType.bitcoinCash,
WalletType.dogecoin
].contains(wallet.type);
@observable
CryptoCurrency selectedCryptoCurrency;
@computed
String get selectedCryptoCurrencySymbol =>
amountParsingProxy.getCryptoSymbol(selectedCryptoCurrency);
List<CryptoCurrency> currencies;
WalletType get walletType => wallet.type;
String? get walletCurrencyName => wallet.currency.fullName?.toLowerCase() ?? wallet.currency.name;
@computed
FiatCurrency get fiatCurrency => _settingsStore.fiatCurrency;
set fiatCurrency(FiatCurrency value) {
_settingsStore.fiatCurrency = value;
}
List<FiatCurrency> fiatCurrencies;
final AppStore _appStore;
SettingsStore get _settingsStore => _appStore.settingsStore;
final SendTemplateViewModel sendTemplateViewModel;
final BalanceViewModel balanceViewModel;
final ContactListViewModel contactListViewModel;
final HardwareWalletViewModel? hardwareWalletViewModel;
final FeesViewModel feesViewModel;
final FiatConversionStore _fiatConversationStore;
final Box<TransactionDescription> transactionDescriptionBox;
@computed
AmountParsingProxy get amountParsingProxy => _appStore.amountParsingProxy;
@computed
bool get hasMultiRecipient =>
sendTemplateViewModel.hasMultiRecipient && coinTypeToSpendFrom != UnspentCoinType.lightning;
@computed
String get languageCode => _appStore.settingsStore.languageCode;
@observable
bool hasMultipleTokens;
@observable
int? selectedChainId;
@computed
List<ContactRecord> get contactsToShow => contactListViewModel.contacts
.where((element) => element.type == selectedCryptoCurrency)
.toList();
@computed
List<WalletContact> get walletContactsToShow => contactListViewModel.walletContacts
.where((element) => element.type == selectedCryptoCurrency)
.toList();
@action
bool checkIfAddressIsAContact(String address) =>
contactsToShow.where((element) => element.address == address).toList().isNotEmpty;
@action
bool checkIfWalletIsAnInternalWallet(String address) =>
walletContactsToShow.where((element) => element.address == address).toList().isNotEmpty;
@computed
bool get shouldDisplayTOTP2FAForContact => _settingsStore.shouldRequireTOTP2FAForSendsToContact;
@computed
bool get shouldDisplayTOTP2FAForNonContact =>
_settingsStore.shouldRequireTOTP2FAForSendsToNonContact;
@computed
bool get shouldDisplayTOTP2FAForSendsToInternalWallet =>
_settingsStore.shouldRequireTOTP2FAForSendsToInternalWallets;
//* Still open to further optimize these checks
//* It works but can be made better
@action
bool checkThroughChecksToDisplayTOTP(String address) {
final isContact = checkIfAddressIsAContact(address);
final isInternalWallet = checkIfWalletIsAnInternalWallet(address);
if (isContact) {
return shouldDisplayTOTP2FAForContact;
} else if (isInternalWallet) {
return shouldDisplayTOTP2FAForSendsToInternalWallet;
} else {
return shouldDisplayTOTP2FAForNonContact;
}
}
bool shouldDisplayTotp() {
List<bool> conditionsList = [];
for (var output in outputs) {
final show = checkThroughChecksToDisplayTOTP(output.extractedAddress);
conditionsList.add(show);
}
return conditionsList.contains(true);
}
final _ocpService = OpenCryptoPayService();
@observable
OpenCryptoPayRequest? ocpRequest;
@action
Future<void> dismissTransaction() async {
state = InitialExecutionState();
if (ocpRequest != null) {
clearOutputs();
_ocpService.cancelOpenCryptoPayRequest(ocpRequest!);
ocpRequest = null;
}
}
@action
Future<PaymentRequest?> getOpenCryptoPayRequest(String uri) async {
try {
final originalOCPRequest = await _ocpService.getOpenCryptoPayInvoice(uri.toString());
final paymentUri = await _ocpService.getOpenCryptoPayAddress(
originalOCPRequest,
selectedCryptoCurrency,
);
ocpRequest = originalOCPRequest;
clearOutputs();
return PaymentRequest.fromUri(paymentUri);
} on OpenCryptoPayNotSupportedException catch (e) {
printV(e.message);
if (walletType == WalletType.bitcoin) {
state = InitialExecutionState();
} else {
state = FailureState(translateErrorMessage(e, walletType, currency));
}
} catch (e) {
printV(e);
state = FailureState(translateErrorMessage(e, walletType, currency));
}
return null;
}
@action
Future<PendingTransaction?> createOpenCryptoPayTransaction(String uri) async {
state = IsExecutingState();
try {
final originalOCPRequest = await _ocpService.getOpenCryptoPayInvoice(uri.toString());
final paymentUri = await _ocpService.getOpenCryptoPayAddress(
originalOCPRequest,
selectedCryptoCurrency,
);
ocpRequest = originalOCPRequest;
final paymentRequest = PaymentRequest.fromUri(paymentUri);
clearOutputs();
outputs.first.address = paymentRequest.address;
outputs.first.parsedAddress =
ParsedAddress(addresses: [paymentRequest.address], name: ocpRequest!.receiverName);
outputs.first.setCryptoAmount(paymentRequest.amount);
outputs.first.note = ocpRequest!.receiverName;
return createTransaction();
} on OpenCryptoPayNotSupportedException catch (e) {
printV(e.message);
if (walletType == WalletType.bitcoin) {
state = InitialExecutionState();
} else {
state = FailureState(translateErrorMessage(e, walletType, currency));
}
} catch (e) {
printV(e);
state = FailureState(translateErrorMessage(e, walletType, currency));
}
return null;
}
static bool isLightningInvoice(String txt) {
return RegExp(AddressValidator.bolt11InvoiceMatcher, caseSensitive: false).hasMatch(txt);
}
static bool isNonZeroAmountLightningInvoice(String txt) {
return RegExp(AddressValidator.bolt11InvoiceMatcher, caseSensitive: false).hasMatch(txt) &&
!isBolt11ZeroInvoice(txt);
}
static bool isLnurlInvoice(String txt) {
return RegExp(AddressValidator.lnurlMatcher, caseSensitive: false).hasMatch(txt);
}
Timer? _ledgerTxStateTimer;
@action
Future<PendingTransaction?> createTransaction({ExchangeProvider? provider, Trade? trade}) async {
_currentTrade = trade;
_currentProvider = provider;
pendingTransaction = null;
try {
if (!(state is IsExecutingState)) state = IsExecutingState();
if (wallet.isHardwareWallet) {
state = IsAwaitingDeviceResponseState();
if (walletType == WalletType.monero) {
_ledgerTxStateTimer = Timer.periodic(Duration(seconds: 1), (timer) {
if (monero!.getLastLedgerCommand() == "INS_CLSAG") {
timer.cancel();
state = IsDeviceSigningResponseState();
}
});
}
}
// Swaps.xyz (EVM) path
if (isEVMWallet && trade != null && provider is SwapsXyzExchangeProvider) {
final routerTo = trade.inputAddress;
final routerData = trade.routerData;
if (routerData != null && routerData != '0x') {
final tokenContract = (trade.sourceTokenAddress ?? '').toLowerCase();
final priority = _settingsStore.getPriority(
walletType, chainId: selectedChainId);
final routerValueWei = BigInt.tryParse(trade.routerValue ?? '0') ??
BigInt.zero;
if (routerTo == null || routerTo.isEmpty) {
state = FailureState('Invalid router address');
return null;
}
try {
final selector = _decodeMethodSelector(routerData);
const transferSig = '0xa9059cbb';
const swapAndExecuteSig = '0x9be111d1';
// Direct Transfer (Simple routing, no approval needed)
if (selector == transferSig) {
pendingTransaction = await evm!.createRawCallDataTransaction(
wallet,
routerTo,
routerData,
BigInt.zero,
priority,
useBlinkProtection: canSupportBlinkProtection(selectedChainId)
? _settingsStore.useBlinkProtection
: false,
);
state = ExecutedSuccessfullyState();
return pendingTransaction;
}
// Smart Swap (Requires Approval)
if (selector == swapAndExecuteSig) {
final requiredAmount = BigInt.tryParse(
(trade.sourceTokenAmountRaw ?? '0').replaceAll('n', '')) ??
BigInt.zero;
final needsApproval = tokenContract.isNotEmpty &&
requiredAmount > BigInt.zero
? await evm!.isApprovalRequired(
wallet, tokenContract, routerTo, requiredAmount)
: false;
printV(
'[Swaps.xyz sending flow] Approval required: $needsApproval for token ${trade
.from?.title} ${trade.from?.tag ??
''} with amount $requiredAmount');
if (needsApproval) {
// USDT Approval Flow (Special Case). We must reset allowance to 0 first.
final isUSDTMainnet = selectedChainId == 1 &&
tokenContract.toLowerCase() ==
'0xdac17f958d2ee523a2206206994597c13d831ec7';
if (isUSDTMainnet) {
final currentAllowance = await evm!.getAllowance(
wallet, tokenContract, routerTo);
if (currentAllowance != null &&
currentAllowance > BigInt.zero) {
printV(
'[Swaps.xyz sending flow] currentAllowance USDT: $currentAllowance. Resetting to 0 before setting new allowance.');
final resetTx = await buildApprovalNeeded(
spender: routerTo,
tokenContract: tokenContract,
requiredAmount: BigInt.zero,
// Approve 0
sourceTokenDecimals: trade.sourceTokenDecimals,
priority: priority
);
if (resetTx != null) {
await resetTx.commit();
final resetConfirmed = await _waitForApprovalUpdate(
tokenContract: tokenContract,
spender: routerTo,
requiredAmount: BigInt.zero, // Wait until it equals 0
waitForExactMatch: true,
);
if (!resetConfirmed) {
state = FailureState(
'Failed to reset USDT allowance. Please try again.');
return null;
}
printV(
'[Swaps.xyz sending flow] USDT allowance reset to 0 confirmed on-chain.');
}
}
}
// Standard Approval Flow
final approvalTx = await buildApprovalNeeded(
spender: routerTo,
tokenContract: tokenContract,
requiredAmount: requiredAmount,
sourceTokenDecimals: trade.sourceTokenDecimals,
priority: priority
);
if (approvalTx == null) {
state = FailureState('Failed to build approval transaction');
return null;
}
pendingTransaction = null;
try {
printV(
'[Swaps.xyz sending flow] Submitting approval transaction for token ${trade
.from?.title} ${trade.from?.tag ?? ''} ');
await approvalTx.commit();
// Wait for the approval to be mined on-chain
final isApproved = await _waitForApprovalUpdate(
tokenContract: tokenContract,
spender: routerTo,
requiredAmount: requiredAmount,
);
if (!isApproved) {
state = FailureState(
'Approval transaction failed or timed out on-chain. Try again.');
return null;
}
printV(
'[Swaps.xyz sending flow] Approval transaction confirmed on-chain. Proceeding with swap execution.');
} catch (e, s) {
printV(
'[Swaps.xyz sending flow] Approval transaction error: $e\n$s');
state = FailureState(
translateErrorMessage(e, wallet.type, wallet.currency));
return null;
}
}
// Construct Final Swap Transaction
printV('[Swaps.xyz sending flow] Building swap transaction');
pendingTransaction = await evm!.createRawCallDataTransaction(
wallet,
routerTo,
routerData,
routerValueWei,
priority,
sourceTokenAddress: tokenContract,
sourceTokenAmount: requiredAmount,
useBlinkProtection: canSupportBlinkProtection(selectedChainId)
? _settingsStore.useBlinkProtection
: false,
);
state = ExecutedSuccessfullyState();
return pendingTransaction;
}
state = FailureState('Unsupported Swaps.xyz transaction type');
return null;
} catch (e, s) {
printV('Swaps.xyz transaction error: $e\n$s');
state = FailureState(
'Failed to create Swaps.xyz transaction - ${translateErrorMessage(
e, wallet.type, wallet.currency)}');
return null;
}
}
}
// END Swaps.xyz path
// Regular flow
final isSendAll = outputs.any((output) => output.sendAll);
if (!isSendAll) {
final estimateTxAmountDouble = outputs.fold<double>(0, (acc, output) =>
acc + (double.tryParse(output.cryptoAmount) ?? 0));
if (estimateTxAmountDouble <= 0) throw Exception(
'Amount must be greater than 0');
}
pendingTransaction = await wallet.createTransaction(_credentials(provider));
final txAmountDouble = double.tryParse(pendingTransaction?.amountFormatted ?? '0') ?? 0.0;
final bool isTradeTx = trade != null && provider != null;
if (isTradeTx) {
final tradeAmountDouble = double.tryParse(trade.amount) ?? 0.0;
if (tradeAmountDouble <= 0) throw Exception('Trade amount must be greater than 0');
if (trade.isSendAll == true) {
if (provider is NearIntentsExchangeProvider) {
if (txAmountDouble != tradeAmountDouble) {
throw Exception(
'Transaction amount $txAmountDouble does not match expected trade amount $tradeAmountDouble');
}
}
}
if (provider is ThorChainExchangeProvider) {
final outputCount = pendingTransaction?.outputCount ?? 0;
if (outputCount > 10) {
throw Exception("THORChain does not support more than 10 outputs");
}
if (_hasTaprootInput(pendingTransaction)) {
throw Exception("THORChain does not support Taproot addresses");
}
}
}
if (wallet.type == WalletType.bitcoin) {
final updatedOutputs = bitcoin!.updateOutputs(pendingTransaction!, outputs);
if (outputs.length == updatedOutputs.length) {
outputs.replaceRange(0, outputs.length, updatedOutputs);
}
}
state = ExecutedSuccessfullyState();
return pendingTransaction;
} catch (e) {
_ledgerTxStateTimer?.cancel();
// if (e is LedgerException) {
// final errorCode = e.errorCode.toRadixString(16);
// final fallbackMsg =
// e.message.isNotEmpty ? e.message : "Unexpected Ledger Error Code: $errorCode";
// final errorMsg = ledgerViewModel!.interpretErrorCode(errorCode) ?? fallbackMsg;
//
// state = FailureState(errorMsg);
// } else {
state = FailureState(translateErrorMessage(e, wallet.type, wallet.currency));
// }
}
return null;
}
@action
Future<void> replaceByFee(TransactionInfo tx, String newFee) async {
state = IsExecutingState();
try {
final isSufficient = await bitcoin!.isChangeSufficientForFee(wallet, tx.id, newFee);
if (!isSufficient) {
state = AwaitingConfirmationState(
title: S.current.confirm_fee_deduction,
message: S.current.confirm_fee_deduction_content,
onConfirm: () async => await _executeReplaceByFee(tx, newFee),
onCancel: () => state = FailureState('Insufficient change for fee'));
} else {
await _executeReplaceByFee(tx, newFee);
}
} catch (e) {
state = FailureState(e.toString());
}
}
Future<void> _executeReplaceByFee(TransactionInfo tx, String newFee) async {
clearOutputs();
final output = outputs.first;
output.address = tx.outputAddresses?.first ?? '';
try {
pendingTransaction = await bitcoin!.replaceByFee(wallet, tx.id, newFee);
state = ExecutedSuccessfullyState();
} catch (e) {
state = FailureState(e.toString());
}
}
Future<void> _handleOcpRequest() async {
if (OpenCryptoPayService.requiresClientCommit(selectedCryptoCurrency)) {
await pendingTransaction!.commit();
}
await _ocpService.commitOpenCryptoPayRequest(
pendingTransaction!.hex,
txId: pendingTransaction!.id,
request: ocpRequest!,
asset: selectedCryptoCurrency,
);
}
Future<void> _commitUR(BuildContext context) async {
final urstr = await pendingTransaction!.commitUR();
final result = await Navigator.of(context).pushNamed(Routes.urqrAnimatedPage, arguments: urstr);
if (result == null) {
throw "Canceled by user";
}
}
@action
Future<void> commitTransaction(BuildContext context) async {
if (pendingTransaction == null) {
throw Exception("Pending transaction doesn't exist. It should not be happened.");
}
try {
state = TransactionCommitting();
if (ocpRequest != null) {
await _handleOcpRequest();
} else if (pendingTransaction!.shouldCommitUR()) {
await _commitUR(context);
} else {
await pendingTransaction!.commit();
}
state = TransactionCommitted();
if (_currentTrade != null) {
final provider = _currentTrade!.provider;
if (provider == ExchangeProviderDescription.swapsXyz) {
registerSwapsXyzTransaction(_currentTrade!);
}
}
// Immediate transaction update for EVM chains and Nano
if (isEVMWallet || [WalletType.bitcoin, WalletType.nano].contains(walletType)) {
Future.delayed(Duration(seconds: 4), () async {
try {
await Future.wait([
wallet.updateTransactionsHistory(),
wallet.updateBalance() as Future<void>,
]);
} catch (e) {
printV('Failed to update transactions after send: $e');
}
});
}
if (pendingTransaction!.id.isNotEmpty) {
_addTransactionDescription();
}
final sharedPreferences = await SharedPreferences.getInstance();
await sharedPreferences.setString(PreferencesKey.backgroundSyncLastTrigger(wallet.name),
DateTime.now().add(Duration(minutes: 1)).toIso8601String());
} catch (e) {
state = FailureState(translateErrorMessage(e, wallet.type, wallet.currency));
}
}
@action
Future<void> updateWalletBalance() async => await wallet.updateBalance();
Future<void> _addTransactionDescription() async {
String address = outputs.fold('', (acc, value) {
return value.isParsedAddress
? '$acc${value.address}\n${value.extractedAddress}\n\n'
: '$acc${value.address}\n\n';
});
address = address.trim();
String note = outputs.fold('', (acc, value) => '$acc${value.note}\n');
note = note.trim();
TransactionInfo? tx;
if (walletType == WalletType.monero) {
await Future.delayed(Duration(milliseconds: 450));
await wallet.fetchTransactions();
final txhistory = monero!.getTransactionHistory(wallet);
tx = txhistory.transactions.values.last;
}
final descriptionKey = '${pendingTransaction!.id}_${wallet.walletAddresses.primaryAddress}';
_settingsStore.shouldSaveRecipientAddress
? await transactionDescriptionBox.add(TransactionDescription(
id: descriptionKey,
recipientAddress: address,
transactionNote: note,
transactionKey: tx?.additionalInfo["key"] as String?,
))
: await transactionDescriptionBox.add(TransactionDescription(
id: descriptionKey,
transactionNote: note,
transactionKey: tx?.additionalInfo["key"] as String?,
));
}
Object _credentials([ExchangeProvider? provider]) {
final priority = _settingsStore.getPriority(wallet.type, chainId: wallet.chainId);
if (priority == null &&
![
WalletType.nano,
WalletType.banano,
WalletType.arbitrum,
].contains(wallet.type)) {
throw Exception('Priority is null for wallet type: ${wallet.type}');
}
switch (wallet.type) {
case WalletType.bitcoin:
case WalletType.bitcoinCash:
case WalletType.dogecoin:
return bitcoin!.createBitcoinTransactionCredentials(
outputs,
priority: priority!,
feeRate: feesViewModel.customBitcoinFeeRate,
coinTypeToSpendFrom: coinTypeToSpendFrom,
payjoinUri: _settingsStore.usePayjoin ? payjoinUri : null,
);
case WalletType.litecoin:
return bitcoin!.createBitcoinTransactionCredentials(
outputs,
priority: priority!,
feeRate: feesViewModel.customBitcoinFeeRate,
// if it's an exchange flow then disable sending from mweb coins
coinTypeToSpendFrom: provider != null ? UnspentCoinType.nonMweb : coinTypeToSpendFrom,
);
case WalletType.monero:
return monero!
.createMoneroTransactionCreationCredentials(outputs: outputs, priority: priority!);
case WalletType.wownero:
return wownero!
.createWowneroTransactionCreationCredentials(outputs: outputs, priority: priority!);
case WalletType.ethereum:
case WalletType.polygon:
case WalletType.base:
case WalletType.arbitrum:
case WalletType.bsc:
return evm!.createEVMTransactionCredentials(
outputs,
priority: priority,
currency: selectedCryptoCurrency,
useBlinkProtection: canSupportBlinkProtection(selectedChainId)
? _settingsStore.useBlinkProtection
: false,
);
case WalletType.nano:
return nano!.createNanoTransactionCredentials(outputs);
default:
throw Exception('Unexpected wallet type: ${wallet.type} for send');
}
}
bool _isEqualCurrency(String currency) =>
wallet.balance.keys.any((e) => currency.toLowerCase() == e.title.toLowerCase());
void onClose() => _settingsStore.fiatCurrency = fiatFromSettings;
@action
FiatCurrency setFiatCurrency(FiatCurrency fiat) {
_settingsStore.fiatCurrency = fiat;
return fiat;
}
@action
void setSelectedCryptoCurrency(String cryptoCurrency) {
try {
selectedCryptoCurrency = wallet.balance.keys
.firstWhere((e) => cryptoCurrency.toLowerCase() == e.title.toLowerCase());
} catch (e) {
selectedCryptoCurrency = wallet.currency;
}
}
@computed
bool get hasMemos => false;
final Map<WalletType, int> _maxMemoLengths = {};
@computed
int get maxMemoLength => _maxMemoLengths[wallet.type] ?? 9999999;
ContactRecord? newContactAddress() {
final Set<String> contactAddresses =
Set.from(contactListViewModel.contacts.map((contact) => contact.address))
..addAll(contactListViewModel.walletContacts.map((contact) => contact.address));
for (final output in outputs) {
final address =
output.isParsedAddress ? output.parsedAddress.addresses.first : output.address;
if (address.isNotEmpty &&
!contactAddresses.contains(address) &&
selectedCryptoCurrency.raw != -1) {
return ContactRecord(
contactListViewModel.contactSource,
Contact(
name: '',
address: address,
type: selectedCryptoCurrency,
),
);
}
}
return null;
}
// Helper functions for EVM transaction monitoring and approval flow
// Polls the token contract directly to see if allowance is updated.
Future<bool> _waitForApprovalUpdate({
required String tokenContract,
required String spender,
required BigInt requiredAmount,
bool waitForExactMatch = false,
}) async {
if (!isEVMWallet || evm == null) return false;
int attempts = 0;
const int maxAttempts = 30; // ~60 seconds
printV('[Swaps.xyz sending flow] Starting allowance check. Target: $requiredAmount (Exact match: $waitForExactMatch)');
while (attempts < maxAttempts) {
try {
final currentAllowance = await evm!.getAllowance(wallet, tokenContract, spender);
if (currentAllowance != null) {
printV('[Swaps.xyz sending flow] Current Allowance: $currentAllowance / Target: $requiredAmount');
if (waitForExactMatch) {
// For Reset (Target 0): We need it to be exactly 0 (or less, though it can't be negative)
if (currentAllowance <= requiredAmount) {
printV('[Swaps.xyz sending flow] Allowance reset verified!');
return true;
}
} else {
// For Approval: We need it to be at least the required amount
if (currentAllowance >= requiredAmount) {
printV('[Swaps.xyz sending flow] Allowance verified!');
return true;
}
}
}
} catch (e) {
printV('[Swaps.xyz sending flow] Allowance check error: $e');
}
await Future.delayed(const Duration(seconds: 1));
attempts++;
}
printV('[Swaps.xyz sending flow] Allowance check timed out.');
return false;
}
// Builds a token approval transaction
Future<PendingTransaction?> buildApprovalNeeded({
required String spender,
required String tokenContract,
required BigInt requiredAmount,
required TransactionPriority? priority,
int? sourceTokenDecimals,
}) async {
final erc20Token = wallet.balance.keys.whereType<Erc20Token>().firstWhere(
(t) => t.contractAddress.toLowerCase() == tokenContract.toLowerCase(),
orElse: () => Erc20Token(
name: '',
symbol: '',
contractAddress: tokenContract,
decimal: sourceTokenDecimals ?? 18,
enabled: true,
),
);
return await evm!.createTokenApproval(
wallet,
requiredAmount,
spender,
erc20Token,
priority,
useBlinkProtection:
canSupportBlinkProtection(selectedChainId) ? _settingsStore.useBlinkProtection : false,
);
}
// End EVM helper functions
String translateErrorMessage(
Object error,
WalletType walletType,
CryptoCurrency currency,
) {
String errorMessage = error.toString();
if (isEVMWallet) {
if (errorMessage.contains('gas required exceeds allowance')) {
return S.current.gas_exceeds_allowance;
}
if (errorMessage.contains('insufficient funds')) {
final feeCurrency = switch (walletType) {
WalletType.bsc => "BNB",
WalletType.polygon => "POL",
_ => "ETH",
};
final parsedErrorMessageResult =
EVMTransactionErrorFeesHandler.parseEthereumFeesErrorMessage(
errorMessage,
_fiatConversationStore.prices[currency] ?? 0.0,
);
// Handle generic insufficient funds error (no specific values available)
if (parsedErrorMessageResult.error == 'generic_insufficient_funds') {
return S.current.insufficient_funds_for_tx;
}
// Handle parsing errors (couldn't parse the error message)
if (parsedErrorMessageResult.error != null) {
return S.current.insufficient_funds_for_tx;
}
// Handle successfully parsed errors with specific values
return '''${S.current.insufficient_funds_for_tx} \n\n'''
'''${S.current.balance}: ${parsedErrorMessageResult.balanceEth} ${feeCurrency} (${parsedErrorMessageResult.balanceUsd} ${fiatFromSettings.name})\n\n'''
'''${S.current.transaction_cost}: ${parsedErrorMessageResult.txCostEth} ${feeCurrency} (${parsedErrorMessageResult.txCostUsd} ${fiatFromSettings.name})\n\n'''
'''${S.current.overshot}: ${parsedErrorMessageResult.overshotEth} ${feeCurrency} (${parsedErrorMessageResult.overshotUsd} ${fiatFromSettings.name})''';
}
if (errorMessage.contains('max fee per gas less than block base fee')) {
return S.current.tx_retry_message;
}
return errorMessage;
}
if (error is TransactionWrongBalanceException) {
if (error.amount != null)
return S.current
.tx_wrong_balance_with_amount_exception(currency.toString(), error.amount.toString());
return S.current.tx_wrong_balance_exception(currency.toString());
}
if (error is TransactionNoInputsException) {
return S.current.tx_not_enough_inputs_exception;
}
if (error is TransactionNoFeeException) {
return S.current.tx_zero_fee_exception;
}
if (error is TransactionNoDustException) {
return S.current.tx_no_dust_exception;
}
if (error is TransactionCommitFailedBIP68Final) {
return S.current.trying_to_spend_locked_funds;
}
if (error is TransactionCommitFailed) {
if (error.errorMessage != null && error.errorMessage!.contains("no peers replied")) {
return S.current.tx_commit_failed_no_peers;
}
return "${S.current.tx_commit_failed}\nsupport@such.software${error.errorMessage != null ? "\n\n${error.errorMessage}" : ""}";
}
if (error is TransactionCommitFailedDustChange) {
return S.current.tx_rejected_dust_change;
}
if (error is TransactionCommitFailedDustOutput) {
return S.current.tx_rejected_dust_output;
}
if (error is TransactionCommitFailedDustOutputSendAll) {
return S.current.tx_rejected_dust_output_send_all;
}
if (error is TransactionCommitFailedVoutNegative) {
return S.current.tx_rejected_vout_negative;
}
if (error is TransactionCommitFailedBIP68Final) {
return S.current.tx_rejected_bip68_final;
}
if (error is TransactionCommitFailedLessThanMin) {
return S.current.fee_less_than_min;
}
if (error is TransactionNoDustOnChangeException) {
return S.current.tx_commit_exception_no_dust_on_change(error.min, error.max);
}
if (error is TransactionInputNotSupported) {
return S.current.tx_invalid_input;
}
if(wallet.type == WalletType.bitcoin) {
final lnError = getLightningErrorMessage(error);
if(lnError != null) return lnError;
}
return errorMessage;
}
String? getLightningErrorMessage(Object error) {
// TODO add more patterns
Map<String, String> errorPatterns = {
"insufficient funds": S.current.insufficient_funds_for_tx
};
for(final pattern in errorPatterns.keys) {
if(error.toString().contains(pattern)) return errorPatterns[pattern]!;
}
return null;
}
bool _hasTaprootInput(PendingTransaction? pendingTransaction) {
if (walletType == WalletType.bitcoin && pendingTransaction != null) {
return bitcoin!.hasTaprootInput(pendingTransaction);
}
return false;
}
Future<void> registerSwapsXyzTransaction(Trade trade) async {
try {
// register only for vmId is alt-vm or bridgeId is alt-vm (trade.needToRegisterInSwapXyz)
final needToRegister = trade.needToRegisterInSwapXyz ?? false;
if (!needToRegister) return;
final vmId = (trade.providerId ?? '').toLowerCase();
if (vmId.isEmpty) {
printV('SwapsXyz: transaction register: skipped (vmId empty)');
return;
}
final txHash = pendingTransaction?.evmTxHashFromRawHex
?? pendingTransaction?.id
?? '';
if (txHash.isEmpty) {
printV('SwapsXyz: transaction register: skipped (txHash empty)');
return;
}
final chainId = int.tryParse(trade.router ?? '') ?? 0;
if (chainId <= 0) {
printV('SwapsXyz: transaction register: skipped (invalid chainId)');
return;
}
printV(
'SwapsXyz: attempting to register transaction: tradeId = ${trade.id}, txHash = $txHash, chainId = $chainId, vmId = $vmId');
final registered = await SwapsXyzExchangeProvider.registerAltVmTx(
txId: trade.id,
txHash: txHash,
chainId: chainId,
vmId: vmId,
);
if (!registered) {
printV('SwapsXyz: transaction register: failed');
} else {
printV('SwapsXyz: transaction register: success');
}
} catch (e) {
printV('registerSwapsXyzTransaction error: $e');
}
}
@computed
bool get usePayjoin => _settingsStore.usePayjoin;
@observable
String? payjoinUri;
@action
Future<void> fetchTokenForContractAddress(String contractAddress) async {
final token = await TokenUtilities.findTokenByAddress(
walletType: wallet.type,
address: contractAddress,
);
if (token != null) {
selectedCryptoCurrency = token;
}
}
String _decodeMethodSelector(String s) =>
(s.startsWith('0x') && s.length >= 10) ? s.substring(0, 10) : '';
}