fix(dashpay): make the voting screens escapable and vote one node at a time - #934
Conversation
…a time Three problems found on device. **You could not leave the contest screen.** `GovernanceMenuScreen` hides its own navigation bar, and `showVoting()` pushed a bare `UIHostingController`. `BaseNavigationController.willShow` only restores the bar for a `NavigationBarDisplayable`, so a non-conforming controller inherits whatever the previous screen left — hidden. No bar meant no back button and no way out, and it silently dropped `.navigationTitle` and the whole `.toolbar` too (sort and "Vote on several" never rendered), because neither applies outside a navigation container. Fixed the way `showMasternodes` directly above already does it: its own `NavigationStack` plus a back button wired explicitly to pop the UIKit stack. **Voting cast every node's vote at once, which is a privacy loss.** Broadcasting several `MasternodeVote` transitions for one poll in quick succession lets an observer group those masternodes as a single operator — and the user cannot undo that afterwards. Voting is now **one node per tap by default**: each tap casts with the next node that has not voted on this contest, so how many nodes you link together is a choice rather than a side effect. The mode is configurable where the node count is shown, as the user asked — "One node at a time" (default) or "All nodes at once" — with a line explaining the trade-off, persisted in `VotingPrefs` so it survives relaunch. The control only appears with more than one node, where the two modes actually differ. **How many votes you have cast is now visible.** The detail screen shows "2 of 5 nodes voted", the vote button reads "Vote ×3" when a tap will use more than one node, nodes that already voted are dropped from the picker with a note saying so, and once every node has voted the buttons give way to an explanation of how to change a vote instead. That needs to survive a relaunch, so votes are recorded in a new `masternode_vote_history` table (`VoteHistoryDAO`), keyed per network so a testnet and a mainnet contest sharing a label never merge. Only accepted votes are recorded, so the count reflects what Platform took, not what was attempted. Platform cannot answer this cheaply — `getContestedResourceVotersForIdentity` is per contender, keyed by voter identity, and cannot see abstain or lock voters at all. Cleared on wallet wipe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
📝 WalkthroughWalkthroughThe wallet now persists successful masternode votes by contest and network. Voting screens exclude nodes that already voted, support one-node or all-node modes, show vote progress, and provide privacy guidance. ChangesVoting history and node selection
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Voter
participant VotingScreen
participant VotingViewModel
participant VoteHistoryDAOImpl
participant MasternodeVoteCaster
Voter->>VotingScreen: Open contest
VotingScreen->>VotingViewModel: Load vote history
VotingViewModel->>VoteHistoryDAOImpl: Query contest votes
VoteHistoryDAOImpl-->>VotingViewModel: Return voted nodes
VotingScreen->>VotingViewModel: Submit selected vote
VotingViewModel->>MasternodeVoteCaster: Cast vote
MasternodeVoteCaster->>VoteHistoryDAOImpl: Store successful vote
VotingViewModel->>VoteHistoryDAOImpl: Reload contest history
VoteHistoryDAOImpl-->>VotingScreen: Updated vote progress
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
🧹 Nitpick comments (1)
DashWallet/Sources/UI/Menu/Governance/GovernanceMenuScreen.swift (1)
134-142: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse a wrapper that keeps the navigation bar visible.
UIHostingControllerglobally conforms toNavigationBarDisplayablewithisNavigationBarHidden == true.BaseNavigationControllertherefore hides the bar, which can hide theNavigationStacktitle, search field, and toolbar. Push a thinUIViewControllerwrapper that embeds the hosting controller and reportsisNavigationBarHidden == false.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@DashWallet/Sources/UI/Menu/Governance/GovernanceMenuScreen.swift` around lines 134 - 142, Wrap the UIHostingController containing UsernameVotingScreen in a thin UIViewController that embeds it as a child and conforms to NavigationBarDisplayable with isNavigationBarHidden set to false. Push this wrapper instead of hosting directly in the vc.pushViewController flow, preserving the NavigationStack’s title, search field, and toolbar.Source: Learnings
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@DashWallet/en.lproj/Localizable.strings`:
- Around line 4803-4809: Add the four missing voting localization keys—“%d of %d
nodes voted”, “Vote ×%d”, “%d of your nodes already voted here and are not
listed.”, and “Cast %u votes”—to the Localizable.strings entries using
BartyCrouch or Xcode-aware localization tooling, preserving the existing
key/value format.
In
`@DashWallet/Sources/Infrastructure/Database/Migrations.bundle/20260808030000_add_masternode_vote_history.sql`:
- Line 12: Rename the migration file containing the masternode_vote_history
CREATE TABLE statement to use its actual creation timestamp on or before August
7, 2026, rather than the future 20260808030000 timestamp. Preserve the migration
name suffix and ensure the updated timestamp maintains correct ordering with
other migrations.
In
`@DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoteCaster.swift`:
- Around line 233-241: Update MasternodeVoteCaster’s successful Platform-vote
path to propagate the history record result or throw on failure, while retaining
retryable local-history handling after the vote succeeds; update App.swift
cleanup at lines 106-109 to propagate or throw the delete result so failed
history deletion cannot complete silently.
In `@DashWallet/Sources/Models/Voting/VoteHistoryDAO.swift`:
- Around line 82-86: Update VoteHistoryDAO.record to propagate database write
failures instead of logging and returning normally. Make
MasternodeVoteCaster.castAuthenticated handle the propagated failure by retrying
the durable vote-history write or reporting a recoverable persistence error
after Platform accepts the vote.
- Around line 139-141: Update VoteHistoryDAO.deleteAll() to be actor-isolated
and async instead of nonisolated and synchronous. In the wallet-wipe flow, wait
for active vote-recording tasks to finish or cancel them before awaiting
deleteAll(), ensuring no asynchronous record operation can recreate history
after deletion.
In `@DashWallet/Sources/UI/DashPay/Voting/VotingViewModel.swift`:
- Around line 185-187: Update castBulk to reload castCountsByContest via
history.voteCountsByContest(network:) after caster.castBulk succeeds, matching
the existing refresh() behavior so node vote totals reflect the bulk cast
immediately.
- Around line 185-187: Separate node progress from vote-attempt history in the
voting flow around voteCountsByContest: count distinct proTxHash values for
progress, while tracking attempts per proTxHash independently. Do not
permanently exclude nodes merely because they have prior history; after every
node has voted once, keep eligible nodes available for replacement votes until
the Platform limit is reached.
- Around line 261-269: The loadVotedNodes flow must distinguish loading and
failed history reads from a successfully empty history. Add label-scoped loading
and failure state around VoteHistoryDAO.votes, set loading before querying, mark
success only after a successful read, and preserve failure separately rather
than treating [] as valid history; update the displayed contest’s node-selection
and CastVoteSheet casting guards to remain disabled until that contest’s history
has loaded successfully.
---
Nitpick comments:
In `@DashWallet/Sources/UI/Menu/Governance/GovernanceMenuScreen.swift`:
- Around line 134-142: Wrap the UIHostingController containing
UsernameVotingScreen in a thin UIViewController that embeds it as a child and
conforms to NavigationBarDisplayable with isNavigationBarHidden set to false.
Push this wrapper instead of hosting directly in the vc.pushViewController flow,
preserving the NavigationStack’s title, search field, and toolbar.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 6c843b54-a38c-4581-9e84-cd242c582d12
📒 Files selected for processing (13)
DashWallet.xcodeproj/project.pbxprojDashWallet/Sources/Application/App.swiftDashWallet/Sources/Infrastructure/Database/Migrations.bundle/20260808030000_add_masternode_vote_history.sqlDashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoteCaster.swiftDashWallet/Sources/Models/Voting/VoteHistoryDAO.swiftDashWallet/Sources/Models/Voting/VotingPrefs.swiftDashWallet/Sources/UI/DashPay/Voting/CastVoteSheet.swiftDashWallet/Sources/UI/DashPay/Voting/ContestDetailScreen.swiftDashWallet/Sources/UI/DashPay/Voting/UsernameVotingScreen.swiftDashWallet/Sources/UI/DashPay/Voting/VotingViewModel.swiftDashWallet/Sources/UI/Menu/Governance/GovernanceMenuScreen.swiftDashWallet/en.lproj/Localizable.stringsDashWallet/en.lproj/Localizable.stringsdict
| "Your votes" = "Your votes"; | ||
| "All of your masternodes have voted on this username. To change a vote, vote again from the contender you now prefer." = "All of your masternodes have voted on this username. To change a vote, vote again from the contender you now prefer."; | ||
| "One node at a time" = "One node at a time"; | ||
| "All nodes at once" = "All nodes at once"; | ||
| "Every node votes together. Faster, but it publicly links your masternodes to each other." = "Every node votes together. Faster, but it publicly links your masternodes to each other."; | ||
| "Each tap votes with one more node, so you choose how many to link together." = "Each tap votes with one more node, so you choose how many to link together."; | ||
| "Selecting fewer nodes reveals less about which masternodes you run." = "Selecting fewer nodes reveals less about which masternodes you run."; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Add the remaining voting localization keys.
The new UI also uses these keys, but they are absent from this file:
"%d of %d nodes voted""Vote ×%d""%d of your nodes already voted here and are not listed.""Cast %u votes"
Add them with BartyCrouch or Xcode-aware localization tooling. Otherwise, localized builds show English fallback text for these controls.
As per coding guidelines, use BartyCrouch or Xcode-aware tooling for Localizable.strings updates.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@DashWallet/en.lproj/Localizable.strings` around lines 4803 - 4809, Add the
four missing voting localization keys—“%d of %d nodes voted”, “Vote ×%d”, “%d of
your nodes already voted here and are not listed.”, and “Cast %u votes”—to the
Localizable.strings entries using BartyCrouch or Xcode-aware localization
tooling, preserving the existing key/value format.
Source: Coding guidelines
| -- | ||
| -- `proTxHash` is stored in raw wire byte order, matching PlatformMasternode. | ||
| -- `castAt` is milliseconds since epoch. | ||
| CREATE TABLE masternode_vote_history ( |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
Use a non-future migration timestamp.
The filename 20260808030000_add_masternode_vote_history.sql encodes August 8, 2026. The review date is August 7, 2026. A migration created later on August 7 can sort before this migration in a timestamp-ordered runner. Rename this file with its actual creation timestamp before merge.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@DashWallet/Sources/Infrastructure/Database/Migrations.bundle/20260808030000_add_masternode_vote_history.sql`
at line 12, Rename the migration file containing the masternode_vote_history
CREATE TABLE statement to use its actual creation timestamp on or before August
7, 2026, rather than the future 20260808030000 timestamp. Preserve the migration
name suffix and ensure the updated timestamp maintains correct ordering with
other migrations.
Source: Coding guidelines
| // Recorded only on success, so the count the UI shows is | ||
| // votes Platform accepted — not votes attempted. | ||
| await Self.history.record( | ||
| CastVoteRecord( | ||
| proTxHash: node.proTxHash, | ||
| normalizedLabel: normalizedLabel, | ||
| choice: choice, | ||
| castAt: Date()), | ||
| network: Self.networkKey) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Expose vote-history database failures to both callers.
The DAO swallows SQLite failures, so callers cannot guarantee that accepted votes are persisted or that wallet cleanup removed old history.
DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoteCaster.swift#L233-L241: return or throw the record result and provide retryable local-history handling after a successful Platform vote.DashWallet/Sources/Application/App.swift#L106-L109: return or throw the delete result and prevent cleanup from completing silently when deletion fails.
📍 Affects 2 files
DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoteCaster.swift#L233-L241(this comment)DashWallet/Sources/Application/App.swift#L106-L109
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoteCaster.swift`
around lines 233 - 241, Update MasternodeVoteCaster’s successful Platform-vote
path to propagate the history record result or throw on failure, while retaining
retryable local-history handling after the vote succeeds; update App.swift
cleanup at lines 106-109 to propagate or throw the delete result so failed
history deletion cannot complete silently.
| do { | ||
| try db.run(query, bindings) | ||
| } catch { | ||
| DWLogger.log("VoteHistoryDAO: record failed: \(error)") | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Do not hide vote-history write failures.
record logs the database error and returns normally. MasternodeVoteCaster.castAuthenticated records the vote after Platform accepts it. If this write fails, the next refresh or app launch reoffers a node that already voted.
Make record return or throw the persistence failure. The caller must retry the durable write or report a recoverable persistence failure.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@DashWallet/Sources/Models/Voting/VoteHistoryDAO.swift` around lines 82 - 86,
Update VoteHistoryDAO.record to propagate database write failures instead of
logging and returning normally. Make MasternodeVoteCaster.castAuthenticated
handle the propagated failure by retrying the durable vote-history write or
reporting a recoverable persistence error after Platform accepts the vote.
| nonisolated func deleteAll() { | ||
| do { | ||
| try db.run("DELETE FROM masternode_vote_history") |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate wallet-wipe deletion callers and DAO access sites.
ast-grep outline DashWallet/Sources/Models/Voting/VoteHistoryDAO.swift --items all --view expanded
rg -n -C 6 --glob '*.swift' 'VoteHistoryDAOImpl\.shared|VoteHistoryDAO|\.deleteAll\s*\(' DashWallet/SourcesRepository: dashpay/dashwallet-ios
Length of output: 12952
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- App.cleanUp context and callers ---'
sed -n '70,130p' DashWallet/Sources/Application/App.swift
rg -n -C 8 --glob '*.swift' 'cleanUp\s*\(|\.cleanUp\s*\(' DashWallet/Sources
printf '%s\n' '--- Vote caster write path ---'
sed -n '100,260p' DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoteCaster.swift
rg -n -C 8 --glob '*.swift' 'history\.record|record\s*\(.*CastVoteRecord|castVote|cast\(' DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting DashWallet/Sources/UI/DashPay/Voting
printf '%s\n' '--- DAO and database connection definitions ---'
sed -n '1,155p' DashWallet/Sources/Models/Voting/VoteHistoryDAO.swift
rg -n -C 8 --glob '*.swift' 'class DatabaseConnection|struct DatabaseConnection|static let shared.*DatabaseConnection|Connection\(' DashWallet/SourcesRepository: dashpay/dashwallet-ios
Length of output: 43142
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- Wallet wiper structure and call sites ---'
ast-grep outline DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift --items all --view expanded
sed -n '1,210p' DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift
rg -n -C 10 --glob '*.swift' 'SwiftDashSDKWalletWiper|wipeWallet|deleteWallet|removeWallet|wallet.*wipe|wipe.*wallet' DashWallet/Sources
printf '%s\n' '--- Vote-cast task and lifecycle coordination ---'
rg -n -C 8 --glob '*.swift' 'MasternodeVoteCaster|VotingViewModel|Task\s*\{|Task\.|cancel\s*\(|isCasting|cleanUp\s*\(' DashWallet/Sources/Infrastructure/SwiftDashSDK DashWallet/Sources/UI/DashPay/Voting DashWallet/Sources/ApplicationRepository: dashpay/dashwallet-ios
Length of output: 50379
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '%s\n' '--- SwiftDashSDKWalletWiper.swift ---'
wc -l DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift
sed -n '1,190p' DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift
printf '%s\n' '--- Exact App.cleanUp callers ---'
rg -n -C 12 --glob '*.swift' 'App(\.shared)?\.cleanUp|waitForPendingWipe|deleteWalletFromSDK' DashWallet/Sources/Infrastructure/SwiftDashSDK DashWallet/Sources/Application DashWallet/Sources/UI/Setup DashWallet/Sources/UI/Menu
printf '%s\n' '--- Vote task ownership ---'
rg -n -C 5 --glob '*.swift' 'Task\s*\{|Task\.|isCasting|MasternodeVoteCaster' DashWallet/Sources/UI/DashPay/Voting DashWallet/Sources/Infrastructure/SwiftDashSDK/VotingRepository: dashpay/dashwallet-ios
Length of output: 50378
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
dao = Path("DashWallet/Sources/Models/Voting/VoteHistoryDAO.swift").read_text()
caster = Path("DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoteCaster.swift").read_text()
app = Path("DashWallet/Sources/Application/App.swift").read_text()
wiper = Path("DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKWalletWiper.swift").read_text()
sheet = Path("DashWallet/Sources/UI/DashPay/Voting/CastVoteSheet.swift").read_text()
bulk = Path("DashWallet/Sources/UI/DashPay/Voting/BulkVoteSheet.swift").read_text()
checks = {
"deleteAll is nonisolated and synchronous": "nonisolated func deleteAll()" in dao,
"record is async": "func record(_ record: CastVoteRecord, network: String) async" in dao,
"successful SDK cast precedes record": (
"try await sdk.castContestedResourceVote(" in caster
and "await Self.history.record(" in caster
and caster.index("try await sdk.castContestedResourceVote(")
< caster.index("await Self.history.record(")
),
"cleanup calls synchronous deleteAll": "VoteHistoryDAOImpl.shared.deleteAll()" in app,
"cleanup is inside main sync": (
"DispatchQueue.main.sync {" in wiper
and wiper.index("DispatchQueue.main.sync {")
< wiper.index("App.shared.cleanUp()")
),
"single-vote UI starts unretained Task": "Task {" in sheet and "await viewModel.cast(" in sheet,
"bulk-vote UI starts unretained Task": "Task { await viewModel.castBulk" in bulk,
}
for name, passed in checks.items():
print(f"{'PASS' if passed else 'FAIL'}: {name}")
if not all(checks.values()):
raise SystemExit(1)
PYRepository: dashpay/dashwallet-ios
Length of output: 433
Coordinate wallet-wipe cleanup with vote recording.
A successful vote records history asynchronously after the SDK call returns. Wallet wipe can delete history before record runs.
Make deleteAll() actor-isolated and asynchronous. Await it after active vote tasks finish or cancel.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@DashWallet/Sources/Models/Voting/VoteHistoryDAO.swift` around lines 139 -
141, Update VoteHistoryDAO.deleteAll() to be actor-isolated and async instead of
nonisolated and synchronous. In the wallet-wipe flow, wait for active
vote-recording tasks to finish or cancel them before awaiting deleteAll(),
ensuring no asynchronous record operation can recreate history after deletion.
| castCountsByContest = await history.voteCountsByContest( | ||
| network: MasternodeVoteCaster.networkKey) | ||
|
|
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Refresh progress after a bulk cast.
castCountsByContest updates only in refresh(). castBulk refreshes contest tallies but does not reload history counts. The list can therefore show stale “n of m nodes voted” values until the user performs a full refresh.
Reload voteCountsByContest(network:) after caster.castBulk succeeds.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@DashWallet/Sources/UI/DashPay/Voting/VotingViewModel.swift` around lines 185
- 187, Update castBulk to reload castCountsByContest via
history.voteCountsByContest(network:) after caster.castBulk succeeds, matching
the existing refresh() behavior so node vote totals reflect the bulk cast
immediately.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Separate node progress from vote-attempt history.
Line 268 counts vote records, not unique nodes. The DAO count query also uses COUNT(*). A replacement vote can make progress display as more than the wallet's node count.
Lines 257-259 then permanently exclude every node with prior history. When all nodes have one vote, this returns no nodes and disables the flow that the UI says can change a vote.
Count distinct proTxHash values for node progress. Track attempts per proTxHash separately. When all nodes have voted once, offer eligible nodes for replacement votes until they reach the Platform limit.
Also applies to: 256-280
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@DashWallet/Sources/UI/DashPay/Voting/VotingViewModel.swift` around lines 185
- 187, Separate node progress from vote-attempt history in the voting flow
around voteCountsByContest: count distinct proTxHash values for progress, while
tracking attempts per proTxHash independently. Do not permanently exclude nodes
merely because they have prior history; after every node has voted once, keep
eligible nodes available for replacement votes until the Platform limit is
reached.
| /// Load which of our nodes already voted on one contest. Called when its | ||
| /// detail screen opens, so the vote button knows what is left. | ||
| func loadVotedNodes(for normalizedLabel: String) async { | ||
| let records = await history.votes( | ||
| forContest: normalizedLabel, | ||
| network: MasternodeVoteCaster.networkKey) | ||
| votedProTxHashesForOpenContest = Set(records.map(\.proTxHash)) | ||
| castCountsByContest[normalizedLabel] = records.count | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Do not treat unknown history as an empty history.
votedProTxHashesForOpenContest starts empty while this asynchronous query runs. The detail screen can therefore open CastVoteSheet with all nodes selectable before this method completes.
VoteHistoryDAO.votes(forContest:network:) also returns [] when its database query fails. This method then enables the same duplicate-vote path after a history-read failure.
Add label-scoped loading and failure state. Disable node selection and casting until history for the displayed contest has loaded successfully.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@DashWallet/Sources/UI/DashPay/Voting/VotingViewModel.swift` around lines 261
- 269, The loadVotedNodes flow must distinguish loading and failed history reads
from a successfully empty history. Add label-scoped loading and failure state
around VoteHistoryDAO.votes, set loading before querying, mark success only
after a successful read, and preserve failure separately rather than treating []
as valid history; update the displayed contest’s node-selection and
CastVoteSheet casting guards to remain disabled until that contest’s history has
loaded successfully.
Three problems found on device.
1. You could not leave the contest screen
GovernanceMenuScreenhides its own navigation bar, andshowVoting()pushed a bareUIHostingController.BaseNavigationController.willShowonly restores the bar for aNavigationBarDisplayable, so a non-conforming controller inherits whatever the previous screen left — hidden.No bar meant no back button and no way out. It also silently dropped
.navigationTitleand the entire.toolbar— sort and "Vote on several" never rendered at all — because neither applies outside a navigation container.Fixed the way
showMasternodesdirectly above already does it: its ownNavigationStackplus a back button wired explicitly to pop the UIKit stack. (That function even carries a comment explaining why it's needed; voting should have followed it.)2. Voting cast every node's vote at once — a privacy loss
Broadcasting several
MasternodeVotetransitions for the same poll in quick succession lets an observer group those masternodes as one operator, and the user cannot undo that after the fact.Voting is now one node per tap by default. Each tap casts with the next node that hasn't voted on this contest, so how many of your nodes get linked together is a decision rather than a side effect.
The mode is configurable where the node count is shown:
Persisted in
VotingPrefs, so it isn't re-chosen every launch. The control only appears with more than one node, where the modes actually differ.3. Votes cast are now visible
Why this needed persistence
It has to survive a relaunch, or the count silently resets to zero and lies. New
masternode_vote_historytable +VoteHistoryDAO, keyed per network so a testnet and a mainnet contest sharing a label never merge. Only accepted votes are recorded, so the count reflects what Platform took, not what was attempted. Cleared on wallet wipe.Platform can't answer this cheaply:
getContestedResourceVotersForIdentityis per contender, keyed by voter identity, and can't see abstain or lock voters at all — so answering "which of my nodes voted here" from the network would cost a query per contender and still be incomplete.Testing
Clean⚠️ Runtime verification is still outstanding — in particular the one-node-per-tap flow and the persisted count across a relaunch want a testnet pass with a real multi-node wallet.
dashpaybuild.Note
developcurrently needs the unmerged dashpay/platform#4332 to compile at all (invitationProspectiveIdentityId); built here against a local merge ofv4.2-dev+ that PR.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes