From 016ac2162f5ecd9090a4dd4a220f7ef8a0b2d8dc Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 8 Aug 2026 15:13:07 +0700 Subject: [PATCH 1/3] fix(voting): derive owner/voting keys through the platform wallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These were the app's only Swift-side private-key derivation, and the only keys sourced from the throwaway key-wallet rebuilt from the mnemonic. Payments, identity/DPNS, and the BLS/Ed25519 provider families all derive Rust-side from the running wallet already. That outlier was broken. `Account.derivePrivateKeyWIF` took a `masterPath` and pre-derived it, while the FFI applies the account's own path itself, so every owner/voting key came from m/9'/5'/3'/1'/9'/5'/3'/1'/index instead of m/9'/5'/3'/1'/index Nothing failed locally — the keys were well-formed and deterministic — so the Masternode Keys screen displayed wrong owner/voting private keys, and masternode votes were rejected by Platform as having no voter identity, because the voter identity is derived from the signing key's own hash160. Routes all four families through `ProviderKeyDeriver` (platform-wallet `providerKeyAtIndex`, requires platform#4338), which derives against the running wallet and cross-checks the seed-derived private key against the account xpub before returning. Drops the key-wallet half of `MasternodeProviderKeyDeriver`; what remains is its live address-pool read, which the owner/voting address join needs and which was always correct. Also stops swallowing derivation errors. `key(at:)` used `try?`, so a failure was indistinguishable from "no such key" — which is how a wrong signing key stayed invisible locally and only showed up as an opaque Platform rejection. Adds voting diagnostics that record the registered address, the pool index it matched, and the key source, so the next failure of this class names itself. Co-Authored-By: Claude Opus 5 --- .../Voting/MasternodeVoterRegistry.swift | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift index ebe6fb4f3..c50c3434e 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift @@ -124,8 +124,18 @@ final class MasternodeVoterRegistry { let nodes = eligible .compactMap { masternode -> (PlatformMasternode, UInt32)? in - guard let address = masternode.votingAddress, - let index = indexByAddress[address] else { return nil } + guard let address = masternode.votingAddress else { + Self.logger.info( + "🗳️ VOTING :: skipping a node with no published voting address") + return nil + } + guard let index = indexByAddress[address] else { + Self.logger.info( + "🗳️ VOTING :: registered voting address \(address, privacy: .public) is not in this wallet's pool — not votable") + return nil + } + Self.logger.info( + "🗳️ VOTING :: matched registered voting address \(address, privacy: .public) at pool index \(index, privacy: .public)") return (masternode, index) } .sorted { $0.0.orderIndex < $1.0.orderIndex } @@ -160,8 +170,19 @@ final class MasternodeVoterRegistry { Self.logger.error("🗳️ VOTING :: no provider-voting deriver available") return nil } - guard let wif = deriver.wif(at: node.votingKeyIndex), - let key = WIFParser.parseWIF(wif) else { + + // The index came from joining the node's registered address against + // the live pool; the key is resolved Rust-side from the running wallet + // (platform#4338), which cross-checks it against the account xpub + // before returning. So a key that does not match this node's registered + // voting address cannot reach the signer — Platform would only be able + // to report that as "no voter identity exists", which is + // indistinguishable from a node that was never registered. + Self.logger.info( + "🗳️ VOTING :: signing with the key at pool index \(node.votingKeyIndex, privacy: .public) for \(deriver.address(at: node.votingKeyIndex) ?? "unknown address", privacy: .public)") + + guard let hex = deriver.privateKeyHex(at: node.votingKeyIndex), + let key = Data(hex: hex) else { Self.logger.error( "🗳️ VOTING :: failed to derive voting key at index \(node.votingKeyIndex, privacy: .public)") return nil From f6d6464879ba09f0cd4cf9cbb30351c5b2e8a735 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 8 Aug 2026 15:13:29 +0700 Subject: [PATCH 2/3] feat(voting): remember voting nodes, add Sole Requester, stop re-fetching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes to how votes are chosen and cast. **Remembered node selection.** Replaces the "One node at a time / All nodes at once" toggle with the nodes themselves: a "Voting with" row naming them ("Evonode 2", "Evonode 2, Evonode 3", "All 6 nodes") that opens a picker. The choice is persisted and shared by the single-contest screen and the bulk sheet, so the next contest opens with the same nodes ticked. A wallet that has never voted defaults to ONE node — voting with several at once publicly links those masternodes to each other, and that stays something the user opts into rather than an accident of the first vote. A remembered node that has since been revoked is dropped rather than left to disable voting silently. **Sole Requester.** Bulk selection now accepts only single-request contests, which is what makes "award it to the only requester" well-defined — the caster previously rejected `towards` as not bulkable because it names a specific contender. Ineligible rows stay visible but disabled with the reason, so the selection list does not silently differ from the browsing list. `castBulk` now carries a per-contest choice and per-contest nodes; the `choiceNotBulkable` error is removed rather than left describing a constraint that no longer holds. **Confirm before re-voting.** A bulk run now plans first: for each contest, which selected nodes still need to vote there. A node that already cast the SAME choice is skipped (re-sending spends a per-contest allowance to change nothing); a node that cast a DIFFERENT choice is carried out, because changing a vote is legitimate — Platform accepts several votes per masternode per contest, and refusing would make a recorded vote more permanent than Platform does. The prompt says which votes are replaced and which are left alone, names the earlier choice only when they all agree, and reads the latest record per node so a node that already changed its vote is judged on its live choice. **Stall on returning from a contest.** `.task` re-fires when the list reappears, and `refresh()` re-queries every active contest over the network — around 12s, which read as the screen hanging. The initial load is now `refreshIfNeeded()`, keyed on a fetch having SUCCEEDED rather than having been attempted, so a failed first load still retries. Pull-to-refresh still forces a real reload, and casting already refreshes the contest it touched. Co-Authored-By: Claude Opus 5 --- DashWallet.xcodeproj/project.pbxproj | 6 + .../Voting/MasternodeVoteCaster.swift | 30 +-- .../Sources/Models/Voting/VotingPrefs.swift | 36 +-- .../UI/DashPay/Voting/BulkVoteSheet.swift | 146 +++++++++++- .../UI/DashPay/Voting/CastVoteSheet.swift | 3 +- .../DashPay/Voting/UsernameVotingScreen.swift | 96 +++++--- .../Voting/VotingNodeSelectionSheet.swift | 103 ++++++++ .../UI/DashPay/Voting/VotingViewModel.swift | 220 ++++++++++++++++-- 8 files changed, 547 insertions(+), 93 deletions(-) create mode 100644 DashWallet/Sources/UI/DashPay/Voting/VotingNodeSelectionSheet.swift diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index 4b999918a..e15a2fd44 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -1916,6 +1916,8 @@ E503EDEA94E656CAE70E5A11 /* UsernameRequestStatusScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED4CB35186918CCBD2FFC49F /* UsernameRequestStatusScreen.swift */; }; D19A81C4F6103AC513D737AA /* UsernameRequestStatusScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED4CB35186918CCBD2FFC49F /* UsernameRequestStatusScreen.swift */; }; 63C3BBEF4EE77A6F02144B2D /* BulkVoteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E5E523355F3EEC5CDEB00A /* BulkVoteSheet.swift */; }; + A1B2C3D40002VOTINGNODESEL /* VotingNodeSelectionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40001VOTINGNODESEL /* VotingNodeSelectionSheet.swift */; }; + A1B2C3D40003VOTINGNODESEL /* VotingNodeSelectionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D40001VOTINGNODESEL /* VotingNodeSelectionSheet.swift */; }; 25B84141680B6A9F225C995C /* BulkVoteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3E5E523355F3EEC5CDEB00A /* BulkVoteSheet.swift */; }; 7062FCF792F292D84A0B819B /* VoteHistoryDAO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AFB72236D79932EAE7A38E2 /* VoteHistoryDAO.swift */; }; 553696A24F315D361BC86231 /* VoteHistoryDAO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AFB72236D79932EAE7A38E2 /* VoteHistoryDAO.swift */; }; @@ -3642,6 +3644,7 @@ FF2C19E8480582798A0155E4 /* CastVoteSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CastVoteSheet.swift; sourceTree = ""; }; ED4CB35186918CCBD2FFC49F /* UsernameRequestStatusScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UsernameRequestStatusScreen.swift; sourceTree = ""; }; C3E5E523355F3EEC5CDEB00A /* BulkVoteSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BulkVoteSheet.swift; sourceTree = ""; }; + A1B2C3D40001VOTINGNODESEL /* VotingNodeSelectionSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VotingNodeSelectionSheet.swift; sourceTree = ""; }; 0AFB72236D79932EAE7A38E2 /* VoteHistoryDAO.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VoteHistoryDAO.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -6722,6 +6725,7 @@ isa = PBXGroup; children = ( C3E5E523355F3EEC5CDEB00A /* BulkVoteSheet.swift */, + A1B2C3D40001VOTINGNODESEL /* VotingNodeSelectionSheet.swift */, 33DEAFAEEDCA07D0A331911B /* UsernameVotingScreen.swift */, 6BA141C505AF0AE495DCAA37 /* ContestDetailScreen.swift */, FF2C19E8480582798A0155E4 /* CastVoteSheet.swift */, @@ -9403,6 +9407,7 @@ files = ( 553696A24F315D361BC86231 /* VoteHistoryDAO.swift in Sources */, 25B84141680B6A9F225C995C /* BulkVoteSheet.swift in Sources */, + A1B2C3D40003VOTINGNODESEL /* VotingNodeSelectionSheet.swift in Sources */, D19A81C4F6103AC513D737AA /* UsernameRequestStatusScreen.swift in Sources */, 6181C570B638E07D8B369CDB /* VotingViewModel.swift in Sources */, AD24D88215A4E51186112B34 /* CastVoteSheet.swift in Sources */, @@ -10272,6 +10277,7 @@ files = ( 7062FCF792F292D84A0B819B /* VoteHistoryDAO.swift in Sources */, 63C3BBEF4EE77A6F02144B2D /* BulkVoteSheet.swift in Sources */, + A1B2C3D40002VOTINGNODESEL /* VotingNodeSelectionSheet.swift in Sources */, E503EDEA94E656CAE70E5A11 /* UsernameRequestStatusScreen.swift in Sources */, 1FED991A9D2D236582CE3C99 /* CastVoteSheet.swift in Sources */, D0C564276BD8B9770F0E0499 /* ContestDetailScreen.swift in Sources */, diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoteCaster.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoteCaster.swift index 568bcc6a4..12337e710 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoteCaster.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoteCaster.swift @@ -129,7 +129,6 @@ final class MasternodeVoteCaster { case authenticationCancelled case noNodesSelected case contestClosed(String) - case choiceNotBulkable case sdkUnavailable var errorDescription: String? { @@ -144,10 +143,6 @@ final class MasternodeVoteCaster { return String(format: NSLocalizedString( "Voting on “%@” has already closed. Refresh to see the result.", comment: "Voting"), label) - case .choiceNotBulkable: - return NSLocalizedString( - "Approving a specific request can only be done one username at a time.", - comment: "Voting") case .sdkUnavailable: return NSLocalizedString( "Dash Platform is not connected yet. Wait for syncing to finish and try again.", @@ -267,21 +262,18 @@ final class MasternodeVoteCaster { /// becomes a report with every node failed rather than aborting the run, /// so one stale row cannot discard the rest of the batch. /// - /// - Note: Only ``VoteChoice/abstain`` and ``VoteChoice/lock`` generalize - /// across contests. `towards` names a specific contender, and the - /// legacy "vote for whoever submitted first" rule is not reproducible - /// here: contender submission time lives inside the serialized `domain` - /// document, which the FFI returns as opaque hex. + /// - Note: Each entry carries its own choice. `abstain` and `lock` + /// generalize across contests, but "award it to the only requester" + /// names a different contender identity per contest, so the caller + /// resolves that and passes the result per label. This function does not + /// decide who a contest's contenders are. func castBulk( - choice: VoteChoice, - onNormalizedLabels labels: [String], - with nodes: [VoterNode] + _ work: [(label: String, choice: VoteChoice, nodes: [VoterNode])] ) async throws -> [VoteCastReport] { - guard !nodes.isEmpty else { throw CastError.noNodesSelected } - guard !labels.isEmpty else { return [] } - if case .towards = choice { - throw CastError.choiceNotBulkable + guard work.contains(where: { !$0.nodes.isEmpty }) else { + throw CastError.noNodesSelected } + guard !work.isEmpty else { return [] } guard let sdk = SwiftDashSDKHost.shared.sdk else { throw CastError.sdkUnavailable } switch await AuthenticationGate.authenticate( @@ -295,9 +287,9 @@ final class MasternodeVoteCaster { } var reports: [VoteCastReport] = [] - reports.reserveCapacity(labels.count) + reports.reserveCapacity(work.count) - for label in labels { + for (label, choice, nodes) in work { // "The check failed" and "the poll is closed" are different // outcomes: collapsing them would tell the user a live contest had // already resolved whenever the network hiccupped. diff --git a/DashWallet/Sources/Models/Voting/VotingPrefs.swift b/DashWallet/Sources/Models/Voting/VotingPrefs.swift index 67f64448c..e30dd1156 100644 --- a/DashWallet/Sources/Models/Voting/VotingPrefs.swift +++ b/DashWallet/Sources/Models/Voting/VotingPrefs.swift @@ -18,7 +18,7 @@ import Foundation private let kVotingEnabled = "votingEnabledKey" -private let kVoteWithAllNodes = "voteWithAllNodesKey" +private let kVotingNodeSelection = "votingNodeSelectionKey" // MARK: - VotingPrefs @@ -28,12 +28,8 @@ class VotingPrefs { public static let shared: VotingPrefs = .init() init() { - // Voting one node at a time is the default on purpose: casting every - // node's vote in one action publicly links those masternodes to each - // other, which is a privacy loss the user cannot undo afterwards. UserDefaults.standard.register(defaults: [ kVotingEnabled: true, - kVoteWithAllNodes: false, ]) } @@ -49,15 +45,27 @@ class VotingPrefs { } } - /// `false` (default) casts one masternode's vote per tap; `true` casts - /// with every votable node at once. + /// proTxHashes of the masternodes the user last voted with, so the next + /// contest opens with the same nodes ticked. /// - /// Voting all at once broadcasts several `MasternodeVote` transitions for - /// the same poll in quick succession, which lets an observer group those - /// nodes as one operator. One at a time keeps that correlation the user's - /// choice rather than a side effect of the default. - var voteWithAllNodes: Bool { - get { UserDefaults.standard.bool(forKey: kVoteWithAllNodes) } - set { UserDefaults.standard.set(newValue, forKey: kVoteWithAllNodes) } + /// Empty means "never chosen": callers fall back to a single node rather + /// than every node. That default is deliberate — casting with every node + /// at once broadcasts several `MasternodeVote` transitions for the same + /// poll in quick succession, which lets an observer group those nodes as + /// one operator. Linking them stays something the user opts into and + /// cannot be an accident of the first vote. + /// + /// Local only: this is a UI convenience, never consulted to decide what a + /// node is permitted to do. + var votingNodeSelection: Set { + get { + let stored = UserDefaults.standard.array(forKey: kVotingNodeSelection) as? [String] ?? [] + return Set(stored.compactMap { Data(base64Encoded: $0) }) + } + set { + UserDefaults.standard.set( + newValue.map { $0.base64EncodedString() }.sorted(), + forKey: kVotingNodeSelection) + } } } diff --git a/DashWallet/Sources/UI/DashPay/Voting/BulkVoteSheet.swift b/DashWallet/Sources/UI/DashPay/Voting/BulkVoteSheet.swift index c212c5f48..d75ebabf6 100644 --- a/DashWallet/Sources/UI/DashPay/Voting/BulkVoteSheet.swift +++ b/DashWallet/Sources/UI/DashPay/Voting/BulkVoteSheet.swift @@ -92,7 +92,10 @@ struct BulkVoteSheet: View { @Environment(\.dismiss) private var dismiss - @State private var choice: VoteChoice = .abstain + @State private var choice: VotingViewModel.BulkChoice = .abstain + /// Set when a run would re-vote where this wallet already voted; drives the + /// confirmation below. + @State private var pendingPlan: VotingViewModel.BulkPlan? @State private var selectedNodeIDs: Set = [] private var selectedNodes: [VoterNode] { @@ -125,10 +128,122 @@ struct BulkVoteSheet: View { } } .onAppear { + // Open with the same nodes the single-contest screen would use, so + // the remembered choice means one thing across the feature rather + // than silently widening to every node here. if selectedNodeIDs.isEmpty { - selectedNodeIDs = Set(viewModel.votableNodes.map(\.proTxHash)) + selectedNodeIDs = viewModel.effectiveSelectedNodeIDs } } + .alert( + NSLocalizedString("Some votes already cast", comment: "Voting"), + isPresented: Binding( + get: { pendingPlan != nil }, + set: { if !$0 { pendingPlan = nil } }), + presenting: pendingPlan + ) { plan in + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) { + pendingPlan = nil + } + // Disabled rather than hidden when nothing is left: the user should + // see that their selection is already fully voted, not be offered a + // button that would do nothing. + Button(NSLocalizedString("Continue", comment: "Voting")) { + let confirmed = plan + pendingPlan = nil + Task { await viewModel.castBulk(plan: confirmed) } + } + .disabled(!plan.hasWork) + } message: { plan in + Text(overlapMessage(for: plan)) + } + .onChange(of: selectedNodeIDs) { newValue in + // Remember what the user actually voted with, wherever they chose it. + guard !newValue.isEmpty else { return } + viewModel.selectedNodeIDs = newValue + } + } + + /// Explains what will be cast, what will be replaced, and what is already + /// settled. + /// + /// Changing a vote is legitimate — Platform accepts several votes per + /// masternode per contest — so an earlier vote of a DIFFERENT choice is + /// reported as a replacement, not as a blocker. Only an identical earlier + /// vote is skipped, because re-sending it changes nothing. + private func overlapMessage(for plan: VotingViewModel.BulkPlan) -> String { + let newName = Self.name(for: choice) + var parts: [String] = [] + + if plan.changedPairs > 0 { + if let replaced = plan.replacedChoice.map(Self.name(for:)) { + parts.append(String( + format: NSLocalizedString( + "%1$d of these masternodes already voted “%2$@” on names you selected. Voting “%3$@” replaces those votes.", + comment: "Voting"), + plan.changedPairs, replaced, newName)) + } else { + parts.append(String( + format: NSLocalizedString( + "%1$d of these masternodes already voted differently on names you selected. Voting “%2$@” replaces those votes.", + comment: "Voting"), + plan.changedPairs, newName)) + } + } + + if plan.duplicatePairs > 0 { + parts.append(String( + format: NSLocalizedString( + "%1$d already voted “%2$@” and will be left as they are.", + comment: "Voting"), + plan.duplicatePairs, newName)) + } + + guard plan.hasWork else { + parts.append(NSLocalizedString( + "Nothing is left to cast.", comment: "Voting")) + return parts.joined(separator: " ") + } + + parts.append(String( + format: NSLocalizedString( + "Continue with %1$d vote(s) across %2$d username(s)?", + comment: "Voting"), + plan.totalPairs, plan.work.count)) + return parts.joined(separator: " ") + } + + private static func name(for choice: VotingViewModel.BulkChoice) -> String { + switch choice { + case .soleRequester: return NSLocalizedString("Sole Requester", comment: "Voting") + case .abstain: return NSLocalizedString("Abstain", comment: "Voting") + case .lock: return NSLocalizedString("Lock", comment: "Voting") + } + } + + private static func name(for choice: VoteChoice) -> String { + switch choice { + case .towards: return NSLocalizedString("Sole Requester", comment: "Voting") + case .abstain: return NSLocalizedString("Abstain", comment: "Voting") + case .lock: return NSLocalizedString("Lock", comment: "Voting") + } + } + + private var choiceExplanation: String { + switch choice { + case .soleRequester: + return NSLocalizedString( + "Awards each username to the only person who requested it. Available because every selected name has a single requester.", + comment: "Voting") + case .abstain: + return NSLocalizedString( + "Records your masternodes as having voted, without taking a side.", + comment: "Voting") + case .lock: + return NSLocalizedString( + "Votes that nobody should receive these usernames.", + comment: "Voting") + } } private var form: some View { @@ -149,16 +264,16 @@ struct BulkVoteSheet: View { Section(NSLocalizedString("Choice", comment: "Voting")) { Picker(NSLocalizedString("Choice", comment: "Voting"), selection: $choice) { - Text(NSLocalizedString("Abstain", comment: "Voting")).tag(VoteChoice.abstain) - Text(NSLocalizedString("Lock", comment: "Voting")).tag(VoteChoice.lock) + Text(NSLocalizedString("Sole Requester", comment: "Voting")) + .tag(VotingViewModel.BulkChoice.soleRequester) + Text(NSLocalizedString("Abstain", comment: "Voting")) + .tag(VotingViewModel.BulkChoice.abstain) + Text(NSLocalizedString("Lock", comment: "Voting")) + .tag(VotingViewModel.BulkChoice.lock) } .pickerStyle(.segmented) - Text(choice == .abstain - ? NSLocalizedString("Records your masternodes as having voted, without taking a side.", - comment: "Voting") - : NSLocalizedString("Votes that nobody should receive these usernames.", - comment: "Voting")) + Text(choiceExplanation) .font(.caption) .foregroundColor(Color.dash.secondaryText) } @@ -193,7 +308,18 @@ struct BulkVoteSheet: View { Section { Button { - Task { await viewModel.castBulk(choice: choice, with: selectedNodes) } + Task { + // Plan first: some of these nodes may already have voted + // on some of these names, and Platform caps votes per + // masternode per contest — re-casting spends that + // allowance for nothing. Ask before skipping silently. + let plan = await viewModel.planBulk(choice: choice, with: selectedNodes) + if plan.needsConfirmation { + pendingPlan = plan + } else { + await viewModel.castBulk(plan: plan) + } + } } label: { HStack { Spacer() diff --git a/DashWallet/Sources/UI/DashPay/Voting/CastVoteSheet.swift b/DashWallet/Sources/UI/DashPay/Voting/CastVoteSheet.swift index f64c0fa69..34258af5d 100644 --- a/DashWallet/Sources/UI/DashPay/Voting/CastVoteSheet.swift +++ b/DashWallet/Sources/UI/DashPay/Voting/CastVoteSheet.swift @@ -111,7 +111,8 @@ struct CastVoteSheet: View { "%d of your nodes already voted here and are not listed.", comment: "Voting"), alreadyVoted)) - } else if !viewModel.voteWithAllNodes && viewModel.votableNodes.count > 1 { + } else if viewModel.selectedNodeIDs.count < viewModel.votableNodes.count, + viewModel.votableNodes.count > 1 { Text(NSLocalizedString( "Selecting fewer nodes reveals less about which masternodes you run.", comment: "Voting")) diff --git a/DashWallet/Sources/UI/DashPay/Voting/UsernameVotingScreen.swift b/DashWallet/Sources/UI/DashPay/Voting/UsernameVotingScreen.swift index 95099e0be..4d1e4a472 100644 --- a/DashWallet/Sources/UI/DashPay/Voting/UsernameVotingScreen.swift +++ b/DashWallet/Sources/UI/DashPay/Voting/UsernameVotingScreen.swift @@ -34,13 +34,15 @@ struct UsernameVotingScreen: View { let onClose: () -> Void @StateObject private var viewModel = VotingViewModel() + @State private var showingNodePicker = false var body: some View { VStack(spacing: 0) { VoterCapacityHeader( nodes: viewModel.votableNodes, totalWeight: viewModel.totalVoteWeight, - voteWithAllNodes: $viewModel.voteWithAllNodes) + selectedNodeIDs: $viewModel.selectedNodeIDs, + onEditSelection: { showingNodePicker = true }) if viewModel.nodeListMayBeIncomplete { VotingBanner( @@ -101,7 +103,12 @@ struct UsernameVotingScreen: View { BulkSelectionBar(viewModel: viewModel) } } - .task { await viewModel.refresh() } + .sheet(isPresented: $showingNodePicker) { + VotingNodeSelectionSheet( + nodes: viewModel.votableNodes, + selectedNodeIDs: $viewModel.selectedNodeIDs) + } + .task { await viewModel.refreshIfNeeded() } .refreshable { await viewModel.refresh() } } @@ -129,18 +136,36 @@ struct UsernameVotingScreen: View { } else { List(viewModel.visibleContests) { contest in if viewModel.isSelecting { + let eligible = viewModel.isBulkEligible(contest) Button { viewModel.toggleSelection(contest) } label: { HStack(spacing: 10) { - Image(systemName: viewModel.isSelected(contest) - ? "checkmark.circle.fill" : "circle") - .foregroundColor(viewModel.isSelected(contest) - ? .accentColor : Color.dash.tertiaryText) - ContestRow(contest: contest) + // Contested names are shown but not selectable: + // hiding them would make the list silently differ + // from the browsing list, so say why instead. + Image(systemName: !eligible + ? "minus.circle" + : (viewModel.isSelected(contest) + ? "checkmark.circle.fill" : "circle")) + .foregroundColor(!eligible + ? Color.dash.tertiaryText + : (viewModel.isSelected(contest) + ? .accentColor : Color.dash.tertiaryText)) + VStack(alignment: .leading, spacing: 2) { + ContestRow(contest: contest) + if !eligible { + Text(NSLocalizedString( + "Several people requested this name — open it to choose between them.", + comment: "Voting")) + .font(.caption2) + .foregroundColor(Color.dash.tertiaryText) + } + } } } .buttonStyle(.plain) + .disabled(!eligible) } else { NavigationLink { ContestDetailScreen(contest: contest, viewModel: viewModel) @@ -162,7 +187,10 @@ struct UsernameVotingScreen: View { private struct VoterCapacityHeader: View { let nodes: [VoterNode] let totalWeight: UInt32 - @Binding var voteWithAllNodes: Bool + /// proTxHashes the next vote will use. Tapping the row edits it. + @Binding var selectedNodeIDs: Set + /// Opens the node picker. + let onEditSelection: () -> Void var body: some View { HStack(spacing: 10) { @@ -196,27 +224,29 @@ private struct VoterCapacityHeader: View { .frame(maxWidth: .infinity, alignment: .leading) .background(Color.dash.secondaryBackground) - // Only meaningful with more than one node — with one, both modes do - // exactly the same thing. + // With more than one node the user needs to see WHICH nodes a tap + // will vote with, because that choice links those masternodes to each + // other publicly. With one node there is nothing to choose. if nodes.count > 1 { - VStack(alignment: .leading, spacing: 4) { - Picker(NSLocalizedString("Vote with", comment: "Voting"), - selection: $voteWithAllNodes) { - Text(NSLocalizedString("One node at a time", comment: "Voting")).tag(false) - Text(NSLocalizedString("All nodes at once", comment: "Voting")).tag(true) + Button(action: onEditSelection) { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text(NSLocalizedString("Voting with", comment: "Voting")) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + Text(selectionSummary) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(Color.dash.primaryText) + .multilineTextAlignment(.leading) + } + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundColor(Color.dash.secondaryText) } - .pickerStyle(.segmented) - - Text(voteWithAllNodes - ? NSLocalizedString( - "Every node votes together. Faster, but it publicly links your masternodes to each other.", - comment: "Voting") - : NSLocalizedString( - "Each tap votes with one more node, so you choose how many to link together.", - comment: "Voting")) - .font(.caption) - .foregroundColor(Color.dash.secondaryText) } + .buttonStyle(.plain) .padding(.horizontal, 16) .padding(.bottom, 12) .frame(maxWidth: .infinity, alignment: .leading) @@ -224,6 +254,20 @@ private struct VoterCapacityHeader: View { } } + /// Names the nodes a vote will use, so the privacy consequence of the + /// current selection is legible without opening the picker. + private var selectionSummary: String { + let chosen = nodes.filter { selectedNodeIDs.contains($0.proTxHash) } + guard !chosen.isEmpty else { + return NSLocalizedString("Select nodes", comment: "Voting") + } + if chosen.count == nodes.count { + return String(format: NSLocalizedString( + "All %d nodes", comment: "Voting"), chosen.count) + } + return chosen.map(\.displayName).joined(separator: ", ") + } + private var nodeSummary: String { let evonodes = nodes.filter(\.isEvonode).count let regularNodes = nodes.count - evonodes diff --git a/DashWallet/Sources/UI/DashPay/Voting/VotingNodeSelectionSheet.swift b/DashWallet/Sources/UI/DashPay/Voting/VotingNodeSelectionSheet.swift new file mode 100644 index 000000000..1b554174f --- /dev/null +++ b/DashWallet/Sources/UI/DashPay/Voting/VotingNodeSelectionSheet.swift @@ -0,0 +1,103 @@ +// +// 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. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// 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 DashUIKit +import SwiftUI + +/// Picks which masternodes cast the next vote. +/// +/// The selection is remembered (``VotingPrefs/votingNodeSelection``) so the +/// next contest opens with the same nodes ticked — the user sets their voting +/// posture once rather than re-deciding under time pressure on every name. +/// +/// Voting with several nodes at once is a privacy trade-off the user cannot +/// undo: the transitions land together and let an observer group those +/// masternodes as one operator. The sheet states that rather than burying it, +/// and never pre-ticks every node on the user's behalf. +struct VotingNodeSelectionSheet: View { + let nodes: [VoterNode] + @Binding var selectedNodeIDs: Set + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationView { + List { + Section { + ForEach(nodes) { node in + Button { + toggle(node) + } label: { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text(node.displayName) + .foregroundColor(Color.dash.primaryText) + if let service = node.serviceAddress, !service.isEmpty { + Text(service) + .font(.caption) + .foregroundColor(Color.dash.secondaryText) + } + } + Spacer(minLength: 0) + if selectedNodeIDs.contains(node.proTxHash) { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + .fontWeight(.semibold) + } + } + } + .buttonStyle(.plain) + } + } footer: { + Text(footerText) + } + } + .navigationTitle(NSLocalizedString("Vote with", comment: "Voting")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button(NSLocalizedString("Done", comment: "")) { dismiss() } + // The vote button keys off this selection, so an empty + // one would leave the user on a screen whose primary + // action silently does nothing. + .disabled(selectedNodeIDs.isEmpty) + } + } + } + } + + private var footerText: String { + if selectedNodeIDs.count > 1 { + return NSLocalizedString( + "These nodes vote together, which publicly links them to each other. Choosing fewer nodes keeps them unlinked, at less voting weight.", + comment: "Voting") + } + return NSLocalizedString( + "Only this node votes, so it stays unlinked from your other masternodes. Add more nodes for greater voting weight.", + comment: "Voting") + } + + /// Never lets the last node be unticked — an empty selection reads as + /// "vote with nothing", which is not a state the vote button can act on. + private func toggle(_ node: VoterNode) { + if selectedNodeIDs.contains(node.proTxHash) { + guard selectedNodeIDs.count > 1 else { return } + selectedNodeIDs.remove(node.proTxHash) + } else { + selectedNodeIDs.insert(node.proTxHash) + } + } +} diff --git a/DashWallet/Sources/UI/DashPay/Voting/VotingViewModel.swift b/DashWallet/Sources/UI/DashPay/Voting/VotingViewModel.swift index 5a11ed1d2..5c3f4ab31 100644 --- a/DashWallet/Sources/UI/DashPay/Voting/VotingViewModel.swift +++ b/DashWallet/Sources/UI/DashPay/Voting/VotingViewModel.swift @@ -76,6 +76,10 @@ final class VotingViewModel: ObservableObject { @Published var searchQuery: String = "" @Published var sort: ContestSort = .endingSoonest + /// True once a contest fetch has actually succeeded. Distinct from + /// ``hasLoadedOnce``, which only records that a load was attempted. + private var didLoadContests = false + /// Multi-select mode, the replacement for the old "Quick Voting" screen. @Published var isSelecting = false @Published private(set) var selectedLabels: Set = [] @@ -83,16 +87,29 @@ final class VotingViewModel: ObservableObject { @Published var bulkReports: [VoteCastReport]? /// How many of this wallet's nodes have already voted, per contest, from - /// local history. Drives the "2 of 5 votes cast" line and picks the next - /// node in one-at-a-time mode. + /// local history. Drives the "2 of 5 votes cast" line and excludes nodes + /// that already voted from the next cast. @Published private(set) var castCountsByContest: [String: Int] = [:] /// Which of our nodes voted on the contest currently being viewed. @Published private(set) var votedProTxHashesForOpenContest: Set = [] - /// `false` (default) casts with one node per tap. Persisted, because it is - /// a privacy choice the user should not have to re-make each launch. - @Published var voteWithAllNodes: Bool = VotingPrefs.shared.voteWithAllNodes { - didSet { VotingPrefs.shared.voteWithAllNodes = voteWithAllNodes } + /// proTxHashes of the nodes the next vote will use, shared by the single + /// contest screen and the bulk sheet and persisted so the next contest + /// opens with the same nodes ticked. + /// + /// Persisted rather than re-asked because it is a privacy choice: voting + /// with several nodes at once links those masternodes to each other, and + /// that should stay the user's standing decision, not something re-made + /// under time pressure on every contest. + @Published var selectedNodeIDs: Set = VotingPrefs.shared.votingNodeSelection { + didSet { VotingPrefs.shared.votingNodeSelection = selectedNodeIDs } + } + + /// The selected nodes, in registration order, restricted to ones that are + /// still votable — a remembered node that has since been revoked or left + /// the masternode list must not silently come back. + var selectedNodes: [VoterNode] { + votableNodes.filter { selectedNodeIDs.contains($0.proTxHash) } } /// Result banner for the most recent casting run. @@ -182,17 +199,45 @@ final class VotingViewModel: ObservableObject { votableNodes = resolution.nodes nodeListMayBeIncomplete = resolution.mayBeIncomplete + // Settle the remembered selection against the nodes that actually + // exist now, so the picker and the "voting with" row agree with what a + // tap would really do. Writing it back also migrates a wallet that + // never had a selection (or whose nodes changed) onto a concrete one + // instead of leaving the fallback implicit. + let settled = effectiveSelectedNodeIDs + if settled != selectedNodeIDs { + selectedNodeIDs = settled + } + castCountsByContest = await history.voteCountsByContest( network: MasternodeVoteCaster.networkKey) do { contests = try await contestsService.activeContests() loadError = nil + didLoadContests = true } catch { loadError = (error as? LocalizedError)?.errorDescription ?? String(describing: error) } } + /// The screen's initial load, which does nothing once contests are in hand. + /// + /// `.task` re-runs when the list reappears — including on the way back from + /// a contest — and `refresh()` re-queries every active contest over the + /// network, which takes long enough to read as the screen hanging. Nothing + /// about returning from a contest invalidates the list: casting already + /// refreshes the one contest it touched via ``refreshContest(normalizedLabel:)``, + /// and pull-to-refresh still forces a real reload. + /// + /// Keyed on contests actually having loaded rather than on an attempt, so a + /// first load that failed still retries instead of stranding the user on an + /// empty list. + func refreshIfNeeded() async { + guard !didLoadContests else { return } + await refresh() + } + /// Re-read one contest's tallies after voting on it, so the row reflects /// the vote without a full refresh. /// @@ -268,16 +313,28 @@ final class VotingViewModel: ObservableObject { castCountsByContest[normalizedLabel] = records.count } - /// The nodes a single tap should vote with, honouring the privacy mode. + /// The nodes a single tap should vote with: the remembered selection, + /// minus any that already voted on this contest. /// - /// One-at-a-time takes the first node that has not voted on this contest - /// yet; all-at-once takes every node still outstanding. Returns empty when - /// every node has already voted — the caller disables the control rather - /// than re-broadcasting a vote Platform would reject as a duplicate. + /// Returns empty when every selected node has already voted here — the + /// caller disables the control rather than re-broadcasting a vote Platform + /// would reject as a duplicate. func nodesForNextVote(on normalizedLabel: String) -> [VoterNode] { let remaining = nodesYetToVote(on: normalizedLabel) - guard !remaining.isEmpty else { return [] } - return voteWithAllNodes ? remaining : [remaining[0]] + let chosen = effectiveSelectedNodeIDs + return remaining.filter { chosen.contains($0.proTxHash) } + } + + /// The remembered selection, or a single node when nothing was ever + /// chosen. Never every node by default: see ``VotingPrefs``. + /// + /// Also recovers from a selection that no longer matches any votable node + /// (nodes revoked, or a different wallet), which would otherwise leave the + /// vote button permanently disabled with no way to see why. + var effectiveSelectedNodeIDs: Set { + let live = selectedNodeIDs.intersection(Set(votableNodes.map(\.proTxHash))) + if !live.isEmpty { return live } + return Set(votableNodes.first.map { [$0.proTxHash] } ?? []) } // MARK: Casting @@ -310,6 +367,10 @@ final class VotingViewModel: ObservableObject { // MARK: Multi-select func toggleSelection(_ contest: DPNSContest) { + // Ineligible rows are not selectable at all rather than selectable and + // then dropped at cast time, which would silently vote on fewer names + // than the user ticked. + guard isBulkEligible(contest) else { return } if selectedLabels.contains(contest.normalizedLabel) { selectedLabels.remove(contest.normalizedLabel) } else { @@ -322,7 +383,7 @@ final class VotingViewModel: ObservableObject { } func selectAllVisible() { - selectedLabels = Set(visibleContests.map(\.normalizedLabel)) + selectedLabels = Set(visibleContests.filter(isBulkEligible).map(\.normalizedLabel)) } func endSelecting() { @@ -336,21 +397,134 @@ final class VotingViewModel: ObservableObject { UInt32(selectedLabels.count) &* nodes.totalVoteWeight } - /// Apply one choice to every selected contest. - func castBulk(choice: VoteChoice, with nodes: [VoterNode]) async { + /// Whether `contest` can be picked for a bulk run. + /// + /// Only single-contender contests qualify. A bulk run applies one decision + /// across many names, and with two or more requesters "approve" has no + /// single meaning — the user would be picking a winner per contest without + /// seeing who they are. Restricting the selection is what makes + /// ``BulkChoice/soleRequester`` well-defined. + func isBulkEligible(_ contest: DPNSContest) -> Bool { + contest.contenders.count == 1 + } + + /// The decision a bulk run applies. Unlike ``VoteChoice`` this is not a + /// single wire value: `soleRequester` resolves to a different contender + /// identity per contest. + enum BulkChoice: Hashable { + case abstain + case lock + /// Award each selected name to its only requester. + case soleRequester + } + + /// Resolve `choice` against one contest, or `nil` when it cannot apply. + private func resolvedChoice(_ choice: BulkChoice, for contest: DPNSContest) -> VoteChoice? { + switch choice { + case .abstain: return .abstain + case .lock: return .lock + case .soleRequester: + // Guarded rather than force-unwrapped: selection eligibility is a + // UI rule, and a contest can gain a contender between the pick and + // the cast. Dropping it is safer than voting for the wrong id. + guard contest.contenders.count == 1, + let sole = contest.contenders.first else { return nil } + return .towards(identityId: sole.identityId) + } + } + + /// What a bulk run would actually do, given what this wallet already voted. + /// + /// A masternode may CHANGE its vote on a contest — Platform accepts up to 5 + /// votes per masternode per contest — so an earlier vote is only an + /// obstacle when it was the SAME choice. Those are true no-ops and get + /// dropped; a different earlier choice is a deliberate change and is + /// carried out, because refusing it would make a recorded vote permanent + /// in a way Platform does not. + struct BulkPlan { + /// Per contest: the wire choice and the nodes that will actually cast. + let work: [(label: String, choice: VoteChoice, nodes: [VoterNode])] + /// (node, name) pairs dropped because that node already cast THIS same + /// choice there — re-sending would spend an allowance to change nothing. + let duplicatePairs: Int + /// (node, name) pairs that will REPLACE a different earlier vote. + let changedPairs: Int + /// The choice being replaced, when every replaced vote agrees — `nil` + /// when they differ, so the prompt never names one falsely. + let replacedChoice: VoteChoice? + + var hasWork: Bool { work.contains { !$0.nodes.isEmpty } } + var totalPairs: Int { work.reduce(0) { $0 + $1.nodes.count } } + /// Whether the user should be asked before this runs. + var needsConfirmation: Bool { duplicatePairs > 0 || changedPairs > 0 } + } + + /// Build the plan for the current selection without casting anything. + func planBulk(choice: BulkChoice, with nodes: [VoterNode]) async -> BulkPlan { + // Order the run the way the list is ordered so the result list reads + // in the same sequence the user selected from. `soleRequester` + // resolves per contest, so each label carries its own wire choice. + let selected = visibleContests.filter { selectedLabels.contains($0.normalizedLabel) } + + var work: [(label: String, choice: VoteChoice, nodes: [VoterNode])] = [] + var duplicatePairs = 0 + var changedPairs = 0 + var replacedChoices = Set() + + for contest in selected { + guard let wireChoice = resolvedChoice(choice, for: contest) else { continue } + let records = await history.votes( + forContest: contest.normalizedLabel, + network: MasternodeVoteCaster.networkKey) + + // A node can appear more than once here (it voted, then changed); + // only its most recent vote says what its live choice is. + var latestByNode: [Data: CastVoteRecord] = [:] + for record in records { + if let seen = latestByNode[record.proTxHash], seen.castAt >= record.castAt { + continue + } + latestByNode[record.proTxHash] = record + } + + var casting: [VoterNode] = [] + for node in nodes { + guard let prior = latestByNode[node.proTxHash] else { + casting.append(node) + continue + } + if prior.choice == wireChoice { + duplicatePairs += 1 + } else { + changedPairs += 1 + replacedChoices.insert(prior.choice) + casting.append(node) + } + } + + if !casting.isEmpty { + work.append((contest.normalizedLabel, wireChoice, casting)) + } + } + + return BulkPlan( + work: work, + duplicatePairs: duplicatePairs, + changedPairs: changedPairs, + replacedChoice: replacedChoices.count == 1 ? replacedChoices.first : nil) + } + + /// Cast a plan produced by ``planBulk(choice:with:)``. + func castBulk(plan: BulkPlan) async { isCasting = true castError = nil defer { isCasting = false } - // Order the run the way the list is ordered so the result list reads - // in the same sequence the user selected from. - let labels = visibleContests - .map(\.normalizedLabel) - .filter { selectedLabels.contains($0) } + let work = plan.work + let labels = work.map(\.label) do { - let reports = try await caster.castBulk( - choice: choice, onNormalizedLabels: labels, with: nodes) + let reports = try await caster.castBulk(work) bulkReports = reports for label in labels { await refreshContest(normalizedLabel: label) From 54ddd738badc3c094b8407ec296f798b8401bd94 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 8 Aug 2026 17:40:17 +0700 Subject: [PATCH 3/3] =?UTF-8?q?fix(voting):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20selection=20loss,=20log=20privacy,=20empty-batch=20reporting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six findings from review on #936. **A remembered multi-node selection could be destroyed (Major).** `refresh()` settled the selection against `votableNodes`, which is empty while the SDK is starting or the masternode phase has not synced. The settle then computed an empty selection and PERSISTED it, so a user who chose several nodes silently dropped to one. Only settle once the node list has actually resolved. **An empty batch reported "Select at least one masternode" (Major).** `castBulk` checked `work.contains(where: nodes non-empty)` before the `work.isEmpty` early return, so a batch where planning dropped everything was reported as a missing node selection — sending the user to fix a selection that was fine. Guards reordered, and the sheet no longer calls the caster with an empty batch: it reports that the selected names can no longer take the vote. **Masternode addresses were written to the unified log in clear (Major).** They identify nodes the user operates and persist in sysdiagnose archives. Redacted to `.private`; the pool index and the pool summary (counts only) stay public, which is where the diagnostic value was. **Vote history was one shared set (Minor).** `nodesYetToVote(on:)` ignored its label and read a single set, so a screen evaluated before its `.task` completed could be answered with the previous contest's history — offering a node that already voted, which Platform rejects while still spending one of that masternode's per-contest votes. Keyed by contest, with an absent entry meaning "not loaded yet" rather than "nobody voted"; `hasLoadedVoteHistory` lets the control stay disabled until it is known. **Two screens described a selection the vote does not use (Minor).** The "Voting with" row read the raw persisted set while the cast resolves the effective one, so it could read "Select nodes" while a tap would vote with the fallback node. It now takes the effective selection, and CastVoteSheet's hint compares its own ticks against its own candidates. **`.disabled` on an alert button is unreliable on iOS 17 (Minor).** It can omit the button or fail to track state. Continue is now only offered when there is work, the action guards again rather than trusting the modifier, and the dismiss button reads "OK" when there is nothing to do. Co-Authored-By: Claude Opus 5 --- .../Voting/MasternodeVoteCaster.swift | 6 ++- .../Voting/MasternodeVoterRegistry.swift | 6 +-- .../UI/DashPay/Voting/BulkVoteSheet.swift | 36 +++++++++---- .../UI/DashPay/Voting/CastVoteSheet.swift | 3 +- .../DashPay/Voting/UsernameVotingScreen.swift | 8 ++- .../UI/DashPay/Voting/VotingViewModel.swift | 52 +++++++++++++++---- 6 files changed, 84 insertions(+), 27 deletions(-) diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoteCaster.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoteCaster.swift index 12337e710..e88d3b9ac 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoteCaster.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoteCaster.swift @@ -270,10 +270,14 @@ final class MasternodeVoteCaster { func castBulk( _ work: [(label: String, choice: VoteChoice, nodes: [VoterNode])] ) async throws -> [VoteCastReport] { + // Nothing to do is not the same as nothing selected. An empty batch + // means every selected contest was already fully voted or dropped, and + // reporting "select a masternode" for that sends the user to fix a + // selection that is fine. + guard !work.isEmpty else { return [] } guard work.contains(where: { !$0.nodes.isEmpty }) else { throw CastError.noNodesSelected } - guard !work.isEmpty else { return [] } guard let sdk = SwiftDashSDKHost.shared.sdk else { throw CastError.sdkUnavailable } switch await AuthenticationGate.authenticate( diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift index c50c3434e..59404559a 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift @@ -131,11 +131,11 @@ final class MasternodeVoterRegistry { } guard let index = indexByAddress[address] else { Self.logger.info( - "🗳️ VOTING :: registered voting address \(address, privacy: .public) is not in this wallet's pool — not votable") + "🗳️ VOTING :: a registered voting address is not in this wallet's pool — not votable: \(address, privacy: .private)") return nil } Self.logger.info( - "🗳️ VOTING :: matched registered voting address \(address, privacy: .public) at pool index \(index, privacy: .public)") + "🗳️ VOTING :: matched a registered voting address at pool index \(index, privacy: .public): \(address, privacy: .private)") return (masternode, index) } .sorted { $0.0.orderIndex < $1.0.orderIndex } @@ -179,7 +179,7 @@ final class MasternodeVoterRegistry { // to report that as "no voter identity exists", which is // indistinguishable from a node that was never registered. Self.logger.info( - "🗳️ VOTING :: signing with the key at pool index \(node.votingKeyIndex, privacy: .public) for \(deriver.address(at: node.votingKeyIndex) ?? "unknown address", privacy: .public)") + "🗳️ VOTING :: signing with the key at pool index \(node.votingKeyIndex, privacy: .public) for \(deriver.address(at: node.votingKeyIndex) ?? "unknown address", privacy: .private)") guard let hex = deriver.privateKeyHex(at: node.votingKeyIndex), let key = Data(hex: hex) else { diff --git a/DashWallet/Sources/UI/DashPay/Voting/BulkVoteSheet.swift b/DashWallet/Sources/UI/DashPay/Voting/BulkVoteSheet.swift index d75ebabf6..6c3e0d4ed 100644 --- a/DashWallet/Sources/UI/DashPay/Voting/BulkVoteSheet.swift +++ b/DashWallet/Sources/UI/DashPay/Voting/BulkVoteSheet.swift @@ -142,18 +142,26 @@ struct BulkVoteSheet: View { set: { if !$0 { pendingPlan = nil } }), presenting: pendingPlan ) { plan in - Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) { + Button( + plan.hasWork + ? NSLocalizedString("Cancel", comment: "") + : NSLocalizedString("OK", comment: ""), + role: .cancel + ) { pendingPlan = nil } - // Disabled rather than hidden when nothing is left: the user should - // see that their selection is already fully voted, not be offered a - // button that would do nothing. - Button(NSLocalizedString("Continue", comment: "Voting")) { - let confirmed = plan - pendingPlan = nil - Task { await viewModel.castBulk(plan: confirmed) } + // Offered only when there is something to cast. `.disabled` inside + // an alert is unreliable on iOS 17 — it can omit the button + // entirely or fail to reflect state — so the decision is made here, + // and the action guards again rather than trusting the modifier. + if plan.hasWork { + Button(NSLocalizedString("Continue", comment: "Voting")) { + let confirmed = plan + pendingPlan = nil + guard confirmed.hasWork else { return } + Task { await viewModel.castBulk(plan: confirmed) } + } } - .disabled(!plan.hasWork) } message: { plan in Text(overlapMessage(for: plan)) } @@ -316,8 +324,16 @@ struct BulkVoteSheet: View { let plan = await viewModel.planBulk(choice: choice, with: selectedNodes) if plan.needsConfirmation { pendingPlan = plan - } else { + } else if plan.hasWork { await viewModel.castBulk(plan: plan) + } else { + // Nothing survived planning — every selected contest + // was dropped (e.g. it gained a contender since it + // was picked, so "Sole Requester" no longer applies). + // Say so instead of casting an empty batch, which + // would surface as "select a masternode" and send the + // user to fix a selection that is fine. + viewModel.reportNothingToCast() } } } label: { diff --git a/DashWallet/Sources/UI/DashPay/Voting/CastVoteSheet.swift b/DashWallet/Sources/UI/DashPay/Voting/CastVoteSheet.swift index 34258af5d..9340c2e7c 100644 --- a/DashWallet/Sources/UI/DashPay/Voting/CastVoteSheet.swift +++ b/DashWallet/Sources/UI/DashPay/Voting/CastVoteSheet.swift @@ -111,8 +111,7 @@ struct CastVoteSheet: View { "%d of your nodes already voted here and are not listed.", comment: "Voting"), alreadyVoted)) - } else if viewModel.selectedNodeIDs.count < viewModel.votableNodes.count, - viewModel.votableNodes.count > 1 { + } else if selectedNodeIDs.count < candidateNodes.count, candidateNodes.count > 1 { Text(NSLocalizedString( "Selecting fewer nodes reveals less about which masternodes you run.", comment: "Voting")) diff --git a/DashWallet/Sources/UI/DashPay/Voting/UsernameVotingScreen.swift b/DashWallet/Sources/UI/DashPay/Voting/UsernameVotingScreen.swift index 4d1e4a472..29c15a20c 100644 --- a/DashWallet/Sources/UI/DashPay/Voting/UsernameVotingScreen.swift +++ b/DashWallet/Sources/UI/DashPay/Voting/UsernameVotingScreen.swift @@ -41,6 +41,7 @@ struct UsernameVotingScreen: View { VoterCapacityHeader( nodes: viewModel.votableNodes, totalWeight: viewModel.totalVoteWeight, + effectiveSelection: viewModel.effectiveSelectedNodeIDs, selectedNodeIDs: $viewModel.selectedNodeIDs, onEditSelection: { showingNodePicker = true }) @@ -188,6 +189,11 @@ private struct VoterCapacityHeader: View { let nodes: [VoterNode] let totalWeight: UInt32 /// proTxHashes the next vote will use. Tapping the row edits it. + /// The selection a vote will ACTUALLY use — the effective set, not the raw + /// persisted one. They differ when the stored set is empty or stale, and + /// describing the raw set would show "Select nodes" while a tap votes with + /// the fallback node. + let effectiveSelection: Set @Binding var selectedNodeIDs: Set /// Opens the node picker. let onEditSelection: () -> Void @@ -257,7 +263,7 @@ private struct VoterCapacityHeader: View { /// Names the nodes a vote will use, so the privacy consequence of the /// current selection is legible without opening the picker. private var selectionSummary: String { - let chosen = nodes.filter { selectedNodeIDs.contains($0.proTxHash) } + let chosen = nodes.filter { effectiveSelection.contains($0.proTxHash) } guard !chosen.isEmpty else { return NSLocalizedString("Select nodes", comment: "Voting") } diff --git a/DashWallet/Sources/UI/DashPay/Voting/VotingViewModel.swift b/DashWallet/Sources/UI/DashPay/Voting/VotingViewModel.swift index 5c3f4ab31..a1dada911 100644 --- a/DashWallet/Sources/UI/DashPay/Voting/VotingViewModel.swift +++ b/DashWallet/Sources/UI/DashPay/Voting/VotingViewModel.swift @@ -90,8 +90,11 @@ final class VotingViewModel: ObservableObject { /// local history. Drives the "2 of 5 votes cast" line and excludes nodes /// that already voted from the next cast. @Published private(set) var castCountsByContest: [String: Int] = [:] - /// Which of our nodes voted on the contest currently being viewed. - @Published private(set) var votedProTxHashesForOpenContest: Set = [] + /// Which of our nodes voted, per contest. Keyed by contest so a screen can + /// never be answered with another contest's history — and so an ABSENT + /// entry is distinguishable from "none voted here", which is what makes + /// gating on load possible. + @Published private(set) var votedProTxHashesByContest: [String: Set] = [:] /// proTxHashes of the nodes the next vote will use, shared by the single /// contest screen and the bulk sheet and persisted so the next contest @@ -201,12 +204,18 @@ final class VotingViewModel: ObservableObject { // Settle the remembered selection against the nodes that actually // exist now, so the picker and the "voting with" row agree with what a - // tap would really do. Writing it back also migrates a wallet that - // never had a selection (or whose nodes changed) onto a concrete one - // instead of leaving the fallback implicit. - let settled = effectiveSelectedNodeIDs - if settled != selectedNodeIDs { - selectedNodeIDs = settled + // tap would really do. + // + // Only when the node list actually resolved. `votableNodes()` returns + // empty while the SDK is starting or the masternode phase has not + // synced, and settling against that would compute an empty selection + // and PERSIST it — destroying a multi-node choice the user made, with + // the next vote silently falling back to one node. + if !votableNodes.isEmpty { + let settled = effectiveSelectedNodeIDs + if settled != selectedNodeIDs { + selectedNodeIDs = settled + } } castCountsByContest = await history.voteCountsByContest( @@ -299,8 +308,20 @@ final class VotingViewModel: ObservableObject { } /// Nodes that have not yet voted on this contest, in registration order. + /// + /// Empty until this contest's history has loaded. The screen evaluates this + /// before its `.task` completes, and treating "not loaded" as "nobody + /// voted" would offer nodes that already voted — a duplicate Platform + /// rejects, spending one of the masternode's per-contest votes. func nodesYetToVote(on normalizedLabel: String) -> [VoterNode] { - votableNodes.filter { !votedProTxHashesForOpenContest.contains($0.proTxHash) } + guard let voted = votedProTxHashesByContest[normalizedLabel] else { return [] } + return votableNodes.filter { !voted.contains($0.proTxHash) } + } + + /// Whether this contest's vote history is known yet. Callers disable the + /// vote control until it is, rather than acting on an unknown. + func hasLoadedVoteHistory(for normalizedLabel: String) -> Bool { + votedProTxHashesByContest[normalizedLabel] != nil } /// Load which of our nodes already voted on one contest. Called when its @@ -309,7 +330,7 @@ final class VotingViewModel: ObservableObject { let records = await history.votes( forContest: normalizedLabel, network: MasternodeVoteCaster.networkKey) - votedProTxHashesForOpenContest = Set(records.map(\.proTxHash)) + votedProTxHashesByContest[normalizedLabel] = Set(records.map(\.proTxHash)) castCountsByContest[normalizedLabel] = records.count } @@ -514,6 +535,17 @@ final class VotingViewModel: ObservableObject { replacedChoice: replacedChoices.count == 1 ? replacedChoices.first : nil) } + /// Surface that a planned run had nothing left to do. + /// + /// Distinct from a failure: the selection was valid, but planning dropped + /// every contest in it. Reported here so the sheet does not call the caster + /// with an empty batch and get "select at least one masternode" back. + func reportNothingToCast() { + castError = NSLocalizedString( + "None of the selected usernames can take this vote any more — reopen them to see their current requests.", + comment: "Voting") + } + /// Cast a plan produced by ``planBulk(choice:with:)``. func castBulk(plan: BulkPlan) async { isCasting = true