diff --git a/DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift b/DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift index d0cc56a9f..59f51e4fe 100644 --- a/DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift +++ b/DashWallet/Sources/UI/Tx/Details/Model/TxDetailModel.swift @@ -25,8 +25,27 @@ class TxDetailModel: NSObject { var transactionId: String var txTaxCategory: TxMetadataTaxCategory + /// Memoization for `computedRawFee` (extensions can't store properties). + /// Two flags so a nil result isn't recomputed on every table reload. + private var didComputeRawFee = false + fileprivate var rawFeeCache: UInt64? + var title: String { - direction.title + // Shielded transfers carry their own identity — the generic + // direction titles ("Moved to Address" / "Amount received") hide + // what actually happened. + if transaction.isShieldedTransfer { + return transaction.isPendingShieldedTransfer + ? NSLocalizedString("Shielded transfer (pending)", + comment: "A to-Shielded transfer whose asset lock is committed but the shield hasn't completed yet") + : NSLocalizedString("Shielded transfer", + comment: "Transfer of own funds into the private shielded balance") + } + if transaction.isShieldedWithdrawalReceipt { + return NSLocalizedString("Shielded withdrawal", + comment: "Transfer of own funds from the private shielded balance back to the transparent wallet") + } + return direction.title } var direction: DSTransactionDirection { @@ -172,6 +191,13 @@ extension TxDetailModel { } var shouldDisplayOutputAddresses: Bool { + // An asset-lock funding's owned outputs are only its change — + // labeling them "Internally moved to" reads like the transfer's + // destination. The From/To route rows carry that instead, and the + // raw transaction inspector shows every output for the curious. + if transaction.isShieldedTransfer { + return false + } if direction == .received && hasDestinationUser { return false } @@ -307,20 +333,109 @@ extension TxDetailModel { return models } + /// Extra rows for shielded transfers: the balance-to-balance route and, + /// for a Core → Shielded funding, the live on-chain asset-lock status. + /// Empty for every other transaction. + func shieldedInfo() -> [DWTitleDetailItem] { + let transparent = NSLocalizedString("Transparent balance", comment: "The transparent (Core) balance of the Dash Wallet") + let shielded = NSLocalizedString("Shielded balance", comment: "") + if transaction.isShieldedTransfer { + var rows: [DWTitleDetailItem] = [ + DWTitleDetailCellModel(style: .default, title: NSLocalizedString("From", comment: ""), plainDetail: transparent), + DWTitleDetailCellModel(style: .default, title: NSLocalizedString("To", comment: ""), plainDetail: shielded), + ] + if let status = shieldedStatusText { + rows.append(DWTitleDetailCellModel( + style: .default, + title: NSLocalizedString("Status", comment: "Transaction details"), + plainDetail: status)) + } + return rows + } + if transaction.isShieldedWithdrawalReceipt { + return [ + DWTitleDetailCellModel(style: .default, title: NSLocalizedString("From", comment: ""), plainDetail: shielded), + DWTitleDetailCellModel(style: .default, title: NSLocalizedString("To", comment: ""), plainDetail: transparent), + ] + } + return [] + } + + /// User-facing name of the funding asset lock's live status + /// (`PersistentAssetLock.statusRaw` via `ShieldedTxLookup`): 0/1 = + /// built/broadcast, 2/3 = IS/CL-locked awaiting the shield transition, + /// 4 = consumed (transfer complete). Nil when the lookup has no entry. + private var shieldedStatusText: String? { + guard let statusRaw = ShieldedTxLookup.shared.info(forTxidHex: transactionId)?.statusRaw else { + return nil + } + switch statusRaw { + case 0, 1: + return NSLocalizedString("Broadcasting", comment: "") + case 2, 3: + return NSLocalizedString("Funds locked — finishing transfer", comment: "Shielded transfer status") + case 4: + return NSLocalizedString("Completed", comment: "Shielded transfer status") + default: + return nil + } + } + + /// Below this (0.0001 DASH) a fee renders as plain duffs — the + /// DASH-formatted form reads as zero at a glance. + private static let duffsDisplayThreshold: UInt64 = 10_000 + func fee(with font: UIFont, tintColor: UIColor) -> DWTitleDetailItem { let title = NSLocalizedString("Network fee", comment: "") var feeValue: UInt64 = 0 - + if hasFee { feeValue = transaction.feeUsed feeValue = feeValue == UInt64.max ? 0 : feeValue } - + if feeValue == 0, direction != .received, let computed = computedRawFee { + // Rows without a persisted fee (asset-lock fundings) still have a + // real one — recover it from the raw transaction. + feeValue = computed + } + + if feeValue > 0, feeValue < Self.duffsDisplayThreshold { + let detail = String(format: NSLocalizedString("%d duffs", comment: "Network fee in duffs (1 DASH = 100,000,000 duffs)"), feeValue) + return DWTitleDetailCellModel(style: .default, title: title, plainDetail: detail) + } + let detail = NSAttributedString.dashAttributedString(for: feeValue, tintColor: tintColor, font: font) return DWTitleDetailCellModel(style: .default, title: title, attributedDetail: detail) } + /// Fee derived from the stored raw transaction as Σ(input values) − + /// Σ(output values) — the consensus definition, computable exactly when + /// every input value is known (all inputs are the wallet's own TXOs, + /// which holds for any tx we authored). Nil when any input value is + /// unknown, the bytes are unavailable, or the difference is negative + /// (mis-matched data — never guessed). Memoized: the detail sheet + /// reloads on tax-category toggles and the parse isn't free. + private var computedRawFee: UInt64? { + if !didComputeRawFee { + didComputeRawFee = true + rawFeeCache = Self.computeRawFee(txidWire: transaction.txHashData) + } + return rawFeeCache + } + + private static func computeRawFee(txidWire: Data) -> UInt64? { + let details = MainActor.assumeIsolated { + RawTransactionInspector.load(txidWire: txidWire) + } + guard let details, !details.inputs.isEmpty else { return nil } + let inputAmounts = details.inputs.compactMap(\.amountDuffs) + guard inputAmounts.count == details.inputs.count else { return nil } + let inSum = inputAmounts.reduce(0, +) + let outSum = details.outputs.reduce(UInt64(0)) { $0 + $1.valueDuffs } + return inSum >= outSum ? inSum - outSum : nil + } + var date: DWTitleDetailCellModel { let title = NSLocalizedString("Date", comment: "") let detail = DWDateFormatter.sharedInstance.longString(from: transaction.date) diff --git a/DashWallet/Sources/UI/Tx/Details/TxDetailViewController.swift b/DashWallet/Sources/UI/Tx/Details/TxDetailViewController.swift index 5bf8e497c..e254864b7 100644 --- a/DashWallet/Sources/UI/Tx/Details/TxDetailViewController.swift +++ b/DashWallet/Sources/UI/Tx/Details/TxDetailViewController.swift @@ -126,6 +126,7 @@ class TXDetailViewController: BaseTxDetailsViewController { case networkFee(DWTitleDetailItem) case date(DWTitleDetailItem) case taxCategory(DWTitleDetailItem) + case shieldedInfo(DWTitleDetailItem) case viewTransaction case copyRawTransaction case explorer @@ -146,6 +147,7 @@ class TXDetailViewController: BaseTxDetailsViewController { case .networkFee(let item): return ["NetworkFee"] + Self.identity(of: [item]) case .date(let item): return ["Date"] + Self.identity(of: [item]) case .taxCategory(let item): return ["TaxCategory"] + Self.identity(of: [item]) + case .shieldedInfo(let item): return ["ShieldedInfo"] + Self.identity(of: [item]) case .viewTransaction: return ["ViewTransaction"] case .copyRawTransaction: return ["CopyRawTransaction"] case .explorer: return ["Explorer"] @@ -304,6 +306,11 @@ extension TXDetailViewController { currentSnapshot.appendSections([.header, .info, .taxCategory, .rawTransaction, .explorer]) currentSnapshot.appendItems([.header], toSection: .header) + // Shielded transfers lead with their balance route + lock status. + for item in model.shieldedInfo() { + currentSnapshot.appendItems([.shieldedInfo(item)], toSection: .info) + } + // Empty address groups are skipped: SDK rows often carry no linked // input addresses and a sent row's external outputs may be unknown — // an empty .sentFrom/.sentTo would render a blank cell. diff --git a/DashWallet/Sources/UI/Tx/Details/Views/TxDetailCells.swift b/DashWallet/Sources/UI/Tx/Details/Views/TxDetailCells.swift index dea5195c4..1095817d5 100644 --- a/DashWallet/Sources/UI/Tx/Details/Views/TxDetailCells.swift +++ b/DashWallet/Sources/UI/Tx/Details/Views/TxDetailCells.swift @@ -103,7 +103,7 @@ class TxDetailInfoCell: TxDetailTitleDetailsCell { valueLabelsStack.addArrangedSubview(view) } break - case .date(let item), .networkFee(let item): + case .date(let item), .networkFee(let item), .shieldedInfo(let item): title = item.title let view = valueLabel(item) valueLabelsStack.addArrangedSubview(view) diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index c007cff39..214d4c0ea 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -61,6 +61,9 @@ /* Raw transaction inspector */ "%d bytes" = "%d bytes"; +/* Network fee in duffs (1 DASH = 100,000,000 duffs) */ +"%d duffs" = "%d duffs"; + /* Masternode keys overview */ "%d key(s)" = "%d key(s)"; @@ -626,6 +629,9 @@ /* Masternodes */ "Collateral" = "Collateral"; +/* Shielded transfer status */ +"Completed" = "Completed"; + /* Balance info sheet section */ "Cons" = "Cons"; @@ -1397,6 +1403,9 @@ /* Usernames */ "Funded from your Shielded balance — your username can't be linked to your other Dash." = "Funded from your Shielded balance — your username can't be linked to your other Dash."; +/* Shielded transfer status */ +"Funds locked — finishing transfer" = "Funds locked — finishing transfer"; + /* CrowdNode */ "Gain rewards from deposits in Dash Masternodes with as little as 0.5 Dash." = "Gain rewards from deposits in Dash Masternodes with as little as 0.5 Dash."; @@ -3310,6 +3319,9 @@ /* Usernames */ "Starts after you add funds" = "Starts after you add funds"; +/* Transaction details */ +"Status" = "Status"; + /* Step 1 */ "Step %ld" = "Step %ld";