Skip to content

feat(voting): derive keys through the platform wallet; rework node selection - #936

Merged
QuantumExplorer merged 3 commits into
developfrom
feat/voting-keys-and-selection-ux
Aug 8, 2026
Merged

feat(voting): derive keys through the platform wallet; rework node selection#936
QuantumExplorer merged 3 commits into
developfrom
feat/voting-keys-and-selection-ux

Conversation

@QuantumExplorer

@QuantumExplorer QuantumExplorer commented Aug 8, 2026

Copy link
Copy Markdown
Member

Two commits: a correctness fix to how masternode voting keys are derived, and a rework of how votes are chosen and cast.


1. Derive the VOTING key through the platform wallet

#937 landed the same fix for the Masternode Keys screen independently, so this commit is now only the part it did not cover: MasternodeVoterRegistry, the signing path.

The shared cause: Account.derivePrivateKeyWIF asked callers for the account root path while the FFI applied the account's own path on top, so every owner/voting key came from m/9'/5'/3'/1'/9'/5'/3'/1'/index. The keys were well-formed and deterministic, so nothing failed locally — masternode votes were simply rejected by Platform as having no voter identity, because the voter identity is derived from the signing key's own hash160.

The voting path now resolves its key through the same Rust-side resolver, which owns the DIP-3 path and cross-checks the derived key against the account xpub before returning it. A key that does not match the node's registered voting address can no longer reach the signer.

Rebased onto #937, taking its version of DerivationPathKeysModel wholesale. The diagnostics I had added there — comparing the live pool against the throwaway derivation wallet — are dropped: they existed to hunt this bug, and the Rust cross-check now verifies the same property at the source.

2. Node selection, Sole Requester, and the stall

Remembered nodes. The "One node at a time / All nodes at once" toggle is replaced by the nodes themselves: a Voting with row naming them ("Evonode 2", "Evonode 2, Evonode 3", "All 6 nodes") that opens a picker. Persisted and shared between 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, and that stays opt-in 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 doesn't silently differ from the browsing list.

Confirm before re-voting. A bulk run plans first. 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 proceeds, 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 leaving a contest. .task re-fires when the list reappears, and refresh() re-queries every active contest — ~12s, which read as the screen hanging. Now refreshIfNeeded(), keyed on a fetch having succeeded rather than been attempted, so a failed first load still retries. Pull-to-refresh still forces a reload; casting already refreshes the contest it touched.


Dependencies

All merged into v4.2-dev. This PR needs an xcframework built from it — that is the only manual step.

#4338 providerKeyAtIndex for the secp256k1 families
#4333 the ProTxHash byte-order fix — without it votes are rejected for a different reason
#4339 removed the derivePrivateKeyWIF that caused the doubled path
#4340 / #4341 moved the voting-key facts into rs-sdk and off the FFI
#4332 unrelated, but develop did not compile without it

Also rebased onto dashwallet-ios#937, which fixed the Masternode Keys screen half of the derivation bug.

Testing

dashpay builds clean and runs on an iPhone 16 Plus simulator against a mainnet wallet with 6 evonodes.

Confirmed: votes now cast successfully — observed by the repo owner against real masternodes. That was the load-bearing unknown: the derivation fix was previously only proven arithmetically (the voter identity the app requests equals SHA256(proTxHash ‖ hash160(registered voting address)), computed independently), with no successful cast observed.

Still not verified:

  • None of the new voting UI has been exercised on screen. The picker, the greyed multi-request rows, the overlap prompt and its disabled-Continue case are all built and installed but unclicked.
  • The overlap check reads local history, so a vote cast from another device with the same masternode is invisible and will look like a fresh vote rather than a replacement.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Select specific masternodes for username and contest voting, with saved preferences and voting-weight guidance.
    • Added a masternode selection screen showing names and service addresses.
    • Added “Sole Requester” as a bulk voting option.
    • Bulk voting now plans votes, skips duplicates, and identifies contests that cannot be selected in bulk.
  • Improvements

    • Added confirmation details when bulk voting would replace existing votes.
    • Updated privacy notices to reflect partial masternode selection.
    • Improved voting refresh behavior and loading reliability.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@QuantumExplorer, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 33 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4d0ada0b-fbe7-4dba-b5f7-78b562e960ee

📥 Commits

Reviewing files that changed from the base of the PR and between f6d6464 and 54ddd73.

📒 Files selected for processing (6)
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoteCaster.swift
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift
  • DashWallet/Sources/UI/DashPay/Voting/BulkVoteSheet.swift
  • DashWallet/Sources/UI/DashPay/Voting/CastVoteSheet.swift
  • DashWallet/Sources/UI/DashPay/Voting/UsernameVotingScreen.swift
  • DashWallet/Sources/UI/DashPay/Voting/VotingViewModel.swift
📝 Walkthrough

Walkthrough

The PR replaces the all-nodes voting preference with persisted masternode selections. It adds node selection UI, contest-specific bulk vote planning, duplicate suppression, replacement confirmation, per-contest casting, and provider-derived signing-key diagnostics.

Changes

Voting architecture

Layer / File(s) Summary
Provider key resolution
DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift
Voter-node filtering now logs address and pool-index results. Signing keys now use provider-derived hex data instead of WIF parsing.
Selection state and vote planning
DashWallet/Sources/Models/Voting/VotingPrefs.swift, DashWallet/Sources/UI/DashPay/Voting/VotingViewModel.swift
Preferences persist selected masternode identifiers. The view model resolves live selections, refreshes contests conditionally, and applies selected nodes to single-contest votes.
Node selection interface
DashWallet/Sources/UI/DashPay/Voting/VotingNodeSelectionSheet.swift, DashWallet/Sources/UI/DashPay/Voting/UsernameVotingScreen.swift, DashWallet/Sources/UI/DashPay/Voting/CastVoteSheet.swift, DashWallet.xcodeproj/project.pbxproj
The screens provide node selection, enforce at least one selected node, update privacy guidance, and include the new sheet in both targets.
Bulk vote execution
DashWallet/Sources/UI/DashPay/Voting/BulkVoteSheet.swift, DashWallet/Sources/UI/DashPay/Voting/UsernameVotingScreen.swift, DashWallet/Sources/UI/DashPay/Voting/VotingViewModel.swift, DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoteCaster.swift
Bulk voting supports Sole Requester, filters ineligible contests, plans duplicate and replacement handling, confirms overlaps, and casts contest-specific work items.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BulkVoteSheet
  participant VotingViewModel
  participant MasternodeVoteCaster
  BulkVoteSheet->>VotingViewModel: request bulk vote plan
  VotingViewModel-->>BulkVoteSheet: return plan and replacement metadata
  BulkVoteSheet->>MasternodeVoteCaster: cast per-contest work items
  MasternodeVoteCaster-->>BulkVoteSheet: return casting results
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 73.68% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two main changes: platform-wallet key derivation and reworked voting-node selection.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/voting-keys-and-selection-ux

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (6)
DashWallet/Sources/UI/DashPay/Voting/VotingNodeSelectionSheet.swift (1)

34-34: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Put the attribute on its own line.

SwiftLint's attributes rule requires an attribute with arguments to sit on the line above the declaration. CastVoteSheet.swift lines 32-33 already use that layout.

♻️ Proposed change
-    `@Environment`(\.dismiss) private var dismiss
+    `@Environment`(\.dismiss)
+    private var dismiss
🤖 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/VotingNodeSelectionSheet.swift` at line
34, Move the `@Environment`(\.dismiss) attribute in VotingNodeSelectionSheet to
its own line above the dismiss property declaration, matching the layout used in
CastVoteSheet.swift and satisfying SwiftLint's attributes rule.

Source: Linters/SAST tools

DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift (1)

204-207: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Distinguish the two "no provider-voting deriver" messages.

Line 170 and line 205 log identical text for two different failures. One is MasternodeProviderKeyDeriver, the other is ProviderKeyDeriver. A reader of the log cannot tell which construction failed.

♻️ Proposed change
         guard let keyDeriver = ProviderKeyDeriver(key: .voting) else {
-            Self.logger.error("🗳️ VOTING :: no provider-voting deriver available")
+            Self.logger.error("🗳️ VOTING :: no platform-wallet provider-voting key deriver available")
             return nil
         }
🤖 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/MasternodeVoterRegistry.swift`
around lines 204 - 207, Update the error log in the ProviderKeyDeriver guard
within MasternodeVoterRegistry to identify that ProviderKeyDeriver construction
failed, distinguishing it from the separate MasternodeProviderKeyDeriver failure
log while preserving the existing return behavior.
DashWallet/Sources/UI/DashPay/Voting/VotingViewModel.swift (2)

