diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index 47796cf01..1e22b3408 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -630,6 +630,12 @@ 5AFDE1012FA1000000000016 /* AddMasternodeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AFDE1012FA1000000000003 /* AddMasternodeScreen.swift */; }; 5AFDE1012FA1000000000017 /* TrackedMasternodeDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AFDE1012FA1000000000004 /* TrackedMasternodeDetailScreen.swift */; }; 5AFDE1012FA1000000000018 /* TrackedMasternodeDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AFDE1012FA1000000000004 /* TrackedMasternodeDetailScreen.swift */; }; + UB26A0012FA2000000000011 /* PendingMasternodeUnbanStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = UB26A0012FA2000000000001 /* PendingMasternodeUnbanStore.swift */; }; + UB26A0012FA2000000000012 /* PendingMasternodeUnbanStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = UB26A0012FA2000000000001 /* PendingMasternodeUnbanStore.swift */; }; + UB26A0012FA2000000000013 /* MasternodeUnbanViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = UB26A0012FA2000000000002 /* MasternodeUnbanViewModel.swift */; }; + UB26A0012FA2000000000014 /* MasternodeUnbanViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = UB26A0012FA2000000000002 /* MasternodeUnbanViewModel.swift */; }; + UB26A0012FA2000000000015 /* UnbanMasternodeSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = UB26A0012FA2000000000003 /* UnbanMasternodeSheet.swift */; }; + UB26A0012FA2000000000016 /* UnbanMasternodeSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = UB26A0012FA2000000000003 /* UnbanMasternodeSheet.swift */; }; 5AE5700E2F9C000000000003 /* EvonodeStatusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AE5700E2F9C000000000001 /* EvonodeStatusViewModel.swift */; }; 5AE5700E2F9C000000000004 /* EvonodeStatusViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AE5700E2F9C000000000001 /* EvonodeStatusViewModel.swift */; }; 5AE5700E2F9C000000000005 /* EvonodeStatusScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AE5700E2F9C000000000002 /* EvonodeStatusScreen.swift */; }; @@ -2868,6 +2874,9 @@ 5AFDE1012FA1000000000002 /* AddMasternodeViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AddMasternodeViewModel.swift; sourceTree = ""; }; 5AFDE1012FA1000000000003 /* AddMasternodeScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AddMasternodeScreen.swift; sourceTree = ""; }; 5AFDE1012FA1000000000004 /* TrackedMasternodeDetailScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrackedMasternodeDetailScreen.swift; sourceTree = ""; }; + UB26A0012FA2000000000001 /* PendingMasternodeUnbanStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PendingMasternodeUnbanStore.swift; sourceTree = ""; }; + UB26A0012FA2000000000002 /* MasternodeUnbanViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MasternodeUnbanViewModel.swift; sourceTree = ""; }; + UB26A0012FA2000000000003 /* UnbanMasternodeSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UnbanMasternodeSheet.swift; sourceTree = ""; }; 5AE5700E2F9C000000000001 /* EvonodeStatusViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EvonodeStatusViewModel.swift; sourceTree = ""; }; 5AE5700E2F9C000000000002 /* EvonodeStatusScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EvonodeStatusScreen.swift; sourceTree = ""; }; 51BA2E9F2F0B2E0100A1B201 /* SwapPendingGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwapPendingGate.swift; sourceTree = ""; }; @@ -3849,6 +3858,16 @@ path = "Tracked Masternodes"; sourceTree = ""; }; + UB26A0012FA2000000000004 /* Unban */ = { + isa = PBXGroup; + children = ( + UB26A0012FA2000000000001 /* PendingMasternodeUnbanStore.swift */, + UB26A0012FA2000000000002 /* MasternodeUnbanViewModel.swift */, + UB26A0012FA2000000000003 /* UnbanMasternodeSheet.swift */, + ); + path = Unban; + sourceTree = ""; + }; 5AE5700E2F9C000000000007 /* Evonode Status */ = { isa = PBXGroup; children = ( @@ -4905,6 +4924,7 @@ 51AA00202F970020005A0020 /* MasternodesScreen.swift */, 5AEDE0012F9B000000000007 /* Masternode Withdrawal */, 5AFDE1012FA1000000000005 /* Tracked Masternodes */, + UB26A0012FA2000000000004 /* Unban */, 5AE5700E2F9C000000000007 /* Evonode Status */, 7566F4822BB69498005238D2 /* ToolsMenuScreen.swift */, RC00C0032FA0000000000001 /* CSVExportSheet.swift */, @@ -10304,6 +10324,9 @@ 5AFDE1012FA1000000000014 /* AddMasternodeViewModel.swift in Sources */, 5AFDE1012FA1000000000016 /* AddMasternodeScreen.swift in Sources */, 5AFDE1012FA1000000000018 /* TrackedMasternodeDetailScreen.swift in Sources */, + UB26A0012FA2000000000012 /* PendingMasternodeUnbanStore.swift in Sources */, + UB26A0012FA2000000000014 /* MasternodeUnbanViewModel.swift in Sources */, + UB26A0012FA2000000000016 /* UnbanMasternodeSheet.swift in Sources */, 5AE5700E2F9C000000000004 /* EvonodeStatusViewModel.swift in Sources */, 5AE5700E2F9C000000000006 /* EvonodeStatusScreen.swift in Sources */, 51AA00022F970002005A0002 /* SyncInfoMenuScreen.swift in Sources */, @@ -11336,6 +11359,9 @@ 5AFDE1012FA1000000000013 /* AddMasternodeViewModel.swift in Sources */, 5AFDE1012FA1000000000015 /* AddMasternodeScreen.swift in Sources */, 5AFDE1012FA1000000000017 /* TrackedMasternodeDetailScreen.swift in Sources */, + UB26A0012FA2000000000011 /* PendingMasternodeUnbanStore.swift in Sources */, + UB26A0012FA2000000000013 /* MasternodeUnbanViewModel.swift in Sources */, + UB26A0012FA2000000000015 /* UnbanMasternodeSheet.swift in Sources */, 5AE5700E2F9C000000000003 /* EvonodeStatusViewModel.swift in Sources */, 5AE5700E2F9C000000000005 /* EvonodeStatusScreen.swift in Sources */, 51AA00032F970003005A0003 /* SyncInfoMenuScreen.swift in Sources */, diff --git a/DashWallet/Sources/UI/Menu/Tools/MasternodesScreen.swift b/DashWallet/Sources/UI/Menu/Tools/MasternodesScreen.swift index 10396b6ab..54ca1afe2 100644 --- a/DashWallet/Sources/UI/Menu/Tools/MasternodesScreen.swift +++ b/DashWallet/Sources/UI/Menu/Tools/MasternodesScreen.swift @@ -171,7 +171,7 @@ extension PlatformMasternode { var statusName: String { switch masternodeStatus { case .active: return NSLocalizedString("Active", comment: "Masternode status") - case .inactive: return NSLocalizedString("Inactive", comment: "Masternode status") + case .inactive: return NSLocalizedString("PoSe banned", comment: "Masternode status") case .retired: return NSLocalizedString("Retired", comment: "Masternode status") case .unknown: return NSLocalizedString("Unknown", comment: "Masternode status") } @@ -526,6 +526,11 @@ struct MasternodeDetailScreen: View { let ownerOwnership: String let votingOwnership: String @StateObject private var viewModel: MasternodeDetailViewModel + @State private var showUnbanSheet = false + /// Set after a successful ProUpServTx broadcast: the DML entry stays + /// PoSe-banned for a few more blocks, so the row must not invite a + /// duplicate submission in that window. + @State private var unbanSubmitted = false init( masternode: PlatformMasternode, @@ -559,6 +564,7 @@ struct MasternodeDetailScreen: View { if masternode.isEvonode { requestStatusRow } + unbanRows } Section(NSLocalizedString("Registration", comment: "Masternodes")) { @@ -677,6 +683,46 @@ struct MasternodeDetailScreen: View { .task { await viewModel.load() } + .sheet(isPresented: $showUnbanSheet) { + UnbanMasternodeSheet( + record: masternode, + keySource: .wallet(operatorKeyIndex: masternode.operatorKeyIndex), + onSubmitted: { unbanSubmitted = true }) + } + } + + /// Unban entry point: shown while the node is PoSe-banned (or an unban + /// funded from the shielded balance is still pending), enabled when this + /// wallet holds the operator key that signs the ProUpServTx. + @ViewBuilder + private var unbanRows: some View { + let pending = PendingMasternodeUnbanStore.shared.pending(forProTxHash: masternode.proTxHash) != nil + if unbanSubmitted { + Text(NSLocalizedString( + "Unban submitted — the masternode list updates within a few blocks.", + comment: "Masternode unban")) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + } else if masternode.masternodeStatus == .inactive || pending { + if masternode.operatorInWallet { + Button { + showUnbanSheet = true + } label: { + Label( + pending + ? NSLocalizedString("Complete unban", comment: "Masternode unban") + : NSLocalizedString("Unban masternode", comment: "Masternode unban"), + systemImage: "arrow.up.heart") + .foregroundColor(Color.dash.blue) + } + } else if masternode.masternodeStatus == .inactive { + Text(NSLocalizedString( + "This wallet doesn't hold this masternode's operator key, so it can't be unbanned from here.", + comment: "Masternode unban")) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + } + } } /// Entry point to the node's DAPI `getStatus` self-report diff --git a/DashWallet/Sources/UI/Menu/Tools/Tracked Masternodes/TrackedMasternodeDetailScreen.swift b/DashWallet/Sources/UI/Menu/Tools/Tracked Masternodes/TrackedMasternodeDetailScreen.swift index cf54a97f1..54d8027f6 100644 --- a/DashWallet/Sources/UI/Menu/Tools/Tracked Masternodes/TrackedMasternodeDetailScreen.swift +++ b/DashWallet/Sources/UI/Menu/Tools/Tracked Masternodes/TrackedMasternodeDetailScreen.swift @@ -262,6 +262,11 @@ struct TrackedMasternodeDetailScreen: View { @State private var showStopConfirm = false @State private var showWithdrawSheet = false @State private var showKeysSheet = false + @State private var showUnbanSheet = false + /// Set after a successful ProUpServTx broadcast: the DML entry stays + /// PoSe-banned for a few more blocks, so the row must not invite a + /// duplicate submission in that window. + @State private var unbanSubmitted = false let onChanged: () -> Void init(record: PlatformMasternode, onChanged: @escaping () -> Void) { @@ -287,6 +292,18 @@ struct TrackedMasternodeDetailScreen: View { .sheet(isPresented: $showKeysSheet, onDismiss: { viewModel.reloadAttachedRoles() }) { TrackedKeyManagementSheet(record: viewModel.record, vault: viewModel.vault) } + .sheet(isPresented: $showUnbanSheet) { + UnbanMasternodeSheet( + record: viewModel.record, + keySource: .tracked(vault: viewModel.vault), + onSubmitted: { + unbanSubmitted = true + Task { + await viewModel.refresh() + onChanged() + } + }) + } .confirmationDialog( NSLocalizedString("Stop tracking this masternode?", comment: "Tracked masternodes"), isPresented: $showStopConfirm, @@ -458,6 +475,8 @@ struct TrackedMasternodeDetailScreen: View { private var manageSection: some View { Section { + unbanRows + requestStatusRow Button { @@ -486,6 +505,41 @@ struct TrackedMasternodeDetailScreen: View { } } + /// Unban entry point: shown while the node is PoSe-banned (or an unban + /// funded from the shielded balance is still pending), enabled when the + /// operator key — the one that signs a ProUpServTx — is on this device. + @ViewBuilder + private var unbanRows: some View { + let pending = PendingMasternodeUnbanStore.shared.pending( + forProTxHash: viewModel.record.proTxHash) != nil + if unbanSubmitted { + Text(NSLocalizedString( + "Unban submitted — the masternode list updates within a few blocks.", + comment: "Masternode unban")) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + } else if viewModel.record.masternodeStatus == .inactive || pending { + if viewModel.capabilities.canUpdateService { + Button { + showUnbanSheet = true + } label: { + Label( + pending + ? NSLocalizedString("Complete unban", comment: "Masternode unban") + : NSLocalizedString("Unban masternode", comment: "Masternode unban"), + systemImage: "arrow.up.heart") + .foregroundColor(Color.dash.blue) + } + } else if viewModel.record.masternodeStatus == .inactive { + Text(NSLocalizedString( + "Add this masternode's operator key to unban it from here.", + comment: "Masternode unban")) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + } + } + } + /// Ask the node itself for its DAPI status — the same /// `EvonodeStatusScreen` the wallet-owned detail pushes, keyed off the /// record's DAPI address (evonodes with a routable service address). diff --git a/DashWallet/Sources/UI/Menu/Tools/Unban/MasternodeUnbanViewModel.swift b/DashWallet/Sources/UI/Menu/Tools/Unban/MasternodeUnbanViewModel.swift new file mode 100644 index 000000000..c9197093b --- /dev/null +++ b/DashWallet/Sources/UI/Menu/Tools/Unban/MasternodeUnbanViewModel.swift @@ -0,0 +1,321 @@ +// +// Created by Claude Code +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Combine +import SwiftDashSDK +import SwiftUI + +/// Where the operator BLS key for an unban comes from. +enum UnbanOperatorKeySource { + /// Wallet-owned masternode: the SDK derives the key at the record's + /// resolved `operatorKeyIndex` through the mnemonic resolver. + case wallet(operatorKeyIndex: UInt32) + /// Tracked masternode: the vaulted operator key text signs. + case tracked(vault: TrackedMasternodeKeyVaulting) +} + +/// The unban (ProUpServTx) flow for one PoSe-banned masternode: fee-funding +/// preflight (with the guided shielded-pool top-up when the wallet has no +/// spendable DASH), the evonode P2P port and operator-payout inputs, the +/// authenticated submit, and the persisted pending step that survives the +/// minutes-long withdrawal settlement. Owns every SDK call; the sheet only +/// renders and forwards actions. +@MainActor +final class MasternodeUnbanViewModel: ObservableObject { + + enum Phase: Equatable { + /// Enough spendable DASH for the network fee — ready to submit. + case ready + /// No spendable DASH for the fee; offer the shielded top-up. + case needsFunds + /// The shielded→L1 withdrawal is proving/broadcasting. + case toppingUp + /// Withdrawal submitted; the L1 payout settles through the Platform + /// withdrawal queue (minutes). Polling the spendable balance. + case waitingForFunds + case submitting + /// The ProUpServTx was broadcast and accepted (display-order txid). + case submitted(txidHex: String) + } + + let record: PlatformMasternode + let keySource: UnbanOperatorKeySource + + /// Evonode platform P2P port (D4: the masternode list doesn't carry it — + /// user-editable, defaulting to Tenderdash's standard port). + @Published var p2pPortText = "26656" + /// Operator payout address — revealed only when the SDK reports the node + /// pays an operator reward (D2: the payload replaces the payout script + /// on-chain, so it must be confirmed explicitly, never defaulted). + @Published var payoutAddressText = "" + @Published private(set) var payoutAddressRequired = false + + @Published private(set) var phase: Phase = .ready + @Published private(set) var errorText: String? + /// A terminal outcome (unconfirmed broadcast, unsupported entry): the + /// submit button stays hidden so the flow can't be retried into a + /// double-spend or a repeat failure. + @Published private(set) var terminal = false + + @Published private(set) var spendableDuffs: UInt64 = 0 + @Published private(set) var shieldedBalanceCredits: UInt64 = 0 + + /// Drives the shielded→L1 top-up; same coordinator the transfer screens + /// use, constructed per flow. + let topUpCoordinator = ShieldedTransferCoordinator() + + /// Spendable floor that comfortably covers a ~500-byte ProUpServTx at + /// the default fee rate, with headroom for the wallet's input shapes. + static let feeFloorDuffs: UInt64 = 10_000 + /// Shielded top-up amount: 0.001 DASH — small, but far above dust and + /// enough for many unban fees. + static let topUpDuffs: UInt64 = 100_000 + + static var topUpCredits: UInt64 { + topUpDuffs * EvonodeWithdrawalViewModel.creditsPerDuff + } + + private var balancePollTask: Task? + + init(record: PlatformMasternode, keySource: UnbanOperatorKeySource) { + self.record = record + self.keySource = keySource + if let pending = PendingMasternodeUnbanStore.shared.pending(forProTxHash: record.proTxHash) { + if let port = pending.platformP2PPort { + p2pPortText = String(port) + } + if let payout = pending.operatorPayoutAddress { + payoutAddressText = payout + payoutAddressRequired = true + } + } + } + + var isEvonode: Bool { record.isEvonode } + + var p2pPort: UInt16? { + // Port 0 parses but is not a usable service port — reject it so + // canSubmit treats it like any other invalid entry. + let port = UInt16(p2pPortText.trimmingCharacters(in: .whitespaces)) + return port == 0 ? nil : port + } + + var canSubmit: Bool { + guard !terminal else { return false } + guard phase == .ready else { return false } + if isEvonode && p2pPort == nil { return false } + if payoutAddressRequired { + let payout = payoutAddressText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !payout.isEmpty, payout.isValidDashAddressForCurrentNetwork else { return false } + } + return true + } + + var topUpAmountText: String { + String(format: "%.4f DASH", Double(Self.topUpDuffs) / 100_000_000.0) + } + + var canTopUp: Bool { + shieldedBalanceCredits >= Self.topUpCredits + } + + /// Refresh the funding preflight: spendable L1 balance vs the fee floor, + /// and the shielded balance the top-up offer is gated on. Re-entered by + /// the balance poller while waiting for a top-up to land. + func refreshFunding() { + let balance = (try? SwiftDashSDKHost.shared.wallet?.balance()) ?? nil + spendableDuffs = balance?.spendable ?? 0 + shieldedBalanceCredits = PlatformAddressSyncCoordinator.shared.shieldedBalance + switch phase { + case .ready, .needsFunds: + phase = spendableDuffs >= Self.feeFloorDuffs ? .ready : .needsFunds + case .waitingForFunds where spendableDuffs >= Self.feeFloorDuffs: + phase = .ready + balancePollTask?.cancel() + default: + break + } + } + + /// Restore the waiting state for a persisted pending unban (funds may + /// still be in the withdrawal queue) — unless they already arrived. + func resumePendingIfAny() { + guard PendingMasternodeUnbanStore.shared.pending(forProTxHash: record.proTxHash) != nil else { + refreshFunding() + return + } + refreshFunding() + if phase == .needsFunds { + phase = .waitingForFunds + startBalancePolling() + } + } + + // MARK: Top-up (D3: guided, resumable) + + /// Withdraw the buffer from the shielded pool to the wallet's own + /// receive address and wait for the L1 payout. The intent persists only + /// once the withdrawal was actually submitted (or its outcome is + /// ambiguous, when the payout can still arrive) — persisting earlier + /// would leave a phantom "Complete unban" behind if the app dies during + /// the authentication prompt, with no payout ever coming. + func topUpFromShielded() async { + errorText = nil + phase = .toppingUp + + await topUpCoordinator.performWithdraw(amountCredits: Self.topUpCredits) + + switch topUpCoordinator.phase { + case .success, .submittedUnconfirmed: + // `.submittedUnconfirmed` is non-retryable but the payout can + // still arrive — same waiting posture, the poller decides. + persistPending() + phase = .waitingForFunds + startBalancePolling() + case .failed(let message): + errorText = message + phase = .needsFunds + default: + // Cancelled auth leaves the coordinator idle; nothing was sent + // and nothing was persisted. + phase = .needsFunds + } + } + + /// Polls until the top-up lands (the ViewModel deallocating ends the + /// task at its next tick via the weak capture — no deinit needed). + private func startBalancePolling() { + balancePollTask?.cancel() + balancePollTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 3_000_000_000) + guard let self, !Task.isCancelled else { return } + self.refreshFunding() + if self.phase != .waitingForFunds { return } + } + } + } + + private func persistPending() { + PendingMasternodeUnbanStore.shared.record(PendingMasternodeUnban( + proTxHashHex: record.proTxHash.map { String(format: "%02x", $0) }.joined(), + platformP2PPort: isEvonode ? p2pPort : nil, + operatorPayoutAddress: payoutAddressRequired + ? payoutAddressText.trimmingCharacters(in: .whitespacesAndNewlines) + : nil, + createdAt: Date())) + } + + // MARK: Submit + + /// Authenticate and broadcast the ProUpServTx. On success the node + /// returns to the valid list within a few blocks — the DML refresh flips + /// the status badge. + func submit() async { + guard canSubmit else { return } + guard let manager = SwiftDashSDKHost.shared.manager, + let walletId = SwiftDashSDKHost.shared.wallet?.walletId else { + errorText = NSLocalizedString("Wallet is not ready. Try again in a moment.", comment: "Masternode unban") + return + } + errorText = nil + // Leave `.ready` BEFORE suspending on authentication: `canSubmit` + // requires `.ready`, so a second tap while the PIN prompt is up + // can't start a duplicate submission. + phase = .submitting + + let outcome = await AuthenticationGate.authenticate( + biometric: DWGlobalOptions.sharedInstance().biometricAuthEnabled) + guard outcome == .ok else { + phase = .ready + return + } + + let port = isEvonode ? p2pPort : nil + let payout = payoutAddressRequired + ? payoutAddressText.trimmingCharacters(in: .whitespacesAndNewlines) + : nil + + do { + let txid: Data + switch keySource { + case .wallet(let operatorKeyIndex): + txid = try await manager.masternodeUpdateService( + 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 + } + txid = try await manager.trackedMasternodeUpdateService( + walletId: walletId, + proTxHash: record.proTxHash, + operatorKey: keyText, + platformP2PPort: port, + operatorPayoutAddress: payout) + } + PendingMasternodeUnbanStore.shared.clear(forProTxHash: record.proTxHash) + let displayHex = txid.reversed().map { String(format: "%02x", $0) }.joined() + phase = .submitted(txidHex: displayHex) + } catch let error as PlatformWalletError { + handleSubmitError(error) + } catch { + errorText = error.localizedDescription + phase = .ready + } + } + + private func handleSubmitError(_ error: PlatformWalletError) { + switch error { + case .transactionBroadcastUnconfirmed: + // Ambiguous outcome: the transaction may be on the network, so a + // retry risks paying twice. Terminal — the DML refresh tells. + PendingMasternodeUnbanStore.shared.clear(forProTxHash: record.proTxHash) + errorText = NSLocalizedString( + "The unban was sent but its result couldn't be confirmed. Don't retry — check the masternode's status again in a few minutes.", + comment: "Masternode unban") + terminal = true + phase = .ready + case .masternodeListUnavailable: + errorText = NSLocalizedString( + "The masternode list hasn't synced yet. Wait for sync to finish and try again.", + comment: "Masternode unban") + phase = .ready + case .invalidParameter(let message) where message.contains("operator reward"): + // D2: the node pays an operator reward, so the payout address + // must be confirmed explicitly. Reveal the field; the Rust + // message carries the reward percentage. + payoutAddressRequired = true + errorText = message + phase = .ready + case .invalidParameter(let message) where message.contains("extended network info"): + // v3 extended entries can't be re-asserted by a v2 payload — + // fail closed rather than downgrade the node's endpoint map. + errorText = NSLocalizedString( + "This masternode uses extended (v3) network info, which this wallet can't re-assert yet. Unban it with dash-cli instead.", + comment: "Masternode unban") + terminal = true + phase = .ready + default: + errorText = error.errorDescription + phase = .ready + } + } +} diff --git a/DashWallet/Sources/UI/Menu/Tools/Unban/PendingMasternodeUnbanStore.swift b/DashWallet/Sources/UI/Menu/Tools/Unban/PendingMasternodeUnbanStore.swift new file mode 100644 index 000000000..2177f3390 --- /dev/null +++ b/DashWallet/Sources/UI/Menu/Tools/Unban/PendingMasternodeUnbanStore.swift @@ -0,0 +1,92 @@ +// +// Created by Claude Code +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// An unban (ProUpServTx) the user funded from the shielded pool and has not +/// submitted yet. The shielded→L1 withdrawal settles through the Platform +/// withdrawal queue minutes later with no txid, so the flow is a persisted +/// two-step: record the intent when the top-up is submitted, and offer +/// "Complete unban" — with the same parameters — once the funds land, even +/// across an app relaunch. +struct PendingMasternodeUnban: Codable, Equatable { + /// proTxHash in forward/wire hex — the orientation + /// `PlatformMasternode.proTxHash` stores. + let proTxHashHex: String + /// Evonode platform P2P port the user confirmed (nil for regular nodes). + let platformP2PPort: UInt16? + /// Operator payout address the user confirmed (nil when the node has no + /// operator reward). + let operatorPayoutAddress: String? + let createdAt: Date +} + +/// UserDefaults-backed, keyed per wallet like `ShieldedWithdrawalStore`, so +/// a pending unban funded from wallet A never surfaces while wallet B is +/// active. +final class PendingMasternodeUnbanStore { + + static let shared = PendingMasternodeUnbanStore() + + private let defaults = UserDefaults.standard + private let lock = NSLock() + private let keyPrefix = "masternodeUnban.v1.pending" + + private init() {} + + private func resolvedKey() -> String { + guard let walletIdHex = WalletEnvironment.activeWalletIdHex as String?, + !walletIdHex.isEmpty else { + return keyPrefix + } + return "\(keyPrefix)_\(walletIdHex)" + } + + private func loaded() -> [PendingMasternodeUnban] { + guard let data = defaults.data(forKey: resolvedKey()), + let entries = try? JSONDecoder().decode([PendingMasternodeUnban].self, from: data) else { + return [] + } + return entries + } + + private func store(_ entries: [PendingMasternodeUnban]) { + if entries.isEmpty { + defaults.removeObject(forKey: resolvedKey()) + } else if let data = try? JSONEncoder().encode(entries) { + defaults.set(data, forKey: resolvedKey()) + } + } + + func pending(forProTxHash proTxHash: Data) -> PendingMasternodeUnban? { + let hex = proTxHash.map { String(format: "%02x", $0) }.joined() + lock.lock() + defer { lock.unlock() } + return loaded().first { $0.proTxHashHex == hex } + } + + func record(_ entry: PendingMasternodeUnban) { + lock.lock() + defer { lock.unlock() } + var entries = loaded().filter { $0.proTxHashHex != entry.proTxHashHex } + entries.append(entry) + store(entries) + } + + func clear(forProTxHash proTxHash: Data) { + let hex = proTxHash.map { String(format: "%02x", $0) }.joined() + lock.lock() + defer { lock.unlock() } + store(loaded().filter { $0.proTxHashHex != hex }) + } +} diff --git a/DashWallet/Sources/UI/Menu/Tools/Unban/UnbanMasternodeSheet.swift b/DashWallet/Sources/UI/Menu/Tools/Unban/UnbanMasternodeSheet.swift new file mode 100644 index 000000000..c14aa1324 --- /dev/null +++ b/DashWallet/Sources/UI/Menu/Tools/Unban/UnbanMasternodeSheet.swift @@ -0,0 +1,206 @@ +// +// Created by Claude Code +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftDashSDK +import SwiftUI + +/// Confirm-and-submit sheet for unbanning a PoSe-banned masternode: shows +/// what will be re-asserted, collects the evonode P2P port (and, when the +/// node pays an operator reward, the payout address), and walks the guided +/// shielded top-up when the wallet has no spendable DASH for the fee. +struct UnbanMasternodeSheet: View { + @StateObject private var viewModel: MasternodeUnbanViewModel + @Environment(\.dismiss) private var dismiss + /// Called after a successful submit so the presenting detail screen can + /// refresh its status badge. + let onSubmitted: () -> Void + + init(record: PlatformMasternode, keySource: UnbanOperatorKeySource, onSubmitted: @escaping () -> Void) { + _viewModel = StateObject(wrappedValue: MasternodeUnbanViewModel(record: record, keySource: keySource)) + self.onSubmitted = onSubmitted + } + + var body: some View { + NavigationStack { + List { + overviewSection + if viewModel.isEvonode { + p2pPortSection + } + if viewModel.payoutAddressRequired { + payoutSection + } + fundingSection + submitSection + } + .navigationTitle(NSLocalizedString("Unban masternode", comment: "Masternode unban")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(NSLocalizedString("Close", comment: "")) { dismiss() } + } + } + .onAppear { viewModel.resumePendingIfAny() } + } + } + + private var overviewSection: some View { + Section { + MasternodeDetailRow( + label: NSLocalizedString("Masternode", comment: "Masternodes"), + value: viewModel.record.label ?? viewModel.record.displayTitle) + if let service = viewModel.record.serviceAddress { + MasternodeDetailRow( + label: NSLocalizedString("Service", comment: "Masternodes"), + value: service) + } + MasternodeCopyRow(label: "proTxHash", value: viewModel.record.proTxHashHex) + } footer: { + Text(NSLocalizedString( + "Unbanning broadcasts a provider update signed with the operator key, re-asserting the service address shown above. The masternode returns to the valid list within a few blocks.", + comment: "Masternode unban")) + } + } + + private var p2pPortSection: some View { + Section { + TextField("26656", text: $viewModel.p2pPortText) + .keyboardType(.numberPad) + } header: { + Text(NSLocalizedString("Platform P2P port", comment: "Masternode unban")) + } footer: { + Text(NSLocalizedString( + "The masternode list doesn't carry an evonode's Platform P2P port, so it must be confirmed here. 26656 is the standard port — change it only if this node uses another.", + comment: "Masternode unban")) + } + } + + private var payoutSection: some View { + Section { + TextField( + NSLocalizedString("Operator payout address", comment: "Masternode unban"), + text: $viewModel.payoutAddressText) + .font(.system(.footnote, design: .monospaced)) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + } header: { + Text(NSLocalizedString("Operator payout address", comment: "Masternode unban")) + } footer: { + Text(NSLocalizedString( + "This masternode pays an operator reward, and the update replaces its payout address on-chain — confirm the address the operator reward should keep paying.", + comment: "Masternode unban")) + } + } + + @ViewBuilder + private var fundingSection: some View { + switch viewModel.phase { + case .needsFunds: + Section(NSLocalizedString("Network fee", comment: "Masternode unban")) { + Text(NSLocalizedString( + "The wallet has no spendable DASH for the network fee.", + comment: "Masternode unban")) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + if viewModel.canTopUp { + Button { + Task { await viewModel.topUpFromShielded() } + } label: { + Label( + String( + format: NSLocalizedString("Move %@ from shielded balance", comment: "Masternode unban"), + viewModel.topUpAmountText), + systemImage: "shield.lefthalf.filled") + .foregroundColor(Color.dash.blue) + } + Text(NSLocalizedString( + "The transfer settles through the network's withdrawal queue — usually a few minutes. You can close this sheet and come back; the unban resumes where it left off.", + comment: "Masternode unban")) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + } else { + Text(NSLocalizedString( + "Receive some DASH to this wallet, or fund its shielded balance, then return here.", + comment: "Masternode unban")) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + } + } + case .toppingUp: + Section(NSLocalizedString("Network fee", comment: "Masternode unban")) { + HStack(spacing: 8) { + SwiftUI.ProgressView().scaleEffect(0.8) + Text(NSLocalizedString("Moving funds from the shielded balance…", comment: "Masternode unban")) + .foregroundColor(Color.dash.secondaryText) + } + } + case .waitingForFunds: + Section(NSLocalizedString("Network fee", comment: "Masternode unban")) { + HStack(spacing: 8) { + SwiftUI.ProgressView().scaleEffect(0.8) + Text(NSLocalizedString("Funds are on the way…", comment: "Masternode unban")) + .foregroundColor(Color.dash.secondaryText) + } + Text(NSLocalizedString( + "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.", + comment: "Masternode unban")) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + } + default: + EmptyView() + } + } + + @ViewBuilder + private var submitSection: some View { + Section { + switch viewModel.phase { + case .submitted(let txidHex): + Label( + NSLocalizedString("Unban submitted", comment: "Masternode unban"), + systemImage: "checkmark.circle.fill") + .foregroundColor(.green) + MasternodeCopyRow(label: "txid", value: txidHex) + Text(NSLocalizedString( + "The masternode should return to the valid list within a few blocks. Refresh its details in a few minutes.", + comment: "Masternode unban")) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + Button(NSLocalizedString("Done", comment: "")) { + onSubmitted() + dismiss() + } + case .submitting: + HStack(spacing: 8) { + SwiftUI.ProgressView().scaleEffect(0.8) + Text(NSLocalizedString("Broadcasting…", comment: "Masternode unban")) + } + .frame(maxWidth: .infinity) + default: + Button { + Task { await viewModel.submit() } + } label: { + Text(NSLocalizedString("Unban", comment: "Masternode unban")) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + } + .disabled(!viewModel.canSubmit) + } + } footer: { + if let error = viewModel.errorText { + Text(error).foregroundColor(.red) + } + } + } +} diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index 0470f7940..795fd669a 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -270,6 +270,7 @@ "Add at least %@ Dash to your Shielded balance" = "Add at least %@ Dash to your Shielded balance"; /* Usernames */ +"Add this masternode's operator key to unban it from here." = "Add this masternode's operator key to unban it from here."; "Add to your Shielded balance now and register your username privately a few hours later" = "Add to your Shielded balance now and register your username privately a few hours later"; /* Wallets */ @@ -572,6 +573,7 @@ "Broadcasting" = "Broadcasting"; /* InternalTransfer recovery */ +"Broadcasting…" = "Broadcasting…"; "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 */ @@ -714,6 +716,7 @@ "Complete Transfer" = "Complete Transfer"; /* Shielded transfer status */ +"Complete unban" = "Complete unban"; "Completed" = "Completed"; /* Balance info sheet section */ @@ -759,6 +762,7 @@ "Funding from a transparent balance publicly links those coins to your identity on the Dash chain. For privacy, pay from your Shielded balance." = "Funding from a transparent balance publicly links those coins to your identity on the Dash chain. For privacy, pay from your Shielded balance."; /* DashPay FAQ */ +"Funds are on the way…" = "Funds are on the way…"; "How does this compare to Bitcoin in terms of privacy?" = "How does this compare to Bitcoin in terms of privacy?"; /* DashPay FAQ */ @@ -795,6 +799,8 @@ "Listing is a network transaction with a small fee, paid from your identity balance." = "Listing is a network transaction with a small fee, paid from your identity balance."; /* DashPay: banner fallback when the identity has no name yet */ +"Move %@ from shielded balance" = "Move %@ from shielded balance"; +"Moving funds from the shielded balance…" = "Moving funds from the shielded balance…"; "My identity" = "My identity"; /* Username marketplace: owned names segment */ @@ -810,6 +816,9 @@ "Completing your purchase…" = "Completing your purchase…"; /* Usernames */ +"Operator payout address" = "Operator payout address"; +"Platform P2P port" = "Platform P2P port"; +"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"; /* Username marketplace: activity overlay while the delist transition runs */ @@ -825,15 +834,26 @@ "Temporary username" = "Temporary username"; /* Usernames */ +"The masternode list doesn't carry an evonode's Platform P2P port, so it must be confirmed here. 26656 is the standard port — change it only if this node uses another." = "The masternode list doesn't carry an evonode's Platform P2P port, so it must be confirmed here. 26656 is the standard port — change it only if this node uses another."; +"The masternode list hasn't synced yet. Wait for sync to finish and try again." = "The masternode list hasn't synced yet. Wait for sync to finish and try again."; +"The masternode should return to the valid list within a few blocks. Refresh its details in a few minutes." = "The masternode should return to the valid list within a few blocks. Refresh its details in a few minutes."; +"The operator key is missing from the keychain — re-add it and try again." = "The operator key is missing from the keychain — re-add it and try again."; "The temporary username must not itself require voting." = "The temporary username must not itself require voting."; /* Usernames */ +"The transfer settles through the network's withdrawal queue — usually a few minutes. You can close this sheet and come back; the unban resumes where it left off." = "The transfer settles through the network's withdrawal queue — usually a few minutes. You can close this sheet and come back; the unban resumes where it left off."; +"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 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."; /* Usernames */ "This username would also require voting — try adding a digit between 2 and 9" = "This username would also require voting — try adding a digit between 2 and 9"; /* Username marketplace: activity overlay while the transfer transition runs */ +"This wallet doesn't hold this masternode's operator key, so it can't be unbanned from here." = "This wallet doesn't hold this masternode's operator key, so it can't be unbanned from here."; "Transferring the username…" = "Transferring the username…"; /* Username marketplace: activity overlay while the registration transition runs */ @@ -888,6 +908,11 @@ "Lock the name" = "Lock the name"; /* Username marketplace: contest voting deadline row */ +"Unban" = "Unban"; +"Unban masternode" = "Unban masternode"; +"Unban submitted" = "Unban submitted"; +"Unban submitted — the masternode list updates within a few blocks." = "Unban submitted — the masternode list updates within a few blocks."; +"Unbanning broadcasts a provider update signed with the operator key, re-asserting the service address shown above. The masternode returns to the valid list within a few blocks." = "Unbanning broadcasts a provider update signed with the operator key, re-asserting the service address shown above. The masternode returns to the valid list within a few blocks."; "Vote ends" = "Vote ends"; /* Username marketplace: until when other identities can join the contest */