import 'package:hash_wallet/reactions/wallet_connect.dart'; import 'package:hash_wallet/src/screens/transaction_details/address_list_item.dart'; import 'package:hash_wallet/src/screens/transaction_details/confirmations_list_item.dart'; import 'package:hash_wallet/store/app_store.dart'; import 'package:hash_wallet/core/address_validator.dart'; import 'package:cw_core/crypto_amount_format.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/currency_for_wallet_type.dart'; import 'package:cw_core/utils/print_verbose.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/transaction_info.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:hash_wallet/evm/evm.dart'; import 'package:hash_wallet/bitcoin/bitcoin.dart'; import 'package:hash_wallet/entities/priority_for_wallet_type.dart'; import 'package:hash_wallet/entities/transaction_description.dart'; import 'package:hash_wallet/generated/i18n.dart'; import 'package:hash_wallet/monero/monero.dart'; import 'package:hash_wallet/src/screens/transaction_details/rbf_details_list_fee_picker_item.dart'; import 'package:hash_wallet/src/screens/transaction_details/standart_list_item.dart'; import 'package:hash_wallet/src/screens/transaction_details/transaction_details_list_item.dart'; import 'package:hash_wallet/src/screens/transaction_details/transaction_expandable_list_item.dart'; import 'package:hash_wallet/utils/date_formatter.dart'; import 'package:hash_wallet/view_model/send/send_view_model.dart'; import 'package:collection/collection.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/transaction_priority.dart'; import 'package:flutter/foundation.dart'; import 'package:hive/hive.dart'; import 'package:mobx/mobx.dart'; import 'package:url_launcher/url_launcher.dart'; part 'transaction_details_view_model.g.dart'; bool _trueFunc(_) => true; bool isLightning(TransactionInfo tx) { printV(tx.additionalInfo); return (tx.additionalInfo["isLightning"] as bool?) ?? false; } class TxDetailRowDefinition { final String keyString; final String title; final String Function(TransactionDetailsViewModelBase) valueGetter; final bool Function(TransactionDetailsViewModelBase) applicable; final dynamic Function({ required String title, required String value, required Key key, }) listItemBuilder; TxDetailRowDefinition( {required this.keyString, required this.title, required this.valueGetter, this.applicable = _trueFunc, this.listItemBuilder = StandartListItem.new}); static final List defs = [ TxDetailRowDefinition( keyString: "standard_list_item_transaction_details_date_key", title: S.current.transaction_details_date, valueGetter: (vm) => DateFormatter.withCurrentLocal().format(vm.transactionInfo.date)), TxDetailRowDefinition( keyString: "standard_list_item_transaction_details_height_key", title: S.current.transaction_details_height, valueGetter: (vm) => vm.transactionInfo.height?.toString() ?? "", applicable: (vm) => !isLightning(vm.transactionInfo)), TxDetailRowDefinition( keyString: "standard_list_item_transaction_details_fee_key", title: S.current.transaction_details_fee, valueGetter: (vm) => vm.transactionInfo.feeFormatted()!, applicable: (vm) => vm.wallet.type != WalletType.nano && (vm.transactionInfo.feeFormatted() ?? "").isNotEmpty), TxDetailRowDefinition( keyString: "standard_list_item_transaction_confirmations_key", title: S.current.confirmations, valueGetter: (vm) => "${vm.transactionInfo.confirmations}/${vm.neededConfirmations}", applicable: (vm) => [...electrumWalletTypes, ...evmWalletTypes, WalletType.monero] .contains(vm.wallet.type) && !isLightning(vm.transactionInfo), listItemBuilder: ConfirmationsListItem.new), TxDetailRowDefinition( keyString: "standard_list_item_transaction_details_recipient_address_key", title: S.current.transaction_details_recipient_address, valueGetter: (vm) { String? ret = null; switch (vm.wallet.type) { case WalletType.monero: if (vm.transactionInfo.direction == TransactionDirection.incoming) { ret = monero!.getTransactionAddress( vm.wallet, vm.transactionInfo.additionalInfo['accountIndex'] as int, vm.transactionInfo.additionalInfo['addressIndex'] as int); } case WalletType.bitcoin: ret = (bitcoin!.getTransactionAddresses(vm.wallet, vm.transactionInfo) ?? []) .firstOrNull ?? ""; default: break; } if(ret == null) { ret = vm.transactionInfo.to ?? ""; } vm.isRecipientAddressShown = ret.isNotEmpty; return ret; }, applicable: (vm) => vm.showRecipientAddress && (vm.transactionInfo.to != null || [WalletType.monero].contains(vm.wallet.type) || vm.wallet.type == WalletType.bitcoin && vm.transactionInfo.direction == TransactionDirection.incoming), listItemBuilder: AddressListItem.new), TxDetailRowDefinition( keyString: "standard_list_item_transaction_details_source_address_key", title: S.current.transaction_details_source_address, valueGetter: (vm) => vm.transactionInfo.from!, applicable: (vm) => vm.transactionInfo.from != null, listItemBuilder: AddressListItem.new), TxDetailRowDefinition( keyString: "standard_list_item_address_label_key", title: S.current.address_label, valueGetter: (vm) => monero!.getSubaddressLabel( vm.wallet, vm.transactionInfo.additionalInfo['accountIndex'] as int, vm.transactionInfo.additionalInfo['addressIndex'] as int), applicable: (vm) => vm.wallet.type == WalletType.monero), TxDetailRowDefinition( keyString: "standard_list_item_transaction_key", title: S.current.transaction_key, valueGetter: (vm) { final descriptionKey = '${vm.transactionInfo.txHash}_${vm.wallet.walletAddresses.primaryAddress}'; final description = vm.transactionDescriptionBox.values.firstWhere( (val) => val.id == descriptionKey || val.id == vm.transactionInfo.txHash, orElse: () => TransactionDescription(id: descriptionKey)); return vm.transactionInfo.additionalInfo['key'] as String? ?? description.transactionKey ?? ""; }, applicable: (vm) => vm.wallet.type == WalletType.monero), TxDetailRowDefinition( keyString: "standard_list_item_transaction_confirmed_key", title: S.current.confirmed_tx, valueGetter: (vm) => (vm.transactionInfo.confirmations > 0).toString(), applicable: (vm) => vm.wallet.type == WalletType.nano), TxDetailRowDefinition( keyString: "standard_list_item_transaction_details_id_key", title: S.current.transaction_details_transaction_id, valueGetter: (vm) => vm.transactionInfo.txHash, ), ]; } class TransactionDetailsViewModel = TransactionDetailsViewModelBase with _$TransactionDetailsViewModel; abstract class TransactionDetailsViewModelBase with Store { TransactionDetailsViewModelBase({ required this.transactionInfo, required this.transactionDescriptionBox, required this.wallet, required AppStore appStore, required this.sendViewModel, this.canReplaceByFee = false, }) : items = [], RBFListItems = [], newFee = 0, isRecipientAddressShown = false, _appStore = appStore, showRecipientAddress = appStore.settingsStore.shouldSaveRecipientAddress { final tx = transactionInfo; for (final def in TxDetailRowDefinition.defs) { if (def.applicable(this)) { items.add(def.listItemBuilder( title: def.title, value: def.valueGetter(this), key: ValueKey(def.keyString)) as TransactionDetailsListItem); } } _checkForRBF(tx); final descriptionKey = '${transactionInfo.txHash}_${wallet.walletAddresses.primaryAddress}'; final description = transactionDescriptionBox.values.firstWhere( (val) => val.id == descriptionKey || val.id == transactionInfo.txHash, orElse: () => TransactionDescription(id: descriptionKey)); if (showRecipientAddress && !isRecipientAddressShown) { final recipientAddress = description.recipientAddress; if (recipientAddress?.isNotEmpty ?? false) { items.add( AddressListItem( title: S.current.transaction_details_recipient_address, value: recipientAddress!, key: ValueKey('standard_list_item_${recipientAddress}_key'), ), ); } } } void updateNote(String note) { final descriptionKey = '${transactionInfo.txHash}_${wallet.walletAddresses.primaryAddress}'; final description = transactionDescriptionBox.values.firstWhere( (val) => val.id == descriptionKey || val.id == transactionInfo.txHash, orElse: () => TransactionDescription(id: descriptionKey)); description.transactionNote = note; if (description.isInBox) { description.save(); } else { transactionDescriptionBox.add(description); } } String get note { final descriptionKey = '${transactionInfo.txHash}_${wallet.walletAddresses.primaryAddress}'; final description = transactionDescriptionBox.values.firstWhereOrNull( (val) => val.id == descriptionKey || val.id == transactionInfo.txHash); return description?.transactionNote??""; } final TransactionInfo transactionInfo; final Box transactionDescriptionBox; final WalletBase wallet; final SendViewModel sendViewModel; final AppStore _appStore; final List items; final List RBFListItems; bool showRecipientAddress; bool isRecipientAddressShown; int newFee; String? rawTransaction; TransactionPriority? transactionPriority; CryptoCurrency get transactionAsset { if (isEVMCompatibleChain(wallet.type)) { return evm!.assetOfTransaction(wallet, transactionInfo); } return walletTypeToCryptoCurrency(wallet.type); } // TODO integrate these getters with the TransactionInfo object String get formattedPendingStatus { switch (wallet.type) { case WalletType.monero: if (transactionInfo.confirmations >= 0 && transactionInfo.confirmations < 10) { return ' (${transactionInfo.confirmations}/10)'; } break; case WalletType.wownero: if (transactionInfo.confirmations >= 0 && transactionInfo.confirmations < 3) { return ' (${transactionInfo.confirmations}/3)'; } break; case WalletType.litecoin: bool isPegIn = (transactionInfo.additionalInfo["isPegIn"] as bool?) ?? false; bool isPegOut = (transactionInfo.additionalInfo["isPegOut"] as bool?) ?? false; bool fromPegOut = (transactionInfo.additionalInfo["fromPegOut"] as bool?) ?? false; String str = ''; if (transactionInfo.confirmations <= 0) { str = S.current.pending; } if ((isPegOut || fromPegOut) && transactionInfo.confirmations >= 0 && transactionInfo.confirmations < 6) { str = " (${transactionInfo.confirmations}/6)"; } if (isPegIn) { str += " (Peg In)"; } if (isPegOut) { str += " (Peg Out)"; } return str; default: return ''; } return ''; } String get formattedStatus { if ([ WalletType.monero, WalletType.wownero, WalletType.litecoin, ].contains(wallet.type)) { return formattedPendingStatus; } return transactionInfo.isPending ? S.current.pending : ''; } int get neededConfirmations { switch (wallet.type) { case WalletType.monero: return 10; case WalletType.wownero: return 3; case WalletType.litecoin: bool isPegOut = (transactionInfo.additionalInfo["isPegOut"] as bool?) ?? false; bool fromPegOut = (transactionInfo.additionalInfo["fromPegOut"] as bool?) ?? false; if(isPegOut || fromPegOut) return 6; default: return 0; } return 0; } String get formattedTitle { if (transactionInfo.additionalInfo['autoShield'] == true) { return "Autoshield"; } if (transactionInfo.direction == TransactionDirection.incoming) { return S.current.received; } return S.current.sent; } @observable bool canReplaceByFee; String get _explorerUrl { final txId = transactionInfo.txHash; if (wallet.chainId != null) { final explorerUrl = evm!.getExplorerUrlForChainId(wallet.chainId!); if (explorerUrl != null) return '$explorerUrl/tx/${txId}'; } switch (wallet.type) { case WalletType.monero: return 'https://monero.com/tx/${txId}'; case WalletType.bitcoin: return 'https://mempool.space/${wallet.isTestnet ? "testnet/" : ""}tx/${txId}'; case WalletType.litecoin: return bitcoin!.txIsMweb(transactionInfo) ? "https://www.mwebexplorer.com/blocks/block/${transactionInfo.height}" : 'https://blockchair.com/litecoin/transaction/${txId}'; case WalletType.bitcoinCash: return 'https://blockchair.com/bitcoin-cash/transaction/${txId}'; case WalletType.ethereum: return 'https://etherscan.io/tx/${txId}'; case WalletType.base: return 'https://basescan.org/tx/${txId}'; case WalletType.arbitrum: return 'https://arbiscan.io/tx/${txId}'; case WalletType.bsc: return 'https://bscscan.com/tx/${txId}'; case WalletType.polygon: return 'https://polygonscan.com/tx/${txId}'; case WalletType.nano: return 'https://nanexplorer.com/nano/block/${txId}'; case WalletType.banano: return 'https://nanexplorer.com/banano/block/${txId}'; case WalletType.wownero: return 'https://explore.wownero.com/tx/${txId}'; case WalletType.dogecoin: return 'https://blockchair.com/dogecoin/transaction/${txId}'; case WalletType.none: return ''; } } String get explorerDescription => S.current.view_transaction_on + Uri.parse(_explorerUrl).host; void launchExplorer() { launchUrl(Uri.parse(_explorerUrl)); } void addBumpFeesListItems(TransactionInfo tx, String rawTransaction) { transactionPriority = bitcoin!.getBitcoinTransactionPriorityMedium(); final inputsCount = (transactionInfo.inputAddresses?.isEmpty ?? true) ? 1 : transactionInfo.inputAddresses!.length; final outputsCount = (transactionInfo.outputAddresses?.isEmpty ?? true) ? 1 : transactionInfo.outputAddresses!.length; newFee = bitcoin!.getFeeAmountForPriority( wallet, bitcoin!.getBitcoinTransactionPriorityMedium(), inputsCount, outputsCount); RBFListItems.add( StandartListItem( title: S.current.old_fee, value: tx.feeFormatted() ?? '0.0', key: ValueKey('standard_list_item_rbf_old_fee_key'), ), ); if (transactionInfo.fee != null && rawTransaction.isNotEmpty) { final size = bitcoin!.getTransactionVSize(wallet, rawTransaction); final recommendedRate = (transactionInfo.fee! / size).round() + 1; RBFListItems.add( StandartListItem(title: 'New recommended fee rate', value: '$recommendedRate sat/byte')); } final priorities = priorityForWalletType(wallet.type); final selectedItem = priorities.indexOf(sendViewModel.feesViewModel.transactionPriority); final customItem = priorities.firstWhereOrNull( (element) => element == sendViewModel.feesViewModel.bitcoinTransactionPriorityCustom); final customItemIndex = customItem != null ? priorities.indexOf(customItem) : null; final maxCustomFeeRate = sendViewModel.feesViewModel.maxCustomFeeRate?.toDouble(); RBFListItems.add( StandardPickerListItem( key: ValueKey('standard_picker_list_item_transaction_priorities_key'), title: S.current.estimated_new_fee, value: bitcoin!.formatterBitcoinAmountToString(amount: newFee) + ' ${wallet.currency}', items: priorityForWalletType(wallet.type), customValue: _appStore.settingsStore.customBitcoinFeeRate.toDouble(), maxValue: maxCustomFeeRate, selectedIdx: selectedItem, customItemIndex: customItemIndex ?? 0, displayItem: (dynamic priority, double sliderValue) => sendViewModel.feesViewModel.displayFeeRate(priority, sliderValue.round()), onSliderChanged: (double newValue) => setNewFee(value: newValue, priority: transactionPriority!), onItemSelected: (dynamic item, double sliderValue) { transactionPriority = item as TransactionPriority; return setNewFee(value: sliderValue, priority: transactionPriority!); }, ), ); if (transactionInfo.inputAddresses != null && transactionInfo.inputAddresses!.isNotEmpty) { RBFListItems.add( StandardExpandableListItem( key: ValueKey('standard_expandable_list_item_transaction_input_addresses_key'), title: S.current.inputs, expandableItems: transactionInfo.inputAddresses!, ), ); } if (transactionInfo.outputAddresses != null && transactionInfo.outputAddresses!.isNotEmpty) { final outputAddresses = transactionInfo.outputAddresses!.map((element) { if (element.contains('OP_RETURN:') && element.length > 40) { return element.substring(0, 40) + '...'; } return element; }).toList(); RBFListItems.add( StandardExpandableListItem( title: S.current.outputs, expandableItems: outputAddresses, key: ValueKey('standard_expandable_list_item_transaction_output_addresses_key'), ), ); } } @action Future _checkForRBF(TransactionInfo tx) async { if (wallet.type == WalletType.bitcoin && transactionInfo.direction == TransactionDirection.outgoing) { final descriptionKey = '${transactionInfo.txHash}_${wallet.walletAddresses.primaryAddress}'; final description = transactionDescriptionBox.values .firstWhereOrNull((val) => val.id == descriptionKey || val.id == transactionInfo.txHash); if (RegExp(AddressValidator.silentPaymentAddressPatternMainnet) .hasMatch(description?.recipientAddress ?? "")) { canReplaceByFee = false; return; } rawTransaction = await bitcoin!.canReplaceByFee(wallet, tx); if (rawTransaction != null) { canReplaceByFee = true; } } } String setNewFee({double? value, required TransactionPriority priority}) { newFee = priority == bitcoin!.getBitcoinTransactionPriorityCustom() && value != null ? bitcoin!.feeAmountWithFeeRate( wallet, value.round(), transactionInfo.inputAddresses?.length ?? 1, transactionInfo.outputAddresses?.length ?? 1) : bitcoin!.getFeeAmountForPriority( wallet, priority, transactionInfo.inputAddresses?.length ?? 1, transactionInfo.outputAddresses?.length ?? 1); return bitcoin!.formatterBitcoinAmountToString(amount: newFee); } String get formattedCryptoAmount { if (wallet.type == WalletType.bitcoin) { final crypto = isLightning(transactionInfo) ? CryptoCurrency.btcln : CryptoCurrency.btc; final amount = _appStore.amountParsingProxy .getDisplayCryptoString(transactionInfo.amount, crypto) .withMaxDecimals(8) .withLocalSeperator(_appStore.settingsStore.languageCode); return '$amount ${_appStore.amountParsingProxy.getCryptoSymbol(crypto)}'; } return transactionInfo.amountFormatted(); } void replaceByFee(String newFee) => sendViewModel.replaceByFee(transactionInfo, newFee); @computed String get pendingTransactionFiatAmountValueFormatted => sendViewModel.isFiatDisabled ? '' : sendViewModel.pendingTransactionFiatAmount + ' ' + sendViewModel.fiat.title; @computed String get pendingTransactionFeeFiatAmountFormatted => sendViewModel.isFiatDisabled ? '' : sendViewModel.pendingTransactionFeeFiatAmount + ' ' + sendViewModel.fiat.title; }