| 1 | import 'dart:async'; |
| 2 | |
| 3 | import 'package:cake_wallet/bitcoin/bitcoin.dart'; |
| 4 | import 'package:cake_wallet/core/address_resolver/parsed_address.dart'; |
| 5 | import 'package:cake_wallet/core/address_resolver/address_resolver_service.dart'; |
| 6 | import 'package:cake_wallet/core/address_validator.dart'; |
| 7 | import 'package:cake_wallet/core/amount_parsing_proxy.dart'; |
| 8 | import 'package:cake_wallet/core/amount_validator.dart'; |
| 9 | import 'package:cake_wallet/core/execution_state.dart'; |
| 10 | import 'package:cake_wallet/core/open_crypto_pay/exceptions.dart'; |
| 11 | import 'package:cake_wallet/core/open_crypto_pay/models.dart'; |
| 12 | import 'package:cake_wallet/core/open_crypto_pay/open_cryptopay_service.dart'; |
| 13 | import 'package:cake_wallet/core/utilities.dart'; |
| 14 | import 'package:cake_wallet/core/validator.dart'; |
| 15 | import 'package:cake_wallet/core/wallet_change_listener_view_model.dart'; |
| 16 | import 'package:cake_wallet/decred/decred.dart'; |
| 17 | import 'package:cake_wallet/entities/calculate_fiat_amount.dart'; |
| 18 | import 'package:cake_wallet/entities/contact.dart'; |
| 19 | import 'package:cake_wallet/entities/contact_record.dart'; |
| 20 | import 'package:cake_wallet/entities/evm_transaction_error_fees_handler.dart'; |
| 21 | import 'package:cake_wallet/entities/fiat_currency.dart'; |
| 22 | import 'package:cake_wallet/entities/preferences_key.dart'; |
| 23 | import 'package:cake_wallet/entities/template.dart'; |
| 24 | import 'package:cake_wallet/entities/transaction_description.dart'; |
| 25 | import 'package:cake_wallet/entities/wallet_contact.dart'; |
| 26 | import 'package:cake_wallet/evm/evm.dart'; |
| 27 | import 'package:cake_wallet/exchange/exchange_provider_description.dart'; |
| 28 | import 'package:cake_wallet/exchange/provider/exchange_provider.dart'; |
| 29 | import 'package:cake_wallet/exchange/provider/jupiter_exchange_provider.dart'; |
| 30 | import 'package:cake_wallet/exchange/provider/near_Intents_exchange_provider.dart'; |
| 31 | import 'package:cake_wallet/solana/solana.dart'; |
| 32 | import 'package:cake_wallet/exchange/provider/swapsxyz_exchange_provider.dart'; |
| 33 | import 'package:cake_wallet/exchange/provider/thorchain_exchange.provider.dart'; |
| 34 | import 'package:cake_wallet/exchange/trade.dart'; |
| 35 | import 'package:cake_wallet/exchange/trade_state.dart'; |
| 36 | import 'package:cake_wallet/generated/i18n.dart'; |
| 37 | import 'package:cake_wallet/monero/monero.dart'; |
| 38 | import 'package:cake_wallet/nano/nano.dart'; |
| 39 | import 'package:cake_wallet/reactions/wallet_connect.dart'; |
| 40 | import 'package:cake_wallet/routes.dart'; |
| 41 | import 'package:cake_wallet/store/app_store.dart'; |
| 42 | import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart'; |
| 43 | import 'package:cake_wallet/store/settings_store.dart'; |
| 44 | import 'package:cake_wallet/tron/tron.dart'; |
| 45 | import 'package:cake_wallet/utils/payment_request.dart'; |
| 46 | import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart'; |
| 47 | import 'package:cake_wallet/view_model/dashboard/balance_view_model.dart'; |
| 48 | import 'package:cake_wallet/view_model/hardware_wallet/hardware_wallet_view_model.dart'; |
| 49 | import 'package:cake_wallet/view_model/send/fees_view_model.dart'; |
| 50 | import 'package:cake_wallet/view_model/send/output.dart'; |
| 51 | import 'package:cake_wallet/view_model/send/send_template_view_model.dart'; |
| 52 | import 'package:cake_wallet/view_model/send/send_view_model_state.dart'; |
| 53 | import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_list_view_model.dart'; |
| 54 | import 'package:cake_wallet/wownero/wownero.dart'; |
| 55 | import 'package:cake_wallet/zano/zano.dart'; |
| 56 | import 'package:cake_wallet/zcash/zcash.dart'; |
| 57 | import 'package:cw_core/amount/money.dart'; |
| 58 | import 'package:cw_core/crypto_currency.dart'; |
| 59 | import 'package:cw_core/currency_for_wallet_type.dart'; |
| 60 | import 'package:cw_core/erc20_token.dart'; |
| 61 | import 'package:cw_core/exceptions.dart'; |
| 62 | import 'package:cw_core/lnurl.dart'; |
| 63 | import 'package:cw_core/pending_transaction.dart'; |
| 64 | import 'package:cw_core/sync_status.dart'; |
| 65 | import 'package:cw_core/transaction_direction.dart'; |
| 66 | import 'package:cw_core/transaction_info.dart'; |
| 67 | import 'package:cw_core/transaction_priority.dart'; |
| 68 | import 'package:cw_core/unspent_coin_type.dart'; |
| 69 | import 'package:cw_core/utils/print_verbose.dart'; |
| 70 | import 'package:cw_core/wallet_type.dart'; |
| 71 | import 'package:flutter/material.dart'; |
| 72 | import 'package:hive/hive.dart'; |
| 73 | import 'package:mobx/mobx.dart'; |
| 74 | import 'package:cake_wallet/utils/token_utilities.dart'; |
| 75 | import 'package:shared_preferences/shared_preferences.dart'; |
| 76 | |
| 77 | part 'send_view_model.g.dart'; |
| 78 | |
| 79 | class SendViewModel = SendViewModelBase with _$SendViewModel; |
| 80 | |
| 81 | abstract class SendViewModelBase extends WalletChangeListenerViewModel with Store { |
| 82 | @override |
| 83 | void onWalletChange(wallet) { |
| 84 | currencies = wallet.balance.keys.toList(); |
| 85 | selectedCryptoCurrency = |
| 86 | coinTypeToSpendFrom == UnspentCoinType.lightning ? CryptoCurrency.btcln : wallet.currency; |
| 87 | hasMultipleTokens = |
| 88 | isEVMWallet || [WalletType.solana, WalletType.tron, WalletType.zano].contains(wallet.type); |
| 89 | |
| 90 | for (final output in outputs) { |
| 91 | output.updateWallet(wallet); |
| 92 | } |
| 93 | |
| 94 | // Update unspent coins list view model with the new wallet reference |
| 95 | unspentCoinsListViewModel.updateWallet(wallet); |
| 96 | |
| 97 | // Update sending balance to reflect the new wallet's balance |
| 98 | updateSendingBalance(); |
| 99 | } |
| 100 | |
| 101 | UnspentCoinsListViewModel unspentCoinsListViewModel; |
| 102 | |
| 103 | SendViewModelBase( |
| 104 | this._appStore, |
| 105 | this.sendTemplateViewModel, |
| 106 | this._fiatConversationStore, |
| 107 | this._adrResService, |
| 108 | this.balanceViewModel, |
| 109 | this.contactListViewModel, |
| 110 | this.transactionDescriptionBox, |
| 111 | this.hardwareWalletViewModel, |
| 112 | this.unspentCoinsListViewModel, |
| 113 | this.feesViewModel, { |
| 114 | this.coinTypeToSpendFrom = UnspentCoinType.nonMweb, |
| 115 | }) : state = InitialExecutionState(), |
| 116 | currencies = _appStore.wallet!.balance.keys.toList(), |
| 117 | selectedCryptoCurrency = coinTypeToSpendFrom == UnspentCoinType.lightning |
| 118 | ? CryptoCurrency.btcln |
| 119 | : _appStore.wallet!.currency, |
| 120 | hasMultipleTokens = isEVMCompatibleChain(_appStore.wallet!.type) || |
| 121 | [WalletType.solana, WalletType.tron, WalletType.zano].contains(_appStore.wallet!.type), |
| 122 | selectedChainId = _appStore.wallet!.chainId, |
| 123 | outputs = ObservableList<Output>(), |
| 124 | fiatFromSettings = _appStore.settingsStore.fiatCurrency, |
| 125 | fiatCurrencies = FiatCurrency.all, |
| 126 | super(appStore: _appStore) { |
| 127 | outputs.add(Output(wallet, _appStore, _fiatConversationStore, _outputCryptoCurrencyHandler)); |
| 128 | |
| 129 | unspentCoinsListViewModel.initialSetup(); |
| 130 | // .then((_) => unspentCoinsListViewModel.resetUnspentCoinsInfoSelections()); |
| 131 | |
| 132 | reaction((_) { |
| 133 | if (isEVMCompatibleChain(wallet.type)) { |
| 134 | // Access currency which depends on selectedChainId, so MobX tracks the change |
| 135 | return wallet.currency; |
| 136 | } |
| 137 | return null; |
| 138 | }, (_) async { |
| 139 | // When chain changes, update currencies and selected currency |
| 140 | final selectionAtChainChange = selectedCryptoCurrency; |
| 141 | await Future.delayed(const Duration(milliseconds: 100)); |
| 142 | currencies = wallet.balance.keys.toList(); |
| 143 | if (selectedCryptoCurrency == selectionAtChainChange) { |
| 144 | selectedCryptoCurrency = wallet.currency; |
| 145 | } |
| 146 | updateSendingBalance(); |
| 147 | }); |
| 148 | } |
| 149 | |
| 150 | // Store trade and provider references for post-commit updates (e.g., Jupiter trade ID update) |
| 151 | Trade? _currentTrade; |
| 152 | ExchangeProvider? _currentProvider; |
| 153 | |
| 154 | @observable |
| 155 | ExecutionState state; |
| 156 | |
| 157 | ObservableList<Output> outputs; |
| 158 | |
| 159 | @observable |
| 160 | UnspentCoinType coinTypeToSpendFrom; |
| 161 | |
| 162 | bool get showAddressBookPopup => _settingsStore.showAddressBookPopupEnabled; |
| 163 | |
| 164 | bool get isMwebEnabled => balanceViewModel.mwebEnabled; |
| 165 | |
| 166 | bool get isMwebAvailable => wallet.currency == CryptoCurrency.ltc && balanceViewModel.mwebEnabled; |
| 167 | |
| 168 | bool get isEVMWallet => isEVMCompatibleChain(walletType); |
| 169 | |
| 170 | CryptoCurrency _outputCryptoCurrencyHandler([CryptoCurrency? override]) { |
| 171 | if (override != null && override != selectedCryptoCurrency) { |
| 172 | runInAction(() => selectedCryptoCurrency = override); |
| 173 | } |
| 174 | |
| 175 | return selectedCryptoCurrency; |
| 176 | } |
| 177 | |
| 178 | @action |
| 179 | void setShowAddressBookPopup(bool value) => _settingsStore.showAddressBookPopupEnabled = value; |
| 180 | |
| 181 | @action |
| 182 | void addOutput() => |
| 183 | outputs.add(Output(wallet, _appStore, _fiatConversationStore, _outputCryptoCurrencyHandler)); |
| 184 | |
| 185 | @action |
| 186 | void removeOutput(Output output) { |
| 187 | if (isBatchSending) outputs.remove(output); |
| 188 | } |
| 189 | |
| 190 | Future<ParsedAddress?> resolveAddressForOutput(Output output) async { |
| 191 | final query = output.address.trim(); |
| 192 | if (query.isEmpty) return null; |
| 193 | |
| 194 | // Check if the address is valid for the current currency (except for Zano, which can use handles as addresses) |
| 195 | if (selectedCryptoCurrency != CryptoCurrency.zano) { |
| 196 | final isValidAddress = AddressValidator( |
| 197 | type: selectedCryptoCurrency, |
| 198 | network: wallet.walletInfo.network, |
| 199 | ).isValid(query); |
| 200 | if (isValidAddress) return null; |
| 201 | } |
| 202 | |
| 203 | final results = await _adrResService.resolve( |
| 204 | query: query, |
| 205 | wallet: wallet, |
| 206 | currency: selectedCryptoCurrency, |
| 207 | ); |
| 208 | |
| 209 | return results.isEmpty ? null : results.first; |
| 210 | } |
| 211 | |
| 212 | @action |
| 213 | void clearOutputs() { |
| 214 | outputs.clear(); |
| 215 | addOutput(); |
| 216 | } |
| 217 | |
| 218 | @action |
| 219 | void setAllowMwebCoins(bool allow) { |
| 220 | if (wallet.type == WalletType.litecoin) { |
| 221 | coinTypeToSpendFrom = allow ? UnspentCoinType.any : UnspentCoinType.nonMweb; |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | @computed |
| 226 | bool get isBatchSending => outputs.length > 1; |
| 227 | |
| 228 | bool get shouldDisplaySendALL { |
| 229 | if (walletType == WalletType.solana) return false; |
| 230 | |
| 231 | // if (walletType == WalletType.ethereum && selectedCryptoCurrency == CryptoCurrency.eth) |
| 232 | // return false; |
| 233 | |
| 234 | // if (walletType == WalletType.polygon && selectedCryptoCurrency == CryptoCurrency.maticpoly) |
| 235 | // return false; |
| 236 | |
| 237 | return true; |
| 238 | } |
| 239 | |
| 240 | @computed |
| 241 | String get pendingTransactionFiatAmount { |
| 242 | if (pendingTransaction == null) return '0.00'; |
| 243 | |
| 244 | try { |
| 245 | final selectedCurrency = selectedCryptoCurrency == CryptoCurrency.btcln |
| 246 | ? CryptoCurrency.btc |
| 247 | : selectedCryptoCurrency; |
| 248 | var currency = _fiatConversationStore.prices.keys |
| 249 | .firstWhere((k) => k.titleAndTagEqual(selectedCurrency)); |
| 250 | |
| 251 | final fiat = calculateFiatAmount( |
| 252 | price: _fiatConversationStore.prices[currency], |
| 253 | cryptoAmount: pendingTransaction!.amountFormatted); |
| 254 | return fiat; |
| 255 | } catch (_) { |
| 256 | return '0.00'; |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | String calculateTransactionFiatAmount(String amountValue) { |
| 261 | try { |
| 262 | final fiat = calculateFiatAmount( |
| 263 | price: _fiatConversationStore.prices[_fiatConversationStore.prices.keys |
| 264 | .firstWhere((k) => k.titleAndTagEqual(selectedCryptoCurrency))], |
| 265 | cryptoAmount: amountValue); |
| 266 | return fiat; |
| 267 | } catch (_) { |
| 268 | return '0.00'; |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | @computed |
| 273 | String get pendingTransactionFeeFiatAmount { |
| 274 | try { |
| 275 | if (pendingTransaction != null) { |
| 276 | final currency = pendingTransactionFeeCurrency(walletType); |
| 277 | final fiat = calculateFiatAmount( |
| 278 | price: _fiatConversationStore.prices[currency]!, |
| 279 | cryptoAmount: pendingTransaction!.feeFormattedValue, |
| 280 | ); |
| 281 | return fiat; |
| 282 | } else { |
| 283 | return '0.00'; |
| 284 | } |
| 285 | } catch (_) { |
| 286 | return '0.00'; |
| 287 | } |
| 288 | } |
| 289 | |
| 290 | CryptoCurrency pendingTransactionFeeCurrency(WalletType type) { |
| 291 | switch (type) { |
| 292 | case WalletType.ethereum: |
| 293 | case WalletType.polygon: |
| 294 | case WalletType.base: |
| 295 | case WalletType.arbitrum: |
| 296 | case WalletType.bsc: |
| 297 | case WalletType.tron: |
| 298 | case WalletType.solana: |
| 299 | case WalletType.bitcoin: |
| 300 | return wallet.currency; |
| 301 | default: |
| 302 | return selectedCryptoCurrency; |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | FiatCurrency get fiat => _settingsStore.fiatCurrency; |
| 307 | |
| 308 | CryptoCurrency get currency => |
| 309 | selectedCryptoCurrency == CryptoCurrency.btcln ? CryptoCurrency.btcln : wallet.currency; |
| 310 | |
| 311 | String get currencySymbol => _appStore.amountParsingProxy.getCryptoSymbol(currency); |
| 312 | |
| 313 | Validator<String> amountValidator(Output output) => AmountValidator( |
| 314 | currency: wallet.currency, |
| 315 | amountParsingProxy: _appStore.amountParsingProxy, |
| 316 | minValue: isSendToSilentPayments(output) |
| 317 | ? |
| 318 | // TODO: get from server |
| 319 | // bitcoin!.silentPaymentsMinAmount |
| 320 | '0.00001' |
| 321 | : null, |
| 322 | ); |
| 323 | |
| 324 | Validator<String> get allAmountValidator => AllAmountValidator(); |
| 325 | |
| 326 | Validator<String> get addressValidator => AddressValidator( |
| 327 | type: selectedCryptoCurrency, |
| 328 | isTestnet: wallet.isTestnet, |
| 329 | network: wallet.walletInfo.network, |
| 330 | ); |
| 331 | |
| 332 | Validator<String> get textValidator => TextValidator(); |
| 333 | |
| 334 | final FiatCurrency fiatFromSettings; |
| 335 | |
| 336 | @observable |
| 337 | PendingTransaction? pendingTransaction; |
| 338 | |
| 339 | String? get pendingTransactionAdditionalCostNotice { |
| 340 | final additionalCost = pendingTransaction?.additionalCost; |
| 341 | |
| 342 | if (additionalCost == null) { |
| 343 | return null; |
| 344 | } |
| 345 | |
| 346 | return S.current.recipient_account_creation_fee( |
| 347 | _appStore.amountParsingProxy.asDisplayStringWithSymbol(additionalCost), |
| 348 | ); |
| 349 | } |
| 350 | |
| 351 | @computed |
| 352 | String get balance { |
| 353 | if (walletType == WalletType.litecoin && coinTypeToSpendFrom == UnspentCoinType.mweb) { |
| 354 | return balanceViewModel.balances.values.first.secondAvailableBalance; |
| 355 | } else if (walletType == WalletType.litecoin && |
| 356 | coinTypeToSpendFrom == UnspentCoinType.nonMweb) { |
| 357 | return balanceViewModel.balances.values.first.availableBalance; |
| 358 | } |
| 359 | |
| 360 | // Handle case where balance might not be available yet (e.g., during chain switch) |
| 361 | final balanceForCurrency = wallet.balance[selectedCryptoCurrency]; |
| 362 | if (balanceForCurrency == null) { |
| 363 | return _appStore.amountParsingProxy.asDisplayString(Money.zero(selectedCryptoCurrency)); |
| 364 | } |
| 365 | return _appStore.amountParsingProxy |
| 366 | .asDisplayString(wallet.balance[selectedCryptoCurrency]!.available); |
| 367 | } |
| 368 | |
| 369 | @action |
| 370 | Future<void> updateSendingBalance() async { |
| 371 | // force the sendingBalance to recompute since unspent coins aren't observable |
| 372 | // or at least mobx can't detect the changes |
| 373 | |
| 374 | final currentType = coinTypeToSpendFrom; |
| 375 | |
| 376 | if (currentType == UnspentCoinType.any) { |
| 377 | coinTypeToSpendFrom = UnspentCoinType.nonMweb; |
| 378 | } else if (currentType == UnspentCoinType.nonMweb) { |
| 379 | coinTypeToSpendFrom = UnspentCoinType.any; |
| 380 | } else if (currentType == UnspentCoinType.mweb) { |
| 381 | coinTypeToSpendFrom = UnspentCoinType.nonMweb; |
| 382 | } |
| 383 | |
| 384 | // set it back to the original value: |
| 385 | coinTypeToSpendFrom = currentType; |
| 386 | } |
| 387 | |
| 388 | @computed |
| 389 | Future<String> get sendingBalance async { |
| 390 | // only for electrum, monero, wownero, decred wallets atm: |
| 391 | switch (wallet.type) { |
| 392 | case WalletType.bitcoin: |
| 393 | if (selectedCryptoCurrency == CryptoCurrency.btcln) return balance; |
| 394 | return _appStore.amountParsingProxy.getDisplayCryptoString( |
| 395 | await unspentCoinsListViewModel.getSendingBalance(coinTypeToSpendFrom), |
| 396 | walletTypeToCryptoCurrency(walletType)); |
| 397 | case WalletType.litecoin: |
| 398 | case WalletType.bitcoinCash: |
| 399 | case WalletType.dogecoin: |
| 400 | case WalletType.monero: |
| 401 | case WalletType.wownero: |
| 402 | case WalletType.decred: |
| 403 | final sendingBalance = |
| 404 | await unspentCoinsListViewModel.getSendingBalance(coinTypeToSpendFrom); |
| 405 | return walletTypeToCryptoCurrency(walletType).formatAmount(BigInt.from(sendingBalance)); |
| 406 | default: |
| 407 | return balance; |
| 408 | } |
| 409 | } |
| 410 | |
| 411 | @computed |
| 412 | bool get isFiatDisabled => balanceViewModel.isFiatDisabled; |
| 413 | |
| 414 | @computed |
| 415 | String get pendingTransactionFiatAmountFormatted => |
| 416 | isFiatDisabled ? '' : '$pendingTransactionFiatAmount ${fiat.title}'; |
| 417 | |
| 418 | @computed |
| 419 | String get pendingTransactionFeeFiatAmountFormatted => |
| 420 | isFiatDisabled ? '' : '$pendingTransactionFeeFiatAmount ${fiat.title}'; |
| 421 | |
| 422 | @computed |
| 423 | bool get isReadyForSend => |
| 424 | wallet.syncStatus is SyncedSyncStatus || |
| 425 | // If silent payments scanning, can still send payments |
| 426 | (wallet.type == WalletType.bitcoin && wallet.syncStatus is SyncingSyncStatus); |
| 427 | |
| 428 | bool isSendToSilentPayments(Output output) { |
| 429 | if (wallet.type != WalletType.bitcoin) return false; |
| 430 | |
| 431 | final sp = RegExp(AddressValidator.silentPaymentAddressPatternMainnet); |
| 432 | |
| 433 | final address = output.extractedAddress.isNotEmpty ? output.extractedAddress : output.address; |
| 434 | |
| 435 | return sp.hasMatch(address); |
| 436 | } |
| 437 | |
| 438 | @computed |
| 439 | List<Template> get templates => sendTemplateViewModel.templates |
| 440 | .where((template) => _isEqualCurrency(template.cryptoCurrency)) |
| 441 | .toList(); |
| 442 | |
| 443 | @computed |
| 444 | bool get hasCoinControl => |
| 445 | [ |
| 446 | WalletType.bitcoin, |
| 447 | WalletType.litecoin, |
| 448 | WalletType.monero, |
| 449 | WalletType.wownero, |
| 450 | WalletType.decred, |
| 451 | WalletType.bitcoinCash, |
| 452 | WalletType.dogecoin |
| 453 | ].contains(wallet.type) && |
| 454 | coinTypeToSpendFrom != UnspentCoinType.lightning; |
| 455 | |
| 456 | @computed |
| 457 | bool get hasFees => feesViewModel.hasFees && coinTypeToSpendFrom != UnspentCoinType.lightning; |
| 458 | |
| 459 | @computed |
| 460 | bool get isElectrumWallet => [ |
| 461 | WalletType.bitcoin, |
| 462 | WalletType.litecoin, |
| 463 | WalletType.bitcoinCash, |
| 464 | WalletType.dogecoin |
| 465 | ].contains(wallet.type); |
| 466 | |
| 467 | @observable |
| 468 | CryptoCurrency selectedCryptoCurrency; |
| 469 | |
| 470 | @computed |
| 471 | String get selectedCryptoCurrencySymbol => |
| 472 | amountParsingProxy.getCryptoSymbol(selectedCryptoCurrency); |
| 473 | |
| 474 | @computed |
| 475 | bool get useBaseUnits => amountParsingProxy.useSatoshi(selectedCryptoCurrency); |
| 476 | |
| 477 | List<CryptoCurrency> currencies; |
| 478 | |
| 479 | WalletType get walletType => wallet.type; |
| 480 | |
| 481 | String? get walletCurrencyName => wallet.currency.fullName?.toLowerCase() ?? wallet.currency.name; |
| 482 | |
| 483 | @computed |
| 484 | FiatCurrency get fiatCurrency => _settingsStore.fiatCurrency; |
| 485 | |
| 486 | set fiatCurrency(FiatCurrency value) { |
| 487 | _settingsStore.fiatCurrency = value; |
| 488 | } |
| 489 | |
| 490 | List<FiatCurrency> fiatCurrencies; |
| 491 | |
| 492 | final AppStore _appStore; |
| 493 | SettingsStore get _settingsStore => _appStore.settingsStore; |
| 494 | final SendTemplateViewModel sendTemplateViewModel; |
| 495 | final BalanceViewModel balanceViewModel; |
| 496 | final ContactListViewModel contactListViewModel; |
| 497 | final HardwareWalletViewModel? hardwareWalletViewModel; |
| 498 | final FeesViewModel feesViewModel; |
| 499 | final FiatConversionStore _fiatConversationStore; |
| 500 | final AddressResolverService _adrResService; |
| 501 | final Box<TransactionDescription> transactionDescriptionBox; |
| 502 | |
| 503 | @computed |
| 504 | AmountParsingProxy get amountParsingProxy => _appStore.amountParsingProxy; |
| 505 | |
| 506 | @computed |
| 507 | bool get hasMultiRecipient => |
| 508 | sendTemplateViewModel.hasMultiRecipient && coinTypeToSpendFrom != UnspentCoinType.lightning; |
| 509 | |
| 510 | @computed |
| 511 | String get languageCode => _appStore.settingsStore.languageCode; |
| 512 | |
| 513 | @observable |
| 514 | bool hasMultipleTokens; |
| 515 | |
| 516 | @observable |
| 517 | int? selectedChainId; |
| 518 | |
| 519 | @computed |
| 520 | List<ContactRecord> get contactsToShow => contactListViewModel.contacts |
| 521 | .where((element) => element.type == selectedCryptoCurrency) |
| 522 | .toList(); |
| 523 | |
| 524 | @computed |
| 525 | List<WalletContact> get walletContactsToShow => contactListViewModel.walletContacts |
| 526 | .where((element) => element.type == selectedCryptoCurrency) |
| 527 | .toList(); |
| 528 | |
| 529 | @action |
| 530 | bool checkIfAddressIsAContact(String address) => |
| 531 | contactsToShow.where((element) => element.address == address).toList().isNotEmpty; |
| 532 | |
| 533 | @action |
| 534 | bool checkIfWalletIsAnInternalWallet(String address) => |
| 535 | walletContactsToShow.where((element) => element.address == address).toList().isNotEmpty; |
| 536 | |
| 537 | @computed |
| 538 | bool get shouldDisplayTOTP2FAForContact => _settingsStore.shouldRequireTOTP2FAForSendsToContact; |
| 539 | |
| 540 | @computed |
| 541 | bool get shouldDisplayTOTP2FAForNonContact => |
| 542 | _settingsStore.shouldRequireTOTP2FAForSendsToNonContact; |
| 543 | |
| 544 | @computed |
| 545 | bool get shouldDisplayTOTP2FAForSendsToInternalWallet => |
| 546 | _settingsStore.shouldRequireTOTP2FAForSendsToInternalWallets; |
| 547 | |
| 548 | @computed |
| 549 | TransactionInfo? get transactionInfo { |
| 550 | if (isEVMCompatibleChain(walletType)) { |
| 551 | return wallet.transactionHistory.transactions[pendingTransaction?.evmTxHashFromRawHex]; |
| 552 | } |
| 553 | if (walletType == WalletType.monero) { |
| 554 | // monero tx history keys are in format txhash_amount_accountindex_addressindex |
| 555 | return wallet.transactionHistory.transactions[wallet.transactionHistory.transactions.keys |
| 556 | .firstWhereOrNull((item) => item.split("_").first == pendingTransaction?.id)]; |
| 557 | } |
| 558 | if (walletType == WalletType.zcash) { |
| 559 | // zcash has a txHash getter, but the pendingTransaction id contains quotation marks for whatever reason? |
| 560 | final txIdNoQuotes = pendingTransaction?.id.replaceAll("\"", ""); |
| 561 | return wallet.transactionHistory.transactions.values |
| 562 | .firstWhereOrNull((item) => item.txHash == txIdNoQuotes); |
| 563 | } |
| 564 | return wallet.transactionHistory.transactions[pendingTransaction?.id]; |
| 565 | } |
| 566 | |
| 567 | //* Still open to further optimize these checks |
| 568 | //* It works but can be made better |
| 569 | @action |
| 570 | bool checkThroughChecksToDisplayTOTP(String address) { |
| 571 | final isContact = checkIfAddressIsAContact(address); |
| 572 | final isInternalWallet = checkIfWalletIsAnInternalWallet(address); |
| 573 | |
| 574 | if (isContact) { |
| 575 | return shouldDisplayTOTP2FAForContact; |
| 576 | } else if (isInternalWallet) { |
| 577 | return shouldDisplayTOTP2FAForSendsToInternalWallet; |
| 578 | } else { |
| 579 | return shouldDisplayTOTP2FAForNonContact; |
| 580 | } |
| 581 | } |
| 582 | |
| 583 | bool shouldDisplayTotp() { |
| 584 | List<bool> conditionsList = []; |
| 585 | |
| 586 | for (var output in outputs) { |
| 587 | final show = checkThroughChecksToDisplayTOTP(output.extractedAddress); |
| 588 | conditionsList.add(show); |
| 589 | } |
| 590 | |
| 591 | return conditionsList.contains(true); |
| 592 | } |
| 593 | |
| 594 | final _ocpService = OpenCryptoPayService(); |
| 595 | |
| 596 | @observable |
| 597 | OpenCryptoPayRequest? ocpRequest; |
| 598 | |
| 599 | @action |
| 600 | Future<void> dismissTransaction() async { |
| 601 | state = InitialExecutionState(); |
| 602 | if (ocpRequest != null) { |
| 603 | clearOutputs(); |
| 604 | _ocpService.cancelOpenCryptoPayRequest(ocpRequest!); |
| 605 | ocpRequest = null; |
| 606 | } |
| 607 | } |
| 608 | |
| 609 | @action |
| 610 | Future<PaymentRequest?> getOpenCryptoPayRequest(String uri) async { |
| 611 | try { |
| 612 | final originalOCPRequest = await _ocpService.getOpenCryptoPayInvoice(uri.toString()); |
| 613 | final paymentUri = await _ocpService.getOpenCryptoPayAddress( |
| 614 | originalOCPRequest, |
| 615 | selectedCryptoCurrency, |
| 616 | ); |
| 617 | |
| 618 | ocpRequest = originalOCPRequest; |
| 619 | |
| 620 | clearOutputs(); |
| 621 | return PaymentRequest.fromUri(paymentUri); |
| 622 | } on OpenCryptoPayNotSupportedException catch (e) { |
| 623 | printV(e.message); |
| 624 | if (walletType == WalletType.bitcoin) { |
| 625 | state = InitialExecutionState(); |
| 626 | } else { |
| 627 | state = FailureState(translateErrorMessage(e, walletType, currency)); |
| 628 | } |
| 629 | } catch (e) { |
| 630 | printV(e); |
| 631 | state = FailureState(translateErrorMessage(e, walletType, currency)); |
| 632 | } |
| 633 | return null; |
| 634 | } |
| 635 | |
| 636 | @action |
| 637 | Future<PendingTransaction?> createOpenCryptoPayTransaction(String uri) async { |
| 638 | state = IsExecutingState(); |
| 639 | |
| 640 | try { |
| 641 | final originalOCPRequest = await _ocpService.getOpenCryptoPayInvoice(uri.toString()); |
| 642 | final paymentUri = await _ocpService.getOpenCryptoPayAddress( |
| 643 | originalOCPRequest, |
| 644 | selectedCryptoCurrency, |
| 645 | ); |
| 646 | |
| 647 | ocpRequest = originalOCPRequest; |
| 648 | |
| 649 | final paymentRequest = PaymentRequest.fromUri(paymentUri); |
| 650 | clearOutputs(); |
| 651 | |
| 652 | outputs.first.address = paymentRequest.address; |
| 653 | outputs.first.parsedAddress = ParsedAddress( |
| 654 | parsedAddressByCurrencyMap: {currency: paymentRequest.address}, |
| 655 | handle: ocpRequest!.receiverName); |
| 656 | outputs.first.setCryptoAmount(paymentRequest.amount); |
| 657 | outputs.first.note = ocpRequest!.receiverName; |
| 658 | |
| 659 | return createTransaction(); |
| 660 | } on OpenCryptoPayNotSupportedException catch (e) { |
| 661 | printV(e.message); |
| 662 | if (walletType == WalletType.bitcoin) { |
| 663 | state = InitialExecutionState(); |
| 664 | } else { |
| 665 | state = FailureState(translateErrorMessage(e, walletType, currency)); |
| 666 | } |
| 667 | } catch (e) { |
| 668 | printV(e); |
| 669 | state = FailureState(translateErrorMessage(e, walletType, currency)); |
| 670 | } |
| 671 | return null; |
| 672 | } |
| 673 | |
| 674 | static bool isLightningInvoice(String txt) { |
| 675 | return RegExp(AddressValidator.bolt11InvoiceMatcher, caseSensitive: false).hasMatch(txt); |
| 676 | } |
| 677 | |
| 678 | static bool isNonZeroAmountLightningInvoice(String txt) { |
| 679 | return RegExp(AddressValidator.bolt11InvoiceMatcher, caseSensitive: false).hasMatch(txt) && |
| 680 | !isBolt11ZeroInvoice(txt); |
| 681 | } |
| 682 | |
| 683 | static bool isLnurlInvoice(String txt) { |
| 684 | return RegExp(AddressValidator.lnurlMatcher, caseSensitive: false).hasMatch(txt); |
| 685 | } |
| 686 | |
| 687 | Timer? _ledgerTxStateTimer; |
| 688 | |
| 689 | @action |
| 690 | Future<PendingTransaction?> createTransaction({ExchangeProvider? provider, Trade? trade}) async { |
| 691 | _currentTrade = trade; |
| 692 | _currentProvider = provider; |
| 693 | pendingTransaction = null; |
| 694 | |
| 695 | try { |
| 696 | if (!(state is IsExecutingState)) state = IsExecutingState(); |
| 697 | |
| 698 | if (wallet.isHardwareWallet) { |
| 699 | if (walletType == WalletType.monero) { |
| 700 | _ledgerTxStateTimer = Timer.periodic(Duration(seconds: 1), (timer) { |
| 701 | if (monero!.getLastLedgerCommand() == "INS_CLSAG") { |
| 702 | timer.cancel(); |
| 703 | state = IsDeviceSigningResponseState(); |
| 704 | } |
| 705 | }); |
| 706 | } else { |
| 707 | state = IsAwaitingDeviceResponseState(); |
| 708 | } |
| 709 | } |
| 710 | |
| 711 | // Swaps.xyz (EVM) path |
| 712 | if (isEVMWallet && trade != null && provider is SwapsXyzExchangeProvider) { |
| 713 | final routerTo = trade.inputAddress; |
| 714 | final routerData = trade.routerData; |
| 715 | |
| 716 | if (routerData != null && routerData != '0x') { |
| 717 | final tokenContract = (trade.sourceTokenAddress ?? '').toLowerCase(); |
| 718 | final priority = _settingsStore.getPriority(walletType, chainId: selectedChainId); |
| 719 | final routerValueWei = BigInt.tryParse(trade.routerValue ?? '0') ?? BigInt.zero; |
| 720 | |
| 721 | if (routerTo == null || routerTo.isEmpty) { |
| 722 | state = FailureState('Invalid router address'); |
| 723 | return null; |
| 724 | } |
| 725 | |
| 726 | try { |
| 727 | final selector = _decodeMethodSelector(routerData); |
| 728 | const transferSig = '0xa9059cbb'; |
| 729 | const swapAndExecuteSig = '0x9be111d1'; |
| 730 | |
| 731 | // Direct Transfer (Simple routing, no approval needed) |
| 732 | if (selector == transferSig) { |
| 733 | pendingTransaction = await evm!.createRawCallDataTransaction( |
| 734 | wallet, |
| 735 | routerTo, |
| 736 | routerData, |
| 737 | Money.zero(wallet.currency), |
| 738 | priority, |
| 739 | useBlinkProtection: canSupportBlinkProtection(selectedChainId) |
| 740 | ? _settingsStore.useBlinkProtection |
| 741 | : false, |
| 742 | ); |
| 743 | state = ExecutedSuccessfullyState(); |
| 744 | return pendingTransaction; |
| 745 | } |
| 746 | |
| 747 | // Smart Swap (Requires Approval) |
| 748 | if (selector == swapAndExecuteSig) { |
| 749 | final requiredAmount = |
| 750 | BigInt.tryParse((trade.sourceTokenAmountRaw ?? '0').replaceAll('n', '')) ?? |
| 751 | BigInt.zero; |
| 752 | |
| 753 | final needsApproval = tokenContract.isNotEmpty && requiredAmount > BigInt.zero |
| 754 | ? await evm!.isApprovalRequired(wallet, tokenContract, routerTo, requiredAmount) |
| 755 | : false; |
| 756 | |
| 757 | printV( |
| 758 | '[Swaps.xyz sending flow] Approval required: $needsApproval for token ${trade.from?.title} ${trade.from?.tag ?? ''} with amount $requiredAmount'); |
| 759 | |
| 760 | if (needsApproval) { |
| 761 | // USDT Approval Flow (Special Case). We must reset allowance to 0 first. |
| 762 | final isUSDTMainnet = selectedChainId == 1 && |
| 763 | tokenContract.toLowerCase() == '0xdac17f958d2ee523a2206206994597c13d831ec7'; |
| 764 | |
| 765 | if (isUSDTMainnet) { |
| 766 | final currentAllowance = await evm!.getAllowance(wallet, tokenContract, routerTo); |
| 767 | |
| 768 | if (currentAllowance != null && currentAllowance > BigInt.zero) { |
| 769 | printV( |
| 770 | '[Swaps.xyz sending flow] currentAllowance USDT: $currentAllowance. Resetting to 0 before setting new allowance.'); |
| 771 | |
| 772 | final resetTx = await buildApprovalNeeded( |
| 773 | spender: routerTo, |
| 774 | tokenContract: tokenContract, |
| 775 | requiredAmount: BigInt.zero, |
| 776 | // Approve 0 |
| 777 | sourceTokenDecimals: trade.sourceTokenDecimals, |
| 778 | priority: priority); |
| 779 | |
| 780 | if (resetTx != null) { |
| 781 | await resetTx.commit(); |
| 782 | |
| 783 | final resetConfirmed = await _waitForApprovalUpdate( |
| 784 | tokenContract: tokenContract, |
| 785 | spender: routerTo, |
| 786 | requiredAmount: BigInt.zero, // Wait until it equals 0 |
| 787 | waitForExactMatch: true, |
| 788 | ); |
| 789 | |
| 790 | if (!resetConfirmed) { |
| 791 | state = FailureState('Failed to reset USDT allowance. Please try again.'); |
| 792 | return null; |
| 793 | } |
| 794 | printV( |
| 795 | '[Swaps.xyz sending flow] USDT allowance reset to 0 confirmed on-chain.'); |
| 796 | } |
| 797 | } |
| 798 | } |
| 799 | |
| 800 | // Standard Approval Flow |
| 801 | final approvalTx = await buildApprovalNeeded( |
| 802 | spender: routerTo, |
| 803 | tokenContract: tokenContract, |
| 804 | requiredAmount: requiredAmount, |
| 805 | sourceTokenDecimals: trade.sourceTokenDecimals, |
| 806 | priority: priority); |
| 807 | |
| 808 | if (approvalTx == null) { |
| 809 | state = FailureState('Failed to build approval transaction'); |
| 810 | return null; |
| 811 | } |
| 812 | |
| 813 | pendingTransaction = null; |
| 814 | |
| 815 | try { |
| 816 | printV( |
| 817 | '[Swaps.xyz sending flow] Submitting approval transaction for token ${trade.from?.title} ${trade.from?.tag ?? ''} '); |
| 818 | await approvalTx.commit(); |
| 819 | |
| 820 | // Wait for the approval to be mined on-chain |
| 821 | final isApproved = await _waitForApprovalUpdate( |
| 822 | tokenContract: tokenContract, |
| 823 | spender: routerTo, |
| 824 | requiredAmount: requiredAmount, |
| 825 | ); |
| 826 | |
| 827 | if (!isApproved) { |
| 828 | state = FailureState( |
| 829 | 'Approval transaction failed or timed out on-chain. Try again.'); |
| 830 | return null; |
| 831 | } |
| 832 | printV( |
| 833 | '[Swaps.xyz sending flow] Approval transaction confirmed on-chain. Proceeding with swap execution.'); |
| 834 | } catch (e, s) { |
| 835 | printV('[Swaps.xyz sending flow] Approval transaction error: $e\n$s'); |
| 836 | state = FailureState(translateErrorMessage(e, wallet.type, wallet.currency)); |
| 837 | return null; |
| 838 | } |
| 839 | } |
| 840 | |
| 841 | // Construct Final Swap Transaction |
| 842 | printV('[Swaps.xyz sending flow] Building swap transaction'); |
| 843 | pendingTransaction = await evm!.createRawCallDataTransaction( |
| 844 | wallet, |
| 845 | routerTo, |
| 846 | routerData, |
| 847 | Money(routerValueWei, wallet.currency), |
| 848 | priority, |
| 849 | sourceTokenAddress: tokenContract, |
| 850 | sourceTokenAmount: requiredAmount, |
| 851 | useBlinkProtection: canSupportBlinkProtection(selectedChainId) |
| 852 | ? _settingsStore.useBlinkProtection |
| 853 | : false, |
| 854 | ); |
| 855 | |
| 856 | state = ExecutedSuccessfullyState(); |
| 857 | return pendingTransaction; |
| 858 | } |
| 859 | |
| 860 | state = FailureState('Unsupported Swaps.xyz transaction type'); |
| 861 | return null; |
| 862 | } catch (e, s) { |
| 863 | printV('Swaps.xyz transaction error: $e\n$s'); |
| 864 | state = FailureState( |
| 865 | 'Failed to create Swaps.xyz transaction - ${translateErrorMessage(e, wallet.type, wallet.currency)}'); |
| 866 | return null; |
| 867 | } |
| 868 | } |
| 869 | } |
| 870 | // END Swaps.xyz path |
| 871 | |
| 872 | // Jupiter (Solana) swap path |
| 873 | if (walletType == WalletType.solana && trade != null && provider is JupiterExchangeProvider) { |
| 874 | final swapTransactionBase64 = trade.routerData; |
| 875 | final requestId = trade.routerValue; |
| 876 | if (swapTransactionBase64?.isNotEmpty == true && |
| 877 | requestId?.isNotEmpty == true && |
| 878 | solana != null) { |
| 879 | try { |
| 880 | final actualFee = trade.fee ?? 0.0005; |
| 881 | // Fallback to estimate if not available |
| 882 | final fee = actualFee > 0 ? actualFee : 0.0005; |
| 883 | |
| 884 | final fromCurrency = trade.from ?? CryptoCurrency.sol; |
| 885 | final amount = Money.tryParse(trade.amount, fromCurrency) ?? Money.zero(fromCurrency); |
| 886 | |
| 887 | pendingTransaction = await solana!.signAndPrepareJupiterSwapTransaction( |
| 888 | wallet, |
| 889 | swapTransactionBase64!, |
| 890 | requestId!, |
| 891 | trade.payoutAddress ?? '', |
| 892 | amount, |
| 893 | Money.tryParse(fee.toString(), CryptoCurrency.sol) ?? Money.zero(CryptoCurrency.sol), |
| 894 | ); |
| 895 | |
| 896 | state = ExecutedSuccessfullyState(); |
| 897 | return pendingTransaction; |
| 898 | } catch (e, s) { |
| 899 | printV('Jupiter swap error: $e\n$s'); |
| 900 | throw Exception('Failed to process Jupiter swap: $e'); |
| 901 | } |
| 902 | } |
| 903 | } |
| 904 | |
| 905 | // Regular flow |
| 906 | |
| 907 | final isSendAll = outputs.any((output) => output.sendAll); |
| 908 | |
| 909 | if (!isSendAll) { |
| 910 | final estimateTxAmountDouble = outputs.fold<double>( |
| 911 | 0, (acc, output) => acc + (double.tryParse(output.cryptoAmount) ?? 0)); |
| 912 | if (estimateTxAmountDouble <= 0) throw Exception('Amount must be greater than 0'); |
| 913 | } |
| 914 | |
| 915 | pendingTransaction = await wallet.createTransaction(_credentials(provider)); |
| 916 | |
| 917 | final txAmountDouble = double.tryParse(pendingTransaction?.amountFormatted ?? '0') ?? 0.0; |
| 918 | final bool isTradeTx = trade != null && provider != null; |
| 919 | |
| 920 | if (isTradeTx) { |
| 921 | final tradeAmountDouble = double.tryParse(trade.amount) ?? 0.0; |
| 922 | if (tradeAmountDouble <= 0) throw Exception('Trade amount must be greater than 0'); |
| 923 | |
| 924 | if (trade.isSendAll == true) { |
| 925 | if (provider is NearIntentsExchangeProvider) { |
| 926 | if (txAmountDouble != tradeAmountDouble) { |
| 927 | throw Exception( |
| 928 | 'Transaction amount $txAmountDouble does not match expected trade amount $tradeAmountDouble'); |
| 929 | } |
| 930 | } |
| 931 | } |
| 932 | |
| 933 | if (provider is ThorChainExchangeProvider) { |
| 934 | final outputCount = pendingTransaction?.outputCount ?? 0; |
| 935 | if (outputCount > 10) { |
| 936 | throw Exception("THORChain does not support more than 10 outputs"); |
| 937 | } |
| 938 | |
| 939 | if (_hasTaprootInput(pendingTransaction)) { |
| 940 | throw Exception("THORChain does not support Taproot addresses"); |
| 941 | } |
| 942 | } |
| 943 | } |
| 944 | |
| 945 | if (wallet.type == WalletType.bitcoin) { |
| 946 | final updatedOutputs = bitcoin!.updateOutputs(pendingTransaction!, outputs); |
| 947 | |
| 948 | if (outputs.length == updatedOutputs.length) { |
| 949 | outputs.replaceRange(0, outputs.length, updatedOutputs); |
| 950 | } |
| 951 | } |
| 952 | |
| 953 | state = ExecutedSuccessfullyState(); |
| 954 | return pendingTransaction; |
| 955 | } catch (e) { |
| 956 | _ledgerTxStateTimer?.cancel(); |
| 957 | // if (e is LedgerException) { |
| 958 | // final errorCode = e.errorCode.toRadixString(16); |
| 959 | // final fallbackMsg = |
| 960 | // e.message.isNotEmpty ? e.message : "Unexpected Ledger Error Code: $errorCode"; |
| 961 | // final errorMsg = ledgerViewModel!.interpretErrorCode(errorCode) ?? fallbackMsg; |
| 962 | // |
| 963 | // state = FailureState(errorMsg); |
| 964 | // } else { |
| 965 | state = FailureState(translateErrorMessage(e, wallet.type, wallet.currency)); |
| 966 | // } |
| 967 | } |
| 968 | return null; |
| 969 | } |
| 970 | |
| 971 | @action |
| 972 | Future<void> replaceByFee(TransactionInfo tx, String newFee) async { |
| 973 | state = IsExecutingState(); |
| 974 | |
| 975 | try { |
| 976 | final isSufficient = await bitcoin!.isChangeSufficientForFee(wallet, tx.id, newFee); |
| 977 | |
| 978 | if (!isSufficient) { |
| 979 | state = AwaitingConfirmationState( |
| 980 | title: S.current.confirm_fee_deduction, |
| 981 | message: S.current.confirm_fee_deduction_content, |
| 982 | onConfirm: () async => await _executeReplaceByFee(tx, newFee), |
| 983 | onCancel: () => state = FailureState('Insufficient change for fee')); |
| 984 | } else { |
| 985 | await _executeReplaceByFee(tx, newFee); |
| 986 | } |
| 987 | } catch (e) { |
| 988 | state = FailureState(e.toString()); |
| 989 | } |
| 990 | } |
| 991 | |
| 992 | Future<void> _executeReplaceByFee(TransactionInfo tx, String newFee) async { |
| 993 | clearOutputs(); |
| 994 | final output = outputs.first; |
| 995 | output.address = tx.outputAddresses?.first ?? ''; |
| 996 | |
| 997 | try { |
| 998 | pendingTransaction = await bitcoin!.replaceByFee(wallet, tx.id, newFee); |
| 999 | state = ExecutedSuccessfullyState(); |
| 1000 | } catch (e) { |
| 1001 | state = FailureState(e.toString()); |
| 1002 | } |
| 1003 | } |
| 1004 | |
| 1005 | Future<void> _handleOcpRequest() async { |
| 1006 | if (OpenCryptoPayService.requiresClientCommit(selectedCryptoCurrency)) { |
| 1007 | await pendingTransaction!.commit(); |
| 1008 | } |
| 1009 | |
| 1010 | await _ocpService.commitOpenCryptoPayRequest( |
| 1011 | pendingTransaction!.hex, |
| 1012 | txId: pendingTransaction!.id, |
| 1013 | request: ocpRequest!, |
| 1014 | asset: selectedCryptoCurrency, |
| 1015 | ); |
| 1016 | } |
| 1017 | |
| 1018 | Future<void> _commitUR(BuildContext context) async { |
| 1019 | final urstr = await pendingTransaction!.commitUR(); |
| 1020 | final result = await Navigator.of(context).pushNamed(Routes.urqrAnimatedPage, arguments: urstr); |
| 1021 | if (result == null) { |
| 1022 | throw "Canceled by user"; |
| 1023 | } |
| 1024 | } |
| 1025 | |
| 1026 | @action |
| 1027 | Future<void> commitTransaction(BuildContext context) async { |
| 1028 | if (pendingTransaction == null) { |
| 1029 | throw Exception("Pending transaction doesn't exist. It should not be happened."); |
| 1030 | } |
| 1031 | |
| 1032 | try { |
| 1033 | state = wallet.isHardwareWallet && walletType == WalletType.monero |
| 1034 | ? IsAwaitingDeviceResponseState() |
| 1035 | : TransactionCommitting(); |
| 1036 | |
| 1037 | if (ocpRequest != null) { |
| 1038 | await _handleOcpRequest(); |
| 1039 | } else if (pendingTransaction!.shouldCommitUR()) { |
| 1040 | await _commitUR(context); |
| 1041 | } else { |
| 1042 | await pendingTransaction!.commit(); |
| 1043 | } |
| 1044 | |
| 1045 | state = TransactionCommitted(); |
| 1046 | |
| 1047 | if (_currentTrade != null) { |
| 1048 | final provider = _currentTrade!.provider; |
| 1049 | if (provider == ExchangeProviderDescription.swapsXyz) { |
| 1050 | registerSwapsXyzTransaction(_currentTrade!); |
| 1051 | } |
| 1052 | } |
| 1053 | |
| 1054 | await _updateSolanaTrade(signature: pendingTransaction!.id, isSuccess: true); |
| 1055 | |
| 1056 | if (walletType == WalletType.solana) { |
| 1057 | Future.delayed(Duration(seconds: 1), () async { |
| 1058 | try { |
| 1059 | await solana!.pollForTransaction( |
| 1060 | wallet, |
| 1061 | pendingTransaction!.id, |
| 1062 | initialDelay: const Duration(seconds: 1), |
| 1063 | maxRetries: 5, |
| 1064 | ); |
| 1065 | } catch (e) { |
| 1066 | printV('Failed to poll for transaction: $e'); |
| 1067 | } |
| 1068 | }); |
| 1069 | |
| 1070 | // Update balances for currencies involved in swap |
| 1071 | if (_currentTrade != null) { |
| 1072 | Future.delayed(Duration(seconds: 2), () async { |
| 1073 | try { |
| 1074 | final tokenMints = <String>[]; |
| 1075 | |
| 1076 | // Extract from currency mint (skip native SOL) |
| 1077 | if (_currentTrade!.from != null && _currentTrade!.from != CryptoCurrency.sol) { |
| 1078 | try { |
| 1079 | final fromMint = solana!.getTokenAddress(_currentTrade!.from!); |
| 1080 | tokenMints.add(fromMint); |
| 1081 | } catch (e) { |
| 1082 | printV('Error getting from currency mint: $e'); |
| 1083 | } |
| 1084 | } |
| 1085 | |
| 1086 | // Extract to currency mint (skip native SOL) |
| 1087 | if (_currentTrade!.to != null && _currentTrade!.to != CryptoCurrency.sol) { |
| 1088 | try { |
| 1089 | final toMint = solana!.getTokenAddress(_currentTrade!.to!); |
| 1090 | tokenMints.add(toMint); |
| 1091 | } catch (e) { |
| 1092 | printV('Error getting to currency mint: $e'); |
| 1093 | } |
| 1094 | } |
| 1095 | |
| 1096 | if (tokenMints.isNotEmpty) { |
| 1097 | solana!.updateTokenBalances( |
| 1098 | wallet, |
| 1099 | tokenMints: tokenMints, |
| 1100 | ); |
| 1101 | |
| 1102 | // Retry after a bit more time to ensure balance is updated |
| 1103 | Future.delayed(Duration(seconds: 2), () async { |
| 1104 | try { |
| 1105 | await solana!.updateTokenBalances( |
| 1106 | wallet, |
| 1107 | tokenMints: tokenMints, |
| 1108 | ); |
| 1109 | } catch (e) { |
| 1110 | printV('Error retrying balance update: $e'); |
| 1111 | } |
| 1112 | }); |
| 1113 | } |
| 1114 | } catch (e) { |
| 1115 | printV('Failed to update balances after send: $e'); |
| 1116 | } finally { |
| 1117 | _currentTrade = null; |
| 1118 | _currentProvider = null; |
| 1119 | } |
| 1120 | }); |
| 1121 | } |
| 1122 | } |
| 1123 | |
| 1124 | // Immediate transaction update for EVM chains, Tron, and Nano |
| 1125 | if (isEVMWallet || |
| 1126 | [WalletType.bitcoin, WalletType.solana, WalletType.tron, WalletType.nano] |
| 1127 | .contains(walletType)) { |
| 1128 | Future.delayed(Duration(seconds: 4), () async { |
| 1129 | try { |
| 1130 | await Future.wait([ |
| 1131 | wallet.updateTransactionsHistory(), |
| 1132 | wallet.updateBalance() as Future<void>, |
| 1133 | ]); |
| 1134 | } catch (e) { |
| 1135 | printV('Failed to update transactions after send: $e'); |
| 1136 | } |
| 1137 | }); |
| 1138 | } |
| 1139 | |
| 1140 | // FIXME(malik) ideally, this should be done wallet-side. |
| 1141 | // it is required because evm, solana and tron don't actually save the transaction info when you send something. |
| 1142 | // instead, they rely on the tx to eventually get fetched at sync time, which can take a while |
| 1143 | if (isEVMWallet) { |
| 1144 | final selectedToken = evm!.getERC20Currencies(wallet).firstWhereOrNull( |
| 1145 | (token) => token.title.toUpperCase() == selectedCryptoCurrency.title.toUpperCase(), |
| 1146 | ); |
| 1147 | |
| 1148 | wallet.transactionHistory.addOne(evm!.getTransactionInfo( |
| 1149 | id: pendingTransaction!.evmTxHashFromRawHex!, |
| 1150 | height: 0, |
| 1151 | amount: outputs.first.cryptoAmountMoney, |
| 1152 | fee: pendingTransaction!.fee, |
| 1153 | tokenSymbol: selectedCryptoCurrency.title, |
| 1154 | direction: TransactionDirection.outgoing, |
| 1155 | isPending: true, |
| 1156 | date: DateTime.now(), |
| 1157 | confirmations: 0, |
| 1158 | chainId: wallet.chainId ?? 0, |
| 1159 | contractAddress: selectedToken?.contractAddress, |
| 1160 | )); |
| 1161 | } |
| 1162 | |
| 1163 | if (walletType == WalletType.solana) { |
| 1164 | wallet.transactionHistory.addOne(solana!.getTransactionInfo( |
| 1165 | id: pendingTransaction!.id, |
| 1166 | blockTime: DateTime.now(), |
| 1167 | to: "", |
| 1168 | from: "", |
| 1169 | direction: TransactionDirection.outgoing, |
| 1170 | amount: pendingTransaction!.amount, |
| 1171 | isPending: true, |
| 1172 | fee: pendingTransaction!.fee, |
| 1173 | )); |
| 1174 | } |
| 1175 | |
| 1176 | if (walletType == WalletType.tron) { |
| 1177 | wallet.transactionHistory.addOne(tron!.getTransactionInfo( |
| 1178 | id: pendingTransaction!.id, |
| 1179 | blockTime: DateTime.now(), |
| 1180 | direction: TransactionDirection.outgoing, |
| 1181 | amount: outputs.first.cryptoAmountMoney, |
| 1182 | isPending: true, |
| 1183 | fee: outputs.first.estimatedFee, |
| 1184 | )); |
| 1185 | } |
| 1186 | |
| 1187 | if (pendingTransaction!.id.isNotEmpty) { |
| 1188 | _addTransactionDescription(); |
| 1189 | } |
| 1190 | final sharedPreferences = await SharedPreferences.getInstance(); |
| 1191 | await sharedPreferences.setString(PreferencesKey.backgroundSyncLastTrigger(wallet.name), |
| 1192 | DateTime.now().add(Duration(minutes: 1)).toIso8601String()); |
| 1193 | } catch (e) { |
| 1194 | state = FailureState(translateErrorMessage(e, wallet.type, wallet.currency)); |
| 1195 | |
| 1196 | final failedSignature = e is JupiterSwapFailedException ? e.signature : ""; |
| 1197 | |
| 1198 | await _updateSolanaTrade(signature: failedSignature, isSuccess: false); |
| 1199 | } |
| 1200 | } |
| 1201 | |
| 1202 | /// Update Jupiter trade with relevant details after transaction is committed |
| 1203 | Future<void> _updateSolanaTrade({required String signature, required bool isSuccess}) async { |
| 1204 | if (_currentTrade == null || |
| 1205 | _currentProvider?.title != 'Jupiter' || |
| 1206 | walletType != WalletType.solana) return; |
| 1207 | |
| 1208 | _currentTrade!.txId = signature; |
| 1209 | |
| 1210 | _currentTrade!.stateRaw = isSuccess ? TradeState.completed.raw : TradeState.failed.raw; |
| 1211 | |
| 1212 | await _currentTrade!.save(); |
| 1213 | } |
| 1214 | |
| 1215 | @action |
| 1216 | Future<void> updateWalletBalance() async => await wallet.updateBalance(); |
| 1217 | |
| 1218 | Future<void> _addTransactionDescription() async { |
| 1219 | String address = outputs.fold('', (acc, value) { |
| 1220 | final canonical = value.extractedAddress.trim().isNotEmpty |
| 1221 | ? value.extractedAddress.trim() |
| 1222 | : value.address.trim(); |
| 1223 | if (canonical.isEmpty) { |
| 1224 | return acc; |
| 1225 | } |
| 1226 | return '$acc$canonical\n\n'; |
| 1227 | }); |
| 1228 | |
| 1229 | address = address.trim(); |
| 1230 | |
| 1231 | String note = outputs.fold('', (acc, value) => '$acc${value.note}\n'); |
| 1232 | |
| 1233 | note = note.trim(); |
| 1234 | |
| 1235 | TransactionInfo? tx; |
| 1236 | if (walletType == WalletType.monero) { |
| 1237 | await Future.delayed(Duration(milliseconds: 450)); |
| 1238 | await wallet.fetchTransactions(); |
| 1239 | final txhistory = monero!.getTransactionHistory(wallet); |
| 1240 | tx = txhistory.transactions.values.last; |
| 1241 | } |
| 1242 | final descriptionKey = '${pendingTransaction!.id}_${wallet.walletAddresses.primaryAddress}'; |
| 1243 | _settingsStore.shouldSaveRecipientAddress |
| 1244 | ? await transactionDescriptionBox.add(TransactionDescription( |
| 1245 | id: descriptionKey, |
| 1246 | recipientAddress: address, |
| 1247 | transactionNote: note, |
| 1248 | transactionKey: tx?.additionalInfo["key"] as String?, |
| 1249 | )) |
| 1250 | : await transactionDescriptionBox.add(TransactionDescription( |
| 1251 | id: descriptionKey, |
| 1252 | transactionNote: note, |
| 1253 | transactionKey: tx?.additionalInfo["key"] as String?, |
| 1254 | )); |
| 1255 | } |
| 1256 | |
| 1257 | Object _credentials([ExchangeProvider? provider]) { |
| 1258 | final priority = _settingsStore.getPriority(wallet.type, chainId: wallet.chainId); |
| 1259 | |
| 1260 | if (priority == null && |
| 1261 | ![ |
| 1262 | WalletType.nano, |
| 1263 | WalletType.banano, |
| 1264 | WalletType.solana, |
| 1265 | WalletType.tron, |
| 1266 | WalletType.arbitrum, |
| 1267 | WalletType.zcash, |
| 1268 | ].contains(wallet.type)) { |
| 1269 | throw Exception('Priority is null for wallet type: ${wallet.type}'); |
| 1270 | } |
| 1271 | |
| 1272 | switch (wallet.type) { |
| 1273 | case WalletType.bitcoin: |
| 1274 | case WalletType.bitcoinCash: |
| 1275 | case WalletType.dogecoin: |
| 1276 | return bitcoin!.createBitcoinTransactionCredentials( |
| 1277 | outputs, |
| 1278 | priority: priority!, |
| 1279 | feeRate: feesViewModel.customBitcoinFeeRate, |
| 1280 | coinTypeToSpendFrom: coinTypeToSpendFrom, |
| 1281 | payjoinUri: _settingsStore.usePayjoin ? payjoinUri : null, |
| 1282 | ); |
| 1283 | case WalletType.litecoin: |
| 1284 | return bitcoin!.createBitcoinTransactionCredentials( |
| 1285 | outputs, |
| 1286 | priority: priority!, |
| 1287 | feeRate: feesViewModel.customBitcoinFeeRate, |
| 1288 | // if it's an exchange flow then disable sending from mweb coins |
| 1289 | coinTypeToSpendFrom: provider != null ? UnspentCoinType.nonMweb : coinTypeToSpendFrom, |
| 1290 | ); |
| 1291 | |
| 1292 | case WalletType.monero: |
| 1293 | return monero! |
| 1294 | .createMoneroTransactionCreationCredentials(outputs: outputs, priority: priority!); |
| 1295 | |
| 1296 | case WalletType.wownero: |
| 1297 | return wownero! |
| 1298 | .createWowneroTransactionCreationCredentials(outputs: outputs, priority: priority!); |
| 1299 | |
| 1300 | case WalletType.ethereum: |
| 1301 | case WalletType.polygon: |
| 1302 | case WalletType.base: |
| 1303 | case WalletType.arbitrum: |
| 1304 | case WalletType.bsc: |
| 1305 | return evm!.createEVMTransactionCredentials( |
| 1306 | outputs, |
| 1307 | priority: priority, |
| 1308 | currency: selectedCryptoCurrency, |
| 1309 | useBlinkProtection: canSupportBlinkProtection(selectedChainId) |
| 1310 | ? _settingsStore.useBlinkProtection |
| 1311 | : false, |
| 1312 | ); |
| 1313 | case WalletType.nano: |
| 1314 | return nano!.createNanoTransactionCredentials(outputs); |
| 1315 | case WalletType.solana: |
| 1316 | return solana! |
| 1317 | .createSolanaTransactionCredentials(outputs, currency: selectedCryptoCurrency); |
| 1318 | case WalletType.tron: |
| 1319 | return tron!.createTronTransactionCredentials(outputs, currency: selectedCryptoCurrency); |
| 1320 | case WalletType.zano: |
| 1321 | return zano!.createZanoTransactionCredentials( |
| 1322 | outputs: outputs, priority: priority!, currency: selectedCryptoCurrency); |
| 1323 | case WalletType.decred: |
| 1324 | this.coinTypeToSpendFrom = UnspentCoinType.any; |
| 1325 | return decred!.createDecredTransactionCredentials(outputs, priority!); |
| 1326 | case WalletType.zcash: |
| 1327 | return zcash!.createZcashTransactionCredentials( |
| 1328 | outputs, |
| 1329 | currency: selectedCryptoCurrency, |
| 1330 | // priority: priority, |
| 1331 | ); |
| 1332 | default: |
| 1333 | throw Exception('Unexpected wallet type: ${wallet.type} for send'); |
| 1334 | } |
| 1335 | } |
| 1336 | |
| 1337 | bool _isEqualCurrency(String currency) => |
| 1338 | wallet.balance.keys.any((e) => currency.toLowerCase() == e.title.toLowerCase()); |
| 1339 | |
| 1340 | void onClose() => _settingsStore.fiatCurrency = fiatFromSettings; |
| 1341 | |
| 1342 | @action |
| 1343 | FiatCurrency setFiatCurrency(FiatCurrency fiat) { |
| 1344 | _settingsStore.fiatCurrency = fiat; |
| 1345 | return fiat; |
| 1346 | } |
| 1347 | |
| 1348 | @action |
| 1349 | void setSelectedCryptoCurrency(String cryptoCurrency) { |
| 1350 | try { |
| 1351 | selectedCryptoCurrency = wallet.balance.keys |
| 1352 | .firstWhere((e) => cryptoCurrency.toLowerCase() == e.title.toLowerCase()); |
| 1353 | } catch (e) { |
| 1354 | selectedCryptoCurrency = wallet.currency; |
| 1355 | } |
| 1356 | } |
| 1357 | |
| 1358 | @computed |
| 1359 | bool get hasMemos => [WalletType.zcash].contains(wallet.type); |
| 1360 | |
| 1361 | final Map<WalletType, int> _maxMemoLengths = { |
| 1362 | WalletType.zcash: 512, |
| 1363 | }; |
| 1364 | |
| 1365 | @computed |
| 1366 | int get maxMemoLength => _maxMemoLengths[wallet.type] ?? 9999999; |
| 1367 | |
| 1368 | ContactRecord? newContactAddress() { |
| 1369 | final Set<String> contactAddresses = |
| 1370 | Set.from(contactListViewModel.contacts.map((contact) => contact.address)) |
| 1371 | ..addAll(contactListViewModel.walletContacts.map((contact) => contact.address)); |
| 1372 | |
| 1373 | for (final output in outputs) { |
| 1374 | final address = output.isParsedAddress |
| 1375 | ? output.parsedAddress.parsedAddressByCurrencyMap[selectedCryptoCurrency] ?? '' |
| 1376 | : output.address; |
| 1377 | |
| 1378 | if (address.isNotEmpty && |
| 1379 | !contactAddresses.contains(address) && |
| 1380 | selectedCryptoCurrency.raw != -1) { |
| 1381 | return ContactRecord( |
| 1382 | contactListViewModel.contactSource, |
| 1383 | Contact( |
| 1384 | name: '', |
| 1385 | address: address, |
| 1386 | type: selectedCryptoCurrency, |
| 1387 | ), |
| 1388 | ); |
| 1389 | } |
| 1390 | } |
| 1391 | return null; |
| 1392 | } |
| 1393 | |
| 1394 | // Helper functions for EVM transaction monitoring and approval flow |
| 1395 | |
| 1396 | // Polls the token contract directly to see if allowance is updated. |
| 1397 | Future<bool> _waitForApprovalUpdate({ |
| 1398 | required String tokenContract, |
| 1399 | required String spender, |
| 1400 | required BigInt requiredAmount, |
| 1401 | bool waitForExactMatch = false, |
| 1402 | }) async { |
| 1403 | if (!isEVMWallet || evm == null) return false; |
| 1404 | |
| 1405 | int attempts = 0; |
| 1406 | const int maxAttempts = 30; // ~60 seconds |
| 1407 | |
| 1408 | printV( |
| 1409 | '[Swaps.xyz sending flow] Starting allowance check. Target: $requiredAmount (Exact match: $waitForExactMatch)'); |
| 1410 | |
| 1411 | while (attempts < maxAttempts) { |
| 1412 | try { |
| 1413 | final currentAllowance = await evm!.getAllowance(wallet, tokenContract, spender); |
| 1414 | |
| 1415 | if (currentAllowance != null) { |
| 1416 | printV( |
| 1417 | '[Swaps.xyz sending flow] Current Allowance: $currentAllowance / Target: $requiredAmount'); |
| 1418 | |
| 1419 | if (waitForExactMatch) { |
| 1420 | // For Reset (Target 0): We need it to be exactly 0 (or less, though it can't be negative) |
| 1421 | if (currentAllowance <= requiredAmount) { |
| 1422 | printV('[Swaps.xyz sending flow] Allowance reset verified!'); |
| 1423 | return true; |
| 1424 | } |
| 1425 | } else { |
| 1426 | // For Approval: We need it to be at least the required amount |
| 1427 | if (currentAllowance >= requiredAmount) { |
| 1428 | printV('[Swaps.xyz sending flow] Allowance verified!'); |
| 1429 | return true; |
| 1430 | } |
| 1431 | } |
| 1432 | } |
| 1433 | } catch (e) { |
| 1434 | printV('[Swaps.xyz sending flow] Allowance check error: $e'); |
| 1435 | } |
| 1436 | |
| 1437 | await Future.delayed(const Duration(seconds: 1)); |
| 1438 | attempts++; |
| 1439 | } |
| 1440 | |
| 1441 | printV('[Swaps.xyz sending flow] Allowance check timed out.'); |
| 1442 | return false; |
| 1443 | } |
| 1444 | |
| 1445 | // Builds a token approval transaction |
| 1446 | Future<PendingTransaction?> buildApprovalNeeded({ |
| 1447 | required String spender, |
| 1448 | required String tokenContract, |
| 1449 | required BigInt requiredAmount, |
| 1450 | required TransactionPriority? priority, |
| 1451 | int? sourceTokenDecimals, |
| 1452 | }) async { |
| 1453 | final erc20Token = wallet.balance.keys.whereType<Erc20Token>().firstWhere( |
| 1454 | (t) => t.contractAddress.toLowerCase() == tokenContract.toLowerCase(), |
| 1455 | orElse: () => Erc20Token( |
| 1456 | name: '', |
| 1457 | symbol: '', |
| 1458 | contractAddress: tokenContract, |
| 1459 | decimal: sourceTokenDecimals ?? 18, |
| 1460 | enabled: true, |
| 1461 | ), |
| 1462 | ); |
| 1463 | |
| 1464 | return await evm!.createTokenApproval( |
| 1465 | wallet, |
| 1466 | Money(requiredAmount, erc20Token), |
| 1467 | spender, |
| 1468 | priority, |
| 1469 | useBlinkProtection: |
| 1470 | canSupportBlinkProtection(selectedChainId) ? _settingsStore.useBlinkProtection : false, |
| 1471 | ); |
| 1472 | } |
| 1473 | |
| 1474 | // End EVM helper functions |
| 1475 | |
| 1476 | String translateErrorMessage( |
| 1477 | Object error, |
| 1478 | WalletType walletType, |
| 1479 | CryptoCurrency currency, |
| 1480 | ) { |
| 1481 | String errorMessage = error.toString(); |
| 1482 | |
| 1483 | if (walletType == WalletType.solana) { |
| 1484 | if (errorMessage.contains('insufficient lamports')) { |
| 1485 | double solValueNeeded = 0.0; |
| 1486 | |
| 1487 | // Regular expression to match the number after "need". This shows the exact lamports the user needs to perform the transaction. |
| 1488 | RegExp regExp = RegExp(r'need (\d+)'); |
| 1489 | |
| 1490 | // Find the match |
| 1491 | Match? match = regExp.firstMatch(errorMessage); |
| 1492 | |
| 1493 | if (match != null) { |
| 1494 | String neededAmount = match.group(1)!; |
| 1495 | final lamportsNeeded = int.tryParse(neededAmount); |
| 1496 | |
| 1497 | // 5000 lamport used here is the constant for sending a transaction on solana |
| 1498 | int lamportsPerSol = 1000000000; |
| 1499 | |
| 1500 | solValueNeeded = |
| 1501 | lamportsNeeded != null ? ((lamportsNeeded + 5000) / lamportsPerSol) : 0.0; |
| 1502 | return S.current.insufficient_lamports(solValueNeeded.toString()); |
| 1503 | } else { |
| 1504 | return S.current.insufficient_lamport_for_tx; |
| 1505 | } |
| 1506 | } |
| 1507 | |
| 1508 | if (error is SignNativeTokenTransactionRentException) { |
| 1509 | return S.current.solana_sign_native_transaction_rent_exception; |
| 1510 | } |
| 1511 | |
| 1512 | if (error is CreateAssociatedTokenAccountException) { |
| 1513 | return "${S.current.solana_create_associated_token_account_exception} ${S.current.added_message_for_ata_error}"; |
| 1514 | } |
| 1515 | |
| 1516 | if (error is SignSPLTokenTransactionRentException) { |
| 1517 | return S.current.solana_sign_spl_token_transaction_rent_exception; |
| 1518 | } |
| 1519 | |
| 1520 | if (error is NoAssociatedTokenAccountException) { |
| 1521 | return S.current.solana_no_associated_token_account_exception; |
| 1522 | } |
| 1523 | |
| 1524 | if (error is AmbiguousTokenSymbolException) { |
| 1525 | return S.current.ambiguous_token_symbol_exception(error.symbol); |
| 1526 | } |
| 1527 | |
| 1528 | if (errorMessage.contains('found no record of a prior credit')) { |
| 1529 | return S.current.insufficient_funds_for_tx; |
| 1530 | } |
| 1531 | |
| 1532 | if (errorMessage.contains('insufficient funds for rent') && |
| 1533 | errorMessage.contains('Transaction simulation failed') && |
| 1534 | errorMessage.contains('account_index')) { |
| 1535 | final accountIndexMatch = RegExp(r'account_index: (\d+)').firstMatch(errorMessage); |
| 1536 | if (accountIndexMatch != null) { |
| 1537 | return int.parse(accountIndexMatch.group(1)!) == 0 |
| 1538 | ? S.current.insufficientFundsForRentError |
| 1539 | : S.current.insufficientFundsForRentErrorReceiver; |
| 1540 | } |
| 1541 | } |
| 1542 | |
| 1543 | if (errorMessage.contains('invalid account data')) { |
| 1544 | return S.current.solana_invalid_data_message; |
| 1545 | } |
| 1546 | |
| 1547 | if (errorMessage.contains('Blockhash not found') || |
| 1548 | errorMessage.contains('BlockhashNotFound') || |
| 1549 | errorMessage.contains('BlockhashMNotFound')) { |
| 1550 | return 'Transaction failed because its recent blockhash expired. ' |
| 1551 | 'Please retry your send; if this keeps happening, try again in ' |
| 1552 | 'a few seconds or switch to a different Solana node.'; |
| 1553 | } |
| 1554 | |
| 1555 | return errorMessage; |
| 1556 | } |
| 1557 | if (isEVMWallet || walletType == WalletType.haven) { |
| 1558 | if (errorMessage.contains('gas required exceeds allowance')) { |
| 1559 | return S.current.gas_exceeds_allowance; |
| 1560 | } |
| 1561 | |
| 1562 | if (errorMessage.contains('insufficient funds')) { |
| 1563 | final feeCurrency = switch (walletType) { |
| 1564 | WalletType.bsc => "BNB", |
| 1565 | WalletType.polygon => "POL", |
| 1566 | _ => "ETH", |
| 1567 | }; |
| 1568 | |
| 1569 | final parsedErrorMessageResult = |
| 1570 | EVMTransactionErrorFeesHandler.parseEthereumFeesErrorMessage( |
| 1571 | errorMessage, |
| 1572 | _fiatConversationStore.prices[currency] ?? 0.0, |
| 1573 | ); |
| 1574 | |
| 1575 | // Handle generic insufficient funds error (no specific values available) |
| 1576 | if (parsedErrorMessageResult.error == 'generic_insufficient_funds') { |
| 1577 | return S.current.insufficient_funds_for_tx; |
| 1578 | } |
| 1579 | |
| 1580 | // Handle parsing errors (couldn't parse the error message) |
| 1581 | if (parsedErrorMessageResult.error != null) { |
| 1582 | return S.current.insufficient_funds_for_tx; |
| 1583 | } |
| 1584 | |
| 1585 | // Handle successfully parsed errors with specific values |
| 1586 | return '''${S.current.insufficient_funds_for_tx} \n\n''' |
| 1587 | '''${S.current.balance}: ${parsedErrorMessageResult.balanceEth} ${feeCurrency} (${parsedErrorMessageResult.balanceUsd} ${fiatFromSettings.name})\n\n''' |
| 1588 | '''${S.current.transaction_cost}: ${parsedErrorMessageResult.txCostEth} ${feeCurrency} (${parsedErrorMessageResult.txCostUsd} ${fiatFromSettings.name})\n\n''' |
| 1589 | '''${S.current.overshot}: ${parsedErrorMessageResult.overshotEth} ${feeCurrency} (${parsedErrorMessageResult.overshotUsd} ${fiatFromSettings.name})'''; |
| 1590 | } |
| 1591 | |
| 1592 | if (errorMessage.contains('max fee per gas less than block base fee')) { |
| 1593 | return S.current.tx_retry_message; |
| 1594 | } |
| 1595 | |
| 1596 | return errorMessage; |
| 1597 | } |
| 1598 | |
| 1599 | if (walletType == WalletType.tron) { |
| 1600 | if (errorMessage.contains('balance is not sufficient')) { |
| 1601 | return S.current.do_not_have_enough_gas_asset(currency.toString()); |
| 1602 | } |
| 1603 | |
| 1604 | if (errorMessage.contains('Transaction expired')) { |
| 1605 | return S.current.tx_retry_message; |
| 1606 | } |
| 1607 | } |
| 1608 | |
| 1609 | if (error is TransactionWrongBalanceException) { |
| 1610 | if (error.amount != null) |
| 1611 | return S.current |
| 1612 | .tx_wrong_balance_with_amount_exception(currency.toString(), error.amount.toString()); |
| 1613 | |
| 1614 | return S.current.tx_wrong_balance_exception(currency.toString()); |
| 1615 | } |
| 1616 | if (error is TransactionNoInputsException) { |
| 1617 | return S.current.tx_not_enough_inputs_exception; |
| 1618 | } |
| 1619 | if (error is TransactionNoFeeException) { |
| 1620 | return S.current.tx_zero_fee_exception; |
| 1621 | } |
| 1622 | if (error is TransactionNoDustException) { |
| 1623 | return S.current.tx_no_dust_exception; |
| 1624 | } |
| 1625 | if (error is TransactionCommitFailedBIP68Final) { |
| 1626 | return S.current.trying_to_spend_locked_funds; |
| 1627 | } |
| 1628 | if (error is TransactionCommitFailed) { |
| 1629 | if (error.errorMessage != null && error.errorMessage!.contains("no peers replied")) { |
| 1630 | return S.current.tx_commit_failed_no_peers; |
| 1631 | } |
| 1632 | return "${S.current.tx_commit_failed}\nsupport@cakewallet.com${error.errorMessage != null ? "\n\n${error.errorMessage}" : ""}"; |
| 1633 | } |
| 1634 | if (error is TransactionCommitFailedDustChange) { |
| 1635 | return S.current.tx_rejected_dust_change; |
| 1636 | } |
| 1637 | if (error is TransactionCommitFailedDustOutput) { |
| 1638 | return S.current.tx_rejected_dust_output; |
| 1639 | } |
| 1640 | if (error is TransactionCommitFailedDustOutputSendAll) { |
| 1641 | return S.current.tx_rejected_dust_output_send_all; |
| 1642 | } |
| 1643 | if (error is TransactionCommitFailedVoutNegative) { |
| 1644 | return S.current.tx_rejected_vout_negative; |
| 1645 | } |
| 1646 | if (error is TransactionCommitFailedBIP68Final) { |
| 1647 | return S.current.tx_rejected_bip68_final; |
| 1648 | } |
| 1649 | if (error is TransactionCommitFailedLessThanMin) { |
| 1650 | return S.current.fee_less_than_min; |
| 1651 | } |
| 1652 | if (error is TransactionNoDustOnChangeException) { |
| 1653 | return S.current.tx_commit_exception_no_dust_on_change(error.min, error.max); |
| 1654 | } |
| 1655 | if (error is TransactionInputNotSupported) { |
| 1656 | return S.current.tx_invalid_input; |
| 1657 | } |
| 1658 | |
| 1659 | if (wallet.type == WalletType.bitcoin) { |
| 1660 | final lnError = getLightningErrorMessage(error); |
| 1661 | if (lnError != null) return lnError; |
| 1662 | } |
| 1663 | |
| 1664 | return errorMessage; |
| 1665 | } |
| 1666 | |
| 1667 | String? getLightningErrorMessage(Object error) { |
| 1668 | // TODO add more patterns |
| 1669 | Map<String, String> errorPatterns = {"insufficient funds": S.current.insufficient_funds_for_tx}; |
| 1670 | |
| 1671 | for (final pattern in errorPatterns.keys) { |
| 1672 | if (error.toString().contains(pattern)) return errorPatterns[pattern]!; |
| 1673 | } |
| 1674 | |
| 1675 | return null; |
| 1676 | } |
| 1677 | |
| 1678 | bool _hasTaprootInput(PendingTransaction? pendingTransaction) { |
| 1679 | if (walletType == WalletType.bitcoin && pendingTransaction != null) { |
| 1680 | return bitcoin!.hasTaprootInput(pendingTransaction); |
| 1681 | } |
| 1682 | |
| 1683 | return false; |
| 1684 | } |
| 1685 | |
| 1686 | Future<void> registerSwapsXyzTransaction(Trade trade) async { |
| 1687 | try { |
| 1688 | // register only for vmId is alt-vm or bridgeId is alt-vm (trade.needToRegisterInSwapXyz) |
| 1689 | final needToRegister = trade.needToRegisterInSwapXyz ?? false; |
| 1690 | if (!needToRegister) return; |
| 1691 | |
| 1692 | final vmId = (trade.providerId ?? '').toLowerCase(); |
| 1693 | if (vmId.isEmpty) { |
| 1694 | printV('SwapsXyz: transaction register: skipped (vmId empty)'); |
| 1695 | return; |
| 1696 | } |
| 1697 | |
| 1698 | final txHash = pendingTransaction?.evmTxHashFromRawHex ?? pendingTransaction?.id ?? ''; |
| 1699 | |
| 1700 | if (txHash.isEmpty) { |
| 1701 | printV('SwapsXyz: transaction register: skipped (txHash empty)'); |
| 1702 | return; |
| 1703 | } |
| 1704 | |
| 1705 | final chainId = int.tryParse(trade.router ?? '') ?? 0; |
| 1706 | if (chainId <= 0) { |
| 1707 | printV('SwapsXyz: transaction register: skipped (invalid chainId)'); |
| 1708 | return; |
| 1709 | } |
| 1710 | |
| 1711 | printV( |
| 1712 | 'SwapsXyz: attempting to register transaction: tradeId = ${trade.id}, txHash = $txHash, chainId = $chainId, vmId = $vmId'); |
| 1713 | |
| 1714 | final registered = await SwapsXyzExchangeProvider.registerAltVmTx( |
| 1715 | txId: trade.id, |
| 1716 | txHash: txHash, |
| 1717 | chainId: chainId, |
| 1718 | vmId: vmId, |
| 1719 | ); |
| 1720 | |
| 1721 | if (!registered) { |
| 1722 | printV('SwapsXyz: transaction register: failed'); |
| 1723 | } else { |
| 1724 | printV('SwapsXyz: transaction register: success'); |
| 1725 | } |
| 1726 | } catch (e) { |
| 1727 | printV('registerSwapsXyzTransaction error: $e'); |
| 1728 | } |
| 1729 | } |
| 1730 | |
| 1731 | @computed |
| 1732 | bool get usePayjoin => _settingsStore.usePayjoin; |
| 1733 | |
| 1734 | @observable |
| 1735 | String? payjoinUri; |
| 1736 | |
| 1737 | @action |
| 1738 | Future<void> fetchTokenForContractAddress(String contractAddress, |
| 1739 | {WalletType? walletType}) async { |
| 1740 | final token = await TokenUtilities.findTokenByAddress( |
| 1741 | walletType: walletType ?? wallet.type, |
| 1742 | address: contractAddress, |
| 1743 | ); |
| 1744 | |
| 1745 | if (token != null) { |
| 1746 | selectedCryptoCurrency = token; |
| 1747 | } |
| 1748 | } |
| 1749 | |
| 1750 | @action |
| 1751 | void applyAnyPayCurrency(CryptoCurrency currency) { |
| 1752 | selectedCryptoCurrency = currency; |
| 1753 | } |
| 1754 | |
| 1755 | String _decodeMethodSelector(String s) => |
| 1756 | (s.startsWith('0x') && s.length >= 10) ? s.substring(0, 10) : ''; |
| 1757 | } |