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..e88d3b9ac 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,20 +262,21 @@ 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 + // 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 let sdk = SwiftDashSDKHost.shared.sdk else { throw CastError.sdkUnavailable } @@ -295,9 +291,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/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift index ebe6fb4f3..59404559a 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 :: a registered voting address is not in this wallet's pool — not votable: \(address, privacy: .private)") + return nil + } + Self.logger.info( + "🗳️ 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 } @@ -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: .private)") + + 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 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..6c3e0d4ed 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,130 @@ 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( + plan.hasWork + ? NSLocalizedString("Cancel", comment: "") + : NSLocalizedString("OK", comment: ""), + role: .cancel + ) { + pendingPlan = nil + } + // 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) } + } + } + } 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 +272,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 +316,26 @@ 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 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: { HStack { Spacer() diff --git a/DashWallet/Sources/UI/DashPay/Voting/CastVoteSheet.swift b/DashWallet/Sources/UI/DashPay/Voting/CastVoteSheet.swift index f64c0fa69..9340c2e7c 100644 --- a/DashWallet/Sources/UI/DashPay/Voting/CastVoteSheet.swift +++ b/DashWallet/Sources/UI/DashPay/Voting/CastVoteSheet.swift @@ -111,7 +111,7 @@ 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 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 95099e0be..29c15a20c 100644 --- a/DashWallet/Sources/UI/DashPay/Voting/UsernameVotingScreen.swift +++ b/DashWallet/Sources/UI/DashPay/Voting/UsernameVotingScreen.swift @@ -34,13 +34,16 @@ 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) + effectiveSelection: viewModel.effectiveSelectedNodeIDs, + selectedNodeIDs: $viewModel.selectedNodeIDs, + onEditSelection: { showingNodePicker = true }) if viewModel.nodeListMayBeIncomplete { VotingBanner( @@ -101,7 +104,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 +137,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 +188,15 @@ 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. + /// 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 var body: some View { HStack(spacing: 10) { @@ -196,27 +230,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 +260,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 { effectiveSelection.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..a1dada911 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,32 @@ 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 = [] + /// 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 + /// 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 } + } - /// `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 } + /// 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 +202,51 @@ 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. + // + // 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( 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. /// @@ -254,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 @@ -264,20 +330,32 @@ 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 } - /// 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 +388,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 +404,7 @@ final class VotingViewModel: ObservableObject { } func selectAllVisible() { - selectedLabels = Set(visibleContests.map(\.normalizedLabel)) + selectedLabels = Set(visibleContests.filter(isBulkEligible).map(\.normalizedLabel)) } func endSelecting() { @@ -336,21 +418,145 @@ 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) + } + + /// 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 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)