| 1 | import "package:cake_wallet/bitcoin/bitcoin.dart"; |
| 2 | import "package:cake_wallet/core/address_validator.dart"; |
| 3 | import "package:cake_wallet/entities/priority_for_wallet_type.dart"; |
| 4 | import "package:cake_wallet/entities/transaction_description.dart"; |
| 5 | import "package:cake_wallet/evm/evm.dart"; |
| 6 | import "package:cake_wallet/generated/i18n.dart"; |
| 7 | import "package:cake_wallet/monero/monero.dart"; |
| 8 | import "package:cake_wallet/reactions/wallet_connect.dart"; |
| 9 | import "package:cake_wallet/solana/solana.dart"; |
| 10 | import "package:cake_wallet/src/screens/transaction_details/address_list_item.dart"; |
| 11 | import "package:cake_wallet/src/screens/transaction_details/confirmations_list_item.dart"; |
| 12 | import "package:cake_wallet/src/screens/transaction_details/rbf_details_list_fee_picker_item.dart"; |
| 13 | import "package:cake_wallet/src/screens/transaction_details/standart_list_item.dart"; |
| 14 | import "package:cake_wallet/src/screens/transaction_details/transaction_details_list_item.dart"; |
| 15 | import "package:cake_wallet/src/screens/transaction_details/transaction_expandable_list_item.dart"; |
| 16 | import "package:cake_wallet/store/app_store.dart"; |
| 17 | import "package:cake_wallet/tron/tron.dart"; |
| 18 | import "package:cake_wallet/view_model/send/send_view_model.dart"; |
| 19 | import "package:cake_wallet/zano/zano.dart"; |
| 20 | import "package:collection/collection.dart"; |
| 21 | import "package:cw_core/crypto_currency.dart"; |
| 22 | import "package:cw_core/currency_for_wallet_type.dart"; |
| 23 | import "package:cw_core/transaction_direction.dart"; |
| 24 | import "package:cw_core/transaction_info.dart"; |
| 25 | import "package:cw_core/transaction_priority.dart"; |
| 26 | import "package:cw_core/wallet_base.dart"; |
| 27 | import "package:cw_core/wallet_type.dart"; |
| 28 | import "package:flutter/foundation.dart"; |
| 29 | import "package:hive/hive.dart"; |
| 30 | import "package:intl/intl.dart"; |
| 31 | import "package:mobx/mobx.dart"; |
| 32 | import "package:url_launcher/url_launcher.dart"; |
| 33 | |
| 34 | part "transaction_details_view_model.g.dart"; |
| 35 | |
| 36 | bool _trueFunc(_) => true; |
| 37 | |
| 38 | /// We're adding a regex here so we can remove any already saved address that has the account in it. |
| 39 | /// In the refactor, we will make another separate variable for accounts and the UI would handle it as needed. |
| 40 | String _moneroRecipientAddressForDisplay(String raw, WalletType walletType) { |
| 41 | if (walletType != WalletType.monero || raw.isEmpty) { |
| 42 | return raw; |
| 43 | } |
| 44 | |
| 45 | final compact = raw.replaceAll(RegExp(r"\s"), ""); |
| 46 | final match = RegExp( |
| 47 | r"4[0-9a-zA-Z]{94}|8[0-9a-zA-Z]{94}|[0-9a-zA-Z]{106}", |
| 48 | caseSensitive: false, |
| 49 | ).firstMatch(compact); |
| 50 | return match?.group(0) ?? raw.trim(); |
| 51 | } |
| 52 | |
| 53 | bool isLightning(TransactionInfo tx) => (tx.additionalInfo["isLightning"] as bool?) ?? false; |
| 54 | |
| 55 | bool hasLightningPreimage(TransactionInfo tx) => (tx.additionalInfo["preimage"] as String?) != null; |
| 56 | |
| 57 | class TxDetailRowDefinition { |
| 58 | TxDetailRowDefinition({ |
| 59 | required this.keyString, |
| 60 | required this.title, |
| 61 | required this.valueGetter, |
| 62 | this.applicable = _trueFunc, |
| 63 | this.listItemBuilder = StandartListItem.new, |
| 64 | }); |
| 65 | |
| 66 | final String keyString; |
| 67 | final String title; |
| 68 | final String Function(TransactionDetailsViewModelBase) valueGetter; |
| 69 | final bool Function(TransactionDetailsViewModelBase) applicable; |
| 70 | final dynamic Function({ |
| 71 | required String title, |
| 72 | required String value, |
| 73 | required Key key, |
| 74 | }) listItemBuilder; |
| 75 | |
| 76 | static final List<TxDetailRowDefinition> defs = [ |
| 77 | TxDetailRowDefinition( |
| 78 | keyString: "standard_list_item_transaction_details_date_key", |
| 79 | title: S.current.transaction_details_date, |
| 80 | valueGetter: (vm) => DateFormat("d MMMM yyyy, HH:mm", vm._appStore.settingsStore.languageCode) |
| 81 | .format(vm.transactionInfo.date), |
| 82 | ), |
| 83 | TxDetailRowDefinition( |
| 84 | keyString: "standard_list_item_transaction_details_height_key", |
| 85 | title: S.current.transaction_details_height, |
| 86 | valueGetter: (vm) => vm.transactionInfo.height?.toString() ?? "", |
| 87 | applicable: (vm) => |
| 88 | ![WalletType.solana, WalletType.tron].contains(vm.wallet.type) || |
| 89 | !isLightning(vm.transactionInfo), |
| 90 | ), |
| 91 | TxDetailRowDefinition( |
| 92 | keyString: "standard_list_item_transaction_details_fee_key", |
| 93 | title: S.current.transaction_details_fee, |
| 94 | valueGetter: (vm) => vm.feeAmount, |
| 95 | applicable: (vm) => |
| 96 | vm.wallet.type != WalletType.nano && |
| 97 | (vm.transactionInfo.fee?.toStringWithSymbol() ?? "").isNotEmpty, |
| 98 | ), |
| 99 | TxDetailRowDefinition( |
| 100 | keyString: "standard_list_item_transaction_confirmations_key", |
| 101 | title: S.current.confirmations, |
| 102 | valueGetter: (vm) => "${vm.transactionInfo.confirmations}/${vm.neededConfirmations}", |
| 103 | applicable: (vm) => |
| 104 | [...electrumWalletTypes, ...evmWalletTypes, WalletType.zcash, WalletType.monero] |
| 105 | .contains(vm.wallet.type) && |
| 106 | !isLightning(vm.transactionInfo), |
| 107 | listItemBuilder: ConfirmationsListItem.new, |
| 108 | ), |
| 109 | TxDetailRowDefinition( |
| 110 | keyString: "standard_list_item_transaction_details_recipient_address_key", |
| 111 | title: S.current.transaction_details_recipient_address, |
| 112 | valueGetter: (vm) { |
| 113 | String? ret; |
| 114 | |
| 115 | switch (vm.wallet.type) { |
| 116 | case WalletType.monero: |
| 117 | if (vm.transactionInfo.direction == TransactionDirection.incoming) { |
| 118 | ret = monero!.getTransactionAddress( |
| 119 | vm.wallet, |
| 120 | vm.transactionInfo.additionalInfo["accountIndex"] as int, |
| 121 | vm.transactionInfo.additionalInfo["addressIndex"] as int, |
| 122 | ); |
| 123 | } |
| 124 | case WalletType.bitcoin: |
| 125 | ret = (bitcoin!.getTransactionAddresses(vm.wallet, vm.transactionInfo) ?? []) |
| 126 | .firstOrNull ?? |
| 127 | ""; |
| 128 | case WalletType.tron: |
| 129 | if (vm.transactionInfo.to != null) { |
| 130 | ret = tron!.getTronBase58Address(vm.transactionInfo.to!, vm.wallet); |
| 131 | } |
| 132 | default: |
| 133 | break; |
| 134 | } |
| 135 | ret ??= vm.transactionInfo.to ?? ""; |
| 136 | |
| 137 | final resolvedAddress = _moneroRecipientAddressForDisplay(ret, vm.wallet.type); |
| 138 | vm.isRecipientAddressShown = resolvedAddress.isNotEmpty; |
| 139 | return resolvedAddress; |
| 140 | }, |
| 141 | applicable: (vm) => |
| 142 | vm.showRecipientAddress && |
| 143 | (vm.transactionInfo.to != null || |
| 144 | [WalletType.monero, WalletType.tron].contains(vm.wallet.type) || |
| 145 | vm.wallet.type == WalletType.bitcoin && |
| 146 | vm.transactionInfo.direction == TransactionDirection.incoming), |
| 147 | listItemBuilder: AddressListItem.new, |
| 148 | ), |
| 149 | TxDetailRowDefinition( |
| 150 | keyString: "standard_list_item_transaction_details_source_address_key", |
| 151 | title: S.current.transaction_details_source_address, |
| 152 | valueGetter: (vm) { |
| 153 | switch (vm.wallet.type) { |
| 154 | case WalletType.tron: |
| 155 | return tron!.getTronBase58Address(vm.transactionInfo.from!, vm.wallet); |
| 156 | default: |
| 157 | return vm.transactionInfo.from!; |
| 158 | } |
| 159 | }, |
| 160 | applicable: (vm) => vm.transactionInfo.from != null, |
| 161 | listItemBuilder: AddressListItem.new, |
| 162 | ), |
| 163 | TxDetailRowDefinition( |
| 164 | keyString: "standard_list_item_address_label_key", |
| 165 | title: S.current.address_label, |
| 166 | valueGetter: (vm) => monero!.getSubaddressLabel( |
| 167 | vm.wallet, |
| 168 | vm.transactionInfo.additionalInfo["accountIndex"] as int, |
| 169 | vm.transactionInfo.additionalInfo["addressIndex"] as int, |
| 170 | ), |
| 171 | applicable: (vm) => vm.wallet.type == WalletType.monero, |
| 172 | ), |
| 173 | TxDetailRowDefinition( |
| 174 | keyString: "standard_list_item_transaction_key", |
| 175 | title: S.current.transaction_key, |
| 176 | valueGetter: (vm) { |
| 177 | final descriptionKey = |
| 178 | "${vm.transactionInfo.txHash}_${vm.wallet.walletAddresses.primaryAddress}"; |
| 179 | |
| 180 | final description = vm.transactionDescriptionBox.values.firstWhere( |
| 181 | (val) => val.id == descriptionKey || val.id == vm.transactionInfo.txHash, |
| 182 | orElse: () => TransactionDescription(id: descriptionKey), |
| 183 | ); |
| 184 | return vm.transactionInfo.additionalInfo["key"] as String? ?? |
| 185 | description.transactionKey ?? |
| 186 | ""; |
| 187 | }, |
| 188 | applicable: (vm) => vm.wallet.type == WalletType.monero, |
| 189 | ), |
| 190 | TxDetailRowDefinition( |
| 191 | keyString: "standard_list_item_lightning_preimage", |
| 192 | title: S.current.transaction_preimage, |
| 193 | valueGetter: (vm) => vm.transactionInfo.additionalInfo["preimage"] as String? ?? "", |
| 194 | applicable: (vm) => |
| 195 | hasLightningPreimage(vm.transactionInfo) && isLightning(vm.transactionInfo), |
| 196 | ), |
| 197 | TxDetailRowDefinition( |
| 198 | keyString: "standard_list_item_transaction_confirmed_key", |
| 199 | title: S.current.confirmed_tx, |
| 200 | valueGetter: (vm) => (vm.transactionInfo.confirmations > 0).toString(), |
| 201 | applicable: (vm) => vm.wallet.type == WalletType.nano, |
| 202 | ), |
| 203 | TxDetailRowDefinition( |
| 204 | keyString: "standard_list_item_transaction_details_memo_key", |
| 205 | title: S.current.memo, |
| 206 | valueGetter: (vm) => vm.transactionInfo.additionalInfo["memo"] as String, |
| 207 | applicable: (vm) => |
| 208 | vm.wallet.type == WalletType.zcash && vm.transactionInfo.additionalInfo["memo"] != null, |
| 209 | ), |
| 210 | TxDetailRowDefinition( |
| 211 | keyString: "standard_list_item_transaction_details_asset_id_key", |
| 212 | title: "Asset ID", |
| 213 | valueGetter: (vm) => |
| 214 | vm.transactionInfo.additionalInfo["assetId"] as String? ?? "Unknown asset id", |
| 215 | applicable: (vm) => vm.wallet.type == WalletType.zano, |
| 216 | ), |
| 217 | TxDetailRowDefinition( |
| 218 | keyString: "standard_list_item_transaction_details_comment_key", |
| 219 | title: S.current.transaction_details_title, |
| 220 | valueGetter: (vm) => vm.transactionInfo.additionalInfo["comment"] as String? ?? "", |
| 221 | applicable: (vm) => vm.wallet.type == WalletType.zano, |
| 222 | ), |
| 223 | TxDetailRowDefinition( |
| 224 | keyString: "standard_list_item_transaction_details_id_key", |
| 225 | title: S.current.transaction_details_transaction_id, |
| 226 | valueGetter: (vm) => vm.transactionInfo.txHash, |
| 227 | ), |
| 228 | ]; |
| 229 | } |
| 230 | |
| 231 | class TransactionDetailsViewModel = TransactionDetailsViewModelBase |
| 232 | with _$TransactionDetailsViewModel; |
| 233 | |
| 234 | abstract class TransactionDetailsViewModelBase with Store { |
| 235 | TransactionDetailsViewModelBase({ |
| 236 | required this.transactionInfo, |
| 237 | required this.transactionDescriptionBox, |
| 238 | required this.wallet, |
| 239 | required AppStore appStore, |
| 240 | required this.sendViewModel, |
| 241 | this.canReplaceByFee = false, |
| 242 | }) : items = [], |
| 243 | rbfListItems = [], |
| 244 | newFee = 0, |
| 245 | isRecipientAddressShown = false, |
| 246 | _appStore = appStore, |
| 247 | showRecipientAddress = appStore.settingsStore.shouldSaveRecipientAddress { |
| 248 | final tx = transactionInfo; |
| 249 | |
| 250 | for (final def in TxDetailRowDefinition.defs) { |
| 251 | if (def.applicable(this)) { |
| 252 | items.add( |
| 253 | def.listItemBuilder( |
| 254 | title: def.title, |
| 255 | value: def.valueGetter(this), |
| 256 | key: ValueKey(def.keyString), |
| 257 | ) as TransactionDetailsListItem, |
| 258 | ); |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | _checkForRBF(tx); |
| 263 | |
| 264 | final descriptionKey = "${transactionInfo.txHash}_${wallet.walletAddresses.primaryAddress}"; |
| 265 | final description = transactionDescriptionBox.values.firstWhere( |
| 266 | (val) => val.id == descriptionKey || val.id == transactionInfo.txHash, |
| 267 | orElse: () => TransactionDescription(id: descriptionKey), |
| 268 | ); |
| 269 | |
| 270 | if (showRecipientAddress && !isRecipientAddressShown) { |
| 271 | final recipientAddress = description.recipientAddress; |
| 272 | |
| 273 | if (recipientAddress?.isNotEmpty ?? false) { |
| 274 | final recipientAddressForDisplay = |
| 275 | _moneroRecipientAddressForDisplay(recipientAddress!, wallet.type); |
| 276 | items.add( |
| 277 | AddressListItem( |
| 278 | title: S.current.transaction_details_recipient_address, |
| 279 | value: recipientAddressForDisplay, |
| 280 | key: ValueKey("standard_list_item_${recipientAddressForDisplay}_key"), |
| 281 | ), |
| 282 | ); |
| 283 | } |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | void updateNote(String note) { |
| 288 | final descriptionKey = "${transactionInfo.txHash}_${wallet.walletAddresses.primaryAddress}"; |
| 289 | final description = transactionDescriptionBox.values.firstWhere( |
| 290 | (val) => val.id == descriptionKey || val.id == transactionInfo.txHash, |
| 291 | orElse: () => TransactionDescription(id: descriptionKey), |
| 292 | ); |
| 293 | |
| 294 | description.transactionNote = note; |
| 295 | |
| 296 | if (description.isInBox) { |
| 297 | description.save(); |
| 298 | } else { |
| 299 | transactionDescriptionBox.add(description); |
| 300 | } |
| 301 | } |
| 302 | |
| 303 | String get note { |
| 304 | final descriptionKey = "${transactionInfo.txHash}_${wallet.walletAddresses.primaryAddress}"; |
| 305 | final description = transactionDescriptionBox.values |
| 306 | .firstWhereOrNull((val) => val.id == descriptionKey || val.id == transactionInfo.txHash, |
| 307 | ); |
| 308 | return description?.transactionNote ?? ""; |
| 309 | } |
| 310 | |
| 311 | final TransactionInfo transactionInfo; |
| 312 | final Box<TransactionDescription> transactionDescriptionBox; |
| 313 | final WalletBase wallet; |
| 314 | final SendViewModel sendViewModel; |
| 315 | final AppStore _appStore; |
| 316 | |
| 317 | final List<TransactionDetailsListItem> items; |
| 318 | final List<TransactionDetailsListItem> rbfListItems; |
| 319 | bool showRecipientAddress; |
| 320 | bool isRecipientAddressShown; |
| 321 | int newFee; |
| 322 | String? rawTransaction; |
| 323 | TransactionPriority? transactionPriority; |
| 324 | |
| 325 | CryptoCurrency get transactionAsset { |
| 326 | if (isEVMCompatibleChain(wallet.type)) { |
| 327 | return evm!.assetOfTransaction(wallet, transactionInfo); |
| 328 | } |
| 329 | |
| 330 | if (isLightning(transactionInfo)) { |
| 331 | return CryptoCurrency.btcln; |
| 332 | } |
| 333 | |
| 334 | return switch (wallet.type) { |
| 335 | WalletType.solana => solana!.assetOfTransaction(wallet, transactionInfo), |
| 336 | WalletType.tron => tron!.assetOfTransaction(wallet, transactionInfo), |
| 337 | WalletType.zano => zano!.assetOfTransaction(wallet, transactionInfo) ?? CryptoCurrency.zano, |
| 338 | _ => walletTypeToCryptoCurrency(wallet.type) |
| 339 | }; |
| 340 | } |
| 341 | |
| 342 | @computed |
| 343 | String get transactionAmount => |
| 344 | _appStore.amountParsingProxy.asDisplayStringWithSymbol(transactionInfo.amount); |
| 345 | |
| 346 | @computed |
| 347 | String get feeAmount => |
| 348 | _appStore.amountParsingProxy.asDisplayStringWithSymbol(transactionInfo.fee!); |
| 349 | |
| 350 | @computed |
| 351 | String get transactionCopyAmount => |
| 352 | _appStore.amountParsingProxy.asDisplayString(transactionInfo.amount); |
| 353 | |
| 354 | // TODO(malik1004x): integrate these getters with the TransactionInfo object |
| 355 | String get formattedPendingStatus { |
| 356 | switch (wallet.type) { |
| 357 | case WalletType.monero: |
| 358 | case WalletType.haven: |
| 359 | case WalletType.zano: |
| 360 | if (transactionInfo.confirmations >= 0 && transactionInfo.confirmations < 10) { |
| 361 | return " (${transactionInfo.confirmations}/10)"; |
| 362 | } |
| 363 | break; |
| 364 | case WalletType.wownero: |
| 365 | if (transactionInfo.confirmations >= 0 && transactionInfo.confirmations < 3) { |
| 366 | return " (${transactionInfo.confirmations}/3)"; |
| 367 | } |
| 368 | break; |
| 369 | case WalletType.litecoin: |
| 370 | final isPegIn = (transactionInfo.additionalInfo["isPegIn"] as bool?) ?? false; |
| 371 | final isPegOut = (transactionInfo.additionalInfo["isPegOut"] as bool?) ?? false; |
| 372 | final fromPegOut = (transactionInfo.additionalInfo["fromPegOut"] as bool?) ?? false; |
| 373 | String str = ""; |
| 374 | if (transactionInfo.confirmations <= 0) { |
| 375 | str = S.current.pending; |
| 376 | } |
| 377 | if ((isPegOut || fromPegOut) && |
| 378 | transactionInfo.confirmations >= 0 && |
| 379 | transactionInfo.confirmations < 6) { |
| 380 | str = " (${transactionInfo.confirmations}/6)"; |
| 381 | } |
| 382 | if (isPegIn) { |
| 383 | str += " (Peg In)"; |
| 384 | } |
| 385 | if (isPegOut) { |
| 386 | str += " (Peg Out)"; |
| 387 | } |
| 388 | return str; |
| 389 | default: |
| 390 | return ""; |
| 391 | } |
| 392 | |
| 393 | return ""; |
| 394 | } |
| 395 | |
| 396 | String get formattedStatus { |
| 397 | if ([ |
| 398 | WalletType.monero, |
| 399 | WalletType.haven, |
| 400 | WalletType.wownero, |
| 401 | WalletType.litecoin, |
| 402 | WalletType.zano, |
| 403 | ].contains(wallet.type)) { |
| 404 | return formattedPendingStatus; |
| 405 | } |
| 406 | |
| 407 | return transactionInfo.isPending ? S.current.pending : ""; |
| 408 | } |
| 409 | |
| 410 | int get neededConfirmations { |
| 411 | switch (wallet.type) { |
| 412 | case WalletType.monero: |
| 413 | case WalletType.haven: |
| 414 | case WalletType.zano: |
| 415 | return 10; |
| 416 | case WalletType.wownero: |
| 417 | return 3; |
| 418 | case WalletType.litecoin: |
| 419 | final isPegOut = (transactionInfo.additionalInfo["isPegOut"] as bool?) ?? false; |
| 420 | final fromPegOut = (transactionInfo.additionalInfo["fromPegOut"] as bool?) ?? false; |
| 421 | if (isPegOut || fromPegOut) { |
| 422 | return 6; |
| 423 | } |
| 424 | default: |
| 425 | return 0; |
| 426 | } |
| 427 | return 0; |
| 428 | } |
| 429 | |
| 430 | String get formattedTitle { |
| 431 | if (transactionInfo.additionalInfo['isIronwoodMigration'] == true) { |
| 432 | return 'Migration'; |
| 433 | } |
| 434 | if (transactionInfo.additionalInfo['isAutoShield'] == true) { |
| 435 | return S.current.shielding; |
| 436 | } |
| 437 | if (transactionInfo.direction == TransactionDirection.incoming) { |
| 438 | return S.current.received; |
| 439 | } |
| 440 | |
| 441 | return S.current.sent; |
| 442 | } |
| 443 | |
| 444 | @observable |
| 445 | bool canReplaceByFee; |
| 446 | |
| 447 | String get _explorerUrl { |
| 448 | final txId = transactionInfo.txHash; |
| 449 | if (wallet.chainId != null) { |
| 450 | final explorerUrl = evm!.getExplorerUrlForChainId(wallet.chainId!); |
| 451 | if (explorerUrl != null) { |
| 452 | return "$explorerUrl/tx/${txId}"; |
| 453 | } |
| 454 | } |
| 455 | |
| 456 | switch (wallet.type) { |
| 457 | case WalletType.monero: |
| 458 | return "https://monero.com/tx/${txId}"; |
| 459 | case WalletType.bitcoin: |
| 460 | return isLightning(transactionInfo) |
| 461 | ? "https://sparkscan.io/tx/${txId}" |
| 462 | : 'https://mempool.cakewallet.com/${wallet.isTestnet ? "testnet/" : ""}tx/${txId}'; |
| 463 | case WalletType.litecoin: |
| 464 | return bitcoin!.txIsMweb(transactionInfo) |
| 465 | ? "https://www.mwebexplorer.com/blocks/block/${transactionInfo.height}" |
| 466 | : "https://blockchair.com/litecoin/transaction/${txId}"; |
| 467 | case WalletType.bitcoinCash: |
| 468 | return "https://blockchair.com/bitcoin-cash/transaction/${txId}"; |
| 469 | case WalletType.haven: |
| 470 | return "https://explorer.havenprotocol.org/search?value=${txId}"; |
| 471 | case WalletType.ethereum: |
| 472 | return "https://etherscan.io/tx/${txId}"; |
| 473 | case WalletType.base: |
| 474 | return "https://basescan.org/tx/${txId}"; |
| 475 | case WalletType.arbitrum: |
| 476 | return "https://arbiscan.io/tx/${txId}"; |
| 477 | case WalletType.bsc: |
| 478 | return "https://bscscan.com/tx/${txId}"; |
| 479 | case WalletType.polygon: |
| 480 | return "https://polygonscan.com/tx/${txId}"; |
| 481 | case WalletType.nano: |
| 482 | return "https://nanexplorer.com/nano/block/${txId}"; |
| 483 | case WalletType.banano: |
| 484 | return "https://nanexplorer.com/banano/block/${txId}"; |
| 485 | case WalletType.solana: |
| 486 | return "https://solscan.io/tx/${txId}"; |
| 487 | case WalletType.tron: |
| 488 | return "https://tronscan.org/#/transaction/${txId}"; |
| 489 | case WalletType.wownero: |
| 490 | return "https://explore.wownero.com/tx/${txId}"; |
| 491 | case WalletType.zano: |
| 492 | return "https://explorer.zano.org/transaction/${txId}"; |
| 493 | case WalletType.decred: |
| 494 | return 'https://${wallet.isTestnet ? "testnet" : "dcrdata"}.decred.org/tx/${txId.split(':')[0]}'; |
| 495 | case WalletType.dogecoin: |
| 496 | return "https://blockchair.com/dogecoin/transaction/${txId}"; |
| 497 | case WalletType.zcash: |
| 498 | return "https://blockchair.com/zcash/transaction/${txId}"; |
| 499 | case WalletType.none: |
| 500 | return ""; |
| 501 | } |
| 502 | } |
| 503 | |
| 504 | String get explorerDescription => S.current.view_transaction_on + Uri.parse(_explorerUrl).host; |
| 505 | |
| 506 | void launchExplorer() { |
| 507 | launchUrl(Uri.parse(_explorerUrl)); |
| 508 | } |
| 509 | |
| 510 | void addBumpFeesListItems(TransactionInfo tx, String rawTransaction) { |
| 511 | transactionPriority = bitcoin!.getBitcoinTransactionPriorityMedium(); |
| 512 | final inputsCount = (transactionInfo.inputAddresses?.isEmpty ?? true) |
| 513 | ? 1 |
| 514 | : transactionInfo.inputAddresses!.length; |
| 515 | final outputsCount = (transactionInfo.outputAddresses?.isEmpty ?? true) |
| 516 | ? 1 |
| 517 | : transactionInfo.outputAddresses!.length; |
| 518 | |
| 519 | newFee = bitcoin!.getFeeAmountForPriority( |
| 520 | wallet, |
| 521 | bitcoin!.getBitcoinTransactionPriorityMedium(), |
| 522 | inputsCount, |
| 523 | outputsCount, |
| 524 | ); |
| 525 | |
| 526 | rbfListItems.add( |
| 527 | StandartListItem( |
| 528 | title: S.current.old_fee, |
| 529 | value: tx.fee?.toStringWithSymbol() ?? "0.0", |
| 530 | key: const ValueKey("standard_list_item_rbf_old_fee_key"), |
| 531 | ), |
| 532 | ); |
| 533 | |
| 534 | if (transactionInfo.fee != null && rawTransaction.isNotEmpty) { |
| 535 | final size = bitcoin!.getTransactionVSize(wallet, rawTransaction); |
| 536 | final recommendedRate = (transactionInfo.fee! / BigInt.from(size)) + |
| 537 | transactionInfo.fee!.copyWith(amount: BigInt.one); |
| 538 | |
| 539 | rbfListItems.add( |
| 540 | StandartListItem(title: "New recommended fee rate", value: "$recommendedRate sat/byte"), |
| 541 | ); |
| 542 | } |
| 543 | |
| 544 | final priorities = priorityForWalletType(wallet.type); |
| 545 | final selectedItem = priorities.indexOf(sendViewModel.feesViewModel.transactionPriority); |
| 546 | final customItem = priorities.firstWhereOrNull( |
| 547 | (element) => element == sendViewModel.feesViewModel.bitcoinTransactionPriorityCustom, |
| 548 | ); |
| 549 | final customItemIndex = customItem != null ? priorities.indexOf(customItem) : null; |
| 550 | final maxCustomFeeRate = sendViewModel.feesViewModel.maxCustomFeeRate?.toDouble(); |
| 551 | |
| 552 | rbfListItems.add( |
| 553 | StandardPickerListItem( |
| 554 | key: const ValueKey("standard_picker_list_item_transaction_priorities_key"), |
| 555 | title: S.current.estimated_new_fee, |
| 556 | value: "${bitcoin!.formatterBitcoinAmountToString(amount: newFee)} ${wallet.currency}", |
| 557 | items: priorityForWalletType(wallet.type), |
| 558 | customValue: _appStore.settingsStore.customBitcoinFeeRate.toDouble(), |
| 559 | maxValue: maxCustomFeeRate, |
| 560 | selectedIdx: selectedItem, |
| 561 | customItemIndex: customItemIndex ?? 0, |
| 562 | displayItem: (dynamic priority, sliderValue) => |
| 563 | sendViewModel.feesViewModel.displayFeeRate(priority, sliderValue.round()), |
| 564 | onSliderChanged: (newValue) => setNewFee(value: newValue, priority: transactionPriority!), |
| 565 | onItemSelected: (dynamic item, sliderValue) { |
| 566 | transactionPriority = item as TransactionPriority; |
| 567 | return setNewFee(value: sliderValue, priority: transactionPriority!); |
| 568 | }, |
| 569 | ), |
| 570 | ); |
| 571 | |
| 572 | if (transactionInfo.inputAddresses != null && transactionInfo.inputAddresses!.isNotEmpty) { |
| 573 | rbfListItems.add( |
| 574 | StandardExpandableListItem( |
| 575 | key: const ValueKey("standard_expandable_list_item_transaction_input_addresses_key"), |
| 576 | title: S.current.inputs, |
| 577 | expandableItems: transactionInfo.inputAddresses!, |
| 578 | ), |
| 579 | ); |
| 580 | } |
| 581 | |
| 582 | if (transactionInfo.outputAddresses != null && transactionInfo.outputAddresses!.isNotEmpty) { |
| 583 | final outputAddresses = transactionInfo.outputAddresses!.map((element) { |
| 584 | if (element.contains("OP_RETURN:") && element.length > 40) { |
| 585 | return "${element.substring(0, 40)}..."; |
| 586 | } |
| 587 | return element; |
| 588 | }).toList(); |
| 589 | |
| 590 | rbfListItems.add( |
| 591 | StandardExpandableListItem( |
| 592 | title: S.current.outputs, |
| 593 | expandableItems: outputAddresses, |
| 594 | key: const ValueKey("standard_expandable_list_item_transaction_output_addresses_key"), |
| 595 | ), |
| 596 | ); |
| 597 | } |
| 598 | } |
| 599 | |
| 600 | @action |
| 601 | Future<void> _checkForRBF(TransactionInfo tx) async { |
| 602 | if (wallet.type == WalletType.bitcoin && |
| 603 | transactionInfo.direction == TransactionDirection.outgoing) { |
| 604 | final descriptionKey = "${transactionInfo.txHash}_${wallet.walletAddresses.primaryAddress}"; |
| 605 | final description = transactionDescriptionBox.values |
| 606 | .firstWhereOrNull((val) => val.id == descriptionKey || val.id == transactionInfo.txHash); |
| 607 | |
| 608 | if (RegExp(AddressValidator.silentPaymentAddressPatternMainnet) |
| 609 | .hasMatch(description?.recipientAddress ?? "")) { |
| 610 | canReplaceByFee = false; |
| 611 | return; |
| 612 | } |
| 613 | |
| 614 | rawTransaction = await bitcoin!.canReplaceByFee(wallet, tx); |
| 615 | if (rawTransaction != null) { |
| 616 | canReplaceByFee = true; |
| 617 | } |
| 618 | } |
| 619 | } |
| 620 | |
| 621 | String setNewFee({required TransactionPriority priority, double? value}) { |
| 622 | newFee = priority == bitcoin!.getBitcoinTransactionPriorityCustom() && value != null |
| 623 | ? bitcoin!.feeAmountWithFeeRate( |
| 624 | wallet, |
| 625 | value.round(), |
| 626 | transactionInfo.inputAddresses?.length ?? 1, |
| 627 | transactionInfo.outputAddresses?.length ?? 1, |
| 628 | ) |
| 629 | : bitcoin!.getFeeAmountForPriority( |
| 630 | wallet, |
| 631 | priority, |
| 632 | transactionInfo.inputAddresses?.length ?? 1, |
| 633 | transactionInfo.outputAddresses?.length ?? 1, |
| 634 | ); |
| 635 | |
| 636 | return bitcoin!.formatterBitcoinAmountToString(amount: newFee); |
| 637 | } |
| 638 | |
| 639 | void replaceByFee(String newFee) => sendViewModel.replaceByFee(transactionInfo, newFee); |
| 640 | } |