From 83b599a018ba4e588a24fa4cd11350afddcfa397 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 28 Aug 2026 18:41:00 +0200 Subject: [PATCH] feat(wallet): review the ProUpServTx before broadcasting an unban MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in "Review transaction first" step to the unban flow. With it on, Unban builds and signs the provider update and shows it — fee, size, inputs, change, every decoded payload field and the raw hex — leaving Broadcast and Discard as the next decision. The preview is decoded from the signed bytes the SDK hands back (platform #4512's prepare entry points), not re-derived from the form, so what is shown is what would be sent. Discarding — or simply closing the sheet — releases the transaction's reserved inputs, and broadcasting goes through the wallet's ordinary send path, so acceptance is classified exactly like any other transaction. The inspector learns to decode ProUpServTx payloads (DIP-3 field order, including the wire's byte-swapped service port and the plain platform ports), which also fills in the transaction detail screen: a provider update there previously showed payload hex and nothing else. Requires swift-sdk at platform v4.2-dev a8ba7403c8 or later. Co-Authored-By: Claude Fable 5 --- .../Model/RawTransactionInspector.swift | 85 ++++++++++- .../Unban/MasternodeUnbanViewModel.swift | 136 ++++++++++++++++++ .../Tools/Unban/UnbanMasternodeSheet.swift | 77 ++++++++++ DashWallet/en.lproj/Localizable.strings | 10 ++ 4 files changed, 307 insertions(+), 1 deletion(-) diff --git a/DashWallet/Sources/Models/Transactions/Model/RawTransactionInspector.swift b/DashWallet/Sources/Models/Transactions/Model/RawTransactionInspector.swift index f755f4977..ea3b1f4f9 100644 --- a/DashWallet/Sources/Models/Transactions/Model/RawTransactionInspector.swift +++ b/DashWallet/Sources/Models/Transactions/Model/RawTransactionInspector.swift @@ -267,6 +267,25 @@ enum RawTransactionInspector { return Data(b[3 ..< 23]) } + /// The 16 network-order bytes of a DIP-3 service address as text: an + /// IPv4-mapped address renders as a dotted quad, anything else as IPv6 + /// groups. + private static func ipText(_ bytes: Data) -> String { + let octets = [UInt8](bytes) + guard octets.count == 16 else { + return octets.map { String(format: "%02x", $0) }.joined() + } + let isV4Mapped = octets[0..<10].allSatisfy { $0 == 0 } + && octets[10] == 0xFF && octets[11] == 0xFF + if isV4Mapped { + return "\(octets[12]).\(octets[13]).\(octets[14]).\(octets[15])" + } + let groups = stride(from: 0, to: 16, by: 2).map { i in + String(format: "%x", Int(octets[i]) << 8 | Int(octets[i + 1])) + } + return "[\(groups.joined(separator: ":"))]" + } + /// DIP-2 special transaction names, keyed by the tx `type` field. static func specialTypeName(_ type: UInt16) -> String? { switch type { @@ -287,11 +306,75 @@ enum RawTransactionInspector { /// Parsed fields for the payload types the app understands. Anything the /// parser doesn't positively recognize contributes no fields — the UI /// falls back to the payload hex rather than showing guessed values. - private static func parsePayloadFields(type: UInt16, payload: Data?, network: PaymentNetwork) -> [RawTransactionDetails.PayloadField] { + /// + /// Internal: the unban preview renders the same fields for a ProUpServTx + /// it has built but not yet broadcast. + static func parsePayloadFields(type: UInt16, payload: Data?, network: PaymentNetwork) -> [RawTransactionDetails.PayloadField] { guard let payload, !payload.isEmpty else { return [] } var fields: [RawTransactionDetails.PayloadField] = [] switch type { + case 2: // ProUpServTx — see DIP-3. Field order matches dashcore's + // `base_payload_data_encode`; the service port is + // byte-swapped on the wire, the platform ports are not. + var reader = ByteReader(payload) + guard let version = try? reader.readUInt16() else { break } + fields.append(.init(label: "Payload version", value: "\(version)")) + + var masternodeType: UInt16? + if version >= 2 { + masternodeType = try? reader.readUInt16() + if let masternodeType { + fields.append(.init( + label: "Masternode type", + value: masternodeType == 1 + ? NSLocalizedString("Evonode", comment: "") + : NSLocalizedString("Masternode", comment: ""))) + } + } + + guard let proTxHash = try? reader.readBytes(32), + let ipBytes = try? reader.readBytes(16), + let portBytes = try? reader.readBytes(2) else { break } + fields.append(.init( + label: "proTxHash", + value: proTxHash.reversed().map { String(format: "%02x", $0) }.joined())) + let port = UInt16(portBytes[portBytes.startIndex]) << 8 + | UInt16(portBytes[portBytes.startIndex + 1]) + fields.append(.init( + label: NSLocalizedString("Service", comment: "Masternodes"), + value: "\(Self.ipText(ipBytes)):\(port)")) + + guard let scriptLength = try? reader.readVarInt(), + let script = try? reader.readBytes(Int(scriptLength)), + let inputsHash = try? reader.readBytes(32) else { break } + fields.append(.init( + label: NSLocalizedString("Operator payout", comment: "Masternode unban"), + value: script.isEmpty + ? NSLocalizedString("None", comment: "Masternode unban") + : (ScriptAddressCodec.address(forScript: script, network: network) + ?? script.map { String(format: "%02x", $0) }.joined()))) + fields.append(.init( + label: "Inputs hash", + value: inputsHash.reversed().map { String(format: "%02x", $0) }.joined())) + + if version >= 2, masternodeType == 1 { + if let nodeId = try? reader.readBytes(20), + let p2pPort = try? reader.readUInt16(), + let httpPort = try? reader.readUInt16() { + fields.append(.init( + label: NSLocalizedString("Platform Node ID", comment: ""), + value: nodeId.map { String(format: "%02x", $0) }.joined())) + fields.append(.init(label: "Platform P2P port", value: "\(p2pPort)")) + fields.append(.init(label: "Platform HTTP port", value: "\(httpPort)")) + } + } + + if let signature = try? reader.readBytes(96) { + fields.append(.init( + label: NSLocalizedString("Operator signature (BLS)", comment: "Masternode unban"), + value: signature.map { String(format: "%02x", $0) }.joined())) + } case 5: // CbTx: u16 version, u32 height, 32B merkle root MN list, … var reader = ByteReader(payload) if let version = try? reader.readUInt16(), diff --git a/DashWallet/Sources/UI/Menu/Tools/Unban/MasternodeUnbanViewModel.swift b/DashWallet/Sources/UI/Menu/Tools/Unban/MasternodeUnbanViewModel.swift index c9197093b..a39353a5e 100644 --- a/DashWallet/Sources/UI/Menu/Tools/Unban/MasternodeUnbanViewModel.swift +++ b/DashWallet/Sources/UI/Menu/Tools/Unban/MasternodeUnbanViewModel.swift @@ -44,10 +44,24 @@ final class MasternodeUnbanViewModel: ObservableObject { /// withdrawal queue (minutes). Polling the spendable balance. case waitingForFunds case submitting + /// Built and signed, waiting for the user to look it over. Only + /// reachable with `reviewBeforeBroadcast` on. + case previewing /// The ProUpServTx was broadcast and accepted (display-order txid). case submitted(txidHex: String) } + /// The prepared ProUpServTx as the review step renders it — decoded from + /// the very bytes that will be broadcast, never re-derived from the form. + struct Preview { + let feeDuffs: UInt64 + let sizeBytes: Int + let inputCount: Int + let outputs: [(address: String, amountDuffs: UInt64)] + let payloadFields: [RawTransactionDetails.PayloadField] + let rawHex: String + } + let record: PlatformMasternode let keySource: UnbanOperatorKeySource @@ -60,6 +74,17 @@ final class MasternodeUnbanViewModel: ObservableObject { @Published var payoutAddressText = "" @Published private(set) var payoutAddressRequired = false + /// Opt-in review step (owner request): off by default, so the ordinary + /// unban stays one tap; on, the signed transaction is shown before it + /// goes out. + @Published var reviewBeforeBroadcast = false + @Published private(set) var preview: Preview? + + /// The signed, reserved transaction behind `preview`. Held only between + /// preparing and the user's decision; releasing it abandons the + /// transaction and frees its inputs. + private var prepared: FinalizedCoreTransaction? + @Published private(set) var phase: Phase = .ready @Published private(set) var errorText: String? /// A terminal outcome (unconfirmed broadcast, unsupported entry): the @@ -246,6 +271,14 @@ final class MasternodeUnbanViewModel: ObservableObject { ? payoutAddressText.trimmingCharacters(in: .whitespacesAndNewlines) : nil + // With review on, stop at a signed transaction and show it; the + // broadcast is a second, explicit decision. + guard !reviewBeforeBroadcast else { + await prepareForReview( + manager: manager, walletId: walletId, port: port, payout: payout) + return + } + do { let txid: Data switch keySource { @@ -318,4 +351,107 @@ final class MasternodeUnbanViewModel: ObservableObject { phase = .ready } } + + // MARK: Review before broadcasting + + /// Build and sign the ProUpServTx without sending it, then decode the + /// exact bytes for the review step. The signed transaction holds its + /// funding inputs reserved until it is broadcast or discarded. + private func prepareForReview( + manager: PlatformWalletManager, + walletId: Data, + port: UInt16?, + payout: String? + ) async { + do { + let prepared: FinalizedCoreTransaction + switch keySource { + case .wallet(let operatorKeyIndex): + prepared = try await manager.masternodePrepareUpdateService( + walletId: walletId, + proTxHash: record.proTxHash, + operatorKeyIndex: operatorKeyIndex, + platformP2PPort: port, + operatorPayoutAddress: payout) + case .tracked(let vault): + guard let keyText = vault.key(for: record.proTxHash, role: .operator) else { + errorText = NSLocalizedString( + "The operator key is missing from the keychain — re-add it and try again.", + comment: "Masternode unban") + phase = .ready + return + } + prepared = try await manager.trackedMasternodePrepareUpdateService( + walletId: walletId, + proTxHash: record.proTxHash, + operatorKey: keyText, + platformP2PPort: port, + operatorPayoutAddress: payout) + } + preview = try Self.makePreview(from: prepared) + self.prepared = prepared + phase = .previewing + } catch let error as PlatformWalletError { + handleSubmitError(error) + } catch { + errorText = error.localizedDescription + phase = .ready + } + } + + /// Decode the signed bytes — the same inspector the transaction detail + /// screen uses — so what is shown is what will be sent. + private static func makePreview( + from prepared: FinalizedCoreTransaction + ) throws -> Preview { + let bytes = try prepared.serializedData() + let parsed = try ParsedRawTransaction(data: bytes) + let network: PaymentNetwork = WalletEnvironment.isTestnet ? .testnet : .mainnet + let outputs = parsed.outputs.map { output in + (address: ScriptAddressCodec.address(forScript: output.scriptPubKey, network: network) + ?? NSLocalizedString("Non-standard output", comment: "Masternode unban"), + amountDuffs: output.valueDuffs) + } + return Preview( + feeDuffs: prepared.fee, + sizeBytes: bytes.count, + inputCount: parsed.inputs.count, + outputs: outputs, + payloadFields: RawTransactionInspector.parsePayloadFields( + type: parsed.type, payload: parsed.extraPayload, network: network), + rawHex: bytes.map { String(format: "%02x", $0) }.joined()) + } + + /// Send the reviewed transaction. Reuses the wallet's own broadcast + /// path, so acceptance is classified exactly like an ordinary send. + func broadcastReviewed() async { + guard let transaction = prepared else { return } + errorText = nil + phase = .submitting + prepared = nil + do { + let outcome = try SwiftDashSDKTransactionSender.broadcast(transaction) + let txid = try SwiftDashSDKTransactionSender.requireAccepted(outcome) + PendingMasternodeUnbanStore.shared.clear(forProTxHash: record.proTxHash) + preview = nil + phase = .submitted(txidHex: txid) + } catch { + // The handle is consumed either way: a rejected broadcast + // released its reservation, an ambiguous one keeps it. Neither + // may be re-sent from here — prepare again for a fresh attempt. + preview = nil + errorText = error.localizedDescription + terminal = true + phase = .ready + } + } + + /// Discard a reviewed transaction the user decided not to send. Dropping + /// the token abandons it and releases its inputs. + func discardReviewed() { + prepared = nil + preview = nil + errorText = nil + phase = .ready + } } diff --git a/DashWallet/Sources/UI/Menu/Tools/Unban/UnbanMasternodeSheet.swift b/DashWallet/Sources/UI/Menu/Tools/Unban/UnbanMasternodeSheet.swift index c14aa1324..a15f3f0ab 100644 --- a/DashWallet/Sources/UI/Menu/Tools/Unban/UnbanMasternodeSheet.swift +++ b/DashWallet/Sources/UI/Menu/Tools/Unban/UnbanMasternodeSheet.swift @@ -41,6 +41,8 @@ struct UnbanMasternodeSheet: View { payoutSection } fundingSection + reviewToggleSection + previewSection submitSection } .navigationTitle(NSLocalizedString("Unban masternode", comment: "Masternode unban")) @@ -162,6 +164,67 @@ struct UnbanMasternodeSheet: View { } } + /// Opt-in review (off by default): with it on, Unban builds and signs + /// the transaction and shows it instead of sending it. + @ViewBuilder + private var reviewToggleSection: some View { + if viewModel.preview == nil { + Section { + Toggle( + NSLocalizedString("Review transaction first", comment: "Masternode unban"), + isOn: $viewModel.reviewBeforeBroadcast) + } footer: { + Text(NSLocalizedString( + "Build and sign the transaction and show it here, so you can check it before it's broadcast.", + comment: "Masternode unban")) + } + } + } + + @ViewBuilder + private var previewSection: some View { + if let preview = viewModel.preview { + Section { + MasternodeDetailRow( + label: NSLocalizedString("Network fee", comment: "Masternode unban"), + value: preview.feeDuffs.formattedDashAmount) + MasternodeDetailRow( + label: NSLocalizedString("Size", comment: "Masternode unban"), + value: String( + format: NSLocalizedString("%d bytes", comment: "Masternode unban"), + preview.sizeBytes)) + MasternodeDetailRow( + label: NSLocalizedString("Inputs", comment: "Masternode unban"), + value: "\(preview.inputCount)") + ForEach(Array(preview.outputs.enumerated()), id: \.offset) { _, output in + MasternodeDetailRow( + label: NSLocalizedString("Change", comment: "Masternode unban"), + value: "\(output.amountDuffs.formattedDashAmount) → \(output.address)") + } + } header: { + Text(NSLocalizedString("Transaction", comment: "Masternode unban")) + } footer: { + Text(NSLocalizedString( + "This is the signed transaction, exactly as it will be broadcast.", + comment: "Masternode unban")) + } + + Section(NSLocalizedString("Provider update payload", comment: "Masternode unban")) { + ForEach(preview.payloadFields, id: \.label) { field in + MasternodeCopyRow(label: field.label, value: field.value) + } + } + + Section { + DisclosureGroup(NSLocalizedString("Raw transaction", comment: "Masternode unban")) { + Text(preview.rawHex) + .font(.system(.caption2, design: .monospaced)) + .textSelection(.enabled) + } + } + } + } + @ViewBuilder private var submitSection: some View { Section { @@ -187,6 +250,20 @@ struct UnbanMasternodeSheet: View { Text(NSLocalizedString("Broadcasting…", comment: "Masternode unban")) } .frame(maxWidth: .infinity) + case .previewing: + Button { + Task { await viewModel.broadcastReviewed() } + } label: { + Text(NSLocalizedString("Broadcast", comment: "Masternode unban")) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + } + Button(role: .destructive) { + viewModel.discardReviewed() + } label: { + Text(NSLocalizedString("Discard", comment: "Masternode unban")) + .frame(maxWidth: .infinity) + } default: Button { Task { await viewModel.submit() } diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index 5da8bd2a6..f9dfb84b1 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -570,10 +570,12 @@ "Bound to contract, document type “%@”" = "Bound to contract, document type “%@”"; /* No comment provided by engineer. */ +"Broadcast" = "Broadcast"; "Broadcasting" = "Broadcasting"; /* InternalTransfer recovery */ "Broadcasting…" = "Broadcasting…"; +"Build and sign the transaction and show it here, so you can check it before it's broadcast." = "Build and sign the transaction and show it here, so you can check it before it's broadcast."; "Building the privacy proof can take up to a minute. Keep the app open." = "Building the privacy proof can take up to a minute. Keep the app open."; /* Log export */ @@ -674,6 +676,7 @@ "Challenge" = "Challenge"; /* SPV sync */ +"Change" = "Change"; "Change peers" = "Change peers"; /* No comment provided by engineer. */ @@ -744,6 +747,7 @@ "DashPay enabled" = "DashPay enabled"; /* Identity top-up sheet — custom amount below the floor */ +"Discard" = "Discard"; "Enter at least %@ DASH" = "Enter at least %@ DASH"; /* Identity top-up sheet — fee estimate line */ @@ -790,6 +794,7 @@ "In the meantime you are reachable at “%1$@”, which is yours to keep. If the vote awards you “%2$@”, you will be reachable at both usernames." = "In the meantime you are reachable at “%1$@”, which is yours to keep. If the vote awards you “%2$@”, you will be reachable at both usernames."; /* Username marketplace: search row for a label a past vote locked */ +"Inputs" = "Inputs"; "Locked by a network vote — nobody can register it" = "Locked by a network vote — nobody can register it"; /* Username marketplace: latest ownership change */ @@ -825,8 +830,11 @@ "Completing your purchase…" = "Completing your purchase…"; /* Usernames */ +"Non-standard output" = "Non-standard output"; "Operator payout address" = "Operator payout address"; +"Operator signature (BLS)" = "Operator signature (BLS)"; "Platform P2P port" = "Platform P2P port"; +"Provider update payload" = "Provider update payload"; "Receive some DASH to this wallet, or fund its shielded balance, then return here." = "Receive some DASH to this wallet, or fund its shielded balance, then return here."; "Register username" = "Register username"; @@ -837,6 +845,7 @@ "Listing for sale…" = "Listing for sale…"; /* Usernames */ +"Review transaction first" = "Review transaction first"; "Submit both usernames" = "Submit both usernames"; /* Usernames */ @@ -854,6 +863,7 @@ "The unban was sent but its result couldn't be confirmed. Don't retry — check the masternode's status again in a few minutes." = "The unban was sent but its result couldn't be confirmed. Don't retry — check the masternode's status again in a few minutes."; "The wallet has no spendable DASH for the network fee." = "The wallet has no spendable DASH for the network fee."; "The withdrawal settles through the network's withdrawal queue — usually a few minutes. You can close this sheet; \"Complete unban\" appears on the masternode until it's done." = "The withdrawal settles through the network's withdrawal queue — usually a few minutes. You can close this sheet; \"Complete unban\" appears on the masternode until it's done."; +"This is the signed transaction, exactly as it will be broadcast." = "This is the signed transaction, exactly as it will be broadcast."; "This masternode pays an operator reward, and the update replaces its payout address on-chain — confirm the address the operator reward should keep paying." = "This masternode pays an operator reward, and the update replaces its payout address on-chain — confirm the address the operator reward should keep paying."; "This masternode uses extended (v3) network info, which this wallet can't re-assert yet. Unban it with dash-cli instead." = "This masternode uses extended (v3) network info, which this wallet can't re-assert yet. Unban it with dash-cli instead."; "This name requires a masternode vote. The contest fee is spent when you submit and is not returned, even if you do not win the name." = "This name requires a masternode vote. The contest fee is spent when you submit and is not returned, even if you do not win the name.";