444-446: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace the three-member tuple with a named type.

SwiftLint reports large_tuple at both lines. The tuple (label:choice:nodes:) also crosses the module boundary into MasternodeVoteCaster.castBulk. A small struct documents the contract and removes the warnings at both ends.

♻️ Proposed change
+    /// One contest's share of a bulk run.
+    struct BulkWorkItem {
+        let label: String
+        let choice: VoteChoice
+        let nodes: [VoterNode]
+    }
+
     struct BulkPlan {
         /// Per contest: the wire choice and the nodes that will actually cast.
-        let work: [(label: String, choice: VoteChoice, nodes: [VoterNode])]
+        let work: [BulkWorkItem]

Then update planBulk and MasternodeVoteCaster.castBulk to accept [BulkWorkItem].

Also applies to: 469-469

🤖 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 444
- 446, Replace the three-member work tuple in BulkPlan with a named BulkWorkItem
type containing label, choice, and nodes, then update planBulk and
MasternodeVoteCaster.castBulk to use [BulkWorkItem] while preserving the
existing data and behavior.

Source: Linters/SAST tools


474-488: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

planBulk runs one history query per selected contest.

history.votes(forContest:network:) is awaited inside the loop, so a selection of N contests produces N sequential database round trips before the confirmation sheet can render. VoteHistoryDAO already exposes a network-wide aggregate; a single query filtered by network, grouped in memory by label, would collapse this to one round trip.

The per-node deduplication at lines 482-488 also assumes several rows per (node, contest). VoteHistoryDAO.record upserts on (proTxHash, normalizedLabel, network), so only one row exists. Confirm the assumption before relying on it.

🤖 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 474
- 488, The planBulk flow currently performs one sequential history query per
selected contest; replace this with VoteHistoryDAO’s network-wide aggregate
query, then group the returned records by normalizedLabel in memory and use each
contest’s group. Remove the unnecessary per-node latestByNode deduplication
because record upserts guarantee one row per proTxHash, normalizedLabel, and
network, while preserving the existing resolved-choice filtering and vote
processing.
DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoteCaster.swift (1)

270-272: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Replace the three-member tuple with a named type.

SwiftLint reports large_tuple at Line 271. The same tuple shape is repeated in VotingViewModel.planBulk and in BulkPlan.work. A small struct (for example BulkWorkItem) documents the contract at the call sites and removes the warning.

♻️ Proposed named type
struct BulkWorkItem {
    let label: String
    let choice: VoteChoice
    let nodes: [VoterNode]
}
     func castBulk(
-        _ work: [(label: String, choice: VoteChoice, nodes: [VoterNode])]
+        _ work: [BulkWorkItem]
     ) async throws -> [VoteCastReport] {
🤖 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 270 - 272, Replace the repeated three-element work tuple with a
named BulkWorkItem type containing label, choice, and nodes. Update castBulk,
VotingViewModel.planBulk, BulkPlan.work, and all construction/access sites to
use BulkWorkItem while preserving existing behavior.

Source: Linters/SAST tools

DashWallet/Sources/UI/DashPay/Voting/BulkVoteSheet.swift (1)

216-230: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Share one choice-name mapping instead of three copies.

VotingViewModel already defines a private static name(for choice: VoteChoice) with the same strings. This file adds two more copies. Move a single mapping to VotingViewModel.BulkChoice and VoteChoice (for example, a displayName property on each type) and call it from both files. This prevents the labels from drifting apart later.

Also note that the VoteChoice overload maps every .towards(identityId:) to "Sole Requester". A stored history record can hold a .towards vote that was cast when the contest had several contenders. In that case the replacement message names the wrong choice.

🤖 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/BulkVoteSheet.swift` around lines 216 -
230, Centralize the choice labels by adding shared display-name mappings to both
VotingViewModel.BulkChoice and VoteChoice, then replace the duplicate name(for:)
helpers in BulkVoteSheet and VotingViewModel with those mappings. Ensure
VoteChoice.towards(identityId:) derives its label from the associated identity
or otherwise preserves the actual contender name instead of always returning
“Sole Requester” for historical multi-contender votes.
🤖 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/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoteCaster.swift`:
- Around line 273-276: The empty-batch handling must precede node validation: in
MasternodeVoteCaster.swift lines 273-276, update the guards so work.isEmpty
returns [] before work.contains checks nodes. In BulkVoteSheet.swift lines
311-322, record contests dropped by resolvedChoice in BulkPlan, include that
count in overlapMessage(for:), and skip castBulk(plan:) when plan.work is empty.
In BulkVoteSheet.swift lines 148-156, add a confirmed.hasWork guard inside the
Continue action so it returns without proceeding.

In
`@DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift`:
- Around line 127-138: Update the logging in MasternodeVoterRegistry.swift at
lines 127-138 and 179-197: mark address, poolAddress, and derivationAddress as
private/redacted in the unmatched, matched, mismatch, and informational log
messages. Keep index and livePoolSummary() public, since they provide
non-sensitive diagnostic details.

In `@DashWallet/Sources/UI/DashPay/Voting/BulkVoteSheet.swift`:
- Around line 148-156: Update the Continue button action in BulkVoteSheet to
guard plan.hasWork and return immediately when no work remains, before clearing
pendingPlan or calling viewModel.castBulk. Keep the existing behavior unchanged
for valid plans.

In `@DashWallet/Sources/UI/DashPay/Voting/UsernameVotingScreen.swift`:
- Around line 259-268: Update UsernameVotingScreen.swift lines 259-268 to build
selectionSummary.chosen from VotingViewModel.effectiveSelectedNodeIDs,
preserving the existing empty, all-nodes, and name-list summaries. Update
CastVoteSheet.swift lines 114-115 to compare the sheet-local selectedNodeIDs
with candidateNodes.count, so its hint reflects the current sheet selection; no
other sites require changes.

In `@DashWallet/Sources/UI/DashPay/Voting/VotingViewModel.swift`:
- Around line 202-210: Update the selection-settling block around
effectiveSelectedNodeIDs so selectedNodeIDs is written back only when the
votable node list has successfully resolved, not when registry.votableNodes() is
temporarily empty during SDK, wallet, or masternode synchronization. Preserve
the stored selection while unresolved, and continue settling it once a resolved
node list is available.
- Around line 322-326: Update nodesForNextVote(on:) and its supporting
vote-history flow to use the hashes loaded for the requested normalizedLabel
rather than a shared set. Ensure loadVotedNodes(for:) completes before
filtering, or otherwise gate the result, so nodes already voted in that contest
are never returned.

---

Nitpick comments:
In
`@DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoteCaster.swift`:
- Around line 270-272: Replace the repeated three-element work tuple with a
named BulkWorkItem type containing label, choice, and nodes. Update castBulk,
VotingViewModel.planBulk, BulkPlan.work, and all construction/access sites to
use BulkWorkItem while preserving existing behavior.

In
`@DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift`:
- Around line 204-207: Update the error log in the ProviderKeyDeriver guard
within MasternodeVoterRegistry to identify that ProviderKeyDeriver construction
failed, distinguishing it from the separate MasternodeProviderKeyDeriver failure
log while preserving the existing return behavior.

In `@DashWallet/Sources/UI/DashPay/Voting/BulkVoteSheet.swift`:
- Around line 216-230: Centralize the choice labels by adding shared
display-name mappings to both VotingViewModel.BulkChoice and VoteChoice, then
replace the duplicate name(for:) helpers in BulkVoteSheet and VotingViewModel
with those mappings. Ensure VoteChoice.towards(identityId:) derives its label
from the associated identity or otherwise preserves the actual contender name
instead of always returning “Sole Requester” for historical multi-contender
votes.

In `@DashWallet/Sources/UI/DashPay/Voting/VotingNodeSelectionSheet.swift`:
- Line 34: Move the `@Environment`(\.dismiss) attribute in
VotingNodeSelectionSheet to its own line above the dismiss property declaration,
matching the layout used in CastVoteSheet.swift and satisfying SwiftLint's
attributes rule.

In `@DashWallet/Sources/UI/DashPay/Voting/VotingViewModel.swift`:
- Around line 444-446: Replace the three-member work tuple in BulkPlan with a
named BulkWorkItem type containing label, choice, and nodes, then update
planBulk and MasternodeVoteCaster.castBulk to use [BulkWorkItem] while
preserving the existing data and behavior.
- Around line 474-488: The planBulk flow currently performs one sequential
history query per selected contest; replace this with VoteHistoryDAO’s
network-wide aggregate query, then group the returned records by normalizedLabel
in memory and use each contest’s group. Remove the unnecessary per-node
latestByNode deduplication because record upserts guarantee one row per
proTxHash, normalizedLabel, and network, while preserving the existing
resolved-choice filtering and vote processing.
🪄 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: 187fdbda-3be2-4845-8d90-17e8127bc515

📥 Commits

Reviewing files that changed from the base of the PR and between 30d9112 and df3123d.

📒 Files selected for processing (10)
  • DashWallet.xcodeproj/project.pbxproj
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoteCaster.swift
  • DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoterRegistry.swift
  • DashWallet/Sources/Models/Voting/VotingPrefs.swift
  • DashWallet/Sources/UI/DashPay/Voting/BulkVoteSheet.swift
  • DashWallet/Sources/UI/DashPay/Voting/CastVoteSheet.swift
  • DashWallet/Sources/UI/DashPay/Voting/UsernameVotingScreen.swift
  • DashWallet/Sources/UI/DashPay/Voting/VotingNodeSelectionSheet.swift
  • DashWallet/Sources/UI/DashPay/Voting/VotingViewModel.swift
  • DashWallet/Sources/UI/Menu/Tools/Masternode Keys/DerivationPathKeys/Models/DerivationPathKeysModel.swift

Comment thread DashWallet/Sources/Infrastructure/SwiftDashSDK/Voting/MasternodeVoteCaster.swift Outdated
Comment thread DashWallet/Sources/UI/DashPay/Voting/BulkVoteSheet.swift Outdated
Comment thread DashWallet/Sources/UI/DashPay/Voting/UsernameVotingScreen.swift
Comment thread DashWallet/Sources/UI/DashPay/Voting/VotingViewModel.swift
Comment thread DashWallet/Sources/UI/DashPay/Voting/VotingViewModel.swift
QuantumExplorer and others added 2 commits August 8, 2026 17:32
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 <noreply@anthropic.com>
…hing

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 <noreply@anthropic.com>
@QuantumExplorer
QuantumExplorer force-pushed the feat/voting-keys-and-selection-ux branch from df3123d to f6d6464 Compare August 8, 2026 10:34
…h reporting

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 <noreply@anthropic.com>
@QuantumExplorer
QuantumExplorer merged commit 95b7932 into develop Aug 8, 2026
2 checks passed
QuantumExplorer added a commit that referenced this pull request Aug 8, 2026
#936 and #937 landed independently and each brought its own memoised
wrapper over `ManagedPlatformWallet.providerKeyAtIndex` — byte-identical
bodies, one for the ECDSA owner/voting family and one for BLS/Ed25519.
Two caches to keep in step and two places to change.

Collapse both into a file-private `ProviderKeyResolver`, now the single
call site for all four families. `MasternodeProviderKeyDeriver` keeps its
distinct job — it carries the address pool the owner/voting rows join
against — and delegates derivation. Operator/evonode-operator have no
address rows, so their wrapper shell goes away entirely and the model
holds a resolver directly; `tenderdashNodeKeyBase64` moves onto the
resolver, still guarded on the Ed25519 kind.

Also drops three pieces of state left dead by #937, when derivation moved
Rust-side:

  * `wallet` — written in init, never read since. It held a `Wallet`
    owned by the `WalletManager` that `derivationWallet()` builds per
    call and that this class never retained, so it was a reference into
    a graph released at the end of init. Unused, so harmless, but not
    worth keeping.
  * `key` — written, never read.
  * the bound `network` — it selected the coin type back when this class
    composed the DIP-3 path app-side. Now a boolean test, with a comment
    for why the check stays.

No behaviour change. Same kinds, same `includePrivate: true`, same cache
lifetime (one per deriver instance). The host-wallet guard now runs
before the `derivationWallet()` guard rather than after, which cannot
change the outcome: `derivationWallet()` returns nil unless that same
wallet is present, so the guard only fires where the other would have.

Verified: clean `dashpay` build, no warnings left in the file.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant