Files
hash-wallet/lib/view_model/send/send_view_model.dart
T
ef9b0fbae6 Ywallet zcash (#2810)
* feat(zcash): initial commit
- initial work on cw_zcash
  - use single .db for everything zcash to speed up sync and scanning
  - implement silent autoshielding of non-orchard funds
  - use FFI for everything to fetch the data
  - add support for passphrase by adding extra word to seed phrase
  - enforce lints on CI for cw_zcash directory
- use correct zcash name, and call transparent zcash tZEC
- minor cleanups outsite of cw_zcash
  - gitmodules, gitignore
  - db debug
  - fix broken addressPageType in WalletInfo
  - enable debug options in kDebugMode and kProfileMode

* feat: T address rotation (and cache) wip: minor fixes

* wip: shield tx list

* fix fiat amounts fix derrivation of rotation account addresses screen
for zcash block height fix T address shields improvements Amount in
disposable T shields UI improvements
show pending outgoing tx in tx history

* fix null check on passphrase page fix tx showing as pending rename auto
shield txs fixed send all to include fee use rotating address in
addressForExchange wrap all calls of WarpApi.getLatestHeight in try
catch to prevent leaking errors

other minor fixes from notion

* wallet group debug cache sent tx address wallet T address rotation fixes
automatically pick the fee for auto-shield

* fix T address cache deserialization fix autoshield icon fix autoshield
text getCachedDestinationAddress fix send address cache

* address comments from review

* wip: iOS fix

* fix swap

* Allow cakepay payment from zcash wallet

* fix: ios (I'm stupid) fix: reorder addresses to make orchard default
fix: send all balance

* 5.6.6 bump fix: ios apple review add transport error to ignored
exceptions

* fix: leaving dust behind when sweeping all

* Change wallets order

* fix: reflect balance of pending out txs fix: restore height support

* linux support (disabled)

* fix: date picker for zcash on restore screen fix: build script for linux
match WarpApi.warpSync args with upstream

* fix: linter script

* ensure all exchange providers support ZEC (#2802)

* ensure all exchange providers support ZEC

* remove zaddr and tzec currencies and refactor providers

* Update lib/exchange/provider/near_Intents_exchange_provider.dart

* add back old zcash currencies for backward compatibility, thanks Czarek

* minor fix [skip ci]

---------

Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>

* Update android/app/src/main/AndroidManifestBase.xml [skip ci]

* Update ios/Runner/InfoBase.plist [skip ci]

* fix: remove unused code fix: macos fix: disable on linux and macos

* update build numbers [skip ci]

* Add zcash to birdpay

---------

Co-authored-by: Czarek Nakamoto <cyjan@mrcyjanek.net>
Co-authored-by: Serhii <borodenko.sv@gmail.com>
2026-01-16 03:16:41 +01:00

1176 lines
40 KiB
Dart

import 'dart:async';
import 'package:cake_wallet/bitcoin/bitcoin.dart';
import 'package:cake_wallet/core/address_validator.dart';
import 'package:cake_wallet/core/amount_validator.dart';
import 'package:cake_wallet/core/execution_state.dart';
import 'package:cake_wallet/core/open_crypto_pay/models.dart';
import 'package:cake_wallet/core/open_crypto_pay/open_cryptopay_service.dart';
import 'package:cake_wallet/core/validator.dart';
import 'package:cake_wallet/core/wallet_change_listener_view_model.dart';
import 'package:cake_wallet/decred/decred.dart';
import 'package:cake_wallet/entities/calculate_fiat_amount.dart';
import 'package:cake_wallet/entities/contact.dart';
import 'package:cake_wallet/entities/contact_record.dart';
import 'package:cake_wallet/entities/evm_transaction_error_fees_handler.dart';
import 'package:cake_wallet/entities/fiat_currency.dart';
import 'package:cake_wallet/entities/parsed_address.dart';
import 'package:cake_wallet/entities/preferences_key.dart';
import 'package:cake_wallet/entities/template.dart';
import 'package:cake_wallet/entities/transaction_description.dart';
import 'package:cake_wallet/entities/wallet_contact.dart';
import 'package:cake_wallet/evm/evm.dart';
import 'package:cake_wallet/exchange/provider/exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/swapsxyz_exchange_provider.dart';
import 'package:cake_wallet/exchange/provider/thorchain_exchange.provider.dart';
import 'package:cake_wallet/exchange/trade.dart';
import 'package:cake_wallet/generated/i18n.dart';
import 'package:cake_wallet/monero/monero.dart';
import 'package:cake_wallet/nano/nano.dart';
import 'package:cake_wallet/reactions/wallet_connect.dart';
import 'package:cake_wallet/routes.dart';
import 'package:cake_wallet/solana/solana.dart';
import 'package:cake_wallet/store/app_store.dart';
import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart';
import 'package:cake_wallet/store/settings_store.dart';
import 'package:cake_wallet/tron/tron.dart';
import 'package:cake_wallet/utils/payment_request.dart';
import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart';
import 'package:cake_wallet/view_model/dashboard/balance_view_model.dart';
import 'package:cake_wallet/view_model/hardware_wallet/hardware_wallet_view_model.dart';
import 'package:cake_wallet/view_model/send/fees_view_model.dart';
import 'package:cake_wallet/view_model/send/output.dart';
import 'package:cake_wallet/view_model/send/send_template_view_model.dart';
import 'package:cake_wallet/view_model/send/send_view_model_state.dart';
import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_list_view_model.dart';
import 'package:cake_wallet/wownero/wownero.dart';
import 'package:cake_wallet/zano/zano.dart';
import 'package:cake_wallet/zcash/zcash.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/erc20_token.dart';
import 'package:cw_core/exceptions.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/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:cake_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 = wallet.currency;
hasMultipleTokens = isEVMWallet ||
wallet.type == WalletType.solana ||
wallet.type == WalletType.tron ||
wallet.type == WalletType.zano;
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(
AppStore 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 = appStore.wallet!.currency,
hasMultipleTokens = isEVMCompatibleChain(appStore.wallet!.type) ||
appStore.wallet!.type == WalletType.solana ||
appStore.wallet!.type == WalletType.tron ||
appStore.wallet!.type == WalletType.zano,
selectedChainId = appStore.wallet!.chainId,
outputs = ObservableList<Output>(),
_settingsStore = appStore.settingsStore,
fiatFromSettings = appStore.settingsStore.fiatCurrency,
super(appStore: appStore) {
outputs
.add(Output(wallet, _settingsStore, _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();
});
}
PendingTransaction? _pendingApprovalTx;
bool _isSwapsXYZCallDataTx = false;
@observable
ExecutionState state;
ObservableList<Output> outputs;
@observable
UnspentCoinType coinTypeToSpendFrom;
bool get showAddressBookPopup => _settingsStore.showAddressBookPopupEnabled;
bool get isMwebEnabled => balanceViewModel.mwebEnabled;
bool get isEVMWallet => isEVMCompatibleChain(walletType);
@action
void setShowAddressBookPopup(bool value) {
_settingsStore.showAddressBookPopupEnabled = value;
}
@action
void addOutput() {
outputs
.add(Output(wallet, _settingsStore, _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.solana) return false;
// 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 fiat = calculateFiatAmount(
price: _fiatConversationStore.prices[_fiatConversationStore.prices.keys
.firstWhere((k) => k.titleAndTagEqual(selectedCryptoCurrency))],
cryptoAmount: pendingTransaction!.amountFormatted);
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.tron:
case WalletType.solana:
return wallet.currency;
default:
return selectedCryptoCurrency;
}
}
FiatCurrency get fiat => _settingsStore.fiatCurrency;
CryptoCurrency get currency => wallet.currency;
Validator<String> amountValidator(Output output) => AmountValidator(
currency: wallet.currency,
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 wallet.formatCryptoAmount('0');
}
return balanceForCurrency.formattedFullAvailableBalance;
}
@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, decred wallets atm:
switch (wallet.type) {
case WalletType.bitcoin:
case WalletType.litecoin:
case WalletType.bitcoinCash:
case WalletType.dogecoin:
case WalletType.monero:
case WalletType.wownero:
case WalletType.decred:
return wallet.formatCryptoAmount(
(await unspentCoinsListViewModel.getSendingBalance(coinTypeToSpendFrom)).toString());
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.decred,
WalletType.bitcoinCash,
WalletType.dogecoin
].contains(wallet.type);
@computed
bool get isElectrumWallet => [
WalletType.bitcoin,
WalletType.litecoin,
WalletType.bitcoinCash,
WalletType.dogecoin
].contains(wallet.type);
@observable
CryptoCurrency selectedCryptoCurrency;
List<CryptoCurrency> currencies;
bool get hasYat => outputs
.any((out) => out.isParsedAddress && out.parsedAddress.parseFrom == ParseFrom.yatRecord);
WalletType get walletType => wallet.type;
String? get walletCurrencyName => wallet.currency.fullName?.toLowerCase() ?? wallet.currency.name;
@computed
FiatCurrency get fiatCurrency => _settingsStore.fiatCurrency;
final SettingsStore _settingsStore;
final SendTemplateViewModel sendTemplateViewModel;
final BalanceViewModel balanceViewModel;
final ContactListViewModel contactListViewModel;
final HardwareWalletViewModel? hardwareWalletViewModel;
final FeesViewModel feesViewModel;
final FiatConversionStore _fiatConversationStore;
final Box<TransactionDescription> transactionDescriptionBox;
@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) {
final contactList = contactsToShow.where((element) => element.address == address).toList();
return contactList.isNotEmpty;
}
@action
bool checkIfWalletIsAnInternalWallet(String address) {
final walletContactList =
walletContactsToShow.where((element) => element.address == address).toList();
return walletContactList.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<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();
} catch (e) {
printV(e);
state = FailureState(translateErrorMessage(e, walletType, currency));
return null;
}
}
Timer? _ledgerTxStateTimer;
@action
Future<PendingTransaction?> createTransaction({ExchangeProvider? provider, Trade? trade}) async {
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;
final routerValueWei =
BigInt.tryParse((trade.routerValue ?? '0').toString()) ?? BigInt.zero;
if (routerTo?.isNotEmpty == true && routerData?.isNotEmpty == true) {
// detect prepared ERC-20 transfer(...) (alt-vm deposit pattern)
String _selector(String s) =>
(s.startsWith('0x') && s.length >= 10) ? s.substring(0, 10) : '';
const _transferSig = '0xa9059cbb';
final _sel = _selector(routerData!);
final _isPreparedTransfer = _sel == _transferSig &&
(trade.sourceTokenAddress ?? '').toLowerCase() == (routerTo ?? '').toLowerCase();
_pendingApprovalTx = null;
// Optionally prebuild approval (SKIP for prepared transfer)
final tokenContract = trade.sourceTokenAddress ?? '';
final requiredAmount = BigInt.tryParse(
(trade.sourceTokenAmountRaw ?? '0').replaceAll('n', ''),
) ??
BigInt.zero;
// Only do approval when NOT a prepared transfer, and only if the API hinted we might need it
final requiresTokenApproval =
(trade.requiresTokenApproval ?? false) && !_isPreparedTransfer;
if (requiresTokenApproval && tokenContract.isNotEmpty && requiredAmount > BigInt.zero) {
if (isEVMWallet) {
final priority = _settingsStore.getPriority(walletType, chainId: selectedChainId);
_pendingApprovalTx = await buildApprovalIfNeeded(
spender: routerTo!,
tokenContract: tokenContract,
requiredAmount: requiredAmount,
sourceTokenDecimals: trade.sourceTokenDecimals,
);
// Build the callData tx
pendingTransaction = await evm!.createRawCallDataTransaction(
wallet,
routerTo,
routerData,
routerValueWei,
priority,
useBlinkProtection: canSupportBlinkProtection(selectedChainId)
? _settingsStore.useBlinkProtection
: false,
);
_isSwapsXYZCallDataTx = true;
state = ExecutedSuccessfullyState();
return pendingTransaction; // do NOT fall back to regular flow
}
}
// No approval needed (or prepared transfer): send exactly what backend prepared
if (isEVMWallet) {
final priority = _settingsStore.getPriority(walletType, chainId: selectedChainId);
pendingTransaction = await evm!.createRawCallDataTransaction(
wallet,
routerTo!,
routerData,
routerValueWei,
priority,
useBlinkProtection: canSupportBlinkProtection(selectedChainId)
? _settingsStore.useBlinkProtection
: false,
);
_isSwapsXYZCallDataTx = true;
state = ExecutedSuccessfullyState();
return pendingTransaction;
}
}
}
// Regular flow
pendingTransaction = await wallet.createTransaction(_credentials(provider));
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> _commitApprovalTransaction() async {
if (_pendingApprovalTx != null) {
await _pendingApprovalTx!.commit();
_pendingApprovalTx = null;
// Small pause to ensure allowance is indexed
await Future.delayed(const Duration(milliseconds: 300));
}
await pendingTransaction!.commit();
_isSwapsXYZCallDataTx = false;
}
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 (_isSwapsXYZCallDataTx) {
// Swaps.xyz approval (if any), then commit the prebuilt router tx
await _commitApprovalTransaction();
} else if (pendingTransaction!.shouldCommitUR()) {
await _commitUR(context);
} else {
await pendingTransaction!.commit();
}
state = TransactionCommitted();
// Immediate transaction update for EVM chains, Solana, Tron, and Nano
if (isEVMWallet ||
[WalletType.solana, WalletType.tron, WalletType.nano].contains(walletType)) {
Future.delayed(Duration(seconds: 4), () async {
try {
await wallet.updateTransactionsHistory();
} 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));
}
}
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 &&
wallet.type != WalletType.nano &&
wallet.type != WalletType.banano &&
wallet.type != WalletType.solana &&
wallet.type != WalletType.tron &&
wallet.type != WalletType.arbitrum &&
wallet.type != WalletType.zcash) {
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:
return evm!.createEVMTransactionCredentials(
outputs,
priority: priority,
currency: selectedCryptoCurrency,
useBlinkProtection: canSupportBlinkProtection(selectedChainId)
? _settingsStore.useBlinkProtection
: false,
);
case WalletType.nano:
return nano!.createNanoTransactionCredentials(outputs);
case WalletType.solana:
return solana!
.createSolanaTransactionCredentials(outputs, currency: selectedCryptoCurrency);
case WalletType.tron:
return tron!.createTronTransactionCredentials(outputs, currency: selectedCryptoCurrency);
case WalletType.zano:
return zano!.createZanoTransactionCredentials(
outputs: outputs, priority: priority!, currency: selectedCryptoCurrency);
case WalletType.decred:
this.coinTypeToSpendFrom = UnspentCoinType.any;
return decred!.createDecredTransactionCredentials(outputs, priority!);
case WalletType.zcash:
return zcash!.createZcashTransactionCredentials(
outputs,
currency: selectedCryptoCurrency,
// priority: priority,
);
default:
throw Exception('Unexpected wallet type: ${wallet.type}');
}
}
bool _isEqualCurrency(String currency) =>
wallet.balance.keys.any((e) => currency.toLowerCase() == e.title.toLowerCase());
void onClose() => _settingsStore.fiatCurrency = fiatFromSettings;
@action
void setFiatCurrency(FiatCurrency fiat) => _settingsStore.fiatCurrency = fiat;
@action
void setSelectedCryptoCurrency(String cryptoCurrency) {
try {
selectedCryptoCurrency = wallet.balance.keys
.firstWhere((e) => cryptoCurrency.toLowerCase() == e.title.toLowerCase());
} catch (e) {
selectedCryptoCurrency = wallet.currency;
}
}
ContactRecord? newContactAddress() {
final Set<String> contactAddresses =
Set.from(contactListViewModel.contacts.map((contact) => contact.address))
..addAll(contactListViewModel.walletContacts.map((contact) => contact.address));
for (var output in outputs) {
String address;
if (output.isParsedAddress) {
address = output.parsedAddress.addresses.first;
} else {
address = output.address;
}
if (address.isNotEmpty &&
!contactAddresses.contains(address) &&
selectedCryptoCurrency.raw != -1) {
return ContactRecord(
contactListViewModel.contactSource,
Contact(
name: '',
address: address,
type: selectedCryptoCurrency,
));
}
}
return null;
}
String translateErrorMessage(
Object error,
WalletType walletType,
CryptoCurrency currency,
) {
String errorMessage = error.toString();
if (walletType == WalletType.solana) {
if (errorMessage.contains('insufficient lamports')) {
double solValueNeeded = 0.0;
// Regular expression to match the number after "need". This shows the exact lamports the user needs to perform the transaction.
RegExp regExp = RegExp(r'need (\d+)');
// Find the match
Match? match = regExp.firstMatch(errorMessage);
if (match != null) {
String neededAmount = match.group(1)!;
final lamportsNeeded = int.tryParse(neededAmount);
// 5000 lamport used here is the constant for sending a transaction on solana
int lamportsPerSol = 1000000000;
solValueNeeded =
lamportsNeeded != null ? ((lamportsNeeded + 5000) / lamportsPerSol) : 0.0;
return S.current.insufficient_lamports(solValueNeeded.toString());
} else {
return S.current.insufficient_lamport_for_tx;
}
}
if (error is SignNativeTokenTransactionRentException) {
return S.current.solana_sign_native_transaction_rent_exception;
}
if (error is CreateAssociatedTokenAccountException) {
return "${S.current.solana_create_associated_token_account_exception} ${S.current.added_message_for_ata_error}";
}
if (error is SignSPLTokenTransactionRentException) {
return S.current.solana_sign_spl_token_transaction_rent_exception;
}
if (error is NoAssociatedTokenAccountException) {
return S.current.solana_no_associated_token_account_exception;
}
if (errorMessage.contains('found no record of a prior credit')) {
return S.current.insufficient_funds_for_tx;
}
if (errorMessage.contains('insufficient funds for rent') &&
errorMessage.contains('Transaction simulation failed') &&
errorMessage.contains('account_index')) {
final accountIndexMatch = RegExp(r'account_index: (\d+)').firstMatch(errorMessage);
if (accountIndexMatch != null) {
return int.parse(accountIndexMatch.group(1)!) == 0
? S.current.insufficientFundsForRentError
: S.current.insufficientFundsForRentErrorReceiver;
}
}
if (errorMessage.contains('invalid account data')) {
return S.current.solana_invalid_data_message;
}
if (errorMessage.contains('Blockhash not found') ||
errorMessage.contains('BlockhashNotFound') ||
errorMessage.contains('BlockhashMNotFound')) {
return 'Transaction failed because its recent blockhash expired. '
'Please retry your send; if this keeps happening, try again in '
'a few seconds or switch to a different Solana node.';
}
return errorMessage;
}
if (isEVMWallet || walletType == WalletType.haven) {
if (errorMessage.contains('gas required exceeds allowance')) {
return S.current.gas_exceeds_allowance;
}
if (errorMessage.contains('insufficient funds')) {
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} ${walletType == WalletType.polygon ? "POL" : "ETH"} (${parsedErrorMessageResult.balanceUsd} ${fiatFromSettings.name})\n\n'''
'''${S.current.transaction_cost}: ${parsedErrorMessageResult.txCostEth} ${walletType == WalletType.polygon ? "POL" : "ETH"} (${parsedErrorMessageResult.txCostUsd} ${fiatFromSettings.name})\n\n'''
'''${S.current.overshot}: ${parsedErrorMessageResult.overshotEth} ${walletType == WalletType.polygon ? "POL" : "ETH"} (${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 (walletType == WalletType.tron) {
if (errorMessage.contains('balance is not sufficient')) {
return S.current.do_not_have_enough_gas_asset(currency.toString());
}
if (errorMessage.contains('Transaction expired')) {
return S.current.tx_retry_message;
}
}
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 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@cakewallet.com${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;
}
return errorMessage;
}
bool _hasTaprootInput(PendingTransaction? pendingTransaction) {
if (walletType == WalletType.bitcoin && pendingTransaction != null) {
return bitcoin!.hasTaprootInput(pendingTransaction);
}
return false;
}
Future<PendingTransaction?> buildApprovalIfNeeded({
required String spender,
required String tokenContract,
required BigInt requiredAmount,
int? sourceTokenDecimals,
}) async {
// Only EVM chains support ERC20 approvals
if (!isEVMWallet) return null;
const zero = '0x0000000000000000000000000000000000000000';
const evmNative = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE';
final tokenLc = tokenContract.toLowerCase();
if (tokenLc == zero || tokenLc == evmNative.toLowerCase()) return null;
if (requiredAmount <= BigInt.zero) return null;
bool needsApproval = false;
if (isEVMWallet) {
needsApproval = await evm!.isApprovalRequired(
wallet,
tokenContract,
spender,
requiredAmount,
);
}
if (!needsApproval) return null;
final erc20Token = wallet.balance.keys.whereType<Erc20Token>().firstWhere(
(t) => t.contractAddress.toLowerCase() == tokenLc,
orElse: () => Erc20Token(
name: '',
symbol: '',
contractAddress: tokenContract,
decimal: sourceTokenDecimals ?? 18,
enabled: true,
),
);
if (isEVMWallet) {
final priority = _settingsStore.getPriority(walletType, chainId: selectedChainId);
return await evm!.createTokenApproval(
wallet,
requiredAmount,
spender,
erc20Token,
priority,
useBlinkProtection:
canSupportBlinkProtection(selectedChainId) ? _settingsStore.useBlinkProtection : false,
);
}
return null;
}
@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;
}
}
}