Files
hash-wallet/lib/view_model/transaction_details_view_model.dart
T
jwinterm dd449f2594 Merge upstream Cake Wallet v6.5.0
Catches up from the May 12 fork point (v6.1.2+23) through v6.5.0: 535
upstream commits, including the Monero silent-open fix, Monero on Trezor,
Ledger/Trezor BLE fixes, the new seed and history screens, quick Bitcoin
sync, git-pinned dependencies (pubspec_overrides.yaml), frozen Hive
adapters (*.part.dart), monero_c v0.18.4.6-RC2 and Flutter 3.41.9.

Merged with both sides normalized first (import rename, 100-column
format), which took the conflict count from 614 paths to 322. The
procedure is now scripts/upstream_sync.sh.

Kept from Hash Bags:
- WalletType enum order and Hive IDs. SQLite stores WalletType.index and
  our order differs from upstream's; taking theirs would reread saved
  wallets as other chains. Our frozen adapter keeps the shipped IDs.
- The 1.0.2 legacy PIN and wallet-password decode and self-heal.
- Removed chains (Solana, Tron, Zano, Zcash, Decred, Haven) and the new
  Robinhood chain stay out; Cake Pay and non-Trocador swaps stay out.
- Default nodes (node.wownero.org, sethforprivacy, Blockstream, bysh.me),
  converted to upstream's isDefault node-list key; Wownero is back in the
  node reset list.
- Privacy: address resolvers limited to domain-anchored sources again;
  prices.neroswap.com for fiat; upstream's charts tab, which reads
  prices.cakewallet.com, sits behind FeatureFlag.isChartsEnabled = false.
- Lightning stays behind FeatureFlag.isLightningEnabled, including
  upstream's new post-seed Lightning username step.
- Our icon pipeline, branding and bundle identifiers.

