Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions DashWallet.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -3642,6 +3644,7 @@
FF2C19E8480582798A0155E4 /* CastVoteSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CastVoteSheet.swift; sourceTree = "<group>"; };
ED4CB35186918CCBD2FFC49F /* UsernameRequestStatusScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UsernameRequestStatusScreen.swift; sourceTree = "<group>"; };
C3E5E523355F3EEC5CDEB00A /* BulkVoteSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BulkVoteSheet.swift; sourceTree = "<group>"; };
A1B2C3D40001VOTINGNODESEL /* VotingNodeSelectionSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VotingNodeSelectionSheet.swift; sourceTree = "<group>"; };
0AFB72236D79932EAE7A38E2 /* VoteHistoryDAO.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = VoteHistoryDAO.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

Expand Down Expand Up @@ -6722,6 +6725,7 @@
isa = PBXGroup;
children = (
C3E5E523355F3EEC5CDEB00A /* BulkVoteSheet.swift */,
A1B2C3D40001VOTINGNODESEL /* VotingNodeSelectionSheet.swift */,
33DEAFAEEDCA07D0A331911B /* UsernameVotingScreen.swift */,
6BA141C505AF0AE495DCAA37 /* ContestDetailScreen.swift */,
FF2C19E8480582798A0155E4 /* CastVoteSheet.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,6 @@ final class MasternodeVoteCaster {
case authenticationCancelled
case noNodesSelected
case contestClosed(String)
case choiceNotBulkable
case sdkUnavailable

var errorDescription: String? {
Expand All @@ -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.",
Expand Down Expand Up @@ -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 }

Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand Down
36 changes: 22 additions & 14 deletions DashWallet/Sources/Models/Voting/VotingPrefs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import Foundation

private let kVotingEnabled = "votingEnabledKey"
private let kVoteWithAllNodes = "voteWithAllNodesKey"
private let kVotingNodeSelection = "votingNodeSelectionKey"

// MARK: - VotingPrefs

Expand All @@ -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,
])
}

Expand All @@ -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<Data> {
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)
}
}
}
Loading
Loading