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.";