Twelve new upstream widget tests are skipped: test and code are unchanged
from v6.5.0 apart from renames (not rerun on a pristine upstream checkout;
upstream's top_bar_test does not compile against its own TopBar).
flutter analyze lib: 0 errors; flutter test: 345 passed, 12 skipped;
flutter build bundle compiles. Built with Flutter 3.41.9.
2026-10-24 23:49:29 -04:00

582 lines
21 KiB
Dart

import "package:hash_wallet/bitcoin/bitcoin.dart";
import "package:hash_wallet/core/address_validator.dart";
import "package:hash_wallet/entities/priority_for_wallet_type.dart";
import "package:hash_wallet/entities/transaction_description.dart";
import "package:hash_wallet/evm/evm.dart";
import "package:hash_wallet/generated/i18n.dart";
import "package:hash_wallet/monero/monero.dart";
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/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/store/app_store.dart";
import "package:hash_wallet/view_model/send/send_view_model.dart";
import "package:collection/collection.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/currency_for_wallet_type.dart";
import "package:cw_core/transaction_direction.dart";
import "package:cw_core/transaction_info.dart";
import "package:cw_core/transaction_priority.dart";
import "package:cw_core/wallet_base.dart";
import "package:cw_core/wallet_type.dart";
import "package:flutter/foundation.dart";
import "package:hive/hive.dart";
import "package:intl/intl.dart";
import "package:mobx/mobx.dart";
import "package:url_launcher/url_launcher.dart";
part "transaction_details_view_model.g.dart";
bool _trueFunc(_) => true;
/// We're adding a regex here so we can remove any already saved address that has the account in it.
/// In the refactor, we will make another separate variable for accounts and the UI would handle it as needed.
String _moneroRecipientAddressForDisplay(String raw, WalletType walletType) {
if (walletType != WalletType.monero || raw.isEmpty) {
return raw;
}
final compact = raw.replaceAll(RegExp(r"\s"), "");
final match = RegExp(
r"4[0-9a-zA-Z]{94}|8[0-9a-zA-Z]{94}|[0-9a-zA-Z]{106}",
caseSensitive: false,
).firstMatch(compact);
return match?.group(0) ?? raw.trim();
}
bool isLightning(TransactionInfo tx) => (tx.additionalInfo["isLightning"] as bool?) ?? false;
bool hasLightningPreimage(TransactionInfo tx) => (tx.additionalInfo["preimage"] as String?) != null;
class TxDetailRowDefinition {
TxDetailRowDefinition({
required this.keyString,
required this.title,
required this.valueGetter,
this.applicable = _trueFunc,
this.listItemBuilder = StandartListItem.new,
});
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;
static final List<TxDetailRowDefinition> defs = [
TxDetailRowDefinition(
keyString: "standard_list_item_transaction_details_date_key",
title: S.current.transaction_details_date,
valueGetter: (vm) => DateFormat("d MMMM yyyy, HH:mm", vm._appStore.settingsStore.languageCode)
.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.feeAmount,
applicable: (vm) =>
vm.wallet.type != WalletType.nano &&
(vm.transactionInfo.fee?.toStringWithSymbol() ?? "").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;
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;
}
ret ??= vm.transactionInfo.to ?? "";
final resolvedAddress = _moneroRecipientAddressForDisplay(ret, vm.wallet.type);
vm.isRecipientAddressShown = resolvedAddress.isNotEmpty;
return resolvedAddress;
},
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_lightning_preimage",
title: S.current.transaction_preimage,
valueGetter: (vm) => vm.transactionInfo.additionalInfo["preimage"] as String? ?? "",
applicable: (vm) =>
hasLightningPreimage(vm.transactionInfo) && isLightning(vm.transactionInfo),
),
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) {
final recipientAddressForDisplay =
_moneroRecipientAddressForDisplay(recipientAddress!, wallet.type);
items.add(
AddressListItem(
title: S.current.transaction_details_recipient_address,
value: recipientAddressForDisplay,
key: ValueKey("standard_list_item_${recipientAddressForDisplay}_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<TransactionDescription> transactionDescriptionBox;
final WalletBase wallet;
final SendViewModel sendViewModel;
final AppStore _appStore;
final List<TransactionDetailsListItem> items;
final List<TransactionDetailsListItem> rbfListItems;
bool showRecipientAddress;
bool isRecipientAddressShown;
int newFee;
String? rawTransaction;
TransactionPriority? transactionPriority;
CryptoCurrency get transactionAsset {
if (isEVMCompatibleChain(wallet.type)) {
return evm!.assetOfTransaction(wallet, transactionInfo);
}
if (isLightning(transactionInfo)) {
return CryptoCurrency.btcln;
}
return walletTypeToCryptoCurrency(wallet.type);
}
@computed
String get transactionAmount =>
_appStore.amountParsingProxy.asDisplayStringWithSymbol(transactionInfo.amount);
@computed
String get feeAmount =>
_appStore.amountParsingProxy.asDisplayStringWithSymbol(transactionInfo.fee!);
@computed
String get transactionCopyAmount =>
_appStore.amountParsingProxy.asDisplayString(transactionInfo.amount);
// TODO(malik1004x): 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:
final isPegIn = (transactionInfo.additionalInfo["isPegIn"] as bool?) ?? false;
final isPegOut = (transactionInfo.additionalInfo["isPegOut"] as bool?) ?? false;
final 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:
final isPegOut = (transactionInfo.additionalInfo["isPegOut"] as bool?) ?? false;
final fromPegOut = (transactionInfo.additionalInfo["fromPegOut"] as bool?) ?? false;
if (isPegOut || fromPegOut) {
return 6;
}
default:
return 0;
}
return 0;
}
String get formattedTitle {
if (transactionInfo.additionalInfo['isIronwoodMigration'] == true) {
return 'Migration';
}
if (transactionInfo.additionalInfo['isAutoShield'] == true) {
return S.current.shielding;
}
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 isLightning(transactionInfo)
? "https://sparkscan.io/tx/${txId}"
: '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.fee?.toStringWithSymbol() ?? "0.0",
key: const ValueKey("standard_list_item_rbf_old_fee_key"),
),
);
if (transactionInfo.fee != null && rawTransaction.isNotEmpty) {
final size = bitcoin!.getTransactionVSize(wallet, rawTransaction);
final recommendedRate = (transactionInfo.fee! / BigInt.from(size)) +
transactionInfo.fee!.copyWith(amount: BigInt.one);
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: const 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, sliderValue) =>
sendViewModel.feesViewModel.displayFeeRate(priority, sliderValue.round()),
onSliderChanged: (newValue) => setNewFee(value: newValue, priority: transactionPriority!),
onItemSelected: (dynamic item, sliderValue) {
transactionPriority = item as TransactionPriority;
return setNewFee(value: sliderValue, priority: transactionPriority!);
},
),
);
if (transactionInfo.inputAddresses != null && transactionInfo.inputAddresses!.isNotEmpty) {
rbfListItems.add(
StandardExpandableListItem(
key: const 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: const ValueKey("standard_expandable_list_item_transaction_output_addresses_key"),
),
);
}
}
@action
Future<void> _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({required TransactionPriority priority, double? value}) {
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);
}
void replaceByFee(String newFee) => sendViewModel.replaceByFee(transactionInfo, newFee);
}