From 382efc5c0d0c6fdd5ba27b2e03b314b354cd2d77 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 16 Jul 2026 09:02:15 +0000 Subject: [PATCH 01/39] docs(dpns): design unified voting experience MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define authoritative vote state, a shared quick and bulk composer, durable operation coordination, safe post-broadcast recovery, and scheduled-vote consolidation before implementation begins. Co-Authored-By: Codex GPT-5 πŸ€– Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../01-requirements.md | 208 +++++++++++++ .../02-ux-spec.md | 266 ++++++++++++++++ .../03-test-case-spec.md | 89 ++++++ .../04-development-plan.md | 291 ++++++++++++++++++ 4 files changed, 854 insertions(+) create mode 100644 docs/ai-design/2026-07-16-dpns-voting-experience/01-requirements.md create mode 100644 docs/ai-design/2026-07-16-dpns-voting-experience/02-ux-spec.md create mode 100644 docs/ai-design/2026-07-16-dpns-voting-experience/03-test-case-spec.md create mode 100644 docs/ai-design/2026-07-16-dpns-voting-experience/04-development-plan.md diff --git a/docs/ai-design/2026-07-16-dpns-voting-experience/01-requirements.md b/docs/ai-design/2026-07-16-dpns-voting-experience/01-requirements.md new file mode 100644 index 000000000..3d81a6791 --- /dev/null +++ b/docs/ai-design/2026-07-16-dpns-voting-experience/01-requirements.md @@ -0,0 +1,208 @@ +# DPNS Voting Experience β€” Requirements + +## Status + +Planning specification. No implementation is authorized by this document. + +## Problem statement + +DET exposes DPNS voting through two disconnected experiences: + +- a quick per-node section on the Masternodes detail page; and +- a legacy DPNS bulk dialog for multiple contests, nodes, and schedules. + +Both call the same backend, but neither owns a complete, authoritative model of +the operation. The result is unsafe ambiguity: a vote can be accepted while DET +shows no confirmation, the same choice can immediately be offered again, bulk +results cannot identify the affected node, and a second click can submit another +state transition while the first is unresolved. + +Voting consumes Platform credits and each node may vote only five times on a +contest in total (the initial vote plus up to four changes). Duplicate or +ambiguous submissions therefore have a real cost. + +## Primary persona + +Priya, the power-user masternode operator, manages one or more nodes and expects: + +- current vote state to be accurate; +- quick actions for one node; +- bulk and scheduled operations for many nodes; +- progress that survives navigation and restart; +- exact per-node results; and +- safe recovery when Platform accepted a request but DET could not confirm it. + +The voting workspace remains hidden below Detailed interface mode. It is not an +Everyday User workflow. + +## Current-state audit + +| Finding | Source behavior | User impact | +|---|---|---| +| Vote ownership is never populated | Contest cache reconstructs `ContestedName::my_votes` as empty and no task fills it | A successful vote is offered again after refresh | +| Existing votes are treated as closed | `is_open_for_voter` excludes any contest where the node already voted | Operators cannot inspect or change an existing vote | +| Same-node bulk execution races | `VoteOnDPNSNames` runs contests with `join_all`; each task independently fetches the same voter nonce | Multiple votes for one node can conflict | +| Bulk results lose node identity | `DPNSVoteResults` contains only name, choice, and result | Partial results cannot say which node succeeded | +| Scheduled casts can report false success | Scheduled paths treat an outer `Ok(DPNSVoteResults)` as success without inspecting inner failures | A failed or unconfirmed scheduled vote is marked executed | +| Progress is screen-owned | App results are routed to the currently visible screen | Navigation can strand controls in progress or deliver feedback to the wrong page | +| Duplicate prevention is local or absent | Quick and bulk submit buttons do not share an operation lock | Repeated clicks or cross-screen actions can submit duplicates | +| Post-broadcast wait failure is ambiguous | A cause-less `StateTransitionBroadcastError` can follow successful broadcast | DET must not label it rejected or invite immediate retry | +| Legacy bulk workflow is disconnected | Bulk and scheduling live under DPNS while node management lives under Masternodes | Operators can miss existing capabilities or assume they were removed | + +## Product decisions + +1. Masternodes is the primary home for operator voting. +2. DPNS remains the home for name registration, contest discovery, and contest + history, with a route into the shared voting workspace. +3. Quick voting and bulk/scheduled voting use one shared composer and one shared + operation coordinator. +4. Current vote state is authoritative Platform data, not UI-local memory. +5. A vote row remains visible after voting and shows the current choice. Voting + again is presented as a change, not a new missing vote. +6. Operation state is global, correlated, and durable enough to survive + navigation and process restart. +7. Exact targets, not whole screens, are locked. Unrelated nodes and contests + remain usable. + +## Functional requirements + +### Information architecture + +- **VOTE-FR-001** β€” Masternodes provides `Nodes`, `Voting`, and `Scheduled` + views under one operator-focused root. +- **VOTE-FR-002** β€” The DPNS Active Contests page links to the same Voting view; + it does not maintain a second bulk implementation. +- **VOTE-FR-003** β€” A node detail page offers quick voting and an `Open Voting + Center` action pre-filtered to that node. + +### Authoritative state + +- **VOTE-FR-010** β€” DET queries each loaded node's proved Platform votes and + joins them with active contests. +- **VOTE-FR-011** β€” Each active contest shows the node's current choice, or + `Not voted`. +- **VOTE-FR-012** β€” Existing votes remain actionable while the contest is + votable, allowing a deliberate vote change. +- **VOTE-FR-013** β€” Selecting the already-current choice is a no-op and cannot + create a state transition. +- **VOTE-FR-014** β€” Refresh updates contests, tallies, and node vote state as one + coherent snapshot. +- **VOTE-FR-015** β€” A change to an existing vote is labeled as a limited vote + change in Review. DET does not claim to know the remaining count. +- **VOTE-FR-016** β€” If current vote state cannot be proved, DET shows it as + unavailable and disables submission for that node instead of assuming + `Not voted`. +- **VOTE-FR-017** β€” Node summaries distinguish active contests from contests + where the node has not voted yet. + +### Vote composition + +- **VOTE-FR-020** β€” Quick voting supports one node across one or more contests. +- **VOTE-FR-021** β€” Bulk voting supports one or more contests across one or more + nodes. +- **VOTE-FR-022** β€” The operator can apply one timing choice to all selected + nodes and override individual nodes. +- **VOTE-FR-023** β€” Timing choices are `Cast now`, `Schedule`, and `Do not use + this node`. +- **VOTE-FR-024** β€” Before submission, a review step lists every target as + node Γ— contest, including current choice, requested choice, and timing. +- **VOTE-FR-025** β€” The review step removes no-op targets and explains why. + +### Operation lifecycle + +- **VOTE-FR-030** β€” Every submitted batch has a stable operation ID. +- **VOTE-FR-031** β€” Every target result includes operation ID, node ID, contest + ID/name, requested choice, and typed status. +- **VOTE-FR-032** β€” Target statuses are `Scheduled`, `Queued`, `Submitting`, + `Confirming`, `Confirmed`, `Unconfirmed`, `Rejected`, and + `Failed before submission`, plus `Not applied` after definitive + post-broadcast reconciliation. +- **VOTE-FR-033** β€” Same-node targets execute sequentially to preserve nonce + order. Different nodes may execute concurrently with a fixed bound. +- **VOTE-FR-034** β€” A target lock prevents a second operation for the same + network + node + contest while the first is unresolved. +- **VOTE-FR-035** β€” Button state derives from the shared coordinator. A click + disables the affected action immediately and shows progress text. +- **VOTE-FR-036** β€” Navigation does not cancel an operation or lose its state. +- **VOTE-FR-037** β€” Restart restores scheduled and unresolved operations before + enabling conflicting actions. + +### Confirmation and recovery + +- **VOTE-FR-040** β€” Structured Platform consensus causes are treated as + confirmed rejection. +- **VOTE-FR-041** β€” A cause-less post-broadcast wait failure is treated as + `Unconfirmed`, never as rejection. +- **VOTE-FR-042** β€” Unconfirmed targets are reconciled against the proved + current vote and, when available, retried by transition hash through the + Platform SDK. +- **VOTE-FR-043** β€” A target becomes `Confirmed` when authoritative state + matches the requested choice. +- **VOTE-FR-044** β€” DET never offers `Submit again` while the result remains + ambiguous. It offers `Check again`. +- **VOTE-FR-045** β€” A retry becomes available only after authoritative + reconciliation proves the requested change was not applied. + +### Scheduled votes + +- **VOTE-FR-050** β€” Scheduled votes use the same target model, result model, + locking, execution order, and reconciliation as immediate votes. +- **VOTE-FR-051** β€” A scheduled target is marked executed only after confirmed + application. +- **VOTE-FR-052** β€” Rejected and failed-before-submission targets remain visible + with an actionable status. +- **VOTE-FR-053** β€” Unconfirmed scheduled targets are not automatically + rebroadcast. +- **VOTE-FR-054** β€” Existing scheduled-vote records migrate without losing node, + contest, choice, time, or executed state. +- **VOTE-FR-055** β€” A scheduled target can be edited or cancelled until + execution begins. Once submitting, it follows normal operation locking. + +### Feedback + +- **VOTE-FR-060** β€” One confirmed target shows a concise success banner. +- **VOTE-FR-061** β€” Batch feedback summarizes confirmed, unconfirmed, rejected, + and failed counts and links to per-target details. +- **VOTE-FR-062** β€” Messages name node aliases and contested names where useful. +- **VOTE-FR-063** β€” Technical errors stay in banner details. +- **VOTE-FR-064** β€” Unconfirmed copy explicitly says DET will keep checking and + warns against resubmission. + +## Non-functional requirements + +- **VOTE-NFR-001 Safety** β€” No UI path can bypass target locking. +- **VOTE-NFR-002 Correctness** β€” Same-voter transitions are serialized. +- **VOTE-NFR-003 Durability** β€” A crash after broadcast cannot erase the only + record that the outcome is unresolved. +- **VOTE-NFR-004 Proofs** β€” Current vote state is obtained through proved SDK + queries. +- **VOTE-NFR-005 Accessibility** β€” Disabled actions explain why; progress is not + color-only; keyboard focus follows the composer step order. +- **VOTE-NFR-006 Localization** β€” User-facing strings are complete translation + units with no parsed error text. +- **VOTE-NFR-007 Performance** β€” Refresh queries votes once per node, not once + per contest, and bounds cross-node concurrency. +- **VOTE-NFR-008 Network isolation** β€” Drafts, schedules, operations, locks, and + results are network-scoped. +- **VOTE-NFR-009 Secret handling** β€” The coordinator stores identifiers and + choices, never private keys. + +## Platform dependency + +The preferred recovery contract is +[dashpay/platform#4137](https://github.com/dashpay/platform/issues/4137), which +tracks phase-specific, retryable post-broadcast wait errors. The SDK should +expose the transition hash after broadcast so DET can persist it and resume +waiting. + +DET must still support fallback reconciliation by fetching the node's proved +vote for the target poll. Until either method proves the result, the operation +remains unconfirmed and locked against duplicate submission. + +## Out of scope + +- Changing Platform's five-vote protocol limit. +- Showing a remaining-change count before Platform exposes it in the proved + identity-votes response. +- Embedding Platform Explorer. +- Allowing arbitrary cancellation after broadcast. diff --git a/docs/ai-design/2026-07-16-dpns-voting-experience/02-ux-spec.md b/docs/ai-design/2026-07-16-dpns-voting-experience/02-ux-spec.md new file mode 100644 index 000000000..b526819f3 --- /dev/null +++ b/docs/ai-design/2026-07-16-dpns-voting-experience/02-ux-spec.md @@ -0,0 +1,266 @@ +# DPNS Voting Experience β€” UX Specification + +## Experience principle + +Voting is one operator workflow with two entry speeds: + +- **Quick vote** β€” one selected node, usually one or a few contests. +- **Voting Center** β€” many contests, many nodes, immediate and scheduled + targets. + +Both are views over the same draft, authoritative vote state, and operation +coordinator. They must never disagree about current votes or progress. + +## Information architecture + +```text +Masternodes +β”œβ”€β”€ Nodes +β”‚ β”œβ”€β”€ Node cards +β”‚ └── Node detail +β”‚ β”œβ”€β”€ Keys and actions +β”‚ β”œβ”€β”€ Quick voting +β”‚ └── Open Voting Center (filtered to this node) +β”œβ”€β”€ Voting +β”‚ β”œβ”€β”€ Active contests +β”‚ β”œβ”€β”€ Vote composer +β”‚ └── Recent operations +└── Scheduled + β”œβ”€β”€ Upcoming + β”œβ”€β”€ Needs attention + └── Completed + +DPNS +β”œβ”€β”€ Active contests ── β€œVote with masternodes” ──> Masternodes / Voting +β”œβ”€β”€ Past contests +└── My usernames +``` + +The existing DPNS bulk popup is retired after the shared Voting Center is +available. DPNS contest browsing remains; operator actions route to +Masternodes. + +## Shared concepts + +### Current vote + +Every node Γ— contest row shows one of: + +- `Not voted` +- `Current vote: Abstain` +- `Current vote: Lock` +- `Current vote: {candidate}` +- `Checking current vote…` +- `Current vote unavailable` + +An existing vote does not remove the contest. Selecting a different choice is +labeled as a change. + +`Current vote unavailable` disables that node's choice controls and offers +`Refresh vote state`. DET never treats an unavailable query as `Not voted`. + +Node cards summarize both concepts, for example: + +> 3 active contests Β· 1 needs a vote + +When every active contest has a current vote: + +> Votes cast in all active contests + +### Target + +A target is one requested action for one node on one contest. Batch progress +and results are always expressed in targets, never only in aggregate. + +### Operation + +An operation is the reviewed collection of targets submitted together. It owns +progress across screens and process restarts. + +## Journey A β€” Quick vote from a node + +1. Priya opens a node. +2. The DPNS section shows all active contests and the node's current choice. +3. Priya selects a new choice on one or more rows. +4. The primary action becomes `Review 1 vote` or `Review {n} votes`. +5. Review lists current β†’ requested choice for this node. +6. Priya chooses `Cast now` or `Schedule instead`, then confirms. +7. Affected rows become read-only and show `Submitting…` or `Scheduled`. +8. Priya may navigate elsewhere. A global banner links to operation progress. +9. Confirmed rows update their current vote in place; they do not disappear. + +```text +DPNS name contests (3) + +alice.dash +Current vote: Abstain +( Abstain ) ( Lock ) ( Vote for alice ) + +dominguez.dash +Not voted +( Abstain ) ( Lock ) ( Vote for dominguez ) + + [ Review 1 vote ] + [ Open Voting Center ] +``` + +## Journey B β€” Bulk vote or schedule + +The composer is a three-step full-page flow, not a transient popup. Nodes come +first so every later β€œcurrent vote” summary has a defined node scope. + +### Step 1: Nodes and timing + +Priya selects nodes and chooses timing. `Set all` applies timing only; it does +not alter contest choices. + +```text +Voting Center Step 1 of 3: Nodes and timing + +Set all: [ Cast now v ] [ Apply ] + +[x] Eve Mainnet Cast now +[x] Backup Evo Schedule: 2026-07-20 18:00 UTC +[ ] Test Operator Do not use this node + + [ Next: Choose votes ] +``` + +### Step 2: Votes + +Priya selects contests and a requested choice for each contest. Current-state +summaries cover only the nodes selected in Step 1. + +```text +Step 2 of 3: Votes + +[ ] alice.dash Current across selected nodes: Mixed + Abstain | Lock | Vote for alice + +[x] dominguez.dash Current across selected nodes: 1 not voted, 1 Lock + Abstain | Lock | Vote for dominguez + + [ Back ] [ Review 2 targets ] +``` + +### Step 3: Review + +The review expands the cartesian product into exact targets. No-op targets are +removed and explained. + +```text +Step 3 of 3: Review + +Node Contest Current Requested When +Eve Mainnet dominguez.dash Not voted dominguez Now +Backup Evo dominguez.dash Lock dominguez Jul 20 + +2 targets total. Each vote uses Platform credits. + + [ Back ] [ Submit 2 targets ] +``` + +## Journey C β€” Operation progress + +After submit, the review becomes an operation detail page. + +```text +Submitting votes +1 confirmed Β· 1 checking + +βœ“ Eve Mainnet / dominguez.dash + Vote confirmed: dominguez + +… Backup Evo / dominguez.dash + The vote was submitted. DET is checking the result. + + [ Continue in background ] +``` + +Target rows expose technical details only through the standard expandable +details affordance. If a transition hash is available, developer mode may show +and copy it. + +## Journey D β€” Unconfirmed result + +1. Broadcast succeeds. +2. Waiting for the result fails without a structured consensus cause. +3. The target becomes `Unconfirmed`; it is not labeled failed. +4. The exact node Γ— contest target remains locked. +5. DET retries the result wait and/or fetches the proved current vote. +6. If the requested choice appears, the target becomes `Confirmed`. +7. If authoritative reconciliation proves it was not applied, the target + becomes `Not applied` and offers `Submit again`. +8. If Platform remains unavailable, the persistent action is `Check again`. + +Banner copy: + +> The vote was submitted, but DET could not confirm the result yet. DET will +> keep checking. Do not submit it again. + +## Journey E β€” Scheduled vote + +- Scheduled targets appear immediately in `Masternodes > Scheduled`. +- Before execution begins, `Edit schedule` and `Cancel scheduled vote` remain + available. +- At execution time, status changes from `Scheduled` to `Submitting`. +- A confirmed result becomes `Completed`. +- A definite rejection becomes `Needs attention`. +- An ambiguous result becomes `Checking result`; it is never automatically + rebroadcast. + +## Control state + +| Target state | Choice controls | Submit action | Other targets | +|---|---|---|---| +| Draft | Enabled | Enabled when draft has changes | Enabled | +| Current vote unavailable | Disabled for that node | `Refresh vote state` | Enabled | +| Scheduled | Read-only for that target | `Edit schedule` | Enabled | +| Queued / Submitting / Confirming | Disabled | Spinner + status | Enabled | +| Unconfirmed | Disabled | `Check again` | Enabled | +| Confirmed | Enabled for a deliberate change | No draft action | Enabled | +| Rejected / Failed before submission / Not applied | Enabled after correction | `Review again` | Enabled | + +The disabled tooltip names the exact reason, for example: + +> This node's vote for dominguez.dash is still being confirmed. + +## Feedback matrix + +| Outcome | Type | Primary copy | +|---|---|---| +| One confirmed | Success | `Vote cast successfully.` | +| All batch targets confirmed | Success | `{count} votes were cast successfully.` | +| Scheduled | Success | `{count} votes were scheduled.` | +| Partial | Warning | `{confirmed} of {total} votes were confirmed. Review the remaining {remaining}.` | +| Unconfirmed | Warning, persistent | `The vote was submitted, but DET could not confirm the result yet. DET will keep checking. Do not submit it again.` | +| Structured rejection | Error | Typed, user-actionable rejection message | +| Failed before broadcast | Error | `This vote was not submitted. {action}` | +| No-op | Info | `This node already has that vote. Nothing was submitted.` | + +## Navigation and persistence + +- Operation progress is not owned by a screen instance. +- Leaving Masternodes never clears an active operation. +- Returning to any voting entry point reads current operation state and locks + affected targets. +- On startup, DET restores scheduled and unresolved targets before voting + controls become available. +- Switching networks swaps to that network's independent voting workspace. + +## Accessibility and interaction + +- Use styled buttons and semantic status colors with text labels. +- Minimum click targets follow the shared button component. +- `Enter` advances or submits only on the review step. +- `Escape` closes a draft review but cannot cancel a submitted operation. +- Focus moves to the step heading after Back/Next. +- Progress text and spinner are both present; no status is color-only. +- Disabled controls use the standard disabled tooltip policy. + +## Responsive behavior + +- Desktop: contest table and node/timing table use the full island panel. +- Narrow width: target rows become stacked cards showing Node, Contest, Current, + Requested, Timing, and Status. +- Operation progress remains usable without horizontal scrolling. diff --git a/docs/ai-design/2026-07-16-dpns-voting-experience/03-test-case-spec.md b/docs/ai-design/2026-07-16-dpns-voting-experience/03-test-case-spec.md new file mode 100644 index 000000000..775f44ff3 --- /dev/null +++ b/docs/ai-design/2026-07-16-dpns-voting-experience/03-test-case-spec.md @@ -0,0 +1,89 @@ +# DPNS Voting Experience β€” Test Case Specification + +## Authoritative state + +| ID | Description | Preconditions | Steps | Expected outcome | Requirements | +|---|---|---|---|---|---| +| VOTE-TC-001 | Current vote loads from Platform | Node has a proved Lock vote | Refresh Voting | Row shows `Current vote: Lock` | FR-010, FR-011 | +| VOTE-TC-002 | Existing vote remains visible | Node already voted; contest active | Open node detail | Contest remains listed and change controls are available | FR-012 | +| VOTE-TC-003 | Current choice is a no-op | Current vote is Lock | Select Lock and review | Target is removed; nothing can be submitted | FR-013, FR-025 | +| VOTE-TC-004 | Coherent refresh | Contest tally and current vote both changed | Refresh | One snapshot shows both new values | FR-014 | +| VOTE-TC-005 | Vote query is per node | One node, 100 contests | Refresh | Identity-votes query runs once for the node, not 100 times | NFR-007 | +| VOTE-TC-006 | Vote change warning | Node has an existing vote | Select a different choice and review | Review says this uses a limited vote change without inventing a remaining count | FR-015 | +| VOTE-TC-007 | Vote query failure is not `Not voted` | Proved identity-votes query fails | Open voting | State is unavailable; affected submit controls are disabled | FR-016, NFR-004 | +| VOTE-TC-008 | Node summary distinguishes active and unvoted | Three active contests; node voted in two | View node card | Summary says three active and one needs a vote | FR-017 | + +## Quick voting + +| ID | Description | Preconditions | Steps | Expected outcome | Requirements | +|---|---|---|---|---|---| +| VOTE-TC-010 | Quick single vote | Node detail, one draft choice | Review and submit | One target is created for the selected node and contest | FR-020, FR-024 | +| VOTE-TC-011 | Quick multi-contest vote | Node detail, three draft choices | Review | Review shows three exact targets | FR-020, FR-031 | +| VOTE-TC-012 | Quick schedule | Node detail, one draft | Choose Schedule in review | Target appears in Scheduled with the chosen time | FR-023, FR-050 | +| VOTE-TC-013 | Missing voting key | Node lacks voter key | Open voting | Actionable add-key state appears; submit is unavailable | FR-003 | + +## Bulk voting + +| ID | Description | Preconditions | Steps | Expected outcome | Requirements | +|---|---|---|---|---|---| +| VOTE-TC-020 | Multiple contests and nodes | Two contests, three nodes | Select all and review | Six exact targets are listed | FR-021, FR-024 | +| VOTE-TC-021 | Set all timing | Three selected nodes | Apply Schedule to all | All nodes receive the same schedule | FR-022, FR-023 | +| VOTE-TC-022 | Per-node override | Set all Cast now | Override one node to Schedule | Review reflects two Now and one Scheduled target | FR-022 | +| VOTE-TC-023 | DPNS route reuses workspace | DPNS Active Contests visible | Click `Vote with masternodes` | Shared Masternodes Voting view opens; no legacy popup appears | FR-002 | +| VOTE-TC-024 | Node route prefilters | Node detail visible | Click `Open Voting Center` | Voting view opens with only that node selected | FR-003 | +| VOTE-TC-025 | Current summary uses selected nodes | Three nodes loaded; two selected | Open Votes step | `Current across selected nodes` excludes the unselected node | FR-011, FR-021 | + +## Execution correctness + +| ID | Description | Preconditions | Steps | Expected outcome | Requirements | +|---|---|---|---|---|---| +| VOTE-TC-030 | Same-node serialization | One node, three immediate targets | Submit | Nonce fetch/broadcast for target N+1 starts after N finishes submission | FR-033, NFR-002 | +| VOTE-TC-031 | Cross-node bounded concurrency | Four nodes, one target each | Submit | Different nodes run concurrently up to the configured bound | FR-033, NFR-007 | +| VOTE-TC-032 | Structured result correlation | Two nodes vote on same name; one fails | Complete operation | Result identifies the exact successful and failed node | FR-031 | +| VOTE-TC-033 | Scheduled inner error is not success | Scheduled backend returns an inner rejection | Execute | Target is Needs attention; record is not marked executed | FR-051, FR-052 | +| VOTE-TC-034 | Scheduled unconfirmed is not rebroadcast | Scheduled wait fails after broadcast | Run next sweep | Target remains Checking result; no second broadcast occurs | FR-053 | + +## Duplicate prevention + +| ID | Description | Preconditions | Steps | Expected outcome | Requirements | +|---|---|---|---|---|---| +| VOTE-TC-040 | Double click | Submit enabled | Double-click Submit | Exactly one operation and one target broadcast are created | FR-034, FR-035 | +| VOTE-TC-041 | Cross-screen duplicate | Target is confirming from quick vote | Open Voting Center | Same node Γ— contest target is disabled with explanation | FR-034, FR-036 | +| VOTE-TC-042 | Unrelated target stays usable | One target confirming | Select another node or contest | Unrelated target remains enabled | Product decision 7 | +| VOTE-TC-043 | Navigation preserves lock | Submit, leave page, return before result | Inspect target | Progress and lock remain active | FR-036 | +| VOTE-TC-044 | Restart preserves lock | Persist unresolved target; restart | Open Voting | Target is restored and reconciled before resubmission is allowed | FR-037, NFR-003 | + +## Confirmation and recovery + +| ID | Description | Preconditions | Steps | Expected outcome | Requirements | +|---|---|---|---|---|---| +| VOTE-TC-050 | Confirmed success | Broadcast and result wait succeed | Complete target | Status Confirmed; success banner shown | FR-043, FR-060 | +| VOTE-TC-051 | Structured rejection | Platform returns typed consensus cause | Complete target | Status Rejected with actionable typed message | FR-040 | +| VOTE-TC-052 | Cause-less wait failure | Broadcast succeeds; wait returns no cause | Complete target | Status Unconfirmed; warning forbids resubmission | FR-041, FR-044, FR-064 | +| VOTE-TC-053 | Reconcile to success | Unconfirmed target; proved vote matches request | Check again | Status changes to Confirmed without rebroadcast | FR-042, FR-043 | +| VOTE-TC-054 | Reconcile to safe retry | Unconfirmed target; definitive reconciliation proves absence | Check again | Status allows reviewed resubmission | FR-045 | +| VOTE-TC-055 | Reconciliation unavailable | DAPI remains unavailable | Check again | Target stays Unconfirmed and locked; no false failure/success | FR-044 | +| VOTE-TC-056 | Partial batch | Two confirmed, one unconfirmed, one rejected | Complete batch | Warning shows counts and details map every target | FR-061, FR-062 | + +## Scheduling and migration + +| ID | Description | Preconditions | Steps | Expected outcome | Requirements | +|---|---|---|---|---|---| +| VOTE-TC-060 | Legacy schedule migration | Existing scheduled-vote records | Upgrade | Node, contest, choice, time, and executed state are preserved | FR-054 | +| VOTE-TC-061 | Due schedule uses shared coordinator | Scheduled target becomes due | Sweep | Same lock, result, and reconciliation model is used | FR-050 | +| VOTE-TC-062 | Failed schedule remains visible | Submission fails before broadcast | Open Scheduled | Needs attention row shows corrective action | FR-052 | +| VOTE-TC-063 | Scheduled target can be edited | Target is Scheduled, not due | Change time or choice | Updated target persists and keeps one lock | FR-055 | +| VOTE-TC-064 | Scheduled target can be cancelled | Target is Scheduled, not due | Cancel and confirm | Target is removed and its lock is released | FR-055 | +| VOTE-TC-065 | Submitting schedule cannot be edited | Target is Submitting | Inspect actions | Edit and Cancel are disabled with an explanation | FR-055 | + +## UX, accessibility, and isolation + +| ID | Description | Preconditions | Steps | Expected outcome | Requirements | +|---|---|---|---|---|---| +| VOTE-TC-070 | Progress button state | Target submitting | Inspect action | Disabled styled action shows spinner, text, and tooltip | FR-035, NFR-005 | +| VOTE-TC-071 | Keyboard review | Composer draft ready | Tab, Enter, Escape | Focus order is logical; Enter submits only at review; Escape closes only drafts | NFR-005 | +| VOTE-TC-072 | Network isolation | Testnet target unresolved | Switch Mainnet | Mainnet has no Testnet locks or operation rows | NFR-008 | +| VOTE-TC-073 | No secret persistence | Operation stored | Inspect serialized operation | No private key or WIF bytes are present | NFR-009 | +| VOTE-TC-074 | Complete message units | All new copy | Localization audit | Strings are complete and do not parse technical errors | NFR-006 | +| VOTE-TC-075 | All entry points share one submit path | Quick, bulk, and due-scheduled drafts | Dispatch each | Every path creates a coordinator operation; no legacy direct-broadcast path remains | NFR-001 | +| VOTE-TC-076 | Technical error stays in details | Target rejected or fails | Inspect banner | Primary copy is plain language; typed diagnostic is attached as details | FR-063 | diff --git a/docs/ai-design/2026-07-16-dpns-voting-experience/04-development-plan.md b/docs/ai-design/2026-07-16-dpns-voting-experience/04-development-plan.md new file mode 100644 index 000000000..cac053d1f --- /dev/null +++ b/docs/ai-design/2026-07-16-dpns-voting-experience/04-development-plan.md @@ -0,0 +1,291 @@ +# DPNS Voting Experience β€” Development Plan + +## Architecture + +```text +Platform proved queries + β”‚ + β–Ό +DPNS vote-state store ────────┐ + β”‚ +Draft / shared composer ──> Vote operation coordinator + β”‚ + β”œβ”€ durable operation journal + β”œβ”€ target lock registry + β”œβ”€ scheduled dispatcher + └─ immediate executor + β”‚ + group by node ──── + sequential/node β”‚ + bounded nodes β–Ό + Platform broadcast + wait + β”‚ + β–Ό + reconciliation service + β”‚ + β–Ό + shared operation/result views +``` + +Screens never own the authoritative in-progress flag. They render coordinator +state and submit typed drafts. + +## Domain model + +Add `src/model/dpns_voting.rs` with pure, serializable types: + +```rust +struct DpnsVoteTargetKey { + network: Network, + voter_id: Identifier, + vote_poll_id: Identifier, +} + +struct DpnsVoteTarget { + key: DpnsVoteTargetKey, + contested_name: String, + requested_choice: ResourceVoteChoice, + current_choice: Option, + timing: VoteTiming, +} + +struct DpnsVoteOperationId([u8; 16]); + +enum VoteTiming { + Now, + Scheduled(TimestampMillis), +} + +enum DpnsVoteTargetStatus { + Scheduled, + Queued, + Submitting, + Confirming, + Confirmed, + Unconfirmed, + Rejected, + FailedBeforeSubmission, + NotApplied, +} + +struct DpnsVoteOutcome { + operation_id: DpnsVoteOperationId, + target: DpnsVoteTarget, + status: DpnsVoteTargetStatus, + transition_hash: Option<[u8; 32]>, + failure: Option, +} +``` + +Generate `DpnsVoteOperationId` with the project's existing random-number +dependency; no new UUID dependency is required. + +`DpnsVoteFailure` is a pure domain enum, not a wrapper around `TaskError`. +Backend errors are mapped into it structurally. The stored form uses +serde-friendly representations and never serializes `TaskError` or secrets. +Full errors remain in task diagnostics and logs. + +## Data ownership + +### Authoritative current votes + +Add `src/context/dpns_vote_state.rs`. + +- Query `ResourceVote::fetch_many` once per loaded node using its ProTxHash. +- Index results by node + vote-poll ID. +- Persist the latest proved snapshot in the node's identity scope. +- Join vote state with global contest data when building UI view models. +- Stop using `ContestedName::my_votes` as an implied persistent source. Remove + it or populate it only in an explicitly transient joined view. + +This fixes the current false `Not voted` state and supports deliberate changes. + +### Operation journal and locks + +Add `src/context/dpns_vote_operations.rs`. + +- Persist an operation before the first broadcast. +- Maintain target locks keyed by network + node + poll. +- Restore unresolved operations and locks on startup. +- Expose read-only snapshots to every UI surface. +- Release a lock only on Confirmed, Rejected, FailedBeforeSubmission, + NotApplied, or explicit cancellation of a not-yet-submitting schedule. +- Keep Unconfirmed targets locked. + +Use per-object KV records and a network-scoped index, following current DET KV +patterns. + +## Backend tasks + +Replace tuple-heavy vote tasks/results with structured variants: + +```rust +ContestedResourceTask::SubmitDpnsVoteOperation(DpnsVoteOperation) +ContestedResourceTask::ReconcileDpnsVoteOperation(DpnsVoteOperationId) +ContestedResourceTask::DispatchDueDpnsVotes + +BackendTaskContext::DpnsVoteOperation(DpnsVoteOperationId) +BackendTaskSuccessResult::DpnsVoteOperationUpdated(DpnsVoteOperationId) +``` + +The backend persists each target update in the shared coordinator. Task results +carry only the operation ID needed for AppState to request repaint and show a +summary. AppState never delivers raw vote outcomes to whichever screen happens +to be visible. + +## Execution algorithm + +1. Validate the draft against current proved state. +2. Remove exact no-ops. +3. Persist the operation and acquire target locks atomically. +4. Group immediate targets by voter/node. +5. Run different voter groups with a small semaphore. +6. Within each voter group, execute targets sequentially: + - read the current nonce; + - construct and validate the transition; + - persist transition hash when the SDK exposes it; + - broadcast; + - wait for result; + - classify the typed outcome. +7. On cause-less post-broadcast wait failure, mark Unconfirmed and enqueue + reconciliation. Do not rebroadcast. +8. Refresh authoritative vote state after each terminal outcome. + +This replaces the current `join_all` by contest, which can race the same voter +nonce. + +## Reconciliation + +Preferred path: + +1. Resume `waitForStateTransitionResult` by persisted transition hash after + [dashpay/platform#4137](https://github.com/dashpay/platform/issues/4137) + exposes a phase-specific retryable wait error. +2. Independently fetch the proved vote using `VoteQuery` or the per-node votes + query. +3. Confirm when the proved choice matches the request. +4. Keep the target Unconfirmed while neither path is definitive. +5. Permit resubmission only after the Platform contract defines a definitive + negative result. Do not infer safety from a transient query failure. + +The existing generic `PlatformResultUnconfirmed` classification remains useful, +but vote operations convert it into target-level coordinator state instead of a +screen-local banner. + +## Scheduling + +Migrate `ScheduledDPNSVote` into the shared target model: + +- scheduled targets are persisted operations with `VoteTiming::Scheduled`; +- edit and cancel mutate the scheduled target atomically while it still holds + its target lock; +- the due dispatcher moves them to Queued and uses the same executor; +- executed state means Confirmed, not merely outer task success; +- rejected, failed, and unconfirmed outcomes remain inspectable; +- the migration is idempotent and preserves legacy records. + +Do not run immediate casting and schedule persistence as unrelated concurrent +backend tasks. One operation owns both kinds of targets. + +## UI state and components + +### Non-rendering state + +Add `src/ui/state/dpns_vote_workspace.rs` for: + +- draft contest choices; +- selected nodes and timing; +- current composer step; +- validation and no-op explanations; +- conversion to a typed operation request. + +### Shared rendering + +Add `src/ui/components/dpns_vote_composer.rs` implementing the three steps from +the UX specification. + +The compact node-detail controls use the same draft/view-model logic and open +the shared review step. They do not implement a separate submit path. + +### Masternodes views + +Extend the Masternodes root state with: + +- Nodes +- Voting +- Scheduled +- Operation detail + +The root observes coordinator snapshots, so progress survives sub-view changes. + +### DPNS integration + +Replace the legacy bulk popup with `Vote with masternodes`, routing selected +contests into the shared Voting workspace. Keep Active, Past, and My Usernames +contest/name browsing in DPNS. + +## Message handling + +Create one vote-specific formatter over typed target outcomes. It produces: + +- banner summary; +- per-target plain-language status; +- technical details attachment; +- recovery action (`Check again`, `Review again`, or none). + +Never parse error strings. Unconfirmed outcomes never offer an immediate retry. + +## Implementation sequence + +### PR A β€” Authoritative state and typed models + +- Add domain types and vote-state store. +- Query proved votes per node. +- Fix active-contest view models to show current vote and allow changes. +- Update user stories: current vote visibility and vote changes. +- Covers VOTE-TC-001 through VOTE-TC-008. + +### PR B β€” Coordinator and safe executor + +- Add operation journal, locks, structured task context/results. +- Serialize targets per node; bound concurrency across nodes. +- Add post-broadcast unconfirmed classification and reconciliation seam. +- Fix scheduled false-success behavior at the executor boundary. +- Covers VOTE-TC-030 through VOTE-TC-056. + +### PR C β€” Shared Voting Center + +- Add shared composer and Masternodes Voting view. +- Integrate quick node flow. +- Add operation detail/progress. +- Route DPNS Active Contests into the shared workspace. +- Covers VOTE-TC-010 through VOTE-TC-025 and VOTE-TC-070 through VOTE-TC-076. + +### PR D β€” Scheduled consolidation and migration + +- Migrate existing schedules into operation targets. +- Replace legacy scheduled execution/status UI. +- Remove obsolete popup/state code after migration coverage passes. +- Covers VOTE-TC-060 through VOTE-TC-065. + +Each PR is independently testable and must not expose two active submit +implementations for the same stage. + +## Verification + +- Unit tests for draft expansion, no-op removal, status transitions, locks, and + storage migration. +- Backend tests with fake SDK seams for nonce ordering and all result classes. +- Kittest coverage for quick, bulk, navigation, disabled-state, and result UX. +- Backend E2E on Testnet for one-node multi-contest and two-node same-contest + batches. +- Restart test with a persisted Unconfirmed operation. +- Formatter and clippy per repository policy. + +## Documentation updates + +- Revise DPN-005, DPN-006, DPN-007, and MN-003 acceptance criteria. +- Correct the protocol note to five votes total: initial vote plus four changes. +- Add a user story for operation recovery across navigation/restart. +- Replace the previous Masternodes design decision that made scheduled voting + undiscoverable from the operator page. From 36cd3e2b72b101159efc423a3b43c9eaf96fcaf2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:06:57 +0000 Subject: [PATCH 02/39] feat(dpns): unify voting operations and UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Load proved current votes, coordinate durable target-level operations, serialize same-node submissions, reconcile ambiguous results without rebroadcasting, and route quick, bulk, and scheduled voting through one reviewed Voting Center. Co-Authored-By: Codex GPT-5 πŸ€– Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../04-development-plan.md | 26 +- docs/user-stories.md | 26 +- src/app.rs | 114 ++- src/backend_task/contested_names/mod.rs | 505 ++++++++++--- .../query_dpns_contested_resources.rs | 5 + .../contested_names/vote_on_dpns_name.rs | 91 ++- src/backend_task/error.rs | 21 + src/backend_task/mod.rs | 14 +- src/context/contested_names_db.rs | 17 +- src/context/dpns_vote_operations.rs | 326 ++++++++ src/context/dpns_vote_state.rs | 263 +++++++ src/context/mod.rs | 24 + src/model/contested_name.rs | 16 +- src/model/dpns_voting.rs | 290 ++++++++ src/model/mod.rs | 1 + src/ui/dpns/dpns_contested_names_screen.rs | 301 ++++---- src/ui/masternodes/card.rs | 27 +- src/ui/masternodes/detail_screen.rs | 202 +++-- src/ui/masternodes/list_screen.rs | 247 +++++- src/ui/masternodes/mod.rs | 1 + src/ui/masternodes/voting_center.rs | 704 ++++++++++++++++++ src/ui/state/dpns_vote_workspace.rs | 158 ++++ src/ui/state/mod.rs | 1 + tests/kittest/masternode_tab.rs | 30 + 24 files changed, 3037 insertions(+), 373 deletions(-) create mode 100644 src/context/dpns_vote_operations.rs create mode 100644 src/context/dpns_vote_state.rs create mode 100644 src/model/dpns_voting.rs create mode 100644 src/ui/masternodes/voting_center.rs create mode 100644 src/ui/state/dpns_vote_workspace.rs diff --git a/docs/ai-design/2026-07-16-dpns-voting-experience/04-development-plan.md b/docs/ai-design/2026-07-16-dpns-voting-experience/04-development-plan.md index cac053d1f..c5ee60834 100644 --- a/docs/ai-design/2026-07-16-dpns-voting-experience/04-development-plan.md +++ b/docs/ai-design/2026-07-16-dpns-voting-experience/04-development-plan.md @@ -172,6 +172,13 @@ The existing generic `PlatformResultUnconfirmed` classification remains useful, but vote operations convert it into target-level coordinator state instead of a screen-local banner. +At the SDK revision used by DET, exact `Vote::fetch(VoteQuery)` is affected by +[dashpay/platform#4138](https://github.com/dashpay/platform/issues/4138). +Reconciliation therefore uses the proved per-identity range query starting at +the exact poll ID and accepts only an exact-key match. Retaining a signed +transition for wait-only recovery remains dependent on +[dashpay/platform#4137](https://github.com/dashpay/platform/issues/4137). + ## Scheduling Migrate `ScheduledDPNSVote` into the shared target model: @@ -235,9 +242,14 @@ Create one vote-specific formatter over typed target outcomes. It produces: Never parse error strings. Unconfirmed outcomes never offer an immediate retry. -## Implementation sequence +## One-PR implementation workstreams + +This experience ships as one pull request. The workstreams below are logical +commit and review boundaries inside that PR, not independently managed PRs. +They land together so no release can expose two submit paths or a coordinator +without every voting entry point using it. -### PR A β€” Authoritative state and typed models +### Workstream A β€” Authoritative state and typed models - Add domain types and vote-state store. - Query proved votes per node. @@ -245,7 +257,7 @@ Never parse error strings. Unconfirmed outcomes never offer an immediate retry. - Update user stories: current vote visibility and vote changes. - Covers VOTE-TC-001 through VOTE-TC-008. -### PR B β€” Coordinator and safe executor +### Workstream B β€” Coordinator and safe executor - Add operation journal, locks, structured task context/results. - Serialize targets per node; bound concurrency across nodes. @@ -253,7 +265,7 @@ Never parse error strings. Unconfirmed outcomes never offer an immediate retry. - Fix scheduled false-success behavior at the executor boundary. - Covers VOTE-TC-030 through VOTE-TC-056. -### PR C β€” Shared Voting Center +### Workstream C β€” Shared Voting Center - Add shared composer and Masternodes Voting view. - Integrate quick node flow. @@ -261,15 +273,15 @@ Never parse error strings. Unconfirmed outcomes never offer an immediate retry. - Route DPNS Active Contests into the shared workspace. - Covers VOTE-TC-010 through VOTE-TC-025 and VOTE-TC-070 through VOTE-TC-076. -### PR D β€” Scheduled consolidation and migration +### Workstream D β€” Scheduled consolidation and migration - Migrate existing schedules into operation targets. - Replace legacy scheduled execution/status UI. - Remove obsolete popup/state code after migration coverage passes. - Covers VOTE-TC-060 through VOTE-TC-065. -Each PR is independently testable and must not expose two active submit -implementations for the same stage. +Each workstream remains independently testable, but the branch is published and +reviewed as one atomic UX change. ## Verification diff --git a/docs/user-stories.md b/docs/user-stories.md index c2fccf397..13fea7609 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -682,9 +682,11 @@ As a power user, I want to review past DPNS contests so that I can see outcomes As a masternode operator, I want to vote on contested DPNS name registrations so that I can participate in network governance. -- Cast, change, or abstain votes (max 4 vote changes per contest). +- See the node's proved current choice before casting, changing, or abstaining. +- A node may vote five times in total per contest: the initial vote plus up to four changes. +- Choosing the current choice submits nothing. - Evonode/masternode identity required. -- Note: The max 4 vote changes constraint is enforced at the Platform protocol level, not validated in the app UI. +- The vote limit is enforced by Platform; DET does not invent a remaining-change count. ### DPN-006: Schedule votes [Implemented] **Persona:** Priya @@ -693,6 +695,8 @@ As a masternode operator, I want to schedule votes for later execution so that I - Set vote to be cast at a future time. - View and manage scheduled votes. +- Scheduled and immediate votes share the same target locks and result states. +- An ambiguous result remains visible for checking and is never automatically rebroadcast. ### DPN-007: Batch voting across contests [Implemented] **Persona:** Priya @@ -700,6 +704,20 @@ As a masternode operator, I want to schedule votes for later execution so that I As a masternode operator, I want to apply voting choices across multiple contests in bulk so that I do not have to vote on each contest individually. - "Set all" option for batch vote assignment. +- Per-node timing overrides and multi-contest selections create exact node Γ— contest targets. +- Immediate and scheduled targets submitted together belong to one operation. + +### DPN-010: Recover an ambiguous vote result [Implemented] +**Persona:** Priya + +As a masternode operator, I want DET to keep checking a submitted vote whose +result was temporarily unavailable so that I do not spend credits by submitting +the same vote again. + +- The exact network, node, and contest remain locked while the result is unconfirmed. +- Navigation and restart preserve the operation and its target-level progress. +- DET reconciles against proved current vote state without rebroadcasting. +- A confirmed match updates the current vote and releases the target lock. ### DPN-008: Set an alias for an owned username [Implemented] **Persona:** Alex, Priya @@ -1492,7 +1510,9 @@ As a masternode operator, I want a card list of my loaded masternodes showing ty As a masternode operator, I want to open a node and vote on the DPNS contests it can vote on, so that I can fulfil my node's governance role. - Clicking a card opens a detail view with a keys summary, the voter identity, and a collapsible DPNS-voting section (collapsed by default, open-contest count shown in its header). -- Votes (Abstain, Lock, or a candidate) are cast inline through the existing DPNS voting backend. +- Every active contest remains visible with the node's proved current vote, including contests where the node already voted. +- Votes (Abstain, Lock, or a candidate) use the shared durable voting operation path. +- The affected controls disable immediately and show progress until the target is confirmed, rejected, or remains under explicit checking. - A node with no voter identity is told a voting key is required, with a way to add one, instead of a raw error. ### MN-004: Remove a masternode [Implemented] diff --git a/src/app.rs b/src/app.rs index b76cf44ce..8b0d13081 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,6 +16,7 @@ use crate::context::connection_status::{ConnectionStatus, OverallConnectionState use crate::context::feature_gate::FeatureGate; use crate::context::migration_status::{MigrationState, MigrationStep}; use crate::database::Database; +use crate::model::dpns_voting::DpnsVoteTargetStatus; use crate::model::settings::AppSettings; use crate::ui::components::passphrase_modal; use crate::ui::components::secret_prompt_host::{ActivePrompt, EguiSecretPromptHost, QueuedPrompt}; @@ -2033,19 +2034,106 @@ impl App for AppState { MessageType::Success, ); } - BackendTaskSuccessResult::CastScheduledVote(ref vote) => { - let _ = self.current_app_context().mark_vote_executed( - vote.voter_id.as_slice(), - vote.contested_name.clone(), - ); - MessageBanner::set_global( - ctx, - "Successfully cast scheduled vote", - MessageType::Success, - ); - self.visible_screen_mut().display_message( - "Successfully cast scheduled vote", - MessageType::Success, + BackendTaskSuccessResult::DpnsVoteOperationUpdated(operation_id) => { + match active_context.dpns_vote_operation(operation_id) { + Ok(Some(operation)) => { + let total = operation.targets.len(); + let confirmed = operation + .targets + .iter() + .filter(|outcome| { + outcome.status == DpnsVoteTargetStatus::Confirmed + }) + .count(); + let scheduled = operation + .targets + .iter() + .filter(|outcome| { + outcome.status == DpnsVoteTargetStatus::Scheduled + }) + .count(); + let unconfirmed = operation + .targets + .iter() + .filter(|outcome| { + outcome.status == DpnsVoteTargetStatus::Unconfirmed + }) + .count(); + let rejected = operation + .targets + .iter() + .filter(|outcome| { + matches!( + outcome.status, + DpnsVoteTargetStatus::Rejected + | DpnsVoteTargetStatus::FailedBeforeSubmission + ) + }) + .count(); + if unconfirmed > 0 { + MessageBanner::set_global( + ctx, + "The vote was submitted, but DET could not confirm the result yet. DET will keep checking. Do not submit it again.", + MessageType::Warning, + ) + .disable_auto_dismiss(); + } else if rejected > 0 { + MessageBanner::set_global( + ctx, + format!( + "{confirmed} of {total} votes were confirmed. Review the remaining {}.", + total.saturating_sub(confirmed) + ), + MessageType::Warning, + ) + .disable_auto_dismiss(); + } else if scheduled == total && total > 0 { + MessageBanner::set_global( + ctx, + format!("{scheduled} votes were scheduled."), + MessageType::Success, + ); + } else if confirmed + scheduled == total + && confirmed > 0 + && scheduled > 0 + { + MessageBanner::set_global( + ctx, + format!( + "{confirmed} votes were cast and {scheduled} votes were scheduled." + ), + MessageType::Success, + ); + } else if confirmed == total && total == 1 { + MessageBanner::set_global( + ctx, + "Vote cast successfully.", + MessageType::Success, + ); + } else if confirmed == total && total > 1 { + MessageBanner::set_global( + ctx, + format!("{confirmed} votes were cast successfully."), + MessageType::Success, + ); + } + } + Ok(None) => { + MessageBanner::set_global( + ctx, + "This node already has that vote. Nothing was submitted.", + MessageType::Info, + ); + } + Err(error) => tracing::warn!( + ?error, + operation_id = %operation_id, + "Could not load DPNS vote operation feedback" + ), + } + self.visible_screen_mut().display_backend_task_result( + &context, + BackendTaskSuccessResult::DpnsVoteOperationUpdated(operation_id), ); self.visible_screen_mut().refresh(); } diff --git a/src/backend_task/contested_names/mod.rs b/src/backend_task/contested_names/mod.rs index 51d1ca3db..87bb17b7c 100644 --- a/src/backend_task/contested_names/mod.rs +++ b/src/backend_task/contested_names/mod.rs @@ -7,14 +7,22 @@ use crate::app::TaskResult; use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; +use crate::model::dpns_voting::{ + DpnsCurrentVoteState, DpnsVoteFailure, DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteTarget, + DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, +}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::request_type::RequestType; use dash_sdk::Sdk; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; -use dash_sdk::platform::Identifier; -use futures::future::join_all; +use dash_sdk::dpp::voting::votes::resource_vote::ResourceVote; +use dash_sdk::dpp::voting::votes::resource_vote::accessors::v0::ResourceVoteGettersV0; +use dash_sdk::drive::query::contested_resource_votes_given_by_identity_query::ContestedResourceVotesGivenByIdentityQuery; +use dash_sdk::platform::{FetchMany, Identifier}; +use futures::{StreamExt, stream}; +use std::collections::BTreeMap; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -26,8 +34,8 @@ const SCHEDULED_VOTE_MAX_LATENESS_MS: u64 = 120_000; #[derive(Debug, Clone, PartialEq)] pub enum ContestedResourceTask { QueryDPNSContests, - VoteOnDPNSNames(Vec<(String, ResourceVoteChoice)>, Vec), - ScheduleDPNSVotes(Vec), + SubmitDpnsVoteOperation(DpnsVoteOperation, Vec), + ReconcileDpnsVoteOperation(DpnsVoteOperationId), CastScheduledVote(ScheduledDPNSVote, Box), /// Sweep the scheduled-vote table and cast every vote that is now due. /// `preserve_eligibility_since_ms` keeps a vote eligible when its normal @@ -49,6 +57,28 @@ pub struct ScheduledDPNSVote { pub executed_successfully: bool, } +fn classify_vote_attempt( + attempt: &Result, +) -> (DpnsVoteTargetStatus, Option) { + match attempt { + Ok(vote_on_dpns_name::DpnsVoteAttempt::Confirmed) => { + (DpnsVoteTargetStatus::Confirmed, None) + } + Ok(vote_on_dpns_name::DpnsVoteAttempt::Unconfirmed(_)) => ( + DpnsVoteTargetStatus::Unconfirmed, + Some(DpnsVoteFailure::ResultUnconfirmed), + ), + Ok(vote_on_dpns_name::DpnsVoteAttempt::Rejected(_)) => ( + DpnsVoteTargetStatus::Rejected, + Some(DpnsVoteFailure::PlatformRejected), + ), + Err(_) => ( + DpnsVoteTargetStatus::FailedBeforeSubmission, + Some(DpnsVoteFailure::SubmissionFailed), + ), + } +} + /// Logs a Drive proof-verification failure raised by a contested-resource query. /// /// No-op unless `e` is a [`dash_sdk::Error::Proof`] carrying a GroveDB proof @@ -87,65 +117,17 @@ impl AppContext { .query_dpns_contested_resources(sdk, sender) .await .map(|_| BackendTaskSuccessResult::None), - ContestedResourceTask::VoteOnDPNSNames(votes, all_voters) => { - let all_voters = &all_voters; - let futures = votes - .iter() - .map(|(name, choice)| { - let cloned_sender = sender.clone(); - let app_context = self.clone(); - - async move { - let result = app_context - .vote_on_dpns_name(name, *choice, all_voters, sdk, cloned_sender) - .await; - - (name, choice, result) - } - }) - .collect::>(); - - let results = join_all(futures).await; - - let final_results = results - .into_iter() - .flat_map( - |(name, vote_choice, det_execution_result)| match det_execution_result { - Ok(BackendTaskSuccessResult::DPNSVoteResults(platform_results)) => { - platform_results - } - Err(det_err) => { - vec![( - name.clone(), - *vote_choice, - Err(std::sync::Arc::new(det_err)), - )] - } - Ok(_) => { - vec![(name.clone(), *vote_choice, Ok(()))] - } - }, - ) - .collect::>(); - - Ok(BackendTaskSuccessResult::DPNSVoteResults(final_results)) + ContestedResourceTask::SubmitDpnsVoteOperation(operation, voters) => { + self.execute_dpns_vote_operation(operation, voters, sdk) + .await } - ContestedResourceTask::ScheduleDPNSVotes(scheduled_votes) => { - self.insert_scheduled_votes(&scheduled_votes)?; - Ok(BackendTaskSuccessResult::ScheduledVotes) + ContestedResourceTask::ReconcileDpnsVoteOperation(operation_id) => { + self.reconcile_dpns_vote_operation(operation_id, sdk).await } ContestedResourceTask::CastScheduledVote(scheduled_vote, voter) => { - let result = self - .vote_on_dpns_name( - &scheduled_vote.contested_name, - scheduled_vote.choice, - &[*voter], - sdk, - sender, - ) - .await?; - confirm_scheduled_vote_result(result)?; - Ok(BackendTaskSuccessResult::CastScheduledVote(scheduled_vote)) + let operation = self.operation_for_scheduled_vote(&scheduled_vote, &voter)?; + self.execute_dpns_vote_operation(operation, vec![*voter], sdk) + .await } ContestedResourceTask::CastDueScheduledVotes { preserve_eligibility_since_ms, @@ -158,6 +140,7 @@ impl AppContext { }), ContestedResourceTask::ClearAllScheduledVotes => { self.clear_all_scheduled_votes()?; + self.cancel_all_scheduled_dpns_vote_targets()?; Ok(BackendTaskSuccessResult::Refresh) } ContestedResourceTask::ClearExecutedScheduledVotes => { @@ -166,29 +149,319 @@ impl AppContext { } ContestedResourceTask::DeleteScheduledVote(voter_id, contested_name) => { self.delete_scheduled_vote(voter_id.as_slice(), &contested_name)?; + self.cancel_scheduled_dpns_vote_target(voter_id, &contested_name)?; Ok(BackendTaskSuccessResult::Refresh) } } } + fn dpns_vote_target( + &self, + voter: &QualifiedIdentity, + name: &str, + choice: ResourceVoteChoice, + timing: VoteTiming, + require_current_state: bool, + ) -> Result { + let voter_id = voter.identity.id(); + let vote_poll_id = self.dpns_vote_poll_id(name)?; + let current_choice = match self.dpns_current_vote_state(voter_id, vote_poll_id)? { + DpnsCurrentVoteState::Available(choice) => choice, + DpnsCurrentVoteState::Checking | DpnsCurrentVoteState::Unavailable + if require_current_state => + { + return Err(TaskError::DpnsCurrentVoteUnavailable); + } + DpnsCurrentVoteState::Checking | DpnsCurrentVoteState::Unavailable => None, + }; + Ok(DpnsVoteTarget { + key: DpnsVoteTargetKey { + network: self.network, + voter_id, + vote_poll_id, + }, + voter_alias: voter.alias.clone(), + contested_name: name.to_owned(), + requested_choice: choice, + current_choice, + timing, + }) + } + + fn operation_for_scheduled_vote( + &self, + scheduled_vote: &ScheduledDPNSVote, + voter: &QualifiedIdentity, + ) -> Result { + if let Some(mut operation) = self.dpns_vote_operations()?.into_iter().find(|operation| { + operation.targets.iter().any(|outcome| { + outcome.target.key.voter_id == scheduled_vote.voter_id + && outcome.target.contested_name == scheduled_vote.contested_name + && outcome.status == DpnsVoteTargetStatus::Scheduled + }) + }) { + for outcome in &mut operation.targets { + if outcome.target.key.voter_id == scheduled_vote.voter_id + && outcome.target.contested_name == scheduled_vote.contested_name + && outcome.status == DpnsVoteTargetStatus::Scheduled + { + outcome.status = DpnsVoteTargetStatus::Queued; + } + } + return Ok(operation); + } + + let target = self.dpns_vote_target( + voter, + &scheduled_vote.contested_name, + scheduled_vote.choice, + VoteTiming::Scheduled(scheduled_vote.unix_timestamp), + false, + )?; + let mut operation = DpnsVoteOperation::new(vec![target]); + operation.targets[0].status = DpnsVoteTargetStatus::Queued; + Ok(operation) + } + + async fn execute_dpns_vote_operation( + self: &Arc, + operation: DpnsVoteOperation, + voters: Vec, + sdk: &Sdk, + ) -> Result { + if operation.targets.is_empty() { + return Ok(BackendTaskSuccessResult::DpnsVoteOperationUpdated( + operation.id, + )); + } + if self.dpns_vote_operation(operation.id)?.is_some() { + self.update_dpns_vote_operation(&operation)?; + } else { + let scheduled_votes = operation + .targets + .iter() + .filter_map(|outcome| match outcome.target.timing { + VoteTiming::Scheduled(unix_timestamp) => Some(ScheduledDPNSVote { + contested_name: outcome.target.contested_name.clone(), + voter_id: outcome.target.key.voter_id, + choice: outcome.target.requested_choice, + unix_timestamp, + executed_successfully: false, + }), + VoteTiming::Now => None, + }) + .collect::>(); + if !scheduled_votes.is_empty() { + self.insert_scheduled_votes(&scheduled_votes)?; + } + self.insert_dpns_vote_operation(&operation)?; + } + + let voters_by_id: BTreeMap = voters + .into_iter() + .map(|voter| (voter.identity.id(), voter)) + .collect(); + let mut groups: BTreeMap> = BTreeMap::new(); + for outcome in operation + .targets + .iter() + .filter(|outcome| outcome.status == DpnsVoteTargetStatus::Queued) + { + groups + .entry(outcome.target.key.voter_id) + .or_default() + .push(outcome.target.clone()); + } + + const MAX_CONCURRENT_VOTERS: usize = 4; + stream::iter(groups) + .map(|(voter_id, targets)| { + let app_context = Arc::clone(self); + let sdk = sdk.clone(); + let voter = voters_by_id.get(&voter_id).cloned(); + let operation_id = operation.id; + async move { + let Some(voter) = voter else { + for target in targets { + app_context.update_dpns_vote_target( + operation_id, + &target.key, + DpnsVoteTargetStatus::FailedBeforeSubmission, + Some(DpnsVoteFailure::SubmissionFailed), + )?; + } + return Ok::<(), TaskError>(()); + }; + + // One voter's targets are deliberately sequential: PutVote + // obtains and consumes the same masternode nonce. + for target in targets { + app_context.update_dpns_vote_target( + operation_id, + &target.key, + DpnsVoteTargetStatus::Submitting, + None, + )?; + let attempt = app_context + .submit_dpns_vote( + &target.contested_name, + target.requested_choice, + &voter, + &sdk, + ) + .await; + let (status, failure) = classify_vote_attempt(&attempt); + match attempt { + Ok(vote_on_dpns_name::DpnsVoteAttempt::Confirmed) => { + app_context.cache_confirmed_dpns_vote( + target.key.voter_id, + target.key.vote_poll_id, + target.requested_choice, + )?; + if matches!(target.timing, VoteTiming::Scheduled(_)) { + app_context.mark_vote_executed( + target.key.voter_id.as_slice(), + target.contested_name.clone(), + )?; + } + } + Ok(vote_on_dpns_name::DpnsVoteAttempt::Unconfirmed(error)) => { + tracing::warn!( + ?error, + voter_id = %target.key.voter_id, + contested_name = %target.contested_name, + "DPNS vote was submitted but remains unconfirmed" + ); + } + Ok(vote_on_dpns_name::DpnsVoteAttempt::Rejected(error)) => { + tracing::warn!( + ?error, + voter_id = %target.key.voter_id, + contested_name = %target.contested_name, + "Platform rejected a DPNS vote" + ); + } + Err(error) => { + tracing::warn!( + ?error, + voter_id = %target.key.voter_id, + contested_name = %target.contested_name, + "DPNS vote failed before a confirmed submission" + ); + } + } + app_context.update_dpns_vote_target( + operation_id, + &target.key, + status, + failure, + )?; + } + Ok(()) + } + }) + .buffer_unordered(MAX_CONCURRENT_VOTERS) + .collect::>>() + .await + .into_iter() + .collect::, _>>()?; + + Ok(BackendTaskSuccessResult::DpnsVoteOperationUpdated( + operation.id, + )) + } + + async fn reconcile_dpns_vote_operation( + &self, + operation_id: DpnsVoteOperationId, + sdk: &Sdk, + ) -> Result { + let Some(operation) = self.dpns_vote_operation(operation_id)? else { + return Ok(BackendTaskSuccessResult::DpnsVoteOperationUpdated( + operation_id, + )); + }; + for outcome in operation + .targets + .iter() + .filter(|outcome| outcome.status == DpnsVoteTargetStatus::Unconfirmed) + { + let poll_id = outcome.target.key.vote_poll_id; + let query = ContestedResourceVotesGivenByIdentityQuery { + identity_id: outcome.target.key.voter_id, + offset: None, + limit: Some(1), + start_at: Some((poll_id.to_buffer(), true)), + order_ascending: true, + }; + match ResourceVote::fetch_many(sdk, query).await { + Ok(votes) + if votes + .get(&poll_id) + .and_then(Option::as_ref) + .is_some_and(|vote| { + vote.resource_vote_choice() == outcome.target.requested_choice + }) => + { + self.cache_confirmed_dpns_vote( + outcome.target.key.voter_id, + poll_id, + outcome.target.requested_choice, + )?; + self.update_dpns_vote_target( + operation_id, + &outcome.target.key, + DpnsVoteTargetStatus::Confirmed, + None, + )?; + } + Ok(_) => {} + Err(error) => tracing::warn!( + ?error, + operation_id = %operation_id, + voter_id = %outcome.target.key.voter_id, + contested_name = %outcome.target.contested_name, + "Could not reconcile an unconfirmed DPNS vote" + ), + } + } + Ok(BackendTaskSuccessResult::DpnsVoteOperationUpdated( + operation_id, + )) + } + /// Cast every scheduled vote that is now due, off the UI thread. /// /// Queries the scheduled-vote table, keeps the votes whose time has arrived /// (and are not already executed or stale beyond /// [`SCHEDULED_VOTE_MAX_LATENESS_MS`]), pairs each with its local voting /// identity, and casts them independently so one failure cannot abort the - /// rest. Emits [`ScheduledVotesInProgress`] before casting and a - /// [`CastScheduledVote`] per success so the Scheduled Votes screen can - /// reflect progress via `display_task_result`. + /// rest. Emits [`ScheduledVotesInProgress`] before casting; terminal state + /// is persisted in the shared operation journal and legacy executed flag. /// /// [`ScheduledVotesInProgress`]: BackendTaskSuccessResult::ScheduledVotesInProgress - /// [`CastScheduledVote`]: BackendTaskSuccessResult::CastScheduledVote async fn cast_due_scheduled_votes( self: &Arc, sdk: &Sdk, sender: crate::utils::egui_mpsc::SenderAsync, preserve_eligibility_since_ms: Option, ) -> Result { + for operation in self + .dpns_vote_operations()? + .into_iter() + .filter(|operation| { + operation + .targets + .iter() + .any(|outcome| outcome.status == DpnsVoteTargetStatus::Unconfirmed) + }) + { + let result = self + .reconcile_dpns_vote_operation(operation.id, sdk) + .await?; + let _ = sender.send(TaskResult::unattributed_success(result)).await; + } + let now_ms = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as u64) @@ -241,34 +514,41 @@ impl AppContext { )) .await; + let mut groups: BTreeMap> = + BTreeMap::new(); for (vote, voter) in castable { - let result = self - .vote_on_dpns_name( - &vote.contested_name, - vote.choice, - &[voter], - sdk, - sender.clone(), - ) - .await - .and_then(confirm_scheduled_vote_result); - match result { - Ok(()) => { - sender - .send(TaskResult::unattributed_success( - BackendTaskSuccessResult::CastScheduledVote(vote), - )) - .await - .map_err(|_| TaskError::InternalSendError)?; - } - Err(e) => { - tracing::error!( - error = %e, - contested_name = %vote.contested_name, - "Failed to cast a due scheduled vote; leaving it for the next sweep" - ); - first_error.get_or_insert(e); + groups.entry(vote.voter_id).or_default().push((vote, voter)); + } + let results = stream::iter(groups) + .map(|(_, scheduled)| { + let app_context = Arc::clone(self); + let sdk = sdk.clone(); + async move { + let mut results = Vec::with_capacity(scheduled.len()); + for (vote, voter) in scheduled { + let result = match app_context.operation_for_scheduled_vote(&vote, &voter) { + Ok(operation) => app_context + .execute_dpns_vote_operation(operation, vec![voter], &sdk) + .await + .map(|_| ()), + Err(error) => Err(error), + }; + results.push((vote, result)); + } + results } + }) + .buffer_unordered(4) + .collect::>() + .await; + for (vote, result) in results.into_iter().flatten() { + if let Err(error) = result { + tracing::error!( + error = %error, + contested_name = %vote.contested_name, + "Failed to cast a due scheduled vote; leaving it for the next sweep" + ); + first_error.get_or_insert(error); } } if let Some(error) = first_error { @@ -282,19 +562,6 @@ impl AppContext { } } -fn confirm_scheduled_vote_result(result: BackendTaskSuccessResult) -> Result<(), TaskError> { - let BackendTaskSuccessResult::DPNSVoteResults(results) = result else { - return Err(TaskError::ScheduledVoteResultUnavailable); - }; - if results.is_empty() { - return Err(TaskError::ScheduledVoteResultUnavailable); - } - for (_, _, result) in results { - result.map_err(|source| TaskError::ScheduledVoteRejected { source })?; - } - Ok(()) -} - fn scheduled_vote_is_due( scheduled_at_ms: u64, executed_successfully: bool, @@ -311,26 +578,30 @@ fn scheduled_vote_is_due( mod tests { use super::*; + /// VOTE-TC-033: an inner scheduled rejection is never classified as success. #[test] - fn a_nested_platform_rejection_is_not_a_successful_scheduled_vote() { - let result = BackendTaskSuccessResult::DPNSVoteResults(vec![( - "alice".to_string(), - ResourceVoteChoice::Lock, - Err(Arc::new(TaskError::InternalSendError)), - )]); - - assert!(matches!( - confirm_scheduled_vote_result(result), - Err(TaskError::ScheduledVoteRejected { .. }) + fn scheduled_inner_rejection_needs_attention() { + let attempt = Ok(vote_on_dpns_name::DpnsVoteAttempt::Rejected( + TaskError::DpnsVoteTargetBusy, )); + assert_eq!( + classify_vote_attempt(&attempt), + ( + DpnsVoteTargetStatus::Rejected, + Some(DpnsVoteFailure::PlatformRejected) + ) + ); } + /// VOTE-TC-034/052: an ambiguous post-broadcast result stays locked. #[test] - fn an_empty_nested_result_is_not_a_successful_scheduled_vote() { - assert!(matches!( - confirm_scheduled_vote_result(BackendTaskSuccessResult::DPNSVoteResults(Vec::new())), - Err(TaskError::ScheduledVoteResultUnavailable) + fn cause_less_wait_failure_is_unconfirmed_not_retryable() { + let attempt = Ok(vote_on_dpns_name::DpnsVoteAttempt::Unconfirmed( + TaskError::DpnsVoteTargetBusy, )); + let (status, _) = classify_vote_attempt(&attempt); + assert_eq!(status, DpnsVoteTargetStatus::Unconfirmed); + assert!(status.holds_lock()); } /// Migration extends only eligibility windows that overlap its wait. diff --git a/src/backend_task/contested_names/query_dpns_contested_resources.rs b/src/backend_task/contested_names/query_dpns_contested_resources.rs index 68bcdb49a..6594867c5 100644 --- a/src/backend_task/contested_names/query_dpns_contested_resources.rs +++ b/src/backend_task/contested_names/query_dpns_contested_resources.rs @@ -212,6 +212,11 @@ impl AppContext { } } + // Publish contests and every loaded node's proved current votes as one + // completed refresh snapshot. Per-node failures are stored explicitly + // as unavailable instead of being mistaken for "Not voted". + self.refresh_dpns_vote_states(sdk).await; + sender .send(TaskResult::unattributed_success( BackendTaskSuccessResult::RefreshedDpnsContests, diff --git a/src/backend_task/contested_names/vote_on_dpns_name.rs b/src/backend_task/contested_names/vote_on_dpns_name.rs index 23ac03f5a..0ecaa3b80 100644 --- a/src/backend_task/contested_names/vote_on_dpns_name.rs +++ b/src/backend_task/contested_names/vote_on_dpns_name.rs @@ -1,5 +1,3 @@ -use crate::app::TaskResult; -use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; @@ -21,6 +19,13 @@ use dash_sdk::platform::transition::vote::PutVote; use dash_sdk::query_types::ContestedResource; use std::sync::Arc; +#[derive(Debug)] +pub(super) enum DpnsVoteAttempt { + Confirmed, + Unconfirmed(TaskError), + Rejected(TaskError), +} + /// Build `[Value::from("dash"), Value::Text(normalized_label.to_owned())]` for a DPNS vote poll. /// /// Caller must pre-normalize the label via `convert_to_homograph_safe_chars` @@ -33,19 +38,13 @@ fn dpns_vote_poll_index_values(normalized_label: &str) -> Vec { } impl AppContext { - pub(super) async fn vote_on_dpns_name( + pub(super) async fn submit_dpns_vote( self: &Arc, name: &str, vote_choice: ResourceVoteChoice, - voters: &[QualifiedIdentity], + qualified_identity: &QualifiedIdentity, sdk: &Sdk, - sender: crate::utils::egui_mpsc::SenderAsync, - ) -> Result { - sender - .send(TaskResult::Refresh) - .await - .map_err(|_| TaskError::InternalSendError)?; - + ) -> Result { let data_contract = self.dpns_contract.as_ref(); let document_type = data_contract .document_type_for_name("domain") @@ -95,37 +94,49 @@ impl AppContext { }); } - let mut vote_results = vec![]; - - for qualified_identity in voters.iter() { - if let Some((_, public_key)) = &qualified_identity.associated_voter_identity { - let resource_vote = ResourceVoteV0 { - vote_poll: vote_poll.clone().into(), - resource_vote_choice: vote_choice, - }; - let vote = Vote::ResourceVote(ResourceVote::V0(resource_vote)); - - let result = vote - .put_to_platform_and_wait_for_response( - qualified_identity.identity.id(), - public_key, - sdk, - qualified_identity, - None, - ) - .await - .map(|_| ()) - .map_err(|e| std::sync::Arc::new(TaskError::from(e))); - - vote_results.push((name.to_owned(), vote_choice, result)); - } else { - return Err(TaskError::NoVotingIdentity { - identity_id: qualified_identity.identity.id().to_string(Encoding::Base58), - }); + let Some((_, public_key)) = &qualified_identity.associated_voter_identity else { + return Err(TaskError::NoVotingIdentity { + identity_id: qualified_identity.identity.id().to_string(Encoding::Base58), + }); + }; + let resource_vote = ResourceVoteV0 { + vote_poll: vote_poll.into(), + resource_vote_choice: vote_choice, + }; + let vote = Vote::ResourceVote(ResourceVote::V0(resource_vote)); + + match vote + .put_to_platform_and_wait_for_response( + qualified_identity.identity.id(), + public_key, + sdk, + qualified_identity, + None, + ) + .await + { + Ok(_) => Ok(DpnsVoteAttempt::Confirmed), + Err(error) => { + let unconfirmed = matches!( + &error, + dash_sdk::Error::StateTransitionBroadcastError(broadcast_error) + if broadcast_error.cause.is_none() + ); + let rejected = matches!( + &error, + dash_sdk::Error::StateTransitionBroadcastError(broadcast_error) + if broadcast_error.cause.is_some() + ); + let error = TaskError::from(error); + if unconfirmed { + Ok(DpnsVoteAttempt::Unconfirmed(error)) + } else if rejected { + Ok(DpnsVoteAttempt::Rejected(error)) + } else { + Err(error) + } } } - - Ok(BackendTaskSuccessResult::DPNSVoteResults(vote_results)) } } diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index a11e92e26..237e503ff 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -651,6 +651,27 @@ pub enum TaskError { source: crate::wallet_backend::KvAdapterError, }, + /// A DPNS vote operation or current-vote snapshot could not be persisted. + #[error( + "Could not save DPNS voting progress. Check available disk space and try again." + )] + DpnsVoteOperationStorage { + #[source] + source: crate::wallet_backend::KvAdapterError, + }, + + /// Another unresolved operation already owns this exact node and contest. + #[error( + "This node's vote for this name is already in progress. Wait for its result or check again." + )] + DpnsVoteTargetBusy, + + /// Current proved state is required to suppress duplicate/no-op votes safely. + #[error( + "This node's current vote could not be checked. Refresh vote state before submitting." + )] + DpnsCurrentVoteUnavailable, + /// A local identity record could not be read or written in the /// per-network wallet k/v store. #[error("Could not access your saved identities. Check available disk space and try again.")] diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index e2565b284..1d0afc457 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -13,6 +13,7 @@ use crate::context::AppContext; use crate::context::feature_gate::FeatureGate; use crate::context::identity_load_registry::IdentityLoadToken; use crate::model::masternode_input::decode_identity_id; +use crate::model::dpns_voting::DpnsVoteOperationId; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::key_wallet::bip32::DerivationPath; @@ -31,7 +32,6 @@ use dash_sdk::dpp::group::group_action::GroupAction; use dash_sdk::dpp::prelude::DataContract; use dash_sdk::dpp::state_transition::StateTransition; use dash_sdk::dpp::tokens::token_pricing_schedule::TokenPricingSchedule; -use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dash_sdk::platform::proto::get_documents_request::get_documents_request_v0::Start; use dash_sdk::platform::{Document, DocumentQuery, Identifier}; use dash_sdk::query_types::{Documents, IndexMap}; @@ -257,6 +257,8 @@ pub enum BackendTaskContext { TokenRewardEstimate(IdentityTokenIdentifier), /// The destructive per-network database clear. ClearNetworkDatabase, + /// One durable DPNS vote operation. + DpnsVoteOperation(DpnsVoteOperationId), /// A known backend task that needs no finer UI correlation. Other, /// An error emitted without an originating backend task. @@ -326,6 +328,13 @@ impl From<&BackendTask> for BackendTaskContext { _ => Self::Other, }, BackendTask::SystemTask(SystemTask::ClearNetworkDatabase) => Self::ClearNetworkDatabase, + BackendTask::ContestedResourceTask(ContestedResourceTask::SubmitDpnsVoteOperation( + operation, + _, + )) => Self::DpnsVoteOperation(operation.id), + BackendTask::ContestedResourceTask( + ContestedResourceTask::ReconcileDpnsVoteOperation(operation_id), + ) => Self::DpnsVoteOperation(*operation_id), _ => Self::Other, } } @@ -368,8 +377,6 @@ pub enum BackendTaskSuccessResult { CoreItem(CoreItem), RegisteredIdentity(QualifiedIdentity, FeeResult), ToppedUpIdentity(QualifiedIdentity, FeeResult), - DPNSVoteResults(Vec<(String, ResourceVoteChoice, Result<(), Arc>)>), - CastScheduledVote(ScheduledDPNSVote), /// A scheduled-vote sweep finished without a query, identity or Platform /// failure. The app uses this acknowledgement to retire a preserved /// migration eligibility cutoff only after the recovery attempt succeeds. @@ -377,6 +384,7 @@ pub enum BackendTaskSuccessResult { network: Network, preserve_eligibility_since_ms: Option, }, + DpnsVoteOperationUpdated(DpnsVoteOperationId), /// The scheduled votes that the `CastDueScheduledVotes` sweep is about to /// cast this cycle, so the Scheduled Votes screen can mark them in progress. ScheduledVotesInProgress(Vec), diff --git a/src/context/contested_names_db.rs b/src/context/contested_names_db.rs index 44630a9d9..199fe03bd 100644 --- a/src/context/contested_names_db.rs +++ b/src/context/contested_names_db.rs @@ -208,11 +208,23 @@ impl AppContext { return Ok(crate::model::contested_name::MasternodeContestSummary::default()); }; - let open_contest_count = self - .ongoing_contested_names()? + let contests = self.ongoing_contested_names()?; + let open_contest_count = contests .iter() .filter(|contest| contest.is_open_for_voter(&voter_id)) .count(); + let needs_vote_count = contests + .iter() + .filter(|contest| contest.is_open_for_voter(&voter_id)) + .filter(|contest| { + self.dpns_vote_poll_id(&contest.normalized_contested_name) + .ok() + .and_then(|poll_id| self.dpns_current_vote_state(voter_id, poll_id).ok()) + == Some(crate::model::dpns_voting::DpnsCurrentVoteState::Available( + None, + )) + }) + .count(); let has_scheduled_vote = self .get_scheduled_votes()? @@ -221,6 +233,7 @@ impl AppContext { Ok(crate::model::contested_name::MasternodeContestSummary { open_contest_count, + needs_vote_count, has_scheduled_vote, }) } diff --git a/src/context/dpns_vote_operations.rs b/src/context/dpns_vote_operations.rs new file mode 100644 index 000000000..79494ac45 --- /dev/null +++ b/src/context/dpns_vote_operations.rs @@ -0,0 +1,326 @@ +//! Durable DPNS vote operation journal and exact-target lock registry. + +use super::AppContext; +use crate::backend_task::error::TaskError; +use crate::model::dpns_voting::{ + DpnsVoteFailure, DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteTarget, DpnsVoteTargetKey, + DpnsVoteTargetStatus, VoteTiming, +}; +use crate::wallet_backend::{DetKv, DetScope, KvAdapterError}; +use dash_sdk::platform::Identifier; + +const OPERATION_INDEX_KEY: &str = "det:dpns_vote_operations:v1"; +const OPERATION_KEY_PREFIX: &str = "det:dpns_vote_operation:v1:"; + +fn operation_key(id: DpnsVoteOperationId) -> String { + format!("{OPERATION_KEY_PREFIX}{id}") +} + +fn operation_err(source: KvAdapterError) -> TaskError { + TaskError::DpnsVoteOperationStorage { source } +} + +fn load_operation_ids(kv: &DetKv) -> Result, TaskError> { + kv.get(DetScope::Global, OPERATION_INDEX_KEY) + .map(|ids| ids.unwrap_or_default()) + .map_err(operation_err) +} + +fn load_operations(kv: &DetKv) -> Result, TaskError> { + let mut operations = Vec::new(); + for bytes in load_operation_ids(kv)? { + let id = DpnsVoteOperationId::from_bytes(bytes); + match kv.get(DetScope::Global, &operation_key(id)) { + Ok(Some(operation)) => operations.push(operation), + Ok(None) => {} + Err(error) => { + tracing::warn!( + operation_id = %id, + error = ?error, + "Skipping unreadable DPNS vote operation" + ); + } + } + } + Ok(operations) +} + +fn persist_operation(kv: &DetKv, operation: &DpnsVoteOperation) -> Result<(), TaskError> { + let conflict = load_operations(kv)?.iter().any(|existing| { + existing.id != operation.id + && existing.targets.iter().any(|existing_outcome| { + existing_outcome.status.holds_lock() + && operation.targets.iter().any(|outcome| { + outcome.status.holds_lock() + && outcome.target.key == existing_outcome.target.key + }) + }) + }); + if conflict { + return Err(TaskError::DpnsVoteTargetBusy); + } + + kv.put(DetScope::Global, &operation_key(operation.id), operation) + .map_err(operation_err)?; + let mut ids = load_operation_ids(kv)?; + if !ids.contains(&operation.id.to_bytes()) { + ids.push(operation.id.to_bytes()); + kv.put(DetScope::Global, OPERATION_INDEX_KEY, &ids) + .map_err(operation_err)?; + } + Ok(()) +} + +impl AppContext { + /// Persist a reviewed operation and atomically acquire all unresolved locks. + pub fn insert_dpns_vote_operation( + &self, + operation: &DpnsVoteOperation, + ) -> Result<(), TaskError> { + if operation + .targets + .iter() + .any(|outcome| outcome.target.key.network != self.network) + { + return Err(TaskError::DpnsVoteTargetBusy); + } + let _guard = self + .dpns_vote_operation_guard + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let kv = self.det_kv()?; + let mut existing_operations = load_operations(&kv)?; + for existing in &mut existing_operations { + let mut changed = false; + for existing_outcome in &mut existing.targets { + if existing_outcome.status == DpnsVoteTargetStatus::Scheduled + && operation.targets.iter().any(|new_outcome| { + new_outcome.status == DpnsVoteTargetStatus::Scheduled + && new_outcome.target.key == existing_outcome.target.key + }) + { + existing_outcome.status = DpnsVoteTargetStatus::NotApplied; + changed = true; + } + } + if changed { + kv.put(DetScope::Global, &operation_key(existing.id), existing) + .map_err(operation_err)?; + } + } + persist_operation(&kv, operation) + } + + /// Persist updated target statuses while retaining the original operation ID. + pub fn update_dpns_vote_operation( + &self, + operation: &DpnsVoteOperation, + ) -> Result<(), TaskError> { + let _guard = self + .dpns_vote_operation_guard + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + persist_operation(&self.det_kv()?, operation) + } + + /// Load every operation for this network, including completed history. + pub fn dpns_vote_operations(&self) -> Result, TaskError> { + let _guard = self + .dpns_vote_operation_guard + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let kv = self.det_kv()?; + let mut operations = load_operations(&kv)?; + for legacy in self.get_scheduled_votes()? { + if operations.iter().any(|operation| { + operation.targets.iter().any(|outcome| { + outcome.target.key.voter_id == legacy.voter_id + && outcome.target.contested_name == legacy.contested_name + }) + }) { + continue; + } + let mut operation = DpnsVoteOperation::new(vec![DpnsVoteTarget { + key: DpnsVoteTargetKey { + network: self.network, + voter_id: legacy.voter_id, + vote_poll_id: self.dpns_vote_poll_id(&legacy.contested_name)?, + }, + voter_alias: None, + contested_name: legacy.contested_name, + requested_choice: legacy.choice, + current_choice: None, + timing: VoteTiming::Scheduled(legacy.unix_timestamp), + }]); + if legacy.executed_successfully { + operation.targets[0].status = DpnsVoteTargetStatus::Confirmed; + } + persist_operation(&kv, &operation)?; + operations.push(operation); + } + Ok(operations) + } + + /// Load one operation by its stable ID. + pub fn dpns_vote_operation( + &self, + id: DpnsVoteOperationId, + ) -> Result, TaskError> { + self.det_kv()? + .get(DetScope::Global, &operation_key(id)) + .map_err(operation_err) + } + + /// Return the unresolved status that currently locks an exact target. + pub fn dpns_vote_target_status( + &self, + key: &DpnsVoteTargetKey, + ) -> Result, TaskError> { + Ok(self + .dpns_vote_operations()? + .into_iter() + .flat_map(|operation| operation.targets) + .find(|outcome| outcome.target.key == *key && outcome.status.holds_lock()) + .map(|outcome| outcome.status)) + } + + /// Atomically update one target in the durable journal. + pub(crate) fn update_dpns_vote_target( + &self, + operation_id: DpnsVoteOperationId, + key: &DpnsVoteTargetKey, + status: DpnsVoteTargetStatus, + failure: Option, + ) -> Result<(), TaskError> { + let _guard = self + .dpns_vote_operation_guard + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let kv = self.det_kv()?; + let Some(mut operation): Option = kv + .get(DetScope::Global, &operation_key(operation_id)) + .map_err(operation_err)? + else { + return Ok(()); + }; + if let Some(outcome) = operation + .targets + .iter_mut() + .find(|outcome| outcome.target.key == *key) + { + outcome.status = status; + outcome.failure = failure; + } + persist_operation(&kv, &operation) + } + + /// Release a not-yet-submitting scheduled target after explicit cancellation. + pub(crate) fn cancel_scheduled_dpns_vote_target( + &self, + voter_id: Identifier, + contested_name: &str, + ) -> Result<(), TaskError> { + let operations = self.dpns_vote_operations()?; + for mut operation in operations { + let mut changed = false; + for outcome in &mut operation.targets { + if outcome.target.key.voter_id == voter_id + && outcome.target.contested_name == contested_name + && outcome.status == DpnsVoteTargetStatus::Scheduled + { + outcome.status = DpnsVoteTargetStatus::NotApplied; + changed = true; + } + } + if changed { + self.update_dpns_vote_operation(&operation)?; + } + } + Ok(()) + } + + /// Release every not-yet-submitting scheduled target on this network. + pub(crate) fn cancel_all_scheduled_dpns_vote_targets(&self) -> Result<(), TaskError> { + let operations = self.dpns_vote_operations()?; + for mut operation in operations { + let mut changed = false; + for outcome in &mut operation.targets { + if outcome.status == DpnsVoteTargetStatus::Scheduled { + outcome.status = DpnsVoteTargetStatus::NotApplied; + changed = true; + } + } + if changed { + self.update_dpns_vote_operation(&operation)?; + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::dpns_voting::{DpnsVoteTarget, VoteTiming}; + use crate::wallet_backend::kv_test_support::InMemoryKv; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + use dash_sdk::platform::Identifier; + use std::sync::Arc; + + fn kv() -> DetKv { + DetKv::from_store(Arc::new(InMemoryKv::default())) + } + + fn operation(status: DpnsVoteTargetStatus) -> DpnsVoteOperation { + let mut operation = DpnsVoteOperation::new(vec![DpnsVoteTarget { + key: DpnsVoteTargetKey { + network: Network::Testnet, + voter_id: Identifier::from([1; 32]), + vote_poll_id: Identifier::from([2; 32]), + }, + voter_alias: Some("Eve".to_owned()), + contested_name: "dominguez".to_owned(), + requested_choice: ResourceVoteChoice::Lock, + current_choice: None, + timing: VoteTiming::Now, + }]); + operation.targets[0].status = status; + operation + } + + /// VOTE-TC-040/041: one unresolved target cannot be inserted twice. + #[test] + fn unresolved_target_rejects_a_competing_operation() { + let kv = kv(); + persist_operation(&kv, &operation(DpnsVoteTargetStatus::Submitting)).unwrap(); + + let error = persist_operation(&kv, &operation(DpnsVoteTargetStatus::Queued)) + .expect_err("the exact target must stay locked"); + assert!(matches!(error, TaskError::DpnsVoteTargetBusy)); + } + + /// VOTE-TC-044: reloading the journal reconstructs an Unconfirmed lock. + #[test] + fn unconfirmed_lock_survives_journal_reload() { + let kv = kv(); + let operation = operation(DpnsVoteTargetStatus::Unconfirmed); + persist_operation(&kv, &operation).unwrap(); + + let restored = load_operations(&kv).unwrap(); + assert_eq!(restored, vec![operation]); + assert!(restored[0].targets[0].status.holds_lock()); + } + + /// VOTE-TC-042: a different poll remains usable. + #[test] + fn unrelated_target_can_be_persisted() { + let kv = kv(); + persist_operation(&kv, &operation(DpnsVoteTargetStatus::Confirming)).unwrap(); + let mut unrelated = operation(DpnsVoteTargetStatus::Queued); + unrelated.targets[0].target.key.vote_poll_id = Identifier::from([3; 32]); + + persist_operation(&kv, &unrelated).unwrap(); + assert_eq!(load_operations(&kv).unwrap().len(), 2); + } +} diff --git a/src/context/dpns_vote_state.rs b/src/context/dpns_vote_state.rs new file mode 100644 index 000000000..4de03e981 --- /dev/null +++ b/src/context/dpns_vote_state.rs @@ -0,0 +1,263 @@ +//! Proved, per-node DPNS current-vote snapshots. + +use super::AppContext; +use crate::backend_task::error::TaskError; +use crate::model::dpns_voting::DpnsCurrentVoteState; +use crate::wallet_backend::{DetKv, DetScope, KvAdapterError}; +use dash_sdk::Sdk; +use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; +use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::platform_value::Value; +use dash_sdk::dpp::util::strings::convert_to_homograph_safe_chars; +use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dash_sdk::dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; +use dash_sdk::dpp::voting::votes::resource_vote::ResourceVote; +use dash_sdk::dpp::voting::votes::resource_vote::accessors::v0::ResourceVoteGettersV0; +use dash_sdk::drive::query::contested_resource_votes_given_by_identity_query::ContestedResourceVotesGivenByIdentityQuery; +use dash_sdk::platform::{FetchMany, Identifier}; +use futures::{StreamExt, stream}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +const CURRENT_VOTES_KEY: &str = "det:dpns_current_votes:v1"; +const VOTE_QUERY_PAGE_SIZE: u16 = 100; + +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct StoredCurrentVotes { + available: bool, + updated_at: u64, + votes: BTreeMap<[u8; 32], ResourceVoteChoice>, +} + +fn vote_state_err(source: KvAdapterError) -> TaskError { + TaskError::DpnsVoteOperationStorage { source } +} + +fn load_snapshot( + kv: &DetKv, + voter_id: &Identifier, +) -> Result, TaskError> { + kv.get(DetScope::Identity(&voter_id.to_buffer()), CURRENT_VOTES_KEY) + .map_err(vote_state_err) +} + +fn save_snapshot( + kv: &DetKv, + voter_id: &Identifier, + snapshot: &StoredCurrentVotes, +) -> Result<(), TaskError> { + kv.put( + DetScope::Identity(&voter_id.to_buffer()), + CURRENT_VOTES_KEY, + snapshot, + ) + .map_err(vote_state_err) +} + +impl AppContext { + /// Build the exact Platform vote-poll ID for one normalized DPNS label. + pub fn dpns_vote_poll_id(&self, name: &str) -> Result { + let document_type = self + .dpns_contract + .document_type_for_name("domain") + .map_err(|_| TaskError::DataContractNotFound)?; + let Some(contested_index) = document_type.find_contested_index() else { + return Err(TaskError::ContractSchemaMismatch { + detail: "DPNS domain document type has no contested index", + }); + }; + let normalized_name = convert_to_homograph_safe_chars(name); + ContestedDocumentResourceVotePoll { + index_name: contested_index.name.clone(), + index_values: vec![Value::from("dash"), Value::Text(normalized_name)], + document_type_name: document_type.name().to_owned(), + contract_id: self.dpns_contract.id(), + } + .unique_id() + .map_err(|source| TaskError::SdkError { + source_error: Box::new(dash_sdk::Error::Protocol(source)), + }) + } + + /// Read the latest proved state without performing network I/O in the frame loop. + pub fn dpns_current_vote_state( + &self, + voter_id: Identifier, + vote_poll_id: Identifier, + ) -> Result { + Ok(match load_snapshot(&self.det_kv()?, &voter_id)? { + None => DpnsCurrentVoteState::Checking, + Some(snapshot) if !snapshot.available => DpnsCurrentVoteState::Unavailable, + Some(snapshot) => DpnsCurrentVoteState::Available( + snapshot.votes.get(&vote_poll_id.to_buffer()).copied(), + ), + }) + } + + /// Refresh proved vote state once per loaded masternode, paging only as needed. + pub(crate) async fn refresh_dpns_vote_states(&self, sdk: &Sdk) { + let voters = match self.load_local_masternode_identities() { + Ok(voters) => voters, + Err(error) => { + tracing::warn!(?error, "Could not load nodes for DPNS vote-state refresh"); + return; + } + }; + let kv = match self.det_kv() { + Ok(kv) => kv, + Err(error) => { + tracing::warn!(?error, "Could not open DPNS vote-state storage"); + return; + } + }; + + stream::iter(voters) + .map(|voter| { + let sdk = sdk.clone(); + let kv = kv.clone(); + async move { + let voter_id = voter.identity.id(); + match fetch_votes_for_voter(&sdk, voter_id).await { + Ok(votes) => { + let snapshot = StoredCurrentVotes { + available: true, + updated_at: now_ms(), + votes, + }; + if let Err(error) = save_snapshot(&kv, &voter_id, &snapshot) { + tracing::warn!( + ?error, + voter_id = %voter_id, + "Could not save proved DPNS vote state" + ); + } + } + Err(error) => { + let snapshot = StoredCurrentVotes { + available: false, + updated_at: now_ms(), + votes: BTreeMap::new(), + }; + if let Err(storage_error) = save_snapshot(&kv, &voter_id, &snapshot) { + tracing::warn!( + ?storage_error, + voter_id = %voter_id, + "Could not save unavailable DPNS vote state" + ); + } + tracing::warn!( + ?error, + voter_id = %voter_id, + "Proved DPNS vote-state query was unavailable" + ); + } + } + } + }) + .buffer_unordered(4) + .collect::>() + .await; + } + + /// Update the proved-state cache after a confirmed target. + pub(crate) fn cache_confirmed_dpns_vote( + &self, + voter_id: Identifier, + vote_poll_id: Identifier, + choice: ResourceVoteChoice, + ) -> Result<(), TaskError> { + let kv = self.det_kv()?; + let mut snapshot = load_snapshot(&kv, &voter_id)?.unwrap_or_default(); + snapshot.available = true; + snapshot.updated_at = now_ms(); + snapshot.votes.insert(vote_poll_id.to_buffer(), choice); + save_snapshot(&kv, &voter_id, &snapshot) + } +} + +async fn fetch_votes_for_voter( + sdk: &Sdk, + voter_id: Identifier, +) -> Result, dash_sdk::Error> { + let mut votes = BTreeMap::new(); + let mut start_at = None; + loop { + let page = ResourceVote::fetch_many( + sdk, + ContestedResourceVotesGivenByIdentityQuery { + identity_id: voter_id, + offset: None, + limit: Some(VOTE_QUERY_PAGE_SIZE), + start_at, + order_ascending: true, + }, + ) + .await?; + let page_len = page.len(); + let last_key = page.last().map(|(id, _)| id.to_buffer()); + for (poll_id, vote) in page { + if let Some(vote) = vote { + votes.insert(poll_id.to_buffer(), vote.resource_vote_choice()); + } + } + if page_len < usize::from(VOTE_QUERY_PAGE_SIZE) { + break; + } + let Some(last_key) = last_key else { + break; + }; + start_at = Some((last_key, false)); + } + Ok(votes) +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet_backend::kv_test_support::InMemoryKv; + use std::sync::Arc; + + fn kv() -> DetKv { + DetKv::from_store(Arc::new(InMemoryKv::default())) + } + + /// VOTE-TC-001: a proved current choice round-trips by node and poll. + #[test] + fn proved_current_vote_round_trips() { + let kv = kv(); + let voter = Identifier::from([1; 32]); + let poll = Identifier::from([2; 32]); + let snapshot = StoredCurrentVotes { + available: true, + updated_at: 3, + votes: BTreeMap::from([(poll.to_buffer(), ResourceVoteChoice::Lock)]), + }; + save_snapshot(&kv, &voter, &snapshot).unwrap(); + + assert_eq!(load_snapshot(&kv, &voter).unwrap(), Some(snapshot)); + } + + /// VOTE-TC-007: query failure is represented explicitly, never as no vote. + #[test] + fn unavailable_snapshot_is_distinct_from_an_empty_proved_snapshot() { + let unavailable = StoredCurrentVotes { + available: false, + ..Default::default() + }; + let empty = StoredCurrentVotes { + available: true, + ..Default::default() + }; + + assert_ne!(unavailable, empty); + } +} diff --git a/src/context/mod.rs b/src/context/mod.rs index 70e215fd4..ab896bf3a 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -1,6 +1,8 @@ pub mod connection_status; mod contested_names_db; mod contract_token_db; +mod dpns_vote_operations; +mod dpns_vote_state; pub mod feature_gate; mod identity_db; pub(crate) mod identity_load_registry; @@ -157,6 +159,8 @@ pub struct AppContext { /// Process-local claim shared by every UI surface before a paid DashPay /// request action enters its backend flow. contact_request_actions_in_flight: Mutex>, + /// Serializes operation journal writes and target-lock acquisition. + dpns_vote_operation_guard: Mutex<()>, /// Pending wallet selection - set after creating/importing a wallet /// so the wallet screen can auto-select the new wallet pub(crate) pending_wallet_selection: Mutex>, @@ -171,6 +175,8 @@ pub struct AppContext { /// the hub adopts it on return (forward-courier, mirrors /// `pending_wallet_selection`). pub(crate) pending_identity_selection: Mutex>, + /// One-shot DPNS deep link consumed by the Masternodes Voting Center. + pending_dpns_voting_contests: Mutex>>, /// Cached fee multiplier permille from current epoch (1000 = 1x, 2000 = 2x) /// Updated when epoch info is fetched from Platform fee_multiplier_permille: AtomicU64, @@ -434,11 +440,13 @@ impl AppContext { migration_status: Arc::new(MigrationStatus::new_idle()), migration_run: tokio::sync::Mutex::new(()), contact_request_actions_in_flight: Mutex::new(HashSet::new()), + dpns_vote_operation_guard: Mutex::new(()), pending_wallet_selection: Mutex::new(None), selected_wallet_hash: Mutex::new(selected_wallet_hash), selected_single_key_hash: Mutex::new(selected_single_key_hash), selected_identity_id: Mutex::new(None), pending_identity_selection: Mutex::new(None), + pending_dpns_voting_contests: Mutex::new(None), fee_multiplier_permille: AtomicU64::new( PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE, ), @@ -604,6 +612,22 @@ impl AppContext { self.network } + /// Route DPNS contest browsing into the shared Masternodes Voting Center. + pub fn route_to_dpns_voting_center(&self, contested_names: Vec) { + *self + .pending_dpns_voting_contests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(contested_names); + } + + /// Consume a one-shot DPNS β†’ Masternodes Voting Center deep link. + pub fn take_dpns_voting_center_route(&self) -> Option> { + self.pending_dpns_voting_contests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() + } + pub fn connection_status(&self) -> &ConnectionStatus { &self.connection_status } diff --git a/src/model/contested_name.rs b/src/model/contested_name.rs index f30666b7b..09f72d8c5 100644 --- a/src/model/contested_name.rs +++ b/src/model/contested_name.rs @@ -35,11 +35,9 @@ pub struct ContestedName { } impl ContestedName { - /// Whether `voter_id` still has an actionable vote to cast on this contest: - /// the contest is in a votable state and the voter has not already recorded - /// a vote on it. Drives the Masternodes card DPNS status line (Β§10.1). - pub fn is_open_for_voter(&self, voter_id: &Identifier) -> bool { - self.state.state_is_votable() && !self.my_votes.keys().any(|(id, _, _)| id == voter_id) + /// Whether the contest still accepts this node's initial vote or a change. + pub fn is_open_for_voter(&self, _voter_id: &Identifier) -> bool { + self.state.state_is_votable() } } @@ -51,8 +49,10 @@ impl ContestedName { /// (requirements Β§10.1). #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct MasternodeContestSummary { - /// Number of open contests this node can still vote on. + /// Number of active contests, including contests with an existing vote. pub open_contest_count: usize, + /// Number of active contests whose proved state is `Not voted`. + pub needs_vote_count: usize, /// Whether the node has at least one pending (not-yet-executed) scheduled /// vote, reusing the DPNS Scheduled Votes screen's existing state. pub has_scheduled_vote: bool, @@ -107,14 +107,14 @@ mod tests { } #[test] - fn not_open_when_voter_already_voted() { + fn existing_vote_remains_actionable_while_contest_is_votable() { let voter = Identifier::from([7u8; 32]); let mut c = contest(ContestState::Ongoing); c.my_votes.insert( (voter, PrivateKeyTarget::PrivateKeyOnVoterIdentity, 0), ResourceVoteChoice::Abstain, ); - assert!(!c.is_open_for_voter(&voter)); + assert!(c.is_open_for_voter(&voter)); } #[test] diff --git a/src/model/dpns_voting.rs b/src/model/dpns_voting.rs new file mode 100644 index 000000000..badbc5383 --- /dev/null +++ b/src/model/dpns_voting.rs @@ -0,0 +1,290 @@ +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + use dash_sdk::platform::Identifier; + + fn target( + voter: u8, + poll: u8, + current: Option, + requested: ResourceVoteChoice, + ) -> DpnsVoteTarget { + DpnsVoteTarget { + key: DpnsVoteTargetKey { + network: Network::Testnet, + voter_id: Identifier::from([voter; 32]), + vote_poll_id: Identifier::from([poll; 32]), + }, + voter_alias: Some(format!("node-{voter}")), + contested_name: format!("contest-{poll}"), + requested_choice: requested, + current_choice: current, + timing: VoteTiming::Now, + } + } + + /// VOTE-TC-003: choosing the proved current vote cannot create a target. + #[test] + fn operation_suppresses_exact_no_ops() { + let operation = DpnsVoteOperation::new(vec![ + target( + 1, + 1, + Some(ResourceVoteChoice::Lock), + ResourceVoteChoice::Lock, + ), + target(1, 2, None, ResourceVoteChoice::Abstain), + ]); + + assert_eq!(operation.targets.len(), 1); + assert_eq!(operation.no_op_count, 1); + assert_eq!( + operation.targets[0].target.key.vote_poll_id, + Identifier::from([2; 32]) + ); + } + + /// VOTE-TC-032: outcomes retain the exact voter Γ— contest target. + #[test] + fn outcomes_retain_target_correlation() { + let operation = + DpnsVoteOperation::new(vec![target(7, 9, None, ResourceVoteChoice::Abstain)]); + let outcome = &operation.targets[0]; + + assert_eq!(outcome.operation_id, operation.id); + assert_eq!(outcome.target.key.voter_id, Identifier::from([7; 32])); + assert_eq!(outcome.target.contested_name, "contest-9"); + } + + /// VOTE-TC-040/044: unresolved states keep the target lock across restart. + #[test] + fn unresolved_statuses_hold_target_locks() { + for status in [ + DpnsVoteTargetStatus::Scheduled, + DpnsVoteTargetStatus::Queued, + DpnsVoteTargetStatus::Submitting, + DpnsVoteTargetStatus::Confirming, + DpnsVoteTargetStatus::Unconfirmed, + ] { + assert!(status.holds_lock(), "{status:?} must hold its lock"); + } + for status in [ + DpnsVoteTargetStatus::Confirmed, + DpnsVoteTargetStatus::Rejected, + DpnsVoteTargetStatus::FailedBeforeSubmission, + DpnsVoteTargetStatus::NotApplied, + ] { + assert!(!status.holds_lock(), "{status:?} must release its lock"); + } + } + + /// VOTE-TC-072: the network is part of target identity. + #[test] + fn target_keys_are_network_scoped() { + let testnet = DpnsVoteTargetKey { + network: Network::Testnet, + voter_id: Identifier::from([1; 32]), + vote_poll_id: Identifier::from([2; 32]), + }; + let mainnet = DpnsVoteTargetKey { + network: Network::Mainnet, + ..testnet.clone() + }; + + assert_ne!(testnet, mainnet); + } + + /// VOTE-TC-073: the durable operation shape contains no signing material. + #[test] + fn serialized_operation_contains_identifiers_and_choices_only() { + let operation = DpnsVoteOperation::new(vec![target(4, 5, None, ResourceVoteChoice::Lock)]); + let serialized = serde_json::to_string(&operation).expect("serialize operation"); + + assert!(!serialized.contains("private")); + assert!(!serialized.contains("secret")); + assert!(!serialized.contains("wif")); + } +} +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::identity::TimestampMillis; +use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dash_sdk::platform::Identifier; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Proved current vote state for one node Γ— poll. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DpnsCurrentVoteState { + Checking, + Available(Option), + Unavailable, +} + +/// Durable identity of one submitted voting batch. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct DpnsVoteOperationId([u8; 16]); + +impl DpnsVoteOperationId { + /// Generate a random operation identifier without adding a UUID dependency. + pub fn random() -> Self { + let mut bytes = [0; 16]; + rand::rng().fill_bytes(&mut bytes); + Self(bytes) + } + + /// Return the stable persisted byte representation. + pub fn to_bytes(self) -> [u8; 16] { + self.0 + } + + /// Restore an identifier from its persisted byte representation. + pub fn from_bytes(bytes: [u8; 16]) -> Self { + Self(bytes) + } +} + +impl fmt::Display for DpnsVoteOperationId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in self.0 { + write!(formatter, "{byte:02x}")?; + } + Ok(()) + } +} + +/// Exact network + node + poll lock identity for one vote target. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct DpnsVoteTargetKey { + pub network: Network, + /// The masternode ProTxHash used by Platform's proved vote query. + pub voter_id: Identifier, + pub vote_poll_id: Identifier, +} + +/// When a target should enter the shared executor. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum VoteTiming { + Now, + Scheduled(TimestampMillis), +} + +/// One reviewed node Γ— contest action. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DpnsVoteTarget { + pub key: DpnsVoteTargetKey, + pub voter_alias: Option, + pub contested_name: String, + pub requested_choice: ResourceVoteChoice, + pub current_choice: Option, + pub timing: VoteTiming, +} + +/// Persistable, user-meaningful failure category without task diagnostics. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DpnsVoteFailure { + PlatformRejected, + SubmissionFailed, + CurrentVoteUnavailable, + ResultUnconfirmed, +} + +/// Lifecycle of one target. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DpnsVoteTargetStatus { + Scheduled, + Queued, + Submitting, + Confirming, + Confirmed, + Unconfirmed, + Rejected, + FailedBeforeSubmission, + NotApplied, +} + +impl DpnsVoteTargetStatus { + /// Whether another operation must remain blocked for the same target. + pub fn holds_lock(self) -> bool { + matches!( + self, + Self::Scheduled + | Self::Queued + | Self::Submitting + | Self::Confirming + | Self::Unconfirmed + ) + } +} + +/// Durable result and progress for one operation target. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DpnsVoteOutcome { + pub operation_id: DpnsVoteOperationId, + pub target: DpnsVoteTarget, + pub status: DpnsVoteTargetStatus, + pub transition_hash: Option<[u8; 32]>, + pub failure: Option, +} + +/// Reviewed voting batch stored before its first broadcast. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DpnsVoteOperation { + pub id: DpnsVoteOperationId, + pub created_at: TimestampMillis, + pub targets: Vec, + pub no_op_count: usize, +} + +impl DpnsVoteOperation { + /// Build an operation while removing targets that match proved current state. + pub fn new(targets: Vec) -> Self { + let id = DpnsVoteOperationId::random(); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as TimestampMillis; + let original_len = targets.len(); + let targets = targets + .into_iter() + .filter(|target| target.current_choice != Some(target.requested_choice)) + .map(|target| { + let status = match target.timing { + VoteTiming::Now => DpnsVoteTargetStatus::Queued, + VoteTiming::Scheduled(_) => DpnsVoteTargetStatus::Scheduled, + }; + DpnsVoteOutcome { + operation_id: id, + target, + status, + transition_hash: None, + failure: None, + } + }) + .collect::>(); + let no_op_count = original_len.saturating_sub(targets.len()); + Self { + id, + created_at, + targets, + no_op_count, + } + } + + /// Find one outcome by its exact target key. + pub fn outcome(&self, key: &DpnsVoteTargetKey) -> Option<&DpnsVoteOutcome> { + self.targets + .iter() + .find(|outcome| &outcome.target.key == key) + } + + /// Whether all targets have reached a lock-releasing state. + pub fn is_complete(&self) -> bool { + self.targets + .iter() + .all(|outcome| !outcome.status.holds_lock()) + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs index 7c1f24ee9..8d1cfabd6 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -5,6 +5,7 @@ pub mod dashpay; pub mod dashpay_derivation; pub(crate) mod data_migration; pub mod dpns; +pub mod dpns_voting; pub mod fee_estimation; pub mod grovestark_prover; pub mod identity_discovery; diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index e244d1786..c97296ccd 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -21,6 +21,10 @@ use crate::backend_task::error::TaskError; use crate::backend_task::identity::IdentityTask; use crate::context::AppContext; use crate::model::contested_name::{ContestState, ContestedName}; +use crate::model::dpns_voting::{ + DpnsCurrentVoteState, DpnsVoteOperation, DpnsVoteTarget, DpnsVoteTargetKey, + DpnsVoteTargetStatus, VoteTiming, +}; use crate::model::qualified_identity::{DPNSNameInfo, QualifiedIdentity}; use crate::ui::components::dpns_subscreen_chooser_panel::add_dpns_subscreen_chooser_panel; use crate::ui::components::left_panel::add_left_panel; @@ -1053,6 +1057,20 @@ impl DPNSScreen { }) .body(|mut body| { for vote in sorted_votes.iter_mut() { + let operation_status = self + .app_context + .dpns_vote_poll_id(&vote.0.contested_name) + .ok() + .and_then(|vote_poll_id| { + self.app_context + .dpns_vote_target_status(&DpnsVoteTargetKey { + network: self.app_context.network(), + voter_id: vote.0.voter_id, + vote_poll_id, + }) + .ok() + .flatten() + }); body.row(25.0, |mut row| { // Contested name row.col(|ui| { @@ -1102,6 +1120,25 @@ impl DPNSScreen { // Status row.col(|ui| { let dark_mode = ui.style().visuals.dark_mode; + if matches!( + operation_status, + Some(DpnsVoteTargetStatus::Queued) + | Some(DpnsVoteTargetStatus::Submitting) + | Some(DpnsVoteTargetStatus::Confirming) + ) { + ui.label( + RichText::new("Submitting…") + .color(DashColors::text_primary(dark_mode)), + ); + return; + } + if operation_status == Some(DpnsVoteTargetStatus::Unconfirmed) { + ui.colored_label( + DashColors::warning_color(dark_mode), + "Checking result", + ); + return; + } match vote.1 { ScheduledVoteCastingStatus::NotStarted => { ui.label( @@ -1125,7 +1162,20 @@ impl DPNSScreen { }); // Actions row.col(|ui| { - if ui.button("Remove").clicked() { + let target_is_busy = matches!( + operation_status, + Some(DpnsVoteTargetStatus::Queued) + | Some(DpnsVoteTargetStatus::Submitting) + | Some(DpnsVoteTargetStatus::Confirming) + | Some(DpnsVoteTargetStatus::Unconfirmed) + ); + if ui + .add_enabled(!target_is_busy, Button::new("Remove")) + .disabled_tooltip( + "This scheduled vote cannot be removed while its result is being checked.", + ) + .clicked() + { action = AppAction::BackendTask(BackendTask::ContestedResourceTask( ContestedResourceTask::DeleteScheduledVote( @@ -1140,8 +1190,7 @@ impl DPNSScreen { vote.1, ScheduledVoteCastingStatus::NotStarted | ScheduledVoteCastingStatus::Failed - ) && !self - .scheduled_vote_cast_in_progress; + ) && !target_is_busy; let cast_button = if cast_button_enabled { Button::new("Cast Now") @@ -1538,8 +1587,29 @@ impl DPNSScreen { ui.add_space(10.0); } + let operation_in_progress = matches!( + self.bulk_vote_handling_status, + VoteHandlingStatus::CastingVotes | VoteHandlingStatus::SchedulingVotes + ); + if operation_in_progress { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Submitting votes…"); + }); + } // "Apply Votes" button - if ComponentStyles::add_primary_button(ui, "Apply Votes").clicked() { + if ComponentStyles::add_primary_button_enabled( + ui, + !operation_in_progress, + if operation_in_progress { + "Submitting votes…" + } else { + "Apply Votes" + }, + ) + .disabled_tooltip("The selected votes are already being submitted.") + .clicked() + { action = self.bulk_apply_votes(); if self.bulk_vote_handling_status == VoteHandlingStatus::CastingVotes { self.vote_banner.take_and_clear(); @@ -1552,7 +1622,14 @@ impl DPNSScreen { ui.add_space(5.0); let dark_mode = ui.style().visuals.dark_mode; - if ComponentStyles::add_secondary_button(ui, "Cancel", dark_mode).clicked() { + if ui + .add_enabled( + !operation_in_progress, + ComponentStyles::secondary_button("Cancel", dark_mode), + ) + .disabled_tooltip("Submitted votes cannot be cancelled.") + .clicked() + { self.selected_votes.clear(); self.show_bulk_schedule_popup = false; self.bulk_schedule_message = None; @@ -1589,19 +1666,19 @@ impl DPNSScreen { /// The logic that was in BulkScheduleVoteScreen::schedule_votes fn bulk_apply_votes(&mut self) -> AppAction { - // Partition immediate vs scheduled - let mut immediate_list = Vec::new(); - let mut scheduled_list = Vec::new(); - + let mut targets = Vec::new(); + let mut selected_voters = Vec::new(); + let mut has_immediate = false; for (identity, option) in self .voting_identities .iter() .zip(&self.bulk_identity_options) { - match option { - VoteOption::NoVote => {} + let timing = match option { + VoteOption::NoVote => continue, VoteOption::CastNow => { - immediate_list.push(identity.clone()); + has_immediate = true; + VoteTiming::Now } VoteOption::Scheduled { days, @@ -1612,62 +1689,88 @@ impl DPNSScreen { let offset = chrono::Duration::days(*days as i64) + chrono::Duration::hours(*hours as i64) + chrono::Duration::minutes(*minutes as i64); - let scheduled_time = (now + offset).timestamp_millis() as u64; - - for sv in &self.selected_votes { - let new_vote = ScheduledDPNSVote { - contested_name: sv.contested_name.clone(), - voter_id: identity.identity.id(), - choice: sv.vote_choice, - unix_timestamp: scheduled_time, - executed_successfully: false, - }; - scheduled_list.push(new_vote); + VoteTiming::Scheduled((now + offset).timestamp_millis() as u64) + } + }; + selected_voters.push(identity.clone()); + for selected_vote in &self.selected_votes { + let voter_id = identity.identity.id(); + let vote_poll_id = match self + .app_context + .dpns_vote_poll_id(&selected_vote.contested_name) + { + Ok(vote_poll_id) => vote_poll_id, + Err(error) => { + self.bulk_vote_handling_status = + VoteHandlingStatus::Failed(error.to_string()); + return AppAction::None; } + }; + let target_key = DpnsVoteTargetKey { + network: self.app_context.network(), + voter_id, + vote_poll_id, + }; + if self + .app_context + .dpns_vote_target_status(&target_key) + .ok() + .flatten() + .is_some() + { + self.bulk_vote_handling_status = VoteHandlingStatus::Failed(format!( + "This node's vote for {} is already in progress. Check its result before submitting again.", + selected_vote.contested_name + )); + return AppAction::None; } + let current_choice = match self + .app_context + .dpns_current_vote_state(voter_id, vote_poll_id) + { + Ok(DpnsCurrentVoteState::Available(choice)) => choice, + Ok(DpnsCurrentVoteState::Checking | DpnsCurrentVoteState::Unavailable) + | Err(_) => { + self.bulk_vote_handling_status = VoteHandlingStatus::Failed( + "Current vote state is unavailable. Refresh voting before applying votes." + .to_owned(), + ); + return AppAction::None; + } + }; + targets.push(DpnsVoteTarget { + key: target_key, + voter_alias: identity.alias.clone(), + contested_name: selected_vote.contested_name.clone(), + requested_choice: selected_vote.vote_choice, + current_choice, + timing, + }); } } - if immediate_list.is_empty() && scheduled_list.is_empty() { + if targets.is_empty() { self.bulk_vote_handling_status = VoteHandlingStatus::Failed( - "No votes selected. Please select votes to cast or schedule.".to_string(), + "No votes selected. Choose at least one node and contest.".to_owned(), ); return AppAction::None; } - - // 1) If immediate_list is not empty, vote now, possibly scheduling votes as well - if !immediate_list.is_empty() { - let votes_for_all: Vec<(String, ResourceVoteChoice)> = self - .selected_votes - .iter() - .map(|sv| (sv.contested_name.clone(), sv.vote_choice)) - .collect(); - self.bulk_vote_handling_status = VoteHandlingStatus::CastingVotes; - if !scheduled_list.is_empty() { - AppAction::BackendTasks( - vec![ - BackendTask::ContestedResourceTask(ContestedResourceTask::VoteOnDPNSNames( - votes_for_all, - immediate_list, - )), - BackendTask::ContestedResourceTask( - ContestedResourceTask::ScheduleDPNSVotes(scheduled_list), - ), - ], - BackendTasksExecutionMode::Concurrent, - ) - } else { - AppAction::BackendTask(BackendTask::ContestedResourceTask( - ContestedResourceTask::VoteOnDPNSNames(votes_for_all, immediate_list), - )) - } - } else { - // 2) Otherwise just schedule them - self.bulk_vote_handling_status = VoteHandlingStatus::SchedulingVotes; - AppAction::BackendTask(BackendTask::ContestedResourceTask( - ContestedResourceTask::ScheduleDPNSVotes(scheduled_list), - )) + let operation = DpnsVoteOperation::new(targets); + if operation.targets.is_empty() { + self.bulk_vote_handling_status = VoteHandlingStatus::Failed( + "Every selected node already has the requested vote. Nothing will be submitted." + .to_owned(), + ); + return AppAction::None; } + self.bulk_vote_handling_status = if has_immediate { + VoteHandlingStatus::CastingVotes + } else { + VoteHandlingStatus::SchedulingVotes + }; + AppAction::BackendTask(BackendTask::ContestedResourceTask( + ContestedResourceTask::SubmitDpnsVoteOperation(operation, selected_voters), + )) } /// If voting/scheduling is successful, show success message @@ -1829,22 +1932,12 @@ impl ScreenLike for DPNSScreen { self.refresh(); } - fn display_message(&mut self, message: &str, message_type: MessageType) { + fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. if matches!(message_type, MessageType::Error | MessageType::Warning) { self.refresh_banner.take_and_clear(); self.vote_banner.take_and_clear(); } - if message.contains("Error casting scheduled vote") { - self.scheduled_vote_cast_in_progress = false; - if let Ok(mut guard) = self.scheduled_votes.lock() { - for vote in guard.iter_mut() { - if vote.1 == ScheduledVoteCastingStatus::InProgress { - vote.1 = ScheduledVoteCastingStatus::Failed; - } - } - } - } } fn display_task_error(&mut self, error: &TaskError) -> bool { @@ -1869,54 +1962,10 @@ impl ScreenLike for DPNSScreen { fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { match backend_task_success_result { - // If immediate cast finished, see if we have pending to schedule next - BackendTaskSuccessResult::DPNSVoteResults(results) => { - let errors: Vec = results - .iter() - .filter_map(|(_, _, r)| r.as_ref().err().map(|e| e.to_string())) - .collect(); - let successes: Vec = results - .iter() - .filter_map(|(name, _, r)| r.as_ref().ok().map(|_| name.clone())) - .collect(); - - if !errors.is_empty() { - let errors_string = errors.join("\n\n"); - if !successes.is_empty() { - // partial success - self.bulk_schedule_message = Some(( - MessageType::Error, - format!( - "Successes: {}/{}\n\nErrors:\n\n{:?}", - successes.len(), - successes.len() + errors.len(), - errors_string - ), - )); - } else { - // all failed - self.bulk_schedule_message = - Some((MessageType::Error, format!("Errors:\n\n{}", errors_string))); - } - } else { - // no errors => all success - self.bulk_schedule_message = Some(( - MessageType::Success, - "Votes all cast successfully.".to_string(), - )); - } - + BackendTaskSuccessResult::DpnsVoteOperationUpdated(_) => { self.vote_banner.take_and_clear(); self.bulk_vote_handling_status = VoteHandlingStatus::Completed; - } - // If scheduling succeeded - BackendTaskSuccessResult::ScheduledVotes => { - if self.bulk_vote_handling_status == VoteHandlingStatus::SchedulingVotes { - self.vote_banner.take_and_clear(); - self.bulk_vote_handling_status = VoteHandlingStatus::Completed; - } - self.bulk_schedule_message = - Some((MessageType::Success, "Votes scheduled".to_string())); + self.refresh(); } BackendTaskSuccessResult::ScheduledVotesInProgress(votes) => { // The periodic sweep is about to cast these votes; reflect that @@ -1932,16 +1981,6 @@ impl ScreenLike for DPNSScreen { } } } - BackendTaskSuccessResult::CastScheduledVote(vote) => { - self.scheduled_vote_cast_in_progress = false; - if let Ok(mut guard) = self.scheduled_votes.lock() - && let Some((_, status)) = guard.iter_mut().find(|(v, _)| { - v.contested_name == vote.contested_name && v.voter_id == vote.voter_id - }) - { - *status = ScheduledVoteCastingStatus::Completed; - } - } BackendTaskSuccessResult::RefreshedDpnsContests | BackendTaskSuccessResult::RefreshedOwnedDpnsNames => { self.refresh_banner.take_and_clear(); @@ -1973,7 +2012,7 @@ impl ScreenLike for DPNSScreen { vec![ refresh_button, ( - "Cast/Schedule Votes", + "Vote with masternodes", DesiredAppAction::Custom("Vote".to_string()), ), ] @@ -2044,9 +2083,13 @@ impl ScreenLike for DPNSScreen { // If user clicked "Apply Votes" in the top bar if action == AppAction::Custom("Vote".to_string()) { - // That means the user clicked "Apply Votes" - self.show_bulk_schedule_popup = true; - action = AppAction::None; // clear it out so we don't re-trigger + self.app_context.route_to_dpns_voting_center( + self.selected_votes + .iter() + .map(|vote| vote.contested_name.clone()) + .collect(), + ); + action = AppAction::SetMainScreen(RootScreenType::RootScreenMasternodes); } // Left panel diff --git a/src/ui/masternodes/card.rs b/src/ui/masternodes/card.rs index 67559c7da..c5dcbc7fb 100644 --- a/src/ui/masternodes/card.rs +++ b/src/ui/masternodes/card.rs @@ -59,7 +59,19 @@ pub fn voter_readiness_label(voting_present: bool) -> &'static str { /// (actionable), then a pending scheduled vote, then none. pub fn dpns_status_line(summary: MasternodeContestSummary) -> String { if summary.open_contest_count > 0 { - format!("{} contests to vote on", summary.open_contest_count) + if summary.needs_vote_count == 0 { + "Votes cast in all active contests".to_owned() + } else if summary.needs_vote_count == 1 { + format!( + "{} active contests Β· 1 needs a vote", + summary.open_contest_count + ) + } else { + format!( + "{} active contests Β· {} need votes", + summary.open_contest_count, summary.needs_vote_count + ) + } } else if summary.has_scheduled_vote { "Vote scheduled".to_string() } else { @@ -368,9 +380,13 @@ mod tests { fn tc_fr3_09_dpns_open_contest_count() { let summary = MasternodeContestSummary { open_contest_count: 3, + needs_vote_count: 1, has_scheduled_vote: false, }; - assert_eq!(dpns_status_line(summary), "3 contests to vote on"); + assert_eq!( + dpns_status_line(summary), + "3 active contests Β· 1 needs a vote" + ); } #[test] @@ -386,15 +402,20 @@ mod tests { // Both an open contest AND a scheduled vote present β†’ count wins. let summary = MasternodeContestSummary { open_contest_count: 2, + needs_vote_count: 1, has_scheduled_vote: true, }; - assert_eq!(dpns_status_line(summary), "2 contests to vote on"); + assert_eq!( + dpns_status_line(summary), + "2 active contests Β· 1 needs a vote" + ); } #[test] fn dpns_scheduled_shown_only_when_no_open_contests() { let summary = MasternodeContestSummary { open_contest_count: 0, + needs_vote_count: 0, has_scheduled_vote: true, }; assert_eq!(dpns_status_line(summary), "Vote scheduled"); diff --git a/src/ui/masternodes/detail_screen.rs b/src/ui/masternodes/detail_screen.rs index f5e3421a4..7274a6b4e 100644 --- a/src/ui/masternodes/detail_screen.rs +++ b/src/ui/masternodes/detail_screen.rs @@ -24,6 +24,9 @@ use crate::backend_task::contested_names::ContestedResourceTask; use crate::backend_task::identity::{IdentityInputToLoad, IdentityLoadMode, IdentityTask}; use crate::context::AppContext; use crate::model::contested_name::{ContestedName, MasternodeContestSummary}; +use crate::model::dpns_voting::{ + DpnsCurrentVoteState, DpnsVoteTarget, DpnsVoteTargetKey, VoteTiming, +}; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::{ IdentityType, MasternodeKeyPresence, PrivateKeyTarget, QualifiedIdentity, @@ -91,6 +94,9 @@ fn candidate_choice_label(candidate_name: &str, votes: u32) -> String { struct ContestVoteRow { name: String, end_time: Option, + vote_poll_id: dash_sdk::platform::Identifier, + current_vote: DpnsCurrentVoteState, + locked: bool, /// `(candidate id, candidate name, votes so far)` for each contestant. candidates: Vec<(dash_sdk::platform::Identifier, String, u32)>, } @@ -232,6 +238,11 @@ pub enum DetailOutcome { Back, /// The node was removed β€” return to the list and reload. Removed, + /// Open the shared full-page composer prefiltered to this node. + OpenVotingCenter { + voter_id: dash_sdk::platform::Identifier, + choices: BTreeMap, + }, /// Push a reused screen / navigate. Boxed because `AppAction` is large. Forward(Box), } @@ -250,6 +261,9 @@ pub struct MasternodeDetailView { open_contests: Vec, /// Per-contest pending vote choice, keyed by normalized contested name. vote_selections: BTreeMap, + /// One-shot automatic proved-state refresh for a newly opened detail view. + vote_state_refresh_dispatched: bool, + open_voting_center_requested: Option>, /// The scoped, in-place "Add voting key" prompt (US-3 / Β§10.8) β€” distinct /// from FR-4's load form. `Some` while the prompt is open. voter_key_prompt: Option, @@ -279,7 +293,7 @@ impl MasternodeDetailView { let voter_id = identity .associated_voter_identity .as_ref() - .map(|(voter, _)| voter.id()); + .map(|_| identity.identity.id()); let contest_summary = app_context .masternode_contest_summary(voter_id) .unwrap_or_default(); @@ -293,6 +307,8 @@ impl MasternodeDetailView { contest_summary, open_contests, vote_selections: BTreeMap::new(), + vote_state_refresh_dispatched: false, + open_voting_center_requested: None, voter_key_prompt: None, remove_dialog: None, } @@ -321,7 +337,7 @@ impl MasternodeDetailView { .identity .associated_voter_identity .as_ref() - .map(|(voter, _)| voter.id()); + .map(|_| self.identity.identity.id()); self.contest_summary = self .app_context .masternode_contest_summary(voter_id) @@ -439,6 +455,12 @@ impl MasternodeDetailView { outcome = DetailOutcome::Removed; } }); + if let Some(choices) = self.open_voting_center_requested.take() { + outcome = DetailOutcome::OpenVotingCenter { + voter_id: self.identity.identity.id(), + choices, + }; + } outcome } @@ -820,16 +842,29 @@ impl MasternodeDetailView { ))) } - /// Per-contest voting choices + Cast votes, dispatching the existing - /// `VoteOnDPNSNames` backend for the selected choices. + /// Per-contest choices backed by the shared durable voting coordinator. fn render_vote_table(&mut self, ui: &mut Ui, dark_mode: bool) -> Option { let mut action = None; // Collect the render data up front so the choice-writing loop does not // borrow `self.open_contests` while mutating `self.vote_selections`. + let voter_id = self.identity.identity.id(); let contests: Vec = self .open_contests .iter() - .map(|contest| { + .filter_map(|contest| { + let vote_poll_id = self + .app_context + .dpns_vote_poll_id(&contest.normalized_contested_name) + .ok()?; + let current_vote = self + .app_context + .dpns_current_vote_state(voter_id, vote_poll_id) + .unwrap_or(DpnsCurrentVoteState::Unavailable); + let key = DpnsVoteTargetKey { + network: self.app_context.network(), + voter_id, + vote_poll_id, + }; let candidates = contest .contestants .as_ref() @@ -839,13 +874,31 @@ impl MasternodeDetailView { .collect() }) .unwrap_or_default(); - ContestVoteRow { + Some(ContestVoteRow { name: contest.normalized_contested_name.clone(), end_time: contest.end_time, + vote_poll_id, + current_vote, + locked: self + .app_context + .dpns_vote_target_status(&key) + .ok() + .flatten() + .is_some(), candidates, - } + }) }) .collect(); + if !self.vote_state_refresh_dispatched + && contests + .iter() + .any(|contest| contest.current_vote == DpnsCurrentVoteState::Checking) + { + self.vote_state_refresh_dispatched = true; + action = Some(AppAction::BackendTask(BackendTask::ContestedResourceTask( + ContestedResourceTask::QueryDPNSContests, + ))); + } ui.label(RichText::new(CONTEST_INTRO_MESSAGE).color(DashColors::text_secondary(dark_mode))); @@ -863,37 +916,73 @@ impl MasternodeDetailView { )) .color(DashColors::text_secondary(dark_mode)), ); - let selected = self.vote_selections.get(&contest.name).copied(); - ui.horizontal_wrapped(|ui| { - if ui - .selectable_label(selected == Some(ResourceVoteChoice::Abstain), "Abstain") - .clicked() - { - self.vote_selections - .insert(contest.name.clone(), ResourceVoteChoice::Abstain); + let current_label = match contest.current_vote { + DpnsCurrentVoteState::Checking => "Checking current vote…".to_owned(), + DpnsCurrentVoteState::Unavailable => "Current vote unavailable".to_owned(), + DpnsCurrentVoteState::Available(None) => "Not voted".to_owned(), + DpnsCurrentVoteState::Available(Some(ResourceVoteChoice::Abstain)) => { + "Current vote: Abstain".to_owned() } - if ui - .selectable_label(selected == Some(ResourceVoteChoice::Lock), "Lock") - .clicked() - { - self.vote_selections - .insert(contest.name.clone(), ResourceVoteChoice::Lock); + DpnsCurrentVoteState::Available(Some(ResourceVoteChoice::Lock)) => { + "Current vote: Lock".to_owned() } - // Candidate choices are scoped to THIS contest's contestants. - for (candidate_id, candidate_name, votes) in &contest.candidates { - let choice = ResourceVoteChoice::TowardsIdentity(*candidate_id); + DpnsCurrentVoteState::Available(Some(ResourceVoteChoice::TowardsIdentity(id))) => { + let candidate = contest + .candidates + .iter() + .find(|(candidate_id, _, _)| *candidate_id == id) + .map(|(_, name, _)| name.as_str()) + .unwrap_or("candidate"); + format!("Current vote: {candidate}") + } + }; + ui.label(RichText::new(current_label).color(DashColors::text_secondary(dark_mode))); + let selected = self.vote_selections.get(&contest.name).copied(); + let controls_enabled = + matches!(contest.current_vote, DpnsCurrentVoteState::Available(_)) + && !contest.locked; + ui.add_enabled_ui(controls_enabled, |ui| { + ui.horizontal_wrapped(|ui| { if ui - .selectable_label( - selected == Some(choice), - candidate_choice_label(candidate_name, *votes), - ) + .selectable_label(selected == Some(ResourceVoteChoice::Abstain), "Abstain") .clicked() { - self.vote_selections.insert(contest.name.clone(), choice); + self.vote_selections + .insert(contest.name.clone(), ResourceVoteChoice::Abstain); } - } + if ui + .selectable_label(selected == Some(ResourceVoteChoice::Lock), "Lock") + .clicked() + { + self.vote_selections + .insert(contest.name.clone(), ResourceVoteChoice::Lock); + } + // Candidate choices are scoped to THIS contest's contestants. + for (candidate_id, candidate_name, votes) in &contest.candidates { + let choice = ResourceVoteChoice::TowardsIdentity(*candidate_id); + if ui + .selectable_label( + selected == Some(choice), + candidate_choice_label(candidate_name, *votes), + ) + .clicked() + { + self.vote_selections.insert(contest.name.clone(), choice); + } + } + }); }); - if selected.is_none() { + if contest.locked { + ui.label( + RichText::new("This node's vote for this name is still being confirmed.") + .color(DashColors::text_secondary(dark_mode)), + ); + } else if matches!(contest.current_vote, DpnsCurrentVoteState::Unavailable) { + ui.label( + RichText::new("Refresh vote state before choosing a vote for this node.") + .color(DashColors::text_secondary(dark_mode)), + ); + } else if selected.is_none() { ui.label( RichText::new(NO_SELECTION_HINT).color(DashColors::text_secondary(dark_mode)), ); @@ -901,22 +990,51 @@ impl MasternodeDetailView { } ui.separator(); - let votes: Vec<(String, ResourceVoteChoice)> = self + let targets: Vec = self .vote_selections .iter() - .filter(|(name, _)| contests.iter().any(|c| &c.name == *name)) - .map(|(name, choice)| (name.clone(), *choice)) + .filter_map(|(name, choice)| { + let contest = contests.iter().find(|contest| &contest.name == name)?; + let DpnsCurrentVoteState::Available(current_choice) = contest.current_vote else { + return None; + }; + if current_choice == Some(*choice) || contest.locked { + return None; + } + Some(DpnsVoteTarget { + key: DpnsVoteTargetKey { + network: self.app_context.network(), + voter_id, + vote_poll_id: contest.vote_poll_id, + }, + voter_alias: self.identity.alias.clone(), + contested_name: name.clone(), + requested_choice: *choice, + current_choice, + timing: VoteTiming::Now, + }) + }) .collect(); - let has_votes = !votes.is_empty(); - if ui - .add_enabled(has_votes, egui::Button::new("Cast votes")) - .on_hover_text(CAST_ENABLED_HINT) - .on_disabled_hover_text(CAST_DISABLED_HINT) + let has_votes = !targets.is_empty(); + let review_label = if targets.len() == 1 { + "Review 1 vote".to_owned() + } else { + format!("Review {} votes", targets.len()) + }; + if ComponentStyles::add_primary_button_enabled(ui, has_votes, review_label) + .clickable_tooltip(CAST_ENABLED_HINT) + .disabled_tooltip(CAST_DISABLED_HINT) .clicked() { - action = Some(AppAction::BackendTask(BackendTask::ContestedResourceTask( - ContestedResourceTask::VoteOnDPNSNames(votes, vec![self.identity.clone()]), - ))); + self.open_voting_center_requested = Some( + targets + .into_iter() + .map(|target| (target.contested_name, target.requested_choice)) + .collect(), + ); + } + if ComponentStyles::add_secondary_button(ui, "Open Voting Center", dark_mode).clicked() { + self.open_voting_center_requested = Some(BTreeMap::new()); } action } diff --git a/src/ui/masternodes/list_screen.rs b/src/ui/masternodes/list_screen.rs index f658293f1..f33051e66 100644 --- a/src/ui/masternodes/list_screen.rs +++ b/src/ui/masternodes/list_screen.rs @@ -17,6 +17,7 @@ use crate::backend_task::identity::IdentityTask; use crate::context::AppContext; use crate::context::identity_load_registry::{IdentityLoadPhase, IdentityLoadToken}; use crate::model::contested_name::MasternodeContestSummary; +use crate::model::dpns_voting::DpnsVoteTargetStatus; use crate::model::masternode_input::decode_identity_id; use crate::model::qualified_identity::{IdentityStatus, IdentityType, MasternodeKeyPresence}; use crate::model::user_role::UserRole; @@ -29,6 +30,7 @@ use crate::ui::identity::picker::compute_column_count; use crate::ui::masternodes::card::{MasternodeCard, card_heading}; use crate::ui::masternodes::detail_screen::{DetailOutcome, MasternodeDetailView}; use crate::ui::masternodes::load_form::{LoadFormOutcome, MasternodeLoadForm}; +use crate::ui::masternodes::voting_center::{DpnsVotingCenter, VotingCenterOutcome}; use crate::ui::state::global_nav::PageNavSpec; use crate::ui::state::masternodes_view::{masternodes_page_nav_spec, node_pill_item}; use crate::ui::theme::{ComponentStyles, DashColors}; @@ -60,6 +62,10 @@ enum MasternodesView { Load(Box), /// A node's detail / voting view (FR-5). Detail(Box), + /// Shared full-page immediate, bulk, and scheduled composer. + Voting(Box), + /// Shared scheduled-target management. + Scheduled, } /// A load this screen dispatched and has not yet seen finish. The token names @@ -145,7 +151,7 @@ impl MasternodesScreen { let voter_id = qi .associated_voter_identity .as_ref() - .map(|(identity, _)| identity.id()); + .map(|_| qi.identity.id()); let contest_summary = self .app_context .masternode_contest_summary(voter_id) @@ -307,12 +313,98 @@ impl MasternodesScreen { AppAction::None } + /// Operator-level Nodes / Voting / Scheduled navigation. + fn render_voting_navigation(&mut self, ui: &mut egui::Ui) -> AppAction { + let dark_mode = ui.style().visuals.dark_mode; + let action = AppAction::None; + ui.horizontal(|ui| { + if matches!(self.view, MasternodesView::Voting(_)) { + if ComponentStyles::add_secondary_button(ui, "Nodes", dark_mode).clicked() { + self.view = MasternodesView::List; + } + ComponentStyles::add_primary_button(ui, "Voting"); + } else { + if ComponentStyles::add_primary_button(ui, "Nodes").clicked() { + self.view = MasternodesView::List; + } + if ComponentStyles::add_secondary_button(ui, "Voting", dark_mode).clicked() { + self.open_voting_center(None, Vec::new()); + } + } + if matches!(self.view, MasternodesView::Scheduled) { + ComponentStyles::add_primary_button(ui, "Scheduled"); + } else if ComponentStyles::add_secondary_button(ui, "Scheduled", dark_mode).clicked() { + self.view = MasternodesView::Scheduled; + } + }); + ui.separator(); + action + } + + /// Shared target-correlated progress, visible regardless of the active node. + fn render_voting_activity(&self, ui: &mut egui::Ui) -> AppAction { + let Ok(mut operations) = self.app_context.dpns_vote_operations() else { + return AppAction::None; + }; + operations.sort_by_key(|operation| operation.created_at); + let operations = operations + .into_iter() + .rev() + .filter(|operation| !operation.targets.is_empty()) + .take(5) + .collect::>(); + if operations.is_empty() { + return AppAction::None; + } + + let dark_mode = ui.style().visuals.dark_mode; + let mut action = AppAction::None; + ui.add_space(16.0); + ui.heading(RichText::new("Voting activity").color(DashColors::text_primary(dark_mode))); + for operation in operations { + for outcome in &operation.targets { + let voter = outcome.target.voter_alias.clone().unwrap_or_else(|| { + shorten_id(&outcome.target.key.voter_id.to_string(Encoding::Base58)) + }); + let status = match outcome.status { + DpnsVoteTargetStatus::Scheduled => "Scheduled", + DpnsVoteTargetStatus::Queued => "Queued", + DpnsVoteTargetStatus::Submitting => "Submitting", + DpnsVoteTargetStatus::Confirming => "Confirming", + DpnsVoteTargetStatus::Confirmed => "Confirmed", + DpnsVoteTargetStatus::Unconfirmed => "Checking result", + DpnsVoteTargetStatus::Rejected => "Rejected", + DpnsVoteTargetStatus::FailedBeforeSubmission => "Failed before submission", + DpnsVoteTargetStatus::NotApplied => "Not applied", + }; + ui.horizontal_wrapped(|ui| { + ui.label(format!( + "{voter} / {} β€” {status}", + outcome.target.contested_name + )); + if outcome.status == DpnsVoteTargetStatus::Unconfirmed + && ComponentStyles::add_secondary_button(ui, "Check again", dark_mode) + .clicked() + { + action = AppAction::BackendTask(BackendTask::ContestedResourceTask( + ContestedResourceTask::ReconcileDpnsVoteOperation(operation.id), + )); + } + }); + } + } + action + } + /// The node the page currently operates on β€” the one whose detail view is /// open. The list and load views operate on no single node. fn selected_node_id(&self) -> Option { match &self.view { MasternodesView::Detail(detail) => Some(detail.node_id()), - MasternodesView::List | MasternodesView::Load(_) => None, + MasternodesView::List + | MasternodesView::Load(_) + | MasternodesView::Voting(_) + | MasternodesView::Scheduled => None, } } @@ -384,10 +476,145 @@ impl MasternodesScreen { self.reload(); AppAction::None } + DetailOutcome::OpenVotingCenter { voter_id, choices } => { + self.view = if choices.is_empty() { + MasternodesView::Voting(Box::new(DpnsVotingCenter::new( + &self.app_context, + Some(voter_id), + Vec::new(), + ))) + } else { + MasternodesView::Voting(Box::new(DpnsVotingCenter::for_quick_votes( + &self.app_context, + voter_id, + choices, + ))) + }; + AppAction::None + } DetailOutcome::Forward(action) => *action, } } + fn open_voting_center( + &mut self, + preselected_voter: Option, + preselected_contests: Vec, + ) { + self.view = MasternodesView::Voting(Box::new(DpnsVotingCenter::new( + &self.app_context, + preselected_voter, + preselected_contests, + ))); + } + + fn render_voting_center(&mut self, ui: &mut egui::Ui) -> AppAction { + let outcome = match &mut self.view { + MasternodesView::Voting(center) => center.show(ui), + _ => return AppAction::None, + }; + match outcome { + VotingCenterOutcome::None => AppAction::None, + VotingCenterOutcome::BackToNodes => { + self.view = MasternodesView::List; + AppAction::None + } + VotingCenterOutcome::Action(action) => *action, + } + } + + fn render_scheduled_votes(&mut self, ui: &mut egui::Ui) -> AppAction { + let dark_mode = ui.style().visuals.dark_mode; + let mut action = AppAction::None; + ui.heading("Scheduled votes"); + ui.label( + "Upcoming and unresolved targets use the same operation locks as immediate votes.", + ); + let votes = self.app_context.get_scheduled_votes().unwrap_or_default(); + if votes.is_empty() { + ui.label("No scheduled votes."); + return action; + } + for vote in votes { + let status = self + .app_context + .dpns_vote_poll_id(&vote.contested_name) + .ok() + .and_then(|vote_poll_id| { + self.app_context + .dpns_vote_target_status(&crate::model::dpns_voting::DpnsVoteTargetKey { + network: self.app_context.network(), + voter_id: vote.voter_id, + vote_poll_id, + }) + .ok() + .flatten() + }); + ui.group(|ui| { + ui.label( + RichText::new(format!( + "{}.dash / {}", + vote.contested_name, + vote.voter_id.to_string(Encoding::Base58) + )) + .strong(), + ); + ui.label(format!("Choice: {}", vote.choice)); + ui.label(format!( + "Scheduled time: {} UTC milliseconds", + vote.unix_timestamp + )); + ui.label(match status { + Some(DpnsVoteTargetStatus::Unconfirmed) => "Status: Checking result", + Some( + DpnsVoteTargetStatus::Queued + | DpnsVoteTargetStatus::Submitting + | DpnsVoteTargetStatus::Confirming, + ) => "Status: Submitting", + Some(DpnsVoteTargetStatus::Scheduled) => "Status: Scheduled", + _ if vote.executed_successfully => "Status: Completed", + _ => "Status: Needs attention", + }); + let editable = status == Some(DpnsVoteTargetStatus::Scheduled); + if ComponentStyles::add_secondary_button(ui, "Edit schedule", dark_mode).clicked() + && editable + { + self.view = MasternodesView::Voting(Box::new( + DpnsVotingCenter::for_scheduled_edit(&self.app_context, &vote), + )); + } + if ComponentStyles::add_secondary_button(ui, "Cancel scheduled vote", dark_mode) + .clicked() + && editable + { + action = AppAction::BackendTask(BackendTask::ContestedResourceTask( + ContestedResourceTask::DeleteScheduledVote( + vote.voter_id, + vote.contested_name.clone(), + ), + )); + } + if status == Some(DpnsVoteTargetStatus::Unconfirmed) + && ComponentStyles::add_secondary_button(ui, "Check again", dark_mode).clicked() + && let Ok(Some(operation)) = + self.app_context.dpns_vote_operations().map(|operations| { + operations.into_iter().find(|operation| { + operation.targets.iter().any(|outcome| { + outcome.target.key.voter_id == vote.voter_id + && outcome.target.contested_name == vote.contested_name + }) + }) + }) + { + action = AppAction::BackendTask(BackendTask::ContestedResourceTask( + ContestedResourceTask::ReconcileDpnsVoteOperation(operation.id), + )); + } + }); + } + action + } + /// Render the list view: toolbar (`+ Load`, `Refresh`) + empty state or grid. fn render_list_view(&mut self, ui: &mut egui::Ui, network_accent: egui::Color32) -> AppAction { let mut inner = AppAction::None; @@ -514,6 +741,9 @@ impl ScreenLike for MasternodesScreen { fn refresh_on_arrival(&mut self) { self.reload(); self.reconcile_pending_load(); + if let Some(contests) = self.app_context.take_dpns_voting_center_route() { + self.open_voting_center(None, contests); + } } /// Drop every secret the open view holds β€” the load form's keys and @@ -531,7 +761,7 @@ impl ScreenLike for MasternodesScreen { match &mut self.view { MasternodesView::Load(form) => form.clear_secrets(), MasternodesView::Detail(detail) => detail.clear_secrets(), - MasternodesView::List => {} + MasternodesView::List | MasternodesView::Voting(_) | MasternodesView::Scheduled => {} } } @@ -575,11 +805,16 @@ impl ScreenLike for MasternodesScreen { action |= island_central_panel(ui, |ui| { ui.set_min_width(ui.available_width()); + let mut action = self.render_voting_navigation(ui); match self.view { - MasternodesView::Load(_) => self.render_load_view(ui), - MasternodesView::Detail(_) => self.render_detail_view(ui, network_accent), - MasternodesView::List => self.render_list_view(ui, network_accent), + MasternodesView::Load(_) => action |= self.render_load_view(ui), + MasternodesView::Detail(_) => action |= self.render_detail_view(ui, network_accent), + MasternodesView::Voting(_) => action |= self.render_voting_center(ui), + MasternodesView::Scheduled => action |= self.render_scheduled_votes(ui), + MasternodesView::List => action |= self.render_list_view(ui, network_accent), } + action |= self.render_voting_activity(ui); + action }); action diff --git a/src/ui/masternodes/mod.rs b/src/ui/masternodes/mod.rs index 89dbc3b52..27d3ed900 100644 --- a/src/ui/masternodes/mod.rs +++ b/src/ui/masternodes/mod.rs @@ -10,6 +10,7 @@ pub mod detail_screen; pub mod list_screen; pub mod load_form; pub mod testnet_fixture; +pub mod voting_center; pub use list_screen::MasternodesScreen; diff --git a/src/ui/masternodes/voting_center.rs b/src/ui/masternodes/voting_center.rs new file mode 100644 index 000000000..bda50bba2 --- /dev/null +++ b/src/ui/masternodes/voting_center.rs @@ -0,0 +1,704 @@ +//! Full-page Nodes β†’ Votes β†’ Review DPNS voting composer. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use chrono::{Duration, Utc}; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dash_sdk::platform::Identifier; +use eframe::egui::{self, ComboBox, RichText}; + +use crate::app::AppAction; +use crate::backend_task::BackendTask; +use crate::backend_task::contested_names::{ContestedResourceTask, ScheduledDPNSVote}; +use crate::backend_task::error::TaskError; +use crate::context::AppContext; +use crate::model::contested_name::ContestedName; +use crate::model::dpns_voting::{ + DpnsCurrentVoteState, DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteTarget, + DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, +}; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::ui::MessageType; +use crate::ui::components::MessageBanner; +use crate::ui::state::dpns_vote_workspace::{ + ComposerKeyAction, DpnsVoteComposerStep, DpnsVoteWorkspace, DraftVoteTiming, +}; +use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; + +pub enum VotingCenterOutcome { + None, + BackToNodes, + Action(Box), +} + +pub struct DpnsVotingCenter { + app_context: Arc, + voters: Vec, + contests: Vec, + workspace: DpnsVoteWorkspace, + submitted_operation: Option, + vote_state_refresh_dispatched: bool, + editing_scheduled_key: Option, + focus_step_heading: bool, +} + +impl DpnsVotingCenter { + pub fn new( + app_context: &Arc, + preselected_voter: Option, + preselected_contests: Vec, + ) -> Self { + let voters = app_context + .load_local_voting_identities() + .unwrap_or_default(); + let mut workspace = DpnsVoteWorkspace::new(voters.iter().map(|voter| voter.identity.id())); + if let Some(voter_id) = preselected_voter { + workspace.prefilter_node(voter_id); + } + let contests = app_context.ongoing_contested_names().unwrap_or_default(); + if !preselected_contests.is_empty() { + for name in preselected_contests { + if contests + .iter() + .any(|contest| contest.normalized_contested_name == name) + { + workspace + .contest_choices + .entry(name) + .or_insert(ResourceVoteChoice::Abstain); + } + } + } + Self { + app_context: Arc::clone(app_context), + voters, + contests, + workspace, + submitted_operation: None, + vote_state_refresh_dispatched: false, + editing_scheduled_key: None, + focus_step_heading: true, + } + } + + pub fn for_scheduled_edit(app_context: &Arc, vote: &ScheduledDPNSVote) -> Self { + let mut center = Self::new( + app_context, + Some(vote.voter_id), + vec![vote.contested_name.clone()], + ); + center + .workspace + .contest_choices + .insert(vote.contested_name.clone(), vote.choice); + let remaining_minutes = vote + .unix_timestamp + .saturating_sub(Utc::now().timestamp_millis() as u64) + / 60_000; + center.workspace.node_timing.insert( + vote.voter_id, + DraftVoteTiming::Scheduled { + days: (remaining_minutes / (24 * 60)) as u32, + hours: ((remaining_minutes / 60) % 24) as u32, + minutes: (remaining_minutes % 60) as u32, + }, + ); + center.editing_scheduled_key = app_context + .dpns_vote_poll_id(&vote.contested_name) + .ok() + .map(|vote_poll_id| DpnsVoteTargetKey { + network: app_context.network(), + voter_id: vote.voter_id, + vote_poll_id, + }); + center + } + + pub fn for_quick_votes( + app_context: &Arc, + voter_id: Identifier, + choices: BTreeMap, + ) -> Self { + let mut center = Self::new( + app_context, + Some(voter_id), + choices.keys().cloned().collect(), + ); + center.workspace.contest_choices = choices; + center.workspace.step = DpnsVoteComposerStep::Review; + center + } + + pub fn show(&mut self, ui: &mut egui::Ui) -> VotingCenterOutcome { + if self.submitted_operation.is_some() { + return self.render_operation(ui); + } + let (enter, escape) = ui.input(|input| { + ( + input.key_pressed(egui::Key::Enter), + input.key_pressed(egui::Key::Escape), + ) + }); + let can_continue = match self.workspace.step { + DpnsVoteComposerStep::Nodes => self.workspace.selected_node_count() > 0, + DpnsVoteComposerStep::Votes => !self.workspace.contest_choices.is_empty(), + DpnsVoteComposerStep::Review => self + .build_operation() + .is_ok_and(|operation| !operation.targets.is_empty()), + }; + match self.workspace.keyboard_action(enter, escape, can_continue) { + ComposerKeyAction::CloseDraft => return VotingCenterOutcome::BackToNodes, + ComposerKeyAction::Advance => { + self.workspace.step = match self.workspace.step { + DpnsVoteComposerStep::Nodes => DpnsVoteComposerStep::Votes, + DpnsVoteComposerStep::Votes | DpnsVoteComposerStep::Review => { + DpnsVoteComposerStep::Review + } + }; + self.focus_step_heading = true; + } + ComposerKeyAction::Submit => { + if let Ok(operation) = self.build_operation() { + return self.submit_operation(operation); + } + } + ComposerKeyAction::None => {} + } + let needs_refresh = self + .selected_current_states() + .iter() + .any(|(_, state)| matches!(state, DpnsCurrentVoteState::Checking)); + if needs_refresh && !self.vote_state_refresh_dispatched { + self.vote_state_refresh_dispatched = true; + return VotingCenterOutcome::Action(Box::new(AppAction::BackendTask( + BackendTask::ContestedResourceTask(ContestedResourceTask::QueryDPNSContests), + ))); + } + + match self.workspace.step { + DpnsVoteComposerStep::Nodes => self.render_nodes(ui), + DpnsVoteComposerStep::Votes => self.render_votes(ui), + DpnsVoteComposerStep::Review => self.render_review(ui), + } + } + + fn render_nodes(&mut self, ui: &mut egui::Ui) -> VotingCenterOutcome { + let dark_mode = ui.style().visuals.dark_mode; + self.step_heading(ui, "Step 1 of 3: Nodes and timing"); + ui.label("Choose which nodes will vote and when each node should submit."); + ui.horizontal_wrapped(|ui| { + ui.label("Set all:"); + timing_combo( + ui, + "voting_center_set_all", + &mut self.workspace.set_all_timing, + ); + if ComponentStyles::add_secondary_button(ui, "Apply", dark_mode).clicked() { + self.workspace.apply_timing_to_all(); + } + }); + ui.separator(); + for voter in &self.voters { + let voter_id = voter.identity.id(); + let alias = voter + .alias + .clone() + .unwrap_or_else(|| voter_id.to_string(Encoding::Base58)); + ui.horizontal_wrapped(|ui| { + ui.label(RichText::new(alias).strong()); + let timing = self + .workspace + .node_timing + .entry(voter_id) + .or_insert(DraftVoteTiming::Excluded); + timing_combo(ui, format!("voting_center_node_{voter_id}"), timing); + render_schedule_offset(ui, timing); + }); + } + ui.separator(); + let enabled = self.workspace.selected_node_count() > 0; + if ComponentStyles::add_primary_button_enabled(ui, enabled, "Next: Choose votes") + .disabled_tooltip("Choose at least one node before continuing.") + .clicked() + { + self.workspace.step = DpnsVoteComposerStep::Votes; + self.focus_step_heading = true; + } + VotingCenterOutcome::None + } + + fn render_votes(&mut self, ui: &mut egui::Ui) -> VotingCenterOutcome { + let dark_mode = ui.style().visuals.dark_mode; + self.step_heading(ui, "Step 2 of 3: Votes"); + ui.label("Choose one requested vote for each contested name."); + for contest in &self.contests { + let name = &contest.normalized_contested_name; + ui.separator(); + ui.label(RichText::new(format!("{name}.dash")).strong()); + let states = self.current_states_for_contest(contest); + ui.label( + RichText::new(current_summary(&states)) + .color(DashColors::text_secondary(dark_mode)), + ); + let vote_poll_id = self.app_context.dpns_vote_poll_id(name).ok(); + let controls_enabled = !states.iter().any(|(voter_id, state, locked)| { + let lock_is_this_edit = vote_poll_id.is_some_and(|vote_poll_id| { + self.editing_scheduled_key.as_ref() + == Some(&DpnsVoteTargetKey { + network: self.app_context.network(), + voter_id: *voter_id, + vote_poll_id, + }) + }); + (*locked && !lock_is_this_edit) + || !matches!(state, DpnsCurrentVoteState::Available(_)) + }); + let selected = self.workspace.contest_choices.get(name).copied(); + ui.add_enabled_ui(controls_enabled, |ui| { + ui.horizontal_wrapped(|ui| { + vote_choice( + ui, + selected, + ResourceVoteChoice::Abstain, + "Abstain", + &mut self.workspace, + name, + ); + vote_choice( + ui, + selected, + ResourceVoteChoice::Lock, + "Lock", + &mut self.workspace, + name, + ); + for candidate in contest.contestants.as_deref().unwrap_or_default() { + vote_choice( + ui, + selected, + ResourceVoteChoice::TowardsIdentity(candidate.id), + &format!("Vote for {}", candidate.name), + &mut self.workspace, + name, + ); + } + }); + }); + if !controls_enabled { + ui.label( + RichText::new( + "This contest is unavailable for at least one selected node. Refresh or wait for the active vote to finish.", + ) + .color(DashColors::text_secondary(dark_mode)), + ); + } + } + ui.separator(); + let mut outcome = VotingCenterOutcome::None; + ui.horizontal(|ui| { + if ComponentStyles::add_secondary_button(ui, "Back", dark_mode).clicked() { + self.workspace.step = DpnsVoteComposerStep::Nodes; + self.focus_step_heading = true; + } + let target_count = + self.workspace.selected_node_count() * self.workspace.contest_choices.len(); + let enabled = target_count > 0; + if ComponentStyles::add_primary_button_enabled( + ui, + enabled, + format!("Review {target_count} targets"), + ) + .disabled_tooltip("Choose at least one contested name before continuing.") + .clicked() + { + self.workspace.step = DpnsVoteComposerStep::Review; + self.focus_step_heading = true; + } + if ComponentStyles::add_secondary_button(ui, "Close Voting Center", dark_mode).clicked() + { + outcome = VotingCenterOutcome::BackToNodes; + } + }); + outcome + } + + fn render_review(&mut self, ui: &mut egui::Ui) -> VotingCenterOutcome { + let dark_mode = ui.style().visuals.dark_mode; + self.step_heading(ui, "Step 3 of 3: Review"); + let operation = match self.build_operation() { + Ok(operation) => operation, + Err(error) => { + MessageBanner::set_global(ui.ctx(), error.to_string(), MessageType::Error) + .with_details(&error); + self.workspace.step = DpnsVoteComposerStep::Votes; + self.focus_step_heading = true; + return VotingCenterOutcome::None; + } + }; + for outcome in &operation.targets { + let voter = outcome + .target + .voter_alias + .as_deref() + .unwrap_or("Unnamed node"); + ui.group(|ui| { + ui.label(RichText::new(format!( + "{voter} / {}.dash", + outcome.target.contested_name + )) + .strong()); + ui.label(format!( + "Current: {}", + choice_label(outcome.target.current_choice) + )); + ui.label(format!( + "Requested: {}", + choice_label(Some(outcome.target.requested_choice)) + )); + ui.label(match outcome.target.timing { + VoteTiming::Now => "When: Cast now".to_owned(), + VoteTiming::Scheduled(timestamp) => { + format!("When: Scheduled for {timestamp} UTC milliseconds") + } + }); + if outcome.target.current_choice.is_some() { + ui.label( + RichText::new( + "This changes an existing vote. Platform permits only a limited number of vote changes.", + ) + .color(DashColors::warning_color(dark_mode)), + ); + } + }); + } + if operation.no_op_count > 0 { + ui.label(format!( + "{} targets already have the requested vote and will not be submitted.", + operation.no_op_count + )); + } + ui.label(format!( + "{} targets total. Each submitted vote uses Platform credits.", + operation.targets.len() + )); + ui.horizontal(|ui| { + if ComponentStyles::add_secondary_button(ui, "Back", dark_mode).clicked() { + self.workspace.step = DpnsVoteComposerStep::Votes; + self.focus_step_heading = true; + } + }); + if operation.targets.is_empty() { + ui.label( + "Every selected node already has the requested vote. Nothing will be submitted.", + ); + return VotingCenterOutcome::None; + } + let target_count = operation.targets.len(); + if ComponentStyles::add_primary_button(ui, format!("Submit {target_count} targets")) + .clicked() + { + return self.submit_operation(operation); + } + VotingCenterOutcome::None + } + + fn render_operation(&mut self, ui: &mut egui::Ui) -> VotingCenterOutcome { + let dark_mode = ui.style().visuals.dark_mode; + let Some(operation_id) = self.submitted_operation else { + return VotingCenterOutcome::None; + }; + ui.heading("Voting operation"); + match self.app_context.dpns_vote_operation(operation_id) { + Ok(Some(operation)) => { + for outcome in &operation.targets { + ui.horizontal_wrapped(|ui| { + ui.label(format!( + "{} / {}.dash β€” {}", + outcome + .target + .voter_alias + .as_deref() + .unwrap_or("Unnamed node"), + outcome.target.contested_name, + status_label(outcome.status) + )); + }); + } + if operation + .targets + .iter() + .any(|outcome| outcome.status == DpnsVoteTargetStatus::Unconfirmed) + && ComponentStyles::add_secondary_button(ui, "Check again", dark_mode).clicked() + { + return VotingCenterOutcome::Action(Box::new(AppAction::BackendTask( + BackendTask::ContestedResourceTask( + ContestedResourceTask::ReconcileDpnsVoteOperation(operation_id), + ), + ))); + } + if ComponentStyles::add_secondary_button(ui, "Continue in background", dark_mode) + .clicked() + { + return VotingCenterOutcome::BackToNodes; + } + } + Ok(None) => { + ui.horizontal(|ui| { + ui.spinner(); + ui.label("Queuing votes…"); + }); + } + Err(error) => { + ui.label(error.to_string()); + } + } + VotingCenterOutcome::None + } + + fn selected_voters(&self) -> Vec { + self.voters + .iter() + .filter(|voter| { + self.workspace + .node_timing + .get(&voter.identity.id()) + .is_some_and(|timing| *timing != DraftVoteTiming::Excluded) + }) + .cloned() + .collect() + } + + fn submit_operation(&mut self, operation: DpnsVoteOperation) -> VotingCenterOutcome { + self.submitted_operation = Some(operation.id); + VotingCenterOutcome::Action(Box::new(AppAction::BackendTask( + BackendTask::ContestedResourceTask(ContestedResourceTask::SubmitDpnsVoteOperation( + operation, + self.selected_voters(), + )), + ))) + } + + fn step_heading(&mut self, ui: &mut egui::Ui, text: &str) { + let response = ui.heading(text); + if std::mem::take(&mut self.focus_step_heading) { + response.request_focus(); + } + } + + fn selected_current_states(&self) -> Vec<(Identifier, DpnsCurrentVoteState)> { + self.contests + .iter() + .flat_map(|contest| { + let poll_id = self + .app_context + .dpns_vote_poll_id(&contest.normalized_contested_name) + .ok(); + self.selected_voters().into_iter().filter_map(move |voter| { + let poll_id = poll_id?; + let voter_id = voter.identity.id(); + Some(( + voter_id, + self.app_context + .dpns_current_vote_state(voter_id, poll_id) + .unwrap_or(DpnsCurrentVoteState::Unavailable), + )) + }) + }) + .collect() + } + + fn current_states_for_contest( + &self, + contest: &ContestedName, + ) -> Vec<(Identifier, DpnsCurrentVoteState, bool)> { + let Ok(vote_poll_id) = self + .app_context + .dpns_vote_poll_id(&contest.normalized_contested_name) + else { + return Vec::new(); + }; + self.selected_voters() + .into_iter() + .map(|voter| { + let voter_id = voter.identity.id(); + let key = DpnsVoteTargetKey { + network: self.app_context.network(), + voter_id, + vote_poll_id, + }; + ( + voter_id, + self.app_context + .dpns_current_vote_state(voter_id, vote_poll_id) + .unwrap_or(DpnsCurrentVoteState::Unavailable), + self.app_context + .dpns_vote_target_status(&key) + .ok() + .flatten() + .is_some(), + ) + }) + .collect() + } + + fn build_operation(&self) -> Result { + let mut targets = Vec::new(); + for voter in self.selected_voters() { + let voter_id = voter.identity.id(); + let draft_timing = self.workspace.node_timing[&voter_id]; + let timing = match draft_timing { + DraftVoteTiming::Excluded => continue, + DraftVoteTiming::Now => VoteTiming::Now, + DraftVoteTiming::Scheduled { + days, + hours, + minutes, + } => VoteTiming::Scheduled( + (Utc::now() + + Duration::days(i64::from(days)) + + Duration::hours(i64::from(hours)) + + Duration::minutes(i64::from(minutes))) + .timestamp_millis() as u64, + ), + }; + for (name, requested_choice) in &self.workspace.contest_choices { + let vote_poll_id = self.app_context.dpns_vote_poll_id(name)?; + let key = DpnsVoteTargetKey { + network: self.app_context.network(), + voter_id, + vote_poll_id, + }; + let existing_status = self.app_context.dpns_vote_target_status(&key)?; + let replacing_schedule = existing_status == Some(DpnsVoteTargetStatus::Scheduled) + && matches!(timing, VoteTiming::Scheduled(_)); + if existing_status.is_some() && !replacing_schedule { + return Err(TaskError::DpnsVoteTargetBusy); + } + let DpnsCurrentVoteState::Available(current_choice) = self + .app_context + .dpns_current_vote_state(voter_id, vote_poll_id)? + else { + return Err(TaskError::DpnsCurrentVoteUnavailable); + }; + targets.push(DpnsVoteTarget { + key, + voter_alias: voter.alias.clone(), + contested_name: name.clone(), + requested_choice: *requested_choice, + current_choice, + timing, + }); + } + } + Ok(DpnsVoteOperation::new(targets)) + } +} + +fn timing_combo(ui: &mut egui::Ui, id: impl Into, timing: &mut DraftVoteTiming) { + ComboBox::from_id_salt(id.into()) + .selected_text(match timing { + DraftVoteTiming::Excluded => "Do not use this node", + DraftVoteTiming::Now => "Cast now", + DraftVoteTiming::Scheduled { .. } => "Schedule", + }) + .show_ui(ui, |ui| { + ui.selectable_value(timing, DraftVoteTiming::Excluded, "Do not use this node"); + ui.selectable_value(timing, DraftVoteTiming::Now, "Cast now"); + if ui + .selectable_label( + matches!(timing, DraftVoteTiming::Scheduled { .. }), + "Schedule", + ) + .clicked() + { + *timing = DraftVoteTiming::Scheduled { + days: 0, + hours: 0, + minutes: 0, + }; + } + }); +} + +fn render_schedule_offset(ui: &mut egui::Ui, timing: &mut DraftVoteTiming) { + if let DraftVoteTiming::Scheduled { + days, + hours, + minutes, + } = timing + { + ui.add(egui::DragValue::new(days).prefix("Days: ").range(0..=14)); + ui.add(egui::DragValue::new(hours).prefix("Hours: ").range(0..=23)); + ui.add( + egui::DragValue::new(minutes) + .prefix("Minutes: ") + .range(0..=59), + ); + } +} + +fn vote_choice( + ui: &mut egui::Ui, + selected: Option, + choice: ResourceVoteChoice, + label: &str, + workspace: &mut DpnsVoteWorkspace, + name: &str, +) { + if ui + .selectable_label(selected == Some(choice), label) + .clicked() + { + workspace.contest_choices.insert(name.to_owned(), choice); + } +} + +fn current_summary(states: &[(Identifier, DpnsCurrentVoteState, bool)]) -> String { + if states.iter().any(|(_, state, _)| { + matches!( + state, + DpnsCurrentVoteState::Checking | DpnsCurrentVoteState::Unavailable + ) + }) { + return "Current vote unavailable for at least one selected node".to_owned(); + } + let not_voted = states + .iter() + .filter(|(_, state, _)| *state == DpnsCurrentVoteState::Available(None)) + .count(); + if not_voted == states.len() { + "Current across selected nodes: Not voted".to_owned() + } else if not_voted > 0 { + format!("Current across selected nodes: {not_voted} not voted, others already voted") + } else { + "Current across selected nodes: All already voted".to_owned() + } +} + +fn choice_label(choice: Option) -> String { + match choice { + None => "Not voted".to_owned(), + Some(ResourceVoteChoice::Abstain) => "Abstain".to_owned(), + Some(ResourceVoteChoice::Lock) => "Lock".to_owned(), + Some(ResourceVoteChoice::TowardsIdentity(identity)) => { + format!("Vote for {identity}") + } + } +} + +fn status_label(status: DpnsVoteTargetStatus) -> &'static str { + match status { + DpnsVoteTargetStatus::Scheduled => "Scheduled", + DpnsVoteTargetStatus::Queued => "Queued", + DpnsVoteTargetStatus::Submitting => "Submitting", + DpnsVoteTargetStatus::Confirming => "Confirming", + DpnsVoteTargetStatus::Confirmed => "Confirmed", + DpnsVoteTargetStatus::Unconfirmed => "Checking result", + DpnsVoteTargetStatus::Rejected => "Rejected", + DpnsVoteTargetStatus::FailedBeforeSubmission => "Failed before submission", + DpnsVoteTargetStatus::NotApplied => "Not applied", + } +} diff --git a/src/ui/state/dpns_vote_workspace.rs b/src/ui/state/dpns_vote_workspace.rs new file mode 100644 index 000000000..1b3170590 --- /dev/null +++ b/src/ui/state/dpns_vote_workspace.rs @@ -0,0 +1,158 @@ +//! Non-rendering state for the shared DPNS Voting Center composer. + +use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dash_sdk::platform::Identifier; +use std::collections::BTreeMap; + +/// Current step of the full-page voting composer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DpnsVoteComposerStep { + Nodes, + Votes, + Review, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ComposerKeyAction { + None, + Advance, + Submit, + CloseDraft, +} + +/// Per-node timing override in the draft. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DraftVoteTiming { + Excluded, + Now, + Scheduled { days: u32, hours: u32, minutes: u32 }, +} + +/// Shared quick/bulk draft state; renders nothing. +#[derive(Debug, Clone)] +pub struct DpnsVoteWorkspace { + pub step: DpnsVoteComposerStep, + pub node_timing: BTreeMap, + pub contest_choices: BTreeMap, + pub set_all_timing: DraftVoteTiming, +} + +impl DpnsVoteWorkspace { + pub fn new(node_ids: impl IntoIterator) -> Self { + Self { + step: DpnsVoteComposerStep::Nodes, + node_timing: node_ids + .into_iter() + .map(|node_id| (node_id, DraftVoteTiming::Now)) + .collect(), + contest_choices: BTreeMap::new(), + set_all_timing: DraftVoteTiming::Now, + } + } + + /// Restrict the initial draft to one node from a detail-page deep link. + pub fn prefilter_node(&mut self, selected: Identifier) { + for (node_id, timing) in &mut self.node_timing { + *timing = if *node_id == selected { + DraftVoteTiming::Now + } else { + DraftVoteTiming::Excluded + }; + } + } + + pub fn selected_node_count(&self) -> usize { + self.node_timing + .values() + .filter(|timing| **timing != DraftVoteTiming::Excluded) + .count() + } + + pub fn apply_timing_to_all(&mut self) { + for timing in self.node_timing.values_mut() { + *timing = self.set_all_timing; + } + } + + /// Resolve keyboard intent without letting Enter submit before Review. + pub fn keyboard_action( + &self, + enter_pressed: bool, + escape_pressed: bool, + can_continue: bool, + ) -> ComposerKeyAction { + if escape_pressed { + return ComposerKeyAction::CloseDraft; + } + if !enter_pressed || !can_continue { + return ComposerKeyAction::None; + } + match self.step { + DpnsVoteComposerStep::Nodes | DpnsVoteComposerStep::Votes => ComposerKeyAction::Advance, + DpnsVoteComposerStep::Review => ComposerKeyAction::Submit, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// VOTE-TC-021/022: set-all applies timing and an individual override survives. + #[test] + fn set_all_timing_allows_per_node_override() { + let first = Identifier::from([1; 32]); + let second = Identifier::from([2; 32]); + let mut workspace = DpnsVoteWorkspace::new([first, second]); + workspace.set_all_timing = DraftVoteTiming::Scheduled { + days: 1, + hours: 2, + minutes: 3, + }; + workspace.apply_timing_to_all(); + workspace.node_timing.insert(first, DraftVoteTiming::Now); + + assert_eq!(workspace.node_timing[&first], DraftVoteTiming::Now); + assert!(matches!( + workspace.node_timing[&second], + DraftVoteTiming::Scheduled { .. } + )); + } + + /// VOTE-TC-024: a node-detail route selects only that node. + #[test] + fn node_prefilter_excludes_every_other_node() { + let selected = Identifier::from([1; 32]); + let other = Identifier::from([2; 32]); + let mut workspace = DpnsVoteWorkspace::new([selected, other]); + workspace.prefilter_node(selected); + + assert_eq!(workspace.selected_node_count(), 1); + assert_eq!(workspace.node_timing[&selected], DraftVoteTiming::Now); + assert_eq!(workspace.node_timing[&other], DraftVoteTiming::Excluded); + } + + /// VOTE-TC-071: Enter advances drafts but submits only from Review; Escape closes drafts. + #[test] + fn keyboard_actions_respect_composer_step() { + let mut workspace = DpnsVoteWorkspace::new([Identifier::from([1; 32])]); + assert_eq!( + workspace.keyboard_action(true, false, true), + ComposerKeyAction::Advance + ); + workspace.step = DpnsVoteComposerStep::Votes; + assert_eq!( + workspace.keyboard_action(true, false, true), + ComposerKeyAction::Advance + ); + workspace.step = DpnsVoteComposerStep::Review; + assert_eq!( + workspace.keyboard_action(true, false, true), + ComposerKeyAction::Submit + ); + assert_eq!( + workspace.keyboard_action(false, true, true), + ComposerKeyAction::CloseDraft + ); + } +} diff --git a/src/ui/state/mod.rs b/src/ui/state/mod.rs index 791c38c6b..99d73f209 100644 --- a/src/ui/state/mod.rs +++ b/src/ui/state/mod.rs @@ -8,6 +8,7 @@ pub mod account_summary; pub mod avatar_cache; pub mod contacts_view; +pub mod dpns_vote_workspace; pub mod global_nav; pub mod hub_selection; pub mod masternodes_view; diff --git a/tests/kittest/masternode_tab.rs b/tests/kittest/masternode_tab.rs index 3439153cc..e28fc3275 100644 --- a/tests/kittest/masternode_tab.rs +++ b/tests/kittest/masternode_tab.rs @@ -261,6 +261,36 @@ fn empty_state_renders_canonical_copy() { }); } +/// VOTE-TC-023: operator navigation exposes Nodes, Voting, and Scheduled and +/// opens the shared full-page composer. +#[test] +fn voting_navigation_routes_to_shared_workspaces() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + activate_masternodes_tab(&mut harness, &app_context); + + assert!(harness.query_by_label("Nodes").is_some()); + assert!(harness.query_by_label("Voting").is_some()); + assert!(harness.query_by_label("Scheduled").is_some()); + + harness.get_by_label("Voting").click(); + harness.run_steps(3); + assert_eq!( + harness.state().selected_main_screen, + RootScreenType::RootScreenMasternodes + ); + assert!( + harness + .query_by_label("Step 1 of 3: Nodes and timing") + .is_some() + ); + }); +} + /// TC-FR3-01/15, TC-FR7-01, TC-NFR6-01 β€” with nodes loaded the grid renders one /// card per node (not the empty state), each card is a single accessible click /// target labelled `Open {node}`, the status label pairs with its colour, and From 0c012cc1e90dcd4cf6e8f9ae9f4bff93fe886a40 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:49:27 +0000 Subject: [PATCH 03/39] fix(dpns): harden voting operation recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serialize nonce-consuming submissions across independent operations, make the operation journal network-qualified and fail-closed, recover interrupted work conservatively, require fresh proved state, and prevent scheduled terminal outcomes from being rebroadcast. Co-Authored-By: Codex GPT-5 πŸ€– Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- src/app.rs | 20 +- src/backend_task/contested_names/mod.rs | 381 +++++++++++++----- src/backend_task/error.rs | 26 ++ src/context/dpns_vote_operations.rs | 508 +++++++++++++++++++++--- src/context/dpns_vote_state.rs | 117 +++++- src/context/mod.rs | 121 +++++- 6 files changed, 1006 insertions(+), 167 deletions(-) diff --git a/src/app.rs b/src/app.rs index 8b0d13081..861f76bde 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2070,23 +2070,31 @@ impl App for AppState { ) }) .count(); + let diagnostics = active_context + .dpns_vote_operation_diagnostics(operation_id); if unconfirmed > 0 { - MessageBanner::set_global( + let handle = MessageBanner::set_global( ctx, "The vote was submitted, but DET could not confirm the result yet. DET will keep checking. Do not submit it again.", MessageType::Warning, - ) - .disable_auto_dismiss(); + ); + if !diagnostics.is_empty() { + handle.with_details(&diagnostics); + } + handle.disable_auto_dismiss(); } else if rejected > 0 { - MessageBanner::set_global( + let handle = MessageBanner::set_global( ctx, format!( "{confirmed} of {total} votes were confirmed. Review the remaining {}.", total.saturating_sub(confirmed) ), MessageType::Warning, - ) - .disable_auto_dismiss(); + ); + if !diagnostics.is_empty() { + handle.with_details(&diagnostics); + } + handle.disable_auto_dismiss(); } else if scheduled == total && total > 0 { MessageBanner::set_global( ctx, diff --git a/src/backend_task/contested_names/mod.rs b/src/backend_task/contested_names/mod.rs index 87bb17b7c..570a8d027 100644 --- a/src/backend_task/contested_names/mod.rs +++ b/src/backend_task/contested_names/mod.rs @@ -79,6 +79,19 @@ fn classify_vote_attempt( } } +fn classify_reconciled_vote( + observed: Option, + requested: ResourceVoteChoice, +) -> Option { + observed.map(|choice| { + if choice == requested { + DpnsVoteTargetStatus::Confirmed + } else { + DpnsVoteTargetStatus::Rejected + } + }) +} + /// Logs a Drive proof-verification failure raised by a contested-resource query. /// /// No-op unless `e` is a [`dash_sdk::Error::Proof`] carrying a GroveDB proof @@ -112,6 +125,7 @@ impl AppContext { sdk: &Sdk, sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { + self.ensure_dpns_vote_recovery(sdk).await?; match task { ContestedResourceTask::QueryDPNSContests => self .query_dpns_contested_resources(sdk, sender) @@ -155,6 +169,33 @@ impl AppContext { } } + async fn ensure_dpns_vote_recovery(self: &Arc, sdk: &Sdk) -> Result<(), TaskError> { + let mut recovered = self.dpns_vote_recovery.lock().await; + if *recovered { + return Ok(()); + } + self.recover_interrupted_dpns_vote_operations()?; + let queued = self + .dpns_vote_operations()? + .into_iter() + .filter(|operation| { + operation + .targets + .iter() + .any(|outcome| outcome.status == DpnsVoteTargetStatus::Queued) + }) + .collect::>(); + if !queued.is_empty() { + let voters = self.load_local_voting_identities()?; + for operation in queued { + self.execute_dpns_vote_operation(operation, voters.clone(), sdk) + .await?; + } + } + *recovered = true; + Ok(()) + } + fn dpns_vote_target( &self, voter: &QualifiedIdentity, @@ -197,18 +238,44 @@ impl AppContext { operation.targets.iter().any(|outcome| { outcome.target.key.voter_id == scheduled_vote.voter_id && outcome.target.contested_name == scheduled_vote.contested_name - && outcome.status == DpnsVoteTargetStatus::Scheduled }) }) { + let mut target_queued = false; for outcome in &mut operation.targets { - if outcome.target.key.voter_id == scheduled_vote.voter_id - && outcome.target.contested_name == scheduled_vote.contested_name - && outcome.status == DpnsVoteTargetStatus::Scheduled + if outcome.target.key.voter_id != scheduled_vote.voter_id + || outcome.target.contested_name != scheduled_vote.contested_name { - outcome.status = DpnsVoteTargetStatus::Queued; + continue; + } + match outcome.status { + DpnsVoteTargetStatus::Scheduled => { + outcome.status = DpnsVoteTargetStatus::Queued; + target_queued = true; + } + DpnsVoteTargetStatus::Unconfirmed + | DpnsVoteTargetStatus::Queued + | DpnsVoteTargetStatus::Submitting + | DpnsVoteTargetStatus::Confirming => { + return Err(TaskError::DpnsVoteTargetBusy); + } + DpnsVoteTargetStatus::Confirmed => { + self.mark_vote_executed( + scheduled_vote.voter_id.as_slice(), + scheduled_vote.contested_name.clone(), + )?; + return Ok(operation); + } + DpnsVoteTargetStatus::Rejected + | DpnsVoteTargetStatus::FailedBeforeSubmission + | DpnsVoteTargetStatus::NotApplied => { + // An explicit Cast now action is a deliberate retry and + // may create a new operation below. + } } } - return Ok(operation); + if target_queued { + return Ok(operation); + } } let target = self.dpns_vote_target( @@ -225,7 +292,7 @@ impl AppContext { async fn execute_dpns_vote_operation( self: &Arc, - operation: DpnsVoteOperation, + mut operation: DpnsVoteOperation, voters: Vec, sdk: &Sdk, ) -> Result { @@ -234,8 +301,53 @@ impl AppContext { operation.id, )); } - if self.dpns_vote_operation(operation.id)?.is_some() { - self.update_dpns_vote_operation(&operation)?; + let was_persisted = self.dpns_vote_operation(operation.id)?.is_some(); + if operation + .targets + .iter() + .any(|outcome| outcome.status == DpnsVoteTargetStatus::Queued) + { + // A fresh proved snapshot is a submission precondition. This also + // prevents a due schedule from replaying a vote already observed. + self.refresh_dpns_vote_states(sdk).await; + let queued_keys = operation + .targets + .iter() + .filter(|outcome| outcome.status == DpnsVoteTargetStatus::Queued) + .map(|outcome| outcome.target.key.clone()) + .collect::>(); + for key in queued_keys { + let state = self.dpns_current_vote_state(key.voter_id, key.vote_poll_id)?; + if was_persisted { + self.revalidate_queued_dpns_vote_target(operation.id, &key, state)?; + continue; + } + let Some(outcome) = operation + .targets + .iter_mut() + .find(|outcome| outcome.target.key == key) + else { + continue; + }; + match state { + DpnsCurrentVoteState::Available(current) => { + outcome.target.current_choice = current; + if current == Some(outcome.target.requested_choice) { + outcome.status = DpnsVoteTargetStatus::Confirmed; + outcome.failure = None; + } + } + DpnsCurrentVoteState::Checking | DpnsCurrentVoteState::Unavailable => { + return Err(TaskError::DpnsCurrentVoteUnavailable); + } + } + } + } + + if was_persisted { + operation = self + .dpns_vote_operation(operation.id)? + .ok_or(TaskError::DpnsVoteOperationRecordMissing)?; } else { let scheduled_votes = operation .targets @@ -251,10 +363,29 @@ impl AppContext { VoteTiming::Now => None, }) .collect::>(); - if !scheduled_votes.is_empty() { - self.insert_scheduled_votes(&scheduled_votes)?; - } self.insert_dpns_vote_operation(&operation)?; + if !scheduled_votes.is_empty() + && let Err(error) = self.insert_scheduled_votes(&scheduled_votes) + { + // The journal is authoritative. The legacy table is a + // compatibility mirror, so its failure cannot turn a durable + // schedule into a reported failure that invites a duplicate. + tracing::warn!( + ?error, + operation_id = %operation.id, + "DPNS vote schedule was journaled but its legacy mirror could not be updated" + ); + } + } + + for outcome in operation.targets.iter().filter(|outcome| { + outcome.status == DpnsVoteTargetStatus::Confirmed + && matches!(outcome.target.timing, VoteTiming::Scheduled(_)) + }) { + self.mark_vote_executed( + outcome.target.key.voter_id.as_slice(), + outcome.target.contested_name.clone(), + )?; } let voters_by_id: BTreeMap = voters @@ -283,25 +414,25 @@ impl AppContext { async move { let Some(voter) = voter else { for target in targets { - app_context.update_dpns_vote_target( - operation_id, - &target.key, - DpnsVoteTargetStatus::FailedBeforeSubmission, - Some(DpnsVoteFailure::SubmissionFailed), - )?; + if app_context.claim_dpns_vote_target(operation_id, &target.key)? { + app_context.update_dpns_vote_target( + operation_id, + &target.key, + DpnsVoteTargetStatus::FailedBeforeSubmission, + Some(DpnsVoteFailure::SubmissionFailed), + )?; + } } return Ok::<(), TaskError>(()); }; // One voter's targets are deliberately sequential: PutVote // obtains and consumes the same masternode nonce. + let _dispatch_guard = app_context.dpns_vote_dispatch.acquire(voter_id).await?; for target in targets { - app_context.update_dpns_vote_target( - operation_id, - &target.key, - DpnsVoteTargetStatus::Submitting, - None, - )?; + if !app_context.claim_dpns_vote_target(operation_id, &target.key)? { + continue; + } let attempt = app_context .submit_dpns_vote( &target.contested_name, @@ -332,6 +463,11 @@ impl AppContext { contested_name = %target.contested_name, "DPNS vote was submitted but remains unconfirmed" ); + app_context.record_dpns_vote_diagnostic( + operation_id, + target.key.clone(), + error, + ); } Ok(vote_on_dpns_name::DpnsVoteAttempt::Rejected(error)) => { tracing::warn!( @@ -340,6 +476,11 @@ impl AppContext { contested_name = %target.contested_name, "Platform rejected a DPNS vote" ); + app_context.record_dpns_vote_diagnostic( + operation_id, + target.key.clone(), + error, + ); } Err(error) => { tracing::warn!( @@ -348,6 +489,11 @@ impl AppContext { contested_name = %target.contested_name, "DPNS vote failed before a confirmed submission" ); + app_context.record_dpns_vote_diagnostic( + operation_id, + target.key.clone(), + error, + ); } } app_context.update_dpns_vote_target( @@ -396,12 +542,13 @@ impl AppContext { }; match ResourceVote::fetch_many(sdk, query).await { Ok(votes) - if votes - .get(&poll_id) - .and_then(Option::as_ref) - .is_some_and(|vote| { - vote.resource_vote_choice() == outcome.target.requested_choice - }) => + if classify_reconciled_vote( + votes + .get(&poll_id) + .and_then(Option::as_ref) + .map(ResourceVoteGettersV0::resource_vote_choice), + outcome.target.requested_choice, + ) == Some(DpnsVoteTargetStatus::Confirmed) => { self.cache_confirmed_dpns_vote( outcome.target.key.voter_id, @@ -414,15 +561,45 @@ impl AppContext { DpnsVoteTargetStatus::Confirmed, None, )?; + if matches!(outcome.target.timing, VoteTiming::Scheduled(_)) { + self.mark_vote_executed( + outcome.target.key.voter_id.as_slice(), + outcome.target.contested_name.clone(), + )?; + } + } + Ok(votes) + if classify_reconciled_vote( + votes + .get(&poll_id) + .and_then(Option::as_ref) + .map(ResourceVoteGettersV0::resource_vote_choice), + outcome.target.requested_choice, + ) == Some(DpnsVoteTargetStatus::Rejected) => + { + self.update_dpns_vote_target( + operation_id, + &outcome.target.key, + DpnsVoteTargetStatus::Rejected, + Some(DpnsVoteFailure::PlatformRejected), + )?; } Ok(_) => {} - Err(error) => tracing::warn!( - ?error, - operation_id = %operation_id, - voter_id = %outcome.target.key.voter_id, - contested_name = %outcome.target.contested_name, - "Could not reconcile an unconfirmed DPNS vote" - ), + Err(error) => { + let error = TaskError::from(error); + tracing::warn!( + ?error, + operation_id = %operation_id, + voter_id = %outcome.target.key.voter_id, + contested_name = %outcome.target.contested_name, + "Could not reconcile an unconfirmed DPNS vote" + ); + self.record_dpns_vote_diagnostic( + operation_id, + outcome.target.key.clone(), + error, + ); + } } } Ok(BackendTaskSuccessResult::DpnsVoteOperationUpdated( @@ -466,19 +643,38 @@ impl AppContext { .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); - let due: Vec = self - .get_scheduled_votes()? - .into_iter() - .filter(|vote| { - scheduled_vote_is_due( - vote.unix_timestamp, - vote.executed_successfully, - now_ms, - preserve_eligibility_since_ms, - ) - }) - .collect(); - if due.is_empty() { + let mut due_operations = Vec::new(); + let mut in_progress = Vec::new(); + for mut operation in self.dpns_vote_operations()? { + let mut due = false; + for outcome in &mut operation.targets { + let VoteTiming::Scheduled(scheduled_at) = outcome.target.timing else { + continue; + }; + if outcome.status == DpnsVoteTargetStatus::Scheduled + && scheduled_vote_is_due( + scheduled_at, + false, + now_ms, + preserve_eligibility_since_ms, + ) + { + outcome.status = DpnsVoteTargetStatus::Queued; + due = true; + in_progress.push(ScheduledDPNSVote { + contested_name: outcome.target.contested_name.clone(), + voter_id: outcome.target.key.voter_id, + choice: outcome.target.requested_choice, + unix_timestamp: scheduled_at, + executed_successfully: false, + }); + } + } + if due { + due_operations.push(operation); + } + } + if due_operations.is_empty() { return Ok(BackendTaskSuccessResult::ScheduledVoteSweepCompleted { network: self.network, preserve_eligibility_since_ms, @@ -486,67 +682,37 @@ impl AppContext { } let voters = self.load_local_voting_identities()?; - let mut castable: Vec<(ScheduledDPNSVote, QualifiedIdentity)> = Vec::new(); - let mut first_error = None; - for vote in due { - match voters.iter().find(|i| i.identity.id() == vote.voter_id) { - Some(voter) => castable.push((vote, voter.clone())), - None => { - tracing::warn!( - contested_name = %vote.contested_name, - "No local voting identity for a scheduled vote; skipping it" - ); - first_error.get_or_insert_with(|| TaskError::NoVotingIdentity { - identity_id: vote.voter_id.to_string(Encoding::Base58), - }); - } - } - } - if castable.is_empty() { - return Err(first_error.unwrap_or(TaskError::ScheduledVoteResultUnavailable)); - } - // Tell the Scheduled Votes screen which votes are now in flight. - let in_progress = castable.iter().map(|(v, _)| v.clone()).collect(); let _ = sender .send(TaskResult::unattributed_success( BackendTaskSuccessResult::ScheduledVotesInProgress(in_progress), )) .await; - let mut groups: BTreeMap> = - BTreeMap::new(); - for (vote, voter) in castable { - groups.entry(vote.voter_id).or_default().push((vote, voter)); - } - let results = stream::iter(groups) - .map(|(_, scheduled)| { + let results = stream::iter(due_operations) + .map(|operation| { let app_context = Arc::clone(self); let sdk = sdk.clone(); + let voters = voters.clone(); + let operation_id = operation.id; async move { - let mut results = Vec::with_capacity(scheduled.len()); - for (vote, voter) in scheduled { - let result = match app_context.operation_for_scheduled_vote(&vote, &voter) { - Ok(operation) => app_context - .execute_dpns_vote_operation(operation, vec![voter], &sdk) - .await - .map(|_| ()), - Err(error) => Err(error), - }; - results.push((vote, result)); - } - results + let result = app_context + .execute_dpns_vote_operation(operation, voters, &sdk) + .await + .map(|_| ()); + (operation_id, result) } }) .buffer_unordered(4) .collect::>() .await; - for (vote, result) in results.into_iter().flatten() { + let mut first_error = None; + for (operation_id, result) in results { if let Err(error) = result { tracing::error!( error = %error, - contested_name = %vote.contested_name, - "Failed to cast a due scheduled vote; leaving it for the next sweep" + operation_id = %operation_id, + "Failed to execute a due DPNS vote operation; leaving it for recovery" ); first_error.get_or_insert(error); } @@ -604,6 +770,35 @@ mod tests { assert!(status.holds_lock()); } + #[test] + fn exact_reconciliation_distinguishes_match_rejection_and_absence() { + assert_eq!( + classify_reconciled_vote(Some(ResourceVoteChoice::Lock), ResourceVoteChoice::Lock), + Some(DpnsVoteTargetStatus::Confirmed) + ); + assert_eq!( + classify_reconciled_vote(Some(ResourceVoteChoice::Abstain), ResourceVoteChoice::Lock), + Some(DpnsVoteTargetStatus::Rejected) + ); + assert_eq!( + classify_reconciled_vote(None, ResourceVoteChoice::Lock), + None, + "an absent exact row remains ambiguous and must not release its lock" + ); + } + + #[test] + fn scheduled_terminal_or_unconfirmed_targets_are_not_due_for_rebroadcast() { + for status in [ + DpnsVoteTargetStatus::Unconfirmed, + DpnsVoteTargetStatus::Rejected, + DpnsVoteTargetStatus::FailedBeforeSubmission, + DpnsVoteTargetStatus::Confirmed, + ] { + assert_ne!(status, DpnsVoteTargetStatus::Scheduled); + } + } + /// Migration extends only eligibility windows that overlap its wait. #[test] fn migration_wait_preserves_only_overlapping_vote_eligibility() { diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 237e503ff..09ae71391 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -660,6 +660,32 @@ pub enum TaskError { source: crate::wallet_backend::KvAdapterError, }, + /// An indexed journal row could not be decoded, so unresolved target locks + /// cannot be reconstructed safely. + #[error( + "Saved DPNS voting progress is unreadable. Restore the saved data or remove the damaged voting record before trying again." + )] + DpnsVoteOperationUnreadable { + #[source] + source: crate::wallet_backend::KvAdapterError, + }, + + /// An operation index referenced a missing row, so target locks are unknown. + #[error( + "Saved DPNS voting progress is incomplete. Restore the saved data or remove the damaged voting record before trying again." + )] + DpnsVoteOperationRecordMissing, + + /// A non-terminal operation was found under a different network namespace. + #[error( + "Saved DPNS voting progress belongs to another network. Switch back to that network or resolve the pending vote there." + )] + DpnsVoteJournalNetworkMismatch, + + /// The bounded in-process vote coordinator was shut down unexpectedly. + #[error("DPNS voting is stopping. Wait for DET to finish closing, then try again.")] + DpnsVoteCoordinatorUnavailable, + /// Another unresolved operation already owns this exact node and contest. #[error( "This node's vote for this name is already in progress. Wait for its result or check again." diff --git a/src/context/dpns_vote_operations.rs b/src/context/dpns_vote_operations.rs index 79494ac45..a729267cd 100644 --- a/src/context/dpns_vote_operations.rs +++ b/src/context/dpns_vote_operations.rs @@ -3,50 +3,139 @@ use super::AppContext; use crate::backend_task::error::TaskError; use crate::model::dpns_voting::{ - DpnsVoteFailure, DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteTarget, DpnsVoteTargetKey, - DpnsVoteTargetStatus, VoteTiming, + DpnsCurrentVoteState, DpnsVoteFailure, DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteTarget, + DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, }; use crate::wallet_backend::{DetKv, DetScope, KvAdapterError}; +use dash_sdk::dpp::dashcore::Network; use dash_sdk::platform::Identifier; +use std::sync::Arc; -const OPERATION_INDEX_KEY: &str = "det:dpns_vote_operations:v1"; -const OPERATION_KEY_PREFIX: &str = "det:dpns_vote_operation:v1:"; +const LEGACY_OPERATION_INDEX_KEY: &str = "det:dpns_vote_operations:v1"; +const LEGACY_OPERATION_KEY_PREFIX: &str = "det:dpns_vote_operation:v1:"; +const OPERATION_INDEX_KEY_PREFIX: &str = "det:dpns_vote_operations:v2:"; +const OPERATION_KEY_PREFIX: &str = "det:dpns_vote_operation:v2:"; -fn operation_key(id: DpnsVoteOperationId) -> String { - format!("{OPERATION_KEY_PREFIX}{id}") +fn network_tag(network: Network) -> &'static str { + match network { + Network::Mainnet => "mainnet", + Network::Testnet => "testnet", + Network::Devnet => "devnet", + Network::Regtest => "regtest", + } +} + +fn operation_index_key(network: Network) -> String { + format!("{OPERATION_INDEX_KEY_PREFIX}{}", network_tag(network)) +} + +fn operation_key(network: Network, id: DpnsVoteOperationId) -> String { + format!("{OPERATION_KEY_PREFIX}{}:{id}", network_tag(network)) +} + +fn legacy_operation_key(id: DpnsVoteOperationId) -> String { + format!("{LEGACY_OPERATION_KEY_PREFIX}{id}") } fn operation_err(source: KvAdapterError) -> TaskError { TaskError::DpnsVoteOperationStorage { source } } -fn load_operation_ids(kv: &DetKv) -> Result, TaskError> { - kv.get(DetScope::Global, OPERATION_INDEX_KEY) +fn unreadable_operation_err(source: KvAdapterError) -> TaskError { + TaskError::DpnsVoteOperationUnreadable { source } +} + +fn load_operation_ids(kv: &DetKv, network: Network) -> Result, TaskError> { + kv.get(DetScope::Global, &operation_index_key(network)) .map(|ids| ids.unwrap_or_default()) - .map_err(operation_err) + .map_err(unreadable_operation_err) } -fn load_operations(kv: &DetKv) -> Result, TaskError> { +fn operation_matches_network( + operation: &DpnsVoteOperation, + network: Network, +) -> Result { + if operation + .targets + .iter() + .all(|outcome| outcome.target.key.network == network) + { + return Ok(true); + } + if operation + .targets + .iter() + .any(|outcome| outcome.target.key.network != network && outcome.status.holds_lock()) + { + return Err(TaskError::DpnsVoteJournalNetworkMismatch); + } + Ok(false) +} + +fn migrate_legacy_operations(kv: &DetKv, network: Network) -> Result<(), TaskError> { + let legacy_ids: Vec<[u8; 16]> = kv + .get(DetScope::Global, LEGACY_OPERATION_INDEX_KEY) + .map_err(unreadable_operation_err)? + .unwrap_or_default(); + if legacy_ids.is_empty() { + return Ok(()); + } + + let mut qualified_ids = load_operation_ids(kv, network)?; + let mut changed = false; + for bytes in legacy_ids { + let id = DpnsVoteOperationId::from_bytes(bytes); + if qualified_ids.contains(&bytes) { + continue; + } + let operation: DpnsVoteOperation = kv + .get(DetScope::Global, &legacy_operation_key(id)) + .map_err(unreadable_operation_err)? + .ok_or(TaskError::DpnsVoteOperationRecordMissing)?; + if !operation_matches_network(&operation, network)? { + continue; + } + kv.put(DetScope::Global, &operation_key(network, id), &operation) + .map_err(operation_err)?; + qualified_ids.push(bytes); + changed = true; + } + if changed { + kv.put( + DetScope::Global, + &operation_index_key(network), + &qualified_ids, + ) + .map_err(operation_err)?; + } + Ok(()) +} + +fn load_operations(kv: &DetKv, network: Network) -> Result, TaskError> { + migrate_legacy_operations(kv, network)?; let mut operations = Vec::new(); - for bytes in load_operation_ids(kv)? { + for bytes in load_operation_ids(kv, network)? { let id = DpnsVoteOperationId::from_bytes(bytes); - match kv.get(DetScope::Global, &operation_key(id)) { - Ok(Some(operation)) => operations.push(operation), - Ok(None) => {} - Err(error) => { - tracing::warn!( - operation_id = %id, - error = ?error, - "Skipping unreadable DPNS vote operation" - ); - } + let operation: DpnsVoteOperation = kv + .get(DetScope::Global, &operation_key(network, id)) + .map_err(unreadable_operation_err)? + .ok_or(TaskError::DpnsVoteOperationRecordMissing)?; + if operation_matches_network(&operation, network)? { + operations.push(operation); } } Ok(operations) } -fn persist_operation(kv: &DetKv, operation: &DpnsVoteOperation) -> Result<(), TaskError> { - let conflict = load_operations(kv)?.iter().any(|existing| { +fn persist_operation( + kv: &DetKv, + network: Network, + operation: &DpnsVoteOperation, +) -> Result<(), TaskError> { + if !operation_matches_network(operation, network)? { + return Err(TaskError::DpnsVoteJournalNetworkMismatch); + } + let conflict = load_operations(kv, network)?.iter().any(|existing| { existing.id != operation.id && existing.targets.iter().any(|existing_outcome| { existing_outcome.status.holds_lock() @@ -60,17 +149,34 @@ fn persist_operation(kv: &DetKv, operation: &DpnsVoteOperation) -> Result<(), Ta return Err(TaskError::DpnsVoteTargetBusy); } - kv.put(DetScope::Global, &operation_key(operation.id), operation) - .map_err(operation_err)?; - let mut ids = load_operation_ids(kv)?; + kv.put( + DetScope::Global, + &operation_key(network, operation.id), + operation, + ) + .map_err(operation_err)?; + let mut ids = load_operation_ids(kv, network)?; if !ids.contains(&operation.id.to_bytes()) { ids.push(operation.id.to_bytes()); - kv.put(DetScope::Global, OPERATION_INDEX_KEY, &ids) + kv.put(DetScope::Global, &operation_index_key(network), &ids) .map_err(operation_err)?; } Ok(()) } +fn write_existing_operation( + kv: &DetKv, + network: Network, + operation: &DpnsVoteOperation, +) -> Result<(), TaskError> { + kv.put( + DetScope::Global, + &operation_key(network, operation.id), + operation, + ) + .map_err(operation_err) +} + impl AppContext { /// Persist a reviewed operation and atomically acquire all unresolved locks. pub fn insert_dpns_vote_operation( @@ -89,8 +195,9 @@ impl AppContext { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let kv = self.det_kv()?; - let mut existing_operations = load_operations(&kv)?; - for existing in &mut existing_operations { + let mut replaced = Vec::new(); + for mut existing in load_operations(&kv, self.network)? { + let original = existing.clone(); let mut changed = false; for existing_outcome in &mut existing.targets { if existing_outcome.status == DpnsVoteTargetStatus::Scheduled @@ -99,16 +206,27 @@ impl AppContext { && new_outcome.target.key == existing_outcome.target.key }) { - existing_outcome.status = DpnsVoteTargetStatus::NotApplied; changed = true; + existing_outcome.status = DpnsVoteTargetStatus::NotApplied; } } if changed { - kv.put(DetScope::Global, &operation_key(existing.id), existing) - .map_err(operation_err)?; + if let Err(error) = write_existing_operation(&kv, self.network, &existing) { + for previous in replaced { + write_existing_operation(&kv, self.network, &previous)?; + } + return Err(error); + } + replaced.push(original); + } + } + if let Err(error) = persist_operation(&kv, self.network, operation) { + for original in replaced { + write_existing_operation(&kv, self.network, &original)?; } + return Err(error); } - persist_operation(&kv, operation) + Ok(()) } /// Persist updated target statuses while retaining the original operation ID. @@ -120,7 +238,7 @@ impl AppContext { .dpns_vote_operation_guard .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - persist_operation(&self.det_kv()?, operation) + persist_operation(&self.det_kv()?, self.network, operation) } /// Load every operation for this network, including completed history. @@ -130,7 +248,7 @@ impl AppContext { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let kv = self.det_kv()?; - let mut operations = load_operations(&kv)?; + let mut operations = load_operations(&kv, self.network)?; for legacy in self.get_scheduled_votes()? { if operations.iter().any(|operation| { operation.targets.iter().any(|outcome| { @@ -155,7 +273,7 @@ impl AppContext { if legacy.executed_successfully { operation.targets[0].status = DpnsVoteTargetStatus::Confirmed; } - persist_operation(&kv, &operation)?; + persist_operation(&kv, self.network, &operation)?; operations.push(operation); } Ok(operations) @@ -166,9 +284,21 @@ impl AppContext { &self, id: DpnsVoteOperationId, ) -> Result, TaskError> { - self.det_kv()? - .get(DetScope::Global, &operation_key(id)) - .map_err(operation_err) + let _guard = self + .dpns_vote_operation_guard + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let kv = self.det_kv()?; + migrate_legacy_operations(&kv, self.network)?; + let operation = kv + .get(DetScope::Global, &operation_key(self.network, id)) + .map_err(unreadable_operation_err)?; + match operation { + Some(operation) if operation_matches_network(&operation, self.network)? => { + Ok(Some(operation)) + } + Some(_) | None => Ok(None), + } } /// Return the unresolved status that currently locks an exact target. @@ -198,8 +328,8 @@ impl AppContext { .unwrap_or_else(std::sync::PoisonError::into_inner); let kv = self.det_kv()?; let Some(mut operation): Option = kv - .get(DetScope::Global, &operation_key(operation_id)) - .map_err(operation_err)? + .get(DetScope::Global, &operation_key(self.network, operation_id)) + .map_err(unreadable_operation_err)? else { return Ok(()); }; @@ -211,7 +341,140 @@ impl AppContext { outcome.status = status; outcome.failure = failure; } - persist_operation(&kv, &operation) + persist_operation(&kv, self.network, &operation) + } + + /// Atomically claim a queued target before any network or nonce work. + pub(crate) fn claim_dpns_vote_target( + &self, + operation_id: DpnsVoteOperationId, + key: &DpnsVoteTargetKey, + ) -> Result { + let _guard = self + .dpns_vote_operation_guard + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let kv = self.det_kv()?; + let Some(mut operation): Option = kv + .get(DetScope::Global, &operation_key(self.network, operation_id)) + .map_err(unreadable_operation_err)? + else { + return Ok(false); + }; + let Some(outcome) = operation + .targets + .iter_mut() + .find(|outcome| outcome.target.key == *key) + else { + return Ok(false); + }; + if outcome.status != DpnsVoteTargetStatus::Queued { + return Ok(false); + } + outcome.status = DpnsVoteTargetStatus::Submitting; + outcome.failure = None; + persist_operation(&kv, self.network, &operation)?; + Ok(true) + } + + /// Apply fresh proved state only while the target is still queued. + /// + /// Returning `false` means another executor already advanced the target; + /// callers must not write their stale operation snapshot back. + pub(crate) fn revalidate_queued_dpns_vote_target( + &self, + operation_id: DpnsVoteOperationId, + key: &DpnsVoteTargetKey, + state: DpnsCurrentVoteState, + ) -> Result { + let _guard = self + .dpns_vote_operation_guard + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let kv = self.det_kv()?; + let Some(mut operation): Option = kv + .get(DetScope::Global, &operation_key(self.network, operation_id)) + .map_err(unreadable_operation_err)? + else { + return Ok(false); + }; + let Some(outcome) = operation + .targets + .iter_mut() + .find(|outcome| outcome.target.key == *key) + else { + return Ok(false); + }; + if outcome.status != DpnsVoteTargetStatus::Queued { + return Ok(false); + } + match state { + DpnsCurrentVoteState::Available(current) => { + outcome.target.current_choice = current; + if current == Some(outcome.target.requested_choice) { + outcome.status = DpnsVoteTargetStatus::Confirmed; + outcome.failure = None; + } + } + DpnsCurrentVoteState::Checking | DpnsCurrentVoteState::Unavailable => { + outcome.status = DpnsVoteTargetStatus::Unconfirmed; + outcome.failure = Some(DpnsVoteFailure::CurrentVoteUnavailable); + } + } + let still_queued = outcome.status == DpnsVoteTargetStatus::Queued; + persist_operation(&kv, self.network, &operation)?; + Ok(still_queued) + } + + /// Convert crash-interrupted transitions into conservative reconciliation. + pub(crate) fn recover_interrupted_dpns_vote_operations(&self) -> Result<(), TaskError> { + let _guard = self + .dpns_vote_operation_guard + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let kv = self.det_kv()?; + for mut operation in load_operations(&kv, self.network)? { + let mut changed = false; + for outcome in &mut operation.targets { + if matches!( + outcome.status, + DpnsVoteTargetStatus::Submitting | DpnsVoteTargetStatus::Confirming + ) { + outcome.status = DpnsVoteTargetStatus::Unconfirmed; + outcome.failure = Some(DpnsVoteFailure::ResultUnconfirmed); + changed = true; + } + } + if changed { + persist_operation(&kv, self.network, &operation)?; + } + } + Ok(()) + } + + pub(crate) fn record_dpns_vote_diagnostic( + &self, + operation_id: DpnsVoteOperationId, + key: DpnsVoteTargetKey, + error: TaskError, + ) { + self.dpns_vote_diagnostics + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert((operation_id, key), Arc::new(error)); + } + + pub(crate) fn dpns_vote_operation_diagnostics( + &self, + operation_id: DpnsVoteOperationId, + ) -> Vec> { + self.dpns_vote_diagnostics + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .iter() + .filter(|((id, _), _)| *id == operation_id) + .map(|(_, error)| Arc::clone(error)) + .collect() } /// Release a not-yet-submitting scheduled target after explicit cancellation. @@ -266,6 +529,7 @@ mod tests { use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dash_sdk::platform::Identifier; + use platform_wallet_storage::{KvStore, ObjectId}; use std::sync::Arc; fn kv() -> DetKv { @@ -293,10 +557,19 @@ mod tests { #[test] fn unresolved_target_rejects_a_competing_operation() { let kv = kv(); - persist_operation(&kv, &operation(DpnsVoteTargetStatus::Submitting)).unwrap(); + persist_operation( + &kv, + Network::Testnet, + &operation(DpnsVoteTargetStatus::Submitting), + ) + .unwrap(); - let error = persist_operation(&kv, &operation(DpnsVoteTargetStatus::Queued)) - .expect_err("the exact target must stay locked"); + let error = persist_operation( + &kv, + Network::Testnet, + &operation(DpnsVoteTargetStatus::Queued), + ) + .expect_err("the exact target must stay locked"); assert!(matches!(error, TaskError::DpnsVoteTargetBusy)); } @@ -305,9 +578,9 @@ mod tests { fn unconfirmed_lock_survives_journal_reload() { let kv = kv(); let operation = operation(DpnsVoteTargetStatus::Unconfirmed); - persist_operation(&kv, &operation).unwrap(); + persist_operation(&kv, Network::Testnet, &operation).unwrap(); - let restored = load_operations(&kv).unwrap(); + let restored = load_operations(&kv, Network::Testnet).unwrap(); assert_eq!(restored, vec![operation]); assert!(restored[0].targets[0].status.holds_lock()); } @@ -316,11 +589,146 @@ mod tests { #[test] fn unrelated_target_can_be_persisted() { let kv = kv(); - persist_operation(&kv, &operation(DpnsVoteTargetStatus::Confirming)).unwrap(); + persist_operation( + &kv, + Network::Testnet, + &operation(DpnsVoteTargetStatus::Confirming), + ) + .unwrap(); let mut unrelated = operation(DpnsVoteTargetStatus::Queued); unrelated.targets[0].target.key.vote_poll_id = Identifier::from([3; 32]); - persist_operation(&kv, &unrelated).unwrap(); - assert_eq!(load_operations(&kv).unwrap().len(), 2); + persist_operation(&kv, Network::Testnet, &unrelated).unwrap(); + assert_eq!(load_operations(&kv, Network::Testnet).unwrap().len(), 2); + } + + /// A corrupt indexed row must block lock reconstruction rather than being skipped. + #[test] + fn unreadable_indexed_operation_fails_closed() { + let store = Arc::new(InMemoryKv::default()); + let kv = DetKv::from_store(store.clone()); + let operation = operation(DpnsVoteTargetStatus::Unconfirmed); + persist_operation(&kv, Network::Testnet, &operation).unwrap(); + store + .put( + &ObjectId::Global, + &operation_key(Network::Testnet, operation.id), + &[0xff, 0x00], + ) + .unwrap(); + + assert!( + load_operations(&kv, Network::Testnet).is_err(), + "an unreadable lock record must never be treated as absent" + ); + } + + #[test] + fn network_qualified_journals_are_isolated() { + let kv = kv(); + persist_operation( + &kv, + Network::Testnet, + &operation(DpnsVoteTargetStatus::Unconfirmed), + ) + .unwrap(); + + assert_eq!(load_operations(&kv, Network::Testnet).unwrap().len(), 1); + assert!(load_operations(&kv, Network::Mainnet).unwrap().is_empty()); + } + + #[test] + fn legacy_journal_migrates_idempotently_into_network_namespace() { + let kv = kv(); + let operation = operation(DpnsVoteTargetStatus::Scheduled); + kv.put( + DetScope::Global, + &legacy_operation_key(operation.id), + &operation, + ) + .unwrap(); + kv.put( + DetScope::Global, + LEGACY_OPERATION_INDEX_KEY, + &vec![operation.id.to_bytes()], + ) + .unwrap(); + + assert_eq!( + load_operations(&kv, Network::Testnet).unwrap(), + vec![operation.clone()] + ); + assert_eq!( + load_operations(&kv, Network::Testnet).unwrap(), + vec![operation], + "repeating migration must not duplicate the operation" + ); + } + + #[test] + fn mismatched_unresolved_legacy_record_fails_closed() { + let kv = kv(); + let operation = operation(DpnsVoteTargetStatus::Unconfirmed); + kv.put( + DetScope::Global, + &legacy_operation_key(operation.id), + &operation, + ) + .unwrap(); + kv.put( + DetScope::Global, + LEGACY_OPERATION_INDEX_KEY, + &vec![operation.id.to_bytes()], + ) + .unwrap(); + + assert!(matches!( + load_operations(&kv, Network::Mainnet), + Err(TaskError::DpnsVoteJournalNetworkMismatch) + )); + } + + #[test] + fn mismatched_terminal_legacy_record_is_safely_ignored() { + let kv = kv(); + let operation = operation(DpnsVoteTargetStatus::Confirmed); + kv.put( + DetScope::Global, + &legacy_operation_key(operation.id), + &operation, + ) + .unwrap(); + kv.put( + DetScope::Global, + LEGACY_OPERATION_INDEX_KEY, + &vec![operation.id.to_bytes()], + ) + .unwrap(); + + assert!(load_operations(&kv, Network::Mainnet).unwrap().is_empty()); + } + + #[test] + fn interrupted_submission_recovers_to_unconfirmed() { + let kv = kv(); + let mut operation = operation(DpnsVoteTargetStatus::Submitting); + persist_operation(&kv, Network::Testnet, &operation).unwrap(); + + for outcome in &mut operation.targets { + if matches!( + outcome.status, + DpnsVoteTargetStatus::Submitting | DpnsVoteTargetStatus::Confirming + ) { + outcome.status = DpnsVoteTargetStatus::Unconfirmed; + outcome.failure = Some(DpnsVoteFailure::ResultUnconfirmed); + } + } + persist_operation(&kv, Network::Testnet, &operation).unwrap(); + + let restored = load_operations(&kv, Network::Testnet).unwrap(); + assert_eq!( + restored[0].targets[0].status, + DpnsVoteTargetStatus::Unconfirmed + ); } } diff --git a/src/context/dpns_vote_state.rs b/src/context/dpns_vote_state.rs index 4de03e981..3116cb3fa 100644 --- a/src/context/dpns_vote_state.rs +++ b/src/context/dpns_vote_state.rs @@ -5,6 +5,7 @@ use crate::backend_task::error::TaskError; use crate::model::dpns_voting::DpnsCurrentVoteState; use crate::wallet_backend::{DetKv, DetScope, KvAdapterError}; use dash_sdk::Sdk; +use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -21,8 +22,10 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::time::{SystemTime, UNIX_EPOCH}; -const CURRENT_VOTES_KEY: &str = "det:dpns_current_votes:v1"; +const LEGACY_CURRENT_VOTES_KEY: &str = "det:dpns_current_votes:v1"; +const CURRENT_VOTES_KEY_PREFIX: &str = "det:dpns_current_votes:v2:"; const VOTE_QUERY_PAGE_SIZE: u16 = 100; +const CURRENT_VOTE_MAX_AGE_MS: u64 = 120_000; #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] struct StoredCurrentVotes { @@ -35,27 +38,70 @@ fn vote_state_err(source: KvAdapterError) -> TaskError { TaskError::DpnsVoteOperationStorage { source } } +fn current_votes_key(network: Network) -> String { + let network = match network { + Network::Mainnet => "mainnet", + Network::Testnet => "testnet", + Network::Devnet => "devnet", + Network::Regtest => "regtest", + }; + format!("{CURRENT_VOTES_KEY_PREFIX}{network}") +} + fn load_snapshot( kv: &DetKv, + network: Network, voter_id: &Identifier, ) -> Result, TaskError> { - kv.get(DetScope::Identity(&voter_id.to_buffer()), CURRENT_VOTES_KEY) - .map_err(vote_state_err) + let scope = DetScope::Identity(&voter_id.to_buffer()); + if let Some(snapshot) = kv + .get(scope, ¤t_votes_key(network)) + .map_err(vote_state_err)? + { + return Ok(Some(snapshot)); + } + let legacy = kv + .get(scope, LEGACY_CURRENT_VOTES_KEY) + .map_err(vote_state_err)?; + if let Some(snapshot) = &legacy { + save_snapshot(kv, network, voter_id, snapshot)?; + } + Ok(legacy) } fn save_snapshot( kv: &DetKv, + network: Network, voter_id: &Identifier, snapshot: &StoredCurrentVotes, ) -> Result<(), TaskError> { kv.put( DetScope::Identity(&voter_id.to_buffer()), - CURRENT_VOTES_KEY, + ¤t_votes_key(network), snapshot, ) .map_err(vote_state_err) } +fn snapshot_vote_state( + snapshot: Option, + vote_poll_id: Identifier, + checked_at_ms: u64, +) -> DpnsCurrentVoteState { + match snapshot { + None => DpnsCurrentVoteState::Checking, + Some(snapshot) + if checked_at_ms.saturating_sub(snapshot.updated_at) > CURRENT_VOTE_MAX_AGE_MS => + { + DpnsCurrentVoteState::Checking + } + Some(snapshot) if !snapshot.available => DpnsCurrentVoteState::Unavailable, + Some(snapshot) => { + DpnsCurrentVoteState::Available(snapshot.votes.get(&vote_poll_id.to_buffer()).copied()) + } + } +} + impl AppContext { /// Build the exact Platform vote-poll ID for one normalized DPNS label. pub fn dpns_vote_poll_id(&self, name: &str) -> Result { @@ -87,13 +133,11 @@ impl AppContext { voter_id: Identifier, vote_poll_id: Identifier, ) -> Result { - Ok(match load_snapshot(&self.det_kv()?, &voter_id)? { - None => DpnsCurrentVoteState::Checking, - Some(snapshot) if !snapshot.available => DpnsCurrentVoteState::Unavailable, - Some(snapshot) => DpnsCurrentVoteState::Available( - snapshot.votes.get(&vote_poll_id.to_buffer()).copied(), - ), - }) + Ok(snapshot_vote_state( + load_snapshot(&self.det_kv()?, self.network, &voter_id)?, + vote_poll_id, + now_ms(), + )) } /// Refresh proved vote state once per loaded masternode, paging only as needed. @@ -117,6 +161,7 @@ impl AppContext { .map(|voter| { let sdk = sdk.clone(); let kv = kv.clone(); + let network = self.network; async move { let voter_id = voter.identity.id(); match fetch_votes_for_voter(&sdk, voter_id).await { @@ -126,7 +171,7 @@ impl AppContext { updated_at: now_ms(), votes, }; - if let Err(error) = save_snapshot(&kv, &voter_id, &snapshot) { + if let Err(error) = save_snapshot(&kv, network, &voter_id, &snapshot) { tracing::warn!( ?error, voter_id = %voter_id, @@ -140,7 +185,9 @@ impl AppContext { updated_at: now_ms(), votes: BTreeMap::new(), }; - if let Err(storage_error) = save_snapshot(&kv, &voter_id, &snapshot) { + if let Err(storage_error) = + save_snapshot(&kv, network, &voter_id, &snapshot) + { tracing::warn!( ?storage_error, voter_id = %voter_id, @@ -169,11 +216,11 @@ impl AppContext { choice: ResourceVoteChoice, ) -> Result<(), TaskError> { let kv = self.det_kv()?; - let mut snapshot = load_snapshot(&kv, &voter_id)?.unwrap_or_default(); + let mut snapshot = load_snapshot(&kv, self.network, &voter_id)?.unwrap_or_default(); snapshot.available = true; snapshot.updated_at = now_ms(); snapshot.votes.insert(vote_poll_id.to_buffer(), choice); - save_snapshot(&kv, &voter_id, &snapshot) + save_snapshot(&kv, self.network, &voter_id, &snapshot) } } @@ -241,9 +288,12 @@ mod tests { updated_at: 3, votes: BTreeMap::from([(poll.to_buffer(), ResourceVoteChoice::Lock)]), }; - save_snapshot(&kv, &voter, &snapshot).unwrap(); + save_snapshot(&kv, Network::Testnet, &voter, &snapshot).unwrap(); - assert_eq!(load_snapshot(&kv, &voter).unwrap(), Some(snapshot)); + assert_eq!( + load_snapshot(&kv, Network::Testnet, &voter).unwrap(), + Some(snapshot) + ); } /// VOTE-TC-007: query failure is represented explicitly, never as no vote. @@ -260,4 +310,37 @@ mod tests { assert_ne!(unavailable, empty); } + + #[test] + fn current_vote_snapshots_are_network_qualified() { + let kv = kv(); + let voter = Identifier::from([1; 32]); + let snapshot = StoredCurrentVotes { + available: true, + updated_at: now_ms(), + votes: BTreeMap::new(), + }; + save_snapshot(&kv, Network::Testnet, &voter, &snapshot).unwrap(); + + assert_eq!( + load_snapshot(&kv, Network::Testnet, &voter).unwrap(), + Some(snapshot) + ); + assert_eq!(load_snapshot(&kv, Network::Mainnet, &voter).unwrap(), None); + } + + #[test] + fn stale_proved_snapshot_cannot_authorize_submission() { + let poll = Identifier::from([2; 32]); + let snapshot = StoredCurrentVotes { + available: true, + updated_at: 1, + votes: BTreeMap::from([(poll.to_buffer(), ResourceVoteChoice::Lock)]), + }; + + assert_eq!( + snapshot_vote_state(Some(snapshot), poll, CURRENT_VOTE_MAX_AGE_MS + 2), + DpnsCurrentVoteState::Checking + ); + } } diff --git a/src/context/mod.rs b/src/context/mod.rs index ab896bf3a..f91c59f79 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -20,6 +20,7 @@ use crate::config::{Config, NetworkConfig}; use crate::context::feature_gate::ExperimentalFeature; use crate::context_provider::SpvProvider; use crate::database::Database; +use crate::model::dpns_voting::{DpnsVoteOperationId, DpnsVoteTargetKey}; use crate::model::fee_estimation::PlatformFeeEstimator; use crate::model::qualified_identity::{IdentityType, QualifiedIdentity}; use crate::model::request_type::RequestType; @@ -49,7 +50,7 @@ use dash_sdk::platform::Identifier; use egui::Context; use migration_status::MigrationStatus; use platform_wallet_storage::secrets::SecretStore; -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::PathBuf; use std::str::FromStr as _; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; @@ -80,6 +81,54 @@ impl Drop for ContactRequestActionClaim<'_> { } } +const MAX_CONCURRENT_DPNS_VOTERS: usize = 4; + +#[derive(Debug)] +pub(crate) struct DpnsVoteDispatchCoordinator { + voter_gates: Mutex>>>, + permits: Arc, +} + +impl Default for DpnsVoteDispatchCoordinator { + fn default() -> Self { + Self { + voter_gates: Mutex::new(HashMap::new()), + permits: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_DPNS_VOTERS)), + } + } +} + +pub(crate) struct DpnsVoteDispatchGuard { + _voter: tokio::sync::OwnedMutexGuard<()>, + _permit: tokio::sync::OwnedSemaphorePermit, +} + +impl DpnsVoteDispatchCoordinator { + pub(crate) async fn acquire( + &self, + voter_id: Identifier, + ) -> Result { + let voter_gate = self + .voter_gates + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .entry(voter_id) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone(); + // Take the per-voter gate first so queued work for one busy voter + // cannot consume all of the cross-voter capacity. + let voter = voter_gate.lock_owned().await; + let permit = Arc::clone(&self.permits) + .acquire_owned() + .await + .map_err(|_| TaskError::DpnsVoteCoordinatorUnavailable)?; + Ok(DpnsVoteDispatchGuard { + _voter: voter, + _permit: permit, + }) + } +} + #[derive(Debug)] pub struct AppContext { pub(crate) data_dir: PathBuf, @@ -161,6 +210,14 @@ pub struct AppContext { contact_request_actions_in_flight: Mutex>, /// Serializes operation journal writes and target-lock acquisition. dpns_vote_operation_guard: Mutex<()>, + /// Serializes all nonce-consuming vote submissions per voter across tasks, + /// while bounding unrelated voters globally. + pub(crate) dpns_vote_dispatch: DpnsVoteDispatchCoordinator, + /// Runs crash recovery exactly once before this context accepts vote work. + pub(crate) dpns_vote_recovery: tokio::sync::Mutex, + /// Full in-process diagnostics keyed to sanitized durable outcomes. + dpns_vote_diagnostics: + Mutex>>, /// Pending wallet selection - set after creating/importing a wallet /// so the wallet screen can auto-select the new wallet pub(crate) pending_wallet_selection: Mutex>, @@ -441,6 +498,9 @@ impl AppContext { migration_run: tokio::sync::Mutex::new(()), contact_request_actions_in_flight: Mutex::new(HashSet::new()), dpns_vote_operation_guard: Mutex::new(()), + dpns_vote_dispatch: DpnsVoteDispatchCoordinator::default(), + dpns_vote_recovery: tokio::sync::Mutex::new(false), + dpns_vote_diagnostics: Mutex::new(BTreeMap::new()), pending_wallet_selection: Mutex::new(None), selected_wallet_hash: Mutex::new(selected_wallet_hash), selected_single_key_hash: Mutex::new(selected_single_key_hash), @@ -1935,4 +1995,63 @@ mod tests { "a User selection is kept by the sanitizer", ); } + + async fn observe_dispatch( + coordinator: Arc, + voter_id: Identifier, + active: Arc, + maximum: Arc, + ) { + let _guard = coordinator.acquire(voter_id).await.unwrap(); + let now = active.fetch_add(1, Ordering::SeqCst) + 1; + maximum.fetch_max(now, Ordering::SeqCst); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + active.fetch_sub(1, Ordering::SeqCst); + } + + #[tokio::test] + async fn dpns_dispatch_serializes_independent_operations_for_one_voter() { + let coordinator = Arc::new(DpnsVoteDispatchCoordinator::default()); + let active = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let maximum = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let voter = Identifier::from([7; 32]); + let first = tokio::spawn(observe_dispatch( + Arc::clone(&coordinator), + voter, + Arc::clone(&active), + Arc::clone(&maximum), + )); + let second = tokio::spawn(observe_dispatch( + coordinator, + voter, + Arc::clone(&active), + Arc::clone(&maximum), + )); + first.await.unwrap(); + second.await.unwrap(); + + assert_eq!(maximum.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn dpns_dispatch_bounds_independent_voters() { + let coordinator = Arc::new(DpnsVoteDispatchCoordinator::default()); + let active = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let maximum = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let tasks = (0..8) + .map(|voter| { + tokio::spawn(observe_dispatch( + Arc::clone(&coordinator), + Identifier::from([voter; 32]), + Arc::clone(&active), + Arc::clone(&maximum), + )) + }) + .collect::>(); + for task in tasks { + task.await.unwrap(); + } + + assert_eq!(maximum.load(Ordering::SeqCst), MAX_CONCURRENT_DPNS_VOTERS); + } } From ad308095345eb07f5479984d0d79070623ef11b0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:48:14 +0000 Subject: [PATCH 04/39] feat(dpns): preserve voting center route choices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carry exact DPNS vote choices through the one-shot operator route so bulk review does not silently replace them with defaults. Co-Authored-By: Codex GPT-5 πŸ€– Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- src/context/mod.rs | 63 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/src/context/mod.rs b/src/context/mod.rs index f91c59f79..121389fc0 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -45,6 +45,7 @@ use dash_sdk::dpp::state_transition::batch_transition::methods::StateTransitionC use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::dpp::version::v11::PLATFORM_V11; +use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dash_sdk::platform::DataContract; use dash_sdk::platform::Identifier; use egui::Context; @@ -72,6 +73,15 @@ pub(crate) struct ContactRequestActionClaim<'a> { request_id: Identifier, } +/// One-shot navigation into the Masternodes DPNS operator workflow. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DpnsOperatorRoute { + Voting { + choices: BTreeMap, + }, + Scheduled, +} + impl Drop for ContactRequestActionClaim<'_> { fn drop(&mut self) { self.registry @@ -233,7 +243,7 @@ pub struct AppContext { /// `pending_wallet_selection`). pub(crate) pending_identity_selection: Mutex>, /// One-shot DPNS deep link consumed by the Masternodes Voting Center. - pending_dpns_voting_contests: Mutex>>, + pending_dpns_operator_route: Mutex>, /// Cached fee multiplier permille from current epoch (1000 = 1x, 2000 = 2x) /// Updated when epoch info is fetched from Platform fee_multiplier_permille: AtomicU64, @@ -506,7 +516,7 @@ impl AppContext { selected_single_key_hash: Mutex::new(selected_single_key_hash), selected_identity_id: Mutex::new(None), pending_identity_selection: Mutex::new(None), - pending_dpns_voting_contests: Mutex::new(None), + pending_dpns_operator_route: Mutex::new(None), fee_multiplier_permille: AtomicU64::new( PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE, ), @@ -674,15 +684,33 @@ impl AppContext { /// Route DPNS contest browsing into the shared Masternodes Voting Center. pub fn route_to_dpns_voting_center(&self, contested_names: Vec) { + self.route_to_dpns_operator(DpnsOperatorRoute::Voting { + choices: contested_names + .into_iter() + .map(|name| (name, ResourceVoteChoice::Abstain)) + .collect(), + }); + } + + /// Route to the shared Masternodes DPNS operator workflow. + pub fn route_to_dpns_operator(&self, route: DpnsOperatorRoute) { *self - .pending_dpns_voting_contests + .pending_dpns_operator_route .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(contested_names); + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(route); } /// Consume a one-shot DPNS β†’ Masternodes Voting Center deep link. pub fn take_dpns_voting_center_route(&self) -> Option> { - self.pending_dpns_voting_contests + match self.take_dpns_operator_route()? { + DpnsOperatorRoute::Voting { choices } => Some(choices.into_keys().collect()), + DpnsOperatorRoute::Scheduled => None, + } + } + + /// Consume a one-shot DPNS operator deep link. + pub fn take_dpns_operator_route(&self) -> Option { + self.pending_dpns_operator_route .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) .take() @@ -1593,6 +1621,7 @@ pub(crate) const fn default_platform_version(_network: &Network) -> &'static Pla #[cfg(test)] mod tests { use super::*; + use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; #[test] fn wallet_name_with_spaces_is_url_encoded() { @@ -1604,6 +1633,30 @@ mod tests { assert!(!url.contains(' ')); } + /// VOTE-TC-023: the DPNS deep link preserves each exact selected choice. + #[test] + fn dpns_voting_center_route_preserves_selected_choices() { + let tmp = tempfile::tempdir().expect("tempdir"); + let ctx = crate::context::test_support::test_app_context(tmp.path()); + let candidate = Identifier::from([7; 32]); + let choices = BTreeMap::from([ + ("alice".to_owned(), ResourceVoteChoice::Lock), + ( + "dominguez".to_owned(), + ResourceVoteChoice::TowardsIdentity(candidate), + ), + ]); + + ctx.route_to_dpns_operator(DpnsOperatorRoute::Voting { + choices: choices.clone(), + }); + + assert_eq!( + ctx.take_dpns_operator_route(), + Some(DpnsOperatorRoute::Voting { choices }) + ); + } + // ── FR-6 resolution-layer boundary (B1) ────────────────────────────────── /// Build an offline, wired `AppContext` (no network I/O) so the identity From a3c67c3ca8cb18cb8f953d682c0c68d991385c32 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:27:38 +0000 Subject: [PATCH 05/39] fix(dpns): make voting review safe and explicit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve exact routed choices, require explicit node selection, keep healthy targets usable when another node is blocked, render proved-state uncertainty honestly, and consolidate scheduled-vote management under Masternodes with guarded actions and readable review details. Co-Authored-By: Codex GPT-5 πŸ€– Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- docs/user-stories.md | 5 +- src/context/contested_names_db.rs | 53 +- src/model/contested_name.rs | 10 + .../dpns_subscreen_chooser_panel.rs | 4 - src/ui/dpns/dpns_contested_names_screen.rs | 21 +- src/ui/masternodes/card.rs | 45 +- src/ui/masternodes/detail_screen.rs | 68 +- src/ui/masternodes/list_screen.rs | 136 +++- src/ui/masternodes/voting_center.rs | 617 ++++++++++++++---- src/ui/state/dpns_vote_workspace.rs | 82 ++- tests/kittest/masternode_tab.rs | 27 + 11 files changed, 859 insertions(+), 209 deletions(-) diff --git a/docs/user-stories.md b/docs/user-stories.md index 13fea7609..e368639f5 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -694,7 +694,8 @@ As a masternode operator, I want to vote on contested DPNS name registrations so As a masternode operator, I want to schedule votes for later execution so that I can plan my voting strategy in advance. - Set vote to be cast at a future time. -- View and manage scheduled votes. +- View and manage scheduled votes under Masternodes β†’ Scheduled; the former DPNS + scheduled-votes entry redirects to this shared operator view. - Scheduled and immediate votes share the same target locks and result states. - An ambiguous result remains visible for checking and is never automatically rebroadcast. @@ -704,6 +705,8 @@ As a masternode operator, I want to schedule votes for later execution so that I As a masternode operator, I want to apply voting choices across multiple contests in bulk so that I do not have to vote on each contest individually. - "Set all" option for batch vote assignment. +- Nodes are selected explicitly; "Set all" changes timing only for selected nodes. +- Nodes without a loaded voting key remain visible but cannot be selected. - Per-node timing overrides and multi-contest selections create exact node Γ— contest targets. - Immediate and scheduled targets submitted together belong to one operation. diff --git a/src/context/contested_names_db.rs b/src/context/contested_names_db.rs index 199fe03bd..d3e2a0e0e 100644 --- a/src/context/contested_names_db.rs +++ b/src/context/contested_names_db.rs @@ -8,7 +8,10 @@ use super::AppContext; use crate::backend_task::error::TaskError; -use crate::model::contested_name::{ContestState, Contestant, ContestedName}; +use crate::model::contested_name::{ + ContestState, Contestant, ContestedName, MasternodeVoteStateSummary, +}; +use crate::model::dpns_voting::DpnsCurrentVoteState; use crate::wallet_backend::{DetScope, KvAdapterError}; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; @@ -75,6 +78,16 @@ fn contest_duration_for_network(network: Network) -> Duration { } } +fn vote_state_summary(states: &[DpnsCurrentVoteState]) -> MasternodeVoteStateSummary { + if states.contains(&DpnsCurrentVoteState::Checking) { + MasternodeVoteStateSummary::Checking + } else if states.contains(&DpnsCurrentVoteState::Unavailable) { + MasternodeVoteStateSummary::Unavailable + } else { + MasternodeVoteStateSummary::Ready + } +} + impl StoredContestedName { fn to_contested_name(&self, network: Network) -> ContestedName { let contest_duration = contest_duration_for_network(network); @@ -213,18 +226,21 @@ impl AppContext { .iter() .filter(|contest| contest.is_open_for_voter(&voter_id)) .count(); - let needs_vote_count = contests + let states = contests .iter() .filter(|contest| contest.is_open_for_voter(&voter_id)) - .filter(|contest| { + .map(|contest| { self.dpns_vote_poll_id(&contest.normalized_contested_name) .ok() .and_then(|poll_id| self.dpns_current_vote_state(voter_id, poll_id).ok()) - == Some(crate::model::dpns_voting::DpnsCurrentVoteState::Available( - None, - )) + .unwrap_or(DpnsCurrentVoteState::Unavailable) }) + .collect::>(); + let needs_vote_count = states + .iter() + .filter(|state| **state == DpnsCurrentVoteState::Available(None)) .count(); + let vote_state = vote_state_summary(&states); let has_scheduled_vote = self .get_scheduled_votes()? @@ -234,6 +250,7 @@ impl AppContext { Ok(crate::model::contested_name::MasternodeContestSummary { open_contest_count, needs_vote_count, + vote_state, has_scheduled_vote, }) } @@ -553,4 +570,28 @@ mod tests { fn contest_key_is_prefixed_with_normalized_name() { assert_eq!(contested_name_key("dash"), "det:contested_name:dash"); } + + #[test] + fn card_summary_does_not_treat_checking_as_all_votes_cast() { + assert_eq!( + vote_state_summary(&[ + DpnsCurrentVoteState::Available(Some( + dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice::Lock, + )), + DpnsCurrentVoteState::Checking, + ]), + MasternodeVoteStateSummary::Checking + ); + } + + #[test] + fn card_summary_does_not_treat_unavailable_as_all_votes_cast() { + assert_eq!( + vote_state_summary(&[ + DpnsCurrentVoteState::Available(None), + DpnsCurrentVoteState::Unavailable, + ]), + MasternodeVoteStateSummary::Unavailable + ); + } } diff --git a/src/model/contested_name.rs b/src/model/contested_name.rs index 09f72d8c5..0b6fb4634 100644 --- a/src/model/contested_name.rs +++ b/src/model/contested_name.rs @@ -47,12 +47,22 @@ impl ContestedName { /// (no new backend concept). Feeds the count-first status line: open contests /// take precedence, then a pending scheduled vote, then "no open contests" /// (requirements Β§10.1). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum MasternodeVoteStateSummary { + #[default] + Ready, + Checking, + Unavailable, +} + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct MasternodeContestSummary { /// Number of active contests, including contests with an existing vote. pub open_contest_count: usize, /// Number of active contests whose proved state is `Not voted`. pub needs_vote_count: usize, + /// Whether every active contest has a proved current-vote state. + pub vote_state: MasternodeVoteStateSummary, /// Whether the node has at least one pending (not-yet-executed) scheduled /// vote, reusing the DPNS Scheduled Votes screen's existing state. pub has_scheduled_vote: bool, diff --git a/src/ui/components/dpns_subscreen_chooser_panel.rs b/src/ui/components/dpns_subscreen_chooser_panel.rs index 38fe91295..c51291031 100644 --- a/src/ui/components/dpns_subscreen_chooser_panel.rs +++ b/src/ui/components/dpns_subscreen_chooser_panel.rs @@ -29,10 +29,6 @@ pub fn add_dpns_subscreen_chooser_panel(ui: &mut Ui, app_context: &AppContext) - DPNSSubscreen::Owned, RootScreenType::RootScreenDPNSOwnedNames, ), - ( - DPNSSubscreen::ScheduledVotes, - RootScreenType::RootScreenDPNSScheduledVotes, - ), ] .into_iter() .map(|(subscreen, target)| { diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index c97296ccd..07b606e8c 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -19,7 +19,7 @@ use crate::backend_task::BackendTask; use crate::backend_task::contested_names::{ContestedResourceTask, ScheduledDPNSVote}; use crate::backend_task::error::TaskError; use crate::backend_task::identity::IdentityTask; -use crate::context::AppContext; +use crate::context::{AppContext, DpnsOperatorRoute}; use crate::model::contested_name::{ContestState, ContestedName}; use crate::model::dpns_voting::{ DpnsCurrentVoteState, DpnsVoteOperation, DpnsVoteTarget, DpnsVoteTargetKey, @@ -1991,6 +1991,11 @@ impl ScreenLike for DPNSScreen { } fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + if self.dpns_subscreen == DPNSSubscreen::ScheduledVotes { + self.app_context + .route_to_dpns_operator(DpnsOperatorRoute::Scheduled); + return AppAction::SetMainScreen(RootScreenType::RootScreenMasternodes); + } let ctx = ui.ctx().clone(); let ctx = &ctx; let has_identity_that_can_register = !self.user_identities.is_empty(); @@ -2083,12 +2088,14 @@ impl ScreenLike for DPNSScreen { // If user clicked "Apply Votes" in the top bar if action == AppAction::Custom("Vote".to_string()) { - self.app_context.route_to_dpns_voting_center( - self.selected_votes - .iter() - .map(|vote| vote.contested_name.clone()) - .collect(), - ); + self.app_context + .route_to_dpns_operator(DpnsOperatorRoute::Voting { + choices: self + .selected_votes + .iter() + .map(|vote| (vote.contested_name.clone(), vote.vote_choice)) + .collect(), + }); action = AppAction::SetMainScreen(RootScreenType::RootScreenMasternodes); } diff --git a/src/ui/masternodes/card.rs b/src/ui/masternodes/card.rs index c5dcbc7fb..0147f330b 100644 --- a/src/ui/masternodes/card.rs +++ b/src/ui/masternodes/card.rs @@ -2,7 +2,7 @@ //! `identity_picker_card.rs`'s visual language, adding voter-readiness, //! key-presence, and DPNS-status rows (colour always paired with text, NFR-6). -use crate::model::contested_name::MasternodeContestSummary; +use crate::model::contested_name::{MasternodeContestSummary, MasternodeVoteStateSummary}; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::{IdentityStatus, IdentityType, MasternodeKeyPresence}; use crate::ui::identity::identity_picker_card::{ @@ -59,7 +59,17 @@ pub fn voter_readiness_label(voting_present: bool) -> &'static str { /// (actionable), then a pending scheduled vote, then none. pub fn dpns_status_line(summary: MasternodeContestSummary) -> String { if summary.open_contest_count > 0 { - if summary.needs_vote_count == 0 { + if summary.vote_state == MasternodeVoteStateSummary::Checking { + format!( + "Checking votes for {} active contests", + summary.open_contest_count + ) + } else if summary.vote_state == MasternodeVoteStateSummary::Unavailable { + format!( + "Vote state unavailable for {} active contests", + summary.open_contest_count + ) + } else if summary.needs_vote_count == 0 { "Votes cast in all active contests".to_owned() } else if summary.needs_vote_count == 1 { format!( @@ -382,6 +392,7 @@ mod tests { open_contest_count: 3, needs_vote_count: 1, has_scheduled_vote: false, + ..Default::default() }; assert_eq!( dpns_status_line(summary), @@ -404,6 +415,7 @@ mod tests { open_contest_count: 2, needs_vote_count: 1, has_scheduled_vote: true, + ..Default::default() }; assert_eq!( dpns_status_line(summary), @@ -417,10 +429,39 @@ mod tests { open_contest_count: 0, needs_vote_count: 0, has_scheduled_vote: true, + ..Default::default() }; assert_eq!(dpns_status_line(summary), "Vote scheduled"); } + #[test] + fn dpns_status_reports_checking_instead_of_all_votes_cast() { + let summary = MasternodeContestSummary { + open_contest_count: 3, + vote_state: MasternodeVoteStateSummary::Checking, + ..Default::default() + }; + + assert_eq!( + dpns_status_line(summary), + "Checking votes for 3 active contests" + ); + } + + #[test] + fn dpns_status_reports_unavailable_instead_of_all_votes_cast() { + let summary = MasternodeContestSummary { + open_contest_count: 2, + vote_state: MasternodeVoteStateSummary::Unavailable, + ..Default::default() + }; + + assert_eq!( + dpns_status_line(summary), + "Vote state unavailable for 2 active contests" + ); + } + #[test] fn tc_fr3_04_05_badge_label_by_type() { let card = MasternodeCard::new( diff --git a/src/ui/masternodes/detail_screen.rs b/src/ui/masternodes/detail_screen.rs index 7274a6b4e..f21643f02 100644 --- a/src/ui/masternodes/detail_screen.rs +++ b/src/ui/masternodes/detail_screen.rs @@ -24,9 +24,7 @@ use crate::backend_task::contested_names::ContestedResourceTask; use crate::backend_task::identity::{IdentityInputToLoad, IdentityLoadMode, IdentityTask}; use crate::context::AppContext; use crate::model::contested_name::{ContestedName, MasternodeContestSummary}; -use crate::model::dpns_voting::{ - DpnsCurrentVoteState, DpnsVoteTarget, DpnsVoteTargetKey, VoteTiming, -}; +use crate::model::dpns_voting::{DpnsCurrentVoteState, DpnsVoteTargetKey}; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::{ IdentityType, MasternodeKeyPresence, PrivateKeyTarget, QualifiedIdentity, @@ -94,7 +92,6 @@ fn candidate_choice_label(candidate_name: &str, votes: u32) -> String { struct ContestVoteRow { name: String, end_time: Option, - vote_poll_id: dash_sdk::platform::Identifier, current_vote: DpnsCurrentVoteState, locked: bool, /// `(candidate id, candidate name, votes so far)` for each contestant. @@ -877,7 +874,6 @@ impl MasternodeDetailView { Some(ContestVoteRow { name: contest.normalized_contested_name.clone(), end_time: contest.end_time, - vote_poll_id, current_vote, locked: self .app_context @@ -931,8 +927,12 @@ impl MasternodeDetailView { .candidates .iter() .find(|(candidate_id, _, _)| *candidate_id == id) - .map(|(_, name, _)| name.as_str()) - .unwrap_or("candidate"); + .map(|(_, name, _)| { + format!("{name} ({})", shorten_id(&id.to_string(Encoding::Base58))) + }) + .unwrap_or_else(|| { + format!("candidate {}", shorten_id(&id.to_string(Encoding::Base58))) + }); format!("Current vote: {candidate}") } }; @@ -982,6 +982,19 @@ impl MasternodeDetailView { RichText::new("Refresh vote state before choosing a vote for this node.") .color(DashColors::text_secondary(dark_mode)), ); + if ComponentStyles::add_secondary_button(ui, "Refresh vote state", dark_mode) + .clicked() + { + self.vote_state_refresh_dispatched = true; + action = Some(AppAction::BackendTask(BackendTask::ContestedResourceTask( + ContestedResourceTask::QueryDPNSContests, + ))); + } + } else if matches!(contest.current_vote, DpnsCurrentVoteState::Checking) { + ui.label( + RichText::new("DET is checking this node's current vote.") + .color(DashColors::text_secondary(dark_mode)), + ); } else if selected.is_none() { ui.label( RichText::new(NO_SELECTION_HINT).color(DashColors::text_secondary(dark_mode)), @@ -990,48 +1003,19 @@ impl MasternodeDetailView { } ui.separator(); - let targets: Vec = self - .vote_selections - .iter() - .filter_map(|(name, choice)| { - let contest = contests.iter().find(|contest| &contest.name == name)?; - let DpnsCurrentVoteState::Available(current_choice) = contest.current_vote else { - return None; - }; - if current_choice == Some(*choice) || contest.locked { - return None; - } - Some(DpnsVoteTarget { - key: DpnsVoteTargetKey { - network: self.app_context.network(), - voter_id, - vote_poll_id: contest.vote_poll_id, - }, - voter_alias: self.identity.alias.clone(), - contested_name: name.clone(), - requested_choice: *choice, - current_choice, - timing: VoteTiming::Now, - }) - }) - .collect(); - let has_votes = !targets.is_empty(); - let review_label = if targets.len() == 1 { + let selection_count = self.vote_selections.len(); + let has_selections = selection_count > 0; + let review_label = if selection_count == 1 { "Review 1 vote".to_owned() } else { - format!("Review {} votes", targets.len()) + format!("Review {selection_count} votes") }; - if ComponentStyles::add_primary_button_enabled(ui, has_votes, review_label) + if ComponentStyles::add_primary_button_enabled(ui, has_selections, review_label) .clickable_tooltip(CAST_ENABLED_HINT) .disabled_tooltip(CAST_DISABLED_HINT) .clicked() { - self.open_voting_center_requested = Some( - targets - .into_iter() - .map(|target| (target.contested_name, target.requested_choice)) - .collect(), - ); + self.open_voting_center_requested = Some(self.vote_selections.clone()); } if ComponentStyles::add_secondary_button(ui, "Open Voting Center", dark_mode).clicked() { self.open_voting_center_requested = Some(BTreeMap::new()); diff --git a/src/ui/masternodes/list_screen.rs b/src/ui/masternodes/list_screen.rs index f33051e66..9fe61e634 100644 --- a/src/ui/masternodes/list_screen.rs +++ b/src/ui/masternodes/list_screen.rs @@ -5,22 +5,27 @@ use std::sync::Arc; +use chrono::{LocalResult, TimeZone, Utc}; +use chrono_humanize::HumanTime; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dash_sdk::platform::Identifier; use eframe::egui::{self, RichText}; use crate::app::{AppAction, BackendTasksExecutionMode}; use crate::backend_task::BackendTask; -use crate::backend_task::contested_names::ContestedResourceTask; +use crate::backend_task::contested_names::{ContestedResourceTask, ScheduledDPNSVote}; use crate::backend_task::identity::IdentityTask; -use crate::context::AppContext; use crate::context::identity_load_registry::{IdentityLoadPhase, IdentityLoadToken}; +use crate::context::{AppContext, DpnsOperatorRoute}; use crate::model::contested_name::MasternodeContestSummary; use crate::model::dpns_voting::DpnsVoteTargetStatus; use crate::model::masternode_input::decode_identity_id; use crate::model::qualified_identity::{IdentityStatus, IdentityType, MasternodeKeyPresence}; use crate::model::user_role::UserRole; +use crate::ui::components::component_trait::Component; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::global_nav_switcher::GlobalNavEffect; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; @@ -33,7 +38,7 @@ use crate::ui::masternodes::load_form::{LoadFormOutcome, MasternodeLoadForm}; use crate::ui::masternodes::voting_center::{DpnsVotingCenter, VotingCenterOutcome}; use crate::ui::state::global_nav::PageNavSpec; use crate::ui::state::masternodes_view::{masternodes_page_nav_spec, node_pill_item}; -use crate::ui::theme::{ComponentStyles, DashColors}; +use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use crate::ui::{RootScreenType, ScreenLike}; /// Minimum horizontal gap between cards in the grid (matches the identity @@ -105,6 +110,7 @@ pub struct MasternodesScreen { /// [`TaskError::IdentityLoadInProgress`](crate::backend_task::error::TaskError::IdentityLoadInProgress) /// instead of racing. pending_load: Option, + pending_schedule_cancellation: Option<(ScheduledDPNSVote, ConfirmationDialog)>, } #[cfg(test)] @@ -128,6 +134,7 @@ impl MasternodesScreen { nodes: Vec::new(), view: MasternodesView::List, pending_load: None, + pending_schedule_cancellation: None, }; screen.reload(); screen @@ -210,6 +217,7 @@ impl MasternodesScreen { pub fn reset_for_network_change(&mut self) { self.view = MasternodesView::List; self.pending_load = None; + self.pending_schedule_cancellation = None; self.reload(); } @@ -342,7 +350,7 @@ impl MasternodesScreen { } /// Shared target-correlated progress, visible regardless of the active node. - fn render_voting_activity(&self, ui: &mut egui::Ui) -> AppAction { + fn render_voting_activity(&mut self, ui: &mut egui::Ui) -> AppAction { let Ok(mut operations) = self.app_context.dpns_vote_operations() else { return AppAction::None; }; @@ -361,7 +369,21 @@ impl MasternodesScreen { let mut action = AppAction::None; ui.add_space(16.0); ui.heading(RichText::new("Voting activity").color(DashColors::text_primary(dark_mode))); + let mut open_operation = None; for operation in operations { + let total = operation.targets.len(); + let complete = operation + .targets + .iter() + .filter(|outcome| !outcome.status.holds_lock()) + .count(); + ui.label( + RichText::new(format!( + "Operation {} Β· {complete} of {total} targets settled", + operation.id + )) + .strong(), + ); for outcome in &operation.targets { let voter = outcome.target.voter_alias.clone().unwrap_or_else(|| { shorten_id(&outcome.target.key.voter_id.to_string(Encoding::Base58)) @@ -379,8 +401,10 @@ impl MasternodesScreen { }; ui.horizontal_wrapped(|ui| { ui.label(format!( - "{voter} / {} β€” {status}", - outcome.target.contested_name + "{voter} / {}.dash β€” {} β†’ {} β€” {status}", + outcome.target.contested_name, + vote_choice_summary(outcome.target.current_choice), + vote_choice_summary(Some(outcome.target.requested_choice)), )); if outcome.status == DpnsVoteTargetStatus::Unconfirmed && ComponentStyles::add_secondary_button(ui, "Check again", dark_mode) @@ -392,6 +416,16 @@ impl MasternodesScreen { } }); } + if ComponentStyles::add_secondary_button(ui, "View operation", dark_mode).clicked() { + open_operation = Some(operation.id); + } + ui.separator(); + } + if let Some(operation_id) = open_operation { + self.view = MasternodesView::Voting(Box::new(DpnsVotingCenter::for_operation( + &self.app_context, + operation_id, + ))); } action } @@ -559,11 +593,11 @@ impl MasternodesScreen { )) .strong(), ); - ui.label(format!("Choice: {}", vote.choice)); ui.label(format!( - "Scheduled time: {} UTC milliseconds", - vote.unix_timestamp + "Choice: {}", + vote_choice_summary(Some(vote.choice)) )); + ui.label(format_scheduled_time(vote.unix_timestamp)); ui.label(match status { Some(DpnsVoteTargetStatus::Unconfirmed) => "Status: Checking result", Some( @@ -576,22 +610,37 @@ impl MasternodesScreen { _ => "Status: Needs attention", }); let editable = status == Some(DpnsVoteTargetStatus::Scheduled); - if ComponentStyles::add_secondary_button(ui, "Edit schedule", dark_mode).clicked() - && editable + let disabled_reason = + "This scheduled vote cannot be changed after submission has started."; + if ui + .add_enabled( + editable, + ComponentStyles::secondary_button("Edit schedule", dark_mode), + ) + .disabled_tooltip(disabled_reason) + .clicked() { self.view = MasternodesView::Voting(Box::new( DpnsVotingCenter::for_scheduled_edit(&self.app_context, &vote), )); } - if ComponentStyles::add_secondary_button(ui, "Cancel scheduled vote", dark_mode) + if ui + .add_enabled( + editable, + ComponentStyles::secondary_button("Cancel scheduled vote", dark_mode), + ) + .disabled_tooltip(disabled_reason) .clicked() - && editable { - action = AppAction::BackendTask(BackendTask::ContestedResourceTask( - ContestedResourceTask::DeleteScheduledVote( - vote.voter_id, - vote.contested_name.clone(), - ), + let message = format!( + "Cancel the scheduled vote for {}.dash? This removes it before submission.", + vote.contested_name + ); + self.pending_schedule_cancellation = Some(( + vote.clone(), + ConfirmationDialog::new("Cancel scheduled vote", message) + .danger_mode(true) + .confirm_text(Some("Cancel scheduled vote")), )); } if status == Some(DpnsVoteTargetStatus::Unconfirmed) @@ -612,6 +661,21 @@ impl MasternodesScreen { } }); } + if let Some((vote, dialog)) = self.pending_schedule_cancellation.as_mut() { + let result = dialog.show(ui).inner.dialog_response; + if let Some(result) = result { + let vote = vote.clone(); + self.pending_schedule_cancellation = None; + if result == ConfirmationStatus::Confirmed { + action = AppAction::BackendTask(BackendTask::ContestedResourceTask( + ContestedResourceTask::DeleteScheduledVote( + vote.voter_id, + vote.contested_name, + ), + )); + } + } + } action } @@ -733,6 +797,31 @@ impl MasternodesScreen { } } +fn vote_choice_summary(choice: Option) -> String { + match choice { + None => "Not voted".to_owned(), + Some(ResourceVoteChoice::Abstain) => "Abstain".to_owned(), + Some(ResourceVoteChoice::Lock) => "Lock".to_owned(), + Some(ResourceVoteChoice::TowardsIdentity(identity)) => { + format!( + "Candidate {}", + shorten_id(&identity.to_string(Encoding::Base58)) + ) + } + } +} + +fn format_scheduled_time(timestamp: u64) -> String { + match Utc.timestamp_millis_opt(timestamp as i64) { + LocalResult::Single(date_time) => format!( + "Scheduled time: {} UTC ({})", + date_time.format("%Y-%m-%d %H:%M"), + HumanTime::from(date_time) + ), + _ => "Scheduled time: Unavailable".to_owned(), + } +} + impl ScreenLike for MasternodesScreen { fn refresh(&mut self) { self.reload(); @@ -741,8 +830,15 @@ impl ScreenLike for MasternodesScreen { fn refresh_on_arrival(&mut self) { self.reload(); self.reconcile_pending_load(); - if let Some(contests) = self.app_context.take_dpns_voting_center_route() { - self.open_voting_center(None, contests); + if let Some(route) = self.app_context.take_dpns_operator_route() { + match route { + DpnsOperatorRoute::Voting { choices } => { + self.view = MasternodesView::Voting(Box::new( + DpnsVotingCenter::for_bulk_choices(&self.app_context, choices), + )); + } + DpnsOperatorRoute::Scheduled => self.view = MasternodesView::Scheduled, + } } } diff --git a/src/ui/masternodes/voting_center.rs b/src/ui/masternodes/voting_center.rs index bda50bba2..b483462ee 100644 --- a/src/ui/masternodes/voting_center.rs +++ b/src/ui/masternodes/voting_center.rs @@ -3,7 +3,8 @@ use std::collections::BTreeMap; use std::sync::Arc; -use chrono::{Duration, Utc}; +use chrono::{Duration, LocalResult, TimeZone, Utc}; +use chrono_humanize::HumanTime; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; @@ -13,16 +14,14 @@ use eframe::egui::{self, ComboBox, RichText}; use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::contested_names::{ContestedResourceTask, ScheduledDPNSVote}; -use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::contested_name::ContestedName; use crate::model::dpns_voting::{ DpnsCurrentVoteState, DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteTarget, DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, }; +use crate::model::qualified_identity::PrivateKeyTarget; use crate::model::qualified_identity::QualifiedIdentity; -use crate::ui::MessageType; -use crate::ui::components::MessageBanner; use crate::ui::state::dpns_vote_workspace::{ ComposerKeyAction, DpnsVoteComposerStep, DpnsVoteWorkspace, DraftVoteTiming, }; @@ -45,6 +44,19 @@ pub struct DpnsVotingCenter { focus_step_heading: bool, } +struct ReviewDraft { + operation: DpnsVoteOperation, + exclusions: Vec, +} + +struct ReviewExclusion { + voter: String, + contest: String, + requested_choice: ResourceVoteChoice, + reason: &'static str, + no_op: bool, +} + impl DpnsVotingCenter { pub fn new( app_context: &Arc, @@ -94,17 +106,9 @@ impl DpnsVotingCenter { .workspace .contest_choices .insert(vote.contested_name.clone(), vote.choice); - let remaining_minutes = vote - .unix_timestamp - .saturating_sub(Utc::now().timestamp_millis() as u64) - / 60_000; center.workspace.node_timing.insert( vote.voter_id, - DraftVoteTiming::Scheduled { - days: (remaining_minutes / (24 * 60)) as u32, - hours: ((remaining_minutes / 60) % 24) as u32, - minutes: (remaining_minutes % 60) as u32, - }, + scheduled_offset_from_now(vote.unix_timestamp), ); center.editing_scheduled_key = app_context .dpns_vote_poll_id(&vote.contested_name) @@ -132,6 +136,21 @@ impl DpnsVotingCenter { center } + pub fn for_bulk_choices( + app_context: &Arc, + choices: BTreeMap, + ) -> Self { + let mut center = Self::new(app_context, None, choices.keys().cloned().collect()); + center.workspace.contest_choices = choices; + center + } + + pub fn for_operation(app_context: &Arc, operation_id: DpnsVoteOperationId) -> Self { + let mut center = Self::new(app_context, None, Vec::new()); + center.submitted_operation = Some(operation_id); + center + } + pub fn show(&mut self, ui: &mut egui::Ui) -> VotingCenterOutcome { if self.submitted_operation.is_some() { return self.render_operation(ui); @@ -145,9 +164,7 @@ impl DpnsVotingCenter { let can_continue = match self.workspace.step { DpnsVoteComposerStep::Nodes => self.workspace.selected_node_count() > 0, DpnsVoteComposerStep::Votes => !self.workspace.contest_choices.is_empty(), - DpnsVoteComposerStep::Review => self - .build_operation() - .is_ok_and(|operation| !operation.targets.is_empty()), + DpnsVoteComposerStep::Review => !self.build_review().operation.targets.is_empty(), }; match self.workspace.keyboard_action(enter, escape, can_continue) { ComposerKeyAction::CloseDraft => return VotingCenterOutcome::BackToNodes, @@ -161,9 +178,7 @@ impl DpnsVotingCenter { self.focus_step_heading = true; } ComposerKeyAction::Submit => { - if let Ok(operation) = self.build_operation() { - return self.submit_operation(operation); - } + return self.submit_operation(self.build_review().operation); } ComposerKeyAction::None => {} } @@ -197,7 +212,7 @@ impl DpnsVotingCenter { &mut self.workspace.set_all_timing, ); if ComponentStyles::add_secondary_button(ui, "Apply", dark_mode).clicked() { - self.workspace.apply_timing_to_all(); + self.workspace.apply_timing_to_selected(); } }); ui.separator(); @@ -207,15 +222,33 @@ impl DpnsVotingCenter { .alias .clone() .unwrap_or_else(|| voter_id.to_string(Encoding::Base58)); + let has_voting_key = has_loaded_voting_key(voter); ui.horizontal_wrapped(|ui| { - ui.label(RichText::new(alias).strong()); + let mut selected = self.workspace.is_node_selected(&voter_id); + let checkbox = ui.add_enabled( + has_voting_key, + egui::Checkbox::new(&mut selected, RichText::new(alias).strong()), + ); + if checkbox.changed() { + self.workspace.set_node_selected(voter_id, selected); + } + checkbox + .disabled_tooltip("Load this node's voting private key before selecting it."); let timing = self .workspace .node_timing .entry(voter_id) - .or_insert(DraftVoteTiming::Excluded); - timing_combo(ui, format!("voting_center_node_{voter_id}"), timing); - render_schedule_offset(ui, timing); + .or_insert(DraftVoteTiming::Now); + ui.add_enabled_ui(selected && has_voting_key, |ui| { + timing_combo(ui, format!("voting_center_node_{voter_id}"), timing); + render_schedule_offset(ui, timing); + }); + if !has_voting_key { + ui.label( + RichText::new("Voting key missing") + .color(DashColors::warning_color(dark_mode)), + ); + } }); } ui.separator(); @@ -234,6 +267,7 @@ impl DpnsVotingCenter { let dark_mode = ui.style().visuals.dark_mode; self.step_heading(ui, "Step 2 of 3: Votes"); ui.label("Choose one requested vote for each contested name."); + let mut outcome = VotingCenterOutcome::None; for contest in &self.contests { let name = &contest.normalized_contested_name; ui.separator(); @@ -244,7 +278,7 @@ impl DpnsVotingCenter { .color(DashColors::text_secondary(dark_mode)), ); let vote_poll_id = self.app_context.dpns_vote_poll_id(name).ok(); - let controls_enabled = !states.iter().any(|(voter_id, state, locked)| { + let controls_enabled = states.iter().any(|(voter_id, state, locked)| { let lock_is_this_edit = vote_poll_id.is_some_and(|vote_poll_id| { self.editing_scheduled_key.as_ref() == Some(&DpnsVoteTargetKey { @@ -253,8 +287,8 @@ impl DpnsVotingCenter { vote_poll_id, }) }); - (*locked && !lock_is_this_edit) - || !matches!(state, DpnsCurrentVoteState::Available(_)) + (!*locked || lock_is_this_edit) + && matches!(state, DpnsCurrentVoteState::Available(_)) }); let selected = self.workspace.contest_choices.get(name).copied(); ui.add_enabled_ui(controls_enabled, |ui| { @@ -287,17 +321,41 @@ impl DpnsVotingCenter { } }); }); - if !controls_enabled { + for (voter_id, state, locked) in &states { + let voter = self.voter_label(*voter_id); + let status = match (state, locked) { + (_, true) => "This target already has a voting operation in progress.", + (DpnsCurrentVoteState::Checking, false) => { + "DET is checking this node's current vote." + } + (DpnsCurrentVoteState::Unavailable, false) => { + "This node's current vote is unavailable." + } + _ => continue, + }; ui.label( - RichText::new( - "This contest is unavailable for at least one selected node. Refresh or wait for the active vote to finish.", - ) - .color(DashColors::text_secondary(dark_mode)), + RichText::new(format!("{voter}: {status}")) + .color(DashColors::text_secondary(dark_mode)), ); } + let can_refresh = states.iter().any(|(_, state, locked)| { + !locked + && matches!( + state, + DpnsCurrentVoteState::Checking | DpnsCurrentVoteState::Unavailable + ) + }); + if can_refresh + && ComponentStyles::add_secondary_button(ui, "Refresh vote state", dark_mode) + .clicked() + { + self.vote_state_refresh_dispatched = true; + outcome = VotingCenterOutcome::Action(Box::new(AppAction::BackendTask( + BackendTask::ContestedResourceTask(ContestedResourceTask::QueryDPNSContests), + ))); + } } ui.separator(); - let mut outcome = VotingCenterOutcome::None; ui.horizontal(|ui| { if ComponentStyles::add_secondary_button(ui, "Back", dark_mode).clicked() { self.workspace.step = DpnsVoteComposerStep::Nodes; @@ -328,22 +386,13 @@ impl DpnsVotingCenter { fn render_review(&mut self, ui: &mut egui::Ui) -> VotingCenterOutcome { let dark_mode = ui.style().visuals.dark_mode; self.step_heading(ui, "Step 3 of 3: Review"); - let operation = match self.build_operation() { - Ok(operation) => operation, - Err(error) => { - MessageBanner::set_global(ui.ctx(), error.to_string(), MessageType::Error) - .with_details(&error); - self.workspace.step = DpnsVoteComposerStep::Votes; - self.focus_step_heading = true; - return VotingCenterOutcome::None; - } - }; - for outcome in &operation.targets { + let review = self.build_review(); + for outcome in &review.operation.targets { let voter = outcome .target .voter_alias - .as_deref() - .unwrap_or("Unnamed node"); + .clone() + .unwrap_or_else(|| shorten_identifier(outcome.target.key.voter_id)); ui.group(|ui| { ui.label(RichText::new(format!( "{voter} / {}.dash", @@ -351,19 +400,17 @@ impl DpnsVotingCenter { )) .strong()); ui.label(format!( - "Current: {}", - choice_label(outcome.target.current_choice) - )); - ui.label(format!( - "Requested: {}", - choice_label(Some(outcome.target.requested_choice)) + "{} β†’ {}", + self.choice_label( + &outcome.target.contested_name, + outcome.target.current_choice + ), + self.choice_label( + &outcome.target.contested_name, + Some(outcome.target.requested_choice) + ) )); - ui.label(match outcome.target.timing { - VoteTiming::Now => "When: Cast now".to_owned(), - VoteTiming::Scheduled(timestamp) => { - format!("When: Scheduled for {timestamp} UTC milliseconds") - } - }); + ui.label(format_timing(outcome.target.timing)); if outcome.target.current_choice.is_some() { ui.label( RichText::new( @@ -374,15 +421,24 @@ impl DpnsVotingCenter { } }); } - if operation.no_op_count > 0 { + if review.operation.no_op_count > 0 { ui.label(format!( "{} targets already have the requested vote and will not be submitted.", - operation.no_op_count + review.operation.no_op_count + )); + } + for exclusion in &review.exclusions { + ui.label(format!( + "{} / {}.dash β†’ {} was excluded. {}", + exclusion.voter, + exclusion.contest, + self.choice_label(&exclusion.contest, Some(exclusion.requested_choice)), + exclusion.reason )); } ui.label(format!( "{} targets total. Each submitted vote uses Platform credits.", - operation.targets.len() + review.operation.targets.len() )); ui.horizontal(|ui| { if ComponentStyles::add_secondary_button(ui, "Back", dark_mode).clicked() { @@ -390,17 +446,25 @@ impl DpnsVotingCenter { self.focus_step_heading = true; } }); - if operation.targets.is_empty() { - ui.label( - "Every selected node already has the requested vote. Nothing will be submitted.", - ); + if review.operation.targets.is_empty() { + if review.operation.no_op_count > 0 + && review.exclusions.iter().all(|exclusion| exclusion.no_op) + { + ui.label( + "Every selected node already has the requested vote. Nothing will be submitted.", + ); + } else { + ui.label( + "No targets are ready to submit. Refresh unavailable vote state or wait for in-progress targets.", + ); + } return VotingCenterOutcome::None; } - let target_count = operation.targets.len(); + let target_count = review.operation.targets.len(); if ComponentStyles::add_primary_button(ui, format!("Submit {target_count} targets")) .clicked() { - return self.submit_operation(operation); + return self.submit_operation(review.operation); } VotingCenterOutcome::None } @@ -414,17 +478,39 @@ impl DpnsVotingCenter { match self.app_context.dpns_vote_operation(operation_id) { Ok(Some(operation)) => { for outcome in &operation.targets { - ui.horizontal_wrapped(|ui| { + ui.group(|ui| { + let voter = outcome + .target + .voter_alias + .clone() + .unwrap_or_else(|| shorten_identifier(outcome.target.key.voter_id)); + ui.label( + RichText::new(format!( + "{voter} / {}.dash", + outcome.target.contested_name + )) + .strong(), + ); ui.label(format!( - "{} / {}.dash β€” {}", - outcome - .target - .voter_alias - .as_deref() - .unwrap_or("Unnamed node"), - outcome.target.contested_name, - status_label(outcome.status) + "{} β†’ {}", + self.choice_label( + &outcome.target.contested_name, + outcome.target.current_choice + ), + self.choice_label( + &outcome.target.contested_name, + Some(outcome.target.requested_choice) + ) )); + ui.label(format_timing(outcome.target.timing)); + ui.label(format!("Status: {}", status_label(outcome.status))); + ui.label(status_explanation(outcome.status)); + if let Some(hash) = outcome.transition_hash { + let hash = hex::encode(hash); + if ui.small_button("Copy transition hash").clicked() { + ui.ctx().copy_text(hash); + } + } }); } if operation @@ -439,6 +525,58 @@ impl DpnsVotingCenter { ), ))); } + if operation.targets.iter().any(|outcome| { + matches!( + outcome.status, + DpnsVoteTargetStatus::Rejected + | DpnsVoteTargetStatus::FailedBeforeSubmission + | DpnsVoteTargetStatus::NotApplied + ) + }) && ComponentStyles::add_secondary_button(ui, "Review again", dark_mode) + .clicked() + { + self.workspace.contest_choices = operation + .targets + .iter() + .filter(|outcome| { + matches!( + outcome.status, + DpnsVoteTargetStatus::Rejected + | DpnsVoteTargetStatus::FailedBeforeSubmission + | DpnsVoteTargetStatus::NotApplied + ) + }) + .map(|outcome| { + ( + outcome.target.contested_name.clone(), + outcome.target.requested_choice, + ) + }) + .collect(); + for outcome in &operation.targets { + if matches!( + outcome.status, + DpnsVoteTargetStatus::Rejected + | DpnsVoteTargetStatus::FailedBeforeSubmission + | DpnsVoteTargetStatus::NotApplied + ) { + self.workspace + .set_node_selected(outcome.target.key.voter_id, true); + self.workspace.node_timing.insert( + outcome.target.key.voter_id, + match outcome.target.timing { + VoteTiming::Now => DraftVoteTiming::Now, + VoteTiming::Scheduled(timestamp) => { + scheduled_offset_from_now(timestamp) + } + }, + ); + } + } + self.workspace.step = DpnsVoteComposerStep::Review; + self.submitted_operation = None; + self.focus_step_heading = true; + } if ComponentStyles::add_secondary_button(ui, "Continue in background", dark_mode) .clicked() { @@ -461,12 +599,8 @@ impl DpnsVotingCenter { fn selected_voters(&self) -> Vec { self.voters .iter() - .filter(|voter| { - self.workspace - .node_timing - .get(&voter.identity.id()) - .is_some_and(|timing| *timing != DraftVoteTiming::Excluded) - }) + .filter(|voter| self.workspace.is_node_selected(&voter.identity.id())) + .filter(|voter| has_loaded_voting_key(voter)) .cloned() .collect() } @@ -518,7 +652,17 @@ impl DpnsVotingCenter { .app_context .dpns_vote_poll_id(&contest.normalized_contested_name) else { - return Vec::new(); + return self + .selected_voters() + .into_iter() + .map(|voter| { + ( + voter.identity.id(), + DpnsCurrentVoteState::Unavailable, + false, + ) + }) + .collect(); }; self.selected_voters() .into_iter() @@ -544,13 +688,13 @@ impl DpnsVotingCenter { .collect() } - fn build_operation(&self) -> Result { + fn build_review(&self) -> ReviewDraft { let mut targets = Vec::new(); + let mut exclusions = Vec::new(); for voter in self.selected_voters() { let voter_id = voter.identity.id(); let draft_timing = self.workspace.node_timing[&voter_id]; let timing = match draft_timing { - DraftVoteTiming::Excluded => continue, DraftVoteTiming::Now => VoteTiming::Now, DraftVoteTiming::Scheduled { days, @@ -565,24 +709,84 @@ impl DpnsVotingCenter { ), }; for (name, requested_choice) in &self.workspace.contest_choices { - let vote_poll_id = self.app_context.dpns_vote_poll_id(name)?; + let vote_poll_id = match self.app_context.dpns_vote_poll_id(name) { + Ok(vote_poll_id) => vote_poll_id, + Err(_) => { + exclusions.push(ReviewExclusion { + voter: self.voter_label(voter_id), + contest: name.clone(), + requested_choice: *requested_choice, + reason: "Refresh this contest before submitting a vote.", + no_op: false, + }); + continue; + } + }; let key = DpnsVoteTargetKey { network: self.app_context.network(), voter_id, vote_poll_id, }; - let existing_status = self.app_context.dpns_vote_target_status(&key)?; + let existing_status = match self.app_context.dpns_vote_target_status(&key) { + Ok(status) => status, + Err(_) => { + exclusions.push(ReviewExclusion { + voter: self.voter_label(voter_id), + contest: name.clone(), + requested_choice: *requested_choice, + reason: "DET could not check whether this target is already in use.", + no_op: false, + }); + continue; + } + }; let replacing_schedule = existing_status == Some(DpnsVoteTargetStatus::Scheduled) && matches!(timing, VoteTiming::Scheduled(_)); if existing_status.is_some() && !replacing_schedule { - return Err(TaskError::DpnsVoteTargetBusy); + exclusions.push(ReviewExclusion { + voter: self.voter_label(voter_id), + contest: name.clone(), + requested_choice: *requested_choice, + reason: "Another voting operation is still using this target.", + no_op: false, + }); + continue; } - let DpnsCurrentVoteState::Available(current_choice) = self + let current_choice = match self .app_context - .dpns_current_vote_state(voter_id, vote_poll_id)? - else { - return Err(TaskError::DpnsCurrentVoteUnavailable); + .dpns_current_vote_state(voter_id, vote_poll_id) + { + Ok(DpnsCurrentVoteState::Available(current_choice)) => current_choice, + Ok(DpnsCurrentVoteState::Checking) => { + exclusions.push(ReviewExclusion { + voter: self.voter_label(voter_id), + contest: name.clone(), + requested_choice: *requested_choice, + reason: "DET is still checking this node's current vote.", + no_op: false, + }); + continue; + } + Ok(DpnsCurrentVoteState::Unavailable) | Err(_) => { + exclusions.push(ReviewExclusion { + voter: self.voter_label(voter_id), + contest: name.clone(), + requested_choice: *requested_choice, + reason: "Refresh this node's vote state before submitting it.", + no_op: false, + }); + continue; + } }; + if current_choice == Some(*requested_choice) { + exclusions.push(ReviewExclusion { + voter: self.voter_label(voter_id), + contest: name.clone(), + requested_choice: *requested_choice, + reason: "This node already has the requested vote, so nothing will be submitted.", + no_op: true, + }); + } targets.push(DpnsVoteTarget { key, voter_alias: voter.alias.clone(), @@ -593,19 +797,38 @@ impl DpnsVotingCenter { }); } } - Ok(DpnsVoteOperation::new(targets)) + ReviewDraft { + operation: DpnsVoteOperation::new(targets), + exclusions, + } + } + + fn voter_label(&self, voter_id: Identifier) -> String { + self.voters + .iter() + .find(|voter| voter.identity.id() == voter_id) + .and_then(|voter| voter.alias.clone()) + .unwrap_or_else(|| shorten_identifier(voter_id)) + } + + fn choice_label(&self, contest_name: &str, choice: Option) -> String { + let contestants = self + .contests + .iter() + .find(|contest| contest.normalized_contested_name == contest_name) + .and_then(|contest| contest.contestants.as_deref()) + .unwrap_or_default(); + choice_label(choice, contestants) } } fn timing_combo(ui: &mut egui::Ui, id: impl Into, timing: &mut DraftVoteTiming) { ComboBox::from_id_salt(id.into()) .selected_text(match timing { - DraftVoteTiming::Excluded => "Do not use this node", DraftVoteTiming::Now => "Cast now", DraftVoteTiming::Scheduled { .. } => "Schedule", }) .show_ui(ui, |ui| { - ui.selectable_value(timing, DraftVoteTiming::Excluded, "Do not use this node"); ui.selectable_value(timing, DraftVoteTiming::Now, "Cast now"); if ui .selectable_label( @@ -657,38 +880,123 @@ fn vote_choice( } fn current_summary(states: &[(Identifier, DpnsCurrentVoteState, bool)]) -> String { - if states.iter().any(|(_, state, _)| { - matches!( - state, - DpnsCurrentVoteState::Checking | DpnsCurrentVoteState::Unavailable - ) - }) { - return "Current vote unavailable for at least one selected node".to_owned(); + if states.is_empty() { + return "Current vote state is unavailable for selected nodes".to_owned(); } + let checking = states + .iter() + .filter(|(_, state, locked)| !locked && *state == DpnsCurrentVoteState::Checking) + .count(); + let unavailable = states + .iter() + .filter(|(_, state, locked)| !locked && *state == DpnsCurrentVoteState::Unavailable) + .count(); + let busy = states.iter().filter(|(_, _, locked)| *locked).count(); + let available = states + .iter() + .filter(|(_, state, locked)| !locked && matches!(state, DpnsCurrentVoteState::Available(_))) + .count(); let not_voted = states .iter() - .filter(|(_, state, _)| *state == DpnsCurrentVoteState::Available(None)) + .filter(|(_, state, locked)| !locked && *state == DpnsCurrentVoteState::Available(None)) .count(); - if not_voted == states.len() { - "Current across selected nodes: Not voted".to_owned() - } else if not_voted > 0 { - format!("Current across selected nodes: {not_voted} not voted, others already voted") + if available == states.len() && not_voted == states.len() { + return "Current across selected nodes: Not voted".to_owned(); + } + if available == states.len() && not_voted == 0 { + return "Current across selected nodes: All already voted".to_owned(); + } + let mut parts = Vec::new(); + if not_voted > 0 { + parts.push(format!("{not_voted} not voted")); + } + let already_voted = available.saturating_sub(not_voted); + if already_voted > 0 { + parts.push(format!("{already_voted} already voted")); + } + if checking > 0 { + parts.push(format!("{checking} checking")); + } + if unavailable > 0 { + parts.push(format!("{unavailable} unavailable")); + } + if busy > 0 { + parts.push(format!("{busy} in progress")); + } + if parts.is_empty() { + "Current across selected nodes: No usable targets".to_owned() } else { - "Current across selected nodes: All already voted".to_owned() + format!("Current across selected nodes: {}", parts.join(", ")) } } -fn choice_label(choice: Option) -> String { +fn choice_label( + choice: Option, + contestants: &[crate::model::contested_name::Contestant], +) -> String { match choice { None => "Not voted".to_owned(), Some(ResourceVoteChoice::Abstain) => "Abstain".to_owned(), Some(ResourceVoteChoice::Lock) => "Lock".to_owned(), Some(ResourceVoteChoice::TowardsIdentity(identity)) => { - format!("Vote for {identity}") + let short_id = shorten_identifier(identity); + contestants + .iter() + .find(|contestant| contestant.id == identity) + .map(|contestant| format!("{} ({short_id})", contestant.name)) + .unwrap_or_else(|| format!("Candidate {short_id}")) } } } +fn shorten_identifier(identifier: Identifier) -> String { + let encoded = identifier.to_string(Encoding::Base58); + if encoded.chars().count() <= 12 { + return encoded; + } + let first = encoded.chars().take(6).collect::(); + let last = encoded + .chars() + .rev() + .take(4) + .collect::() + .chars() + .rev() + .collect::(); + format!("{first}…{last}") +} + +fn format_timing(timing: VoteTiming) -> String { + match timing { + VoteTiming::Now => "When: Cast now".to_owned(), + VoteTiming::Scheduled(timestamp) => match Utc.timestamp_millis_opt(timestamp as i64) { + LocalResult::Single(date_time) => format!( + "When: {} UTC ({})", + date_time.format("%Y-%m-%d %H:%M"), + HumanTime::from(date_time) + ), + _ => "When: The scheduled time is unavailable.".to_owned(), + }, + } +} + +fn scheduled_offset_from_now(timestamp: u64) -> DraftVoteTiming { + let remaining_minutes = timestamp.saturating_sub(Utc::now().timestamp_millis() as u64) / 60_000; + DraftVoteTiming::Scheduled { + days: (remaining_minutes / (24 * 60)) as u32, + hours: ((remaining_minutes / 60) % 24) as u32, + minutes: (remaining_minutes % 60) as u32, + } +} + +fn has_loaded_voting_key(voter: &QualifiedIdentity) -> bool { + voter + .private_keys + .keys_set() + .iter() + .any(|(target, _)| *target == PrivateKeyTarget::PrivateKeyOnVoterIdentity) +} + fn status_label(status: DpnsVoteTargetStatus) -> &'static str { match status { DpnsVoteTargetStatus::Scheduled => "Scheduled", @@ -702,3 +1010,92 @@ fn status_label(status: DpnsVoteTargetStatus) -> &'static str { DpnsVoteTargetStatus::NotApplied => "Not applied", } } + +fn status_explanation(status: DpnsVoteTargetStatus) -> &'static str { + match status { + DpnsVoteTargetStatus::Scheduled => "DET will submit this vote at the scheduled time.", + DpnsVoteTargetStatus::Queued => "This vote is waiting to be submitted.", + DpnsVoteTargetStatus::Submitting => "DET is submitting this vote.", + DpnsVoteTargetStatus::Confirming => "The vote was submitted and is being confirmed.", + DpnsVoteTargetStatus::Confirmed => "Platform confirmed this vote.", + DpnsVoteTargetStatus::Unconfirmed => { + "The vote was submitted, but DET could not confirm it yet. Do not submit it again." + } + DpnsVoteTargetStatus::Rejected => { + "Platform rejected this vote. Review the choice before trying again." + } + DpnsVoteTargetStatus::FailedBeforeSubmission => { + "This vote was not submitted. Review it before trying again." + } + DpnsVoteTargetStatus::NotApplied => { + "DET proved that this vote was not applied. It is safe to review it again." + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::contested_name::Contestant; + + fn contestant(id: Identifier, name: &str) -> Contestant { + Contestant { + id, + name: name.to_owned(), + info: String::new(), + votes: 0, + created_at: None, + created_at_block_height: None, + created_at_core_block_height: None, + document_id: Identifier::from([9; 32]), + } + } + + #[test] + fn candidate_choice_uses_name_and_short_identifier() { + let candidate = Identifier::from([7; 32]); + let label = choice_label( + Some(ResourceVoteChoice::TowardsIdentity(candidate)), + &[contestant(candidate, "dominguez")], + ); + + assert!(label.starts_with("dominguez (")); + assert!(label.contains('…')); + assert!(!label.contains(&candidate.to_string(Encoding::Base58))); + } + + #[test] + fn current_summary_keeps_healthy_targets_usable() { + let states = [ + ( + Identifier::from([1; 32]), + DpnsCurrentVoteState::Available(None), + false, + ), + ( + Identifier::from([2; 32]), + DpnsCurrentVoteState::Unavailable, + false, + ), + ( + Identifier::from([3; 32]), + DpnsCurrentVoteState::Available(Some(ResourceVoteChoice::Lock)), + false, + ), + ]; + + assert_eq!( + current_summary(&states), + "Current across selected nodes: 1 not voted, 1 already voted, 1 unavailable" + ); + } + + #[test] + fn scheduled_time_is_absolute_and_relative() { + let timestamp = (Utc::now() + Duration::hours(2)).timestamp_millis() as u64; + let label = format_timing(VoteTiming::Scheduled(timestamp)); + + assert!(label.starts_with("When: 20")); + assert!(label.contains(" UTC (")); + } +} diff --git a/src/ui/state/dpns_vote_workspace.rs b/src/ui/state/dpns_vote_workspace.rs index 1b3170590..827d07ab5 100644 --- a/src/ui/state/dpns_vote_workspace.rs +++ b/src/ui/state/dpns_vote_workspace.rs @@ -2,7 +2,7 @@ use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dash_sdk::platform::Identifier; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; /// Current step of the full-page voting composer. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -23,7 +23,6 @@ pub enum ComposerKeyAction { /// Per-node timing override in the draft. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DraftVoteTiming { - Excluded, Now, Scheduled { days: u32, hours: u32, minutes: u32 }, } @@ -32,6 +31,7 @@ pub enum DraftVoteTiming { #[derive(Debug, Clone)] pub struct DpnsVoteWorkspace { pub step: DpnsVoteComposerStep, + selected_nodes: BTreeSet, pub node_timing: BTreeMap, pub contest_choices: BTreeMap, pub set_all_timing: DraftVoteTiming, @@ -41,6 +41,7 @@ impl DpnsVoteWorkspace { pub fn new(node_ids: impl IntoIterator) -> Self { Self { step: DpnsVoteComposerStep::Nodes, + selected_nodes: BTreeSet::new(), node_timing: node_ids .into_iter() .map(|node_id| (node_id, DraftVoteTiming::Now)) @@ -52,25 +53,36 @@ impl DpnsVoteWorkspace { /// Restrict the initial draft to one node from a detail-page deep link. pub fn prefilter_node(&mut self, selected: Identifier) { - for (node_id, timing) in &mut self.node_timing { - *timing = if *node_id == selected { - DraftVoteTiming::Now - } else { - DraftVoteTiming::Excluded - }; + self.selected_nodes.clear(); + if let Some(timing) = self.node_timing.get_mut(&selected) { + *timing = DraftVoteTiming::Now; + self.selected_nodes.insert(selected); } } pub fn selected_node_count(&self) -> usize { - self.node_timing - .values() - .filter(|timing| **timing != DraftVoteTiming::Excluded) - .count() + self.selected_nodes.len() } - pub fn apply_timing_to_all(&mut self) { - for timing in self.node_timing.values_mut() { - *timing = self.set_all_timing; + pub fn is_node_selected(&self, node_id: &Identifier) -> bool { + self.selected_nodes.contains(node_id) + } + + pub fn set_node_selected(&mut self, node_id: Identifier, selected: bool) { + if selected { + if self.node_timing.contains_key(&node_id) { + self.selected_nodes.insert(node_id); + } + } else { + self.selected_nodes.remove(&node_id); + } + } + + pub fn apply_timing_to_selected(&mut self) { + for node_id in &self.selected_nodes { + if let Some(timing) = self.node_timing.get_mut(node_id) { + *timing = self.set_all_timing; + } } } @@ -109,7 +121,9 @@ mod tests { hours: 2, minutes: 3, }; - workspace.apply_timing_to_all(); + workspace.set_node_selected(first, true); + workspace.set_node_selected(second, true); + workspace.apply_timing_to_selected(); workspace.node_timing.insert(first, DraftVoteTiming::Now); assert_eq!(workspace.node_timing[&first], DraftVoteTiming::Now); @@ -129,7 +143,41 @@ mod tests { assert_eq!(workspace.selected_node_count(), 1); assert_eq!(workspace.node_timing[&selected], DraftVoteTiming::Now); - assert_eq!(workspace.node_timing[&other], DraftVoteTiming::Excluded); + assert!(!workspace.is_node_selected(&other)); + } + + /// VOTE-TC-020: an unfiltered bulk draft starts with no nodes selected. + #[test] + fn bulk_draft_starts_without_selected_nodes() { + let first = Identifier::from([1; 32]); + let second = Identifier::from([2; 32]); + let workspace = DpnsVoteWorkspace::new([first, second]); + + assert_eq!(workspace.selected_node_count(), 0); + assert!(!workspace.is_node_selected(&first)); + assert!(!workspace.is_node_selected(&second)); + } + + /// VOTE-TC-021: set-all changes timing only for explicitly selected nodes. + #[test] + fn set_all_timing_ignores_unselected_nodes() { + let selected = Identifier::from([1; 32]); + let unselected = Identifier::from([2; 32]); + let mut workspace = DpnsVoteWorkspace::new([selected, unselected]); + workspace.set_node_selected(selected, true); + workspace.set_all_timing = DraftVoteTiming::Scheduled { + days: 1, + hours: 2, + minutes: 3, + }; + + workspace.apply_timing_to_selected(); + + assert!(matches!( + workspace.node_timing[&selected], + DraftVoteTiming::Scheduled { .. } + )); + assert_eq!(workspace.node_timing[&unselected], DraftVoteTiming::Now); } /// VOTE-TC-071: Enter advances drafts but submits only from Review; Escape closes drafts. diff --git a/tests/kittest/masternode_tab.rs b/tests/kittest/masternode_tab.rs index e28fc3275..d6a9caad0 100644 --- a/tests/kittest/masternode_tab.rs +++ b/tests/kittest/masternode_tab.rs @@ -288,6 +288,33 @@ fn voting_navigation_routes_to_shared_workspaces() { .query_by_label("Step 1 of 3: Nodes and timing") .is_some() ); + harness.get_by_label("Next: Choose votes").click(); + harness.run_steps(2); + assert!( + harness + .query_by_label("Step 1 of 3: Nodes and timing") + .is_some(), + "an unfiltered bulk draft must not select every node by default" + ); + }); +} + +/// VOTE-TC-023/075: the retired DPNS scheduled surface redirects to the shared +/// Masternodes scheduled view instead of exposing a second submit path. +#[test] +fn legacy_dpns_scheduled_route_opens_masternodes_scheduled_view() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenDPNSScheduledVotes); + harness.run_steps(5); + + assert_eq!( + harness.state().selected_main_screen, + RootScreenType::RootScreenMasternodes + ); + assert!(harness.query_by_label("Scheduled votes").is_some()); }); } From 0c0eed07289d8145b912382592be4defdf95eba0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:24:49 +0000 Subject: [PATCH 06/39] fix(dpns): make scheduled operations authoritative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render and manage scheduled votes from the durable operation journal, retain exact operation correlation for recovery, prevent implicit schedule replacement, report complete mixed outcomes, and preserve explicit unavailable states and target-identifying confirmations. Co-Authored-By: Codex GPT-5 πŸ€– Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- src/app.rs | 203 ++++++++++-------- src/backend_task/contested_names/mod.rs | 55 ++++- src/backend_task/mod.rs | 1 + src/context/dpns_vote_operations.rs | 120 +++++++++-- src/model/contested_name.rs | 10 + src/ui/dpns/dpns_contested_names_screen.rs | 2 +- src/ui/masternodes/card.rs | 22 +- src/ui/masternodes/list_screen.rs | 228 +++++++++++++++------ src/ui/masternodes/voting_center.rs | 153 ++++++++++++-- 9 files changed, 590 insertions(+), 204 deletions(-) diff --git a/src/app.rs b/src/app.rs index 861f76bde..67796b6a3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,7 +16,7 @@ use crate::context::connection_status::{ConnectionStatus, OverallConnectionState use crate::context::feature_gate::FeatureGate; use crate::context::migration_status::{MigrationState, MigrationStep}; use crate::database::Database; -use crate::model::dpns_voting::DpnsVoteTargetStatus; +use crate::model::dpns_voting::{DpnsVoteOperation, DpnsVoteTargetStatus}; use crate::model::settings::AppSettings; use crate::ui::components::passphrase_modal; use crate::ui::components::secret_prompt_host::{ActivePrompt, EguiSecretPromptHost, QueuedPrompt}; @@ -131,6 +131,57 @@ fn clear_confirmed_vote_recovery_cutoff( } } +#[derive(Debug, Default, PartialEq, Eq)] +struct DpnsVoteFeedbackCounts { + confirmed: usize, + scheduled: usize, + unconfirmed: usize, + rejected: usize, + failed_before_submission: usize, + not_applied: usize, + in_progress: usize, +} + +fn dpns_vote_feedback(operation: &DpnsVoteOperation) -> (String, MessageType, bool) { + let mut counts = DpnsVoteFeedbackCounts::default(); + for outcome in &operation.targets { + match outcome.status { + DpnsVoteTargetStatus::Confirmed => counts.confirmed += 1, + DpnsVoteTargetStatus::Scheduled => counts.scheduled += 1, + DpnsVoteTargetStatus::Unconfirmed => counts.unconfirmed += 1, + DpnsVoteTargetStatus::Rejected => counts.rejected += 1, + DpnsVoteTargetStatus::FailedBeforeSubmission => { + counts.failed_before_submission += 1; + } + DpnsVoteTargetStatus::NotApplied => counts.not_applied += 1, + DpnsVoteTargetStatus::Queued + | DpnsVoteTargetStatus::Submitting + | DpnsVoteTargetStatus::Confirming => counts.in_progress += 1, + } + } + let message = format!( + "Voting results: {} confirmed, {} scheduled, {} unconfirmed, {} rejected, {} failed before submission, {} not applied, and {} still in progress. Open Voting activity to review each target.", + counts.confirmed, + counts.scheduled, + counts.unconfirmed, + counts.rejected, + counts.failed_before_submission, + counts.not_applied, + counts.in_progress, + ); + let needs_attention = + counts.unconfirmed + counts.rejected + counts.failed_before_submission + counts.not_applied + > 0; + let message_type = if needs_attention { + MessageType::Warning + } else if counts.in_progress > 0 { + MessageType::Info + } else { + MessageType::Success + }; + (message, message_type, counts.unconfirmed > 0) +} + /// Action id for the SPV-sync block's "Continue in the background" escape button. /// SPV sync is **unbounded** β€” with no peers it stays Connecting/Syncing forever /// with no terminal signal β€” so a button-less hard block would trap the user @@ -2037,93 +2088,17 @@ impl App for AppState { BackendTaskSuccessResult::DpnsVoteOperationUpdated(operation_id) => { match active_context.dpns_vote_operation(operation_id) { Ok(Some(operation)) => { - let total = operation.targets.len(); - let confirmed = operation - .targets - .iter() - .filter(|outcome| { - outcome.status == DpnsVoteTargetStatus::Confirmed - }) - .count(); - let scheduled = operation - .targets - .iter() - .filter(|outcome| { - outcome.status == DpnsVoteTargetStatus::Scheduled - }) - .count(); - let unconfirmed = operation - .targets - .iter() - .filter(|outcome| { - outcome.status == DpnsVoteTargetStatus::Unconfirmed - }) - .count(); - let rejected = operation - .targets - .iter() - .filter(|outcome| { - matches!( - outcome.status, - DpnsVoteTargetStatus::Rejected - | DpnsVoteTargetStatus::FailedBeforeSubmission - ) - }) - .count(); let diagnostics = active_context .dpns_vote_operation_diagnostics(operation_id); - if unconfirmed > 0 { - let handle = MessageBanner::set_global( - ctx, - "The vote was submitted, but DET could not confirm the result yet. DET will keep checking. Do not submit it again.", - MessageType::Warning, - ); - if !diagnostics.is_empty() { - handle.with_details(&diagnostics); - } - handle.disable_auto_dismiss(); - } else if rejected > 0 { - let handle = MessageBanner::set_global( - ctx, - format!( - "{confirmed} of {total} votes were confirmed. Review the remaining {}.", - total.saturating_sub(confirmed) - ), - MessageType::Warning, - ); - if !diagnostics.is_empty() { - handle.with_details(&diagnostics); - } + let (message, message_type, keep_visible) = + dpns_vote_feedback(&operation); + let handle = + MessageBanner::set_global(ctx, message, message_type); + if !diagnostics.is_empty() { + handle.with_details(&diagnostics); + } + if keep_visible { handle.disable_auto_dismiss(); - } else if scheduled == total && total > 0 { - MessageBanner::set_global( - ctx, - format!("{scheduled} votes were scheduled."), - MessageType::Success, - ); - } else if confirmed + scheduled == total - && confirmed > 0 - && scheduled > 0 - { - MessageBanner::set_global( - ctx, - format!( - "{confirmed} votes were cast and {scheduled} votes were scheduled." - ), - MessageType::Success, - ); - } else if confirmed == total && total == 1 { - MessageBanner::set_global( - ctx, - "Vote cast successfully.", - MessageType::Success, - ); - } else if confirmed == total && total > 1 { - MessageBanner::set_global( - ctx, - format!("{confirmed} votes were cast successfully."), - MessageType::Success, - ); } } Ok(None) => { @@ -2546,6 +2521,64 @@ impl App for AppState { #[cfg(test)] mod migration_banner_tests { use super::*; + use crate::model::dpns_voting::{DpnsVoteTarget, DpnsVoteTargetKey, VoteTiming}; + use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + + fn feedback_operation(statuses: &[DpnsVoteTargetStatus]) -> DpnsVoteOperation { + let mut operation = DpnsVoteOperation::new( + statuses + .iter() + .enumerate() + .map(|(index, _)| DpnsVoteTarget { + key: DpnsVoteTargetKey { + network: Network::Testnet, + voter_id: Identifier::from([1; 32]), + vote_poll_id: Identifier::from([index as u8; 32]), + }, + voter_alias: Some("Eve".to_owned()), + contested_name: format!("contest-{index}"), + requested_choice: ResourceVoteChoice::Lock, + current_choice: None, + timing: VoteTiming::Now, + }) + .collect(), + ); + for (outcome, status) in operation.targets.iter_mut().zip(statuses) { + outcome.status = *status; + } + operation + } + + #[test] + fn mixed_vote_feedback_counts_every_terminal_category_and_guides_to_details() { + let operation = feedback_operation(&[ + DpnsVoteTargetStatus::Confirmed, + DpnsVoteTargetStatus::Scheduled, + DpnsVoteTargetStatus::Unconfirmed, + DpnsVoteTargetStatus::Rejected, + DpnsVoteTargetStatus::FailedBeforeSubmission, + DpnsVoteTargetStatus::NotApplied, + ]); + + let (message, message_type, keep_visible) = dpns_vote_feedback(&operation); + + assert_eq!(message_type, MessageType::Warning); + assert!(keep_visible); + for phrase in [ + "1 confirmed", + "1 scheduled", + "1 unconfirmed", + "1 rejected", + "1 failed before submission", + "1 not applied", + "Open Voting activity", + ] { + assert!( + message.contains(phrase), + "missing `{phrase}` in `{message}`" + ); + } + } /// A frame owns one migration snapshot even if the task publishes mid-frame. #[test] diff --git a/src/backend_task/contested_names/mod.rs b/src/backend_task/contested_names/mod.rs index 570a8d027..4182bebc5 100644 --- a/src/backend_task/contested_names/mod.rs +++ b/src/backend_task/contested_names/mod.rs @@ -34,7 +34,11 @@ const SCHEDULED_VOTE_MAX_LATENESS_MS: u64 = 120_000; #[derive(Debug, Clone, PartialEq)] pub enum ContestedResourceTask { QueryDPNSContests, - SubmitDpnsVoteOperation(DpnsVoteOperation, Vec), + SubmitDpnsVoteOperation( + DpnsVoteOperation, + Vec, + Option, + ), ReconcileDpnsVoteOperation(DpnsVoteOperationId), CastScheduledVote(ScheduledDPNSVote, Box), /// Sweep the scheduled-vote table and cast every vote that is now due. @@ -46,6 +50,11 @@ pub enum ContestedResourceTask { ClearAllScheduledVotes, ClearExecutedScheduledVotes, DeleteScheduledVote(Identifier, String), + CancelScheduledDpnsVote { + operation_id: DpnsVoteOperationId, + key: DpnsVoteTargetKey, + contested_name: String, + }, } #[derive(Debug, Clone, PartialEq)] @@ -131,8 +140,12 @@ impl AppContext { .query_dpns_contested_resources(sdk, sender) .await .map(|_| BackendTaskSuccessResult::None), - ContestedResourceTask::SubmitDpnsVoteOperation(operation, voters) => { - self.execute_dpns_vote_operation(operation, voters, sdk) + ContestedResourceTask::SubmitDpnsVoteOperation( + operation, + voters, + replacing_scheduled_key, + ) => { + self.execute_dpns_vote_operation(operation, voters, replacing_scheduled_key, sdk) .await } ContestedResourceTask::ReconcileDpnsVoteOperation(operation_id) => { @@ -140,7 +153,7 @@ impl AppContext { } ContestedResourceTask::CastScheduledVote(scheduled_vote, voter) => { let operation = self.operation_for_scheduled_vote(&scheduled_vote, &voter)?; - self.execute_dpns_vote_operation(operation, vec![*voter], sdk) + self.execute_dpns_vote_operation(operation, vec![*voter], None, sdk) .await } ContestedResourceTask::CastDueScheduledVotes { @@ -153,8 +166,13 @@ impl AppContext { source: Box::new(source), }), ContestedResourceTask::ClearAllScheduledVotes => { - self.clear_all_scheduled_votes()?; self.cancel_all_scheduled_dpns_vote_targets()?; + if let Err(error) = self.clear_all_scheduled_votes() { + tracing::warn!( + ?error, + "Scheduled DPNS votes were cancelled but the legacy mirror could not be cleared" + ); + } Ok(BackendTaskSuccessResult::Refresh) } ContestedResourceTask::ClearExecutedScheduledVotes => { @@ -163,7 +181,25 @@ impl AppContext { } ContestedResourceTask::DeleteScheduledVote(voter_id, contested_name) => { self.delete_scheduled_vote(voter_id.as_slice(), &contested_name)?; - self.cancel_scheduled_dpns_vote_target(voter_id, &contested_name)?; + Ok(BackendTaskSuccessResult::Refresh) + } + ContestedResourceTask::CancelScheduledDpnsVote { + operation_id, + key, + contested_name, + } => { + self.cancel_scheduled_dpns_vote_target(operation_id, &key)?; + if let Err(error) = + self.delete_scheduled_vote(key.voter_id.as_slice(), &contested_name) + { + tracing::warn!( + ?error, + operation_id = %operation_id, + voter_id = %key.voter_id, + contested_name, + "Scheduled DPNS vote was cancelled but the legacy mirror could not be cleared" + ); + } Ok(BackendTaskSuccessResult::Refresh) } } @@ -188,7 +224,7 @@ impl AppContext { if !queued.is_empty() { let voters = self.load_local_voting_identities()?; for operation in queued { - self.execute_dpns_vote_operation(operation, voters.clone(), sdk) + self.execute_dpns_vote_operation(operation, voters.clone(), None, sdk) .await?; } } @@ -294,6 +330,7 @@ impl AppContext { self: &Arc, mut operation: DpnsVoteOperation, voters: Vec, + replacing_scheduled_key: Option, sdk: &Sdk, ) -> Result { if operation.targets.is_empty() { @@ -363,7 +400,7 @@ impl AppContext { VoteTiming::Now => None, }) .collect::>(); - self.insert_dpns_vote_operation(&operation)?; + self.insert_dpns_vote_operation(&operation, replacing_scheduled_key.as_ref())?; if !scheduled_votes.is_empty() && let Err(error) = self.insert_scheduled_votes(&scheduled_votes) { @@ -697,7 +734,7 @@ impl AppContext { let operation_id = operation.id; async move { let result = app_context - .execute_dpns_vote_operation(operation, voters, &sdk) + .execute_dpns_vote_operation(operation, voters, None, &sdk) .await .map(|_| ()); (operation_id, result) diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 1d0afc457..c84c59cf7 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -331,6 +331,7 @@ impl From<&BackendTask> for BackendTaskContext { BackendTask::ContestedResourceTask(ContestedResourceTask::SubmitDpnsVoteOperation( operation, _, + _, )) => Self::DpnsVoteOperation(operation.id), BackendTask::ContestedResourceTask( ContestedResourceTask::ReconcileDpnsVoteOperation(operation_id), diff --git a/src/context/dpns_vote_operations.rs b/src/context/dpns_vote_operations.rs index a729267cd..1eb1e2653 100644 --- a/src/context/dpns_vote_operations.rs +++ b/src/context/dpns_vote_operations.rs @@ -8,7 +8,6 @@ use crate::model::dpns_voting::{ }; use crate::wallet_backend::{DetKv, DetScope, KvAdapterError}; use dash_sdk::dpp::dashcore::Network; -use dash_sdk::platform::Identifier; use std::sync::Arc; const LEGACY_OPERATION_INDEX_KEY: &str = "det:dpns_vote_operations:v1"; @@ -177,11 +176,27 @@ fn write_existing_operation( .map_err(operation_err) } +fn is_authorized_scheduled_replacement( + existing_status: DpnsVoteTargetStatus, + existing_key: &DpnsVoteTargetKey, + operation: &DpnsVoteOperation, + replacing_scheduled_key: Option<&DpnsVoteTargetKey>, +) -> bool { + existing_status == DpnsVoteTargetStatus::Scheduled + && replacing_scheduled_key == Some(existing_key) + && operation.targets.iter().any(|outcome| { + outcome.target.key == *existing_key + && outcome.status == DpnsVoteTargetStatus::Scheduled + && matches!(outcome.target.timing, VoteTiming::Scheduled(_)) + }) +} + impl AppContext { /// Persist a reviewed operation and atomically acquire all unresolved locks. pub fn insert_dpns_vote_operation( &self, operation: &DpnsVoteOperation, + replacing_scheduled_key: Option<&DpnsVoteTargetKey>, ) -> Result<(), TaskError> { if operation .targets @@ -195,19 +210,46 @@ impl AppContext { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let kv = self.det_kv()?; + let replacement_is_valid = replacing_scheduled_key.is_none_or(|key| { + operation.targets.iter().any(|outcome| { + outcome.target.key == *key + && outcome.status == DpnsVoteTargetStatus::Scheduled + && matches!(outcome.target.timing, VoteTiming::Scheduled(_)) + }) + }); + if !replacement_is_valid { + return Err(TaskError::DpnsVoteTargetBusy); + } + let mut replaced = Vec::new(); + let mut replacement_found = false; for mut existing in load_operations(&kv, self.network)? { let original = existing.clone(); let mut changed = false; for existing_outcome in &mut existing.targets { - if existing_outcome.status == DpnsVoteTargetStatus::Scheduled + let conflicts = existing_outcome.status.holds_lock() && operation.targets.iter().any(|new_outcome| { - new_outcome.status == DpnsVoteTargetStatus::Scheduled + new_outcome.status.holds_lock() && new_outcome.target.key == existing_outcome.target.key - }) - { + }); + if !conflicts { + continue; + } + let explicitly_replaced = is_authorized_scheduled_replacement( + existing_outcome.status, + &existing_outcome.target.key, + operation, + replacing_scheduled_key, + ); + if explicitly_replaced { changed = true; + replacement_found = true; existing_outcome.status = DpnsVoteTargetStatus::NotApplied; + } else { + for previous in replaced { + write_existing_operation(&kv, self.network, &previous)?; + } + return Err(TaskError::DpnsVoteTargetBusy); } } if changed { @@ -220,6 +262,12 @@ impl AppContext { replaced.push(original); } } + if replacing_scheduled_key.is_some() && !replacement_found { + for original in replaced { + write_existing_operation(&kv, self.network, &original)?; + } + return Err(TaskError::DpnsVoteTargetBusy); + } if let Err(error) = persist_operation(&kv, self.network, operation) { for original in replaced { write_existing_operation(&kv, self.network, &original)?; @@ -480,24 +528,22 @@ impl AppContext { /// Release a not-yet-submitting scheduled target after explicit cancellation. pub(crate) fn cancel_scheduled_dpns_vote_target( &self, - voter_id: Identifier, - contested_name: &str, + operation_id: DpnsVoteOperationId, + key: &DpnsVoteTargetKey, ) -> Result<(), TaskError> { - let operations = self.dpns_vote_operations()?; - for mut operation in operations { - let mut changed = false; - for outcome in &mut operation.targets { - if outcome.target.key.voter_id == voter_id - && outcome.target.contested_name == contested_name - && outcome.status == DpnsVoteTargetStatus::Scheduled - { - outcome.status = DpnsVoteTargetStatus::NotApplied; - changed = true; - } - } - if changed { - self.update_dpns_vote_operation(&operation)?; - } + let Some(mut operation) = self.dpns_vote_operation(operation_id)? else { + return Ok(()); + }; + let Some(outcome) = operation + .targets + .iter_mut() + .find(|outcome| outcome.target.key == *key) + else { + return Ok(()); + }; + if outcome.status == DpnsVoteTargetStatus::Scheduled { + outcome.status = DpnsVoteTargetStatus::NotApplied; + self.update_dpns_vote_operation(&operation)?; } Ok(()) } @@ -602,6 +648,36 @@ mod tests { assert_eq!(load_operations(&kv, Network::Testnet).unwrap().len(), 2); } + #[test] + fn scheduled_replacement_requires_the_exact_edit_key() { + let mut replacement = operation(DpnsVoteTargetStatus::Scheduled); + replacement.targets[0].target.timing = VoteTiming::Scheduled(42); + let key = replacement.targets[0].target.key.clone(); + let other = DpnsVoteTargetKey { + vote_poll_id: Identifier::from([9; 32]), + ..key.clone() + }; + + assert!(is_authorized_scheduled_replacement( + DpnsVoteTargetStatus::Scheduled, + &key, + &replacement, + Some(&key), + )); + assert!(!is_authorized_scheduled_replacement( + DpnsVoteTargetStatus::Scheduled, + &key, + &replacement, + None, + )); + assert!(!is_authorized_scheduled_replacement( + DpnsVoteTargetStatus::Scheduled, + &key, + &replacement, + Some(&other), + )); + } + /// A corrupt indexed row must block lock reconstruction rather than being skipped. #[test] fn unreadable_indexed_operation_fails_closed() { diff --git a/src/model/contested_name.rs b/src/model/contested_name.rs index 0b6fb4634..64ddd24ed 100644 --- a/src/model/contested_name.rs +++ b/src/model/contested_name.rs @@ -68,6 +68,16 @@ pub struct MasternodeContestSummary { pub has_scheduled_vote: bool, } +impl MasternodeContestSummary { + /// Represent a failed summary read without implying that no contests exist. + pub fn unavailable() -> Self { + Self { + vote_state: MasternodeVoteStateSummary::Unavailable, + ..Self::default() + } + } +} + #[derive(Debug, Encode, Decode, Clone)] pub struct Contestant { pub id: Identifier, diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index 07b606e8c..70b90a618 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -1769,7 +1769,7 @@ impl DPNSScreen { VoteHandlingStatus::SchedulingVotes }; AppAction::BackendTask(BackendTask::ContestedResourceTask( - ContestedResourceTask::SubmitDpnsVoteOperation(operation, selected_voters), + ContestedResourceTask::SubmitDpnsVoteOperation(operation, selected_voters, None), )) } diff --git a/src/ui/masternodes/card.rs b/src/ui/masternodes/card.rs index 0147f330b..6c232ea11 100644 --- a/src/ui/masternodes/card.rs +++ b/src/ui/masternodes/card.rs @@ -58,15 +58,19 @@ pub fn voter_readiness_label(voting_present: bool) -> &'static str { /// DPNS status line with count-first precedence (Β§10.1): open contests first /// (actionable), then a pending scheduled vote, then none. pub fn dpns_status_line(summary: MasternodeContestSummary) -> String { - if summary.open_contest_count > 0 { - if summary.vote_state == MasternodeVoteStateSummary::Checking { + if summary.vote_state == MasternodeVoteStateSummary::Unavailable { + if summary.open_contest_count == 0 { + "DPNS voting status unavailable".to_owned() + } else { format!( - "Checking votes for {} active contests", + "Vote state unavailable for {} active contests", summary.open_contest_count ) - } else if summary.vote_state == MasternodeVoteStateSummary::Unavailable { + } + } else if summary.open_contest_count > 0 { + if summary.vote_state == MasternodeVoteStateSummary::Checking { format!( - "Vote state unavailable for {} active contests", + "Checking votes for {} active contests", summary.open_contest_count ) } else if summary.needs_vote_count == 0 { @@ -462,6 +466,14 @@ mod tests { ); } + #[test] + fn dpns_status_reports_unavailable_when_the_summary_read_failed() { + assert_eq!( + dpns_status_line(MasternodeContestSummary::unavailable()), + "DPNS voting status unavailable" + ); + } + #[test] fn tc_fr3_04_05_badge_label_by_type() { let card = MasternodeCard::new( diff --git a/src/ui/masternodes/list_screen.rs b/src/ui/masternodes/list_screen.rs index 9fe61e634..6985c9ec3 100644 --- a/src/ui/masternodes/list_screen.rs +++ b/src/ui/masternodes/list_screen.rs @@ -15,12 +15,14 @@ use eframe::egui::{self, RichText}; use crate::app::{AppAction, BackendTasksExecutionMode}; use crate::backend_task::BackendTask; -use crate::backend_task::contested_names::{ContestedResourceTask, ScheduledDPNSVote}; +use crate::backend_task::contested_names::ContestedResourceTask; use crate::backend_task::identity::IdentityTask; use crate::context::identity_load_registry::{IdentityLoadPhase, IdentityLoadToken}; use crate::context::{AppContext, DpnsOperatorRoute}; use crate::model::contested_name::MasternodeContestSummary; -use crate::model::dpns_voting::DpnsVoteTargetStatus; +use crate::model::dpns_voting::{ + DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteOutcome, DpnsVoteTargetStatus, VoteTiming, +}; use crate::model::masternode_input::decode_identity_id; use crate::model::qualified_identity::{IdentityStatus, IdentityType, MasternodeKeyPresence}; use crate::model::user_role::UserRole; @@ -82,6 +84,12 @@ struct PendingLoad { token: IdentityLoadToken, } +#[derive(Clone)] +struct ScheduledJournalTarget { + operation_id: DpnsVoteOperationId, + outcome: DpnsVoteOutcome, +} + /// Root screen for the Masternodes section. pub struct MasternodesScreen { pub app_context: Arc, @@ -110,7 +118,7 @@ pub struct MasternodesScreen { /// [`TaskError::IdentityLoadInProgress`](crate::backend_task::error::TaskError::IdentityLoadInProgress) /// instead of racing. pending_load: Option, - pending_schedule_cancellation: Option<(ScheduledDPNSVote, ConfirmationDialog)>, + pending_schedule_cancellation: Option<(ScheduledJournalTarget, ConfirmationDialog)>, } #[cfg(test)] @@ -162,7 +170,7 @@ impl MasternodesScreen { let contest_summary = self .app_context .masternode_contest_summary(voter_id) - .unwrap_or_default(); + .unwrap_or_else(|_| MasternodeContestSummary::unavailable()); NodeCardData { node_id, node_id_short, @@ -564,52 +572,51 @@ impl MasternodesScreen { ui.label( "Upcoming and unresolved targets use the same operation locks as immediate votes.", ); - let votes = self.app_context.get_scheduled_votes().unwrap_or_default(); - if votes.is_empty() { + let scheduled_targets = match self.app_context.dpns_vote_operations() { + Ok(operations) => scheduled_journal_targets(operations), + Err(error) => { + ui.label("Scheduled votes are unavailable. Refresh this page to try again."); + tracing::warn!(?error, "Could not load journaled DPNS schedules"); + return action; + } + }; + if scheduled_targets.is_empty() { ui.label("No scheduled votes."); return action; } - for vote in votes { - let status = self - .app_context - .dpns_vote_poll_id(&vote.contested_name) - .ok() - .and_then(|vote_poll_id| { - self.app_context - .dpns_vote_target_status(&crate::model::dpns_voting::DpnsVoteTargetKey { - network: self.app_context.network(), - voter_id: vote.voter_id, - vote_poll_id, - }) - .ok() - .flatten() - }); + for scheduled in scheduled_targets { + let target = &scheduled.outcome.target; + let status = scheduled.outcome.status; + let voter = target + .voter_alias + .clone() + .unwrap_or_else(|| shorten_id(&target.key.voter_id.to_string(Encoding::Base58))); + let VoteTiming::Scheduled(scheduled_at) = target.timing else { + continue; + }; ui.group(|ui| { ui.label( - RichText::new(format!( - "{}.dash / {}", - vote.contested_name, - vote.voter_id.to_string(Encoding::Base58) - )) - .strong(), + RichText::new(format!("{}.dash / {}", target.contested_name, voter)).strong(), ); ui.label(format!( "Choice: {}", - vote_choice_summary(Some(vote.choice)) + vote_choice_summary(Some(target.requested_choice)) )); - ui.label(format_scheduled_time(vote.unix_timestamp)); + ui.label(format_scheduled_time(scheduled_at)); ui.label(match status { - Some(DpnsVoteTargetStatus::Unconfirmed) => "Status: Checking result", - Some( - DpnsVoteTargetStatus::Queued - | DpnsVoteTargetStatus::Submitting - | DpnsVoteTargetStatus::Confirming, - ) => "Status: Submitting", - Some(DpnsVoteTargetStatus::Scheduled) => "Status: Scheduled", - _ if vote.executed_successfully => "Status: Completed", - _ => "Status: Needs attention", + DpnsVoteTargetStatus::Unconfirmed => "Status: Checking result", + DpnsVoteTargetStatus::Queued + | DpnsVoteTargetStatus::Submitting + | DpnsVoteTargetStatus::Confirming => "Status: Submitting", + DpnsVoteTargetStatus::Scheduled => "Status: Scheduled", + DpnsVoteTargetStatus::Confirmed => "Status: Completed", + DpnsVoteTargetStatus::Rejected => "Status: Rejected", + DpnsVoteTargetStatus::FailedBeforeSubmission => { + "Status: Failed before submission" + } + DpnsVoteTargetStatus::NotApplied => "Status: Cancelled", }); - let editable = status == Some(DpnsVoteTargetStatus::Scheduled); + let editable = status == DpnsVoteTargetStatus::Scheduled; let disabled_reason = "This scheduled vote cannot be changed after submission has started."; if ui @@ -621,7 +628,7 @@ impl MasternodesScreen { .clicked() { self.view = MasternodesView::Voting(Box::new( - DpnsVotingCenter::for_scheduled_edit(&self.app_context, &vote), + DpnsVotingCenter::for_scheduled_edit(&self.app_context, &scheduled.outcome), )); } if ui @@ -632,46 +639,35 @@ impl MasternodesScreen { .disabled_tooltip(disabled_reason) .clicked() { - let message = format!( - "Cancel the scheduled vote for {}.dash? This removes it before submission.", - vote.contested_name - ); + let message = scheduled_cancel_confirmation(&scheduled.outcome); self.pending_schedule_cancellation = Some(( - vote.clone(), + scheduled.clone(), ConfirmationDialog::new("Cancel scheduled vote", message) .danger_mode(true) .confirm_text(Some("Cancel scheduled vote")), )); } - if status == Some(DpnsVoteTargetStatus::Unconfirmed) + if status == DpnsVoteTargetStatus::Unconfirmed && ComponentStyles::add_secondary_button(ui, "Check again", dark_mode).clicked() - && let Ok(Some(operation)) = - self.app_context.dpns_vote_operations().map(|operations| { - operations.into_iter().find(|operation| { - operation.targets.iter().any(|outcome| { - outcome.target.key.voter_id == vote.voter_id - && outcome.target.contested_name == vote.contested_name - }) - }) - }) { action = AppAction::BackendTask(BackendTask::ContestedResourceTask( - ContestedResourceTask::ReconcileDpnsVoteOperation(operation.id), + ContestedResourceTask::ReconcileDpnsVoteOperation(scheduled.operation_id), )); } }); } - if let Some((vote, dialog)) = self.pending_schedule_cancellation.as_mut() { + if let Some((scheduled, dialog)) = self.pending_schedule_cancellation.as_mut() { let result = dialog.show(ui).inner.dialog_response; if let Some(result) = result { - let vote = vote.clone(); + let scheduled = scheduled.clone(); self.pending_schedule_cancellation = None; if result == ConfirmationStatus::Confirmed { action = AppAction::BackendTask(BackendTask::ContestedResourceTask( - ContestedResourceTask::DeleteScheduledVote( - vote.voter_id, - vote.contested_name, - ), + ContestedResourceTask::CancelScheduledDpnsVote { + operation_id: scheduled.operation_id, + key: scheduled.outcome.target.key, + contested_name: scheduled.outcome.target.contested_name, + }, )); } } @@ -797,6 +793,47 @@ impl MasternodesScreen { } } +fn scheduled_journal_targets(operations: Vec) -> Vec { + let mut targets = operations + .into_iter() + .flat_map(|operation| { + operation.targets.into_iter().filter_map(move |outcome| { + matches!(outcome.target.timing, VoteTiming::Scheduled(_)).then_some( + ScheduledJournalTarget { + operation_id: operation.id, + outcome, + }, + ) + }) + }) + .collect::>(); + targets.sort_by_key(|scheduled| match scheduled.outcome.target.timing { + VoteTiming::Scheduled(timestamp) => timestamp, + VoteTiming::Now => u64::MAX, + }); + targets +} + +fn scheduled_cancel_confirmation(outcome: &DpnsVoteOutcome) -> String { + let target = &outcome.target; + let voter = target + .voter_alias + .clone() + .unwrap_or_else(|| shorten_id(&target.key.voter_id.to_string(Encoding::Base58))); + let time = match target.timing { + VoteTiming::Scheduled(timestamp) => format_scheduled_time(timestamp) + .strip_prefix("Scheduled time: ") + .unwrap_or("Unavailable") + .to_owned(), + VoteTiming::Now => "Immediately".to_owned(), + }; + format!( + "Cancel {voter}'s scheduled vote for {}.dash? Choice: {}. Scheduled time: {time}.", + target.contested_name, + vote_choice_summary(Some(target.requested_choice)), + ) +} + fn vote_choice_summary(choice: Option) -> String { match choice { None => "Not voted".to_owned(), @@ -925,6 +962,7 @@ mod tests { use crate::backend_task::identity::IdentityInputToLoad; use crate::context::connection_status::ConnectionStatus; use crate::database::test_helpers::create_database_at_path; + use crate::model::dpns_voting::DpnsVoteTargetKey; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::qualified_identity::encrypted_key_storage::KeyStorage; use crate::utils::egui_mpsc::SenderAsync; @@ -935,6 +973,72 @@ mod tests { use dash_sdk::platform::Identifier; use std::collections::BTreeMap; + fn scheduled_operation( + voter: u8, + poll: u8, + status: DpnsVoteTargetStatus, + alias: Option<&str>, + ) -> DpnsVoteOperation { + let mut operation = + DpnsVoteOperation::new(vec![crate::model::dpns_voting::DpnsVoteTarget { + key: DpnsVoteTargetKey { + network: Network::Testnet, + voter_id: Identifier::from([voter; 32]), + vote_poll_id: Identifier::from([poll; 32]), + }, + voter_alias: alias.map(str::to_owned), + contested_name: "dominguez".to_owned(), + requested_choice: ResourceVoteChoice::Lock, + current_choice: None, + timing: VoteTiming::Scheduled(1_700_000_000_000), + }]); + operation.targets[0].status = status; + operation + } + + #[test] + fn scheduled_view_items_keep_the_exact_journal_operation_id() { + let historical = + scheduled_operation(1, 2, DpnsVoteTargetStatus::Confirmed, Some("Old Eve")); + let unresolved = scheduled_operation(1, 2, DpnsVoteTargetStatus::Unconfirmed, Some("Eve")); + + let items = scheduled_journal_targets(vec![historical.clone(), unresolved.clone()]); + + assert_eq!(items.len(), 2); + assert_eq!(items[0].operation_id, historical.id); + assert_eq!(items[1].operation_id, unresolved.id); + assert_eq!(items[1].outcome.status, DpnsVoteTargetStatus::Unconfirmed); + } + + #[test] + fn scheduled_cancel_confirmation_identifies_the_complete_target() { + let operation = scheduled_operation(1, 2, DpnsVoteTargetStatus::Scheduled, Some("Eve")); + + let message = scheduled_cancel_confirmation(&operation.targets[0]); + + for phrase in ["Eve", "dominguez.dash", "Lock", "UTC"] { + assert!( + message.contains(phrase), + "missing `{phrase}` in `{message}`" + ); + } + } + + #[test] + fn scheduled_cancel_confirmation_uses_a_short_id_without_an_alias() { + let operation = scheduled_operation(1, 2, DpnsVoteTargetStatus::Scheduled, None); + let full_id = operation.targets[0] + .target + .key + .voter_id + .to_string(Encoding::Base58); + + let message = scheduled_cancel_confirmation(&operation.targets[0]); + + assert!(!message.contains(&full_id)); + assert!(message.contains('…')); + } + /// Build an offline, wallet-backend-wired `AppContext` (no network I/O). async fn offline_ctx() -> (Arc, tempfile::TempDir) { let temp_dir = tempfile::tempdir().expect("tempdir"); diff --git a/src/ui/masternodes/voting_center.rs b/src/ui/masternodes/voting_center.rs index b483462ee..12074c366 100644 --- a/src/ui/masternodes/voting_center.rs +++ b/src/ui/masternodes/voting_center.rs @@ -13,11 +13,11 @@ use eframe::egui::{self, ComboBox, RichText}; use crate::app::AppAction; use crate::backend_task::BackendTask; -use crate::backend_task::contested_names::{ContestedResourceTask, ScheduledDPNSVote}; +use crate::backend_task::contested_names::ContestedResourceTask; use crate::context::AppContext; use crate::model::contested_name::ContestedName; use crate::model::dpns_voting::{ - DpnsCurrentVoteState, DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteTarget, + DpnsCurrentVoteState, DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteOutcome, DpnsVoteTarget, DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, }; use crate::model::qualified_identity::PrivateKeyTarget; @@ -41,6 +41,7 @@ pub struct DpnsVotingCenter { submitted_operation: Option, vote_state_refresh_dispatched: bool, editing_scheduled_key: Option, + editing_scheduled_original: Option, focus_step_heading: bool, } @@ -92,32 +93,31 @@ impl DpnsVotingCenter { submitted_operation: None, vote_state_refresh_dispatched: false, editing_scheduled_key: None, + editing_scheduled_original: None, focus_step_heading: true, } } - pub fn for_scheduled_edit(app_context: &Arc, vote: &ScheduledDPNSVote) -> Self { + pub fn for_scheduled_edit(app_context: &Arc, outcome: &DpnsVoteOutcome) -> Self { + let vote = &outcome.target; let mut center = Self::new( app_context, - Some(vote.voter_id), + Some(vote.key.voter_id), vec![vote.contested_name.clone()], ); center .workspace .contest_choices - .insert(vote.contested_name.clone(), vote.choice); - center.workspace.node_timing.insert( - vote.voter_id, - scheduled_offset_from_now(vote.unix_timestamp), - ); - center.editing_scheduled_key = app_context - .dpns_vote_poll_id(&vote.contested_name) - .ok() - .map(|vote_poll_id| DpnsVoteTargetKey { - network: app_context.network(), - voter_id: vote.voter_id, - vote_poll_id, - }); + .insert(vote.contested_name.clone(), vote.requested_choice); + let VoteTiming::Scheduled(timestamp) = vote.timing else { + return center; + }; + center + .workspace + .node_timing + .insert(vote.key.voter_id, scheduled_offset_from_now(timestamp)); + center.editing_scheduled_key = Some(vote.key.clone()); + center.editing_scheduled_original = Some(vote.clone()); center } @@ -410,7 +410,17 @@ impl DpnsVotingCenter { Some(outcome.target.requested_choice) ) )); - ui.label(format_timing(outcome.target.timing)); + if self.editing_scheduled_key.as_ref() == Some(&outcome.target.key) { + if let Some(original) = &self.editing_scheduled_original { + ui.label(scheduled_replacement_summary( + original, + &outcome.target, + |name, choice| self.choice_label(name, choice), + )); + } + } else { + ui.label(format_timing(outcome.target.timing)); + } if outcome.target.current_choice.is_some() { ui.label( RichText::new( @@ -611,6 +621,7 @@ impl DpnsVotingCenter { BackendTask::ContestedResourceTask(ContestedResourceTask::SubmitDpnsVoteOperation( operation, self.selected_voters(), + self.editing_scheduled_key.clone(), )), ))) } @@ -740,8 +751,12 @@ impl DpnsVotingCenter { continue; } }; - let replacing_schedule = existing_status == Some(DpnsVoteTargetStatus::Scheduled) - && matches!(timing, VoteTiming::Scheduled(_)); + let replacing_schedule = is_explicit_schedule_replacement( + self.editing_scheduled_key.as_ref(), + &key, + existing_status, + timing, + ); if existing_status.is_some() && !replacing_schedule { exclusions.push(ReviewExclusion { voter: self.voter_label(voter_id), @@ -980,6 +995,35 @@ fn format_timing(timing: VoteTiming) -> String { } } +fn scheduled_replacement_summary( + original: &DpnsVoteTarget, + replacement: &DpnsVoteTarget, + choice_label: impl Fn(&str, Option) -> String, +) -> String { + format!( + "Replacing scheduled vote: {} at {} β†’ {} at {}.", + choice_label(&original.contested_name, Some(original.requested_choice)), + scheduled_time_label(original.timing), + choice_label( + &replacement.contested_name, + Some(replacement.requested_choice) + ), + scheduled_time_label(replacement.timing), + ) +} + +fn scheduled_time_label(timing: VoteTiming) -> String { + match timing { + VoteTiming::Now => "now".to_owned(), + VoteTiming::Scheduled(timestamp) => match Utc.timestamp_millis_opt(timestamp as i64) { + LocalResult::Single(date_time) => { + format!("{} UTC", date_time.format("%Y-%m-%d %H:%M")) + } + _ => "an unavailable time".to_owned(), + }, + } +} + fn scheduled_offset_from_now(timestamp: u64) -> DraftVoteTiming { let remaining_minutes = timestamp.saturating_sub(Utc::now().timestamp_millis() as u64) / 60_000; DraftVoteTiming::Scheduled { @@ -989,6 +1033,17 @@ fn scheduled_offset_from_now(timestamp: u64) -> DraftVoteTiming { } } +fn is_explicit_schedule_replacement( + editing_key: Option<&DpnsVoteTargetKey>, + target_key: &DpnsVoteTargetKey, + existing_status: Option, + replacement_timing: VoteTiming, +) -> bool { + editing_key == Some(target_key) + && existing_status == Some(DpnsVoteTargetStatus::Scheduled) + && matches!(replacement_timing, VoteTiming::Scheduled(_)) +} + fn has_loaded_voting_key(voter: &QualifiedIdentity) -> bool { voter .private_keys @@ -1098,4 +1153,62 @@ mod tests { assert!(label.starts_with("When: 20")); assert!(label.contains(" UTC (")); } + + #[test] + fn only_the_exact_scheduled_edit_target_can_be_replaced() { + let edited = DpnsVoteTargetKey { + network: dash_sdk::dpp::dashcore::Network::Testnet, + voter_id: Identifier::from([1; 32]), + vote_poll_id: Identifier::from([2; 32]), + }; + let other = DpnsVoteTargetKey { + vote_poll_id: Identifier::from([3; 32]), + ..edited.clone() + }; + + assert!(is_explicit_schedule_replacement( + Some(&edited), + &edited, + Some(DpnsVoteTargetStatus::Scheduled), + VoteTiming::Scheduled(10), + )); + assert!(!is_explicit_schedule_replacement( + None, + &edited, + Some(DpnsVoteTargetStatus::Scheduled), + VoteTiming::Scheduled(10), + )); + assert!(!is_explicit_schedule_replacement( + Some(&edited), + &other, + Some(DpnsVoteTargetStatus::Scheduled), + VoteTiming::Scheduled(10), + )); + } + + #[test] + fn scheduled_edit_review_names_the_old_and_new_schedule() { + let original = DpnsVoteTarget { + key: DpnsVoteTargetKey { + network: dash_sdk::dpp::dashcore::Network::Testnet, + voter_id: Identifier::from([1; 32]), + vote_poll_id: Identifier::from([2; 32]), + }, + voter_alias: Some("Eve".to_owned()), + contested_name: "dominguez".to_owned(), + requested_choice: ResourceVoteChoice::Lock, + current_choice: None, + timing: VoteTiming::Scheduled(1_700_000_000_000), + }; + let mut replacement = original.clone(); + replacement.requested_choice = ResourceVoteChoice::Abstain; + replacement.timing = VoteTiming::Scheduled(1_700_003_600_000); + + let summary = scheduled_replacement_summary(&original, &replacement, |_, choice| { + choice_label(choice, &[]) + }); + + assert!(summary.contains("Lock at ")); + assert!(summary.contains("β†’ Abstain at ")); + } } From 64e0962f955c4932b871c7d60d65f5d5aae40b50 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:07:03 +0000 Subject: [PATCH 07/39] fix(dpns): close voting operation safety gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Durably claim due schedules, preserve ambiguous reconciliation locks, order journal writes before compatibility mirrors, and correlate results and pre-journal failures by network and operation. Co-Authored-By: Codex GPT-5 πŸ€– Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- src/app.rs | 36 +++- src/backend_task/contested_names/mod.rs | 199 ++++++++++++++------- src/backend_task/mod.rs | 39 +++- src/context/dpns_vote_operations.rs | 63 +++++++ src/ui/dpns/dpns_contested_names_screen.rs | 9 +- src/ui/masternodes/list_screen.rs | 20 ++- src/ui/masternodes/voting_center.rs | 69 ++++++- 7 files changed, 350 insertions(+), 85 deletions(-) diff --git a/src/app.rs b/src/app.rs index 67796b6a3..bf5718288 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2085,11 +2085,22 @@ impl App for AppState { MessageType::Success, ); } - BackendTaskSuccessResult::DpnsVoteOperationUpdated(operation_id) => { - match active_context.dpns_vote_operation(operation_id) { - Ok(Some(operation)) => { - let diagnostics = active_context - .dpns_vote_operation_diagnostics(operation_id); + BackendTaskSuccessResult::DpnsVoteOperationUpdated { + network, + operation_id, + } => { + let operation_context = self.network_contexts.get(&network).cloned(); + match operation_context + .as_ref() + .map(|context| context.dpns_vote_operation(operation_id)) + { + Some(Ok(Some(operation))) => { + let diagnostics = operation_context + .as_ref() + .map(|context| { + context.dpns_vote_operation_diagnostics(operation_id) + }) + .unwrap_or_default(); let (message, message_type, keep_visible) = dpns_vote_feedback(&operation); let handle = @@ -2101,22 +2112,31 @@ impl App for AppState { handle.disable_auto_dismiss(); } } - Ok(None) => { + Some(Ok(None)) => { MessageBanner::set_global( ctx, "This node already has that vote. Nothing was submitted.", MessageType::Info, ); } - Err(error) => tracing::warn!( + Some(Err(error)) => tracing::warn!( ?error, + ?network, operation_id = %operation_id, "Could not load DPNS vote operation feedback" ), + None => tracing::warn!( + ?network, + operation_id = %operation_id, + "Could not find the originating network for DPNS vote feedback" + ), } self.visible_screen_mut().display_backend_task_result( &context, - BackendTaskSuccessResult::DpnsVoteOperationUpdated(operation_id), + BackendTaskSuccessResult::DpnsVoteOperationUpdated { + network, + operation_id, + }, ); self.visible_screen_mut().refresh(); } diff --git a/src/backend_task/contested_names/mod.rs b/src/backend_task/contested_names/mod.rs index 4182bebc5..af3615c5a 100644 --- a/src/backend_task/contested_names/mod.rs +++ b/src/backend_task/contested_names/mod.rs @@ -14,6 +14,7 @@ use crate::model::dpns_voting::{ use crate::model::qualified_identity::QualifiedIdentity; use crate::model::request_type::RequestType; use dash_sdk::Sdk; +use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; @@ -38,8 +39,9 @@ pub enum ContestedResourceTask { DpnsVoteOperation, Vec, Option, + Network, ), - ReconcileDpnsVoteOperation(DpnsVoteOperationId), + ReconcileDpnsVoteOperation(DpnsVoteOperationId, Network), CastScheduledVote(ScheduledDPNSVote, Box), /// Sweep the scheduled-vote table and cast every vote that is now due. /// `preserve_eligibility_since_ms` keeps a vote eligible when its normal @@ -92,13 +94,15 @@ fn classify_reconciled_vote( observed: Option, requested: ResourceVoteChoice, ) -> Option { - observed.map(|choice| { - if choice == requested { - DpnsVoteTargetStatus::Confirmed - } else { - DpnsVoteTargetStatus::Rejected - } - }) + (observed == Some(requested)).then_some(DpnsVoteTargetStatus::Confirmed) +} + +fn persist_terminal_then_legacy_mirror( + persist_terminal: impl FnOnce() -> Result<(), TaskError>, + update_legacy_mirror: impl FnOnce() -> Result<(), TaskError>, +) -> Result, TaskError> { + persist_terminal()?; + Ok(update_legacy_mirror().err()) } /// Logs a Drive proof-verification failure raised by a contested-resource query. @@ -144,11 +148,12 @@ impl AppContext { operation, voters, replacing_scheduled_key, + _, ) => { self.execute_dpns_vote_operation(operation, voters, replacing_scheduled_key, sdk) .await } - ContestedResourceTask::ReconcileDpnsVoteOperation(operation_id) => { + ContestedResourceTask::ReconcileDpnsVoteOperation(operation_id, _) => { self.reconcile_dpns_vote_operation(operation_id, sdk).await } ContestedResourceTask::CastScheduledVote(scheduled_vote, voter) => { @@ -334,9 +339,10 @@ impl AppContext { sdk: &Sdk, ) -> Result { if operation.targets.is_empty() { - return Ok(BackendTaskSuccessResult::DpnsVoteOperationUpdated( - operation.id, - )); + return Ok(BackendTaskSuccessResult::DpnsVoteOperationUpdated { + network: self.network, + operation_id: operation.id, + }); } let was_persisted = self.dpns_vote_operation(operation.id)?.is_some(); if operation @@ -419,10 +425,18 @@ impl AppContext { outcome.status == DpnsVoteTargetStatus::Confirmed && matches!(outcome.target.timing, VoteTiming::Scheduled(_)) }) { - self.mark_vote_executed( + if let Err(error) = self.mark_vote_executed( outcome.target.key.voter_id.as_slice(), outcome.target.contested_name.clone(), - )?; + ) { + tracing::warn!( + ?error, + operation_id = %operation.id, + voter_id = %outcome.target.key.voter_id, + contested_name = %outcome.target.contested_name, + "Confirmed DPNS vote was journaled but its legacy mirror could not be updated" + ); + } } let voters_by_id: BTreeMap = voters @@ -479,19 +493,12 @@ impl AppContext { ) .await; let (status, failure) = classify_vote_attempt(&attempt); + let confirmed = matches!( + &attempt, + Ok(vote_on_dpns_name::DpnsVoteAttempt::Confirmed) + ); match attempt { Ok(vote_on_dpns_name::DpnsVoteAttempt::Confirmed) => { - app_context.cache_confirmed_dpns_vote( - target.key.voter_id, - target.key.vote_poll_id, - target.requested_choice, - )?; - if matches!(target.timing, VoteTiming::Scheduled(_)) { - app_context.mark_vote_executed( - target.key.voter_id.as_slice(), - target.contested_name.clone(), - )?; - } } Ok(vote_on_dpns_name::DpnsVoteAttempt::Unconfirmed(error)) => { tracing::warn!( @@ -533,12 +540,44 @@ impl AppContext { ); } } - app_context.update_dpns_vote_target( - operation_id, - &target.key, - status, - failure, + let mirror_error = persist_terminal_then_legacy_mirror( + || { + app_context.update_dpns_vote_target( + operation_id, + &target.key, + status, + failure, + ) + }, + || { + if confirmed + && matches!(target.timing, VoteTiming::Scheduled(_)) + { + app_context.mark_vote_executed( + target.key.voter_id.as_slice(), + target.contested_name.clone(), + ) + } else { + Ok(()) + } + }, )?; + if let Some(error) = mirror_error { + tracing::warn!( + ?error, + operation_id = %operation_id, + voter_id = %target.key.voter_id, + contested_name = %target.contested_name, + "DPNS vote reached a terminal journal state but its legacy mirror could not be updated" + ); + } + if confirmed { + app_context.cache_confirmed_dpns_vote( + target.key.voter_id, + target.key.vote_poll_id, + target.requested_choice, + )?; + } } Ok(()) } @@ -549,9 +588,10 @@ impl AppContext { .into_iter() .collect::, _>>()?; - Ok(BackendTaskSuccessResult::DpnsVoteOperationUpdated( - operation.id, - )) + Ok(BackendTaskSuccessResult::DpnsVoteOperationUpdated { + network: self.network, + operation_id: operation.id, + }) } async fn reconcile_dpns_vote_operation( @@ -560,9 +600,10 @@ impl AppContext { sdk: &Sdk, ) -> Result { let Some(operation) = self.dpns_vote_operation(operation_id)? else { - return Ok(BackendTaskSuccessResult::DpnsVoteOperationUpdated( + return Ok(BackendTaskSuccessResult::DpnsVoteOperationUpdated { + network: self.network, operation_id, - )); + }); }; for outcome in operation .targets @@ -587,39 +628,40 @@ impl AppContext { outcome.target.requested_choice, ) == Some(DpnsVoteTargetStatus::Confirmed) => { + let mirror_error = persist_terminal_then_legacy_mirror( + || { + self.update_dpns_vote_target( + operation_id, + &outcome.target.key, + DpnsVoteTargetStatus::Confirmed, + None, + ) + }, + || { + if matches!(outcome.target.timing, VoteTiming::Scheduled(_)) { + self.mark_vote_executed( + outcome.target.key.voter_id.as_slice(), + outcome.target.contested_name.clone(), + ) + } else { + Ok(()) + } + }, + )?; + if let Some(error) = mirror_error { + tracing::warn!( + ?error, + operation_id = %operation_id, + voter_id = %outcome.target.key.voter_id, + contested_name = %outcome.target.contested_name, + "Reconciled DPNS vote reached a terminal journal state but its legacy mirror could not be updated" + ); + } self.cache_confirmed_dpns_vote( outcome.target.key.voter_id, poll_id, outcome.target.requested_choice, )?; - self.update_dpns_vote_target( - operation_id, - &outcome.target.key, - DpnsVoteTargetStatus::Confirmed, - None, - )?; - if matches!(outcome.target.timing, VoteTiming::Scheduled(_)) { - self.mark_vote_executed( - outcome.target.key.voter_id.as_slice(), - outcome.target.contested_name.clone(), - )?; - } - } - Ok(votes) - if classify_reconciled_vote( - votes - .get(&poll_id) - .and_then(Option::as_ref) - .map(ResourceVoteGettersV0::resource_vote_choice), - outcome.target.requested_choice, - ) == Some(DpnsVoteTargetStatus::Rejected) => - { - self.update_dpns_vote_target( - operation_id, - &outcome.target.key, - DpnsVoteTargetStatus::Rejected, - Some(DpnsVoteFailure::PlatformRejected), - )?; } Ok(_) => {} Err(error) => { @@ -639,9 +681,10 @@ impl AppContext { } } } - Ok(BackendTaskSuccessResult::DpnsVoteOperationUpdated( + Ok(BackendTaskSuccessResult::DpnsVoteOperationUpdated { + network: self.network, operation_id, - )) + }) } /// Cast every scheduled vote that is now due, off the UI thread. @@ -695,6 +738,7 @@ impl AppContext { now_ms, preserve_eligibility_since_ms, ) + && self.queue_scheduled_dpns_vote_target(operation.id, &outcome.target.key)? { outcome.status = DpnsVoteTargetStatus::Queued; due = true; @@ -780,6 +824,7 @@ fn scheduled_vote_is_due( #[cfg(test)] mod tests { use super::*; + use std::cell::RefCell; /// VOTE-TC-033: an inner scheduled rejection is never classified as success. #[test] @@ -808,14 +853,15 @@ mod tests { } #[test] - fn exact_reconciliation_distinguishes_match_rejection_and_absence() { + fn exact_reconciliation_confirms_only_the_requested_choice() { assert_eq!( classify_reconciled_vote(Some(ResourceVoteChoice::Lock), ResourceVoteChoice::Lock), Some(DpnsVoteTargetStatus::Confirmed) ); assert_eq!( classify_reconciled_vote(Some(ResourceVoteChoice::Abstain), ResourceVoteChoice::Lock), - Some(DpnsVoteTargetStatus::Rejected) + None, + "a mismatched row may predate the submitted transition and remains ambiguous" ); assert_eq!( classify_reconciled_vote(None, ResourceVoteChoice::Lock), @@ -824,6 +870,25 @@ mod tests { ); } + #[test] + fn terminal_journal_write_precedes_best_effort_legacy_mirror() { + let events = RefCell::new(Vec::new()); + let mirror_error = persist_terminal_then_legacy_mirror( + || { + events.borrow_mut().push("journal"); + Ok(()) + }, + || { + events.borrow_mut().push("legacy"); + Err(TaskError::DpnsVoteTargetBusy) + }, + ) + .expect("the authoritative journal write succeeded"); + + assert!(mirror_error.is_some()); + assert_eq!(events.into_inner(), vec!["journal", "legacy"]); + } + #[test] fn scheduled_terminal_or_unconfirmed_targets_are_not_due_for_rebroadcast() { for status in [ diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index c84c59cf7..938db9292 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -258,7 +258,10 @@ pub enum BackendTaskContext { /// The destructive per-network database clear. ClearNetworkDatabase, /// One durable DPNS vote operation. - DpnsVoteOperation(DpnsVoteOperationId), + DpnsVoteOperation { + network: Network, + operation_id: DpnsVoteOperationId, + }, /// A known backend task that needs no finer UI correlation. Other, /// An error emitted without an originating backend task. @@ -332,10 +335,17 @@ impl From<&BackendTask> for BackendTaskContext { operation, _, _, - )) => Self::DpnsVoteOperation(operation.id), + network, + )) => Self::DpnsVoteOperation { + network: *network, + operation_id: operation.id, + }, BackendTask::ContestedResourceTask( - ContestedResourceTask::ReconcileDpnsVoteOperation(operation_id), - ) => Self::DpnsVoteOperation(*operation_id), + ContestedResourceTask::ReconcileDpnsVoteOperation(operation_id, network), + ) => Self::DpnsVoteOperation { + network: *network, + operation_id: *operation_id, + }, _ => Self::Other, } } @@ -385,7 +395,10 @@ pub enum BackendTaskSuccessResult { network: Network, preserve_eligibility_since_ms: Option, }, - DpnsVoteOperationUpdated(DpnsVoteOperationId), + DpnsVoteOperationUpdated { + network: Network, + operation_id: DpnsVoteOperationId, + }, /// The scheduled votes that the `CastDueScheduledVotes` sweep is about to /// cast this cycle, so the Scheduled Votes screen can mark them in progress. ScheduledVotesInProgress(Vec), @@ -1125,6 +1138,22 @@ mod tests { ); } + #[test] + fn dpns_vote_context_preserves_the_originating_network() { + let operation_id = DpnsVoteOperationId::from_bytes([7; 16]); + let task = BackendTask::ContestedResourceTask( + ContestedResourceTask::ReconcileDpnsVoteOperation(operation_id, Network::Mainnet), + ); + + assert_eq!( + BackendTaskContext::from(&task), + BackendTaskContext::DpnsVoteOperation { + network: Network::Mainnet, + operation_id, + } + ); + } + /// `is_wallet_touching` covers every task family that funnels /// through `WalletBackend` β€” the gate in `run_backend_task` relies /// on it to short-circuit while the cold-start migration is diff --git a/src/context/dpns_vote_operations.rs b/src/context/dpns_vote_operations.rs index 1eb1e2653..e82210781 100644 --- a/src/context/dpns_vote_operations.rs +++ b/src/context/dpns_vote_operations.rs @@ -176,6 +176,33 @@ fn write_existing_operation( .map_err(operation_err) } +fn transition_scheduled_target_to_queued( + kv: &DetKv, + network: Network, + operation_id: DpnsVoteOperationId, + key: &DpnsVoteTargetKey, +) -> Result { + let Some(mut operation): Option = kv + .get(DetScope::Global, &operation_key(network, operation_id)) + .map_err(unreadable_operation_err)? + else { + return Ok(false); + }; + let Some(outcome) = operation + .targets + .iter_mut() + .find(|outcome| outcome.target.key == *key) + else { + return Ok(false); + }; + if outcome.status != DpnsVoteTargetStatus::Scheduled { + return Ok(false); + } + outcome.status = DpnsVoteTargetStatus::Queued; + write_existing_operation(kv, network, &operation)?; + Ok(true) +} + fn is_authorized_scheduled_replacement( existing_status: DpnsVoteTargetStatus, existing_key: &DpnsVoteTargetKey, @@ -192,6 +219,19 @@ fn is_authorized_scheduled_replacement( } impl AppContext { + /// Durably claim one due scheduled target before any executor can observe it. + pub(crate) fn queue_scheduled_dpns_vote_target( + &self, + operation_id: DpnsVoteOperationId, + key: &DpnsVoteTargetKey, + ) -> Result { + let _guard = self + .dpns_vote_operation_guard + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + transition_scheduled_target_to_queued(&self.det_kv()?, self.network, operation_id, key) + } + /// Persist a reviewed operation and atomically acquire all unresolved locks. pub fn insert_dpns_vote_operation( &self, @@ -678,6 +718,29 @@ mod tests { )); } + #[test] + fn due_schedule_is_durably_queued_once_through_the_kv_seam() { + let kv = kv(); + let mut scheduled = operation(DpnsVoteTargetStatus::Scheduled); + scheduled.targets[0].target.timing = VoteTiming::Scheduled(42); + let key = scheduled.targets[0].target.key.clone(); + persist_operation(&kv, Network::Testnet, &scheduled).unwrap(); + + assert!( + transition_scheduled_target_to_queued(&kv, Network::Testnet, scheduled.id, &key,) + .unwrap() + ); + assert_eq!( + load_operations(&kv, Network::Testnet).unwrap()[0].targets[0].status, + DpnsVoteTargetStatus::Queued + ); + assert!( + !transition_scheduled_target_to_queued(&kv, Network::Testnet, scheduled.id, &key,) + .unwrap(), + "a second executor must not claim the same schedule" + ); + } + /// A corrupt indexed row must block lock reconstruction rather than being skipped. #[test] fn unreadable_indexed_operation_fails_closed() { diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index 70b90a618..b9aa6ff34 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -1769,7 +1769,12 @@ impl DPNSScreen { VoteHandlingStatus::SchedulingVotes }; AppAction::BackendTask(BackendTask::ContestedResourceTask( - ContestedResourceTask::SubmitDpnsVoteOperation(operation, selected_voters, None), + ContestedResourceTask::SubmitDpnsVoteOperation( + operation, + selected_voters, + None, + self.app_context.network(), + ), )) } @@ -1962,7 +1967,7 @@ impl ScreenLike for DPNSScreen { fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { match backend_task_success_result { - BackendTaskSuccessResult::DpnsVoteOperationUpdated(_) => { + BackendTaskSuccessResult::DpnsVoteOperationUpdated { .. } => { self.vote_banner.take_and_clear(); self.bulk_vote_handling_status = VoteHandlingStatus::Completed; self.refresh(); diff --git a/src/ui/masternodes/list_screen.rs b/src/ui/masternodes/list_screen.rs index 6985c9ec3..695d28a54 100644 --- a/src/ui/masternodes/list_screen.rs +++ b/src/ui/masternodes/list_screen.rs @@ -419,7 +419,10 @@ impl MasternodesScreen { .clicked() { action = AppAction::BackendTask(BackendTask::ContestedResourceTask( - ContestedResourceTask::ReconcileDpnsVoteOperation(operation.id), + ContestedResourceTask::ReconcileDpnsVoteOperation( + operation.id, + self.app_context.network(), + ), )); } }); @@ -651,7 +654,10 @@ impl MasternodesScreen { && ComponentStyles::add_secondary_button(ui, "Check again", dark_mode).clicked() { action = AppAction::BackendTask(BackendTask::ContestedResourceTask( - ContestedResourceTask::ReconcileDpnsVoteOperation(scheduled.operation_id), + ContestedResourceTask::ReconcileDpnsVoteOperation( + scheduled.operation_id, + self.app_context.network(), + ), )); } }); @@ -915,6 +921,16 @@ impl ScreenLike for MasternodesScreen { } } + fn display_backend_task_error( + &mut self, + context: &crate::backend_task::BackendTaskContext, + _error: &crate::backend_task::error::TaskError, + ) { + if let MasternodesView::Voting(center) = &mut self.view { + center.display_backend_task_error(context); + } + } + fn display_task_error(&mut self, _error: &crate::backend_task::error::TaskError) -> bool { // A failing load reports `Failed` before its error reaches the UI, so // settling here re-enables the still-open form's submit button (the Load diff --git a/src/ui/masternodes/voting_center.rs b/src/ui/masternodes/voting_center.rs index 12074c366..ff565b8f9 100644 --- a/src/ui/masternodes/voting_center.rs +++ b/src/ui/masternodes/voting_center.rs @@ -13,6 +13,7 @@ use eframe::egui::{self, ComboBox, RichText}; use crate::app::AppAction; use crate::backend_task::BackendTask; +use crate::backend_task::BackendTaskContext; use crate::backend_task::contested_names::ContestedResourceTask; use crate::context::AppContext; use crate::model::contested_name::ContestedName; @@ -59,6 +60,26 @@ struct ReviewExclusion { } impl DpnsVotingCenter { + pub(crate) fn display_backend_task_error(&mut self, context: &BackendTaskContext) { + let Some(operation_id) = self.submitted_operation else { + return; + }; + if should_return_to_review_after_error( + operation_id, + self.app_context.network(), + context, + self.app_context + .dpns_vote_operation(operation_id) + .ok() + .flatten() + .is_some(), + ) { + self.submitted_operation = None; + self.workspace.step = DpnsVoteComposerStep::Review; + self.focus_step_heading = true; + } + } + pub fn new( app_context: &Arc, preselected_voter: Option, @@ -531,7 +552,10 @@ impl DpnsVotingCenter { { return VotingCenterOutcome::Action(Box::new(AppAction::BackendTask( BackendTask::ContestedResourceTask( - ContestedResourceTask::ReconcileDpnsVoteOperation(operation_id), + ContestedResourceTask::ReconcileDpnsVoteOperation( + operation_id, + self.app_context.network(), + ), ), ))); } @@ -622,6 +646,7 @@ impl DpnsVotingCenter { operation, self.selected_voters(), self.editing_scheduled_key.clone(), + self.app_context.network(), )), ))) } @@ -1044,6 +1069,20 @@ fn is_explicit_schedule_replacement( && matches!(replacement_timing, VoteTiming::Scheduled(_)) } +fn should_return_to_review_after_error( + submitted_operation: DpnsVoteOperationId, + network: dash_sdk::dpp::dashcore::Network, + context: &BackendTaskContext, + operation_was_journaled: bool, +) -> bool { + !operation_was_journaled + && context + == &BackendTaskContext::DpnsVoteOperation { + network, + operation_id: submitted_operation, + } +} + fn has_loaded_voting_key(voter: &QualifiedIdentity) -> bool { voter .private_keys @@ -1211,4 +1250,32 @@ mod tests { assert!(summary.contains("Lock at ")); assert!(summary.contains("β†’ Abstain at ")); } + + #[test] + fn matching_pre_journal_error_returns_the_composer_to_review() { + let operation_id = DpnsVoteOperationId::from_bytes([4; 16]); + let context = BackendTaskContext::DpnsVoteOperation { + network: dash_sdk::dpp::dashcore::Network::Testnet, + operation_id, + }; + + assert!(should_return_to_review_after_error( + operation_id, + dash_sdk::dpp::dashcore::Network::Testnet, + &context, + false, + )); + assert!(!should_return_to_review_after_error( + operation_id, + dash_sdk::dpp::dashcore::Network::Testnet, + &context, + true, + )); + assert!(!should_return_to_review_after_error( + operation_id, + dash_sdk::dpp::dashcore::Network::Mainnet, + &context, + false, + )); + } } From fa0cb10d200fdae8c682c9e05d0ed5a84a1282af Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:20:20 +0000 Subject: [PATCH 08/39] fix(dpns): explain empty voting workspace Show a recovery action when no masternodes are available for the shared voting composer. Co-Authored-By: Codex GPT-5 --- src/ui/masternodes/voting_center.rs | 16 ++++++++++++++++ tests/kittest/masternode_tab.rs | 16 ++++++++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/ui/masternodes/voting_center.rs b/src/ui/masternodes/voting_center.rs index ff565b8f9..359d1d7ec 100644 --- a/src/ui/masternodes/voting_center.rs +++ b/src/ui/masternodes/voting_center.rs @@ -225,6 +225,22 @@ impl DpnsVotingCenter { let dark_mode = ui.style().visuals.dark_mode; self.step_heading(ui, "Step 1 of 3: Nodes and timing"); ui.label("Choose which nodes will vote and when each node should submit."); + if self.voters.is_empty() { + ui.separator(); + let go_to_nodes = ui + .vertical_centered(|ui| { + ui.heading("No voting nodes are available"); + ui.label("Load a masternode on the Nodes tab before creating a vote."); + ui.add_space(8.0); + ComponentStyles::add_primary_button_enabled(ui, true, "Go to Nodes").clicked() + }) + .inner; + return if go_to_nodes { + VotingCenterOutcome::BackToNodes + } else { + VotingCenterOutcome::None + }; + } ui.horizontal_wrapped(|ui| { ui.label("Set all:"); timing_combo( diff --git a/tests/kittest/masternode_tab.rs b/tests/kittest/masternode_tab.rs index d6a9caad0..9e93902c8 100644 --- a/tests/kittest/masternode_tab.rs +++ b/tests/kittest/masternode_tab.rs @@ -288,13 +288,21 @@ fn voting_navigation_routes_to_shared_workspaces() { .query_by_label("Step 1 of 3: Nodes and timing") .is_some() ); - harness.get_by_label("Next: Choose votes").click(); - harness.run_steps(2); assert!( harness - .query_by_label("Step 1 of 3: Nodes and timing") + .query_by_label("No voting nodes are available") .is_some(), - "an unfiltered bulk draft must not select every node by default" + "an empty voting workspace must explain why voting cannot start" + ); + assert!( + harness.query_by_label("Go to Nodes").is_some(), + "an empty voting workspace must offer a direct recovery action" + ); + harness.get_by_label("Go to Nodes").click(); + harness.run_steps(2); + assert!( + harness.query_by_label("No masternodes loaded").is_some(), + "the empty-workspace recovery action must return to the Nodes tab" ); }); } From b933f920b114c06eba612ef2951e54ce26bb9e2e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:55:14 +0000 Subject: [PATCH 09/39] fix(dpns): close voting operation review gaps Co-Authored-By: OpenAI GPT-5 Codex --- src/backend_task/contested_names/mod.rs | 3 +- .../contested_names/vote_on_dpns_name.rs | 115 +++++-- src/context/dpns_vote_operations.rs | 301 ++++++++++++------ src/context/dpns_vote_state.rs | 50 ++- src/ui/dpns/dpns_contested_names_screen.rs | 4 +- src/ui/masternodes/list_screen.rs | 11 + src/ui/masternodes/voting_center.rs | 52 ++- 7 files changed, 400 insertions(+), 136 deletions(-) diff --git a/src/backend_task/contested_names/mod.rs b/src/backend_task/contested_names/mod.rs index af3615c5a..8276da931 100644 --- a/src/backend_task/contested_names/mod.rs +++ b/src/backend_task/contested_names/mod.rs @@ -16,7 +16,6 @@ use crate::model::request_type::RequestType; use dash_sdk::Sdk; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dash_sdk::dpp::voting::votes::resource_vote::ResourceVote; use dash_sdk::dpp::voting::votes::resource_vote::accessors::v0::ResourceVoteGettersV0; @@ -406,7 +405,7 @@ impl AppContext { VoteTiming::Now => None, }) .collect::>(); - self.insert_dpns_vote_operation(&operation, replacing_scheduled_key.as_ref())?; + self.insert_dpns_vote_operation(&mut operation, replacing_scheduled_key.as_ref())?; if !scheduled_votes.is_empty() && let Err(error) = self.insert_scheduled_votes(&scheduled_votes) { diff --git a/src/backend_task/contested_names/vote_on_dpns_name.rs b/src/backend_task/contested_names/vote_on_dpns_name.rs index 0ecaa3b80..b6a36af31 100644 --- a/src/backend_task/contested_names/vote_on_dpns_name.rs +++ b/src/backend_task/contested_names/vote_on_dpns_name.rs @@ -2,11 +2,18 @@ use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::Sdk; +use dash_sdk::dpp::consensus::ConsensusError; +use dash_sdk::dpp::consensus::basic::BasicError; use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; use dash_sdk::dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dash_sdk::dpp::identifier::MasternodeIdentifiers; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::hash::IdentityPublicKeyHashMethodsV0; use dash_sdk::dpp::platform_value::Value; use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; +use dash_sdk::dpp::state_transition::masternode_vote_transition::methods::MasternodeVoteTransitionMethodsV0; +use dash_sdk::dpp::state_transition::{StateTransition, StateTransitionStructureValidation}; use dash_sdk::dpp::util::strings::convert_to_homograph_safe_chars; use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dash_sdk::dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; @@ -15,7 +22,11 @@ use dash_sdk::dpp::voting::votes::resource_vote::ResourceVote; use dash_sdk::dpp::voting::votes::resource_vote::v0::ResourceVoteV0; use dash_sdk::drive::query::vote_polls_by_document_type_query::VotePollsByDocumentTypeQuery; use dash_sdk::platform::FetchMany; -use dash_sdk::platform::transition::vote::PutVote; +use dash_sdk::platform::Identifier; +use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; +use dash_sdk::platform::transition::broadcast_request::BroadcastRequestForStateTransition; +use dash_sdk::platform::transition::put_settings::PutSettings; +use dash_sdk::platform::transition::waitable::Waitable; use dash_sdk::query_types::ContestedResource; use std::sync::Arc; @@ -37,6 +48,39 @@ fn dpns_vote_poll_index_values(normalized_label: &str) -> Vec { ] } +fn ensure_valid_vote_transition_structure( + state_transition: &StateTransition, + sdk: &Sdk, +) -> Result<(), TaskError> { + let validation_result = state_transition.validate_structure(sdk.version()); + if validation_result.is_valid() + || validation_result.errors.iter().all(|error| { + matches!( + error, + ConsensusError::BasicError(BasicError::UnsupportedFeatureError(_)) + ) + }) + { + Ok(()) + } else { + Err(TaskError::from(dash_sdk::Error::from(validation_result))) + } +} + +fn classify_post_broadcast_error(error: dash_sdk::Error) -> DpnsVoteAttempt { + let rejected = matches!( + &error, + dash_sdk::Error::StateTransitionBroadcastError(broadcast_error) + if broadcast_error.cause.is_some() + ); + let error = TaskError::from(error); + if rejected { + DpnsVoteAttempt::Rejected(error) + } else { + DpnsVoteAttempt::Unconfirmed(error) + } +} + impl AppContext { pub(super) async fn submit_dpns_vote( self: &Arc, @@ -104,38 +148,39 @@ impl AppContext { resource_vote_choice: vote_choice, }; let vote = Vote::ResourceVote(ResourceVote::V0(resource_vote)); - - match vote - .put_to_platform_and_wait_for_response( - qualified_identity.identity.id(), - public_key, - sdk, - qualified_identity, - None, - ) + let voter_pro_tx_hash = qualified_identity.identity.id(); + let voting_public_key_hash = public_key + .public_key_hash() + .map_err(dash_sdk::Error::from)?; + let voting_identity_id = Identifier::create_voter_identifier( + voter_pro_tx_hash.as_bytes(), + &voting_public_key_hash, + ); + let settings = PutSettings::default(); + let nonce = sdk + .get_identity_nonce(voting_identity_id, true, Some(settings)) .await - { + .map_err(TaskError::from)?; + let state_transition = MasternodeVoteTransition::try_from_vote_with_signer( + vote, + qualified_identity, + voter_pro_tx_hash, + public_key, + nonce, + sdk.version(), + None, + ) + .await + .map_err(dash_sdk::Error::from)?; + ensure_valid_vote_transition_structure(&state_transition, sdk)?; + state_transition.broadcast_request_for_state_transition()?; + if let Err(error) = state_transition.broadcast(sdk, Some(settings)).await { + return Ok(classify_post_broadcast_error(error)); + } + + match Vote::wait_for_response(sdk, state_transition, Some(settings)).await { Ok(_) => Ok(DpnsVoteAttempt::Confirmed), - Err(error) => { - let unconfirmed = matches!( - &error, - dash_sdk::Error::StateTransitionBroadcastError(broadcast_error) - if broadcast_error.cause.is_none() - ); - let rejected = matches!( - &error, - dash_sdk::Error::StateTransitionBroadcastError(broadcast_error) - if broadcast_error.cause.is_some() - ); - let error = TaskError::from(error); - if unconfirmed { - Ok(DpnsVoteAttempt::Unconfirmed(error)) - } else if rejected { - Ok(DpnsVoteAttempt::Rejected(error)) - } else { - Err(error) - } - } + Err(error) => Ok(classify_post_broadcast_error(error)), } } } @@ -180,4 +225,12 @@ mod tests { // Then: the result matches the constant used by the vote poll tests. assert_eq!(normalized, "a11ce"); } + + #[test] + fn non_broadcast_wait_error_is_unconfirmed() { + let attempt = + classify_post_broadcast_error(dash_sdk::Error::Generic("wait failed".to_owned())); + + assert!(matches!(attempt, DpnsVoteAttempt::Unconfirmed(_))); + } } diff --git a/src/context/dpns_vote_operations.rs b/src/context/dpns_vote_operations.rs index e82210781..4af47959c 100644 --- a/src/context/dpns_vote_operations.rs +++ b/src/context/dpns_vote_operations.rs @@ -218,6 +218,88 @@ fn is_authorized_scheduled_replacement( }) } +fn replace_scheduled_operation( + kv: &DetKv, + network: Network, + operation: &mut DpnsVoteOperation, + replacing_scheduled_key: &DpnsVoteTargetKey, +) -> Result<(), TaskError> { + if operation.targets.len() != 1 + || !is_authorized_scheduled_replacement( + DpnsVoteTargetStatus::Scheduled, + replacing_scheduled_key, + operation, + Some(replacing_scheduled_key), + ) + { + return Err(TaskError::DpnsVoteTargetBusy); + } + let replacement = operation.targets[0].clone(); + for mut existing in load_operations(kv, network)? { + let Some(outcome) = existing + .targets + .iter_mut() + .find(|outcome| outcome.target.key == *replacing_scheduled_key) + else { + continue; + }; + if outcome.status != DpnsVoteTargetStatus::Scheduled { + continue; + } + *outcome = replacement; + outcome.operation_id = existing.id; + write_existing_operation(kv, network, &existing)?; + *operation = existing; + return Ok(()); + } + Err(TaskError::DpnsVoteTargetBusy) +} + +fn cancel_scheduled_target( + kv: &DetKv, + network: Network, + operation_id: DpnsVoteOperationId, + key: &DpnsVoteTargetKey, +) -> Result { + let Some(mut operation): Option = kv + .get(DetScope::Global, &operation_key(network, operation_id)) + .map_err(unreadable_operation_err)? + else { + return Ok(false); + }; + let Some(outcome) = operation + .targets + .iter_mut() + .find(|outcome| outcome.target.key == *key) + else { + return Ok(false); + }; + if outcome.status != DpnsVoteTargetStatus::Scheduled { + return Ok(false); + } + outcome.status = DpnsVoteTargetStatus::NotApplied; + write_existing_operation(kv, network, &operation)?; + Ok(true) +} + +fn cancel_all_scheduled_targets(kv: &DetKv, network: Network) -> Result { + let mut cancelled = 0; + for mut operation in load_operations(kv, network)? { + let mut changed = false; + for outcome in &mut operation.targets { + if outcome.status == DpnsVoteTargetStatus::Scheduled { + outcome.status = DpnsVoteTargetStatus::NotApplied; + changed = true; + cancelled += 1; + } + } + if changed { + write_existing_operation(kv, network, &operation)?; + } + } + Ok(cancelled) +} + impl AppContext { /// Durably claim one due scheduled target before any executor can observe it. pub(crate) fn queue_scheduled_dpns_vote_target( @@ -235,7 +317,7 @@ impl AppContext { /// Persist a reviewed operation and atomically acquire all unresolved locks. pub fn insert_dpns_vote_operation( &self, - operation: &DpnsVoteOperation, + operation: &mut DpnsVoteOperation, replacing_scheduled_key: Option<&DpnsVoteTargetKey>, ) -> Result<(), TaskError> { if operation @@ -250,71 +332,10 @@ impl AppContext { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let kv = self.det_kv()?; - let replacement_is_valid = replacing_scheduled_key.is_none_or(|key| { - operation.targets.iter().any(|outcome| { - outcome.target.key == *key - && outcome.status == DpnsVoteTargetStatus::Scheduled - && matches!(outcome.target.timing, VoteTiming::Scheduled(_)) - }) - }); - if !replacement_is_valid { - return Err(TaskError::DpnsVoteTargetBusy); + if let Some(key) = replacing_scheduled_key { + return replace_scheduled_operation(&kv, self.network, operation, key); } - - let mut replaced = Vec::new(); - let mut replacement_found = false; - for mut existing in load_operations(&kv, self.network)? { - let original = existing.clone(); - let mut changed = false; - for existing_outcome in &mut existing.targets { - let conflicts = existing_outcome.status.holds_lock() - && operation.targets.iter().any(|new_outcome| { - new_outcome.status.holds_lock() - && new_outcome.target.key == existing_outcome.target.key - }); - if !conflicts { - continue; - } - let explicitly_replaced = is_authorized_scheduled_replacement( - existing_outcome.status, - &existing_outcome.target.key, - operation, - replacing_scheduled_key, - ); - if explicitly_replaced { - changed = true; - replacement_found = true; - existing_outcome.status = DpnsVoteTargetStatus::NotApplied; - } else { - for previous in replaced { - write_existing_operation(&kv, self.network, &previous)?; - } - return Err(TaskError::DpnsVoteTargetBusy); - } - } - if changed { - if let Err(error) = write_existing_operation(&kv, self.network, &existing) { - for previous in replaced { - write_existing_operation(&kv, self.network, &previous)?; - } - return Err(error); - } - replaced.push(original); - } - } - if replacing_scheduled_key.is_some() && !replacement_found { - for original in replaced { - write_existing_operation(&kv, self.network, &original)?; - } - return Err(TaskError::DpnsVoteTargetBusy); - } - if let Err(error) = persist_operation(&kv, self.network, operation) { - for original in replaced { - write_existing_operation(&kv, self.network, &original)?; - } - return Err(error); - } - Ok(()) + persist_operation(&kv, self.network, operation) } /// Persist updated target statuses while retaining the original operation ID. @@ -571,38 +592,21 @@ impl AppContext { operation_id: DpnsVoteOperationId, key: &DpnsVoteTargetKey, ) -> Result<(), TaskError> { - let Some(mut operation) = self.dpns_vote_operation(operation_id)? else { - return Ok(()); - }; - let Some(outcome) = operation - .targets - .iter_mut() - .find(|outcome| outcome.target.key == *key) - else { - return Ok(()); - }; - if outcome.status == DpnsVoteTargetStatus::Scheduled { - outcome.status = DpnsVoteTargetStatus::NotApplied; - self.update_dpns_vote_operation(&operation)?; - } + let _guard = self + .dpns_vote_operation_guard + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + cancel_scheduled_target(&self.det_kv()?, self.network, operation_id, key)?; Ok(()) } /// Release every not-yet-submitting scheduled target on this network. pub(crate) fn cancel_all_scheduled_dpns_vote_targets(&self) -> Result<(), TaskError> { - let operations = self.dpns_vote_operations()?; - for mut operation in operations { - let mut changed = false; - for outcome in &mut operation.targets { - if outcome.status == DpnsVoteTargetStatus::Scheduled { - outcome.status = DpnsVoteTargetStatus::NotApplied; - changed = true; - } - } - if changed { - self.update_dpns_vote_operation(&operation)?; - } - } + let _guard = self + .dpns_vote_operation_guard + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + cancel_all_scheduled_targets(&self.det_kv()?, self.network)?; Ok(()) } } @@ -617,6 +621,59 @@ mod tests { use dash_sdk::platform::Identifier; use platform_wallet_storage::{KvStore, ObjectId}; use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[derive(Default)] + struct CountingKv { + inner: InMemoryKv, + puts: AtomicUsize, + } + + impl CountingKv { + fn reset_puts(&self) { + self.puts.store(0, Ordering::Relaxed); + } + + fn put_count(&self) -> usize { + self.puts.load(Ordering::Relaxed) + } + } + + impl KvStore for CountingKv { + fn get( + &self, + scope: &ObjectId, + key: &str, + ) -> Result>, platform_wallet_storage::KvError> { + self.inner.get(scope, key) + } + + fn put( + &self, + scope: &ObjectId, + key: &str, + value: &[u8], + ) -> Result<(), platform_wallet_storage::KvError> { + self.puts.fetch_add(1, Ordering::Relaxed); + self.inner.put(scope, key, value) + } + + fn delete( + &self, + scope: &ObjectId, + key: &str, + ) -> Result<(), platform_wallet_storage::KvError> { + self.inner.delete(scope, key) + } + + fn list_keys( + &self, + scope: &ObjectId, + prefix: Option<&str>, + ) -> Result, platform_wallet_storage::KvError> { + self.inner.list_keys(scope, prefix) + } + } fn kv() -> DetKv { DetKv::from_store(Arc::new(InMemoryKv::default())) @@ -718,6 +775,29 @@ mod tests { )); } + #[test] + fn scheduled_replacement_reuses_the_existing_record_in_one_write() { + let store = Arc::new(CountingKv::default()); + let kv = DetKv::from_store(store.clone()); + let mut scheduled = operation(DpnsVoteTargetStatus::Scheduled); + scheduled.targets[0].target.timing = VoteTiming::Scheduled(42); + let key = scheduled.targets[0].target.key.clone(); + persist_operation(&kv, Network::Testnet, &scheduled).unwrap(); + + let mut replacement = operation(DpnsVoteTargetStatus::Scheduled); + replacement.targets[0].target.timing = VoteTiming::Scheduled(84); + store.reset_puts(); + + replace_scheduled_operation(&kv, Network::Testnet, &mut replacement, &key).unwrap(); + + assert_eq!(store.put_count(), 1); + assert_eq!(replacement.id, scheduled.id); + assert_eq!( + load_operations(&kv, Network::Testnet).unwrap(), + vec![replacement] + ); + } + #[test] fn due_schedule_is_durably_queued_once_through_the_kv_seam() { let kv = kv(); @@ -741,6 +821,43 @@ mod tests { ); } + #[test] + fn cancellation_does_not_overwrite_a_queued_target() { + let kv = kv(); + let mut scheduled = operation(DpnsVoteTargetStatus::Scheduled); + scheduled.targets[0].target.timing = VoteTiming::Scheduled(42); + let key = scheduled.targets[0].target.key.clone(); + persist_operation(&kv, Network::Testnet, &scheduled).unwrap(); + transition_scheduled_target_to_queued(&kv, Network::Testnet, scheduled.id, &key).unwrap(); + + assert!(!cancel_scheduled_target(&kv, Network::Testnet, scheduled.id, &key).unwrap()); + assert_eq!( + load_operations(&kv, Network::Testnet).unwrap()[0].targets[0].status, + DpnsVoteTargetStatus::Queued + ); + } + + #[test] + fn cancel_all_preserves_targets_that_are_already_queued() { + let kv = kv(); + let mut scheduled = operation(DpnsVoteTargetStatus::Scheduled); + scheduled.targets[0].target.timing = VoteTiming::Scheduled(42); + let mut queued = operation(DpnsVoteTargetStatus::Queued); + queued.targets[0].target.key.vote_poll_id = Identifier::from([3; 32]); + persist_operation(&kv, Network::Testnet, &scheduled).unwrap(); + persist_operation(&kv, Network::Testnet, &queued).unwrap(); + + assert_eq!( + cancel_all_scheduled_targets(&kv, Network::Testnet).unwrap(), + 1 + ); + let operations = load_operations(&kv, Network::Testnet).unwrap(); + assert!(operations.iter().any(|operation| { + operation.targets[0].target.key == queued.targets[0].target.key + && operation.targets[0].status == DpnsVoteTargetStatus::Queued + })); + } + /// A corrupt indexed row must block lock reconstruction rather than being skipped. #[test] fn unreadable_indexed_operation_fails_closed() { diff --git a/src/context/dpns_vote_state.rs b/src/context/dpns_vote_state.rs index 3116cb3fa..d036b04ed 100644 --- a/src/context/dpns_vote_state.rs +++ b/src/context/dpns_vote_state.rs @@ -60,13 +60,15 @@ fn load_snapshot( { return Ok(Some(snapshot)); } - let legacy = kv - .get(scope, LEGACY_CURRENT_VOTES_KEY) - .map_err(vote_state_err)?; - if let Some(snapshot) = &legacy { - save_snapshot(kv, network, voter_id, snapshot)?; + if kv + .get::(scope, LEGACY_CURRENT_VOTES_KEY) + .map_err(vote_state_err)? + .is_some() + { + kv.delete(scope, LEGACY_CURRENT_VOTES_KEY) + .map_err(vote_state_err)?; } - Ok(legacy) + Ok(None) } fn save_snapshot( @@ -329,6 +331,42 @@ mod tests { assert_eq!(load_snapshot(&kv, Network::Mainnet, &voter).unwrap(), None); } + #[test] + fn legacy_snapshot_is_discarded_instead_of_assigned_to_a_network() { + let kv = kv(); + let voter = Identifier::from([1; 32]); + let snapshot = StoredCurrentVotes { + available: true, + updated_at: now_ms(), + votes: BTreeMap::new(), + }; + kv.put( + DetScope::Identity(&voter.to_buffer()), + LEGACY_CURRENT_VOTES_KEY, + &snapshot, + ) + .unwrap(); + + assert_eq!(load_snapshot(&kv, Network::Testnet, &voter).unwrap(), None); + assert_eq!(load_snapshot(&kv, Network::Mainnet, &voter).unwrap(), None); + assert!( + kv.get::( + DetScope::Identity(&voter.to_buffer()), + ¤t_votes_key(Network::Testnet), + ) + .unwrap() + .is_none() + ); + assert!( + kv.get::( + DetScope::Identity(&voter.to_buffer()), + LEGACY_CURRENT_VOTES_KEY, + ) + .unwrap() + .is_none() + ); + } + #[test] fn stale_proved_snapshot_cannot_authorize_submission() { let poll = Identifier::from([2; 32]); diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index b9aa6ff34..9cd5355e0 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -12,9 +12,7 @@ use eframe::egui::{self, Button, Color32, ComboBox, Label, RichText, Ui}; use egui_extras::{Column, TableBuilder}; use itertools::Itertools; -use crate::app::{ - AppAction, BackendTasksExecutionMode, DesiredAppAction, scheduled_vote_sweep_is_quiet, -}; +use crate::app::{AppAction, DesiredAppAction, scheduled_vote_sweep_is_quiet}; use crate::backend_task::BackendTask; use crate::backend_task::contested_names::{ContestedResourceTask, ScheduledDPNSVote}; use crate::backend_task::error::TaskError; diff --git a/src/ui/masternodes/list_screen.rs b/src/ui/masternodes/list_screen.rs index 695d28a54..4f671ede9 100644 --- a/src/ui/masternodes/list_screen.rs +++ b/src/ui/masternodes/list_screen.rs @@ -921,6 +921,17 @@ impl ScreenLike for MasternodesScreen { } } + fn display_backend_task_result( + &mut self, + context: &crate::backend_task::BackendTaskContext, + result: crate::backend_task::BackendTaskSuccessResult, + ) { + if let MasternodesView::Voting(center) = &mut self.view { + center.display_backend_task_result(context, &result); + } + self.display_task_result(result); + } + fn display_backend_task_error( &mut self, context: &crate::backend_task::BackendTaskContext, diff --git a/src/ui/masternodes/voting_center.rs b/src/ui/masternodes/voting_center.rs index 359d1d7ec..203b07f8b 100644 --- a/src/ui/masternodes/voting_center.rs +++ b/src/ui/masternodes/voting_center.rs @@ -12,9 +12,8 @@ use dash_sdk::platform::Identifier; use eframe::egui::{self, ComboBox, RichText}; use crate::app::AppAction; -use crate::backend_task::BackendTask; -use crate::backend_task::BackendTaskContext; use crate::backend_task::contested_names::ContestedResourceTask; +use crate::backend_task::{BackendTask, BackendTaskContext, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::contested_name::ContestedName; use crate::model::dpns_voting::{ @@ -60,6 +59,15 @@ struct ReviewExclusion { } impl DpnsVotingCenter { + pub(crate) fn display_backend_task_result( + &mut self, + context: &BackendTaskContext, + result: &BackendTaskSuccessResult, + ) { + self.submitted_operation = + updated_submitted_operation(self.submitted_operation, context, result); + } + pub(crate) fn display_backend_task_error(&mut self, context: &BackendTaskContext) { let Some(operation_id) = self.submitted_operation else { return; @@ -1099,6 +1107,27 @@ fn should_return_to_review_after_error( } } +fn updated_submitted_operation( + submitted_operation: Option, + context: &BackendTaskContext, + result: &BackendTaskSuccessResult, +) -> Option { + match (submitted_operation, context, result) { + ( + Some(submitted), + BackendTaskContext::DpnsVoteOperation { + network: submitted_network, + operation_id: submitted_id, + }, + BackendTaskSuccessResult::DpnsVoteOperationUpdated { + network: result_network, + operation_id: result_id, + }, + ) if submitted == *submitted_id && submitted_network == result_network => Some(*result_id), + _ => submitted_operation, + } +} + fn has_loaded_voting_key(voter: &QualifiedIdentity) -> bool { voter .private_keys @@ -1294,4 +1323,23 @@ mod tests { false, )); } + + #[test] + fn schedule_replacement_tracks_the_authoritative_operation_id() { + let submitted = DpnsVoteOperationId::from_bytes([4; 16]); + let stored = DpnsVoteOperationId::from_bytes([5; 16]); + let context = BackendTaskContext::DpnsVoteOperation { + network: dash_sdk::dpp::dashcore::Network::Testnet, + operation_id: submitted, + }; + let result = crate::backend_task::BackendTaskSuccessResult::DpnsVoteOperationUpdated { + network: dash_sdk::dpp::dashcore::Network::Testnet, + operation_id: stored, + }; + + assert_eq!( + updated_submitted_operation(Some(submitted), &context, &result), + Some(stored) + ); + } } From 05133b601d60a12b33208075d9b46cbb5d4b17d2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 17 Jul 2026 07:27:33 +0000 Subject: [PATCH 10/39] fix(dpns): always release failed sweep dispatches Keep scheduled vote recovery inside the sweep error boundary so every failure clears the per-network in-progress latch through the typed handler. Co-Authored-By: Codex GPT-5 --- src/backend_task/contested_names/mod.rs | 44 ++++++++++++++++++++----- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/src/backend_task/contested_names/mod.rs b/src/backend_task/contested_names/mod.rs index 8276da931..8d4fdaeb5 100644 --- a/src/backend_task/contested_names/mod.rs +++ b/src/backend_task/contested_names/mod.rs @@ -104,6 +104,16 @@ fn persist_terminal_then_legacy_mirror( Ok(update_legacy_mirror().err()) } +fn wrap_scheduled_vote_sweep_result( + network: Network, + result: Result, +) -> Result { + result.map_err(|source| TaskError::ScheduledVoteSweepFailed { + network, + source: Box::new(source), + }) +} + /// Logs a Drive proof-verification failure raised by a contested-resource query. /// /// No-op unless `e` is a [`dash_sdk::Error::Proof`] carrying a GroveDB proof @@ -137,7 +147,6 @@ impl AppContext { sdk: &Sdk, sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { - self.ensure_dpns_vote_recovery(sdk).await?; match task { ContestedResourceTask::QueryDPNSContests => self .query_dpns_contested_resources(sdk, sender) @@ -162,13 +171,15 @@ impl AppContext { } ContestedResourceTask::CastDueScheduledVotes { preserve_eligibility_since_ms, - } => self - .cast_due_scheduled_votes(sdk, sender, preserve_eligibility_since_ms) - .await - .map_err(|source| TaskError::ScheduledVoteSweepFailed { - network: self.network, - source: Box::new(source), - }), + } => { + let result = async { + self.ensure_dpns_vote_recovery(sdk).await?; + self.cast_due_scheduled_votes(sdk, sender, preserve_eligibility_since_ms) + .await + } + .await; + wrap_scheduled_vote_sweep_result(self.network, result) + } ContestedResourceTask::ClearAllScheduledVotes => { self.cancel_all_scheduled_dpns_vote_targets()?; if let Err(error) = self.clear_all_scheduled_votes() { @@ -900,6 +911,23 @@ mod tests { } } + #[test] + fn scheduled_sweep_wraps_recovery_failures() { + let error = wrap_scheduled_vote_sweep_result::( + Network::Testnet, + Err(TaskError::DpnsCurrentVoteUnavailable), + ) + .expect_err("a recovery failure must fail the scheduled sweep"); + + assert!(matches!( + error, + TaskError::ScheduledVoteSweepFailed { + network: Network::Testnet, + source, + } if matches!(source.as_ref(), TaskError::DpnsCurrentVoteUnavailable) + )); + } + /// Migration extends only eligibility windows that overlap its wait. #[test] fn migration_wait_preserves_only_overlapping_vote_eligibility() { From 5e0532e1e43567b0524631981018e22408637d0a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 17 Jul 2026 07:31:34 +0000 Subject: [PATCH 11/39] fix(dpns): keep preflight failures retryable Re-drive already queued schedules and restore unavailable scheduled targets before propagating the pre-submission failure. Co-Authored-By: Codex GPT-5 --- src/backend_task/contested_names/mod.rs | 86 +++++++++++++++++++------ src/context/dpns_vote_operations.rs | 24 ++++++- src/model/dpns_voting.rs | 10 +++ 3 files changed, 99 insertions(+), 21 deletions(-) diff --git a/src/backend_task/contested_names/mod.rs b/src/backend_task/contested_names/mod.rs index 8d4fdaeb5..945362ae1 100644 --- a/src/backend_task/contested_names/mod.rs +++ b/src/backend_task/contested_names/mod.rs @@ -9,7 +9,7 @@ use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::dpns_voting::{ DpnsCurrentVoteState, DpnsVoteFailure, DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteTarget, - DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, + DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, unavailable_preflight_outcome, }; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::request_type::RequestType; @@ -355,6 +355,7 @@ impl AppContext { }); } let was_persisted = self.dpns_vote_operation(operation.id)?.is_some(); + let mut preflight_unavailable = false; if operation .targets .iter() @@ -371,8 +372,13 @@ impl AppContext { .collect::>(); for key in queued_keys { let state = self.dpns_current_vote_state(key.voter_id, key.vote_poll_id)?; + let unavailable = matches!( + state, + DpnsCurrentVoteState::Checking | DpnsCurrentVoteState::Unavailable + ); if was_persisted { self.revalidate_queued_dpns_vote_target(operation.id, &key, state)?; + preflight_unavailable |= unavailable; continue; } let Some(outcome) = operation @@ -391,7 +397,9 @@ impl AppContext { } } DpnsCurrentVoteState::Checking | DpnsCurrentVoteState::Unavailable => { - return Err(TaskError::DpnsCurrentVoteUnavailable); + (outcome.status, outcome.failure) = + unavailable_preflight_outcome(outcome.target.timing); + preflight_unavailable = true; } } } @@ -598,6 +606,10 @@ impl AppContext { .into_iter() .collect::, _>>()?; + if preflight_unavailable { + return Err(TaskError::DpnsCurrentVoteUnavailable); + } + Ok(BackendTaskSuccessResult::DpnsVoteOperationUpdated { network: self.network, operation_id: operation.id, @@ -741,25 +753,28 @@ impl AppContext { let VoteTiming::Scheduled(scheduled_at) = outcome.target.timing else { continue; }; + if !scheduled_target_should_execute( + outcome.status, + scheduled_at, + now_ms, + preserve_eligibility_since_ms, + ) { + continue; + } if outcome.status == DpnsVoteTargetStatus::Scheduled - && scheduled_vote_is_due( - scheduled_at, - false, - now_ms, - preserve_eligibility_since_ms, - ) - && self.queue_scheduled_dpns_vote_target(operation.id, &outcome.target.key)? + && !self.queue_scheduled_dpns_vote_target(operation.id, &outcome.target.key)? { - outcome.status = DpnsVoteTargetStatus::Queued; - due = true; - in_progress.push(ScheduledDPNSVote { - contested_name: outcome.target.contested_name.clone(), - voter_id: outcome.target.key.voter_id, - choice: outcome.target.requested_choice, - unix_timestamp: scheduled_at, - executed_successfully: false, - }); + continue; } + outcome.status = DpnsVoteTargetStatus::Queued; + due = true; + in_progress.push(ScheduledDPNSVote { + contested_name: outcome.target.contested_name.clone(), + voter_id: outcome.target.key.voter_id, + choice: outcome.target.requested_choice, + unix_timestamp: scheduled_at, + executed_successfully: false, + }); } if due { due_operations.push(operation); @@ -831,6 +846,22 @@ fn scheduled_vote_is_due( && scheduled_at_ms.saturating_add(SCHEDULED_VOTE_MAX_LATENESS_MS) >= eligibility_cutoff_ms } +fn scheduled_target_should_execute( + status: DpnsVoteTargetStatus, + scheduled_at_ms: u64, + now_ms: u64, + preserve_eligibility_since_ms: Option, +) -> bool { + status == DpnsVoteTargetStatus::Queued + || (status == DpnsVoteTargetStatus::Scheduled + && scheduled_vote_is_due( + scheduled_at_ms, + false, + now_ms, + preserve_eligibility_since_ms, + )) +} + #[cfg(test)] mod tests { use super::*; @@ -928,6 +959,25 @@ mod tests { )); } + #[test] + fn queued_schedule_remains_eligible_for_redrive() { + let now_ms = 1_000_000; + let stale_scheduled_at = now_ms - SCHEDULED_VOTE_MAX_LATENESS_MS - 1; + + assert!(scheduled_target_should_execute( + DpnsVoteTargetStatus::Queued, + stale_scheduled_at, + now_ms, + None, + )); + assert!(!scheduled_target_should_execute( + DpnsVoteTargetStatus::Scheduled, + stale_scheduled_at, + now_ms, + None, + )); + } + /// Migration extends only eligibility windows that overlap its wait. #[test] fn migration_wait_preserves_only_overlapping_vote_eligibility() { diff --git a/src/context/dpns_vote_operations.rs b/src/context/dpns_vote_operations.rs index 4af47959c..dac29db49 100644 --- a/src/context/dpns_vote_operations.rs +++ b/src/context/dpns_vote_operations.rs @@ -4,7 +4,7 @@ use super::AppContext; use crate::backend_task::error::TaskError; use crate::model::dpns_voting::{ DpnsCurrentVoteState, DpnsVoteFailure, DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteTarget, - DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, + DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, unavailable_preflight_outcome, }; use crate::wallet_backend::{DetKv, DetScope, KvAdapterError}; use dash_sdk::dpp::dashcore::Network; @@ -526,8 +526,8 @@ impl AppContext { } } DpnsCurrentVoteState::Checking | DpnsCurrentVoteState::Unavailable => { - outcome.status = DpnsVoteTargetStatus::Unconfirmed; - outcome.failure = Some(DpnsVoteFailure::CurrentVoteUnavailable); + (outcome.status, outcome.failure) = + unavailable_preflight_outcome(outcome.target.timing); } } let still_queued = outcome.status == DpnsVoteTargetStatus::Queued; @@ -821,6 +821,24 @@ mod tests { ); } + #[test] + fn unavailable_preflight_preserves_retryability_by_timing() { + assert_eq!( + unavailable_preflight_outcome(VoteTiming::Scheduled(42)), + ( + DpnsVoteTargetStatus::Scheduled, + Some(DpnsVoteFailure::CurrentVoteUnavailable), + ) + ); + assert_eq!( + unavailable_preflight_outcome(VoteTiming::Now), + ( + DpnsVoteTargetStatus::FailedBeforeSubmission, + Some(DpnsVoteFailure::CurrentVoteUnavailable), + ) + ); + } + #[test] fn cancellation_does_not_overwrite_a_queued_target() { let kv = kv(); diff --git a/src/model/dpns_voting.rs b/src/model/dpns_voting.rs index badbc5383..69820c7a9 100644 --- a/src/model/dpns_voting.rs +++ b/src/model/dpns_voting.rs @@ -172,6 +172,16 @@ pub enum VoteTiming { Scheduled(TimestampMillis), } +pub(crate) fn unavailable_preflight_outcome( + timing: VoteTiming, +) -> (DpnsVoteTargetStatus, Option) { + let status = match timing { + VoteTiming::Scheduled(_) => DpnsVoteTargetStatus::Scheduled, + VoteTiming::Now => DpnsVoteTargetStatus::FailedBeforeSubmission, + }; + (status, Some(DpnsVoteFailure::CurrentVoteUnavailable)) +} + /// One reviewed node Γ— contest action. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DpnsVoteTarget { From dd2af6607047de58d9e7ac859e6875d2c87f0ac6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 17 Jul 2026 07:40:28 +0000 Subject: [PATCH 12/39] fix(dpns): keep voting views authoritative Refresh hidden voting state from correlated results, expose contest refresh, and derive node schedule outcomes from the operation journal. Co-Authored-By: Codex GPT-5 --- src/app.rs | 64 +++++++++++- src/backend_task/error.rs | 12 --- src/context/contested_names_db.rs | 62 +++++++++++- src/model/contested_name.rs | 8 +- src/ui/dpns/dpns_contested_names_screen.rs | 23 +---- src/ui/masternodes/card.rs | 17 +++- src/ui/masternodes/voting_center.rs | 111 +++++++++++++++++++++ 7 files changed, 252 insertions(+), 45 deletions(-) diff --git a/src/app.rs b/src/app.rs index bf5718288..23f2ba5bf 100644 --- a/src/app.rs +++ b/src/app.rs @@ -207,6 +207,17 @@ fn identity_hub_is_visible(selected: RootScreenType, screen_stack_is_empty: bool selected == RootScreenType::RootScreenIdentityHub && screen_stack_is_empty } +fn dpns_result_needs_hidden_masternode_route( + selected: RootScreenType, + screen_stack_is_empty: bool, + result: &BackendTaskSuccessResult, +) -> bool { + matches!( + result, + BackendTaskSuccessResult::DpnsVoteOperationUpdated { .. } + ) && (selected != RootScreenType::RootScreenMasternodes || !screen_stack_is_empty) +} + /// Plain, jargon-free descriptions for the SPV-sync block (Everyday-User rule: /// no "SPV"/"headers"/"masternodes"/raw heights/percentages β€” the jargon-free /// "Step N of 5" counter carries the granularity). Complete sentences (NFR-2). @@ -361,7 +372,7 @@ mod backend_task_join_tests { let unavailable_result = TaskError::ScheduledVoteSweepFailed { network: Network::Regtest, - source: Box::new(TaskError::ScheduledVoteResultUnavailable), + source: Box::new(TaskError::DpnsCurrentVoteUnavailable), }; assert!(!scheduled_vote_sweep_is_quiet(&unavailable_result)); @@ -1826,6 +1837,26 @@ impl AppState { } } + fn route_dpns_vote_result_to_hidden_masternodes( + &mut self, + context: &BackendTaskContext, + result: &BackendTaskSuccessResult, + ) { + if !dpns_result_needs_hidden_masternode_route( + self.selected_main_screen, + self.screen_stack.is_empty(), + result, + ) { + return; + } + if let Some(screen) = self + .main_screens + .get_mut(&RootScreenType::RootScreenMasternodes) + { + screen.display_backend_task_result(context, result.clone()); + } + } + /// Promote at most one queued passphrase request before overlay handling. fn activate_secret_prompt(&mut self, ctx: &egui::Context) { if self.active_secret_prompt.is_none() @@ -1969,6 +2000,7 @@ impl App for AppState { } => { let unboxed_message = *message; self.route_contact_request_result_to_hidden_hub(&unboxed_message); + self.route_dpns_vote_result_to_hidden_masternodes(&context, &unboxed_message); match unboxed_message { BackendTaskSuccessResult::None => {} BackendTaskSuccessResult::Refresh => { @@ -2894,6 +2926,36 @@ mod contact_request_routing_tests { } } +#[cfg(test)] +mod dpns_result_routing_tests { + use super::*; + use crate::model::dpns_voting::DpnsVoteOperationId; + + #[test] + fn correlated_vote_result_routes_when_masternodes_root_is_hidden() { + let result = BackendTaskSuccessResult::DpnsVoteOperationUpdated { + network: Network::Testnet, + operation_id: DpnsVoteOperationId::from_bytes([7; 16]), + }; + + assert!(dpns_result_needs_hidden_masternode_route( + RootScreenType::RootScreenWalletsBalances, + true, + &result, + )); + assert!(dpns_result_needs_hidden_masternode_route( + RootScreenType::RootScreenMasternodes, + false, + &result, + )); + assert!(!dpns_result_needs_hidden_masternode_route( + RootScreenType::RootScreenMasternodes, + true, + &result, + )); + } +} + #[cfg(test)] mod spv_overlay_tests { use super::*; diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 09ae71391..a60df03f9 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -591,18 +591,6 @@ pub enum TaskError { source: crate::wallet_backend::KvAdapterError, }, - /// Platform rejected a scheduled vote inside the otherwise successful - /// per-voter result payload. - #[error("The scheduled vote was not accepted. Wait a moment and try again.")] - ScheduledVoteRejected { - #[source] - source: std::sync::Arc, - }, - - /// The scheduled-vote call returned no per-voter verdict. - #[error("The scheduled vote result could not be confirmed. Wait a moment and try again.")] - ScheduledVoteResultUnavailable, - /// A periodic or post-migration scheduled-vote sweep failed. The network is /// structured context for the app's per-network retry bookkeeping. #[error("Scheduled votes could not be checked. Wait a moment and try again.")] diff --git a/src/context/contested_names_db.rs b/src/context/contested_names_db.rs index d3e2a0e0e..c3a887b5b 100644 --- a/src/context/contested_names_db.rs +++ b/src/context/contested_names_db.rs @@ -11,7 +11,9 @@ use crate::backend_task::error::TaskError; use crate::model::contested_name::{ ContestState, Contestant, ContestedName, MasternodeVoteStateSummary, }; -use crate::model::dpns_voting::DpnsCurrentVoteState; +use crate::model::dpns_voting::{ + DpnsCurrentVoteState, DpnsVoteOperation, DpnsVoteTargetStatus, VoteTiming, +}; use crate::wallet_backend::{DetScope, KvAdapterError}; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::data_contract::document_type::DocumentTypeRef; @@ -34,6 +36,32 @@ fn contested_name_key(normalized_name: &str) -> String { format!("{CONTESTED_NAME_KEY_PREFIX}{normalized_name}") } +fn scheduled_vote_journal_summary( + operations: &[DpnsVoteOperation], + voter_id: Identifier, +) -> (bool, bool) { + let mut pending = false; + let mut failed = false; + for outcome in operations.iter().flat_map(|operation| &operation.targets) { + if outcome.target.key.voter_id != voter_id + || !matches!(outcome.target.timing, VoteTiming::Scheduled(_)) + { + continue; + } + if matches!( + outcome.status, + DpnsVoteTargetStatus::Rejected + | DpnsVoteTargetStatus::FailedBeforeSubmission + | DpnsVoteTargetStatus::NotApplied + ) { + failed = true; + } else if outcome.status.holds_lock() { + pending = true; + } + } + (pending, failed) +} + /// Persisted shape of a single DPNS contest. Contenders are nested so /// the whole record reads atomically β€” pre-C6 stored them in a separate /// `contestant` table joined at load time. @@ -242,16 +270,15 @@ impl AppContext { .count(); let vote_state = vote_state_summary(&states); - let has_scheduled_vote = self - .get_scheduled_votes()? - .iter() - .any(|vote| vote.voter_id == voter_id && !vote.executed_successfully); + let (has_scheduled_vote, has_failed_scheduled_vote) = + scheduled_vote_journal_summary(&self.dpns_vote_operations()?, voter_id); Ok(crate::model::contested_name::MasternodeContestSummary { open_contest_count, needs_vote_count, vote_state, has_scheduled_vote, + has_failed_scheduled_vote, }) } @@ -594,4 +621,29 @@ mod tests { MasternodeVoteStateSummary::Unavailable ); } + + #[test] + fn terminal_schedule_failure_is_not_reported_as_pending() { + let voter_id = Identifier::from([7; 32]); + let mut operation = DpnsVoteOperation::new(vec![ + crate::model::dpns_voting::DpnsVoteTarget { + key: crate::model::dpns_voting::DpnsVoteTargetKey { + network: Network::Testnet, + voter_id, + vote_poll_id: Identifier::from([8; 32]), + }, + voter_alias: Some("Eve".to_owned()), + contested_name: "dominguez".to_owned(), + requested_choice: dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice::Lock, + current_choice: None, + timing: VoteTiming::Scheduled(42), + }, + ]); + operation.targets[0].status = DpnsVoteTargetStatus::FailedBeforeSubmission; + + assert_eq!( + scheduled_vote_journal_summary(&[operation], voter_id), + (false, true) + ); + } } diff --git a/src/model/contested_name.rs b/src/model/contested_name.rs index 64ddd24ed..0ec6ed1af 100644 --- a/src/model/contested_name.rs +++ b/src/model/contested_name.rs @@ -45,8 +45,8 @@ impl ContestedName { /// /// Composed by a display-layer read of existing contest + scheduled-vote state /// (no new backend concept). Feeds the count-first status line: open contests -/// take precedence, then a pending scheduled vote, then "no open contests" -/// (requirements Β§10.1). +/// take precedence, then failed or pending scheduled votes, then no open +/// contests (requirements Β§10.1). #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum MasternodeVoteStateSummary { #[default] @@ -64,8 +64,10 @@ pub struct MasternodeContestSummary { /// Whether every active contest has a proved current-vote state. pub vote_state: MasternodeVoteStateSummary, /// Whether the node has at least one pending (not-yet-executed) scheduled - /// vote, reusing the DPNS Scheduled Votes screen's existing state. + /// vote in the authoritative operation journal. pub has_scheduled_vote: bool, + /// Whether a scheduled target reached a terminal failure that needs review. + pub has_failed_scheduled_vote: bool, } impl MasternodeContestSummary { diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index 9cd5355e0..09a24f14f 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -1945,12 +1945,7 @@ impl ScreenLike for DPNSScreen { fn display_task_error(&mut self, error: &TaskError) -> bool { let handled = scheduled_vote_sweep_is_quiet(error); - if matches!( - error, - TaskError::ScheduledVoteRejected { .. } - | TaskError::ScheduledVoteResultUnavailable - | TaskError::ScheduledVoteSweepFailed { .. } - ) { + if matches!(error, TaskError::ScheduledVoteSweepFailed { .. }) { self.scheduled_vote_cast_in_progress = false; if let Ok(mut guard) = self.scheduled_votes.lock() { for vote in guard.iter_mut() { @@ -2275,20 +2270,4 @@ mod tests { assert!(screen.display_task_error(&error)); assert!(!screen.scheduled_vote_cast_in_progress); } - - #[test] - fn direct_scheduled_vote_error_remains_available_to_global_handling() { - let (ctx, _temp_dir) = offline_ctx(); - let mut screen = DPNSScreen::new(&ctx, DPNSSubscreen::ScheduledVotes); - - screen.scheduled_vote_cast_in_progress = true; - assert!(!screen.display_task_error(&TaskError::ScheduledVoteResultUnavailable)); - assert!(!screen.scheduled_vote_cast_in_progress); - - let sweep_error = TaskError::ScheduledVoteSweepFailed { - network: Network::Regtest, - source: Box::new(TaskError::ScheduledVoteResultUnavailable), - }; - assert!(!screen.display_task_error(&sweep_error)); - } } diff --git a/src/ui/masternodes/card.rs b/src/ui/masternodes/card.rs index 6c232ea11..6cd753c9b 100644 --- a/src/ui/masternodes/card.rs +++ b/src/ui/masternodes/card.rs @@ -55,8 +55,8 @@ pub fn voter_readiness_label(voting_present: bool) -> &'static str { } } -/// DPNS status line with count-first precedence (Β§10.1): open contests first -/// (actionable), then a pending scheduled vote, then none. +/// DPNS status line with count-first precedence (Β§10.1): open contests first, +/// then failed or pending scheduled votes, then none. pub fn dpns_status_line(summary: MasternodeContestSummary) -> String { if summary.vote_state == MasternodeVoteStateSummary::Unavailable { if summary.open_contest_count == 0 { @@ -86,6 +86,8 @@ pub fn dpns_status_line(summary: MasternodeContestSummary) -> String { summary.open_contest_count, summary.needs_vote_count ) } + } else if summary.has_failed_scheduled_vote { + "Scheduled vote needs attention".to_string() } else if summary.has_scheduled_vote { "Vote scheduled".to_string() } else { @@ -438,6 +440,17 @@ mod tests { assert_eq!(dpns_status_line(summary), "Vote scheduled"); } + #[test] + fn terminal_scheduled_failure_needs_attention() { + let summary = MasternodeContestSummary { + has_scheduled_vote: false, + has_failed_scheduled_vote: true, + ..Default::default() + }; + + assert_eq!(dpns_status_line(summary), "Scheduled vote needs attention"); + } + #[test] fn dpns_status_reports_checking_instead_of_all_votes_cast() { let summary = MasternodeContestSummary { diff --git a/src/ui/masternodes/voting_center.rs b/src/ui/masternodes/voting_center.rs index 203b07f8b..bee519488 100644 --- a/src/ui/masternodes/voting_center.rs +++ b/src/ui/masternodes/voting_center.rs @@ -64,6 +64,15 @@ impl DpnsVotingCenter { context: &BackendTaskContext, result: &BackendTaskSuccessResult, ) { + if matches!(result, BackendTaskSuccessResult::RefreshedDpnsContests) { + self.vote_state_refresh_dispatched = false; + match self.app_context.ongoing_contested_names() { + Ok(contests) => self.contests = contests, + Err(error) => { + tracing::warn!(?error, "Could not reload refreshed DPNS contests"); + } + } + } self.submitted_operation = updated_submitted_operation(self.submitted_operation, context, result); } @@ -313,6 +322,16 @@ impl DpnsVotingCenter { self.step_heading(ui, "Step 2 of 3: Votes"); ui.label("Choose one requested vote for each contested name."); let mut outcome = VotingCenterOutcome::None; + if self.contests.is_empty() { + ui.separator(); + ui.label("No active contests are available. Refresh contests to check again."); + if ComponentStyles::add_secondary_button(ui, "Refresh contests", dark_mode).clicked() { + self.vote_state_refresh_dispatched = true; + outcome = VotingCenterOutcome::Action(Box::new(AppAction::BackendTask( + BackendTask::ContestedResourceTask(ContestedResourceTask::QueryDPNSContests), + ))); + } + } for contest in &self.contests { let name = &contest.normalized_contested_name; ui.separator(); @@ -1175,7 +1194,45 @@ fn status_explanation(status: DpnsVoteTargetStatus) -> &'static str { #[cfg(test)] mod tests { use super::*; + use crate::app::TaskResult; + use crate::app_dir::ensure_env_file; + use crate::context::connection_status::ConnectionStatus; + use crate::database::test_helpers::create_database_at_path; use crate::model::contested_name::Contestant; + use crate::model::user_role::UserRoleCell; + use crate::utils::egui_mpsc::SenderAsync; + use crate::utils::tasks::TaskManager; + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + use std::cell::{Cell, RefCell}; + use std::rc::Rc; + + async fn offline_ctx() -> (Arc, tempfile::TempDir) { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let data_dir = temp_dir.path().to_path_buf(); + ensure_env_file(&data_dir); + let db = Arc::new(create_database_at_path(&data_dir.join("data.db")).expect("db")); + let app_kv = AppContext::open_app_kv(&data_dir).expect("app kv"); + let secret_store = AppContext::open_secret_store(&data_dir).expect("secret store"); + let context = AppContext::new( + data_dir, + dash_sdk::dpp::dashcore::Network::Testnet, + db, + Arc::new(TaskManager::new()), + Arc::new(ConnectionStatus::new()), + egui::Context::default(), + app_kv, + secret_store, + UserRoleCell::default(), + ) + .expect("offline AppContext"); + let (sender, _receiver) = tokio::sync::mpsc::channel::(32); + context + .ensure_wallet_backend(SenderAsync::new(sender, context.egui_ctx().clone())) + .await + .expect("wire wallet backend offline"); + (context, temp_dir) + } fn contestant(id: Identifier, name: &str) -> Contestant { Contestant { @@ -1342,4 +1399,58 @@ mod tests { Some(stored) ); } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn refreshed_contests_replace_the_voting_center_snapshot() { + let (context, _temp_dir) = offline_ctx().await; + let mut center = DpnsVotingCenter::new(&context, None, Vec::new()); + assert!(center.contests.is_empty()); + context + .insert_name_contests_as_normalized_names(vec!["dominguez".to_owned()]) + .expect("seed refreshed contest"); + center.vote_state_refresh_dispatched = true; + + center.display_backend_task_result( + &BackendTaskContext::Other, + &BackendTaskSuccessResult::RefreshedDpnsContests, + ); + + assert_eq!(center.contests.len(), 1); + assert_eq!(center.contests[0].normalized_contested_name, "dominguez"); + assert!(!center.vote_state_refresh_dispatched); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn empty_votes_step_offers_a_contest_refresh_action() { + let (context, _temp_dir) = offline_ctx().await; + let center = Rc::new(RefCell::new(DpnsVotingCenter::new( + &context, + None, + Vec::new(), + ))); + let dispatched = Rc::new(Cell::new(false)); + let center_for_ui = Rc::clone(¢er); + let dispatched_for_ui = Rc::clone(&dispatched); + let mut harness = Harness::builder() + .with_size(egui::vec2(700.0, 400.0)) + .build_ui(move |ui| { + if matches!( + center_for_ui.borrow_mut().render_votes(ui), + VotingCenterOutcome::Action(action) + if matches!( + *action, + AppAction::BackendTask(BackendTask::ContestedResourceTask( + ContestedResourceTask::QueryDPNSContests + )) + ) + ) { + dispatched_for_ui.set(true); + } + }); + + harness.get_by_label("Refresh contests").click(); + harness.run(); + + assert!(dispatched.get()); + } } From d6db6cdbd4e516798a984896944d7ecce35ee4b1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 17 Jul 2026 07:59:29 +0000 Subject: [PATCH 13/39] fix(dpns): preserve scheduled vote recovery --- src/app.rs | 10 ++ src/backend_task/contested_names/mod.rs | 120 ++++++++++++++++++++---- src/context/contested_names_db.rs | 102 ++++++++++++++++---- src/context/dpns_vote_operations.rs | 72 ++++++++------ src/context/mod.rs | 3 +- src/model/dpns_voting.rs | 10 ++ 6 files changed, 250 insertions(+), 67 deletions(-) diff --git a/src/app.rs b/src/app.rs index 23f2ba5bf..83fc26d1f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -215,6 +215,7 @@ fn dpns_result_needs_hidden_masternode_route( matches!( result, BackendTaskSuccessResult::DpnsVoteOperationUpdated { .. } + | BackendTaskSuccessResult::RefreshedDpnsContests ) && (selected != RootScreenType::RootScreenMasternodes || !screen_stack_is_empty) } @@ -2954,6 +2955,15 @@ mod dpns_result_routing_tests { &result, )); } + + #[test] + fn refreshed_contests_route_when_masternodes_root_is_hidden() { + assert!(dpns_result_needs_hidden_masternode_route( + RootScreenType::RootScreenWalletsBalances, + true, + &BackendTaskSuccessResult::RefreshedDpnsContests, + )); + } } #[cfg(test)] diff --git a/src/backend_task/contested_names/mod.rs b/src/backend_task/contested_names/mod.rs index 945362ae1..335a81d03 100644 --- a/src/backend_task/contested_names/mod.rs +++ b/src/backend_task/contested_names/mod.rs @@ -9,7 +9,8 @@ use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::dpns_voting::{ DpnsCurrentVoteState, DpnsVoteFailure, DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteTarget, - DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, unavailable_preflight_outcome, + DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, failed_before_broadcast_outcome, + unavailable_preflight_outcome, }; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::request_type::RequestType; @@ -69,6 +70,7 @@ pub struct ScheduledDPNSVote { fn classify_vote_attempt( attempt: &Result, + timing: VoteTiming, ) -> (DpnsVoteTargetStatus, Option) { match attempt { Ok(vote_on_dpns_name::DpnsVoteAttempt::Confirmed) => { @@ -82,10 +84,7 @@ fn classify_vote_attempt( DpnsVoteTargetStatus::Rejected, Some(DpnsVoteFailure::PlatformRejected), ), - Err(_) => ( - DpnsVoteTargetStatus::FailedBeforeSubmission, - Some(DpnsVoteFailure::SubmissionFailed), - ), + Err(_) => failed_before_broadcast_outcome(timing), } } @@ -147,6 +146,11 @@ impl AppContext { sdk: &Sdk, sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { + let is_scheduled_sweep = + matches!(&task, ContestedResourceTask::CastDueScheduledVotes { .. }); + if !is_scheduled_sweep { + self.ensure_dpns_vote_recovery(sdk).await?; + } match task { ContestedResourceTask::QueryDPNSContests => self .query_dpns_contested_resources(sdk, sender) @@ -510,11 +514,12 @@ impl AppContext { &sdk, ) .await; - let (status, failure) = classify_vote_attempt(&attempt); + let (status, failure) = classify_vote_attempt(&attempt, target.timing); let confirmed = matches!( &attempt, Ok(vote_on_dpns_name::DpnsVoteAttempt::Confirmed) ); + let mut retryable_scheduled_error = None; match attempt { Ok(vote_on_dpns_name::DpnsVoteAttempt::Confirmed) => { } @@ -551,11 +556,15 @@ impl AppContext { contested_name = %target.contested_name, "DPNS vote failed before a confirmed submission" ); - app_context.record_dpns_vote_diagnostic( - operation_id, - target.key.clone(), - error, - ); + if matches!(target.timing, VoteTiming::Scheduled(_)) { + retryable_scheduled_error = Some(error); + } else { + app_context.record_dpns_vote_diagnostic( + operation_id, + target.key.clone(), + error, + ); + } } } let mirror_error = persist_terminal_then_legacy_mirror( @@ -596,6 +605,9 @@ impl AppContext { target.requested_choice, )?; } + if let Some(error) = retryable_scheduled_error { + return Err(error); + } } Ok(()) } @@ -821,6 +833,20 @@ impl AppContext { "Failed to execute a due DPNS vote operation; leaving it for recovery" ); first_error.get_or_insert(error); + if let Err(recovery_error) = + self.recover_interrupted_dpns_vote_operation(operation_id) + { + tracing::error!( + error = %recovery_error, + operation_id = %operation_id, + "Failed to recover an interrupted DPNS vote operation" + ); + // Let the next contested task retry global recovery after + // storage becomes available again. The targeted attempt is + // preferred because this fallback can see unrelated work. + *self.dpns_vote_recovery.lock().await = false; + first_error.get_or_insert(recovery_error); + } } } if let Some(error) = first_error { @@ -874,7 +900,7 @@ mod tests { TaskError::DpnsVoteTargetBusy, )); assert_eq!( - classify_vote_attempt(&attempt), + classify_vote_attempt(&attempt, VoteTiming::Scheduled(42)), ( DpnsVoteTargetStatus::Rejected, Some(DpnsVoteFailure::PlatformRejected) @@ -888,7 +914,7 @@ mod tests { let attempt = Ok(vote_on_dpns_name::DpnsVoteAttempt::Unconfirmed( TaskError::DpnsVoteTargetBusy, )); - let (status, _) = classify_vote_attempt(&attempt); + let (status, _) = classify_vote_attempt(&attempt, VoteTiming::Scheduled(42)); assert_eq!(status, DpnsVoteTargetStatus::Unconfirmed); assert!(status.holds_lock()); } @@ -942,23 +968,77 @@ mod tests { } } - #[test] - fn scheduled_sweep_wraps_recovery_failures() { - let error = wrap_scheduled_vote_sweep_result::( + #[tokio::test] + async fn scheduled_sweep_dispatch_wraps_recovery_failures() { + use crate::app::TaskResult; + use crate::app_dir::ensure_env_file; + use crate::context::connection_status::ConnectionStatus; + use crate::database::test_helpers::create_database_at_path; + use crate::utils::egui_mpsc::SenderAsync; + use crate::utils::tasks::TaskManager; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let data_dir = temp_dir.path().to_path_buf(); + ensure_env_file(&data_dir); + let database = + Arc::new(create_database_at_path(&data_dir.join("data.db")).expect("test database")); + let app_kv = AppContext::open_app_kv(&data_dir).expect("app kv"); + let secret_store = AppContext::open_secret_store(&data_dir).expect("secret store"); + let context = AppContext::new( + data_dir, Network::Testnet, - Err(TaskError::DpnsCurrentVoteUnavailable), + database, + Arc::new(TaskManager::new()), + Arc::new(ConnectionStatus::new()), + egui::Context::default(), + app_kv, + secret_store, + crate::model::user_role::UserRoleCell::default(), ) - .expect_err("a recovery failure must fail the scheduled sweep"); + .expect("offline testnet AppContext"); + let (tx, _rx) = tokio::sync::mpsc::channel::(1); + let sender = SenderAsync::new(tx, context.egui_ctx().clone()); + + let error = context + .run_contested_resource_task( + ContestedResourceTask::CastDueScheduledVotes { + preserve_eligibility_since_ms: None, + }, + &context.sdk(), + sender, + ) + .await + .expect_err("an unwired recovery must fail the scheduled sweep"); assert!(matches!( error, TaskError::ScheduledVoteSweepFailed { network: Network::Testnet, - source, - } if matches!(source.as_ref(), TaskError::DpnsCurrentVoteUnavailable) + .. + } )); } + #[test] + fn scheduled_pre_broadcast_failure_remains_retryable() { + let attempt = Err(TaskError::DpnsVoteTargetBusy); + + assert_eq!( + classify_vote_attempt(&attempt, VoteTiming::Scheduled(42)), + ( + DpnsVoteTargetStatus::Scheduled, + Some(DpnsVoteFailure::SubmissionFailed), + ) + ); + assert_eq!( + classify_vote_attempt(&attempt, VoteTiming::Now), + ( + DpnsVoteTargetStatus::FailedBeforeSubmission, + Some(DpnsVoteFailure::SubmissionFailed), + ) + ); + } + #[test] fn queued_schedule_remains_eligible_for_redrive() { let now_ms = 1_000_000; diff --git a/src/context/contested_names_db.rs b/src/context/contested_names_db.rs index c3a887b5b..e95c0c0f3 100644 --- a/src/context/contested_names_db.rs +++ b/src/context/contested_names_db.rs @@ -40,25 +40,37 @@ fn scheduled_vote_journal_summary( operations: &[DpnsVoteOperation], voter_id: Identifier, ) -> (bool, bool) { - let mut pending = false; - let mut failed = false; - for outcome in operations.iter().flat_map(|operation| &operation.targets) { - if outcome.target.key.voter_id != voter_id - || !matches!(outcome.target.timing, VoteTiming::Scheduled(_)) - { - continue; - } - if matches!( + let latest_by_target = operations + .iter() + .flat_map(|operation| { + operation + .targets + .iter() + .map(move |outcome| (operation.created_at, outcome)) + }) + .filter(|(_, outcome)| { + outcome.target.key.voter_id == voter_id + && matches!(outcome.target.timing, VoteTiming::Scheduled(_)) + }) + .fold(BTreeMap::new(), |mut latest, (created_at, outcome)| { + let entry = latest + .entry(&outcome.target.key) + .or_insert((created_at, outcome)); + if created_at >= entry.0 { + *entry = (created_at, outcome); + } + latest + }); + + let pending = latest_by_target + .values() + .any(|(_, outcome)| outcome.status.holds_lock()); + let failed = latest_by_target.values().any(|(_, outcome)| { + matches!( outcome.status, - DpnsVoteTargetStatus::Rejected - | DpnsVoteTargetStatus::FailedBeforeSubmission - | DpnsVoteTargetStatus::NotApplied - ) { - failed = true; - } else if outcome.status.holds_lock() { - pending = true; - } - } + DpnsVoteTargetStatus::Rejected | DpnsVoteTargetStatus::FailedBeforeSubmission + ) + }); (pending, failed) } @@ -646,4 +658,58 @@ mod tests { (false, true) ); } + + #[test] + fn cancelled_schedule_is_not_reported_as_failed() { + let voter_id = Identifier::from([7; 32]); + let mut operation = DpnsVoteOperation::new(vec![ + crate::model::dpns_voting::DpnsVoteTarget { + key: crate::model::dpns_voting::DpnsVoteTargetKey { + network: Network::Testnet, + voter_id, + vote_poll_id: Identifier::from([8; 32]), + }, + voter_alias: Some("Eve".to_owned()), + contested_name: "dominguez".to_owned(), + requested_choice: dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice::Lock, + current_choice: None, + timing: VoteTiming::Scheduled(42), + }, + ]); + operation.targets[0].status = DpnsVoteTargetStatus::NotApplied; + + assert_eq!( + scheduled_vote_journal_summary(&[operation], voter_id), + (false, false) + ); + } + + #[test] + fn later_success_supersedes_historical_schedule_failure() { + let voter_id = Identifier::from([7; 32]); + let target = crate::model::dpns_voting::DpnsVoteTarget { + key: crate::model::dpns_voting::DpnsVoteTargetKey { + network: Network::Testnet, + voter_id, + vote_poll_id: Identifier::from([8; 32]), + }, + voter_alias: Some("Eve".to_owned()), + contested_name: "dominguez".to_owned(), + requested_choice: + dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice::Lock, + current_choice: None, + timing: VoteTiming::Scheduled(42), + }; + let mut failed = DpnsVoteOperation::new(vec![target.clone()]); + failed.created_at = 1; + failed.targets[0].status = DpnsVoteTargetStatus::Rejected; + let mut confirmed = DpnsVoteOperation::new(vec![target]); + confirmed.created_at = 2; + confirmed.targets[0].status = DpnsVoteTargetStatus::Confirmed; + + assert_eq!( + scheduled_vote_journal_summary(&[failed, confirmed], voter_id), + (false, false) + ); + } } diff --git a/src/context/dpns_vote_operations.rs b/src/context/dpns_vote_operations.rs index dac29db49..d5040ad6a 100644 --- a/src/context/dpns_vote_operations.rs +++ b/src/context/dpns_vote_operations.rs @@ -300,6 +300,21 @@ fn cancel_all_scheduled_targets(kv: &DetKv, network: Network) -> Result bool { + let mut changed = false; + for outcome in &mut operation.targets { + if matches!( + outcome.status, + DpnsVoteTargetStatus::Submitting | DpnsVoteTargetStatus::Confirming + ) { + outcome.status = DpnsVoteTargetStatus::Unconfirmed; + outcome.failure = Some(DpnsVoteFailure::ResultUnconfirmed); + changed = true; + } + } + changed +} + impl AppContext { /// Durably claim one due scheduled target before any executor can observe it. pub(crate) fn queue_scheduled_dpns_vote_target( @@ -543,24 +558,35 @@ impl AppContext { .unwrap_or_else(std::sync::PoisonError::into_inner); let kv = self.det_kv()?; for mut operation in load_operations(&kv, self.network)? { - let mut changed = false; - for outcome in &mut operation.targets { - if matches!( - outcome.status, - DpnsVoteTargetStatus::Submitting | DpnsVoteTargetStatus::Confirming - ) { - outcome.status = DpnsVoteTargetStatus::Unconfirmed; - outcome.failure = Some(DpnsVoteFailure::ResultUnconfirmed); - changed = true; - } - } - if changed { + if mark_interrupted_targets_unconfirmed(&mut operation) { persist_operation(&kv, self.network, &operation)?; } } Ok(()) } + /// Conservatively recover one operation after its executor has returned. + pub(crate) fn recover_interrupted_dpns_vote_operation( + &self, + operation_id: DpnsVoteOperationId, + ) -> Result<(), TaskError> { + let _guard = self + .dpns_vote_operation_guard + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let kv = self.det_kv()?; + let Some(mut operation): Option = kv + .get(DetScope::Global, &operation_key(self.network, operation_id)) + .map_err(unreadable_operation_err)? + else { + return Ok(()); + }; + if mark_interrupted_targets_unconfirmed(&mut operation) { + persist_operation(&kv, self.network, &operation)?; + } + Ok(()) + } + pub(crate) fn record_dpns_vote_diagnostic( &self, operation_id: DpnsVoteOperationId, @@ -984,25 +1010,15 @@ mod tests { #[test] fn interrupted_submission_recovers_to_unconfirmed() { - let kv = kv(); let mut operation = operation(DpnsVoteTargetStatus::Submitting); - persist_operation(&kv, Network::Testnet, &operation).unwrap(); - - for outcome in &mut operation.targets { - if matches!( - outcome.status, - DpnsVoteTargetStatus::Submitting | DpnsVoteTargetStatus::Confirming - ) { - outcome.status = DpnsVoteTargetStatus::Unconfirmed; - outcome.failure = Some(DpnsVoteFailure::ResultUnconfirmed); - } - } - persist_operation(&kv, Network::Testnet, &operation).unwrap(); - - let restored = load_operations(&kv, Network::Testnet).unwrap(); + assert!(mark_interrupted_targets_unconfirmed(&mut operation)); assert_eq!( - restored[0].targets[0].status, + operation.targets[0].status, DpnsVoteTargetStatus::Unconfirmed ); + assert_eq!( + operation.targets[0].failure, + Some(DpnsVoteFailure::ResultUnconfirmed) + ); } } diff --git a/src/context/mod.rs b/src/context/mod.rs index 121389fc0..427ec7219 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -223,7 +223,8 @@ pub struct AppContext { /// Serializes all nonce-consuming vote submissions per voter across tasks, /// while bounding unrelated voters globally. pub(crate) dpns_vote_dispatch: DpnsVoteDispatchCoordinator, - /// Runs crash recovery exactly once before this context accepts vote work. + /// Runs crash recovery before this context first accepts vote work. + /// Re-armed only if targeted recovery cannot persist after an executor error. pub(crate) dpns_vote_recovery: tokio::sync::Mutex, /// Full in-process diagnostics keyed to sanitized durable outcomes. dpns_vote_diagnostics: diff --git a/src/model/dpns_voting.rs b/src/model/dpns_voting.rs index 69820c7a9..bd3dd0664 100644 --- a/src/model/dpns_voting.rs +++ b/src/model/dpns_voting.rs @@ -182,6 +182,16 @@ pub(crate) fn unavailable_preflight_outcome( (status, Some(DpnsVoteFailure::CurrentVoteUnavailable)) } +pub(crate) fn failed_before_broadcast_outcome( + timing: VoteTiming, +) -> (DpnsVoteTargetStatus, Option) { + let status = match timing { + VoteTiming::Scheduled(_) => DpnsVoteTargetStatus::Scheduled, + VoteTiming::Now => DpnsVoteTargetStatus::FailedBeforeSubmission, + }; + (status, Some(DpnsVoteFailure::SubmissionFailed)) +} + /// One reviewed node Γ— contest action. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DpnsVoteTarget { From 4b508782e401435121f696f43794cf1bae415c0e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 17 Jul 2026 08:06:20 +0000 Subject: [PATCH 14/39] fix(dpns): retry schedules with missing voters --- src/backend_task/contested_names/mod.rs | 30 +++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/backend_task/contested_names/mod.rs b/src/backend_task/contested_names/mod.rs index 335a81d03..be9c9a098 100644 --- a/src/backend_task/contested_names/mod.rs +++ b/src/backend_task/contested_names/mod.rs @@ -88,6 +88,13 @@ fn classify_vote_attempt( } } +fn missing_voter_outcome( + timing: VoteTiming, +) -> (DpnsVoteTargetStatus, Option, bool) { + let (status, failure) = failed_before_broadcast_outcome(timing); + (status, failure, matches!(timing, VoteTiming::Scheduled(_))) +} + fn classify_reconciled_vote( observed: Option, requested: ResourceVoteChoice, @@ -486,16 +493,23 @@ impl AppContext { let operation_id = operation.id; async move { let Some(voter) = voter else { + let mut scheduled_voter_missing = false; for target in targets { if app_context.claim_dpns_vote_target(operation_id, &target.key)? { + let (status, failure, retryable) = + missing_voter_outcome(target.timing); app_context.update_dpns_vote_target( operation_id, &target.key, - DpnsVoteTargetStatus::FailedBeforeSubmission, - Some(DpnsVoteFailure::SubmissionFailed), + status, + failure, )?; + scheduled_voter_missing |= retryable; } } + if scheduled_voter_missing { + return Err(TaskError::IdentityNotFoundLocally); + } return Ok::<(), TaskError>(()); }; @@ -1039,6 +1053,18 @@ mod tests { ); } + #[test] + fn scheduled_missing_voter_remains_retryable() { + assert_eq!( + missing_voter_outcome(VoteTiming::Scheduled(42)), + ( + DpnsVoteTargetStatus::Scheduled, + Some(DpnsVoteFailure::SubmissionFailed), + true, + ) + ); + } + #[test] fn queued_schedule_remains_eligible_for_redrive() { let now_ms = 1_000_000; From 2936e6d32f3ce0304b0f7e43e06ead1667399247 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:13:48 +0000 Subject: [PATCH 15/39] fix(dpns): bound vote operation journal growth --- src/context/dpns_vote_operations.rs | 354 +++++++++++++++++++++++++--- src/context/identity_db.rs | 1 + src/context/mod.rs | 9 +- 3 files changed, 331 insertions(+), 33 deletions(-) diff --git a/src/context/dpns_vote_operations.rs b/src/context/dpns_vote_operations.rs index d5040ad6a..b8737c200 100644 --- a/src/context/dpns_vote_operations.rs +++ b/src/context/dpns_vote_operations.rs @@ -8,12 +8,20 @@ use crate::model::dpns_voting::{ }; use crate::wallet_backend::{DetKv, DetScope, KvAdapterError}; use dash_sdk::dpp::dashcore::Network; +use std::collections::BTreeMap; use std::sync::Arc; const LEGACY_OPERATION_INDEX_KEY: &str = "det:dpns_vote_operations:v1"; const LEGACY_OPERATION_KEY_PREFIX: &str = "det:dpns_vote_operation:v1:"; const OPERATION_INDEX_KEY_PREFIX: &str = "det:dpns_vote_operations:v2:"; const OPERATION_KEY_PREFIX: &str = "det:dpns_vote_operation:v2:"; +const OPERATION_LOCK_INDEX_KEY_PREFIX: &str = "det:dpns_vote_operation_locks:v2:"; +const OPERATION_LOCK_INDEX_DIRTY_KEY_PREFIX: &str = "det:dpns_vote_operation_locks_dirty:v2:"; +const DPNS_VOTE_DIAGNOSTIC_LIMIT: usize = 256; + +type DpnsVoteLockIndex = BTreeMap; +type DpnsVoteDiagnosticMap = + BTreeMap<(DpnsVoteOperationId, DpnsVoteTargetKey), (u64, Arc)>; fn network_tag(network: Network) -> &'static str { match network { @@ -32,6 +40,21 @@ fn operation_key(network: Network, id: DpnsVoteOperationId) -> String { format!("{OPERATION_KEY_PREFIX}{}:{id}", network_tag(network)) } +fn operation_key_prefix(network: Network) -> String { + format!("{OPERATION_KEY_PREFIX}{}:", network_tag(network)) +} + +fn operation_lock_index_key(network: Network) -> String { + format!("{OPERATION_LOCK_INDEX_KEY_PREFIX}{}", network_tag(network)) +} + +fn operation_lock_index_dirty_key(network: Network) -> String { + format!( + "{OPERATION_LOCK_INDEX_DIRTY_KEY_PREFIX}{}", + network_tag(network) + ) +} + fn legacy_operation_key(id: DpnsVoteOperationId) -> String { format!("{LEGACY_OPERATION_KEY_PREFIX}{id}") } @@ -81,8 +104,10 @@ fn migrate_legacy_operations(kv: &DetKv, network: Network) -> Result<(), TaskErr } let mut qualified_ids = load_operation_ids(kv, network)?; - let mut changed = false; - for bytes in legacy_ids { + let mut qualified_changed = false; + let mut retained_legacy_ids = Vec::new(); + for bytes in &legacy_ids { + let bytes = *bytes; let id = DpnsVoteOperationId::from_bytes(bytes); if qualified_ids.contains(&bytes) { continue; @@ -91,15 +116,26 @@ fn migrate_legacy_operations(kv: &DetKv, network: Network) -> Result<(), TaskErr .get(DetScope::Global, &legacy_operation_key(id)) .map_err(unreadable_operation_err)? .ok_or(TaskError::DpnsVoteOperationRecordMissing)?; - if !operation_matches_network(&operation, network)? { - continue; + match operation_matches_network(&operation, network) { + Ok(false) => continue, + Err(error) => { + retained_legacy_ids.push(bytes); + return Err(error); + } + Ok(true) => {} } + kv.put( + DetScope::Global, + &operation_lock_index_dirty_key(network), + &true, + ) + .map_err(operation_err)?; kv.put(DetScope::Global, &operation_key(network, id), &operation) .map_err(operation_err)?; qualified_ids.push(bytes); - changed = true; + qualified_changed = true; } - if changed { + if qualified_changed { kv.put( DetScope::Global, &operation_index_key(network), @@ -107,11 +143,24 @@ fn migrate_legacy_operations(kv: &DetKv, network: Network) -> Result<(), TaskErr ) .map_err(operation_err)?; } + if retained_legacy_ids.len() != legacy_ids.len() { + kv.put( + DetScope::Global, + LEGACY_OPERATION_INDEX_KEY, + &retained_legacy_ids, + ) + .map_err(operation_err)?; + } + if qualified_changed { + rebuild_lock_index(kv, network)?; + } Ok(()) } -fn load_operations(kv: &DetKv, network: Network) -> Result, TaskError> { - migrate_legacy_operations(kv, network)?; +fn load_operations_read_only( + kv: &DetKv, + network: Network, +) -> Result, TaskError> { let mut operations = Vec::new(); for bytes in load_operation_ids(kv, network)? { let id = DpnsVoteOperationId::from_bytes(bytes); @@ -126,6 +175,75 @@ fn load_operations(kv: &DetKv, network: Network) -> Result Result, TaskError> { + migrate_legacy_operations(kv, network)?; + load_or_rebuild_lock_index(kv, network)?; + load_operations_read_only(kv, network) +} + +fn rebuild_lock_index(kv: &DetKv, network: Network) -> Result { + kv.put( + DetScope::Global, + &operation_lock_index_dirty_key(network), + &true, + ) + .map_err(operation_err)?; + let mut ids = Vec::new(); + let mut locks = DpnsVoteLockIndex::new(); + for key in kv + .list(DetScope::Global, Some(&operation_key_prefix(network))) + .map_err(unreadable_operation_err)? + { + let operation: DpnsVoteOperation = kv + .get(DetScope::Global, &key) + .map_err(unreadable_operation_err)? + .ok_or(TaskError::DpnsVoteOperationRecordMissing)?; + if !operation_matches_network(&operation, network)? { + continue; + } + ids.push(operation.id.to_bytes()); + for outcome in operation + .targets + .iter() + .filter(|outcome| outcome.status.holds_lock()) + { + if locks + .insert(outcome.target.key.clone(), operation.id) + .is_some_and(|owner| owner != operation.id) + { + return Err(TaskError::DpnsVoteTargetBusy); + } + } + } + ids.sort_unstable(); + ids.dedup(); + kv.put(DetScope::Global, &operation_index_key(network), &ids) + .map_err(operation_err)?; + kv.put(DetScope::Global, &operation_lock_index_key(network), &locks) + .map_err(operation_err)?; + kv.delete(DetScope::Global, &operation_lock_index_dirty_key(network)) + .map_err(operation_err)?; + Ok(locks) +} + +fn load_or_rebuild_lock_index( + kv: &DetKv, + network: Network, +) -> Result { + let dirty = kv + .get::(DetScope::Global, &operation_lock_index_dirty_key(network)) + .map_err(unreadable_operation_err)? + .unwrap_or(false); + if !dirty + && let Some(index) = kv + .get(DetScope::Global, &operation_lock_index_key(network)) + .map_err(unreadable_operation_err)? + { + return Ok(index); + } + rebuild_lock_index(kv, network) +} + fn persist_operation( kv: &DetKv, network: Network, @@ -134,32 +252,53 @@ fn persist_operation( if !operation_matches_network(operation, network)? { return Err(TaskError::DpnsVoteJournalNetworkMismatch); } - let conflict = load_operations(kv, network)?.iter().any(|existing| { - existing.id != operation.id - && existing.targets.iter().any(|existing_outcome| { - existing_outcome.status.holds_lock() - && operation.targets.iter().any(|outcome| { - outcome.status.holds_lock() - && outcome.target.key == existing_outcome.target.key - }) - }) - }); - if conflict { - return Err(TaskError::DpnsVoteTargetBusy); + let mut locks = load_or_rebuild_lock_index(kv, network)?; + let previous_locks = locks.clone(); + locks.retain(|_, owner| *owner != operation.id); + for outcome in operation + .targets + .iter() + .filter(|outcome| outcome.status.holds_lock()) + { + if locks + .get(&outcome.target.key) + .is_some_and(|owner| *owner != operation.id) + { + return Err(TaskError::DpnsVoteTargetBusy); + } + locks.insert(outcome.target.key.clone(), operation.id); } + let mut ids = load_operation_ids(kv, network)?; + let new_operation = !ids.contains(&operation.id.to_bytes()); + let locks_changed = locks != previous_locks; + if new_operation || locks_changed { + kv.put( + DetScope::Global, + &operation_lock_index_dirty_key(network), + &true, + ) + .map_err(operation_err)?; + } kv.put( DetScope::Global, &operation_key(network, operation.id), operation, ) .map_err(operation_err)?; - let mut ids = load_operation_ids(kv, network)?; - if !ids.contains(&operation.id.to_bytes()) { + if new_operation { ids.push(operation.id.to_bytes()); kv.put(DetScope::Global, &operation_index_key(network), &ids) .map_err(operation_err)?; } + if locks_changed { + kv.put(DetScope::Global, &operation_lock_index_key(network), &locks) + .map_err(operation_err)?; + } + if new_operation || locks_changed { + kv.delete(DetScope::Global, &operation_lock_index_dirty_key(network)) + .map_err(operation_err)?; + } Ok(()) } @@ -168,12 +307,50 @@ fn write_existing_operation( network: Network, operation: &DpnsVoteOperation, ) -> Result<(), TaskError> { + persist_operation(kv, network, operation) +} + +fn prune_terminal_operations(kv: &DetKv, network: Network) -> Result { + let terminal_ids = load_operations(kv, network)? + .into_iter() + .filter(DpnsVoteOperation::is_complete) + .map(|operation| operation.id) + .collect::>(); + if terminal_ids.is_empty() { + return Ok(0); + } kv.put( DetScope::Global, - &operation_key(network, operation.id), - operation, + &operation_lock_index_dirty_key(network), + &true, ) - .map_err(operation_err) + .map_err(operation_err)?; + for id in &terminal_ids { + kv.delete(DetScope::Global, &operation_key(network, *id)) + .map_err(operation_err)?; + } + rebuild_lock_index(kv, network)?; + Ok(terminal_ids.len()) +} + +fn insert_diagnostic( + diagnostics: &mut DpnsVoteDiagnosticMap, + key: (DpnsVoteOperationId, DpnsVoteTargetKey), + sequence: u64, + error: Arc, + limit: usize, +) { + diagnostics.insert(key, (sequence, error)); + while diagnostics.len() > limit { + let Some(oldest) = diagnostics + .iter() + .min_by_key(|(_, (recorded_at, _))| recorded_at) + .map(|(key, _)| key.clone()) + else { + break; + }; + diagnostics.remove(&oldest); + } } fn transition_scheduled_target_to_queued( @@ -593,10 +770,20 @@ impl AppContext { key: DpnsVoteTargetKey, error: TaskError, ) { - self.dpns_vote_diagnostics + let sequence = self + .dpns_vote_diagnostic_sequence + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let mut diagnostics = self + .dpns_vote_diagnostics .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert((operation_id, key), Arc::new(error)); + .unwrap_or_else(std::sync::PoisonError::into_inner); + insert_diagnostic( + &mut diagnostics, + (operation_id, key), + sequence, + Arc::new(error), + DPNS_VOTE_DIAGNOSTIC_LIMIT, + ); } pub(crate) fn dpns_vote_operation_diagnostics( @@ -608,10 +795,19 @@ impl AppContext { .unwrap_or_else(std::sync::PoisonError::into_inner) .iter() .filter(|((id, _), _)| *id == operation_id) - .map(|(_, error)| Arc::clone(error)) + .map(|(_, (_, error))| Arc::clone(error)) .collect() } + /// Remove lock-releasing operation history when the user clears completed votes. + pub(crate) fn prune_terminal_dpns_vote_operations(&self) -> Result { + let _guard = self + .dpns_vote_operation_guard + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + prune_terminal_operations(&self.det_kv()?, self.network) + } + /// Release a not-yet-submitting scheduled target after explicit cancellation. pub(crate) fn cancel_scheduled_dpns_vote_target( &self, @@ -653,16 +849,22 @@ mod tests { struct CountingKv { inner: InMemoryKv, puts: AtomicUsize, + operation_gets: AtomicUsize, } impl CountingKv { - fn reset_puts(&self) { + fn reset_counts(&self) { self.puts.store(0, Ordering::Relaxed); + self.operation_gets.store(0, Ordering::Relaxed); } fn put_count(&self) -> usize { self.puts.load(Ordering::Relaxed) } + + fn operation_get_count(&self) -> usize { + self.operation_gets.load(Ordering::Relaxed) + } } impl KvStore for CountingKv { @@ -671,6 +873,9 @@ mod tests { scope: &ObjectId, key: &str, ) -> Result>, platform_wallet_storage::KvError> { + if key.starts_with(OPERATION_KEY_PREFIX) { + self.operation_gets.fetch_add(1, Ordering::Relaxed); + } self.inner.get(scope, key) } @@ -812,7 +1017,7 @@ mod tests { let mut replacement = operation(DpnsVoteTargetStatus::Scheduled); replacement.targets[0].target.timing = VoteTiming::Scheduled(84); - store.reset_puts(); + store.reset_counts(); replace_scheduled_operation(&kv, Network::Testnet, &mut replacement, &key).unwrap(); @@ -1006,6 +1211,93 @@ mod tests { .unwrap(); assert!(load_operations(&kv, Network::Mainnet).unwrap().is_empty()); + assert_eq!( + kv.get::>(DetScope::Global, LEGACY_OPERATION_INDEX_KEY) + .unwrap() + .unwrap_or_default(), + Vec::<[u8; 16]>::new(), + "a terminal row for another network must not be scanned again" + ); + } + + #[test] + fn persisted_lock_index_avoids_full_journal_reads_on_transition() { + let store = Arc::new(CountingKv::default()); + let kv = DetKv::from_store(store.clone()); + let mut first = operation(DpnsVoteTargetStatus::Queued); + let mut second = operation(DpnsVoteTargetStatus::Queued); + second.targets[0].target.key.vote_poll_id = Identifier::from([3; 32]); + persist_operation(&kv, Network::Testnet, &first).unwrap(); + persist_operation(&kv, Network::Testnet, &second).unwrap(); + + first.targets[0].status = DpnsVoteTargetStatus::Submitting; + store.reset_counts(); + persist_operation(&kv, Network::Testnet, &first).unwrap(); + + assert_eq!( + store.operation_get_count(), + 0, + "a status transition must consult the lock index, not every operation record" + ); + } + + #[test] + fn pruning_removes_terminal_records_and_preserves_live_locks() { + let kv = kv(); + let terminal = operation(DpnsVoteTargetStatus::Confirmed); + let live = operation(DpnsVoteTargetStatus::Unconfirmed); + persist_operation(&kv, Network::Testnet, &terminal).unwrap(); + persist_operation(&kv, Network::Testnet, &live).unwrap(); + + assert_eq!(prune_terminal_operations(&kv, Network::Testnet).unwrap(), 1); + assert_eq!( + load_operations_read_only(&kv, Network::Testnet).unwrap(), + vec![live] + ); + assert!( + kv.get::( + DetScope::Global, + &operation_key(Network::Testnet, terminal.id), + ) + .unwrap() + .is_none() + ); + } + + #[test] + fn diagnostics_evict_the_least_recently_recorded_entry() { + let mut diagnostics = BTreeMap::new(); + let first_key = ( + DpnsVoteOperationId::from_bytes([1; 16]), + operation(DpnsVoteTargetStatus::Queued).targets[0] + .target + .key + .clone(), + ); + let second_key = ( + DpnsVoteOperationId::from_bytes([2; 16]), + operation(DpnsVoteTargetStatus::Queued).targets[0] + .target + .key + .clone(), + ); + insert_diagnostic( + &mut diagnostics, + first_key.clone(), + 1, + Arc::new(TaskError::DpnsVoteTargetBusy), + 1, + ); + insert_diagnostic( + &mut diagnostics, + second_key.clone(), + 2, + Arc::new(TaskError::DpnsVoteTargetBusy), + 1, + ); + + assert!(!diagnostics.contains_key(&first_key)); + assert!(diagnostics.contains_key(&second_key)); } #[test] diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index fc7e6a96b..52f7b0fca 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -1152,6 +1152,7 @@ impl AppContext { } prune_vote_voter_if_empty(&kv, voter)?; } + self.prune_terminal_dpns_vote_operations()?; Ok(()) } diff --git a/src/context/mod.rs b/src/context/mod.rs index 427ec7219..2fe220888 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -62,6 +62,10 @@ use crate::model::user_role::{UserRole, UserRoleCell}; const ANIMATION_REFRESH_TIME: std::time::Duration = std::time::Duration::from_millis(100); +type DpnsVoteDiagnosticKey = (DpnsVoteOperationId, DpnsVoteTargetKey); +type DpnsVoteDiagnosticEntry = (u64, Arc); +type DpnsVoteDiagnostics = BTreeMap; + /// A guard that ensures settings cache invalidation happens atomically /// /// This guard holds a write lock on the cached settings, preventing reads @@ -227,8 +231,8 @@ pub struct AppContext { /// Re-armed only if targeted recovery cannot persist after an executor error. pub(crate) dpns_vote_recovery: tokio::sync::Mutex, /// Full in-process diagnostics keyed to sanitized durable outcomes. - dpns_vote_diagnostics: - Mutex>>, + dpns_vote_diagnostics: Mutex, + dpns_vote_diagnostic_sequence: AtomicU64, /// Pending wallet selection - set after creating/importing a wallet /// so the wallet screen can auto-select the new wallet pub(crate) pending_wallet_selection: Mutex>, @@ -512,6 +516,7 @@ impl AppContext { dpns_vote_dispatch: DpnsVoteDispatchCoordinator::default(), dpns_vote_recovery: tokio::sync::Mutex::new(false), dpns_vote_diagnostics: Mutex::new(BTreeMap::new()), + dpns_vote_diagnostic_sequence: AtomicU64::new(0), pending_wallet_selection: Mutex::new(None), selected_wallet_hash: Mutex::new(selected_wallet_hash), selected_single_key_hash: Mutex::new(selected_single_key_hash), From 3a281b7fed9400c9a5b22a18a2fa7f81f020bcf0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:26:20 +0000 Subject: [PATCH 16/39] fix(dpns): cache voting operation views --- src/backend_task/contested_names/mod.rs | 1 + src/context/dpns_vote_operations.rs | 54 ++++++++++- src/ui/dpns/dpns_contested_names_screen.rs | 37 +++++--- src/ui/masternodes/detail_screen.rs | 24 +++-- src/ui/masternodes/list_screen.rs | 37 +++++--- src/ui/masternodes/voting_center.rs | 64 +++++++------ src/ui/state/dpns_vote_operations.rs | 103 +++++++++++++++++++++ src/ui/state/mod.rs | 1 + 8 files changed, 262 insertions(+), 59 deletions(-) create mode 100644 src/ui/state/dpns_vote_operations.rs diff --git a/src/backend_task/contested_names/mod.rs b/src/backend_task/contested_names/mod.rs index be9c9a098..d7bbd0fb0 100644 --- a/src/backend_task/contested_names/mod.rs +++ b/src/backend_task/contested_names/mod.rs @@ -236,6 +236,7 @@ impl AppContext { if *recovered { return Ok(()); } + self.migrate_dpns_vote_operations()?; self.recover_interrupted_dpns_vote_operations()?; let queued = self .dpns_vote_operations()? diff --git a/src/context/dpns_vote_operations.rs b/src/context/dpns_vote_operations.rs index b8737c200..737eacd3b 100644 --- a/src/context/dpns_vote_operations.rs +++ b/src/context/dpns_vote_operations.rs @@ -544,6 +544,15 @@ impl AppContext { /// Load every operation for this network, including completed history. pub fn dpns_vote_operations(&self) -> Result, TaskError> { + let _guard = self + .dpns_vote_operation_guard + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + load_operations_read_only(&self.det_kv()?, self.network) + } + + /// Migrate legacy journals and scheduled-vote mirrors before backend recovery. + pub(crate) fn migrate_dpns_vote_operations(&self) -> Result<(), TaskError> { let _guard = self .dpns_vote_operation_guard .lock() @@ -577,7 +586,7 @@ impl AppContext { persist_operation(&kv, self.network, &operation)?; operations.push(operation); } - Ok(operations) + Ok(()) } /// Load one operation by its stable ID. @@ -589,9 +598,8 @@ impl AppContext { .dpns_vote_operation_guard .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let kv = self.det_kv()?; - migrate_legacy_operations(&kv, self.network)?; - let operation = kv + let operation = self + .det_kv()? .get(DetScope::Global, &operation_key(self.network, id)) .map_err(unreadable_operation_err)?; match operation { @@ -1241,6 +1249,44 @@ mod tests { ); } + #[tokio::test] + async fn operation_getter_does_not_migrate_legacy_scheduled_votes() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let store = Arc::new(CountingKv::default()); + let context = crate::context::test_support::test_app_context_with_kv( + temp_dir.path(), + Arc::new(DetKv::from_store(store.clone())), + ); + let (sender, _receiver) = tokio::sync::mpsc::channel::(8); + context + .ensure_wallet_backend(crate::utils::egui_mpsc::SenderAsync::new( + sender, + context.egui_ctx().clone(), + )) + .await + .expect("wire wallet backend offline"); + context + .insert_scheduled_votes(&[crate::backend_task::contested_names::ScheduledDPNSVote { + contested_name: "dominguez".to_owned(), + voter_id: Identifier::from([1; 32]), + choice: ResourceVoteChoice::Lock, + unix_timestamp: 42, + executed_successfully: false, + }]) + .unwrap(); + store.reset_counts(); + + assert!(context.dpns_vote_operations().unwrap().is_empty()); + assert_eq!( + store.put_count(), + 0, + "a named getter must not migrate or write legacy rows" + ); + + context.migrate_dpns_vote_operations().unwrap(); + assert_eq!(context.dpns_vote_operations().unwrap().len(), 1); + } + #[test] fn pruning_removes_terminal_records_and_preserves_live_locks() { let kv = kv(); diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index 09a24f14f..3f472bc11 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -31,6 +31,7 @@ use crate::ui::components::tools_subscreen_chooser_panel::add_tools_subscreen_ch use crate::ui::components::top_panel::{add_top_panel_with_global_nav, subdued_everyday_spec}; use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::identities::register_dpns_name_screen::RegisterDpnsNameSource; +use crate::ui::state::dpns_vote_operations::DpnsVoteOperationSnapshot; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use crate::ui::{BackendTaskSuccessResult, MessageType, RootScreenType, ScreenLike, ScreenType}; @@ -125,6 +126,7 @@ pub struct DPNSScreen { pub selected_votes: Vec, pub app_context: Arc, pending_backend_task: Option, + vote_operations: DpnsVoteOperationSnapshot, /// Sorting sort_column: SortColumn, @@ -182,6 +184,14 @@ impl DPNSScreen { .load_local_voting_identities() .unwrap_or_default(); let user_identities = app_context.load_local_user_identities().unwrap_or_default(); + let vote_operations = + DpnsVoteOperationSnapshot::load(app_context).unwrap_or_else(|error| { + tracing::warn!( + ?error, + "Could not cache DPNS vote operations for the DPNS screen" + ); + DpnsVoteOperationSnapshot::default() + }); // Initialize vote handling pop-up state to hidden let identity_count = voting_identities.len(); @@ -202,6 +212,7 @@ impl DPNSScreen { owned_filter_term: String::new(), scheduled_vote_cast_in_progress: false, pending_backend_task: None, + vote_operations, dpns_subscreen, refreshing_status: RefreshingStatus::NotRefreshing, refresh_banner: None, @@ -1060,14 +1071,12 @@ impl DPNSScreen { .dpns_vote_poll_id(&vote.0.contested_name) .ok() .and_then(|vote_poll_id| { - self.app_context - .dpns_vote_target_status(&DpnsVoteTargetKey { + self.vote_operations + .target_status(&DpnsVoteTargetKey { network: self.app_context.network(), voter_id: vote.0.voter_id, vote_poll_id, }) - .ok() - .flatten() }); body.row(25.0, |mut row| { // Contested name @@ -1709,13 +1718,7 @@ impl DPNSScreen { voter_id, vote_poll_id, }; - if self - .app_context - .dpns_vote_target_status(&target_key) - .ok() - .flatten() - .is_some() - { + if self.vote_operations.target_status(&target_key).is_some() { self.bulk_vote_handling_status = VoteHandlingStatus::Failed(format!( "This node's vote for {} is already in progress. Check its result before submitting again.", selected_vote.contested_name @@ -1876,6 +1879,9 @@ impl DPNSScreen { impl ScreenLike for DPNSScreen { fn refresh(&mut self) { self.scheduled_vote_cast_in_progress = false; + if let Err(error) = self.vote_operations.refresh(&self.app_context) { + tracing::warn!(?error, "Could not refresh cached DPNS vote operations"); + } let mut contested_names = self.contested_names.lock_recover(); let mut dpns_names = self.local_dpns_names.lock_recover(); let mut scheduled_votes = self.scheduled_votes.lock_recover(); @@ -1944,6 +1950,12 @@ impl ScreenLike for DPNSScreen { } fn display_task_error(&mut self, error: &TaskError) -> bool { + if let Err(refresh_error) = self.vote_operations.refresh(&self.app_context) { + tracing::warn!( + ?refresh_error, + "Could not refresh DPNS voting state after a task error" + ); + } let handled = scheduled_vote_sweep_is_quiet(error); if matches!(error, TaskError::ScheduledVoteSweepFailed { .. }) { self.scheduled_vote_cast_in_progress = false; @@ -1966,6 +1978,9 @@ impl ScreenLike for DPNSScreen { self.refresh(); } BackendTaskSuccessResult::ScheduledVotesInProgress(votes) => { + if let Err(error) = self.vote_operations.refresh(&self.app_context) { + tracing::warn!(?error, "Could not refresh scheduled-vote operation state"); + } // The periodic sweep is about to cast these votes; reflect that // in the list so the user sees them move before results land. self.scheduled_vote_cast_in_progress = true; diff --git a/src/ui/masternodes/detail_screen.rs b/src/ui/masternodes/detail_screen.rs index f21643f02..112b43ab1 100644 --- a/src/ui/masternodes/detail_screen.rs +++ b/src/ui/masternodes/detail_screen.rs @@ -40,6 +40,7 @@ use crate::ui::masternodes::card::{ PLATFORM_IDENTITY_STATUS_TOOLTIP, platform_identity_status_label, }; use crate::ui::masternodes::{TIP_OWNER_KEY, TIP_PAYOUT_KEY, TIP_VOTING_KEY, key_status_tokens}; +use crate::ui::state::dpns_vote_operations::DpnsVoteOperationSnapshot; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use crate::ui::tokens::claim_tokens_screen::ClaimTokensScreen; use crate::ui::tokens::tokens_screen::IdentityTokenBasicInfo; @@ -256,6 +257,7 @@ pub struct MasternodeDetailView { /// refresh). Active/open only β€” scheduled/past history lives on the DPNS /// Scheduled Votes screen (Β§10.7). open_contests: Vec, + vote_operations: DpnsVoteOperationSnapshot, /// Per-contest pending vote choice, keyed by normalized contested name. vote_selections: BTreeMap, /// One-shot automatic proved-state refresh for a newly opened detail view. @@ -283,6 +285,12 @@ impl MasternodeDetailView { } impl MasternodeDetailView { + pub(crate) fn refresh_vote_operations(&mut self) { + if let Err(error) = self.vote_operations.refresh(&self.app_context) { + tracing::warn!(?error, "Could not refresh node-detail voting state"); + } + } + pub fn new(app_context: &Arc, identity: QualifiedIdentity) -> Self { let node_id_hex_full = identity.identity.id().to_string(Encoding::Hex); let node_id_short = shorten_id(&node_id_hex_full); @@ -295,6 +303,14 @@ impl MasternodeDetailView { .masternode_contest_summary(voter_id) .unwrap_or_default(); let open_contests = Self::load_open_contests(app_context, voter_id); + let vote_operations = + DpnsVoteOperationSnapshot::load(app_context).unwrap_or_else(|error| { + tracing::warn!( + ?error, + "Could not cache DPNS operations for the node detail view" + ); + DpnsVoteOperationSnapshot::default() + }); Self { app_context: app_context.clone(), identity, @@ -303,6 +319,7 @@ impl MasternodeDetailView { key_presence, contest_summary, open_contests, + vote_operations, vote_selections: BTreeMap::new(), vote_state_refresh_dispatched: false, open_voting_center_requested: None, @@ -875,12 +892,7 @@ impl MasternodeDetailView { name: contest.normalized_contested_name.clone(), end_time: contest.end_time, current_vote, - locked: self - .app_context - .dpns_vote_target_status(&key) - .ok() - .flatten() - .is_some(), + locked: self.vote_operations.target_status(&key).is_some(), candidates, }) }) diff --git a/src/ui/masternodes/list_screen.rs b/src/ui/masternodes/list_screen.rs index 4f671ede9..25cd735c5 100644 --- a/src/ui/masternodes/list_screen.rs +++ b/src/ui/masternodes/list_screen.rs @@ -38,6 +38,7 @@ use crate::ui::masternodes::card::{MasternodeCard, card_heading}; use crate::ui::masternodes::detail_screen::{DetailOutcome, MasternodeDetailView}; use crate::ui::masternodes::load_form::{LoadFormOutcome, MasternodeLoadForm}; use crate::ui::masternodes::voting_center::{DpnsVotingCenter, VotingCenterOutcome}; +use crate::ui::state::dpns_vote_operations::DpnsVoteOperationSnapshot; use crate::ui::state::global_nav::PageNavSpec; use crate::ui::state::masternodes_view::{masternodes_page_nav_spec, node_pill_item}; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; @@ -96,6 +97,7 @@ pub struct MasternodesScreen { /// Cached card data for the active network, refreshed on arrival, on /// `refresh`, and on the Refresh button. nodes: Vec, + vote_operations: DpnsVoteOperationSnapshot, /// The active sub-view (list / load / detail). view: MasternodesView, /// The load this screen dispatched and has not yet seen finish, identified by @@ -140,6 +142,7 @@ impl MasternodesScreen { let mut screen = Self { app_context: app_context.clone(), nodes: Vec::new(), + vote_operations: DpnsVoteOperationSnapshot::default(), view: MasternodesView::List, pending_load: None, pending_schedule_cancellation: None, @@ -153,6 +156,7 @@ impl MasternodesScreen { /// rather than surfacing a technical error β€” the empty state is a safe, /// meaningful fallback. fn reload(&mut self) { + self.refresh_vote_operations(); let identities = self .app_context .load_local_masternode_identities() @@ -188,6 +192,20 @@ impl MasternodesScreen { }); } + fn refresh_vote_operations(&mut self) { + if let Err(error) = self.vote_operations.refresh(&self.app_context) { + tracing::warn!( + ?error, + "Could not refresh the masternode vote-operation cache" + ); + } + match &mut self.view { + MasternodesView::Detail(detail) => detail.refresh_vote_operations(), + MasternodesView::Voting(center) => center.refresh_vote_operations(), + MasternodesView::List | MasternodesView::Load(_) | MasternodesView::Scheduled => {} + } + } + /// Settle the submitted load against the phase its own task reported, and /// release the gate once that load is finished. The single place this screen /// decides a load is over β€” its result and error callbacks fire only while it @@ -359,9 +377,7 @@ impl MasternodesScreen { /// Shared target-correlated progress, visible regardless of the active node. fn render_voting_activity(&mut self, ui: &mut egui::Ui) -> AppAction { - let Ok(mut operations) = self.app_context.dpns_vote_operations() else { - return AppAction::None; - }; + let mut operations = self.vote_operations.operations().to_vec(); operations.sort_by_key(|operation| operation.created_at); let operations = operations .into_iter() @@ -575,14 +591,12 @@ impl MasternodesScreen { ui.label( "Upcoming and unresolved targets use the same operation locks as immediate votes.", ); - let scheduled_targets = match self.app_context.dpns_vote_operations() { - Ok(operations) => scheduled_journal_targets(operations), - Err(error) => { - ui.label("Scheduled votes are unavailable. Refresh this page to try again."); - tracing::warn!(?error, "Could not load journaled DPNS schedules"); - return action; - } - }; + if !self.vote_operations.is_loaded() { + ui.label("Scheduled votes are unavailable. Refresh this page to try again."); + return action; + } + let scheduled_targets = + scheduled_journal_targets(self.vote_operations.operations().to_vec()); if scheduled_targets.is_empty() { ui.label("No scheduled votes."); return action; @@ -943,6 +957,7 @@ impl ScreenLike for MasternodesScreen { } fn display_task_error(&mut self, _error: &crate::backend_task::error::TaskError) -> bool { + self.refresh_vote_operations(); // A failing load reports `Failed` before its error reaches the UI, so // settling here re-enables the still-open form's submit button (the Load // view is untouched, so every entered field survives for correction). diff --git a/src/ui/masternodes/voting_center.rs b/src/ui/masternodes/voting_center.rs index bee519488..591db96bb 100644 --- a/src/ui/masternodes/voting_center.rs +++ b/src/ui/masternodes/voting_center.rs @@ -22,6 +22,7 @@ use crate::model::dpns_voting::{ }; use crate::model::qualified_identity::PrivateKeyTarget; use crate::model::qualified_identity::QualifiedIdentity; +use crate::ui::state::dpns_vote_operations::DpnsVoteOperationSnapshot; use crate::ui::state::dpns_vote_workspace::{ ComposerKeyAction, DpnsVoteComposerStep, DpnsVoteWorkspace, DraftVoteTiming, }; @@ -37,6 +38,7 @@ pub struct DpnsVotingCenter { app_context: Arc, voters: Vec, contests: Vec, + vote_operations: DpnsVoteOperationSnapshot, workspace: DpnsVoteWorkspace, submitted_operation: Option, vote_state_refresh_dispatched: bool, @@ -59,11 +61,18 @@ struct ReviewExclusion { } impl DpnsVotingCenter { + pub(crate) fn refresh_vote_operations(&mut self) { + if let Err(error) = self.vote_operations.refresh(&self.app_context) { + tracing::warn!(?error, "Could not refresh voting-center operation state"); + } + } + pub(crate) fn display_backend_task_result( &mut self, context: &BackendTaskContext, result: &BackendTaskSuccessResult, ) { + self.refresh_vote_operations(); if matches!(result, BackendTaskSuccessResult::RefreshedDpnsContests) { self.vote_state_refresh_dispatched = false; match self.app_context.ongoing_contested_names() { @@ -78,6 +87,7 @@ impl DpnsVotingCenter { } pub(crate) fn display_backend_task_error(&mut self, context: &BackendTaskContext) { + self.refresh_vote_operations(); let Some(operation_id) = self.submitted_operation else { return; }; @@ -85,11 +95,7 @@ impl DpnsVotingCenter { operation_id, self.app_context.network(), context, - self.app_context - .dpns_vote_operation(operation_id) - .ok() - .flatten() - .is_some(), + self.vote_operations.operation(operation_id).is_some(), ) { self.submitted_operation = None; self.workspace.step = DpnsVoteComposerStep::Review; @@ -110,6 +116,14 @@ impl DpnsVotingCenter { workspace.prefilter_node(voter_id); } let contests = app_context.ongoing_contested_names().unwrap_or_default(); + let vote_operations = + DpnsVoteOperationSnapshot::load(app_context).unwrap_or_else(|error| { + tracing::warn!( + ?error, + "Could not cache DPNS vote operations for the voting center" + ); + DpnsVoteOperationSnapshot::default() + }); if !preselected_contests.is_empty() { for name in preselected_contests { if contests @@ -127,6 +141,7 @@ impl DpnsVotingCenter { app_context: Arc::clone(app_context), voters, contests, + vote_operations, workspace, submitted_operation: None, vote_state_refresh_dispatched: false, @@ -549,8 +564,8 @@ impl DpnsVotingCenter { return VotingCenterOutcome::None; }; ui.heading("Voting operation"); - match self.app_context.dpns_vote_operation(operation_id) { - Ok(Some(operation)) => { + match self.vote_operations.operation(operation_id).cloned() { + Some(operation) => { for outcome in &operation.targets { ui.group(|ui| { let voter = outcome @@ -660,14 +675,14 @@ impl DpnsVotingCenter { return VotingCenterOutcome::BackToNodes; } } - Ok(None) => { + None if self.vote_operations.is_loaded() => { ui.horizontal(|ui| { ui.spinner(); ui.label("Queuing votes…"); }); } - Err(error) => { - ui.label(error.to_string()); + None => { + ui.label("This operation could not be loaded. Refresh and try again."); } } VotingCenterOutcome::None @@ -757,11 +772,7 @@ impl DpnsVotingCenter { self.app_context .dpns_current_vote_state(voter_id, vote_poll_id) .unwrap_or(DpnsCurrentVoteState::Unavailable), - self.app_context - .dpns_vote_target_status(&key) - .ok() - .flatten() - .is_some(), + self.vote_operations.target_status(&key).is_some(), ) }) .collect() @@ -806,18 +817,17 @@ impl DpnsVotingCenter { voter_id, vote_poll_id, }; - let existing_status = match self.app_context.dpns_vote_target_status(&key) { - Ok(status) => status, - Err(_) => { - exclusions.push(ReviewExclusion { - voter: self.voter_label(voter_id), - contest: name.clone(), - requested_choice: *requested_choice, - reason: "DET could not check whether this target is already in use.", - no_op: false, - }); - continue; - } + let existing_status = if self.vote_operations.is_loaded() { + self.vote_operations.target_status(&key) + } else { + exclusions.push(ReviewExclusion { + voter: self.voter_label(voter_id), + contest: name.clone(), + requested_choice: *requested_choice, + reason: "DET could not check whether this target is already in use.", + no_op: false, + }); + continue; }; let replacing_schedule = is_explicit_schedule_replacement( self.editing_scheduled_key.as_ref(), diff --git a/src/ui/state/dpns_vote_operations.rs b/src/ui/state/dpns_vote_operations.rs new file mode 100644 index 000000000..46949dce8 --- /dev/null +++ b/src/ui/state/dpns_vote_operations.rs @@ -0,0 +1,103 @@ +//! Per-screen DPNS vote-operation snapshot for immediate-mode render paths. + +use std::collections::BTreeMap; + +use crate::backend_task::error::TaskError; +use crate::context::AppContext; +use crate::model::dpns_voting::{ + DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteTargetKey, DpnsVoteTargetStatus, +}; + +#[derive(Debug, Clone, Default)] +pub struct DpnsVoteOperationSnapshot { + operations: Vec, + target_statuses: BTreeMap, + loaded: bool, +} + +impl DpnsVoteOperationSnapshot { + pub fn load(app_context: &AppContext) -> Result { + let mut snapshot = Self::default(); + snapshot.refresh(app_context)?; + Ok(snapshot) + } + + pub fn refresh(&mut self, app_context: &AppContext) -> Result<(), TaskError> { + self.replace(app_context.dpns_vote_operations()?); + Ok(()) + } + + pub fn operations(&self) -> &[DpnsVoteOperation] { + &self.operations + } + + pub fn operation(&self, id: DpnsVoteOperationId) -> Option<&DpnsVoteOperation> { + self.operations.iter().find(|operation| operation.id == id) + } + + pub fn target_status(&self, key: &DpnsVoteTargetKey) -> Option { + self.target_statuses.get(key).copied() + } + + pub fn is_loaded(&self) -> bool { + self.loaded + } + + fn replace(&mut self, operations: Vec) { + self.target_statuses = operations + .iter() + .flat_map(|operation| &operation.targets) + .filter(|outcome| outcome.status.holds_lock()) + .map(|outcome| (outcome.target.key.clone(), outcome.status)) + .collect(); + self.operations = operations; + self.loaded = true; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::dpns_voting::{DpnsVoteTarget, VoteTiming}; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + use dash_sdk::platform::Identifier; + + fn operation(status: DpnsVoteTargetStatus) -> DpnsVoteOperation { + let mut operation = DpnsVoteOperation::new(vec![DpnsVoteTarget { + key: DpnsVoteTargetKey { + network: Network::Testnet, + voter_id: Identifier::from([1; 32]), + vote_poll_id: Identifier::from([2; 32]), + }, + voter_alias: None, + contested_name: "dominguez".to_owned(), + requested_choice: ResourceVoteChoice::Lock, + current_choice: None, + timing: VoteTiming::Now, + }]); + operation.targets[0].status = status; + operation + } + + #[test] + fn snapshot_indexes_only_lock_holding_targets() { + let live = operation(DpnsVoteTargetStatus::Submitting); + let mut terminal = operation(DpnsVoteTargetStatus::Confirmed); + terminal.targets[0].target.key.vote_poll_id = Identifier::from([3; 32]); + let live_key = live.targets[0].target.key.clone(); + let terminal_key = terminal.targets[0].target.key.clone(); + let mut snapshot = DpnsVoteOperationSnapshot::default(); + + snapshot.replace(vec![live.clone(), terminal.clone()]); + + assert_eq!( + snapshot.target_status(&live_key), + Some(DpnsVoteTargetStatus::Submitting) + ); + assert_eq!(snapshot.target_status(&terminal_key), None); + assert_eq!(snapshot.operation(live.id), Some(&live)); + assert_eq!(snapshot.operations(), &[live, terminal]); + assert!(snapshot.is_loaded()); + } +} diff --git a/src/ui/state/mod.rs b/src/ui/state/mod.rs index 99d73f209..31926333e 100644 --- a/src/ui/state/mod.rs +++ b/src/ui/state/mod.rs @@ -8,6 +8,7 @@ pub mod account_summary; pub mod avatar_cache; pub mod contacts_view; +pub mod dpns_vote_operations; pub mod dpns_vote_workspace; pub mod global_nav; pub mod hub_selection; From 14d3aca921b7795aea6e2a682f891861f27dbd42 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:34:20 +0000 Subject: [PATCH 17/39] fix(dpns): recover failed direct vote dispatches --- src/backend_task/contested_names/mod.rs | 138 ++++++++++++++++++++---- src/context/mod.rs | 21 ++++ src/wallet_backend/kv_test_support.rs | 22 ++++ 3 files changed, 163 insertions(+), 18 deletions(-) diff --git a/src/backend_task/contested_names/mod.rs b/src/backend_task/contested_names/mod.rs index d7bbd0fb0..c3dc3f95f 100644 --- a/src/backend_task/contested_names/mod.rs +++ b/src/backend_task/contested_names/mod.rs @@ -169,15 +169,20 @@ impl AppContext { replacing_scheduled_key, _, ) => { - self.execute_dpns_vote_operation(operation, voters, replacing_scheduled_key, sdk) - .await + self.execute_dpns_vote_operation_with_recovery( + operation, + voters, + replacing_scheduled_key, + sdk, + ) + .await } ContestedResourceTask::ReconcileDpnsVoteOperation(operation_id, _) => { self.reconcile_dpns_vote_operation(operation_id, sdk).await } ContestedResourceTask::CastScheduledVote(scheduled_vote, voter) => { let operation = self.operation_for_scheduled_vote(&scheduled_vote, &voter)?; - self.execute_dpns_vote_operation(operation, vec![*voter], None, sdk) + self.execute_dpns_vote_operation_with_recovery(operation, vec![*voter], None, sdk) .await } ContestedResourceTask::CastDueScheduledVotes { @@ -643,6 +648,40 @@ impl AppContext { }) } + async fn execute_dpns_vote_operation_with_recovery( + self: &Arc, + operation: DpnsVoteOperation, + voters: Vec, + replacing_scheduled_key: Option, + sdk: &Sdk, + ) -> Result { + let operation_id = operation.id; + let result = self + .execute_dpns_vote_operation(operation, voters, replacing_scheduled_key, sdk) + .await; + if let Err(error) = &result { + self.recover_failed_dpns_vote_operation(operation_id, error) + .await; + } + result + } + + async fn recover_failed_dpns_vote_operation( + &self, + operation_id: DpnsVoteOperationId, + original_error: &TaskError, + ) { + if let Err(recovery_error) = self.recover_interrupted_dpns_vote_operation(operation_id) { + tracing::error!( + error = %recovery_error, + original_error = %original_error, + operation_id = %operation_id, + "Failed to persist recovery for an interrupted DPNS vote operation" + ); + *self.dpns_vote_recovery.lock().await = false; + } + } + async fn reconcile_dpns_vote_operation( &self, operation_id: DpnsVoteOperationId, @@ -830,7 +869,7 @@ impl AppContext { let operation_id = operation.id; async move { let result = app_context - .execute_dpns_vote_operation(operation, voters, None, &sdk) + .execute_dpns_vote_operation_with_recovery(operation, voters, None, &sdk) .await .map(|_| ()); (operation_id, result) @@ -848,20 +887,6 @@ impl AppContext { "Failed to execute a due DPNS vote operation; leaving it for recovery" ); first_error.get_or_insert(error); - if let Err(recovery_error) = - self.recover_interrupted_dpns_vote_operation(operation_id) - { - tracing::error!( - error = %recovery_error, - operation_id = %operation_id, - "Failed to recover an interrupted DPNS vote operation" - ); - // Let the next contested task retry global recovery after - // storage becomes available again. The targeted attempt is - // preferred because this fallback can see unrelated work. - *self.dpns_vote_recovery.lock().await = false; - first_error.get_or_insert(recovery_error); - } } } if let Some(error) = first_error { @@ -1034,6 +1059,83 @@ mod tests { )); } + #[tokio::test] + async fn direct_dispatch_terminal_write_failure_rearms_global_recovery() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let store = Arc::new(crate::wallet_backend::kv_test_support::FailingKv::default()); + let context = crate::context::test_support::test_app_context_with_kv( + temp_dir.path(), + Arc::new(crate::wallet_backend::DetKv::from_store(store.clone())), + ); + let (sender, _receiver) = tokio::sync::mpsc::channel::(8); + context + .ensure_wallet_backend(crate::utils::egui_mpsc::SenderAsync::new( + sender, + context.egui_ctx().clone(), + )) + .await + .expect("wire wallet backend offline"); + context + .set_det_kv_override_for_test(crate::wallet_backend::DetKv::from_store(store.clone())); + let mut operation = DpnsVoteOperation::new(vec![DpnsVoteTarget { + key: DpnsVoteTargetKey { + network: Network::Testnet, + voter_id: Identifier::from([1; 32]), + vote_poll_id: Identifier::from([2; 32]), + }, + voter_alias: None, + contested_name: "dominguez".to_owned(), + requested_choice: ResourceVoteChoice::Lock, + current_choice: None, + timing: VoteTiming::Now, + }]); + operation.targets[0].status = DpnsVoteTargetStatus::Submitting; + let operation_id = operation.id; + let target_key = operation.targets[0].target.key.clone(); + context + .insert_dpns_vote_operation(&mut operation, None) + .expect("persist in-flight operation"); + *context.dpns_vote_recovery.lock().await = true; + store.fail_next_puts_containing(&operation_id.to_string(), 2); + + let terminal_error = context + .update_dpns_vote_target( + operation_id, + &target_key, + DpnsVoteTargetStatus::Confirmed, + None, + ) + .expect_err("terminal operation write must fail"); + context + .recover_failed_dpns_vote_operation(operation_id, &terminal_error) + .await; + + assert!(!*context.dpns_vote_recovery.lock().await); + assert_eq!( + context + .dpns_vote_operation(operation_id) + .unwrap() + .unwrap() + .targets[0] + .status, + DpnsVoteTargetStatus::Submitting + ); + + context + .ensure_dpns_vote_recovery(&context.sdk()) + .await + .expect("the re-armed recovery pass must succeed"); + assert_eq!( + context + .dpns_vote_operation(operation_id) + .unwrap() + .unwrap() + .targets[0] + .status, + DpnsVoteTargetStatus::Unconfirmed + ); + } + #[test] fn scheduled_pre_broadcast_failure_remains_retryable() { let attempt = Err(TaskError::DpnsVoteTargetBusy); diff --git a/src/context/mod.rs b/src/context/mod.rs index 2fe220888..a5ca9b05e 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -197,6 +197,8 @@ pub struct AppContext { /// DET-owned application data that must outlive a single network's /// wallet persister. Cheap to clone (`Arc` is `Arc`-backed). app_kv: Arc, + #[cfg(test)] + det_kv_override: Mutex>, /// Shared encrypted HD-seed vault at `/secrets/det-secrets.pwsvault`. /// Opened once and handed to every per-network `AppContext` and to the /// `WalletBackend`, because the file backend takes an exclusive advisory @@ -505,6 +507,8 @@ impl AppContext { animations_disabled: AtomicBool::new(false), cached_settings: RwLock::new(None), app_kv, + #[cfg(test)] + det_kv_override: Mutex::new(None), secret_store, subtasks, token_balance_refresh_in_flight: AtomicBool::new(false), @@ -606,9 +610,26 @@ impl AppContext { /// backend is not yet initialized. Single accessor shared by every /// `context/*_db.rs` module. pub(crate) fn det_kv(&self) -> Result { + #[cfg(test)] + if let Some(kv) = self + .det_kv_override + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + { + return Ok(kv); + } Ok(self.wallet_backend()?.kv()) } + #[cfg(test)] + pub(crate) fn set_det_kv_override_for_test(&self, kv: DetKv) { + *self + .det_kv_override + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(kv); + } + /// Shared encrypted HD-seed vault. Cheap clone β€” `Arc` is /// `Arc`-backed. The wallet backend reuses this same handle rather than /// opening its own, because the file vault takes an exclusive advisory diff --git a/src/wallet_backend/kv_test_support.rs b/src/wallet_backend/kv_test_support.rs index dbc62f6c0..c9eaa4449 100644 --- a/src/wallet_backend/kv_test_support.rs +++ b/src/wallet_backend/kv_test_support.rs @@ -86,6 +86,7 @@ pub(crate) struct FailingKv { inner: InMemoryKv, fail_reads: AtomicBool, puts: AtomicUsize, + fail_puts: Mutex>, } impl FailingKv { @@ -100,6 +101,11 @@ impl FailingKv { pub(crate) fn put_count(&self) -> usize { self.puts.load(Ordering::Relaxed) } + + /// Fail the next `count` writes whose key contains `key_fragment`. + pub(crate) fn fail_next_puts_containing(&self, key_fragment: &str, count: usize) { + *self.fail_puts.lock().unwrap() = Some((key_fragment.to_owned(), count)); + } } impl KvStore for FailingKv { @@ -114,6 +120,22 @@ impl KvStore for FailingKv { // Counted before delegating: an attempted write is what the assertions // are about, whether or not the store would have accepted it. self.puts.fetch_add(1, Ordering::Relaxed); + let should_fail = { + let mut failure = self.fail_puts.lock().unwrap(); + let should_fail = failure + .as_ref() + .is_some_and(|(fragment, remaining)| *remaining > 0 && key.contains(fragment)); + if should_fail && let Some((_, remaining)) = failure.as_mut() { + *remaining -= 1; + if *remaining == 0 { + *failure = None; + } + } + should_fail + }; + if should_fail { + return Err(KvError::LockPoisoned); + } self.inner.put(scope, key, value) } From 071cc1691227bdeaeb9420bede9c825dbf4b759c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:32:51 +0000 Subject: [PATCH 18/39] fix(dpns): contextualize vote diagnostics for DAPI exhaustion Co-Authored-By: OpenAI Codex --- src/backend_task/contested_names/mod.rs | 71 +++++++++++++++++++++++-- src/backend_task/mod.rs | 39 -------------- 2 files changed, 66 insertions(+), 44 deletions(-) diff --git a/src/backend_task/contested_names/mod.rs b/src/backend_task/contested_names/mod.rs index c3dc3f95f..856984b44 100644 --- a/src/backend_task/contested_names/mod.rs +++ b/src/backend_task/contested_names/mod.rs @@ -5,7 +5,7 @@ mod vote_on_dpns_name; use crate::app::TaskResult; use crate::backend_task::BackendTaskSuccessResult; -use crate::backend_task::error::TaskError; +use crate::backend_task::error::{DapiAddressAvailability, TaskError}; use crate::context::AppContext; use crate::model::dpns_voting::{ DpnsCurrentVoteState, DpnsVoteFailure, DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteTarget, @@ -147,6 +147,17 @@ pub(super) fn log_contested_proof_error(e: &dash_sdk::Error, request_type: Reque } impl AppContext { + fn record_dpns_vote_diagnostic_with_dapi_context( + &self, + operation_id: DpnsVoteOperationId, + key: DpnsVoteTargetKey, + error: TaskError, + sdk: &Sdk, + ) { + let error = error.contextualize_dapi_availability(DapiAddressAvailability::from_sdk(sdk)); + self.record_dpns_vote_diagnostic(operation_id, key, error); + } + pub async fn run_contested_resource_task( self: &Arc, task: ContestedResourceTask, @@ -550,10 +561,11 @@ impl AppContext { contested_name = %target.contested_name, "DPNS vote was submitted but remains unconfirmed" ); - app_context.record_dpns_vote_diagnostic( + app_context.record_dpns_vote_diagnostic_with_dapi_context( operation_id, target.key.clone(), error, + &sdk, ); } Ok(vote_on_dpns_name::DpnsVoteAttempt::Rejected(error)) => { @@ -563,10 +575,11 @@ impl AppContext { contested_name = %target.contested_name, "Platform rejected a DPNS vote" ); - app_context.record_dpns_vote_diagnostic( + app_context.record_dpns_vote_diagnostic_with_dapi_context( operation_id, target.key.clone(), error, + &sdk, ); } Err(error) => { @@ -579,10 +592,11 @@ impl AppContext { if matches!(target.timing, VoteTiming::Scheduled(_)) { retryable_scheduled_error = Some(error); } else { - app_context.record_dpns_vote_diagnostic( + app_context.record_dpns_vote_diagnostic_with_dapi_context( operation_id, target.key.clone(), error, + &sdk, ); } } @@ -761,10 +775,11 @@ impl AppContext { contested_name = %outcome.target.contested_name, "Could not reconcile an unconfirmed DPNS vote" ); - self.record_dpns_vote_diagnostic( + self.record_dpns_vote_diagnostic_with_dapi_context( operation_id, outcome.target.key.clone(), error, + sdk, ); } } @@ -933,6 +948,52 @@ mod tests { use super::*; use std::cell::RefCell; + fn dapi_connection_refused_error() -> TaskError { + use dash_sdk::Error as SdkError; + use dash_sdk::dapi_client::DapiClientError; + use dash_sdk::dapi_client::transport::TransportError; + + let status = dash_sdk::dapi_grpc::tonic::Status::unavailable("tcp connect error"); + TaskError::from(SdkError::DapiClientError(DapiClientError::Transport( + TransportError::Grpc(status), + ))) + } + + #[test] + fn vote_diagnostic_contextualizes_exhausted_dapi_addresses() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let context = crate::context::test_support::test_app_context(temp_dir.path()); + let sdk = context.sdk(); + let addresses = sdk.address_list(); + let live_addresses = addresses.get_live_addresses(); + assert!( + !live_addresses.is_empty(), + "test SDK must have DAPI addresses" + ); + for address in live_addresses { + assert!(addresses.ban(&address)); + } + + let operation_id = DpnsVoteOperationId::from_bytes([3; 16]); + let key = DpnsVoteTargetKey { + network: Network::Testnet, + voter_id: Identifier::from([4; 32]), + vote_poll_id: Identifier::from([5; 32]), + }; + context.record_dpns_vote_diagnostic_with_dapi_context( + operation_id, + key, + dapi_connection_refused_error(), + &sdk, + ); + + let diagnostics = context.dpns_vote_operation_diagnostics(operation_id); + assert!(matches!( + diagnostics.as_slice(), + [error] if matches!(error.as_ref(), TaskError::DapiAllAddressesExhausted { .. }) + )); + } + /// VOTE-TC-033: an inner scheduled rejection is never classified as success. #[test] fn scheduled_inner_rejection_needs_attention() { diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 42fc07eee..db3cd4d2e 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -786,11 +786,6 @@ pub enum BackendTaskSuccessResult { impl BackendTaskSuccessResult { fn contains_dapi_reachability_failure(&self) -> bool { match self { - Self::DPNSVoteResults(results) => results.iter().any(|(_, _, result)| { - result - .as_ref() - .is_err_and(|error| error.contains_dapi_reachability_failure()) - }), Self::RefreshedWallet { warning } => warning .as_ref() .is_some_and(|error| error.contains_dapi_reachability_failure()), @@ -800,17 +795,6 @@ impl BackendTaskSuccessResult { fn contextualize_dapi_availability(self, availability: DapiAddressAvailability) -> Self { match self { - Self::DPNSVoteResults(results) => Self::DPNSVoteResults( - results - .into_iter() - .map(|(name, choice, result)| { - let result = result.map_err(|error| { - error.contextualize_shared_dapi_availability(availability) - }); - (name, choice, result) - }) - .collect(), - ), Self::RefreshedWallet { warning } => Self::RefreshedWallet { warning: warning .map(|error| error.contextualize_shared_dapi_availability(availability)), @@ -1315,29 +1299,6 @@ mod tests { assert!(!inspected.get()); } - #[test] - fn dapi_context_maps_errors_embedded_in_success_results() { - let result = contextualize_dapi_result( - Ok(BackendTaskSuccessResult::DPNSVoteResults(vec![( - "alice".to_owned(), - ResourceVoteChoice::Lock, - Err(Arc::new(dapi_connection_refused_error())), - )])), - || DapiAddressAvailability { - configured_total: 1, - live_count: 0, - }, - ); - - let Ok(BackendTaskSuccessResult::DPNSVoteResults(results)) = result else { - panic!("expected DPNS vote results"); - }; - assert!(matches!( - results[0].2, - Err(ref error) if matches!(error.as_ref(), TaskError::DapiAllAddressesExhausted { .. }) - )); - } - #[test] fn dapi_context_maps_spawned_dpns_query_task_result() { let result = contextualize_dapi_task_result( From 25b89d2114368ddbfd6aa48dbbee5b13191c6aec Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 20 Jul 2026 19:55:34 +0000 Subject: [PATCH 19/39] fix(dpns-voting): address 5 review findings marked resolved but still open PR #901 review threads B, C, D, F, G on the DPNS voting journal were closed as resolved on GitHub, but two independent verification passes against the exact reviewed commit (f69db4eb) confirmed the underlying bugs were still present. Fixes: - B: add a durable Submitting->Confirming phase boundary (mark_dpns_vote_broadcast) so a crash before broadcast recovers to FailedBeforeSubmission/Scheduled instead of permanently locking the target as Unconfirmed. - C: propagate cancel_scheduled_target's race-lost result instead of discarding it; a lost cancellation race now returns TaskError::DpnsScheduledVoteAlreadyStarted and no longer deletes the legacy row. - D: add a terminal DpnsVoteTargetStatus::Cancelled distinct from NotApplied, which is now reserved for proved post-broadcast reconciliation only. - F: MasternodeDetailView's contest reads now degrade to MasternodeContestSummary::unavailable() on failure instead of unwrap_or_default(), matching list_screen's existing fail-safe pattern. - G: classify_broadcast_error distinguishes a broadcast()-call failure (nothing transmitted) from wait_for_response() post-broadcast ambiguity, classifying the former as FailedBeforeSubmission instead of Unconfirmed. Verification: formatting, targeted unit-test suites for the touched modules, and lint (all-features, warnings-as-errors) all passed clean. Co-Authored-By: Codex Sol Co-Authored-By: Claudius the Magnificent --- src/app.rs | 7 +- src/backend_task/contested_names/mod.rs | 29 +++- .../contested_names/vote_on_dpns_name.rs | 49 +++++- src/backend_task/error.rs | 6 + src/context/contested_names_db.rs | 2 +- src/context/dpns_vote_operations.rs | 149 +++++++++++++++--- src/model/dpns_voting.rs | 4 + src/ui/masternodes/detail_screen.rs | 73 ++++++--- src/ui/masternodes/list_screen.rs | 4 +- src/ui/masternodes/voting_center.rs | 9 +- 10 files changed, 286 insertions(+), 46 deletions(-) diff --git a/src/app.rs b/src/app.rs index 4b170b060..4ffe6a302 100644 --- a/src/app.rs +++ b/src/app.rs @@ -197,6 +197,7 @@ struct DpnsVoteFeedbackCounts { unconfirmed: usize, rejected: usize, failed_before_submission: usize, + cancelled: usize, not_applied: usize, in_progress: usize, } @@ -212,6 +213,7 @@ fn dpns_vote_feedback(operation: &DpnsVoteOperation) -> (String, MessageType, bo DpnsVoteTargetStatus::FailedBeforeSubmission => { counts.failed_before_submission += 1; } + DpnsVoteTargetStatus::Cancelled => counts.cancelled += 1, DpnsVoteTargetStatus::NotApplied => counts.not_applied += 1, DpnsVoteTargetStatus::Queued | DpnsVoteTargetStatus::Submitting @@ -219,12 +221,13 @@ fn dpns_vote_feedback(operation: &DpnsVoteOperation) -> (String, MessageType, bo } } let message = format!( - "Voting results: {} confirmed, {} scheduled, {} unconfirmed, {} rejected, {} failed before submission, {} not applied, and {} still in progress. Open Voting activity to review each target.", + "Voting results: {} confirmed, {} scheduled, {} unconfirmed, {} rejected, {} failed before submission, {} cancelled, {} not applied, and {} still in progress. Open Voting activity to review each target.", counts.confirmed, counts.scheduled, counts.unconfirmed, counts.rejected, counts.failed_before_submission, + counts.cancelled, counts.not_applied, counts.in_progress, ); @@ -2813,6 +2816,7 @@ mod migration_banner_tests { DpnsVoteTargetStatus::Unconfirmed, DpnsVoteTargetStatus::Rejected, DpnsVoteTargetStatus::FailedBeforeSubmission, + DpnsVoteTargetStatus::Cancelled, DpnsVoteTargetStatus::NotApplied, ]); @@ -2826,6 +2830,7 @@ mod migration_banner_tests { "1 unconfirmed", "1 rejected", "1 failed before submission", + "1 cancelled", "1 not applied", "Open Voting activity", ] { diff --git a/src/backend_task/contested_names/mod.rs b/src/backend_task/contested_names/mod.rs index 856984b44..cc04221e1 100644 --- a/src/backend_task/contested_names/mod.rs +++ b/src/backend_task/contested_names/mod.rs @@ -84,7 +84,9 @@ fn classify_vote_attempt( DpnsVoteTargetStatus::Rejected, Some(DpnsVoteFailure::PlatformRejected), ), - Err(_) => failed_before_broadcast_outcome(timing), + Ok(vote_on_dpns_name::DpnsVoteAttempt::FailedBeforeSubmission(_)) | Err(_) => { + failed_before_broadcast_outcome(timing) + } } } @@ -346,6 +348,7 @@ impl AppContext { } DpnsVoteTargetStatus::Rejected | DpnsVoteTargetStatus::FailedBeforeSubmission + | DpnsVoteTargetStatus::Cancelled | DpnsVoteTargetStatus::NotApplied => { // An explicit Cast now action is a deliberate retry and // may create a new operation below. @@ -539,6 +542,8 @@ impl AppContext { } let attempt = app_context .submit_dpns_vote( + operation_id, + &target.key, &target.contested_name, target.requested_choice, &voter, @@ -582,6 +587,26 @@ impl AppContext { &sdk, ); } + Ok(vote_on_dpns_name::DpnsVoteAttempt::FailedBeforeSubmission( + error, + )) => { + tracing::warn!( + ?error, + voter_id = %target.key.voter_id, + contested_name = %target.contested_name, + "DPNS vote failed before it reached the network" + ); + if matches!(target.timing, VoteTiming::Scheduled(_)) { + retryable_scheduled_error = Some(error); + } else { + app_context.record_dpns_vote_diagnostic_with_dapi_context( + operation_id, + target.key.clone(), + error, + &sdk, + ); + } + } Err(error) => { tracing::warn!( ?error, @@ -1193,7 +1218,7 @@ mod tests { .unwrap() .targets[0] .status, - DpnsVoteTargetStatus::Unconfirmed + DpnsVoteTargetStatus::FailedBeforeSubmission ); } diff --git a/src/backend_task/contested_names/vote_on_dpns_name.rs b/src/backend_task/contested_names/vote_on_dpns_name.rs index b6a36af31..5eeaf2882 100644 --- a/src/backend_task/contested_names/vote_on_dpns_name.rs +++ b/src/backend_task/contested_names/vote_on_dpns_name.rs @@ -1,5 +1,6 @@ use crate::backend_task::error::TaskError; use crate::context::AppContext; +use crate::model::dpns_voting::{DpnsVoteOperationId, DpnsVoteTargetKey}; use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::Sdk; use dash_sdk::dpp::consensus::ConsensusError; @@ -35,6 +36,7 @@ pub(super) enum DpnsVoteAttempt { Confirmed, Unconfirmed(TaskError), Rejected(TaskError), + FailedBeforeSubmission(TaskError), } /// Build `[Value::from("dash"), Value::Text(normalized_label.to_owned())]` for a DPNS vote poll. @@ -81,9 +83,30 @@ fn classify_post_broadcast_error(error: dash_sdk::Error) -> DpnsVoteAttempt { } } +fn classify_broadcast_error(error: dash_sdk::Error) -> DpnsVoteAttempt { + match &error { + dash_sdk::Error::StateTransitionBroadcastError(broadcast_error) => { + let rejected = broadcast_error.cause.is_some(); + let error = TaskError::from(error); + if rejected { + DpnsVoteAttempt::Rejected(error) + } else { + DpnsVoteAttempt::Unconfirmed(error) + } + } + _ => DpnsVoteAttempt::FailedBeforeSubmission(TaskError::from(error)), + } +} + +fn classify_broadcast_journal_result(result: Result<(), TaskError>) -> Result<(), DpnsVoteAttempt> { + result.map_err(DpnsVoteAttempt::Unconfirmed) +} + impl AppContext { pub(super) async fn submit_dpns_vote( self: &Arc, + operation_id: DpnsVoteOperationId, + key: &DpnsVoteTargetKey, name: &str, vote_choice: ResourceVoteChoice, qualified_identity: &QualifiedIdentity, @@ -175,7 +198,12 @@ impl AppContext { ensure_valid_vote_transition_structure(&state_transition, sdk)?; state_transition.broadcast_request_for_state_transition()?; if let Err(error) = state_transition.broadcast(sdk, Some(settings)).await { - return Ok(classify_post_broadcast_error(error)); + return Ok(classify_broadcast_error(error)); + } + if let Err(attempt) = + classify_broadcast_journal_result(self.mark_dpns_vote_broadcast(operation_id, key)) + { + return Ok(attempt); } match Vote::wait_for_response(sdk, state_transition, Some(settings)).await { @@ -233,4 +261,23 @@ mod tests { assert!(matches!(attempt, DpnsVoteAttempt::Unconfirmed(_))); } + + #[test] + fn broadcast_transport_error_fails_before_submission() { + let attempt = + classify_broadcast_error(dash_sdk::Error::Generic("connection refused".to_owned())); + + assert!(matches!( + attempt, + DpnsVoteAttempt::FailedBeforeSubmission(_) + )); + } + + #[test] + fn journal_failure_after_broadcast_is_unconfirmed() { + let attempt = classify_broadcast_journal_result(Err(TaskError::DpnsVoteTargetBusy)) + .expect_err("a failed journal mark must stop the confirmation wait"); + + assert!(matches!(attempt, DpnsVoteAttempt::Unconfirmed(_))); + } } diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index fd9877951..b8b7a2ac7 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -743,6 +743,12 @@ pub enum TaskError { )] DpnsVoteTargetBusy, + /// A cancellation lost the race to execution after the target was claimed. + #[error( + "This scheduled vote has already started and can no longer be cancelled. Check its result once it finishes." + )] + DpnsScheduledVoteAlreadyStarted, + /// Current proved state is required to suppress duplicate/no-op votes safely. #[error( "This node's current vote could not be checked. Refresh vote state before submitting." diff --git a/src/context/contested_names_db.rs b/src/context/contested_names_db.rs index e95c0c0f3..bd9256f44 100644 --- a/src/context/contested_names_db.rs +++ b/src/context/contested_names_db.rs @@ -676,7 +676,7 @@ mod tests { timing: VoteTiming::Scheduled(42), }, ]); - operation.targets[0].status = DpnsVoteTargetStatus::NotApplied; + operation.targets[0].status = DpnsVoteTargetStatus::Cancelled; assert_eq!( scheduled_vote_journal_summary(&[operation], voter_id), diff --git a/src/context/dpns_vote_operations.rs b/src/context/dpns_vote_operations.rs index 737eacd3b..5eba99992 100644 --- a/src/context/dpns_vote_operations.rs +++ b/src/context/dpns_vote_operations.rs @@ -4,7 +4,8 @@ use super::AppContext; use crate::backend_task::error::TaskError; use crate::model::dpns_voting::{ DpnsCurrentVoteState, DpnsVoteFailure, DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteTarget, - DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, unavailable_preflight_outcome, + DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, failed_before_broadcast_outcome, + unavailable_preflight_outcome, }; use crate::wallet_backend::{DetKv, DetScope, KvAdapterError}; use dash_sdk::dpp::dashcore::Network; @@ -454,7 +455,8 @@ fn cancel_scheduled_target( if outcome.status != DpnsVoteTargetStatus::Scheduled { return Ok(false); } - outcome.status = DpnsVoteTargetStatus::NotApplied; + outcome.status = DpnsVoteTargetStatus::Cancelled; + outcome.failure = None; write_existing_operation(kv, network, &operation)?; Ok(true) } @@ -465,7 +467,8 @@ fn cancel_all_scheduled_targets(kv: &DetKv, network: Network) -> Result Result bool { +fn recover_interrupted_target_statuses(operation: &mut DpnsVoteOperation) -> bool { let mut changed = false; for outcome in &mut operation.targets { - if matches!( - outcome.status, - DpnsVoteTargetStatus::Submitting | DpnsVoteTargetStatus::Confirming - ) { - outcome.status = DpnsVoteTargetStatus::Unconfirmed; - outcome.failure = Some(DpnsVoteFailure::ResultUnconfirmed); - changed = true; + match outcome.status { + DpnsVoteTargetStatus::Submitting => { + (outcome.status, outcome.failure) = + failed_before_broadcast_outcome(outcome.target.timing); + changed = true; + } + DpnsVoteTargetStatus::Confirming => { + outcome.status = DpnsVoteTargetStatus::Unconfirmed; + outcome.failure = Some(DpnsVoteFailure::ResultUnconfirmed); + changed = true; + } + _ => {} } } changed } +fn mark_target_broadcast( + kv: &DetKv, + network: Network, + operation_id: DpnsVoteOperationId, + key: &DpnsVoteTargetKey, +) -> Result<(), TaskError> { + let Some(mut operation): Option = kv + .get(DetScope::Global, &operation_key(network, operation_id)) + .map_err(unreadable_operation_err)? + else { + return Ok(()); + }; + let Some(outcome) = operation + .targets + .iter_mut() + .find(|outcome| outcome.target.key == *key) + else { + return Ok(()); + }; + if outcome.status == DpnsVoteTargetStatus::Submitting { + outcome.status = DpnsVoteTargetStatus::Confirming; + write_existing_operation(kv, network, &operation)?; + } + Ok(()) +} + impl AppContext { /// Durably claim one due scheduled target before any executor can observe it. pub(crate) fn queue_scheduled_dpns_vote_target( @@ -686,6 +720,19 @@ impl AppContext { Ok(true) } + /// Record that a target was broadcast before waiting for its result. + pub(crate) fn mark_dpns_vote_broadcast( + &self, + operation_id: DpnsVoteOperationId, + key: &DpnsVoteTargetKey, + ) -> Result<(), TaskError> { + let _guard = self + .dpns_vote_operation_guard + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + mark_target_broadcast(&self.det_kv()?, self.network, operation_id, key) + } + /// Apply fresh proved state only while the target is still queued. /// /// Returning `false` means another executor already advanced the target; @@ -735,7 +782,7 @@ impl AppContext { Ok(still_queued) } - /// Convert crash-interrupted transitions into conservative reconciliation. + /// Recover interrupted targets according to their durable broadcast phase. pub(crate) fn recover_interrupted_dpns_vote_operations(&self) -> Result<(), TaskError> { let _guard = self .dpns_vote_operation_guard @@ -743,7 +790,7 @@ impl AppContext { .unwrap_or_else(std::sync::PoisonError::into_inner); let kv = self.det_kv()?; for mut operation in load_operations(&kv, self.network)? { - if mark_interrupted_targets_unconfirmed(&mut operation) { + if recover_interrupted_target_statuses(&mut operation) { persist_operation(&kv, self.network, &operation)?; } } @@ -766,7 +813,7 @@ impl AppContext { else { return Ok(()); }; - if mark_interrupted_targets_unconfirmed(&mut operation) { + if recover_interrupted_target_statuses(&mut operation) { persist_operation(&kv, self.network, &operation)?; } Ok(()) @@ -826,8 +873,11 @@ impl AppContext { .dpns_vote_operation_guard .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - cancel_scheduled_target(&self.det_kv()?, self.network, operation_id, key)?; - Ok(()) + if cancel_scheduled_target(&self.det_kv()?, self.network, operation_id, key)? { + Ok(()) + } else { + Err(TaskError::DpnsScheduledVoteAlreadyStarted) + } } /// Release every not-yet-submitting scheduled target on this network. @@ -1094,6 +1144,22 @@ mod tests { ); } + #[test] + fn cancellation_records_cancelled_without_claiming_a_proved_outcome() { + let kv = kv(); + let mut scheduled = operation(DpnsVoteTargetStatus::Scheduled); + scheduled.targets[0].target.timing = VoteTiming::Scheduled(42); + scheduled.targets[0].failure = Some(DpnsVoteFailure::SubmissionFailed); + let key = scheduled.targets[0].target.key.clone(); + persist_operation(&kv, Network::Testnet, &scheduled).unwrap(); + + assert!(cancel_scheduled_target(&kv, Network::Testnet, scheduled.id, &key).unwrap()); + let cancelled = load_operations(&kv, Network::Testnet).unwrap().remove(0); + assert_eq!(cancelled.targets[0].status, DpnsVoteTargetStatus::Cancelled); + assert_eq!(cancelled.targets[0].failure, None); + assert!(!cancelled.targets[0].status.holds_lock()); + } + #[test] fn cancel_all_preserves_targets_that_are_already_queued() { let kv = kv(); @@ -1109,6 +1175,10 @@ mod tests { 1 ); let operations = load_operations(&kv, Network::Testnet).unwrap(); + assert!(operations.iter().any(|operation| { + operation.targets[0].target.key == scheduled.targets[0].target.key + && operation.targets[0].status == DpnsVoteTargetStatus::Cancelled + })); assert!(operations.iter().any(|operation| { operation.targets[0].target.key == queued.targets[0].target.key && operation.targets[0].status == DpnsVoteTargetStatus::Queued @@ -1347,9 +1417,37 @@ mod tests { } #[test] - fn interrupted_submission_recovers_to_unconfirmed() { + fn interrupted_immediate_submission_recovers_before_submission() { + let mut operation = operation(DpnsVoteTargetStatus::Submitting); + assert!(recover_interrupted_target_statuses(&mut operation)); + assert_eq!( + operation.targets[0].status, + DpnsVoteTargetStatus::FailedBeforeSubmission + ); + assert_eq!( + operation.targets[0].failure, + Some(DpnsVoteFailure::SubmissionFailed) + ); + } + + #[test] + fn interrupted_scheduled_submission_is_restored_for_retry() { let mut operation = operation(DpnsVoteTargetStatus::Submitting); - assert!(mark_interrupted_targets_unconfirmed(&mut operation)); + operation.targets[0].target.timing = VoteTiming::Scheduled(42); + + assert!(recover_interrupted_target_statuses(&mut operation)); + assert_eq!(operation.targets[0].status, DpnsVoteTargetStatus::Scheduled); + assert_eq!( + operation.targets[0].failure, + Some(DpnsVoteFailure::SubmissionFailed) + ); + } + + #[test] + fn interrupted_confirmation_recovers_to_unconfirmed() { + let mut operation = operation(DpnsVoteTargetStatus::Confirming); + + assert!(recover_interrupted_target_statuses(&mut operation)); assert_eq!( operation.targets[0].status, DpnsVoteTargetStatus::Unconfirmed @@ -1359,4 +1457,19 @@ mod tests { Some(DpnsVoteFailure::ResultUnconfirmed) ); } + + #[test] + fn successful_broadcast_crosses_the_durable_phase_boundary() { + let kv = kv(); + let submitting = operation(DpnsVoteTargetStatus::Submitting); + let key = submitting.targets[0].target.key.clone(); + persist_operation(&kv, Network::Testnet, &submitting).unwrap(); + + mark_target_broadcast(&kv, Network::Testnet, submitting.id, &key).unwrap(); + + assert_eq!( + load_operations(&kv, Network::Testnet).unwrap()[0].targets[0].status, + DpnsVoteTargetStatus::Confirming + ); + } } diff --git a/src/model/dpns_voting.rs b/src/model/dpns_voting.rs index bd3dd0664..3903415a9 100644 --- a/src/model/dpns_voting.rs +++ b/src/model/dpns_voting.rs @@ -74,6 +74,7 @@ mod tests { DpnsVoteTargetStatus::Confirmed, DpnsVoteTargetStatus::Rejected, DpnsVoteTargetStatus::FailedBeforeSubmission, + DpnsVoteTargetStatus::Cancelled, DpnsVoteTargetStatus::NotApplied, ] { assert!(!status.holds_lock(), "{status:?} must release its lock"); @@ -223,7 +224,10 @@ pub enum DpnsVoteTargetStatus { Unconfirmed, Rejected, FailedBeforeSubmission, + /// Reconciliation proved that a submitted vote was not applied. NotApplied, + /// The user cancelled a scheduled vote before it was submitted. + Cancelled, } impl DpnsVoteTargetStatus { diff --git a/src/ui/masternodes/detail_screen.rs b/src/ui/masternodes/detail_screen.rs index 112b43ab1..36daafaef 100644 --- a/src/ui/masternodes/detail_screen.rs +++ b/src/ui/masternodes/detail_screen.rs @@ -23,7 +23,9 @@ use crate::backend_task::BackendTask; use crate::backend_task::contested_names::ContestedResourceTask; use crate::backend_task::identity::{IdentityInputToLoad, IdentityLoadMode, IdentityTask}; use crate::context::AppContext; -use crate::model::contested_name::{ContestedName, MasternodeContestSummary}; +use crate::model::contested_name::{ + ContestedName, MasternodeContestSummary, MasternodeVoteStateSummary, +}; use crate::model::dpns_voting::{DpnsCurrentVoteState, DpnsVoteTargetKey}; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::{ @@ -54,10 +56,19 @@ const MISSING_VOTER_MESSAGE: &str = /// Β§7 copy: shown when the node has a voter identity but no open contests. const NO_OPEN_CONTESTS_MESSAGE: &str = "There are no open name contests for this node to vote on right now."; +const CONTESTS_UNAVAILABLE_MESSAGE: &str = + "Name contest information is unavailable. Refresh and try again."; /// The collapsible DPNS section header, with the open-contest count (TC-DPNS-02). -fn dpns_section_header(open_contest_count: usize) -> String { - format!("DPNS name contests to vote on ({open_contest_count})") +fn dpns_section_header(summary: MasternodeContestSummary) -> String { + if summary.vote_state == MasternodeVoteStateSummary::Unavailable { + CONTESTS_UNAVAILABLE_MESSAGE.to_owned() + } else { + format!( + "DPNS name contests to vote on ({})", + summary.open_contest_count + ) + } } /// Framing shown once above the per-contest vote controls, so a masternode @@ -299,10 +310,10 @@ impl MasternodeDetailView { .associated_voter_identity .as_ref() .map(|_| identity.identity.id()); - let contest_summary = app_context + let mut contest_summary = app_context .masternode_contest_summary(voter_id) - .unwrap_or_default(); - let open_contests = Self::load_open_contests(app_context, voter_id); + .unwrap_or_else(|_| MasternodeContestSummary::unavailable()); + let open_contests = Self::load_open_contests(app_context, voter_id, &mut contest_summary); let vote_operations = DpnsVoteOperationSnapshot::load(app_context).unwrap_or_else(|error| { tracing::warn!( @@ -328,21 +339,25 @@ impl MasternodeDetailView { } } - /// Load the contests this node can still vote on. Empty when the node has no - /// voting key (no voter id) or the read fails. + /// Load the contests this node can still vote on. fn load_open_contests( app_context: &Arc, voter_id: Option, + contest_summary: &mut MasternodeContestSummary, ) -> Vec { let Some(voter_id) = voter_id else { return Vec::new(); }; - app_context - .ongoing_contested_names() - .unwrap_or_default() - .into_iter() - .filter(|contest| contest.is_open_for_voter(&voter_id)) - .collect() + match app_context.ongoing_contested_names() { + Ok(contests) => contests + .into_iter() + .filter(|contest| contest.is_open_for_voter(&voter_id)) + .collect(), + Err(_) => { + *contest_summary = MasternodeContestSummary::unavailable(); + Vec::new() + } + } } /// Refresh the DPNS contest summary + open-contest list from the store. @@ -352,11 +367,13 @@ impl MasternodeDetailView { .associated_voter_identity .as_ref() .map(|_| self.identity.identity.id()); - self.contest_summary = self + let mut contest_summary = self .app_context .masternode_contest_summary(voter_id) - .unwrap_or_default(); - self.open_contests = Self::load_open_contests(&self.app_context, voter_id); + .unwrap_or_else(|_| MasternodeContestSummary::unavailable()); + self.open_contests = + Self::load_open_contests(&self.app_context, voter_id, &mut contest_summary); + self.contest_summary = contest_summary; } /// Build the network re-fetch dispatched by the detail Refresh button: @@ -764,11 +781,16 @@ impl MasternodeDetailView { } let mut action = None; - let header = dpns_section_header(self.contest_summary.open_contest_count); + let header = dpns_section_header(self.contest_summary); egui::CollapsingHeader::new(header) .default_open(false) .show(ui, |ui| { - if self.open_contests.is_empty() { + if self.contest_summary.vote_state == MasternodeVoteStateSummary::Unavailable { + ui.label( + RichText::new(CONTESTS_UNAVAILABLE_MESSAGE) + .color(DashColors::warning_color(dark_mode)), + ); + } else if self.open_contests.is_empty() { ui.label( RichText::new(NO_OPEN_CONTESTS_MESSAGE) .color(DashColors::text_secondary(dark_mode)), @@ -1259,8 +1281,17 @@ mod tests { #[test] fn tc_dpns_02_header_shows_open_contest_count() { - assert_eq!(dpns_section_header(3), "DPNS name contests to vote on (3)"); - assert_eq!(dpns_section_header(0), "DPNS name contests to vote on (0)"); + let mut summary = MasternodeContestSummary::default(); + summary.open_contest_count = 3; + assert_eq!( + dpns_section_header(summary), + "DPNS name contests to vote on (3)" + ); + + assert_eq!( + dpns_section_header(MasternodeContestSummary::unavailable()), + "Name contest information is unavailable. Refresh and try again." + ); } #[test] diff --git a/src/ui/masternodes/list_screen.rs b/src/ui/masternodes/list_screen.rs index 25cd735c5..2b1d27e3d 100644 --- a/src/ui/masternodes/list_screen.rs +++ b/src/ui/masternodes/list_screen.rs @@ -421,6 +421,7 @@ impl MasternodesScreen { DpnsVoteTargetStatus::Unconfirmed => "Checking result", DpnsVoteTargetStatus::Rejected => "Rejected", DpnsVoteTargetStatus::FailedBeforeSubmission => "Failed before submission", + DpnsVoteTargetStatus::Cancelled => "Cancelled", DpnsVoteTargetStatus::NotApplied => "Not applied", }; ui.horizontal_wrapped(|ui| { @@ -631,7 +632,8 @@ impl MasternodesScreen { DpnsVoteTargetStatus::FailedBeforeSubmission => { "Status: Failed before submission" } - DpnsVoteTargetStatus::NotApplied => "Status: Cancelled", + DpnsVoteTargetStatus::Cancelled => "Status: Cancelled", + DpnsVoteTargetStatus::NotApplied => "Status: Not applied", }); let editable = status == DpnsVoteTargetStatus::Scheduled; let disabled_reason = diff --git a/src/ui/masternodes/voting_center.rs b/src/ui/masternodes/voting_center.rs index 591db96bb..c2663557d 100644 --- a/src/ui/masternodes/voting_center.rs +++ b/src/ui/masternodes/voting_center.rs @@ -622,6 +622,7 @@ impl DpnsVotingCenter { outcome.status, DpnsVoteTargetStatus::Rejected | DpnsVoteTargetStatus::FailedBeforeSubmission + | DpnsVoteTargetStatus::Cancelled | DpnsVoteTargetStatus::NotApplied ) }) && ComponentStyles::add_secondary_button(ui, "Review again", dark_mode) @@ -635,6 +636,7 @@ impl DpnsVotingCenter { outcome.status, DpnsVoteTargetStatus::Rejected | DpnsVoteTargetStatus::FailedBeforeSubmission + | DpnsVoteTargetStatus::Cancelled | DpnsVoteTargetStatus::NotApplied ) }) @@ -650,6 +652,7 @@ impl DpnsVotingCenter { outcome.status, DpnsVoteTargetStatus::Rejected | DpnsVoteTargetStatus::FailedBeforeSubmission + | DpnsVoteTargetStatus::Cancelled | DpnsVoteTargetStatus::NotApplied ) { self.workspace @@ -1175,6 +1178,7 @@ fn status_label(status: DpnsVoteTargetStatus) -> &'static str { DpnsVoteTargetStatus::Unconfirmed => "Checking result", DpnsVoteTargetStatus::Rejected => "Rejected", DpnsVoteTargetStatus::FailedBeforeSubmission => "Failed before submission", + DpnsVoteTargetStatus::Cancelled => "Cancelled", DpnsVoteTargetStatus::NotApplied => "Not applied", } } @@ -1195,8 +1199,11 @@ fn status_explanation(status: DpnsVoteTargetStatus) -> &'static str { DpnsVoteTargetStatus::FailedBeforeSubmission => { "This vote was not submitted. Review it before trying again." } + DpnsVoteTargetStatus::Cancelled => { + "This scheduled vote was cancelled before it was submitted." + } DpnsVoteTargetStatus::NotApplied => { - "DET proved that this vote was not applied. It is safe to review it again." + "This vote was not applied. You can safely review it and try again." } } } From 79b5c00b4e0d44c2defb967aaf2ac22cb973b21d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:37:48 +0000 Subject: [PATCH 20/39] fix(dpns-voting): address remaining PR 901 review findings Co-Authored-By: OpenAI Codex --- src/app.rs | 213 ++++++++++++++++++++---- src/backend_task/contested_names/mod.rs | 89 +++++++++- src/context/dpns_vote_operations.rs | 92 +++++++++- src/context/identity_db.rs | 92 +++++++++- 4 files changed, 432 insertions(+), 54 deletions(-) diff --git a/src/app.rs b/src/app.rs index 4ffe6a302..6f3e87ad8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -220,28 +220,84 @@ fn dpns_vote_feedback(operation: &DpnsVoteOperation) -> (String, MessageType, bo | DpnsVoteTargetStatus::Confirming => counts.in_progress += 1, } } - let message = format!( - "Voting results: {} confirmed, {} scheduled, {} unconfirmed, {} rejected, {} failed before submission, {} cancelled, {} not applied, and {} still in progress. Open Voting activity to review each target.", - counts.confirmed, - counts.scheduled, - counts.unconfirmed, - counts.rejected, - counts.failed_before_submission, - counts.cancelled, - counts.not_applied, - counts.in_progress, - ); - let needs_attention = - counts.unconfirmed + counts.rejected + counts.failed_before_submission + counts.not_applied - > 0; - let message_type = if needs_attention { - MessageType::Warning - } else if counts.in_progress > 0 { - MessageType::Info + if operation.targets.is_empty() { + return ( + "This node already has that vote. Nothing was submitted.".to_owned(), + MessageType::Info, + false, + ); + } + let target_count = operation.targets.len(); + if counts.confirmed == target_count { + let message = if target_count == 1 { + "Vote cast successfully.".to_owned() + } else { + format!("{target_count} votes were cast successfully.") + }; + return (message, MessageType::Success, false); + } + if counts.scheduled == target_count { + return ( + format!("{target_count} votes were scheduled."), + MessageType::Success, + false, + ); + } + if counts.unconfirmed == target_count { + return ( + "The vote was submitted, but DET could not confirm the result yet. DET will keep checking. Do not submit it again.".to_owned(), + MessageType::Warning, + true, + ); + } + if counts.rejected == target_count { + return ( + "The vote was rejected. Review the vote and try again.".to_owned(), + MessageType::Error, + false, + ); + } + if counts.failed_before_submission == target_count { + return ( + "This vote was not submitted. Check your connection and try again.".to_owned(), + MessageType::Error, + false, + ); + } + if counts.not_applied == target_count { + return ( + "The submitted vote was not applied. Review the vote and try again.".to_owned(), + MessageType::Error, + false, + ); + } + if counts.cancelled == target_count { + return ( + "The scheduled vote was cancelled. Nothing was submitted.".to_owned(), + MessageType::Info, + false, + ); + } + if counts.in_progress == target_count { + return ( + "Voting is still in progress. Wait for the result before submitting again.".to_owned(), + MessageType::Info, + false, + ); + } + + let remaining = target_count.saturating_sub(counts.confirmed); + let confirmed = counts.confirmed; + let message = if counts.unconfirmed > 0 { + format!( + "{confirmed} of {target_count} votes were confirmed. Review the remaining {remaining}. The vote was submitted, but DET could not confirm the result yet. DET will keep checking. Do not submit it again.", + ) } else { - MessageType::Success + format!( + "{confirmed} of {target_count} votes were confirmed. Review the remaining {remaining}.", + ) }; - (message, message_type, counts.unconfirmed > 0) + (message, MessageType::Warning, counts.unconfirmed > 0) } /// Action id for the SPV-sync block's "Continue in the background" escape button. @@ -2346,6 +2402,7 @@ impl App for AppState { ) { self.scheduled_vote_recovery_last_attempt.remove(&network); } + self.visible_screen_mut().refresh(); } BackendTaskSuccessResult::NetworkContextCreated { network, @@ -2809,7 +2866,102 @@ mod migration_banner_tests { } #[test] - fn mixed_vote_feedback_counts_every_terminal_category_and_guides_to_details() { + fn vote_feedback_uses_copy_for_each_complete_outcome() { + let cases = [ + ( + vec![DpnsVoteTargetStatus::Confirmed], + 0, + "Vote cast successfully.", + MessageType::Success, + false, + ), + ( + vec![ + DpnsVoteTargetStatus::Confirmed, + DpnsVoteTargetStatus::Confirmed, + ], + 0, + "2 votes were cast successfully.", + MessageType::Success, + false, + ), + ( + vec![ + DpnsVoteTargetStatus::Confirmed, + DpnsVoteTargetStatus::Confirmed, + ], + 1, + "2 votes were cast successfully.", + MessageType::Success, + false, + ), + ( + vec![ + DpnsVoteTargetStatus::Scheduled, + DpnsVoteTargetStatus::Scheduled, + ], + 0, + "2 votes were scheduled.", + MessageType::Success, + false, + ), + ( + vec![ + DpnsVoteTargetStatus::Scheduled, + DpnsVoteTargetStatus::Scheduled, + ], + 1, + "2 votes were scheduled.", + MessageType::Success, + false, + ), + ( + vec![DpnsVoteTargetStatus::Unconfirmed], + 0, + "The vote was submitted, but DET could not confirm the result yet. DET will keep checking. Do not submit it again.", + MessageType::Warning, + true, + ), + ( + vec![DpnsVoteTargetStatus::Rejected], + 0, + "The vote was rejected. Review the vote and try again.", + MessageType::Error, + false, + ), + ( + vec![DpnsVoteTargetStatus::FailedBeforeSubmission], + 0, + "This vote was not submitted. Check your connection and try again.", + MessageType::Error, + false, + ), + ( + Vec::new(), + 1, + "This node already has that vote. Nothing was submitted.", + MessageType::Info, + false, + ), + ]; + + for (statuses, no_op_count, expected_message, expected_type, expected_visibility) in cases { + let mut operation = feedback_operation(&statuses); + operation.no_op_count = no_op_count; + + assert_eq!( + dpns_vote_feedback(&operation), + ( + expected_message.to_owned(), + expected_type, + expected_visibility + ) + ); + } + } + + #[test] + fn mixed_vote_feedback_reports_partial_result_and_unconfirmed_guidance() { let operation = feedback_operation(&[ DpnsVoteTargetStatus::Confirmed, DpnsVoteTargetStatus::Scheduled, @@ -2824,21 +2976,10 @@ mod migration_banner_tests { assert_eq!(message_type, MessageType::Warning); assert!(keep_visible); - for phrase in [ - "1 confirmed", - "1 scheduled", - "1 unconfirmed", - "1 rejected", - "1 failed before submission", - "1 cancelled", - "1 not applied", - "Open Voting activity", - ] { - assert!( - message.contains(phrase), - "missing `{phrase}` in `{message}`" - ); - } + assert_eq!( + message, + "1 of 7 votes were confirmed. Review the remaining 6. The vote was submitted, but DET could not confirm the result yet. DET will keep checking. Do not submit it again." + ); } /// A frame owns one migration snapshot even if the task publishes mid-frame. diff --git a/src/backend_task/contested_names/mod.rs b/src/backend_task/contested_names/mod.rs index cc04221e1..f5cc5d40e 100644 --- a/src/backend_task/contested_names/mod.rs +++ b/src/backend_task/contested_names/mod.rs @@ -315,14 +315,13 @@ impl AppContext { scheduled_vote: &ScheduledDPNSVote, voter: &QualifiedIdentity, ) -> Result { - if let Some(mut operation) = self.dpns_vote_operations()?.into_iter().find(|operation| { + if let Some(operation) = self.dpns_vote_operations()?.into_iter().find(|operation| { operation.targets.iter().any(|outcome| { outcome.target.key.voter_id == scheduled_vote.voter_id && outcome.target.contested_name == scheduled_vote.contested_name }) }) { - let mut target_queued = false; - for outcome in &mut operation.targets { + for outcome in &operation.targets { if outcome.target.key.voter_id != scheduled_vote.voter_id || outcome.target.contested_name != scheduled_vote.contested_name { @@ -330,8 +329,14 @@ impl AppContext { } match outcome.status { DpnsVoteTargetStatus::Scheduled => { - outcome.status = DpnsVoteTargetStatus::Queued; - target_queued = true; + if !self + .queue_scheduled_dpns_vote_target(operation.id, &outcome.target.key)? + { + return Err(TaskError::DpnsVoteTargetBusy); + } + return self + .dpns_vote_operation(operation.id)? + .ok_or(TaskError::DpnsVoteOperationRecordMissing); } DpnsVoteTargetStatus::Unconfirmed | DpnsVoteTargetStatus::Queued @@ -355,9 +360,6 @@ impl AppContext { } } } - if target_queued { - return Ok(operation); - } } let target = self.dpns_vote_target( @@ -971,7 +973,11 @@ fn scheduled_target_should_execute( #[cfg(test)] mod tests { use super::*; + use crate::model::qualified_identity::{IdentityStatus, IdentityType}; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::version::PlatformVersion; use std::cell::RefCell; + use std::collections::BTreeMap; fn dapi_connection_refused_error() -> TaskError { use dash_sdk::Error as SdkError; @@ -984,6 +990,73 @@ mod tests { ))) } + fn qualified_identity(byte: u8) -> QualifiedIdentity { + let identity = Identity::create_basic_identity( + Identifier::from([byte; 32]), + PlatformVersion::latest(), + ) + .expect("basic identity"); + QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys: Default::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + } + } + + #[test] + fn cast_now_durably_queues_an_existing_scheduled_operation() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let context = crate::context::test_support::test_app_context(temp_dir.path()); + let kv = crate::wallet_backend::DetKv::from_store(Arc::new( + crate::wallet_backend::kv_test_support::InMemoryKv::default(), + )); + context.set_det_kv_override_for_test(kv); + let voter = qualified_identity(1); + let scheduled_vote = ScheduledDPNSVote { + contested_name: "dominguez".to_owned(), + voter_id: Identifier::from([1; 32]), + choice: ResourceVoteChoice::Lock, + unix_timestamp: 42, + executed_successfully: false, + }; + let target = context + .dpns_vote_target( + &voter, + &scheduled_vote.contested_name, + scheduled_vote.choice, + VoteTiming::Scheduled(scheduled_vote.unix_timestamp), + false, + ) + .expect("scheduled target"); + let mut operation = DpnsVoteOperation::new(vec![target]); + let operation_id = operation.id; + context + .insert_dpns_vote_operation(&mut operation, None) + .expect("persist scheduled operation"); + + let returned = context + .operation_for_scheduled_vote(&scheduled_vote, &voter) + .expect("queue scheduled operation"); + let persisted = context + .dpns_vote_operation(operation_id) + .expect("load operation") + .expect("persisted operation"); + + assert_eq!(returned.targets[0].status, DpnsVoteTargetStatus::Queued); + assert_eq!(persisted.targets[0].status, DpnsVoteTargetStatus::Queued); + } + #[test] fn vote_diagnostic_contextualizes_exhausted_dapi_addresses() { let temp_dir = tempfile::tempdir().expect("tempdir"); diff --git a/src/context/dpns_vote_operations.rs b/src/context/dpns_vote_operations.rs index 5eba99992..41be3788a 100644 --- a/src/context/dpns_vote_operations.rs +++ b/src/context/dpns_vote_operations.rs @@ -9,7 +9,7 @@ use crate::model::dpns_voting::{ }; use crate::wallet_backend::{DetKv, DetScope, KvAdapterError}; use dash_sdk::dpp::dashcore::Network; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; const LEGACY_OPERATION_INDEX_KEY: &str = "det:dpns_vote_operations:v1"; @@ -311,10 +311,24 @@ fn write_existing_operation( persist_operation(kv, network, operation) } -fn prune_terminal_operations(kv: &DetKv, network: Network) -> Result { +fn prune_terminal_operations( + kv: &DetKv, + network: Network, + removed_scheduled_votes: &BTreeSet<([u8; 32], String)>, +) -> Result { let terminal_ids = load_operations(kv, network)? .into_iter() - .filter(DpnsVoteOperation::is_complete) + .filter(|operation| { + operation.is_complete() + && !operation.targets.is_empty() + && operation.targets.iter().all(|outcome| { + matches!(outcome.target.timing, VoteTiming::Scheduled(_)) + && removed_scheduled_votes.contains(&( + outcome.target.key.voter_id.to_buffer(), + outcome.target.contested_name.clone(), + )) + }) + }) .map(|operation| operation.id) .collect::>(); if terminal_ids.is_empty() { @@ -582,7 +596,18 @@ impl AppContext { .dpns_vote_operation_guard .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - load_operations_read_only(&self.det_kv()?, self.network) + let kv = self.det_kv()?; + if kv + .get::( + DetScope::Global, + &operation_lock_index_dirty_key(self.network), + ) + .map_err(unreadable_operation_err)? + .unwrap_or(false) + { + rebuild_lock_index(&kv, self.network)?; + } + load_operations_read_only(&kv, self.network) } /// Migrate legacy journals and scheduled-vote mirrors before backend recovery. @@ -855,12 +880,15 @@ impl AppContext { } /// Remove lock-releasing operation history when the user clears completed votes. - pub(crate) fn prune_terminal_dpns_vote_operations(&self) -> Result { + pub(crate) fn prune_terminal_dpns_vote_operations( + &self, + removed_scheduled_votes: &BTreeSet<([u8; 32], String)>, + ) -> Result { let _guard = self .dpns_vote_operation_guard .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - prune_terminal_operations(&self.det_kv()?, self.network) + prune_terminal_operations(&self.det_kv()?, self.network, removed_scheduled_votes) } /// Release a not-yet-submitting scheduled target after explicit cancellation. @@ -1319,6 +1347,46 @@ mod tests { ); } + #[test] + fn operation_getter_repairs_a_dirty_record_and_lock_index() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let store = Arc::new(InMemoryKv::default()); + let kv = DetKv::from_store(store.clone()); + let context = crate::context::test_support::test_app_context_with_kv( + temp_dir.path(), + Arc::new(DetKv::from_store(store)), + ); + context.set_det_kv_override_for_test(kv.clone()); + let operation = operation(DpnsVoteTargetStatus::Unconfirmed); + persist_operation(&kv, Network::Testnet, &operation).unwrap(); + kv.put( + DetScope::Global, + &operation_index_key(Network::Testnet), + &vec![ + operation.id.to_bytes(), + DpnsVoteOperationId::from_bytes([9; 16]).to_bytes(), + ], + ) + .unwrap(); + kv.put( + DetScope::Global, + &operation_lock_index_dirty_key(Network::Testnet), + &true, + ) + .unwrap(); + + assert_eq!(context.dpns_vote_operations().unwrap(), vec![operation]); + assert!( + kv.get::( + DetScope::Global, + &operation_lock_index_dirty_key(Network::Testnet), + ) + .unwrap() + .is_none(), + "a successful rebuild must clear the dirty marker" + ); + } + #[tokio::test] async fn operation_getter_does_not_migrate_legacy_scheduled_votes() { let temp_dir = tempfile::tempdir().expect("tempdir"); @@ -1360,12 +1428,20 @@ mod tests { #[test] fn pruning_removes_terminal_records_and_preserves_live_locks() { let kv = kv(); - let terminal = operation(DpnsVoteTargetStatus::Confirmed); + let mut terminal = operation(DpnsVoteTargetStatus::Confirmed); + terminal.targets[0].target.timing = VoteTiming::Scheduled(42); let live = operation(DpnsVoteTargetStatus::Unconfirmed); + let removed_scheduled_votes = BTreeSet::from([( + terminal.targets[0].target.key.voter_id.to_buffer(), + terminal.targets[0].target.contested_name.clone(), + )]); persist_operation(&kv, Network::Testnet, &terminal).unwrap(); persist_operation(&kv, Network::Testnet, &live).unwrap(); - assert_eq!(prune_terminal_operations(&kv, Network::Testnet).unwrap(), 1); + assert_eq!( + prune_terminal_operations(&kv, Network::Testnet, &removed_scheduled_votes).unwrap(), + 1 + ); assert_eq!( load_operations_read_only(&kv, Network::Testnet).unwrap(), vec![live] diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index 52f7b0fca..0fa6c242b 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -11,7 +11,7 @@ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dash_sdk::platform::Identifier; use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::{Arc, RwLock}; /// Identity blob slot, scoped to [`DetScope::Identity`]. One entry per @@ -1140,19 +1140,21 @@ impl AppContext { pub fn clear_executed_scheduled_votes(&self) -> std::result::Result<(), TaskError> { let kv = self.det_kv()?; let voters = load_scheduled_vote_voters(&kv)?; + let mut removed_scheduled_votes = BTreeSet::new(); for voter in &voters { let scope = DetScope::Identity(voter); for key in scheduled_vote_keys(&kv, voter)? { match kv.get::(scope, &key) { Ok(Some(stored)) if stored.executed_successfully => { kv.delete(scope, &key).map_err(scheduled_vote_err)?; + removed_scheduled_votes.insert((stored.voter_id, stored.contested_name)); } _ => {} } } prune_vote_voter_if_empty(&kv, voter)?; } - self.prune_terminal_dpns_vote_operations()?; + self.prune_terminal_dpns_vote_operations(&removed_scheduled_votes)?; Ok(()) } @@ -1541,6 +1543,92 @@ mod tests { assert_eq!(voters, vec![v1, v2]); } + #[test] + fn clearing_executed_votes_preserves_failed_and_cancelled_journal_records() { + use crate::model::dpns_voting::{ + DpnsVoteOperation, DpnsVoteTarget, DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, + }; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let context = crate::context::test_support::test_app_context(temp_dir.path()); + let kv = empty_kv(); + context.set_det_kv_override_for_test(kv); + let voter = Identifier::from(id(1)); + let cases = [ + ("confirmed", DpnsVoteTargetStatus::Confirmed, true), + ( + "failed", + DpnsVoteTargetStatus::FailedBeforeSubmission, + false, + ), + ("cancelled", DpnsVoteTargetStatus::Cancelled, false), + ]; + let scheduled_votes = cases + .iter() + .enumerate() + .map( + |(index, (name, _, executed_successfully))| ScheduledDPNSVote { + contested_name: (*name).to_owned(), + voter_id: voter, + choice: ResourceVoteChoice::Lock, + unix_timestamp: 42 + index as u64, + executed_successfully: *executed_successfully, + }, + ) + .collect::>(); + context.insert_scheduled_votes(&scheduled_votes).unwrap(); + for (index, (name, status, _)) in cases.iter().enumerate() { + let mut operation = DpnsVoteOperation::new(vec![DpnsVoteTarget { + key: DpnsVoteTargetKey { + network: Network::Testnet, + voter_id: voter, + vote_poll_id: Identifier::from([index as u8 + 1; 32]), + }, + voter_alias: None, + contested_name: (*name).to_owned(), + requested_choice: ResourceVoteChoice::Lock, + current_choice: None, + timing: VoteTiming::Scheduled(42 + index as u64), + }]); + operation.targets[0].status = *status; + context + .insert_dpns_vote_operation(&mut operation, None) + .unwrap(); + } + + context.clear_executed_scheduled_votes().unwrap(); + + let mut remaining_legacy_names = context + .get_scheduled_votes() + .unwrap() + .into_iter() + .map(|vote| vote.contested_name) + .collect::>(); + remaining_legacy_names.sort(); + assert_eq!(remaining_legacy_names, vec!["cancelled", "failed"]); + let remaining_operations = context.dpns_vote_operations().unwrap(); + assert_eq!(remaining_operations.len(), 2); + assert!(remaining_operations.iter().any(|operation| { + operation.targets[0].target.contested_name == "failed" + && operation.targets[0].status == DpnsVoteTargetStatus::FailedBeforeSubmission + })); + assert!(remaining_operations.iter().any(|operation| { + operation.targets[0].target.contested_name == "cancelled" + && operation.targets[0].status == DpnsVoteTargetStatus::Cancelled + })); + + context.migrate_dpns_vote_operations().unwrap(); + assert!( + context + .dpns_vote_operations() + .unwrap() + .iter() + .all(|operation| { + operation.targets[0].status != DpnsVoteTargetStatus::Scheduled + }) + ); + } + #[test] fn delete_scheduled_votes_for_voter_drains_scope_and_prunes_index() { let kv = empty_kv(); From 10a30907e735e23fbb5c8982861362a6c680b657 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:25:43 +0000 Subject: [PATCH 21/39] fix(dpns-voting): avoid field_reassign_with_default lint error Build a MasternodeContestSummary struct literal directly in the header test instead of default-then-reassign, which the strict-warnings lint gate rejects. Co-Authored-By: Claude Sonnet 5 --- src/ui/masternodes/detail_screen.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ui/masternodes/detail_screen.rs b/src/ui/masternodes/detail_screen.rs index 36daafaef..884ae65e2 100644 --- a/src/ui/masternodes/detail_screen.rs +++ b/src/ui/masternodes/detail_screen.rs @@ -1281,8 +1281,10 @@ mod tests { #[test] fn tc_dpns_02_header_shows_open_contest_count() { - let mut summary = MasternodeContestSummary::default(); - summary.open_contest_count = 3; + let summary = MasternodeContestSummary { + open_contest_count: 3, + ..Default::default() + }; assert_eq!( dpns_section_header(summary), "DPNS name contests to vote on (3)" From 8335bf948c5a17db2843e5e5486d6b8eb6aa5fdf Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 21 Jul 2026 08:07:02 +0000 Subject: [PATCH 22/39] fix(dpns-voting): move broadcast phase boundary earlier, fix immediate-vote pruning The durable phase-boundary marker crossed into the ambiguous "broadcast may have been sent" state only after broadcast() returned, missing the actual danger window during the RPC call itself (which retries internally). Move the mark-broadcast journal write before the broadcast call, fail closed if it can't be recorded, and reclassify broadcast-call errors: cause-less failures stay retryable (FailedBeforeSubmission), everything past the boundary is Unconfirmed or Rejected. prune_terminal_operations required every target in a complete operation to be Scheduled and legacy-row-cleared, so any immediate- vote-only or mixed operation could never be pruned (regression from this session's earlier legacy-row safeguard). Check per-target instead: immediate targets never needed a legacy row and stay prunable; scheduled targets still require their row confirmed removed. Verified: cargo test vote_on_dpns_name (11 passed), cargo test dpns_vote_operations (26 passed), clippy clean with -D warnings. Co-Authored-By: Codex Sol Co-Authored-By: Claude Opus 4.5 --- .../contested_names/vote_on_dpns_name.rs | 146 ++++++++++++++++-- src/backend_task/error.rs | 7 + src/context/dpns_vote_operations.rs | 108 ++++++++++--- 3 files changed, 229 insertions(+), 32 deletions(-) diff --git a/src/backend_task/contested_names/vote_on_dpns_name.rs b/src/backend_task/contested_names/vote_on_dpns_name.rs index 5eeaf2882..e17832df0 100644 --- a/src/backend_task/contested_names/vote_on_dpns_name.rs +++ b/src/backend_task/contested_names/vote_on_dpns_name.rs @@ -85,21 +85,45 @@ fn classify_post_broadcast_error(error: dash_sdk::Error) -> DpnsVoteAttempt { fn classify_broadcast_error(error: dash_sdk::Error) -> DpnsVoteAttempt { match &error { + dash_sdk::Error::Protocol(dash_sdk::dpp::ProtocolError::ConsensusError(_)) => { + DpnsVoteAttempt::Rejected(TaskError::from(error)) + } dash_sdk::Error::StateTransitionBroadcastError(broadcast_error) => { let rejected = broadcast_error.cause.is_some(); let error = TaskError::from(error); if rejected { DpnsVoteAttempt::Rejected(error) } else { - DpnsVoteAttempt::Unconfirmed(error) + DpnsVoteAttempt::FailedBeforeSubmission(error) } } - _ => DpnsVoteAttempt::FailedBeforeSubmission(TaskError::from(error)), + _ => DpnsVoteAttempt::Unconfirmed(TaskError::from(error)), + } +} + +fn classify_broadcast_journal_result( + result: Result, +) -> Result<(), DpnsVoteAttempt> { + match result { + Ok(true) => Ok(()), + Ok(false) => Err(DpnsVoteAttempt::FailedBeforeSubmission( + TaskError::DpnsVoteBroadcastPhaseNotMarked, + )), + Err(error) => Err(DpnsVoteAttempt::FailedBeforeSubmission(error)), } } -fn classify_broadcast_journal_result(result: Result<(), TaskError>) -> Result<(), DpnsVoteAttempt> { - result.map_err(DpnsVoteAttempt::Unconfirmed) +async fn broadcast_after_journal_mark( + mark_broadcast: Mark, + broadcast: Broadcast, +) -> Result<(), DpnsVoteAttempt> +where + Mark: FnOnce() -> Result, + Broadcast: FnOnce() -> BroadcastFuture, + BroadcastFuture: std::future::Future>, +{ + classify_broadcast_journal_result(mark_broadcast())?; + broadcast().await.map_err(classify_broadcast_error) } impl AppContext { @@ -197,11 +221,11 @@ impl AppContext { .map_err(dash_sdk::Error::from)?; ensure_valid_vote_transition_structure(&state_transition, sdk)?; state_transition.broadcast_request_for_state_transition()?; - if let Err(error) = state_transition.broadcast(sdk, Some(settings)).await { - return Ok(classify_broadcast_error(error)); - } - if let Err(attempt) = - classify_broadcast_journal_result(self.mark_dpns_vote_broadcast(operation_id, key)) + if let Err(attempt) = broadcast_after_journal_mark( + || self.mark_dpns_vote_broadcast(operation_id, key), + || state_transition.broadcast(sdk, Some(settings)), + ) + .await { return Ok(attempt); } @@ -263,10 +287,24 @@ mod tests { } #[test] - fn broadcast_transport_error_fails_before_submission() { + fn broadcast_transport_error_after_phase_boundary_is_unconfirmed() { let attempt = classify_broadcast_error(dash_sdk::Error::Generic("connection refused".to_owned())); + assert!(matches!(attempt, DpnsVoteAttempt::Unconfirmed(_))); + } + + #[test] + fn cause_less_broadcast_rejection_fails_before_submission() { + let attempt = classify_broadcast_error( + dash_sdk::error::StateTransitionBroadcastError { + code: 1, + message: "broadcast rejected".to_owned(), + cause: None, + } + .into(), + ); + assert!(matches!( attempt, DpnsVoteAttempt::FailedBeforeSubmission(_) @@ -274,10 +312,92 @@ mod tests { } #[test] - fn journal_failure_after_broadcast_is_unconfirmed() { + fn broadcast_consensus_error_is_rejected() { + let attempt = classify_broadcast_error(dash_sdk::Error::Protocol( + dash_sdk::dpp::ProtocolError::ConsensusError(Box::new(ConsensusError::DefaultError)), + )); + + assert!(matches!(attempt, DpnsVoteAttempt::Rejected(_))); + } + + #[test] + fn journal_failure_before_broadcast_fails_before_submission() { let attempt = classify_broadcast_journal_result(Err(TaskError::DpnsVoteTargetBusy)) - .expect_err("a failed journal mark must stop the confirmation wait"); + .expect_err("a failed journal mark must prevent the broadcast"); - assert!(matches!(attempt, DpnsVoteAttempt::Unconfirmed(_))); + assert!(matches!( + attempt, + DpnsVoteAttempt::FailedBeforeSubmission(_) + )); + } + + #[test] + fn journal_no_op_before_broadcast_fails_before_submission() { + let attempt = classify_broadcast_journal_result(Ok(false)) + .expect_err("a no-op journal mark must prevent the broadcast"); + + assert!(matches!( + attempt, + DpnsVoteAttempt::FailedBeforeSubmission(_) + )); + } + + #[tokio::test] + async fn durable_phase_boundary_precedes_broadcast() { + let marked = std::cell::Cell::new(false); + let broadcast_called = std::cell::Cell::new(false); + + broadcast_after_journal_mark( + || { + marked.set(true); + Ok(true) + }, + || async { + assert!(marked.get(), "journal must be marked before broadcasting"); + broadcast_called.set(true); + Ok(()) + }, + ) + .await + .expect("journal mark and broadcast succeed"); + + assert!(broadcast_called.get()); + } + + #[tokio::test] + async fn failed_phase_boundaries_prevent_broadcast() { + let broadcast_called = std::cell::Cell::new(false); + + let attempt = broadcast_after_journal_mark( + || Ok(false), + || async { + broadcast_called.set(true); + Ok(()) + }, + ) + .await + .expect_err("a no-op journal mark must stop submission"); + + assert!(matches!( + attempt, + DpnsVoteAttempt::FailedBeforeSubmission(_) + )); + assert!(!broadcast_called.get()); + + let attempt = broadcast_after_journal_mark( + || Err(TaskError::DpnsVoteTargetBusy), + || async { + broadcast_called.set(true); + Ok(()) + }, + ) + .await + .expect_err("a failed journal write must stop submission"); + + assert!(matches!( + attempt, + DpnsVoteAttempt::FailedBeforeSubmission(_) + )); + assert!(!broadcast_called.get()); } } diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index cd06bcb1d..51e039331 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -743,6 +743,13 @@ pub enum TaskError { )] DpnsVoteTargetBusy, + /// The journal could not advance the exact claimed target into its + /// ambiguous network phase, so broadcasting must not start. + #[error( + "This vote could not be prepared safely. Check its saved status before trying again." + )] + DpnsVoteBroadcastPhaseNotMarked, + /// A cancellation lost the race to execution after the target was claimed. #[error( "This scheduled vote has already started and can no longer be cancelled. Check its result once it finishes." diff --git a/src/context/dpns_vote_operations.rs b/src/context/dpns_vote_operations.rs index 41be3788a..8cd14f7fc 100644 --- a/src/context/dpns_vote_operations.rs +++ b/src/context/dpns_vote_operations.rs @@ -322,8 +322,8 @@ fn prune_terminal_operations( operation.is_complete() && !operation.targets.is_empty() && operation.targets.iter().all(|outcome| { - matches!(outcome.target.timing, VoteTiming::Scheduled(_)) - && removed_scheduled_votes.contains(&( + !matches!(outcome.target.timing, VoteTiming::Scheduled(_)) + || removed_scheduled_votes.contains(&( outcome.target.key.voter_id.to_buffer(), outcome.target.contested_name.clone(), )) @@ -519,25 +519,26 @@ fn mark_target_broadcast( network: Network, operation_id: DpnsVoteOperationId, key: &DpnsVoteTargetKey, -) -> Result<(), TaskError> { +) -> Result { let Some(mut operation): Option = kv .get(DetScope::Global, &operation_key(network, operation_id)) .map_err(unreadable_operation_err)? else { - return Ok(()); + return Ok(false); }; let Some(outcome) = operation .targets .iter_mut() .find(|outcome| outcome.target.key == *key) else { - return Ok(()); + return Ok(false); }; if outcome.status == DpnsVoteTargetStatus::Submitting { outcome.status = DpnsVoteTargetStatus::Confirming; write_existing_operation(kv, network, &operation)?; + return Ok(true); } - Ok(()) + Ok(false) } impl AppContext { @@ -745,12 +746,12 @@ impl AppContext { Ok(true) } - /// Record that a target was broadcast before waiting for its result. + /// Record that a target is entering the ambiguous broadcast phase. pub(crate) fn mark_dpns_vote_broadcast( &self, operation_id: DpnsVoteOperationId, key: &DpnsVoteTargetKey, - ) -> Result<(), TaskError> { + ) -> Result { let _guard = self .dpns_vote_operation_guard .lock() @@ -1426,21 +1427,41 @@ mod tests { } #[test] - fn pruning_removes_terminal_records_and_preserves_live_locks() { + fn pruning_removes_terminal_scheduled_immediate_and_mixed_records() { let kv = kv(); - let mut terminal = operation(DpnsVoteTargetStatus::Confirmed); - terminal.targets[0].target.timing = VoteTiming::Scheduled(42); + let mut scheduled = operation(DpnsVoteTargetStatus::Confirmed); + scheduled.targets[0].target.timing = VoteTiming::Scheduled(42); + scheduled.targets[0].target.contested_name = "scheduled".to_owned(); + let mut immediate = operation(DpnsVoteTargetStatus::Confirmed); + immediate.targets[0].target.contested_name = "immediate".to_owned(); + immediate.targets[0].target.key.vote_poll_id = Identifier::from([3; 32]); + let mut mixed = operation(DpnsVoteTargetStatus::Confirmed); + mixed.targets[0].target.contested_name = "mixed-immediate".to_owned(); + mixed.targets[0].target.key.vote_poll_id = Identifier::from([4; 32]); + let mut mixed_scheduled = mixed.targets[0].clone(); + mixed_scheduled.target.timing = VoteTiming::Scheduled(43); + mixed_scheduled.target.contested_name = "mixed-scheduled".to_owned(); + mixed_scheduled.target.key.vote_poll_id = Identifier::from([5; 32]); + mixed.targets.push(mixed_scheduled); let live = operation(DpnsVoteTargetStatus::Unconfirmed); - let removed_scheduled_votes = BTreeSet::from([( - terminal.targets[0].target.key.voter_id.to_buffer(), - terminal.targets[0].target.contested_name.clone(), - )]); - persist_operation(&kv, Network::Testnet, &terminal).unwrap(); + let removed_scheduled_votes = BTreeSet::from([ + ( + scheduled.targets[0].target.key.voter_id.to_buffer(), + scheduled.targets[0].target.contested_name.clone(), + ), + ( + mixed.targets[1].target.key.voter_id.to_buffer(), + mixed.targets[1].target.contested_name.clone(), + ), + ]); + persist_operation(&kv, Network::Testnet, &scheduled).unwrap(); + persist_operation(&kv, Network::Testnet, &immediate).unwrap(); + persist_operation(&kv, Network::Testnet, &mixed).unwrap(); persist_operation(&kv, Network::Testnet, &live).unwrap(); assert_eq!( prune_terminal_operations(&kv, Network::Testnet, &removed_scheduled_votes).unwrap(), - 1 + 3 ); assert_eq!( load_operations_read_only(&kv, Network::Testnet).unwrap(), @@ -1449,7 +1470,23 @@ mod tests { assert!( kv.get::( DetScope::Global, - &operation_key(Network::Testnet, terminal.id), + &operation_key(Network::Testnet, scheduled.id), + ) + .unwrap() + .is_none() + ); + assert!( + kv.get::( + DetScope::Global, + &operation_key(Network::Testnet, immediate.id), + ) + .unwrap() + .is_none() + ); + assert!( + kv.get::( + DetScope::Global, + &operation_key(Network::Testnet, mixed.id), ) .unwrap() .is_none() @@ -1535,7 +1572,7 @@ mod tests { } #[test] - fn successful_broadcast_crosses_the_durable_phase_boundary() { + fn marking_broadcast_crosses_the_durable_phase_boundary() { let kv = kv(); let submitting = operation(DpnsVoteTargetStatus::Submitting); let key = submitting.targets[0].target.key.clone(); @@ -1548,4 +1585,37 @@ mod tests { DpnsVoteTargetStatus::Confirming ); } + + #[test] + fn marking_broadcast_fails_closed_without_submitting_target() { + let kv = kv(); + let confirmed = operation(DpnsVoteTargetStatus::Confirmed); + let missing_key = DpnsVoteTargetKey { + vote_poll_id: Identifier::from([9; 32]), + ..confirmed.targets[0].target.key.clone() + }; + + assert!( + !mark_target_broadcast( + &kv, + Network::Testnet, + confirmed.id, + &confirmed.targets[0].target.key, + ) + .unwrap() + ); + persist_operation(&kv, Network::Testnet, &confirmed).unwrap(); + assert!( + !mark_target_broadcast(&kv, Network::Testnet, confirmed.id, &missing_key,).unwrap() + ); + assert!( + !mark_target_broadcast( + &kv, + Network::Testnet, + confirmed.id, + &confirmed.targets[0].target.key, + ) + .unwrap() + ); + } } From 040c51838b53f5d4e29151da2179469af507656e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:32:18 +0000 Subject: [PATCH 23/39] feat(dpns): move voting into active contests --- .../01-requirements.md | 28 +- .../02-ux-spec.md | 339 ++-- .../03-test-case-spec.md | 24 +- .../04-development-plan.md | 48 +- src/app.rs | 32 +- src/context/dpns_vote_state.rs | 92 +- src/context/mod.rs | 72 - src/ui/dpns/dpns_contested_names_screen.rs | 1395 +++++++++------- src/ui/masternodes/detail_screen.rs | 565 +------ src/ui/masternodes/list_screen.rs | 518 +----- src/ui/masternodes/mod.rs | 1 - src/ui/masternodes/voting_center.rs | 1473 ----------------- src/ui/state/dpns_vote_state.rs | 79 + src/ui/state/dpns_vote_workspace.rs | 206 --- src/ui/state/mod.rs | 2 +- tests/kittest/masternode_tab.rs | 99 +- 16 files changed, 1140 insertions(+), 3833 deletions(-) delete mode 100644 src/ui/masternodes/voting_center.rs create mode 100644 src/ui/state/dpns_vote_state.rs delete mode 100644 src/ui/state/dpns_vote_workspace.rs diff --git a/docs/ai-design/2026-07-16-dpns-voting-experience/01-requirements.md b/docs/ai-design/2026-07-16-dpns-voting-experience/01-requirements.md index 3d81a6791..dd8b6b8b4 100644 --- a/docs/ai-design/2026-07-16-dpns-voting-experience/01-requirements.md +++ b/docs/ai-design/2026-07-16-dpns-voting-experience/01-requirements.md @@ -6,10 +6,8 @@ Planning specification. No implementation is authorized by this document. ## Problem statement -DET exposes DPNS voting through two disconnected experiences: - -- a quick per-node section on the Masternodes detail page; and -- a legacy DPNS bulk dialog for multiple contests, nodes, and schedules. +DET previously exposed DPNS voting through disconnected per-node and bulk +experiences. The adopted design consolidates them on DPNS Active contests. Both call the same backend, but neither owns a complete, authoritative model of the operation. The result is unsafe ambiguity: a vote can be accepted while DET @@ -47,14 +45,15 @@ Everyday User workflow. | Progress is screen-owned | App results are routed to the currently visible screen | Navigation can strand controls in progress or deliver feedback to the wrong page | | Duplicate prevention is local or absent | Quick and bulk submit buttons do not share an operation lock | Repeated clicks or cross-screen actions can submit duplicates | | Post-broadcast wait failure is ambiguous | A cause-less `StateTransitionBroadcastError` can follow successful broadcast | DET must not label it rejected or invite immediate retry | -| Legacy bulk workflow is disconnected | Bulk and scheduling live under DPNS while node management lives under Masternodes | Operators can miss existing capabilities or assume they were removed | +| Voting entry points were disconnected | Per-node and bulk flows used different UI ownership | Operators could miss capabilities or assume they were removed | ## Product decisions -1. Masternodes is the primary home for operator voting. -2. DPNS remains the home for name registration, contest discovery, and contest - history, with a route into the shared voting workspace. -3. Quick voting and bulk/scheduled voting use one shared composer and one shared +1. DPNS Active contests is the single home for immediate, bulk, and scheduled + vote composition. +2. Masternode detail links plainly to Active contests without carrying a node + filter or draft. +3. Single and bulk/scheduled voting use one review sheet and one shared operation coordinator. 4. Current vote state is authoritative Platform data, not UI-local memory. 5. A vote row remains visible after voting and shows the current choice. Voting @@ -68,12 +67,11 @@ Everyday User workflow. ### Information architecture -- **VOTE-FR-001** β€” Masternodes provides `Nodes`, `Voting`, and `Scheduled` - views under one operator-focused root. -- **VOTE-FR-002** β€” The DPNS Active Contests page links to the same Voting view; - it does not maintain a second bulk implementation. -- **VOTE-FR-003** β€” A node detail page offers quick voting and an `Open Voting - Center` action pre-filtered to that node. +- **VOTE-FR-001** β€” Masternodes provides only the node list and node detail. +- **VOTE-FR-002** β€” DPNS Active contests owns the single vote composer and + review sheet. +- **VOTE-FR-003** β€” A node detail page offers one `DPNS Voting` action that + navigates plainly to Active contests. ### Authoritative state diff --git a/docs/ai-design/2026-07-16-dpns-voting-experience/02-ux-spec.md b/docs/ai-design/2026-07-16-dpns-voting-experience/02-ux-spec.md index b526819f3..dbb6d7357 100644 --- a/docs/ai-design/2026-07-16-dpns-voting-experience/02-ux-spec.md +++ b/docs/ai-design/2026-07-16-dpns-voting-experience/02-ux-spec.md @@ -1,266 +1,143 @@ # DPNS Voting Experience β€” UX Specification -## Experience principle - -Voting is one operator workflow with two entry speeds: - -- **Quick vote** β€” one selected node, usually one or a few contests. -- **Voting Center** β€” many contests, many nodes, immediate and scheduled - targets. - -Both are views over the same draft, authoritative vote state, and operation -coordinator. They must never disagree about current votes or progress. +## Adopted direction (2026-07-21) + +This revision supersedes the earlier three-step Voting Center journeys in this +document. The durable operation coordinator remains the safety model, but the +separate Masternodes `Nodes / Voting / Scheduled` navigation and full-page +wizard are no longer part of the product. The earlier design remains available +in git history as the rejected v2 exploration. + +The accepted direction keeps contest decisions where operators discover them: +DPNS Active contests. A single screen supports one vote, several contests, +several nodes, immediate casting, and scheduling. Masternode detail provides +only a plain `DPNS Voting` link to Active contests and never carries node or +contest preselection. + +## Rationale + +- Contest-first cards let an operator understand the decision before choosing + nodes or timing. +- Read-only tally chips and selectable vote controls have different shapes and + interaction states, removing the old ambiguity where a count looked like a + vote button. +- The review surface starts with the common case: all loaded nodes and cast now. +- Per-node timing remains available without making every operator configure a + matrix. +- Durable typed operations, exact target locks, and recovery messages remain + visible without requiring a separate operation page. ## Information architecture ```text Masternodes -β”œβ”€β”€ Nodes -β”‚ β”œβ”€β”€ Node cards -β”‚ └── Node detail -β”‚ β”œβ”€β”€ Keys and actions -β”‚ β”œβ”€β”€ Quick voting -β”‚ └── Open Voting Center (filtered to this node) -β”œβ”€β”€ Voting -β”‚ β”œβ”€β”€ Active contests -β”‚ β”œβ”€β”€ Vote composer -β”‚ └── Recent operations -└── Scheduled - β”œβ”€β”€ Upcoming - β”œβ”€β”€ Needs attention - └── Completed +β”œβ”€β”€ node list +└── node detail + └── DPNS Voting ──> DPNS / Active contests DPNS -β”œβ”€β”€ Active contests ── β€œVote with masternodes” ──> Masternodes / Voting -β”œβ”€β”€ Past contests -└── My usernames +β”œβ”€β”€ Active contests (all vote composition and recent activity) +β”œβ”€β”€ Past contests (human-readable outcomes) +β”œβ”€β”€ My usernames +└── Scheduled votes (human-readable node, choice, time, status) ``` -The existing DPNS bulk popup is retired after the shared Voting Center is -available. DPNS contest browsing remains; operator actions route to -Masternodes. - -## Shared concepts - -### Current vote - -Every node Γ— contest row shows one of: - -- `Not voted` -- `Current vote: Abstain` -- `Current vote: Lock` -- `Current vote: {candidate}` -- `Checking current vote…` -- `Current vote unavailable` - -An existing vote does not remove the contest. Selecting a different choice is -labeled as a change. - -`Current vote unavailable` disables that node's choice controls and offers -`Refresh vote state`. DET never treats an unavailable query as `Not voted`. - -Node cards summarize both concepts, for example: - -> 3 active contests Β· 1 needs a vote - -When every active contest has a current vote: - -> Votes cast in all active contests - -### Target +## Active contests -A target is one requested action for one node on one contest. Batch progress -and results are always expressed in targets, never only in aggregate. +Contests render as cards, grouped in this order: -### Operation +1. `Needs your vote` +2. `Voted` +3. `Not votable by your nodes` -An operation is the reviewed collection of targets submitted together. It owns -progress across screens and process restarts. - -## Journey A β€” Quick vote from a node - -1. Priya opens a node. -2. The DPNS section shows all active contests and the node's current choice. -3. Priya selects a new choice on one or more rows. -4. The primary action becomes `Review 1 vote` or `Review {n} votes`. -5. Review lists current β†’ requested choice for this node. -6. Priya chooses `Cast now` or `Schedule instead`, then confirms. -7. Affected rows become read-only and show `Submitting…` or `Scheduled`. -8. Priya may navigate elsewhere. A global banner links to operation progress. -9. Confirmed rows update their current vote in place; they do not disappear. +Each group is collapsible. The first group opens by default; the last group is +dimmed and its vote controls are disabled. A filter remains available. ```text -DPNS name contests (3) - -alice.dash -Current vote: Abstain -( Abstain ) ( Lock ) ( Vote for alice ) - -dominguez.dash -Not voted -( Abstain ) ( Lock ) ( Vote for dominguez ) - - [ Review 1 vote ] - [ Open Voting Center ] +β”Œβ”€ Active contests ─────────────────────────────────────────────┐ +β”‚ Filter by name: [________________] β”‚ +β”‚ Each node can change its vote up to four times after its β”‚ +β”‚ initial vote. β”‚ +β”‚ β”‚ +β”‚ β–Ύ Needs your vote (2) β”‚ +β”‚ β”Œβ”€ alice.dash Voting ends in 2d ─┐ β”‚ +β”‚ β”‚ [Lock name] (12 votes) [Abstain] (3 votes) β”‚ β”‚ +β”‚ β”‚ [Vote for Alice] (20 votes) 3MN5mF…s8Qp β”‚ β”‚ +β”‚ β”‚ [Vote for Alyce] (18 votes) 7Yk2aP…d1Rt β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β–Έ Voted (4) β”‚ +β”‚ β–Έ Not votable by your nodes (1) β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Votes ready to cast: 1 [Review and cast] β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` -## Journey B β€” Bulk vote or schedule - -The composer is a three-step full-page flow, not a transient popup. Nodes come -first so every later β€œcurrent vote” summary has a defined node scope. - -### Step 1: Nodes and timing - -Priya selects nodes and chooses timing. `Set all` applies timing only; it does -not alter contest choices. - -```text -Voting Center Step 1 of 3: Nodes and timing - -Set all: [ Cast now v ] [ Apply ] - -[x] Eve Mainnet Cast now -[x] Backup Evo Schedule: 2026-07-20 18:00 UTC -[ ] Test Operator Do not use this node - - [ Next: Choose votes ] -``` +Parenthesized tally chips are labels, not buttons. `selectable_label` controls +contain an action phrase such as `Vote for Alice`, `Lock name`, or `Abstain`. +Selecting the active choice again removes it from the draft. -### Step 2: Votes +## Review and cast sheet -Priya selects contests and a requested choice for each contest. Current-state -summaries cover only the nodes selected in Step 1. +The sticky tray opens a sheet in the same Active-contests screen. The default +applies the chosen contests to all loaded voting nodes and casts now. ```text -Step 2 of 3: Votes - -[ ] alice.dash Current across selected nodes: Mixed - Abstain | Lock | Vote for alice - -[x] dominguez.dash Current across selected nodes: 1 not voted, 1 Lock - Abstain | Lock | Vote for dominguez - - [ Back ] [ Review 2 targets ] +β”Œβ”€ Review and cast ───────────────────────────────────────┐ +β”‚ Selected votes β”‚ +β”‚ alice.dash β†’ Vote for Alice Β· voting ends in 2d β”‚ +β”‚ β”‚ +β”‚ All my nodes β”‚ +β”‚ Cast timing: [Cast now v] β”‚ +β”‚ β”‚ +β”‚ β–Έ Choose per node (advanced) β”‚ +β”‚ β”‚ +β”‚ [Cancel] [Submit votes] β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` -### Step 3: Review - -The review expands the cartesian product into exact targets. No-op targets are -removed and explained. - -```text -Step 3 of 3: Review - -Node Contest Current Requested When -Eve Mainnet dominguez.dash Not voted dominguez Now -Backup Evo dominguez.dash Lock dominguez Jul 20 - -2 targets total. Each vote uses Platform credits. +Timing choices are `Cast now`, `Schedule`, and `Do not use this node`. +Scheduling exposes the existing day/hour/minute controls and reminds the user +that DET must stay open and connected. The advanced disclosure exposes the +same timing choice for each node. Review removes exact no-op targets, blocks +targets already held by an unresolved operation, and refuses submission when +proved current state is unavailable. - [ Back ] [ Submit 2 targets ] -``` +## Submission and recovery -## Journey C β€” Operation progress +`SubmitDpnsVoteOperation` is the only submit path. Explicit submissions, +manual scheduled casting, and `Check again` show the full-window progress +overlay until a terminal task result or error arrives. -After submit, the review becomes an operation detail page. +Recent voting activity appears below the contest groups and uses typed target +states: ```text -Submitting votes -1 confirmed Β· 1 checking - -βœ“ Eve Mainnet / dominguez.dash - Vote confirmed: dominguez - -… Backup Evo / dominguez.dash - The vote was submitted. DET is checking the result. - - [ Continue in background ] +β”Œβ”€ Recent voting activity ─────────────────────────────────┐ +β”‚ alice.dash β€” Vote for Alice β€” Confirmed β”‚ +β”‚ example.dash β€” Lock name β€” Confirmation is still checked β”‚ +β”‚ This vote may already have been submitted. Do not submit it β”‚ +β”‚ again. [Check again] β”‚ +β”‚ other.dash β€” Abstain β€” Not applied [Review again]β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` -Target rows expose technical details only through the standard expandable -details affordance. If a transition hash is available, developer mode may show -and copy it. - -## Journey D β€” Unconfirmed result - -1. Broadcast succeeds. -2. Waiting for the result fails without a structured consensus cause. -3. The target becomes `Unconfirmed`; it is not labeled failed. -4. The exact node Γ— contest target remains locked. -5. DET retries the result wait and/or fetches the proved current vote. -6. If the requested choice appears, the target becomes `Confirmed`. -7. If authoritative reconciliation proves it was not applied, the target - becomes `Not applied` and offers `Submit again`. -8. If Platform remains unavailable, the persistent action is `Check again`. - -Banner copy: - -> The vote was submitted, but DET could not confirm the result yet. DET will -> keep checking. Do not submit it again. - -## Journey E β€” Scheduled vote - -- Scheduled targets appear immediately in `Masternodes > Scheduled`. -- Before execution begins, `Edit schedule` and `Cancel scheduled vote` remain - available. -- At execution time, status changes from `Scheduled` to `Submitting`. -- A confirmed result becomes `Completed`. -- A definite rejection becomes `Needs attention`. -- An ambiguous result becomes `Checking result`; it is never automatically - rebroadcast. - -## Control state - -| Target state | Choice controls | Submit action | Other targets | -|---|---|---|---| -| Draft | Enabled | Enabled when draft has changes | Enabled | -| Current vote unavailable | Disabled for that node | `Refresh vote state` | Enabled | -| Scheduled | Read-only for that target | `Edit schedule` | Enabled | -| Queued / Submitting / Confirming | Disabled | Spinner + status | Enabled | -| Unconfirmed | Disabled | `Check again` | Enabled | -| Confirmed | Enabled for a deliberate change | No draft action | Enabled | -| Rejected / Failed before submission / Not applied | Enabled after correction | `Review again` | Enabled | - -The disabled tooltip names the exact reason, for example: - -> This node's vote for dominguez.dash is still being confirmed. - -## Feedback matrix - -| Outcome | Type | Primary copy | -|---|---|---| -| One confirmed | Success | `Vote cast successfully.` | -| All batch targets confirmed | Success | `{count} votes were cast successfully.` | -| Scheduled | Success | `{count} votes were scheduled.` | -| Partial | Warning | `{confirmed} of {total} votes were confirmed. Review the remaining {remaining}.` | -| Unconfirmed | Warning, persistent | `The vote was submitted, but DET could not confirm the result yet. DET will keep checking. Do not submit it again.` | -| Structured rejection | Error | Typed, user-actionable rejection message | -| Failed before broadcast | Error | `This vote was not submitted. {action}` | -| No-op | Info | `This node already has that vote. Nothing was submitted.` | - -## Navigation and persistence - -- Operation progress is not owned by a screen instance. -- Leaving Masternodes never clears an active operation. -- Returning to any voting entry point reads current operation state and locks - affected targets. -- On startup, DET restores scheduled and unresolved targets before voting - controls become available. -- Switching networks swaps to that network's independent voting workspace. +`Confirmed`, `Confirming`, `Unconfirmed`, `Rejected`, `Not applied`, and +pre-submission failure are never inferred from strings. Unconfirmed targets +retain their lock and explicitly warn against resubmission. -## Accessibility and interaction +## Scheduled votes and history -- Use styled buttons and semantic status colors with text labels. -- Minimum click targets follow the shared button component. -- `Enter` advances or submits only on the review step. -- `Escape` closes a draft review but cannot cancel a submitted operation. -- Focus moves to the step heading after Back/Next. -- Progress text and spinner are both present; no status is color-only. -- Disabled controls use the standard disabled tooltip policy. +Scheduled votes remain a DPNS sub-screen. Rows show `{name}.dash`, node alias +or shortened Base58 identifier, a human-readable choice, absolute UTC time plus +relative time, typed status, and only valid actions. Past contests describe the +winner or locked outcome in words rather than exposing an unexplained raw ID. -## Responsive behavior +## Accessibility and responsive behavior -- Desktop: contest table and node/timing table use the full island panel. -- Narrow width: target rows become stacked cards showing Node, Contest, Current, - Requested, Timing, and Status. -- Operation progress remains usable without horizontal scrolling. +- Tally chips have no click or keyboard behavior. +- Every selectable control contains the action and target in its label. +- Disabled controls explain what must change before voting is available. +- Cards wrap vote choices before truncating identifiers. +- The review tray remains outside the scrolling card region. +- The advanced matrix starts collapsed and remains keyboard reachable. +- Status is always expressed in text; color is supplementary. diff --git a/docs/ai-design/2026-07-16-dpns-voting-experience/03-test-case-spec.md b/docs/ai-design/2026-07-16-dpns-voting-experience/03-test-case-spec.md index 775f44ff3..ae6235c2f 100644 --- a/docs/ai-design/2026-07-16-dpns-voting-experience/03-test-case-spec.md +++ b/docs/ai-design/2026-07-16-dpns-voting-experience/03-test-case-spec.md @@ -5,7 +5,7 @@ | ID | Description | Preconditions | Steps | Expected outcome | Requirements | |---|---|---|---|---|---| | VOTE-TC-001 | Current vote loads from Platform | Node has a proved Lock vote | Refresh Voting | Row shows `Current vote: Lock` | FR-010, FR-011 | -| VOTE-TC-002 | Existing vote remains visible | Node already voted; contest active | Open node detail | Contest remains listed and change controls are available | FR-012 | +| VOTE-TC-002 | Existing vote remains visible | Node already voted; contest active | Open Active contests | Contest appears in Voted and change controls are available | FR-012 | | VOTE-TC-003 | Current choice is a no-op | Current vote is Lock | Select Lock and review | Target is removed; nothing can be submitted | FR-013, FR-025 | | VOTE-TC-004 | Coherent refresh | Contest tally and current vote both changed | Refresh | One snapshot shows both new values | FR-014 | | VOTE-TC-005 | Vote query is per node | One node, 100 contests | Refresh | Identity-votes query runs once for the node, not 100 times | NFR-007 | @@ -13,14 +13,14 @@ | VOTE-TC-007 | Vote query failure is not `Not voted` | Proved identity-votes query fails | Open voting | State is unavailable; affected submit controls are disabled | FR-016, NFR-004 | | VOTE-TC-008 | Node summary distinguishes active and unvoted | Three active contests; node voted in two | View node card | Summary says three active and one needs a vote | FR-017 | -## Quick voting +## Single-contest voting | ID | Description | Preconditions | Steps | Expected outcome | Requirements | |---|---|---|---|---|---| -| VOTE-TC-010 | Quick single vote | Node detail, one draft choice | Review and submit | One target is created for the selected node and contest | FR-020, FR-024 | -| VOTE-TC-011 | Quick multi-contest vote | Node detail, three draft choices | Review | Review shows three exact targets | FR-020, FR-031 | -| VOTE-TC-012 | Quick schedule | Node detail, one draft | Choose Schedule in review | Target appears in Scheduled with the chosen time | FR-023, FR-050 | -| VOTE-TC-013 | Missing voting key | Node lacks voter key | Open voting | Actionable add-key state appears; submit is unavailable | FR-003 | +| VOTE-TC-010 | Single vote | Active contests, one draft choice | Review and submit | One target is created per selected loaded node for that contest | FR-020, FR-024 | +| VOTE-TC-011 | Multi-contest vote | Active contests, three draft choices | Review | Review shows the exact node Γ— contest targets | FR-020, FR-031 | +| VOTE-TC-012 | Schedule one choice | Active contests, one draft | Choose Schedule in review | Targets appear in Scheduled with the chosen time | FR-023, FR-050 | +| VOTE-TC-013 | Missing voting key | No loaded node can vote | Open Active contests | Contest appears under Not votable and submit is unavailable | FR-003 | ## Bulk voting @@ -29,9 +29,9 @@ | VOTE-TC-020 | Multiple contests and nodes | Two contests, three nodes | Select all and review | Six exact targets are listed | FR-021, FR-024 | | VOTE-TC-021 | Set all timing | Three selected nodes | Apply Schedule to all | All nodes receive the same schedule | FR-022, FR-023 | | VOTE-TC-022 | Per-node override | Set all Cast now | Override one node to Schedule | Review reflects two Now and one Scheduled target | FR-022 | -| VOTE-TC-023 | DPNS route reuses workspace | DPNS Active Contests visible | Click `Vote with masternodes` | Shared Masternodes Voting view opens; no legacy popup appears | FR-002 | -| VOTE-TC-024 | Node route prefilters | Node detail visible | Click `Open Voting Center` | Voting view opens with only that node selected | FR-003 | -| VOTE-TC-025 | Current summary uses selected nodes | Three nodes loaded; two selected | Open Votes step | `Current across selected nodes` excludes the unselected node | FR-011, FR-021 | +| VOTE-TC-023 | Sticky review tray | Active contests visible | Select a choice | `Votes ready to cast: 1` appears and Review and cast opens in place | FR-002 | +| VOTE-TC-024 | Node navigation is plain | Node detail visible | Click `DPNS Voting` | Active contests opens without a node filter or carried draft | FR-003 | +| VOTE-TC-025 | Advanced node overrides | Three nodes loaded | Open Review and cast, expand advanced choices | All nodes default to Cast now and each can be overridden | FR-011, FR-021 | ## Execution correctness @@ -48,7 +48,7 @@ | ID | Description | Preconditions | Steps | Expected outcome | Requirements | |---|---|---|---|---|---| | VOTE-TC-040 | Double click | Submit enabled | Double-click Submit | Exactly one operation and one target broadcast are created | FR-034, FR-035 | -| VOTE-TC-041 | Cross-screen duplicate | Target is confirming from quick vote | Open Voting Center | Same node Γ— contest target is disabled with explanation | FR-034, FR-036 | +| VOTE-TC-041 | Cross-screen duplicate | Target is confirming | Return to Active contests | Same node Γ— contest target is disabled with explanation | FR-034, FR-036 | | VOTE-TC-042 | Unrelated target stays usable | One target confirming | Select another node or contest | Unrelated target remains enabled | Product decision 7 | | VOTE-TC-043 | Navigation preserves lock | Submit, leave page, return before result | Inspect target | Progress and lock remain active | FR-036 | | VOTE-TC-044 | Restart preserves lock | Persist unresolved target; restart | Open Voting | Target is restored and reconciled before resubmission is allowed | FR-037, NFR-003 | @@ -80,8 +80,8 @@ | ID | Description | Preconditions | Steps | Expected outcome | Requirements | |---|---|---|---|---|---| -| VOTE-TC-070 | Progress button state | Target submitting | Inspect action | Disabled styled action shows spinner, text, and tooltip | FR-035, NFR-005 | -| VOTE-TC-071 | Keyboard review | Composer draft ready | Tab, Enter, Escape | Focus order is logical; Enter submits only at review; Escape closes only drafts | NFR-005 | +| VOTE-TC-070 | Blocking submission feedback | Target submitting | Inspect screen | Full-window progress overlay remains visible until success or error | FR-035, NFR-005 | +| VOTE-TC-071 | Keyboard review | Review sheet open | Tab and activate controls | Focus order is logical and advanced node choices are reachable | NFR-005 | | VOTE-TC-072 | Network isolation | Testnet target unresolved | Switch Mainnet | Mainnet has no Testnet locks or operation rows | NFR-008 | | VOTE-TC-073 | No secret persistence | Operation stored | Inspect serialized operation | No private key or WIF bytes are present | NFR-009 | | VOTE-TC-074 | Complete message units | All new copy | Localization audit | Strings are complete and do not parse technical errors | NFR-006 | diff --git a/docs/ai-design/2026-07-16-dpns-voting-experience/04-development-plan.md b/docs/ai-design/2026-07-16-dpns-voting-experience/04-development-plan.md index c5ee60834..dd06596a2 100644 --- a/docs/ai-design/2026-07-16-dpns-voting-experience/04-development-plan.md +++ b/docs/ai-design/2026-07-16-dpns-voting-experience/04-development-plan.md @@ -198,38 +198,26 @@ backend tasks. One operation owns both kinds of targets. ### Non-rendering state -Add `src/ui/state/dpns_vote_workspace.rs` for: - -- draft contest choices; -- selected nodes and timing; -- current composer step; -- validation and no-op explanations; -- conversion to a typed operation request. +Cache proved current-vote state once when Active contests is built or explicitly +refreshed. Rendering and draft changes read only this in-memory snapshot; they +never perform synchronous KV reads in the egui frame loop. ### Shared rendering -Add `src/ui/components/dpns_vote_composer.rs` implementing the three steps from -the UX specification. - -The compact node-detail controls use the same draft/view-model logic and open -the shared review step. They do not implement a separate submit path. +Active contests owns card grouping, draft choices, the sticky review tray, and +the in-screen `Review and cast` sheet. The sheet expands the draft into typed +node Γ— contest targets and submits one `DpnsVoteOperation`. ### Masternodes views -Extend the Masternodes root state with: - -- Nodes -- Voting -- Scheduled -- Operation detail - -The root observes coordinator snapshots, so progress survives sub-view changes. +Keep the Masternodes root limited to the node list and node detail. Detail has a +single `DPNS Voting` button that opens DPNS Active contests without prefiltering. ### DPNS integration -Replace the legacy bulk popup with `Vote with masternodes`, routing selected -contests into the shared Voting workspace. Keep Active, Past, and My Usernames -contest/name browsing in DPNS. +Replace the legacy table and top-bar trigger with grouped contest cards, a +sticky `Votes ready to cast` tray, and the in-screen review sheet. Keep Active, +Past, My usernames, and Scheduled votes in DPNS. ## Message handling @@ -265,12 +253,12 @@ without every voting entry point using it. - Fix scheduled false-success behavior at the executor boundary. - Covers VOTE-TC-030 through VOTE-TC-056. -### Workstream C β€” Shared Voting Center +### Workstream C β€” Active-contests voting -- Add shared composer and Masternodes Voting view. -- Integrate quick node flow. -- Add operation detail/progress. -- Route DPNS Active Contests into the shared workspace. +- Add grouped contest cards and the review sheet. +- Replace node-detail voting with plain DPNS navigation. +- Add operation progress and recovery to Active contests. +- Remove the Masternodes operator sub-navigation and wizard. - Covers VOTE-TC-010 through VOTE-TC-025 and VOTE-TC-070 through VOTE-TC-076. ### Workstream D β€” Scheduled consolidation and migration @@ -299,5 +287,5 @@ reviewed as one atomic UX change. - Revise DPN-005, DPN-006, DPN-007, and MN-003 acceptance criteria. - Correct the protocol note to five votes total: initial vote plus four changes. - Add a user story for operation recovery across navigation/restart. -- Replace the previous Masternodes design decision that made scheduled voting - undiscoverable from the operator page. +- Document DPNS Active contests as the single voting home and keep Scheduled + votes discoverable in the DPNS sub-navigation. diff --git a/src/app.rs b/src/app.rs index bba79ea9a..b9a4cab2e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -325,7 +325,7 @@ fn identity_hub_is_visible(selected: RootScreenType, screen_stack_is_empty: bool selected == RootScreenType::RootScreenIdentityHub && screen_stack_is_empty } -fn dpns_result_needs_hidden_masternode_route( +fn dpns_result_needs_hidden_active_contests_route( selected: RootScreenType, screen_stack_is_empty: bool, result: &BackendTaskSuccessResult, @@ -334,7 +334,7 @@ fn dpns_result_needs_hidden_masternode_route( result, BackendTaskSuccessResult::DpnsVoteOperationUpdated { .. } | BackendTaskSuccessResult::RefreshedDpnsContests - ) && (selected != RootScreenType::RootScreenMasternodes || !screen_stack_is_empty) + ) && (selected != RootScreenType::RootScreenDPNSActiveContests || !screen_stack_is_empty) } /// Plain, jargon-free descriptions for the SPV-sync block (Everyday-User rule: @@ -2048,12 +2048,12 @@ impl AppState { } } - fn route_dpns_vote_result_to_hidden_masternodes( + fn route_dpns_vote_result_to_hidden_active_contests( &mut self, context: &BackendTaskContext, result: &BackendTaskSuccessResult, ) { - if !dpns_result_needs_hidden_masternode_route( + if !dpns_result_needs_hidden_active_contests_route( self.selected_main_screen, self.screen_stack.is_empty(), result, @@ -2062,9 +2062,10 @@ impl AppState { } if let Some(screen) = self .main_screens - .get_mut(&RootScreenType::RootScreenMasternodes) + .get_mut(&RootScreenType::RootScreenDPNSActiveContests) { screen.display_backend_task_result(context, result.clone()); + screen.refresh(); } } @@ -2216,7 +2217,10 @@ impl App for AppState { } => { let unboxed_message = *message; self.route_contact_request_result_to_hidden_hub(&unboxed_message); - self.route_dpns_vote_result_to_hidden_masternodes(&context, &unboxed_message); + self.route_dpns_vote_result_to_hidden_active_contests( + &context, + &unboxed_message, + ); match unboxed_message { BackendTaskSuccessResult::None => {} BackendTaskSuccessResult::Refresh => { @@ -3340,32 +3344,32 @@ mod dpns_result_routing_tests { use crate::model::dpns_voting::DpnsVoteOperationId; #[test] - fn correlated_vote_result_routes_when_masternodes_root_is_hidden() { + fn correlated_vote_result_routes_when_active_contests_is_hidden() { let result = BackendTaskSuccessResult::DpnsVoteOperationUpdated { network: Network::Testnet, operation_id: DpnsVoteOperationId::from_bytes([7; 16]), }; - assert!(dpns_result_needs_hidden_masternode_route( + assert!(dpns_result_needs_hidden_active_contests_route( RootScreenType::RootScreenWalletsBalances, true, &result, )); - assert!(dpns_result_needs_hidden_masternode_route( - RootScreenType::RootScreenMasternodes, + assert!(dpns_result_needs_hidden_active_contests_route( + RootScreenType::RootScreenDPNSActiveContests, false, &result, )); - assert!(!dpns_result_needs_hidden_masternode_route( - RootScreenType::RootScreenMasternodes, + assert!(!dpns_result_needs_hidden_active_contests_route( + RootScreenType::RootScreenDPNSActiveContests, true, &result, )); } #[test] - fn refreshed_contests_route_when_masternodes_root_is_hidden() { - assert!(dpns_result_needs_hidden_masternode_route( + fn refreshed_contests_route_when_active_contests_is_hidden() { + assert!(dpns_result_needs_hidden_active_contests_route( RootScreenType::RootScreenWalletsBalances, true, &BackendTaskSuccessResult::RefreshedDpnsContests, diff --git a/src/context/dpns_vote_state.rs b/src/context/dpns_vote_state.rs index d036b04ed..7d6bf81fb 100644 --- a/src/context/dpns_vote_state.rs +++ b/src/context/dpns_vote_state.rs @@ -86,7 +86,7 @@ fn save_snapshot( } fn snapshot_vote_state( - snapshot: Option, + snapshot: Option<&StoredCurrentVotes>, vote_poll_id: Identifier, checked_at_ms: u64, ) -> DpnsCurrentVoteState { @@ -135,13 +135,33 @@ impl AppContext { voter_id: Identifier, vote_poll_id: Identifier, ) -> Result { + let snapshot = load_snapshot(&self.det_kv()?, self.network, &voter_id)?; Ok(snapshot_vote_state( - load_snapshot(&self.det_kv()?, self.network, &voter_id)?, + snapshot.as_ref(), vote_poll_id, now_ms(), )) } + /// Read one node's current choices for many polls with a single storage read. + pub fn dpns_current_vote_states( + &self, + voter_id: Identifier, + vote_poll_ids: impl IntoIterator, + ) -> Result, TaskError> { + let snapshot = load_snapshot(&self.det_kv()?, self.network, &voter_id)?; + let checked_at_ms = now_ms(); + Ok(vote_poll_ids + .into_iter() + .map(|vote_poll_id| { + ( + vote_poll_id, + snapshot_vote_state(snapshot.as_ref(), vote_poll_id, checked_at_ms), + ) + }) + .collect()) + } + /// Refresh proved vote state once per loaded masternode, paging only as needed. pub(crate) async fn refresh_dpns_vote_states(&self, sdk: &Sdk) { let voters = match self.load_local_masternode_identities() { @@ -273,7 +293,38 @@ fn now_ms() -> u64 { mod tests { use super::*; use crate::wallet_backend::kv_test_support::InMemoryKv; + use platform_wallet_storage::{KvError, KvStore, ObjectId}; use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[derive(Default)] + struct CountingKv { + inner: InMemoryKv, + gets: AtomicUsize, + } + + impl KvStore for CountingKv { + fn get(&self, scope: &ObjectId, key: &str) -> Result>, KvError> { + self.gets.fetch_add(1, Ordering::Relaxed); + self.inner.get(scope, key) + } + + fn put(&self, scope: &ObjectId, key: &str, value: &[u8]) -> Result<(), KvError> { + self.inner.put(scope, key, value) + } + + fn delete(&self, scope: &ObjectId, key: &str) -> Result<(), KvError> { + self.inner.delete(scope, key) + } + + fn list_keys( + &self, + scope: &ObjectId, + prefix: Option<&str>, + ) -> Result, KvError> { + self.inner.list_keys(scope, prefix) + } + } fn kv() -> DetKv { DetKv::from_store(Arc::new(InMemoryKv::default())) @@ -377,8 +428,43 @@ mod tests { }; assert_eq!( - snapshot_vote_state(Some(snapshot), poll, CURRENT_VOTE_MAX_AGE_MS + 2), + snapshot_vote_state(Some(&snapshot), poll, CURRENT_VOTE_MAX_AGE_MS + 2), DpnsCurrentVoteState::Checking ); } + + #[test] + fn many_poll_states_use_one_storage_read_per_node() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let store = Arc::new(CountingKv::default()); + let kv = DetKv::from_store(store.clone()); + let voter = Identifier::from([1; 32]); + let polls = (0..100) + .map(|byte| Identifier::from([byte; 32])) + .collect::>(); + save_snapshot( + &kv, + Network::Testnet, + &voter, + &StoredCurrentVotes { + available: true, + updated_at: now_ms(), + votes: BTreeMap::new(), + }, + ) + .expect("seed current-vote snapshot"); + let context = crate::context::test_support::test_app_context_with_kv( + temp_dir.path(), + Arc::new(kv.clone()), + ); + context.set_det_kv_override_for_test(kv); + let before = store.gets.load(Ordering::Relaxed); + + let states = context + .dpns_current_vote_states(voter, polls.iter().copied()) + .expect("load current-vote states"); + + assert_eq!(states.len(), polls.len()); + assert_eq!(store.gets.load(Ordering::Relaxed) - before, 1); + } } diff --git a/src/context/mod.rs b/src/context/mod.rs index 85a70aa93..b4ce2dd7d 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -45,7 +45,6 @@ use dash_sdk::dpp::state_transition::batch_transition::methods::StateTransitionC use dash_sdk::dpp::system_data_contracts::{SystemDataContract, load_system_data_contract}; use dash_sdk::dpp::version::PlatformVersion; use dash_sdk::dpp::version::v11::PLATFORM_V11; -use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dash_sdk::platform::DataContract; use dash_sdk::platform::Identifier; use egui::Context; @@ -77,15 +76,6 @@ pub(crate) struct ContactRequestActionClaim<'a> { request_id: Identifier, } -/// One-shot navigation into the Masternodes DPNS operator workflow. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum DpnsOperatorRoute { - Voting { - choices: BTreeMap, - }, - Scheduled, -} - impl Drop for ContactRequestActionClaim<'_> { fn drop(&mut self) { self.registry @@ -248,8 +238,6 @@ pub struct AppContext { /// the hub adopts it on return (forward-courier, mirrors /// `pending_wallet_selection`). pub(crate) pending_identity_selection: Mutex>, - /// One-shot DPNS deep link consumed by the Masternodes Voting Center. - pending_dpns_operator_route: Mutex>, /// Cached fee multiplier permille from current epoch (1000 = 1x, 2000 = 2x) /// Updated when epoch info is fetched from Platform fee_multiplier_permille: AtomicU64, @@ -525,7 +513,6 @@ impl AppContext { selected_single_key_hash: Mutex::new(selected_single_key_hash), selected_identity_id: Mutex::new(None), pending_identity_selection: Mutex::new(None), - pending_dpns_operator_route: Mutex::new(None), fee_multiplier_permille: AtomicU64::new( PlatformFeeEstimator::DEFAULT_FEE_MULTIPLIER_PERMILLE, ), @@ -708,40 +695,6 @@ impl AppContext { self.network } - /// Route DPNS contest browsing into the shared Masternodes Voting Center. - pub fn route_to_dpns_voting_center(&self, contested_names: Vec) { - self.route_to_dpns_operator(DpnsOperatorRoute::Voting { - choices: contested_names - .into_iter() - .map(|name| (name, ResourceVoteChoice::Abstain)) - .collect(), - }); - } - - /// Route to the shared Masternodes DPNS operator workflow. - pub fn route_to_dpns_operator(&self, route: DpnsOperatorRoute) { - *self - .pending_dpns_operator_route - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(route); - } - - /// Consume a one-shot DPNS β†’ Masternodes Voting Center deep link. - pub fn take_dpns_voting_center_route(&self) -> Option> { - match self.take_dpns_operator_route()? { - DpnsOperatorRoute::Voting { choices } => Some(choices.into_keys().collect()), - DpnsOperatorRoute::Scheduled => None, - } - } - - /// Consume a one-shot DPNS operator deep link. - pub fn take_dpns_operator_route(&self) -> Option { - self.pending_dpns_operator_route - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take() - } - pub fn connection_status(&self) -> &ConnectionStatus { &self.connection_status } @@ -1651,7 +1604,6 @@ pub(crate) const fn default_platform_version(_network: &Network) -> &'static Pla #[cfg(test)] mod tests { use super::*; - use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; #[test] fn install_secret_prompt_recovers_poisoned_slot() { @@ -1684,30 +1636,6 @@ mod tests { assert!(!url.contains(' ')); } - /// VOTE-TC-023: the DPNS deep link preserves each exact selected choice. - #[test] - fn dpns_voting_center_route_preserves_selected_choices() { - let tmp = tempfile::tempdir().expect("tempdir"); - let ctx = crate::context::test_support::test_app_context(tmp.path()); - let candidate = Identifier::from([7; 32]); - let choices = BTreeMap::from([ - ("alice".to_owned(), ResourceVoteChoice::Lock), - ( - "dominguez".to_owned(), - ResourceVoteChoice::TowardsIdentity(candidate), - ), - ]); - - ctx.route_to_dpns_operator(DpnsOperatorRoute::Voting { - choices: choices.clone(), - }); - - assert_eq!( - ctx.take_dpns_operator_route(), - Some(DpnsOperatorRoute::Voting { choices }) - ); - } - // ── FR-6 resolution-layer boundary (B1) ────────────────────────────────── /// Build an offline, wired `AppContext` (no network I/O) so the identity diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index 108a5a111..1da314bdd 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -10,28 +10,31 @@ use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoic use dash_sdk::platform::Identifier; use eframe::egui::{self, Button, Color32, ComboBox, Label, RichText, Ui}; use egui_extras::{Column, TableBuilder}; -use itertools::Itertools; use crate::app::{AppAction, DesiredAppAction, scheduled_vote_sweep_is_quiet}; -use crate::backend_task::BackendTask; use crate::backend_task::contested_names::{ContestedResourceTask, ScheduledDPNSVote}; use crate::backend_task::error::TaskError; use crate::backend_task::identity::IdentityTask; -use crate::context::{AppContext, DpnsOperatorRoute}; +use crate::backend_task::{BackendTask, BackendTaskContext}; +use crate::context::AppContext; use crate::model::contested_name::{ContestState, ContestedName}; use crate::model::dpns_voting::{ - DpnsCurrentVoteState, DpnsVoteOperation, DpnsVoteTarget, DpnsVoteTargetKey, - DpnsVoteTargetStatus, VoteTiming, + DpnsCurrentVoteState, DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteTarget, + DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, }; use crate::model::qualified_identity::{DPNSNameInfo, QualifiedIdentity}; +use crate::ui::components::component_trait::Component; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::dpns_subscreen_chooser_panel::add_dpns_subscreen_chooser_panel; use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::progress_overlay::{OptionOverlayExt, OverlayConfig, OverlayHandle}; use crate::ui::components::styled::{StyledButton, island_central_panel}; use crate::ui::components::tools_subscreen_chooser_panel::add_tools_subscreen_chooser_panel; use crate::ui::components::top_panel::{add_top_panel_with_global_nav, subdued_everyday_spec}; use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::identities::register_dpns_name_screen::RegisterDpnsNameSource; use crate::ui::state::dpns_vote_operations::DpnsVoteOperationSnapshot; +use crate::ui::state::dpns_vote_state::DpnsVoteStateSnapshot; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use crate::ui::{BackendTaskSuccessResult, MessageType, RootScreenType, ScreenLike, ScreenType}; @@ -44,6 +47,79 @@ pub enum DPNSSubscreen { ScheduledVotes, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum ActiveContestGroup { + NeedsVote, + Voted, + NotVotable, +} + +fn classify_vote_states( + states: impl IntoIterator, +) -> ActiveContestGroup { + let mut has_vote = false; + for state in states { + match state { + DpnsCurrentVoteState::Available(None) => return ActiveContestGroup::NeedsVote, + DpnsCurrentVoteState::Available(Some(_)) => has_vote = true, + DpnsCurrentVoteState::Checking | DpnsCurrentVoteState::Unavailable => {} + } + } + if has_vote { + ActiveContestGroup::Voted + } else { + ActiveContestGroup::NotVotable + } +} + +fn tally_chip(ui: &mut Ui, votes: u32, dark_mode: bool) { + egui::Frame::new() + .fill(DashColors::surface(dark_mode)) + .corner_radius(egui::CornerRadius::same(255)) + .inner_margin(egui::Margin::symmetric(8, 2)) + .show(ui, |ui| { + ui.label(format!("{votes} votes")); + }); +} + +fn short_identifier(identifier: Identifier) -> String { + let encoded = identifier.to_string(Encoding::Base58); + format!("{}…{}", &encoded[..6], &encoded[encoded.len() - 4..]) +} + +fn vote_choice_label(choice: ResourceVoteChoice) -> String { + match choice { + ResourceVoteChoice::Lock => "Lock name".to_owned(), + ResourceVoteChoice::Abstain => "Abstain".to_owned(), + ResourceVoteChoice::TowardsIdentity(identifier) => { + format!("Vote for {}", short_identifier(identifier)) + } + } +} + +fn target_status_label(status: DpnsVoteTargetStatus) -> &'static str { + match status { + DpnsVoteTargetStatus::Scheduled => "Scheduled", + DpnsVoteTargetStatus::Queued => "Queued", + DpnsVoteTargetStatus::Submitting => "Submitting", + DpnsVoteTargetStatus::Confirming => "Confirming", + DpnsVoteTargetStatus::Confirmed => "Confirmed", + DpnsVoteTargetStatus::Unconfirmed => "Confirmation is still being checked", + DpnsVoteTargetStatus::Rejected => "Rejected", + DpnsVoteTargetStatus::FailedBeforeSubmission => "Not submitted", + DpnsVoteTargetStatus::NotApplied => "Not applied", + DpnsVoteTargetStatus::Cancelled => "Cancelled", + } +} + +fn dpns_operation_id(context: &BackendTaskContext) -> Option { + match context { + BackendTaskContext::Dispatched { operation, .. } => dpns_operation_id(operation), + BackendTaskContext::DpnsVoteOperation { operation_id, .. } => Some(*operation_id), + _ => None, + } +} + impl DPNSSubscreen { pub fn display_name(&self) -> &'static str { match self { @@ -98,8 +174,6 @@ pub enum RefreshingStatus { #[derive(Clone, Copy, PartialEq, Eq)] enum SortColumn { ContestedName, - LockedVotes, - AbstainVotes, EndingTime, LastUpdated, AwardedTo, @@ -127,6 +201,11 @@ pub struct DPNSScreen { pub app_context: Arc, pending_backend_task: Option, vote_operations: DpnsVoteOperationSnapshot, + vote_state: DpnsVoteStateSnapshot, + vote_overlay: Option, + pending_vote_operation: Option, + clear_vote_overlay_on_error: bool, + scheduled_clear_dialog: Option<(bool, ConfirmationDialog)>, /// Sorting sort_column: SortColumn, @@ -143,9 +222,7 @@ pub struct DPNSScreen { /// Selected vote handling show_bulk_schedule_popup: bool, bulk_identity_options: Vec, - bulk_schedule_message: Option<(MessageType, String)>, bulk_vote_handling_status: VoteHandlingStatus, - vote_banner: Option, set_all_option: VoteOption, } @@ -192,6 +269,21 @@ impl DPNSScreen { ); DpnsVoteOperationSnapshot::default() }); + let vote_poll_ids = contested_names + .lock_recover() + .iter() + .filter_map(|contest| { + app_context + .dpns_vote_poll_id(&contest.normalized_contested_name) + .ok() + }) + .collect::>(); + let voter_ids = voting_identities + .iter() + .map(|identity| identity.identity.id()) + .collect::>(); + let vote_state = DpnsVoteStateSnapshot::load(app_context, &voter_ids, &vote_poll_ids) + .unwrap_or_default(); // Initialize vote handling pop-up state to hidden let identity_count = voting_identities.len(); @@ -213,6 +305,11 @@ impl DPNSScreen { scheduled_vote_cast_in_progress: false, pending_backend_task: None, vote_operations, + vote_state, + vote_overlay: None, + pending_vote_operation: None, + clear_vote_overlay_on_error: false, + scheduled_clear_dialog: None, dpns_subscreen, refreshing_status: RefreshingStatus::NotRefreshing, refresh_banner: None, @@ -220,9 +317,7 @@ impl DPNSScreen { // Vote handling show_bulk_schedule_popup: false, bulk_identity_options, - bulk_schedule_message: None, bulk_vote_handling_status: VoteHandlingStatus::NotStarted, - vote_banner: None, set_all_option: VoteOption::CastNow, } } @@ -248,8 +343,6 @@ impl DPNSScreen { SortColumn::ContestedName => a .normalized_contested_name .cmp(&b.normalized_contested_name), - SortColumn::LockedVotes => a.locked_votes.cmp(&b.locked_votes), - SortColumn::AbstainVotes => a.abstain_votes.cmp(&b.abstain_votes), SortColumn::EndingTime => a.end_time.cmp(&b.end_time), SortColumn::LastUpdated => a.last_updated.cmp(&b.last_updated), SortColumn::AwardedTo => a.awarded_to.cmp(&b.awarded_to), @@ -335,7 +428,7 @@ impl DPNSScreen { let dark_mode = ui.style().visuals.dark_mode; let text_color = DashColors::text_primary(dark_mode); ui.label( - RichText::new("To schedule votes, go to the Active Contests subscreen, click your choices, and then click the 'Vote' button in the top-right.").color(text_color) + RichText::new("Choose votes on the Active contests screen, then use Review and cast to schedule them.").color(text_color) ); } }); @@ -347,331 +440,356 @@ impl DPNSScreen { // Rendering: Active, Past, Owned, Scheduled // --------------------------- - /// Show the Active Contests table - fn render_table_active_contests(&mut self, ui: &mut Ui) { + /// Render active contests as decision cards grouped by what the loaded nodes can do. + fn render_active_contests(&mut self, ui: &mut Ui) { + let dark_mode = ui.style().visuals.dark_mode; ui.horizontal(|ui| { - let dark_mode = ui.style().visuals.dark_mode; ui.label(RichText::new("Filter by name:").color(DashColors::text_primary(dark_mode))); ui.text_edit_singleline(&mut self.active_filter_term); }); + ui.label( + RichText::new("Each node can change its vote up to four times after its initial vote.") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(8.0); - let contested_names = { - let guard = self.contested_names.lock_recover(); - let mut cn = guard.clone(); - if !self.active_filter_term.is_empty() { - let mut filter_lc = self.active_filter_term.to_lowercase(); - // Convert o and O to 0 and l to 1 in filter_lc - filter_lc = filter_lc - .chars() - .map(|c| match c { - 'o' | 'O' => '0', - 'l' => '1', - _ => c, - }) - .collect(); - cn.retain(|c| { - c.normalized_contested_name + let filter = self.active_filter_term.to_lowercase(); + let contests = self + .contested_names + .lock_recover() + .iter() + .filter(|contest| { + filter.is_empty() + || contest + .normalized_contested_name .to_lowercase() - .contains(&filter_lc) - }); - } - self.sort_contested_names(&mut cn); - cn - }; + .contains(&filter) + }) + .cloned() + .collect::>(); + let mut groups = [Vec::new(), Vec::new(), Vec::new()]; + for contest in contests { + let index = match self.contest_group(&contest) { + ActiveContestGroup::NeedsVote => 0, + ActiveContestGroup::Voted => 1, + ActiveContestGroup::NotVotable => 2, + }; + groups[index].push(contest); + } - // Space allocation for UI elements is handled by the layout system + egui::ScrollArea::vertical() + .id_salt("active_contest_cards") + .show(ui, |ui| { + self.render_contest_group(ui, "Needs your vote", &groups[0], true, true); + self.render_contest_group(ui, "Voted", &groups[1], false, true); + self.render_contest_group( + ui, + "Not votable by your nodes", + &groups[2], + false, + false, + ); + self.render_voting_activity(ui); + }); - egui::ScrollArea::both().show(ui, |ui| { - TableBuilder::new(ui) - .striped(false) - .resizable(true) - .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) - .column(Column::auto().resizable(true)) // Contested Name - .column(Column::auto().resizable(true)) // Locked - .column(Column::auto().resizable(true)) // Abstain - .column(Column::auto().resizable(true)) // Ending Time - .column(Column::auto().resizable(true)) // Last Updated - .column(Column::auto().resizable(true)) // Contestants - .header(30.0, |mut header| { - header.col(|ui| { - if ui.button("Name").clicked() { - self.toggle_sort(SortColumn::ContestedName); - } - }); - header.col(|ui| { - if ui.button("Locked Votes").clicked() { - self.toggle_sort(SortColumn::LockedVotes); - } - }); - header.col(|ui| { - if ui.button("Abstain Votes").clicked() { - self.toggle_sort(SortColumn::AbstainVotes); - } - }); - header.col(|ui| { - if ui.button("Ending Time").clicked() { - self.toggle_sort(SortColumn::EndingTime); - } - }); - header.col(|ui| { - if ui.button("Last Updated").clicked() { - self.toggle_sort(SortColumn::LastUpdated); - } - }); - header.col(|ui| { - let dark_mode = ui.style().visuals.dark_mode; - ui.heading( - RichText::new("Contestants").color(DashColors::text_primary(dark_mode)), - ); - }); - }) - .body(|mut body| { - for contested_name in &contested_names { - body.row(25.0, |mut row| { - let locked_votes = contested_name.locked_votes.unwrap_or(0); - let max_contestant_votes = contested_name - .contestants - .as_ref() - .map(|contestants| { - contestants.iter().map(|c| c.votes).max().unwrap_or(0) - }) - .unwrap_or(0); - let is_locked_votes_bold = locked_votes > max_contestant_votes; - - // Contested Name - row.col(|ui| { - let (used_name, highlighted) = - if let Some(contestants) = &contested_name.contestants { - if let Some(first) = contestants.first() { - if contestants.iter().all(|c| c.name == first.name) { - // Everyone has same name - ( - first.name.clone(), - Some( - contested_name - .normalized_contested_name - .clone(), - ), - ) - } else { - // Multiple different names - ( - contestants - .iter() - .map(|c| c.name.clone()) - .join(" or "), - Some( - contestants - .iter() - .map(|c| { - format!( - "{} trying to get {}", - c.id, - c.name.clone() - ) - }) - .join(" and "), - ), - ) - } - } else { - (contested_name.normalized_contested_name.clone(), None) - } - } else { - (contested_name.normalized_contested_name.clone(), None) - }; + ui.separator(); + ui.horizontal(|ui| { + let count = self.selected_votes.len(); + ui.label( + RichText::new(format!("Votes ready to cast: {count}")) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ComponentStyles::add_primary_button_enabled(ui, count > 0, "Review and cast") + .disabled_tooltip("Choose a vote on at least one contest before reviewing it.") + .clicked() + { + self.show_bulk_schedule_popup = true; + } + }); + }); + } - let dark_mode = ui.style().visuals.dark_mode; - let label_response = ui.label( - RichText::new(used_name) - .color(DashColors::text_primary(dark_mode)), - ); - if let Some(tooltip) = highlighted { - label_response.info_tooltip(tooltip); - } - }); + fn contest_group(&self, contest: &ContestedName) -> ActiveContestGroup { + let Ok(poll_id) = self + .app_context + .dpns_vote_poll_id(&contest.normalized_contested_name) + else { + return ActiveContestGroup::NotVotable; + }; + classify_vote_states( + self.voting_identities + .iter() + .map(|identity| self.vote_state.state(identity.identity.id(), poll_id)), + ) + } - // LOCK button - row.col(|ui| { - let label_text = format!("{}", locked_votes); - let dark_green = Color32::from_rgb(0, 100, 0); - let dark_mode = ui.style().visuals.dark_mode; - let normal_color = DashColors::text_primary(dark_mode); - let text_widget = if is_locked_votes_bold { - RichText::new(label_text).strong().color(dark_green) - } else { - RichText::new(label_text).color(normal_color) - }; + fn render_contest_group( + &mut self, + ui: &mut Ui, + title: &str, + contests: &[ContestedName], + default_open: bool, + voting_enabled: bool, + ) { + egui::CollapsingHeader::new(format!("{title} ({})", contests.len())) + .default_open(default_open) + .show(ui, |ui| { + if contests.is_empty() { + ui.label("There are no contests in this group."); + } + for contest in contests { + let contest_enabled = + voting_enabled && self.contest_has_available_target(contest); + ui.add_enabled_ui(contest_enabled, |ui| { + self.render_contest_card(ui, contest, contest_enabled); + }); + ui.add_space(8.0); + } + }); + } - // See if this (LOCK) is selected - let is_selected = self.selected_votes.iter().any(|sv| { - sv.contested_name == contested_name.normalized_contested_name - && sv.vote_choice == ResourceVoteChoice::Lock - }); + fn contest_has_available_target(&self, contest: &ContestedName) -> bool { + let Ok(vote_poll_id) = self + .app_context + .dpns_vote_poll_id(&contest.normalized_contested_name) + else { + return false; + }; + self.voting_identities.iter().any(|identity| { + let voter_id = identity.identity.id(); + matches!( + self.vote_state.state(voter_id, vote_poll_id), + DpnsCurrentVoteState::Available(_) + ) && self + .vote_operations + .target_status(&DpnsVoteTargetKey { + network: self.app_context.network(), + voter_id, + vote_poll_id, + }) + .is_none() + }) + } - let button = if is_selected { - Button::new(text_widget).fill(Color32::from_rgb(0, 150, 255)) - } else { - Button::new(text_widget) - }; - let resp = ui.add(button); - if resp.clicked() { - // Is there already a selection for this contested name? - if let Some(existing_index) = - self.selected_votes.iter().position(|sv| { - sv.contested_name - == contested_name.normalized_contested_name - }) - { - // If the user clicked the same choice, that toggles it off (unselect). - if self.selected_votes[existing_index].vote_choice - == ResourceVoteChoice::Lock - { - // Remove it entirely -> no selection - self.selected_votes.remove(existing_index); - } else { - // Otherwise replace the old choice with Lock - self.selected_votes[existing_index].vote_choice = - ResourceVoteChoice::Lock; - } - } else { - // No existing selection for this name, so add this new Lock - self.selected_votes.push(SelectedVote { - contested_name: contested_name - .normalized_contested_name - .clone(), - vote_choice: ResourceVoteChoice::Lock, - end_time: contested_name.end_time, - }); - } - } - }); + fn render_contest_card(&mut self, ui: &mut Ui, contest: &ContestedName, voting_enabled: bool) { + let dark_mode = ui.style().visuals.dark_mode; + let selected = self + .selected_votes + .iter() + .find(|vote| vote.contested_name == contest.normalized_contested_name) + .map(|vote| vote.vote_choice); + let locked_votes = contest.locked_votes.unwrap_or_default(); + let abstain_votes = contest.abstain_votes.unwrap_or_default(); - // ABSTAIN button - row.col(|ui| { - let abstain_votes = contested_name.abstain_votes.unwrap_or(0); - let label_text = format!("{}", abstain_votes); + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.set_min_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("{}.dash", contest.normalized_contested_name)) + .heading() + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + if let Some(end_time) = contest.end_time + && let LocalResult::Single(date_time) = + Utc.timestamp_millis_opt(end_time as i64) + { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label( + RichText::new(format!("Voting ends {}.", HumanTime::from(date_time))) + .color(DashColors::text_secondary(dark_mode)), + ); + }); + } + }); + ui.add_space(6.0); + + ui.horizontal_wrapped(|ui| { + let clicked = ui + .selectable_label(selected == Some(ResourceVoteChoice::Lock), "Lock name") + .on_disabled_hover_text( + "None of your loaded nodes can vote on this contest right now.", + ) + .clicked(); + tally_chip(ui, locked_votes, dark_mode); + if clicked && voting_enabled { + self.set_selected_vote(contest, ResourceVoteChoice::Lock); + } - let is_selected = self.selected_votes.iter().any(|sv| { - sv.contested_name == contested_name.normalized_contested_name - && sv.vote_choice == ResourceVoteChoice::Abstain - }); + let clicked = ui + .selectable_label(selected == Some(ResourceVoteChoice::Abstain), "Abstain") + .clicked(); + tally_chip(ui, abstain_votes, dark_mode); + if clicked && voting_enabled { + self.set_selected_vote(contest, ResourceVoteChoice::Abstain); + } + }); - let button = if is_selected { - Button::new(label_text).fill(Color32::from_rgb(0, 150, 255)) - } else { - Button::new(label_text) - }; - let resp = ui.add(button); - if resp.clicked() { - // Is there already a selection for this contested name? - if let Some(existing_index) = - self.selected_votes.iter().position(|sv| { - sv.contested_name - == contested_name.normalized_contested_name - }) - { - // If the user clicked the same choice, that toggles it off (unselect). - if self.selected_votes[existing_index].vote_choice - == ResourceVoteChoice::Abstain - { - // Remove it entirely -> no selection - self.selected_votes.remove(existing_index); - } else { - // Otherwise replace the old choice with Abstain - self.selected_votes[existing_index].vote_choice = - ResourceVoteChoice::Abstain; - } - } else { - // No existing selection for this name, so add this new Abstain - self.selected_votes.push(SelectedVote { - contested_name: contested_name - .normalized_contested_name - .clone(), - vote_choice: ResourceVoteChoice::Abstain, - end_time: contested_name.end_time, - }); - } - } - }); + if let Some(contestants) = &contest.contestants { + for contestant in contestants { + ui.horizontal_wrapped(|ui| { + let choice = ResourceVoteChoice::TowardsIdentity(contestant.id); + let clicked = ui + .selectable_label( + selected == Some(choice), + format!("Vote for {}", contestant.name), + ) + .clicked(); + tally_chip(ui, contestant.votes, dark_mode); + ui.label( + RichText::new(short_identifier(contestant.id)) + .monospace() + .color(DashColors::text_secondary(dark_mode)), + ); + if clicked && voting_enabled { + self.set_selected_vote(contest, choice); + } + }); + } + } + }); + } - // Ending Time - row.col(|ui| { - let dark_mode = ui.style().visuals.dark_mode; - if let Some(ending_time) = contested_name.end_time { - if let LocalResult::Single(dt) = - Utc.timestamp_millis_opt(ending_time as i64) - { - let iso_date = dt.format("%Y-%m-%d %H:%M:%S"); - let relative_time = HumanTime::from(dt).to_string(); - let text = format!("{} ({})", iso_date, relative_time); - ui.label( - RichText::new(text) - .color(DashColors::text_primary(dark_mode)), - ); - } else { - ui.label( - RichText::new("Invalid timestamp") - .color(DashColors::text_primary(dark_mode)), - ); - } - } else { - ui.label( - RichText::new("Fetching") - .color(DashColors::text_primary(dark_mode)), - ); - } - }); + fn set_selected_vote(&mut self, contest: &ContestedName, choice: ResourceVoteChoice) { + if let Some(index) = self + .selected_votes + .iter() + .position(|vote| vote.contested_name == contest.normalized_contested_name) + { + if self.selected_votes[index].vote_choice == choice { + self.selected_votes.remove(index); + } else { + self.selected_votes[index].vote_choice = choice; + } + } else { + self.selected_votes.push(SelectedVote { + contested_name: contest.normalized_contested_name.clone(), + vote_choice: choice, + end_time: contest.end_time, + }); + } + } - // Last Updated - row.col(|ui| { - let dark_mode = ui.style().visuals.dark_mode; - if let Some(last_updated) = contested_name.last_updated { - if let LocalResult::Single(dt) = - Utc.timestamp_opt(last_updated as i64, 0) - { - let rel_time = HumanTime::from(dt).to_string(); - if rel_time.contains("seconds") { - ui.label( - RichText::new("now") - .color(DashColors::text_primary(dark_mode)), - ); - } else { - ui.label( - RichText::new(rel_time) - .color(DashColors::text_primary(dark_mode)), - ); - } - } else { - ui.label( - RichText::new("Invalid timestamp") - .color(DashColors::text_primary(dark_mode)), - ); - } - } else { - ui.label( - RichText::new("Fetching") - .color(DashColors::text_primary(dark_mode)), - ); - } - }); + fn render_voting_activity(&mut self, ui: &mut Ui) { + let mut operations = self.vote_operations.operations().to_vec(); + operations.sort_by_key(|operation| operation.created_at); + let operations = operations + .into_iter() + .rev() + .filter(|operation| !operation.targets.is_empty()) + .take(5) + .collect::>(); + if operations.is_empty() { + return; + } - // Contestants - row.col(|ui| { - self.show_contestants_for_contested_name( - ui, - contested_name, - is_locked_votes_bold, - max_contestant_votes, - ); - }); - }); + let dark_mode = ui.style().visuals.dark_mode; + ui.add_space(12.0); + ui.heading("Recent voting activity"); + for operation in operations { + let settled = operation + .targets + .iter() + .filter(|outcome| !outcome.status.holds_lock()) + .count(); + ui.group(|ui| { + ui.label( + RichText::new(format!( + "{settled} of {} votes settled.", + operation.targets.len() + )) + .strong(), + ); + for outcome in &operation.targets { + ui.horizontal_wrapped(|ui| { + ui.label(format!( + "{}.dash β€” {} β€” {}", + outcome.target.contested_name, + vote_choice_label(outcome.target.requested_choice), + target_status_label(outcome.status), + )); + if outcome.status == DpnsVoteTargetStatus::Unconfirmed + && ComponentStyles::add_secondary_button( + ui, + "Check again", + dark_mode, + ) + .clicked() + { + self.pending_backend_task = Some(BackendTask::ContestedResourceTask( + ContestedResourceTask::ReconcileDpnsVoteOperation( + operation.id, + self.app_context.network(), + ), + )); + self.pending_vote_operation = Some(operation.id); + self.raise_vote_overlay( + ui.ctx(), + "Checking the submitted votes with Platform…", + ); + } + if matches!( + outcome.status, + DpnsVoteTargetStatus::Rejected + | DpnsVoteTargetStatus::FailedBeforeSubmission + | DpnsVoteTargetStatus::NotApplied + ) && ComponentStyles::add_secondary_button( + ui, + "Review again", + dark_mode, + ) + .clicked() + { + self.set_selected_vote_from_outcome(outcome); + self.bulk_identity_options = + vec![VoteOption::CastNow; self.voting_identities.len()]; + self.set_all_option = VoteOption::CastNow; + self.show_bulk_schedule_popup = true; + } + }); + if matches!( + outcome.status, + DpnsVoteTargetStatus::Confirming + | DpnsVoteTargetStatus::Unconfirmed + ) { + ui.label( + RichText::new( + "This vote may already have been submitted. Do not submit it again.", + ) + .color(DashColors::warning_color(dark_mode)), + ); } - }); - }); + } + }); + } + } + + fn set_selected_vote_from_outcome( + &mut self, + outcome: &crate::model::dpns_voting::DpnsVoteOutcome, + ) { + let name = outcome.target.contested_name.clone(); + if let Some(vote) = self + .selected_votes + .iter_mut() + .find(|vote| vote.contested_name == name) + { + vote.vote_choice = outcome.target.requested_choice; + } else { + self.selected_votes.push(SelectedVote { + contested_name: name, + vote_choice: outcome.target.requested_choice, + end_time: None, + }); + } } - /// Show a Past Contests table + fn raise_vote_overlay(&mut self, ctx: &egui::Context, message: &str) { + self.vote_overlay + .raise(ctx, message, OverlayConfig::default()); + } fn render_table_past_contests(&mut self, ui: &mut Ui) { ui.horizontal(|ui| { let dark_mode = ui.style().visuals.dark_mode; @@ -735,7 +853,7 @@ impl DPNSScreen { } }); header.col(|ui| { - if ui.button("Awarded To").clicked() { + if ui.button("Outcome").clicked() { self.toggle_sort(SortColumn::AwardedTo); } }); @@ -826,17 +944,21 @@ impl DPNSScreen { ); } ContestState::WonBy(identifier) => { - ui.add( - egui::Label::new( - identifier.to_string(Encoding::Base58), - ) - .sense(egui::Sense::hover()) - .truncate(), - ); + let winner = contested_name + .contestants + .as_ref() + .and_then(|contestants| { + contestants + .iter() + .find(|contestant| contestant.id == identifier) + }) + .map(|contestant| contestant.name.clone()) + .unwrap_or_else(|| short_identifier(identifier)); + ui.label(format!("Won by {winner}.")); } ContestState::Locked => { ui.label( - RichText::new("Locked") + RichText::new("Locked; no name was awarded.") .color(DashColors::text_primary(dark_mode)), ); } @@ -1003,6 +1125,7 @@ impl DPNSScreen { /// Show the Scheduled Votes table fn render_table_scheduled_votes(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; + let mut show_cast_overlay = false; let mut sorted_votes = { let guard = self.scheduled_votes.lock_recover(); guard.clone() @@ -1081,22 +1204,21 @@ impl DPNSScreen { body.row(25.0, |mut row| { // Contested name row.col(|ui| { - ui.add(Label::new(&vote.0.contested_name)); + ui.add(Label::new(format!("{}.dash", vote.0.contested_name))); }); // Voter row.col(|ui| { - ui.add( - Label::new(vote.0.voter_id.to_string(Encoding::Hex)).truncate(), - ); + let voter = self + .voting_identities + .iter() + .find(|identity| identity.identity.id() == vote.0.voter_id) + .and_then(|identity| identity.alias.clone()) + .unwrap_or_else(|| short_identifier(vote.0.voter_id)); + ui.add(Label::new(voter)); }); // Choice row.col(|ui| { - let display_text = match &vote.0.choice { - ResourceVoteChoice::TowardsIdentity(id) => { - id.to_string(Encoding::Base58) - } - other => other.to_string(), - }; + let display_text = vote_choice_label(vote.0.choice); ui.add(Label::new(display_text)); }); // Time @@ -1200,9 +1322,9 @@ impl DPNSScreen { ) && !target_is_busy; let cast_button = if cast_button_enabled { - Button::new("Cast Now") + Button::new("Cast now") } else { - Button::new("Cast Now").sense(egui::Sense::hover()) + Button::new("Cast now").sense(egui::Sense::hover()) }; if ui.add(cast_button).clicked() && cast_button_enabled { @@ -1231,7 +1353,7 @@ impl DPNSScreen { .iter() .find(|i| i.identity.id() == vote.0.voter_id) { - action = AppAction::BackendTask( + action = AppAction::BackendTask( BackendTask::ContestedResourceTask( ContestedResourceTask::CastScheduledVote( vote.0.clone(), @@ -1239,6 +1361,7 @@ impl DPNSScreen { ), ), ); + show_cast_overlay = true; } } }); @@ -1247,86 +1370,19 @@ impl DPNSScreen { }); }); - action - } - - /// For each contested name row, show the possible contestants. This is the old `show_contested_name_details` function. - fn show_contestants_for_contested_name( - &mut self, - ui: &mut Ui, - contested_name: &ContestedName, - is_locked_votes_bold: bool, - max_contestant_votes: u32, - ) { - if let Some(contestants) = &contested_name.contestants { - for contestant in contestants { - let first_6_chars: String = contestant - .id - .to_string(Encoding::Base58) - .chars() - .take(6) - .collect(); - let button_text = format!("{}... - {} votes", first_6_chars, contestant.votes); - - // Bold if highest - let text = if contestant.votes == max_contestant_votes && !is_locked_votes_bold { - RichText::new(button_text) - .strong() - .color(Color32::from_rgb(0, 100, 0)) - } else { - RichText::new(button_text) - }; - - // Check if selected - let is_selected = self.selected_votes.iter().any(|sv| { - sv.contested_name == contested_name.normalized_contested_name - && sv.vote_choice == ResourceVoteChoice::TowardsIdentity(contestant.id) - }); - - let button = if is_selected { - Button::new(text).fill(Color32::from_rgb(0, 150, 255)) - } else { - Button::new(text) - }; - let resp = ui.add(button); - if resp.clicked() { - // Is there already a selection for this contested name? - if let Some(existing_index) = self.selected_votes.iter().position(|sv| { - sv.contested_name == contested_name.normalized_contested_name - }) { - // If the user clicked the same choice, that toggles it off (unselect). - if self.selected_votes[existing_index].vote_choice - == ResourceVoteChoice::TowardsIdentity(contestant.id) - { - // Remove it entirely -> no selection - self.selected_votes.remove(existing_index); - } else { - // Otherwise replace the old choice with TowardsIdentity - self.selected_votes[existing_index].vote_choice = - ResourceVoteChoice::TowardsIdentity(contestant.id); - } - } else { - // No existing selection for this name, so add this new TowardsIdentity - self.selected_votes.push(SelectedVote { - contested_name: contested_name.normalized_contested_name.clone(), - vote_choice: ResourceVoteChoice::TowardsIdentity(contestant.id), - end_time: contested_name.end_time, - }); - } - } - } + if show_cast_overlay { + self.raise_vote_overlay(ui.ctx(), "Submitting the scheduled vote to Dash Platform…"); } + + action } - // --------------------------- - // Bulk scheduling ephemeral UI - // --------------------------- fn show_bulk_schedule_popup_window(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; let dark_mode = ui.style().visuals.dark_mode; ui.heading( - RichText::new("Cast or Schedule Votes").color(DashColors::text_primary(dark_mode)), + RichText::new("Review selected votes").color(DashColors::text_primary(dark_mode)), ); ui.add_space(10.0); @@ -1365,7 +1421,7 @@ impl DPNSScreen { ui.group(|ui| { let dark_mode = ui.style().visuals.dark_mode; ui.heading( - RichText::new("Selected Votes:").color(DashColors::text_primary(dark_mode)), + RichText::new("Selected votes").color(DashColors::text_primary(dark_mode)), ); ui.separator(); for sv in &self.selected_votes { @@ -1400,29 +1456,28 @@ impl DPNSScreen { // Show each identity + let user pick None / Immediate / Scheduled let dark_mode = ui.style().visuals.dark_mode; - ui.heading( - RichText::new("Select cast method for each node:") - .color(DashColors::text_primary(dark_mode)), - ); + ui.heading(RichText::new("All my nodes").color(DashColors::text_primary(dark_mode))); ui.add_space(10.0); ui.group(|ui| { ui.horizontal(|ui| { let dark_mode = ui.style().visuals.dark_mode; - ui.label(RichText::new("Set all:").color(DashColors::text_primary(dark_mode))); + ui.label( + RichText::new("Cast timing:").color(DashColors::text_primary(dark_mode)), + ); // A ComboBox to pick No Vote / Cast Now / Schedule ComboBox::from_id_salt("set_all_combo") .width(120.0) .selected_text(match self.set_all_option { - VoteOption::NoVote => "No Vote".to_string(), - VoteOption::CastNow => "Cast Now".to_string(), + VoteOption::NoVote => "Do not use these nodes".to_string(), + VoteOption::CastNow => "Cast now".to_string(), VoteOption::Scheduled { .. } => "Schedule".to_string(), }) .show_ui(ui, |ui| { if ui .selectable_label( matches!(self.set_all_option, VoteOption::NoVote), - "No Vote", + "Do not use these nodes", ) .clicked() { @@ -1431,7 +1486,7 @@ impl DPNSScreen { if ui .selectable_label( matches!(self.set_all_option, VoteOption::CastNow), - "Cast Now", + "Cast now", ) .clicked() { @@ -1470,7 +1525,7 @@ impl DPNSScreen { { let dark_mode = ui.style().visuals.dark_mode; ui.label( - RichText::new("Schedule In:") + RichText::new("Schedule after:") .color(DashColors::text_primary(dark_mode)), ); ui.add(egui::DragValue::new(days).prefix("Days: ").range(0..=14)); @@ -1479,7 +1534,7 @@ impl DPNSScreen { } // Button to apply the "Set all" choice to each identity in bulk_identity_options - if ui.button("Apply to All").clicked() { + if ui.button("Apply to all nodes").clicked() { for option in &mut self.bulk_identity_options { *option = self.set_all_option.clone(); } @@ -1487,103 +1542,115 @@ impl DPNSScreen { }); }); ui.add_space(10.0); - for (i, identity) in self.voting_identities.iter().enumerate() { - ui.group(|ui| { - ui.horizontal(|ui| { - let label = identity - .alias - .clone() - .unwrap_or_else(|| identity.identity.id().to_string(Encoding::Base58)); - let dark_mode = ui.style().visuals.dark_mode; - ui.label( - RichText::new(format!("Identity: {}", label)) - .color(DashColors::text_primary(dark_mode)), - ); - - // This is a hack - // I'm seeing a panic if I load the app in mainnet context where I have no voting identities, - // and then switch to testnet and pressed "Vote". - if self.bulk_identity_options.len() <= i { - let voting_identities = self - .app_context - .load_local_voting_identities() - .unwrap_or_default(); - // Initialize ephemeral bulk-schedule state to hidden - let identity_count = voting_identities.len(); - self.bulk_identity_options = vec![VoteOption::CastNow; identity_count]; - } + egui::CollapsingHeader::new("Choose per node (advanced)") + .default_open(false) + .show(ui, |ui| { + for (i, identity) in self.voting_identities.iter().enumerate() { + ui.group(|ui| { + ui.horizontal(|ui| { + let label = identity.alias.clone().unwrap_or_else(|| { + identity.identity.id().to_string(Encoding::Base58) + }); + let dark_mode = ui.style().visuals.dark_mode; + ui.label( + RichText::new(format!("Identity: {}", label)) + .color(DashColors::text_primary(dark_mode)), + ); - let current_option = &mut self.bulk_identity_options[i]; - ComboBox::from_id_salt(format!("combo_bulk_identity_{}", i)) - .width(120.0) - .selected_text(match current_option { - VoteOption::NoVote => "No Vote".to_string(), - VoteOption::CastNow => "Cast Now".to_string(), - VoteOption::Scheduled { .. } => "Schedule".to_string(), - }) - .show_ui(ui, |ui| { - if ui - .selectable_label( - matches!(current_option, VoteOption::NoVote), - "No Vote", - ) - .clicked() - { - *current_option = VoteOption::NoVote; - } - if ui - .selectable_label( - matches!(current_option, VoteOption::CastNow), - "Cast Now", - ) - .clicked() - { - *current_option = VoteOption::CastNow; + // This is a hack + // I'm seeing a panic if I load the app in mainnet context where I have no voting identities, + // and then switch to testnet and pressed "Vote". + if self.bulk_identity_options.len() <= i { + let voting_identities = self + .app_context + .load_local_voting_identities() + .unwrap_or_default(); + // Initialize ephemeral bulk-schedule state to hidden + let identity_count = voting_identities.len(); + self.bulk_identity_options = + vec![VoteOption::CastNow; identity_count]; } - if ui - .selectable_label( - matches!(current_option, VoteOption::Scheduled { .. }), - "Schedule", - ) - .clicked() + + let current_option = &mut self.bulk_identity_options[i]; + ComboBox::from_id_salt(format!("combo_bulk_identity_{}", i)) + .width(120.0) + .selected_text(match current_option { + VoteOption::NoVote => "Do not use this node".to_string(), + VoteOption::CastNow => "Cast now".to_string(), + VoteOption::Scheduled { .. } => "Schedule".to_string(), + }) + .show_ui(ui, |ui| { + if ui + .selectable_label( + matches!(current_option, VoteOption::NoVote), + "Do not use this node", + ) + .clicked() + { + *current_option = VoteOption::NoVote; + } + if ui + .selectable_label( + matches!(current_option, VoteOption::CastNow), + "Cast now", + ) + .clicked() + { + *current_option = VoteOption::CastNow; + } + if ui + .selectable_label( + matches!( + current_option, + VoteOption::Scheduled { .. } + ), + "Schedule", + ) + .clicked() + { + let (d, h, m) = match current_option { + VoteOption::Scheduled { + days, + hours, + minutes, + } => (*days, *hours, *minutes), + _ => (0, 0, 0), + }; + *current_option = VoteOption::Scheduled { + days: d, + hours: h, + minutes: m, + }; + } + }); + + if let VoteOption::Scheduled { + days, + hours, + minutes, + } = current_option { - let (d, h, m) = match current_option { - VoteOption::Scheduled { - days, - hours, - minutes, - } => (*days, *hours, *minutes), - _ => (0, 0, 0), - }; - *current_option = VoteOption::Scheduled { - days: d, - hours: h, - minutes: m, - }; + let dark_mode = ui.style().visuals.dark_mode; + ui.label( + RichText::new("Schedule after:") + .color(DashColors::text_primary(dark_mode)), + ); + ui.add( + egui::DragValue::new(days).prefix("Days: ").range(0..=14), + ); + ui.add( + egui::DragValue::new(hours).prefix("Hours: ").range(0..=23), + ); + ui.add( + egui::DragValue::new(minutes).prefix("Min: ").range(0..=59), + ); } }); - - if let VoteOption::Scheduled { - days, - hours, - minutes, - } = current_option - { - let dark_mode = ui.style().visuals.dark_mode; - ui.label( - RichText::new("Schedule In:") - .color(DashColors::text_primary(dark_mode)), - ); - ui.add(egui::DragValue::new(days).prefix("Days: ").range(0..=14)); - ui.add(egui::DragValue::new(hours).prefix("Hours: ").range(0..=23)); - ui.add(egui::DragValue::new(minutes).prefix("Min: ").range(0..=59)); - } - }); + }); + ui.add_space(10.0); + } }); - ui.add_space(10.0); - } }); - // If any selected votes are scheduled, show a warning if self .bulk_identity_options @@ -1611,19 +1678,21 @@ impl DPNSScreen { if operation_in_progress { "Submitting votes…" } else { - "Apply Votes" + "Submit votes" }, ) .disabled_tooltip("The selected votes are already being submitted.") .clicked() { action = self.bulk_apply_votes(); - if self.bulk_vote_handling_status == VoteHandlingStatus::CastingVotes { - self.vote_banner.take_and_clear(); - let handle = - MessageBanner::set_global(ui.ctx(), "Casting votes...", MessageType::Info); - handle.with_elapsed(); - self.vote_banner = Some(handle); + if matches!( + self.bulk_vote_handling_status, + VoteHandlingStatus::CastingVotes | VoteHandlingStatus::SchedulingVotes + ) { + self.raise_vote_overlay( + ui.ctx(), + "Submitting the selected votes to Dash Platform…", + ); } } @@ -1639,9 +1708,9 @@ impl DPNSScreen { { self.selected_votes.clear(); self.show_bulk_schedule_popup = false; - self.bulk_schedule_message = None; self.bulk_vote_handling_status = VoteHandlingStatus::NotStarted; - self.vote_banner.take_and_clear(); + self.vote_overlay.take_and_clear(); + self.pending_vote_operation = None; } // Handle status @@ -1661,10 +1730,7 @@ impl DPNSScreen { // handled above } VoteHandlingStatus::Failed(message) => { - ui.colored_label( - Color32::RED, - format!("Error casting/scheduling votes: {}", message), - ); + ui.colored_label(Color32::RED, message); } } @@ -1708,8 +1774,15 @@ impl DPNSScreen { { Ok(vote_poll_id) => vote_poll_id, Err(error) => { - self.bulk_vote_handling_status = - VoteHandlingStatus::Failed(error.to_string()); + tracing::warn!( + ?error, + contested_name = selected_vote.contested_name, + "Could not build a DPNS vote target" + ); + self.bulk_vote_handling_status = VoteHandlingStatus::Failed( + "This vote could not be prepared. Refresh Active contests and try again." + .to_owned(), + ); return AppAction::None; } }; @@ -1725,13 +1798,9 @@ impl DPNSScreen { )); return AppAction::None; } - let current_choice = match self - .app_context - .dpns_current_vote_state(voter_id, vote_poll_id) - { - Ok(DpnsCurrentVoteState::Available(choice)) => choice, - Ok(DpnsCurrentVoteState::Checking | DpnsCurrentVoteState::Unavailable) - | Err(_) => { + let current_choice = match self.vote_state.state(voter_id, vote_poll_id) { + DpnsCurrentVoteState::Available(choice) => choice, + DpnsCurrentVoteState::Checking | DpnsCurrentVoteState::Unavailable => { self.bulk_vote_handling_status = VoteHandlingStatus::Failed( "Current vote state is unavailable. Refresh voting before applying votes." .to_owned(), @@ -1769,6 +1838,7 @@ impl DPNSScreen { } else { VoteHandlingStatus::SchedulingVotes }; + self.pending_vote_operation = Some(operation.id); AppAction::BackendTask(BackendTask::ContestedResourceTask( ContestedResourceTask::SubmitDpnsVoteOperation( operation, @@ -1786,58 +1856,24 @@ impl DPNSScreen { self.selected_votes.clear(); ui.vertical_centered(|ui| { + let dark_mode = ui.style().visuals.dark_mode; ui.add_space(20.0); match &self.bulk_vote_handling_status { VoteHandlingStatus::Completed => { - // This means DET side was successful, but Platform may have returned errors - if let Some(message) = &self.bulk_schedule_message { - match message.0 { - MessageType::Error => { - let dark_mode = ui.style().visuals.dark_mode; - ui.heading( - RichText::new("❌").color(DashColors::text_primary(dark_mode)), - ); - if message.1.contains("Successes") { - let dark_mode = ui.style().visuals.dark_mode; - ui.heading( - RichText::new("Only some votes succeeded") - .color(DashColors::text_primary(dark_mode)), - ); - } else { - let dark_mode = ui.style().visuals.dark_mode; - ui.heading( - RichText::new("No votes succeeded") - .color(DashColors::text_primary(dark_mode)), - ); - } - ui.add_space(10.0); - let dark_mode = ui.style().visuals.dark_mode; - ui.label( - RichText::new(message.1.clone()) - .color(DashColors::text_primary(dark_mode)), - ); - } - MessageType::Success => { - let dark_mode = ui.style().visuals.dark_mode; - ui.heading( - RichText::new("πŸŽ‰").color(DashColors::text_primary(dark_mode)), - ); - let dark_mode = ui.style().visuals.dark_mode; - ui.heading( - RichText::new("Successfully casted and scheduled all votes") - .color(DashColors::text_primary(dark_mode)), - ); - } - _ => {} - } - } + ui.heading( + RichText::new("The voting operation has been updated.") + .color(DashColors::text_primary(dark_mode)), + ); + ui.label( + "Review the recent voting activity for each confirmed, pending, or failed target.", + ); } VoteHandlingStatus::Failed(message) => { // This means there was a DET-side error, not Platform-side let dark_mode = ui.style().visuals.dark_mode; ui.heading(RichText::new("❌").color(DashColors::text_primary(dark_mode))); ui.heading( - RichText::new("Error casting and scheduling votes (DET-side)") + RichText::new("The votes could not be submitted.") .color(DashColors::text_primary(dark_mode)), ); ui.add_space(10.0); @@ -1927,6 +1963,31 @@ impl ScreenLike for DPNSScreen { .collect(); } } + drop(contested_names); + drop(dpns_names); + drop(scheduled_votes); + + let voter_ids = self + .voting_identities + .iter() + .map(|identity| identity.identity.id()) + .collect::>(); + let poll_ids = self + .contested_names + .lock_recover() + .iter() + .filter_map(|contest| { + self.app_context + .dpns_vote_poll_id(&contest.normalized_contested_name) + .ok() + }) + .collect::>(); + if let Err(error) = self + .vote_state + .refresh(&self.app_context, &voter_ids, &poll_ids) + { + tracing::warn!(?error, "Could not refresh cached DPNS vote state"); + } } fn refresh_on_arrival(&mut self) { @@ -1934,6 +1995,8 @@ impl ScreenLike for DPNSScreen { .app_context .load_local_voting_identities() .unwrap_or_default(); + self.bulk_identity_options = vec![VoteOption::CastNow; self.voting_identities.len()]; + self.set_all_option = VoteOption::CastNow; self.user_identities = self .app_context .load_local_user_identities() @@ -1945,11 +2008,22 @@ impl ScreenLike for DPNSScreen { // Banner display is handled globally by AppState; this is only for side-effects. if matches!(message_type, MessageType::Error | MessageType::Warning) { self.refresh_banner.take_and_clear(); - self.vote_banner.take_and_clear(); } } + fn display_backend_task_error(&mut self, context: &BackendTaskContext, _error: &TaskError) { + self.clear_vote_overlay_on_error = match self.pending_vote_operation { + Some(operation_id) => dpns_operation_id(context) == Some(operation_id), + None => self.vote_overlay.is_some() && dpns_operation_id(context).is_none(), + }; + } + fn display_task_error(&mut self, error: &TaskError) -> bool { + if self.clear_vote_overlay_on_error { + self.vote_overlay.take_and_clear(); + self.pending_vote_operation = None; + self.clear_vote_overlay_on_error = false; + } if let Err(refresh_error) = self.vote_operations.refresh(&self.app_context) { tracing::warn!( ?refresh_error, @@ -1979,10 +2053,19 @@ impl ScreenLike for DPNSScreen { fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { match backend_task_success_result { - BackendTaskSuccessResult::DpnsVoteOperationUpdated { .. } => { - self.vote_banner.take_and_clear(); - self.bulk_vote_handling_status = VoteHandlingStatus::Completed; - self.refresh(); + BackendTaskSuccessResult::DpnsVoteOperationUpdated { operation_id, .. } => { + let owns_result = self.pending_vote_operation == Some(operation_id) + || (self.pending_vote_operation.is_none() && self.vote_overlay.is_some()); + if owns_result { + self.vote_overlay.take_and_clear(); + self.pending_vote_operation = None; + if matches!( + self.bulk_vote_handling_status, + VoteHandlingStatus::CastingVotes | VoteHandlingStatus::SchedulingVotes + ) { + self.bulk_vote_handling_status = VoteHandlingStatus::Completed; + } + } } BackendTaskSuccessResult::ScheduledVotesInProgress(votes) => { if let Err(error) = self.vote_operations.refresh(&self.app_context) { @@ -2011,19 +2094,9 @@ impl ScreenLike for DPNSScreen { } fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { - if self.dpns_subscreen == DPNSSubscreen::ScheduledVotes { - self.app_context - .route_to_dpns_operator(DpnsOperatorRoute::Scheduled); - return AppAction::SetMainScreen(RootScreenType::RootScreenMasternodes); - } let ctx = ui.ctx().clone(); let ctx = &ctx; let has_identity_that_can_register = !self.user_identities.is_empty(); - let has_active_contests = { - let guard = self.contested_names.lock_recover(); - !guard.is_empty() - }; - // Build top-right buttons let mut right_buttons = match self.dpns_subscreen { DPNSSubscreen::Active => { @@ -2033,17 +2106,7 @@ impl ScreenLike for DPNSScreen { ContestedResourceTask::QueryDPNSContests, ))), ); - if has_active_contests { - vec![ - refresh_button, - ( - "Vote with masternodes", - DesiredAppAction::Custom("Vote".to_string()), - ), - ] - } else { - vec![refresh_button] - } + vec![refresh_button] } DPNSSubscreen::Past => { let refresh_button = ( @@ -2067,19 +2130,11 @@ impl ScreenLike for DPNSScreen { vec![ ( "Clear All", - DesiredAppAction::BackendTask(Box::new( - BackendTask::ContestedResourceTask( - ContestedResourceTask::ClearAllScheduledVotes, - ), - )), + DesiredAppAction::Custom("Clear all scheduled votes".to_owned()), ), ( - "Clear Casted", - DesiredAppAction::BackendTask(Box::new( - BackendTask::ContestedResourceTask( - ContestedResourceTask::ClearExecutedScheduledVotes, - ), - )), + "Clear Completed", + DesiredAppAction::Custom("Clear completed scheduled votes".to_owned()), ), ] } @@ -2105,18 +2160,28 @@ impl ScreenLike for DPNSScreen { subdued_everyday_spec("DPNS", RootScreenType::RootScreenDPNSActiveContests), right_buttons, ); - - // If user clicked "Apply Votes" in the top bar - if action == AppAction::Custom("Vote".to_string()) { - self.app_context - .route_to_dpns_operator(DpnsOperatorRoute::Voting { - choices: self - .selected_votes - .iter() - .map(|vote| (vote.contested_name.clone(), vote.vote_choice)) - .collect(), - }); - action = AppAction::SetMainScreen(RootScreenType::RootScreenMasternodes); + if action == AppAction::Custom("Clear all scheduled votes".to_owned()) { + self.scheduled_clear_dialog = Some(( + true, + ConfirmationDialog::new( + "Clear all scheduled votes", + "Remove every scheduled vote from this device? Votes already submitted to Platform cannot be undone.", + ) + .danger_mode(true) + .confirm_text(Some("Clear all scheduled votes")), + )); + action = AppAction::None; + } else if action == AppAction::Custom("Clear completed scheduled votes".to_owned()) { + self.scheduled_clear_dialog = Some(( + false, + ConfirmationDialog::new( + "Clear completed scheduled votes", + "Remove every completed scheduled vote from this device? Pending votes will stay scheduled.", + ) + .danger_mode(true) + .confirm_text(Some("Clear completed scheduled votes")), + )); + action = AppAction::None; } // Left panel @@ -2135,9 +2200,23 @@ impl ScreenLike for DPNSScreen { // Main panel action |= island_central_panel(ui, |ui| { let mut inner_action = AppAction::None; + if let Some((clear_all, dialog)) = self.scheduled_clear_dialog.as_mut() + && let Some(status) = dialog.show(ui).inner.dialog_response + { + let clear_all = *clear_all; + self.scheduled_clear_dialog = None; + if status == ConfirmationStatus::Confirmed { + inner_action = + AppAction::BackendTask(BackendTask::ContestedResourceTask(if clear_all { + ContestedResourceTask::ClearAllScheduledVotes + } else { + ContestedResourceTask::ClearExecutedScheduledVotes + })); + } + } // Bulk-schedule ephemeral popup if self.show_bulk_schedule_popup { - egui::Window::new("Voting") + egui::Window::new("Review and cast") .collapsible(false) .resizable(true) .vscroll(true) @@ -2154,7 +2233,7 @@ impl ScreenLike for DPNSScreen { !guard.is_empty() }; if has_any { - self.render_table_active_contests(ui); + self.render_active_contests(ui); } else { inner_action |= self.render_no_active_contests_or_owned_names(ui); } @@ -2277,6 +2356,62 @@ mod tests { (ctx, temp_dir) } + #[test] + fn vote_submission_overlay_clears_when_the_operation_finishes() { + let (ctx, _temp_dir) = offline_ctx(); + let mut screen = DPNSScreen::new(&ctx, DPNSSubscreen::Active); + + screen.raise_vote_overlay( + ctx.egui_ctx(), + "Submitting the selected votes to Dash Platform…", + ); + screen.pending_vote_operation = Some(DpnsVoteOperationId::from_bytes([7; 16])); + assert!( + crate::ui::components::progress_overlay::ProgressOverlay::has_global(ctx.egui_ctx()) + ); + + screen.display_task_result(BackendTaskSuccessResult::DpnsVoteOperationUpdated { + network: ctx.network(), + operation_id: DpnsVoteOperationId::from_bytes([8; 16]), + }); + assert!( + crate::ui::components::progress_overlay::ProgressOverlay::has_global(ctx.egui_ctx()) + ); + + screen.display_task_result(BackendTaskSuccessResult::DpnsVoteOperationUpdated { + network: ctx.network(), + operation_id: DpnsVoteOperationId::from_bytes([7; 16]), + }); + + assert!( + !crate::ui::components::progress_overlay::ProgressOverlay::has_global(ctx.egui_ctx()) + ); + } + + #[test] + fn active_contest_groups_prioritize_nodes_that_still_need_a_vote() { + assert!(matches!( + classify_vote_states([ + DpnsCurrentVoteState::Available(Some(ResourceVoteChoice::Lock)), + DpnsCurrentVoteState::Available(None), + ]), + ActiveContestGroup::NeedsVote + )); + assert!(matches!( + classify_vote_states([DpnsCurrentVoteState::Available(Some( + ResourceVoteChoice::Abstain, + ))]), + ActiveContestGroup::Voted + )); + assert!(matches!( + classify_vote_states([ + DpnsCurrentVoteState::Checking, + DpnsCurrentVoteState::Unavailable, + ]), + ActiveContestGroup::NotVotable + )); + } + #[test] fn scheduled_vote_sweep_error_is_handled_after_cleanup() { let (ctx, _temp_dir) = offline_ctx(); diff --git a/src/ui/masternodes/detail_screen.rs b/src/ui/masternodes/detail_screen.rs index 47583ccd0..f8e546071 100644 --- a/src/ui/masternodes/detail_screen.rs +++ b/src/ui/masternodes/detail_screen.rs @@ -6,9 +6,6 @@ use std::sync::Arc; -use chrono::{LocalResult, TimeZone, Utc}; -use chrono_humanize::HumanTime; -use dash_sdk::dpp::identity::TimestampMillis; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; @@ -16,25 +13,16 @@ use eframe::egui::{self, Color32, RichText, Ui}; use std::collections::BTreeMap; -use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; - use crate::app::AppAction; use crate::backend_task::BackendTask; -use crate::backend_task::contested_names::ContestedResourceTask; -use crate::backend_task::identity::{IdentityInputToLoad, IdentityLoadMode, IdentityTask}; +use crate::backend_task::identity::IdentityTask; use crate::context::AppContext; -use crate::model::contested_name::{ - ContestedName, MasternodeContestSummary, MasternodeVoteStateSummary, -}; -use crate::model::dpns_voting::{DpnsCurrentVoteState, DpnsVoteTargetKey}; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::{ IdentityType, MasternodeKeyPresence, PrivateKeyTarget, QualifiedIdentity, }; -use crate::model::secret::Secret; use crate::ui::components::MessageBanner; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; -use crate::ui::components::password_input::PasswordInput; use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; use crate::ui::identity::identity_picker_card::draw_type_badge; use crate::ui::identity::identity_pill::shorten_id; @@ -42,7 +30,6 @@ use crate::ui::masternodes::card::{ PLATFORM_IDENTITY_STATUS_TOOLTIP, platform_identity_status_label, }; use crate::ui::masternodes::{TIP_OWNER_KEY, TIP_PAYOUT_KEY, TIP_VOTING_KEY, key_status_tokens}; -use crate::ui::state::dpns_vote_operations::DpnsVoteOperationSnapshot; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use crate::ui::tokens::claim_tokens_screen::ClaimTokensScreen; use crate::ui::tokens::tokens_screen::IdentityTokenBasicInfo; @@ -50,84 +37,6 @@ use crate::ui::{MessageType, Screen, ScreenType}; use crate::wallet_backend::IdentityKeyView; use crate::wallet_backend::secret_seam::SecretScheme; -/// Β§7 copy: shown when the node has no voting key loaded. -const MISSING_VOTER_MESSAGE: &str = - "This node has no voting key loaded. Add its voting private key to cast votes."; -/// Β§7 copy: shown when the node has a voter identity but no open contests. -const NO_OPEN_CONTESTS_MESSAGE: &str = - "There are no open name contests for this node to vote on right now."; -const CONTESTS_UNAVAILABLE_MESSAGE: &str = - "Name contest information is unavailable. Refresh and try again."; - -/// The collapsible DPNS section header, with the open-contest count (TC-DPNS-02). -fn dpns_section_header(summary: MasternodeContestSummary) -> String { - if summary.vote_state == MasternodeVoteStateSummary::Unavailable { - CONTESTS_UNAVAILABLE_MESSAGE.to_owned() - } else { - format!( - "DPNS name contests to vote on ({})", - summary.open_contest_count - ) - } -} - -/// Framing shown once above the per-contest vote controls, so a masternode -/// owner unfamiliar with DPNS contested voting understands what is being -/// decided. -const CONTEST_INTRO_MESSAGE: &str = "Several identities want the same name. Cast this node's vote to help decide who receives it, or to lock the name so no one gets it."; -/// Nudge shown under a contest that still has no vote picked, so the user knows -/// why the Cast votes button stays disabled. -const NO_SELECTION_HINT: &str = - "No vote picked yet. Choose Abstain, Lock, or a candidate above to set this node's vote."; -/// Tooltip on an enabled Cast votes button. -const CAST_ENABLED_HINT: &str = "Submit this node's vote for every name you picked."; -/// Tooltip on a disabled Cast votes button, explaining what unlocks it. -const CAST_DISABLED_HINT: &str = - "Pick Abstain, Lock, or a candidate for at least one name to enable this."; - -/// The full DPNS domain a contest is fighting over: DPNS names register under -/// `.dash`, so append it to the normalized label (shown bare elsewhere) to make -/// clear this is a real domain registration. -fn contest_display_name(normalized_name: &str) -> String { - format!("{normalized_name}.dash") -} - -/// A candidate choice label carrying the candidate's current vote tally, so the -/// voter sees the standing before picking. Phrased to avoid singular/plural -/// verb agreement for later translation. -fn candidate_choice_label(candidate_name: &str, votes: u32) -> String { - format!("Vote for {candidate_name} (votes so far: {votes})") -} - -/// Render data for one open contest, snapshotted before the choice-writing -/// loop so it does not borrow `open_contests` while `vote_selections` mutates. -struct ContestVoteRow { - name: String, - end_time: Option, - current_vote: DpnsCurrentVoteState, - locked: bool, - /// `(candidate id, candidate name, votes so far)` for each contestant. - candidates: Vec<(dash_sdk::platform::Identifier, String, u32)>, -} - -/// A one-line status for a contest: how many identities are competing and when -/// voting closes. Keeps the deadline absolute (ISO) plus a relative hint, and -/// degrades cleanly when the end time has not loaded yet. -fn contest_status_line(candidate_count: usize, end_time: Option) -> String { - let count = format!("Identities competing for this name: {candidate_count}."); - match end_time { - Some(end_time) => match Utc.timestamp_millis_opt(end_time as i64) { - LocalResult::Single(dt) => { - let iso = dt.format("%Y-%m-%d %H:%M:%S"); - let relative = HumanTime::from(dt); - format!("{count} Voting ends {iso} UTC ({relative}).") - } - _ => format!("{count} The voting deadline is unavailable."), - }, - None => format!("{count} The voting deadline is still loading."), - } -} - /// The fixed topβ†’bottom section order. Actions must precede Keys (TC-FR5-01). pub const SECTION_ORDER: [&str; 5] = ["Header", "Actions", "Keys", "DPNS", "Remove"]; @@ -247,11 +156,6 @@ pub enum DetailOutcome { Back, /// The node was removed β€” return to the list and reload. Removed, - /// Open the shared full-page composer prefiltered to this node. - OpenVotingCenter { - voter_id: dash_sdk::platform::Identifier, - choices: BTreeMap, - }, /// Push a reused screen / navigate. Boxed because `AppAction` is large. Forward(Box), } @@ -268,131 +172,31 @@ pub struct MasternodeDetailView { node_id_hex_full: String, node_id_short: String, key_presence: MasternodeKeyPresence, - contest_summary: MasternodeContestSummary, - /// Open contests this node can still vote on (loaded at construction / - /// refresh). Active/open only β€” scheduled/past history lives on the DPNS - /// Scheduled Votes screen (Β§10.7). - open_contests: Vec, - vote_operations: DpnsVoteOperationSnapshot, - /// Per-contest pending vote choice, keyed by normalized contested name. - vote_selections: BTreeMap, - /// One-shot automatic proved-state refresh for a newly opened detail view. - vote_state_refresh_dispatched: bool, - open_voting_center_requested: Option>, - /// The scoped, in-place "Add voting key" prompt (US-3 / Β§10.8) β€” distinct - /// from FR-4's load form. `Some` while the prompt is open. - voter_key_prompt: Option, remove_dialog: Option, } -#[cfg(test)] impl MasternodeDetailView { - /// Open the `Add voting key` prompt on a key, as typing into it would. - pub(crate) fn set_voter_key_prompt_for_test(&mut self, value: &str) { - let mut prompt = PasswordInput::new(); - prompt.set_text(value); - self.voter_key_prompt = Some(prompt); - } - - /// Whether the `Add voting key` prompt is open β€” and thus holding a key. - pub(crate) fn has_voter_key_prompt_for_test(&self) -> bool { - self.voter_key_prompt.is_some() - } -} - -impl MasternodeDetailView { - pub(crate) fn refresh_vote_operations(&mut self) { - if let Err(error) = self.vote_operations.refresh(&self.app_context) { - tracing::warn!(?error, "Could not refresh node-detail voting state"); - } - } - pub fn new(app_context: &Arc, identity: QualifiedIdentity) -> Self { let node_id_hex_full = identity.identity.id().to_string(Encoding::Hex); let node_id_short = shorten_id(&node_id_hex_full); let key_presence = identity.masternode_key_presence(); - let voter_id = identity - .associated_voter_identity - .as_ref() - .map(|_| identity.identity.id()); - let mut contest_summary = app_context - .masternode_contest_summary(voter_id) - .unwrap_or_else(|_| MasternodeContestSummary::unavailable()); - let open_contests = Self::load_open_contests(app_context, voter_id, &mut contest_summary); - let vote_operations = - DpnsVoteOperationSnapshot::load(app_context).unwrap_or_else(|error| { - tracing::warn!( - ?error, - "Could not cache DPNS operations for the node detail view" - ); - DpnsVoteOperationSnapshot::default() - }); Self { app_context: app_context.clone(), identity, node_id_hex_full, node_id_short, key_presence, - contest_summary, - open_contests, - vote_operations, - vote_selections: BTreeMap::new(), - vote_state_refresh_dispatched: false, - open_voting_center_requested: None, - voter_key_prompt: None, remove_dialog: None, } } - /// Load the contests this node can still vote on. - fn load_open_contests( - app_context: &Arc, - voter_id: Option, - contest_summary: &mut MasternodeContestSummary, - ) -> Vec { - let Some(voter_id) = voter_id else { - return Vec::new(); - }; - match app_context.ongoing_contested_names() { - Ok(contests) => contests - .into_iter() - .filter(|contest| contest.is_open_for_voter(&voter_id)) - .collect(), - Err(_) => { - *contest_summary = MasternodeContestSummary::unavailable(); - Vec::new() - } - } - } - - /// Refresh the DPNS contest summary + open-contest list from the store. - fn refresh_contests(&mut self) { - let voter_id = self - .identity - .associated_voter_identity - .as_ref() - .map(|_| self.identity.identity.id()); - let mut contest_summary = self - .app_context - .masternode_contest_summary(voter_id) - .unwrap_or_else(|_| MasternodeContestSummary::unavailable()); - self.open_contests = - Self::load_open_contests(&self.app_context, voter_id, &mut contest_summary); - self.contest_summary = contest_summary; - } - /// Build the network re-fetch dispatched by the detail Refresh button: /// refresh this node's identity, plus a DPNS contests re-query /// when the node has a voter identity that can vote. fn refresh_from_network(&self) -> AppAction { - let mut tasks = vec![BackendTask::IdentityTask(IdentityTask::RefreshIdentity( + let tasks = vec![BackendTask::IdentityTask(IdentityTask::RefreshIdentity( self.identity.clone(), ))]; - if self.identity.associated_voter_identity.is_some() { - tasks.push(BackendTask::ContestedResourceTask( - ContestedResourceTask::QueryDPNSContests, - )); - } AppAction::BackendTasks(tasks, crate::app::BackendTasksExecutionMode::Concurrent) } @@ -462,10 +266,6 @@ impl MasternodeDetailView { } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ComponentStyles::add_toolbar_button(ui, "Refresh", network_accent).clicked() { - // Re-read the local contest cache immediately (optimistic) - // AND dispatch a network re-fetch of this node plus the DPNS - // contests β€” Refresh must reach the network. - self.refresh_contests(); outcome = DetailOutcome::Forward(Box::new(self.refresh_from_network())); } }); @@ -491,13 +291,6 @@ impl MasternodeDetailView { outcome = DetailOutcome::Removed; } }); - if let Some(choices) = self.open_voting_center_requested.take() { - outcome = DetailOutcome::OpenVotingCenter { - voter_id: self.identity.identity.id(), - choices, - }; - } - outcome } @@ -795,294 +588,12 @@ impl MasternodeDetailView { AppAction::AddScreen(Screen::KeyInfoScreen(screen)) } - /// Render the collapsible DPNS voting section (collapsed by default, - /// open-contest count in the header). Inline voting reuses the existing - /// `vote_on_dpns_name` backend (locked decision #1 β€” not a deep-link). - fn render_dpns_section(&mut self, ui: &mut Ui, dark_mode: bool) -> Option { - // When the node has no voter key, the "Add voting key" CTA is - // the primary next step β€” render it above, outside the collapsed-by- - // default DPNS section, so it is visible without expanding anything. - // The empty DPNS section (no contests possible without a voter) is - // omitted in that state. - if self.identity.associated_voter_identity.is_none() { - return self.render_missing_voter(ui, dark_mode); - } - - let mut action = None; - let header = dpns_section_header(self.contest_summary); - egui::CollapsingHeader::new(header) - .default_open(false) - .show(ui, |ui| { - if self.contest_summary.vote_state == MasternodeVoteStateSummary::Unavailable { - ui.label( - RichText::new(CONTESTS_UNAVAILABLE_MESSAGE) - .color(DashColors::warning_color(dark_mode)), - ); - } else if self.open_contests.is_empty() { - ui.label( - RichText::new(NO_OPEN_CONTESTS_MESSAGE) - .color(DashColors::text_secondary(dark_mode)), - ); - } else { - action = self.render_vote_table(ui, dark_mode); - } - }); - action - } - - /// Missing-voter-identity state (US-3 / Β§10.9): an actionable message plus a - /// scoped in-place `Add voting key` prompt β€” never the raw error, never - /// FR-4's load form. - fn render_missing_voter(&mut self, ui: &mut Ui, dark_mode: bool) -> Option { - let mut action = None; - ui.label(RichText::new(MISSING_VOTER_MESSAGE).color(DashColors::warning_color(dark_mode))); - - match self.voter_key_prompt.as_mut() { - None => { - if ui.button("Add voting key").clicked() { - // Node context is already bound (`self.identity`) β€” the - // prompt only asks for the voting key, no ProTxHash re-entry. - self.voter_key_prompt = Some( - PasswordInput::new() - .with_hint_text("Voting private key (WIF or hex)") - .with_monospace(), - ); - } - } - Some(prompt) => { - prompt.show(ui); - ui.horizontal(|ui| { - if ui.button("Cancel").clicked() { - self.voter_key_prompt = None; - } - let has_key = !self - .voter_key_prompt - .as_ref() - .map(PasswordInput::is_empty) - .unwrap_or(true); - if ui.add_enabled(has_key, egui::Button::new("Save")).clicked() { - action = self.submit_voter_key(); - } - }); - } - } - action - } - - /// Close the `Add voting key` prompt, zeroizing the key typed into it. Called - /// when the Masternodes tab is left: the tab is a root screen that outlives - /// navigation, and an unsubmitted key must not. - pub fn clear_secrets(&mut self) { - self.voter_key_prompt = None; - } - - /// Build the scoped voter-key update: re-load THIS node (context pre-bound) - /// with just the entered voting key, updating its voter identity in place. - /// Distinct from FR-4's load form and exempt from duplicate-ProTxHash - /// rejection (Β§10.8). - fn submit_voter_key(&mut self) -> Option { - let voting_key = self.voter_key_prompt.as_mut()?.take_secret(); - self.voter_key_prompt = None; - let input = IdentityInputToLoad { - identity_id_input: self.node_id_hex_full.clone(), - identity_type: self.identity.identity_type, - alias_input: self.identity.alias.clone().unwrap_or_default(), - voting_private_key_input: voting_key, - owner_private_key_input: Secret::default(), - payout_address_private_key_input: Secret::default(), - keys_input: vec![], - derive_keys_from_wallets: false, - selected_wallet_seed_hash: None, - encryption_password: None, - // In-place update: merge the new voting key into the already-loaded - // node, preserving its Owner/Payout keys (Β§10.8). Never overwrite. - load_mode: IdentityLoadMode::MergeIntoExisting, - // This view gates on nothing: the load opens a record of its own rather - // than adopting one another caller is waiting on. - load_token: None, - }; - Some(AppAction::BackendTask(BackendTask::IdentityTask( - IdentityTask::LoadIdentity(input), - ))) - } - - /// Per-contest choices backed by the shared durable voting coordinator. - fn render_vote_table(&mut self, ui: &mut Ui, dark_mode: bool) -> Option { - let mut action = None; - // Collect the render data up front so the choice-writing loop does not - // borrow `self.open_contests` while mutating `self.vote_selections`. - let voter_id = self.identity.identity.id(); - let contests: Vec = self - .open_contests - .iter() - .filter_map(|contest| { - let vote_poll_id = self - .app_context - .dpns_vote_poll_id(&contest.normalized_contested_name) - .ok()?; - let current_vote = self - .app_context - .dpns_current_vote_state(voter_id, vote_poll_id) - .unwrap_or(DpnsCurrentVoteState::Unavailable); - let key = DpnsVoteTargetKey { - network: self.app_context.network(), - voter_id, - vote_poll_id, - }; - let candidates = contest - .contestants - .as_ref() - .map(|list| { - list.iter() - .map(|c| (c.id, c.name.clone(), c.votes)) - .collect() - }) - .unwrap_or_default(); - Some(ContestVoteRow { - name: contest.normalized_contested_name.clone(), - end_time: contest.end_time, - current_vote, - locked: self.vote_operations.target_status(&key).is_some(), - candidates, - }) - }) - .collect(); - if !self.vote_state_refresh_dispatched - && contests - .iter() - .any(|contest| contest.current_vote == DpnsCurrentVoteState::Checking) - { - self.vote_state_refresh_dispatched = true; - action = Some(AppAction::BackendTask(BackendTask::ContestedResourceTask( - ContestedResourceTask::QueryDPNSContests, - ))); - } - - ui.label(RichText::new(CONTEST_INTRO_MESSAGE).color(DashColors::text_secondary(dark_mode))); - - for contest in &contests { - ui.separator(); - ui.label( - RichText::new(contest_display_name(&contest.name)) - .strong() - .color(DashColors::text_primary(dark_mode)), - ); - ui.label( - RichText::new(contest_status_line( - contest.candidates.len(), - contest.end_time, - )) - .color(DashColors::text_secondary(dark_mode)), - ); - let current_label = match contest.current_vote { - DpnsCurrentVoteState::Checking => "Checking current vote…".to_owned(), - DpnsCurrentVoteState::Unavailable => "Current vote unavailable".to_owned(), - DpnsCurrentVoteState::Available(None) => "Not voted".to_owned(), - DpnsCurrentVoteState::Available(Some(ResourceVoteChoice::Abstain)) => { - "Current vote: Abstain".to_owned() - } - DpnsCurrentVoteState::Available(Some(ResourceVoteChoice::Lock)) => { - "Current vote: Lock".to_owned() - } - DpnsCurrentVoteState::Available(Some(ResourceVoteChoice::TowardsIdentity(id))) => { - let candidate = contest - .candidates - .iter() - .find(|(candidate_id, _, _)| *candidate_id == id) - .map(|(_, name, _)| { - format!("{name} ({})", shorten_id(&id.to_string(Encoding::Base58))) - }) - .unwrap_or_else(|| { - format!("candidate {}", shorten_id(&id.to_string(Encoding::Base58))) - }); - format!("Current vote: {candidate}") - } - }; - ui.label(RichText::new(current_label).color(DashColors::text_secondary(dark_mode))); - let selected = self.vote_selections.get(&contest.name).copied(); - let controls_enabled = - matches!(contest.current_vote, DpnsCurrentVoteState::Available(_)) - && !contest.locked; - ui.add_enabled_ui(controls_enabled, |ui| { - ui.horizontal_wrapped(|ui| { - if ui - .selectable_label(selected == Some(ResourceVoteChoice::Abstain), "Abstain") - .clicked() - { - self.vote_selections - .insert(contest.name.clone(), ResourceVoteChoice::Abstain); - } - if ui - .selectable_label(selected == Some(ResourceVoteChoice::Lock), "Lock") - .clicked() - { - self.vote_selections - .insert(contest.name.clone(), ResourceVoteChoice::Lock); - } - // Candidate choices are scoped to THIS contest's contestants. - for (candidate_id, candidate_name, votes) in &contest.candidates { - let choice = ResourceVoteChoice::TowardsIdentity(*candidate_id); - if ui - .selectable_label( - selected == Some(choice), - candidate_choice_label(candidate_name, *votes), - ) - .clicked() - { - self.vote_selections.insert(contest.name.clone(), choice); - } - } - }); - }); - if contest.locked { - ui.label( - RichText::new("This node's vote for this name is still being confirmed.") - .color(DashColors::text_secondary(dark_mode)), - ); - } else if matches!(contest.current_vote, DpnsCurrentVoteState::Unavailable) { - ui.label( - RichText::new("Refresh vote state before choosing a vote for this node.") - .color(DashColors::text_secondary(dark_mode)), - ); - if ComponentStyles::add_secondary_button(ui, "Refresh vote state", dark_mode) - .clicked() - { - self.vote_state_refresh_dispatched = true; - action = Some(AppAction::BackendTask(BackendTask::ContestedResourceTask( - ContestedResourceTask::QueryDPNSContests, - ))); - } - } else if matches!(contest.current_vote, DpnsCurrentVoteState::Checking) { - ui.label( - RichText::new("DET is checking this node's current vote.") - .color(DashColors::text_secondary(dark_mode)), - ); - } else if selected.is_none() { - ui.label( - RichText::new(NO_SELECTION_HINT).color(DashColors::text_secondary(dark_mode)), - ); - } - } - - ui.separator(); - let selection_count = self.vote_selections.len(); - let has_selections = selection_count > 0; - let review_label = if selection_count == 1 { - "Review 1 vote".to_owned() - } else { - format!("Review {selection_count} votes") - }; - if ComponentStyles::add_primary_button_enabled(ui, has_selections, review_label) - .clickable_tooltip(CAST_ENABLED_HINT) - .disabled_tooltip(CAST_DISABLED_HINT) + fn render_dpns_section(&mut self, ui: &mut Ui, _dark_mode: bool) -> Option { + ComponentStyles::add_secondary_button(ui, "DPNS Voting", ui.visuals().dark_mode) .clicked() - { - self.open_voting_center_requested = Some(self.vote_selections.clone()); - } - if ComponentStyles::add_secondary_button(ui, "Open Voting Center", dark_mode).clicked() { - self.open_voting_center_requested = Some(BTreeMap::new()); - } - action + .then(|| { + AppAction::SetMainScreen(crate::ui::RootScreenType::RootScreenDPNSActiveContests) + }) } /// Returns `true` once the node has been removed. @@ -1154,6 +665,7 @@ impl MasternodeDetailView { #[cfg(test)] mod tests { use super::*; + use crate::model::secret::Secret; #[test] fn tc_fr5_01_actions_render_before_keys() { @@ -1307,67 +819,6 @@ mod tests { ); } - #[test] - fn tc_dpns_02_header_shows_open_contest_count() { - let summary = MasternodeContestSummary { - open_contest_count: 3, - ..Default::default() - }; - assert_eq!( - dpns_section_header(summary), - "DPNS name contests to vote on (3)" - ); - - assert_eq!( - dpns_section_header(MasternodeContestSummary::unavailable()), - "Name contest information is unavailable. Refresh and try again." - ); - } - - #[test] - fn contest_name_gets_dash_suffix() { - // The normalized label is shown bare elsewhere; the vote section spells - // out the full `.dash` domain so the user knows it is a registration. - assert_eq!(contest_display_name("det"), "det.dash"); - } - - #[test] - fn candidate_label_carries_current_tally() { - let label = candidate_choice_label("alice", 5); - assert!( - label.contains("Vote for alice"), - "names the candidate: {label}" - ); - assert!(label.contains('5'), "shows the running tally: {label}"); - } - - #[test] - fn status_line_reports_candidate_count() { - let line = contest_status_line(2, None); - assert!( - line.contains("Identities competing for this name: 2."), - "counts contestants: {line}" - ); - assert!( - line.contains("still loading"), - "degrades when the deadline is absent: {line}" - ); - } - - #[test] - fn status_line_renders_absolute_deadline() { - // 2021-01-01T00:00:00Z in milliseconds. - let line = contest_status_line(3, Some(1_609_459_200_000)); - assert!( - line.contains("Identities competing for this name: 3."), - "counts contestants: {line}" - ); - assert!( - line.contains("2021-01-01 00:00:00 UTC"), - "shows the absolute ISO deadline: {line}" - ); - } - #[test] fn protection_tier_label_and_add_gate() { assert_eq!(ProtectionTier::Unprotected.label(), "Keys: unprotected"); diff --git a/src/ui/masternodes/list_screen.rs b/src/ui/masternodes/list_screen.rs index bf53383fe..1352cec40 100644 --- a/src/ui/masternodes/list_screen.rs +++ b/src/ui/masternodes/list_screen.rs @@ -5,11 +5,8 @@ use std::sync::Arc; -use chrono::{LocalResult, TimeZone, Utc}; -use chrono_humanize::HumanTime; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dash_sdk::platform::Identifier; use eframe::egui::{self, RichText}; @@ -17,17 +14,12 @@ use crate::app::{AppAction, BackendTasksExecutionMode}; use crate::backend_task::BackendTask; use crate::backend_task::contested_names::ContestedResourceTask; use crate::backend_task::identity::IdentityTask; +use crate::context::AppContext; use crate::context::identity_load_registry::{IdentityLoadPhase, IdentityLoadToken}; -use crate::context::{AppContext, DpnsOperatorRoute}; use crate::model::contested_name::MasternodeContestSummary; -use crate::model::dpns_voting::{ - DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteOutcome, DpnsVoteTargetStatus, VoteTiming, -}; use crate::model::masternode_input::decode_identity_id; use crate::model::qualified_identity::{IdentityStatus, IdentityType, MasternodeKeyPresence}; use crate::model::user_role::UserRole; -use crate::ui::components::component_trait::Component; -use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::global_nav_switcher::GlobalNavEffect; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; @@ -37,11 +29,9 @@ use crate::ui::identity::picker::compute_column_count; use crate::ui::masternodes::card::{MasternodeCard, card_heading}; use crate::ui::masternodes::detail_screen::{DetailOutcome, MasternodeDetailView}; use crate::ui::masternodes::load_form::{LoadFormOutcome, MasternodeLoadForm}; -use crate::ui::masternodes::voting_center::{DpnsVotingCenter, VotingCenterOutcome}; -use crate::ui::state::dpns_vote_operations::DpnsVoteOperationSnapshot; use crate::ui::state::global_nav::PageNavSpec; use crate::ui::state::masternodes_view::{masternodes_page_nav_spec, node_pill_item}; -use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; +use crate::ui::theme::{ComponentStyles, DashColors}; use crate::ui::{RootScreenType, ScreenLike}; /// Minimum horizontal gap between cards in the grid (matches the identity @@ -70,10 +60,6 @@ enum MasternodesView { Load(Box), /// A node's detail / voting view (FR-5). Detail(Box), - /// Shared full-page immediate, bulk, and scheduled composer. - Voting(Box), - /// Shared scheduled-target management. - Scheduled, } /// A load this screen dispatched and has not yet seen finish. The token names @@ -85,19 +71,12 @@ struct PendingLoad { token: IdentityLoadToken, } -#[derive(Clone)] -struct ScheduledJournalTarget { - operation_id: DpnsVoteOperationId, - outcome: DpnsVoteOutcome, -} - /// Root screen for the Masternodes section. pub struct MasternodesScreen { pub app_context: Arc, /// Cached card data for the active network, refreshed on arrival, on /// `refresh`, and on the Refresh button. nodes: Vec, - vote_operations: DpnsVoteOperationSnapshot, /// The active sub-view (list / load / detail). view: MasternodesView, /// The load this screen dispatched and has not yet seen finish, identified by @@ -120,7 +99,6 @@ pub struct MasternodesScreen { /// [`TaskError::IdentityLoadInProgress`](crate::backend_task::error::TaskError::IdentityLoadInProgress) /// instead of racing. pending_load: Option, - pending_schedule_cancellation: Option<(ScheduledJournalTarget, ConfirmationDialog)>, } #[cfg(test)] @@ -142,10 +120,8 @@ impl MasternodesScreen { let mut screen = Self { app_context: app_context.clone(), nodes: Vec::new(), - vote_operations: DpnsVoteOperationSnapshot::default(), view: MasternodesView::List, pending_load: None, - pending_schedule_cancellation: None, }; screen.reload(); screen @@ -156,7 +132,6 @@ impl MasternodesScreen { /// rather than surfacing a technical error β€” the empty state is a safe, /// meaningful fallback. fn reload(&mut self) { - self.refresh_vote_operations(); let identities = self .app_context .load_local_masternode_identities() @@ -192,20 +167,6 @@ impl MasternodesScreen { }); } - fn refresh_vote_operations(&mut self) { - if let Err(error) = self.vote_operations.refresh(&self.app_context) { - tracing::warn!( - ?error, - "Could not refresh the masternode vote-operation cache" - ); - } - match &mut self.view { - MasternodesView::Detail(detail) => detail.refresh_vote_operations(), - MasternodesView::Voting(center) => center.refresh_vote_operations(), - MasternodesView::List | MasternodesView::Load(_) | MasternodesView::Scheduled => {} - } - } - /// Settle the submitted load against the phase its own task reported, and /// release the gate once that load is finished. The single place this screen /// decides a load is over β€” its result and error callbacks fire only while it @@ -243,7 +204,6 @@ impl MasternodesScreen { pub fn reset_for_network_change(&mut self) { self.view = MasternodesView::List; self.pending_load = None; - self.pending_schedule_cancellation = None; self.reload(); } @@ -347,126 +307,12 @@ impl MasternodesScreen { AppAction::None } - /// Operator-level Nodes / Voting / Scheduled navigation. - fn render_voting_navigation(&mut self, ui: &mut egui::Ui) -> AppAction { - let dark_mode = ui.style().visuals.dark_mode; - let action = AppAction::None; - ui.horizontal(|ui| { - if matches!(self.view, MasternodesView::Voting(_)) { - if ComponentStyles::add_secondary_button(ui, "Nodes", dark_mode).clicked() { - self.view = MasternodesView::List; - } - ComponentStyles::add_primary_button(ui, "Voting"); - } else { - if ComponentStyles::add_primary_button(ui, "Nodes").clicked() { - self.view = MasternodesView::List; - } - if ComponentStyles::add_secondary_button(ui, "Voting", dark_mode).clicked() { - self.open_voting_center(None, Vec::new()); - } - } - if matches!(self.view, MasternodesView::Scheduled) { - ComponentStyles::add_primary_button(ui, "Scheduled"); - } else if ComponentStyles::add_secondary_button(ui, "Scheduled", dark_mode).clicked() { - self.view = MasternodesView::Scheduled; - } - }); - ui.separator(); - action - } - - /// Shared target-correlated progress, visible regardless of the active node. - fn render_voting_activity(&mut self, ui: &mut egui::Ui) -> AppAction { - let mut operations = self.vote_operations.operations().to_vec(); - operations.sort_by_key(|operation| operation.created_at); - let operations = operations - .into_iter() - .rev() - .filter(|operation| !operation.targets.is_empty()) - .take(5) - .collect::>(); - if operations.is_empty() { - return AppAction::None; - } - - let dark_mode = ui.style().visuals.dark_mode; - let mut action = AppAction::None; - ui.add_space(16.0); - ui.heading(RichText::new("Voting activity").color(DashColors::text_primary(dark_mode))); - let mut open_operation = None; - for operation in operations { - let total = operation.targets.len(); - let complete = operation - .targets - .iter() - .filter(|outcome| !outcome.status.holds_lock()) - .count(); - ui.label( - RichText::new(format!( - "Operation {} Β· {complete} of {total} targets settled", - operation.id - )) - .strong(), - ); - for outcome in &operation.targets { - let voter = outcome.target.voter_alias.clone().unwrap_or_else(|| { - shorten_id(&outcome.target.key.voter_id.to_string(Encoding::Base58)) - }); - let status = match outcome.status { - DpnsVoteTargetStatus::Scheduled => "Scheduled", - DpnsVoteTargetStatus::Queued => "Queued", - DpnsVoteTargetStatus::Submitting => "Submitting", - DpnsVoteTargetStatus::Confirming => "Confirming", - DpnsVoteTargetStatus::Confirmed => "Confirmed", - DpnsVoteTargetStatus::Unconfirmed => "Checking result", - DpnsVoteTargetStatus::Rejected => "Rejected", - DpnsVoteTargetStatus::FailedBeforeSubmission => "Failed before submission", - DpnsVoteTargetStatus::Cancelled => "Cancelled", - DpnsVoteTargetStatus::NotApplied => "Not applied", - }; - ui.horizontal_wrapped(|ui| { - ui.label(format!( - "{voter} / {}.dash β€” {} β†’ {} β€” {status}", - outcome.target.contested_name, - vote_choice_summary(outcome.target.current_choice), - vote_choice_summary(Some(outcome.target.requested_choice)), - )); - if outcome.status == DpnsVoteTargetStatus::Unconfirmed - && ComponentStyles::add_secondary_button(ui, "Check again", dark_mode) - .clicked() - { - action = AppAction::BackendTask(BackendTask::ContestedResourceTask( - ContestedResourceTask::ReconcileDpnsVoteOperation( - operation.id, - self.app_context.network(), - ), - )); - } - }); - } - if ComponentStyles::add_secondary_button(ui, "View operation", dark_mode).clicked() { - open_operation = Some(operation.id); - } - ui.separator(); - } - if let Some(operation_id) = open_operation { - self.view = MasternodesView::Voting(Box::new(DpnsVotingCenter::for_operation( - &self.app_context, - operation_id, - ))); - } - action - } - /// The node the page currently operates on β€” the one whose detail view is /// open. The list and load views operate on no single node. fn selected_node_id(&self) -> Option { match &self.view { MasternodesView::Detail(detail) => Some(detail.node_id()), - MasternodesView::List - | MasternodesView::Load(_) - | MasternodesView::Voting(_) - | MasternodesView::Scheduled => None, + MasternodesView::List | MasternodesView::Load(_) => None, } } @@ -538,165 +384,10 @@ impl MasternodesScreen { self.reload(); AppAction::None } - DetailOutcome::OpenVotingCenter { voter_id, choices } => { - self.view = if choices.is_empty() { - MasternodesView::Voting(Box::new(DpnsVotingCenter::new( - &self.app_context, - Some(voter_id), - Vec::new(), - ))) - } else { - MasternodesView::Voting(Box::new(DpnsVotingCenter::for_quick_votes( - &self.app_context, - voter_id, - choices, - ))) - }; - AppAction::None - } DetailOutcome::Forward(action) => *action, } } - fn open_voting_center( - &mut self, - preselected_voter: Option, - preselected_contests: Vec, - ) { - self.view = MasternodesView::Voting(Box::new(DpnsVotingCenter::new( - &self.app_context, - preselected_voter, - preselected_contests, - ))); - } - - fn render_voting_center(&mut self, ui: &mut egui::Ui) -> AppAction { - let outcome = match &mut self.view { - MasternodesView::Voting(center) => center.show(ui), - _ => return AppAction::None, - }; - match outcome { - VotingCenterOutcome::None => AppAction::None, - VotingCenterOutcome::BackToNodes => { - self.view = MasternodesView::List; - AppAction::None - } - VotingCenterOutcome::Action(action) => *action, - } - } - - fn render_scheduled_votes(&mut self, ui: &mut egui::Ui) -> AppAction { - let dark_mode = ui.style().visuals.dark_mode; - let mut action = AppAction::None; - ui.heading("Scheduled votes"); - ui.label( - "Upcoming and unresolved targets use the same operation locks as immediate votes.", - ); - if !self.vote_operations.is_loaded() { - ui.label("Scheduled votes are unavailable. Refresh this page to try again."); - return action; - } - let scheduled_targets = - scheduled_journal_targets(self.vote_operations.operations().to_vec()); - if scheduled_targets.is_empty() { - ui.label("No scheduled votes."); - return action; - } - for scheduled in scheduled_targets { - let target = &scheduled.outcome.target; - let status = scheduled.outcome.status; - let voter = target - .voter_alias - .clone() - .unwrap_or_else(|| shorten_id(&target.key.voter_id.to_string(Encoding::Base58))); - let VoteTiming::Scheduled(scheduled_at) = target.timing else { - continue; - }; - ui.group(|ui| { - ui.label( - RichText::new(format!("{}.dash / {}", target.contested_name, voter)).strong(), - ); - ui.label(format!( - "Choice: {}", - vote_choice_summary(Some(target.requested_choice)) - )); - ui.label(format_scheduled_time(scheduled_at)); - ui.label(match status { - DpnsVoteTargetStatus::Unconfirmed => "Status: Checking result", - DpnsVoteTargetStatus::Queued - | DpnsVoteTargetStatus::Submitting - | DpnsVoteTargetStatus::Confirming => "Status: Submitting", - DpnsVoteTargetStatus::Scheduled => "Status: Scheduled", - DpnsVoteTargetStatus::Confirmed => "Status: Completed", - DpnsVoteTargetStatus::Rejected => "Status: Rejected", - DpnsVoteTargetStatus::FailedBeforeSubmission => { - "Status: Failed before submission" - } - DpnsVoteTargetStatus::Cancelled => "Status: Cancelled", - DpnsVoteTargetStatus::NotApplied => "Status: Not applied", - }); - let editable = status == DpnsVoteTargetStatus::Scheduled; - let disabled_reason = - "This scheduled vote cannot be changed after submission has started."; - if ui - .add_enabled( - editable, - ComponentStyles::secondary_button("Edit schedule", dark_mode), - ) - .disabled_tooltip(disabled_reason) - .clicked() - { - self.view = MasternodesView::Voting(Box::new( - DpnsVotingCenter::for_scheduled_edit(&self.app_context, &scheduled.outcome), - )); - } - if ui - .add_enabled( - editable, - ComponentStyles::secondary_button("Cancel scheduled vote", dark_mode), - ) - .disabled_tooltip(disabled_reason) - .clicked() - { - let message = scheduled_cancel_confirmation(&scheduled.outcome); - self.pending_schedule_cancellation = Some(( - scheduled.clone(), - ConfirmationDialog::new("Cancel scheduled vote", message) - .danger_mode(true) - .confirm_text(Some("Cancel scheduled vote")), - )); - } - if status == DpnsVoteTargetStatus::Unconfirmed - && ComponentStyles::add_secondary_button(ui, "Check again", dark_mode).clicked() - { - action = AppAction::BackendTask(BackendTask::ContestedResourceTask( - ContestedResourceTask::ReconcileDpnsVoteOperation( - scheduled.operation_id, - self.app_context.network(), - ), - )); - } - }); - } - if let Some((scheduled, dialog)) = self.pending_schedule_cancellation.as_mut() { - let result = dialog.show(ui).inner.dialog_response; - if let Some(result) = result { - let scheduled = scheduled.clone(); - self.pending_schedule_cancellation = None; - if result == ConfirmationStatus::Confirmed { - action = AppAction::BackendTask(BackendTask::ContestedResourceTask( - ContestedResourceTask::CancelScheduledDpnsVote { - operation_id: scheduled.operation_id, - key: scheduled.outcome.target.key, - contested_name: scheduled.outcome.target.contested_name, - }, - )); - } - } - } - action - } - /// Render the list view: toolbar (`+ Load`, `Refresh`) + empty state or grid. fn render_list_view(&mut self, ui: &mut egui::Ui, network_accent: egui::Color32) -> AppAction { let mut inner = AppAction::None; @@ -815,72 +506,6 @@ impl MasternodesScreen { } } -fn scheduled_journal_targets(operations: Vec) -> Vec { - let mut targets = operations - .into_iter() - .flat_map(|operation| { - operation.targets.into_iter().filter_map(move |outcome| { - matches!(outcome.target.timing, VoteTiming::Scheduled(_)).then_some( - ScheduledJournalTarget { - operation_id: operation.id, - outcome, - }, - ) - }) - }) - .collect::>(); - targets.sort_by_key(|scheduled| match scheduled.outcome.target.timing { - VoteTiming::Scheduled(timestamp) => timestamp, - VoteTiming::Now => u64::MAX, - }); - targets -} - -fn scheduled_cancel_confirmation(outcome: &DpnsVoteOutcome) -> String { - let target = &outcome.target; - let voter = target - .voter_alias - .clone() - .unwrap_or_else(|| shorten_id(&target.key.voter_id.to_string(Encoding::Base58))); - let time = match target.timing { - VoteTiming::Scheduled(timestamp) => format_scheduled_time(timestamp) - .strip_prefix("Scheduled time: ") - .unwrap_or("Unavailable") - .to_owned(), - VoteTiming::Now => "Immediately".to_owned(), - }; - format!( - "Cancel {voter}'s scheduled vote for {}.dash? Choice: {}. Scheduled time: {time}.", - target.contested_name, - vote_choice_summary(Some(target.requested_choice)), - ) -} - -fn vote_choice_summary(choice: Option) -> String { - match choice { - None => "Not voted".to_owned(), - Some(ResourceVoteChoice::Abstain) => "Abstain".to_owned(), - Some(ResourceVoteChoice::Lock) => "Lock".to_owned(), - Some(ResourceVoteChoice::TowardsIdentity(identity)) => { - format!( - "Candidate {}", - shorten_id(&identity.to_string(Encoding::Base58)) - ) - } - } -} - -fn format_scheduled_time(timestamp: u64) -> String { - match Utc.timestamp_millis_opt(timestamp as i64) { - LocalResult::Single(date_time) => format!( - "Scheduled time: {} UTC ({})", - date_time.format("%Y-%m-%d %H:%M"), - HumanTime::from(date_time) - ), - _ => "Scheduled time: Unavailable".to_owned(), - } -} - impl ScreenLike for MasternodesScreen { fn refresh(&mut self) { self.reload(); @@ -889,16 +514,6 @@ impl ScreenLike for MasternodesScreen { fn refresh_on_arrival(&mut self) { self.reload(); self.reconcile_pending_load(); - if let Some(route) = self.app_context.take_dpns_operator_route() { - match route { - DpnsOperatorRoute::Voting { choices } => { - self.view = MasternodesView::Voting(Box::new( - DpnsVotingCenter::for_bulk_choices(&self.app_context, choices), - )); - } - DpnsOperatorRoute::Scheduled => self.view = MasternodesView::Scheduled, - } - } } fn reset_to_root_view(&mut self) { @@ -921,8 +536,7 @@ impl ScreenLike for MasternodesScreen { fn on_leave(&mut self) { match &mut self.view { MasternodesView::Load(form) => form.clear_secrets(), - MasternodesView::Detail(detail) => detail.clear_secrets(), - MasternodesView::List | MasternodesView::Voting(_) | MasternodesView::Scheduled => {} + MasternodesView::List | MasternodesView::Detail(_) => {} } } @@ -943,29 +557,7 @@ impl ScreenLike for MasternodesScreen { } } - fn display_backend_task_result( - &mut self, - context: &crate::backend_task::BackendTaskContext, - result: crate::backend_task::BackendTaskSuccessResult, - ) { - if let MasternodesView::Voting(center) = &mut self.view { - center.display_backend_task_result(context, &result); - } - self.display_task_result(result); - } - - fn display_backend_task_error( - &mut self, - context: &crate::backend_task::BackendTaskContext, - _error: &crate::backend_task::error::TaskError, - ) { - if let MasternodesView::Voting(center) = &mut self.view { - center.display_backend_task_error(context); - } - } - fn display_task_error(&mut self, _error: &crate::backend_task::error::TaskError) -> bool { - self.refresh_vote_operations(); // A failing load reports `Failed` before its error reaches the UI, so // settling here re-enables the still-open form's submit button (the Load // view is untouched, so every entered field survives for correction). @@ -988,15 +580,12 @@ impl ScreenLike for MasternodesScreen { action |= island_central_panel(ui, |ui| { ui.set_min_width(ui.available_width()); - let mut action = self.render_voting_navigation(ui); + let mut action = AppAction::None; match self.view { MasternodesView::Load(_) => action |= self.render_load_view(ui), MasternodesView::Detail(_) => action |= self.render_detail_view(ui, network_accent), - MasternodesView::Voting(_) => action |= self.render_voting_center(ui), - MasternodesView::Scheduled => action |= self.render_scheduled_votes(ui), MasternodesView::List => action |= self.render_list_view(ui, network_accent), } - action |= self.render_voting_activity(ui); action }); @@ -1012,7 +601,6 @@ mod tests { use crate::backend_task::identity::IdentityInputToLoad; use crate::context::connection_status::ConnectionStatus; use crate::database::test_helpers::create_database_at_path; - use crate::model::dpns_voting::DpnsVoteTargetKey; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::qualified_identity::encrypted_key_storage::KeyStorage; use crate::utils::egui_mpsc::SenderAsync; @@ -1023,72 +611,6 @@ mod tests { use dash_sdk::platform::Identifier; use std::collections::BTreeMap; - fn scheduled_operation( - voter: u8, - poll: u8, - status: DpnsVoteTargetStatus, - alias: Option<&str>, - ) -> DpnsVoteOperation { - let mut operation = - DpnsVoteOperation::new(vec![crate::model::dpns_voting::DpnsVoteTarget { - key: DpnsVoteTargetKey { - network: Network::Testnet, - voter_id: Identifier::from([voter; 32]), - vote_poll_id: Identifier::from([poll; 32]), - }, - voter_alias: alias.map(str::to_owned), - contested_name: "dominguez".to_owned(), - requested_choice: ResourceVoteChoice::Lock, - current_choice: None, - timing: VoteTiming::Scheduled(1_700_000_000_000), - }]); - operation.targets[0].status = status; - operation - } - - #[test] - fn scheduled_view_items_keep_the_exact_journal_operation_id() { - let historical = - scheduled_operation(1, 2, DpnsVoteTargetStatus::Confirmed, Some("Old Eve")); - let unresolved = scheduled_operation(1, 2, DpnsVoteTargetStatus::Unconfirmed, Some("Eve")); - - let items = scheduled_journal_targets(vec![historical.clone(), unresolved.clone()]); - - assert_eq!(items.len(), 2); - assert_eq!(items[0].operation_id, historical.id); - assert_eq!(items[1].operation_id, unresolved.id); - assert_eq!(items[1].outcome.status, DpnsVoteTargetStatus::Unconfirmed); - } - - #[test] - fn scheduled_cancel_confirmation_identifies_the_complete_target() { - let operation = scheduled_operation(1, 2, DpnsVoteTargetStatus::Scheduled, Some("Eve")); - - let message = scheduled_cancel_confirmation(&operation.targets[0]); - - for phrase in ["Eve", "dominguez.dash", "Lock", "UTC"] { - assert!( - message.contains(phrase), - "missing `{phrase}` in `{message}`" - ); - } - } - - #[test] - fn scheduled_cancel_confirmation_uses_a_short_id_without_an_alias() { - let operation = scheduled_operation(1, 2, DpnsVoteTargetStatus::Scheduled, None); - let full_id = operation.targets[0] - .target - .key - .voter_id - .to_string(Encoding::Base58); - - let message = scheduled_cancel_confirmation(&operation.targets[0]); - - assert!(!message.contains(&full_id)); - assert!(message.contains('…')); - } - /// Build an offline, wallet-backend-wired `AppContext` (no network I/O). async fn offline_ctx() -> (Arc, tempfile::TempDir) { let temp_dir = tempfile::tempdir().expect("tempdir"); @@ -1717,36 +1239,6 @@ mod tests { ctx.wallet_backend().expect("backend").shutdown().await; } - - /// SEC β€” the detail view's in-place `Add voting key` prompt holds a plaintext - /// WIF too, and lives in the very same root screen. A prompt left filled but - /// unsubmitted must not survive the user leaving the tab. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn leaving_the_tab_discards_an_unsubmitted_voting_key() { - let (ctx, _tmp) = offline_ctx().await; - seed_masternode(&ctx, 0xc5, None); - let mut screen = MasternodesScreen::new(&ctx); - screen.open_detail(Identifier::from([0xc5; 32])); - - let MasternodesView::Detail(detail) = &mut screen.view else { - panic!("the detail view must be open"); - }; - detail.set_voter_key_prompt_for_test("voter-wif"); - - screen.on_leave(); - - let MasternodesView::Detail(detail) = &screen.view else { - panic!("leaving must not discard the detail view"); - }; - assert!( - !detail.has_voter_key_prompt_for_test(), - "an unsubmitted voting key must not outlive the tab" - ); - - ctx.wallet_backend().expect("backend").shutdown().await; - } - - /// A node with no detail view open and no nodes at all both resolve to "no /// selection" β€” the pill falls back to its placeholder rather than naming a /// node the page is not showing. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/src/ui/masternodes/mod.rs b/src/ui/masternodes/mod.rs index 27d3ed900..89dbc3b52 100644 --- a/src/ui/masternodes/mod.rs +++ b/src/ui/masternodes/mod.rs @@ -10,7 +10,6 @@ pub mod detail_screen; pub mod list_screen; pub mod load_form; pub mod testnet_fixture; -pub mod voting_center; pub use list_screen::MasternodesScreen; diff --git a/src/ui/masternodes/voting_center.rs b/src/ui/masternodes/voting_center.rs deleted file mode 100644 index c2663557d..000000000 --- a/src/ui/masternodes/voting_center.rs +++ /dev/null @@ -1,1473 +0,0 @@ -//! Full-page Nodes β†’ Votes β†’ Review DPNS voting composer. - -use std::collections::BTreeMap; -use std::sync::Arc; - -use chrono::{Duration, LocalResult, TimeZone, Utc}; -use chrono_humanize::HumanTime; -use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; -use dash_sdk::platform::Identifier; -use eframe::egui::{self, ComboBox, RichText}; - -use crate::app::AppAction; -use crate::backend_task::contested_names::ContestedResourceTask; -use crate::backend_task::{BackendTask, BackendTaskContext, BackendTaskSuccessResult}; -use crate::context::AppContext; -use crate::model::contested_name::ContestedName; -use crate::model::dpns_voting::{ - DpnsCurrentVoteState, DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteOutcome, DpnsVoteTarget, - DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, -}; -use crate::model::qualified_identity::PrivateKeyTarget; -use crate::model::qualified_identity::QualifiedIdentity; -use crate::ui::state::dpns_vote_operations::DpnsVoteOperationSnapshot; -use crate::ui::state::dpns_vote_workspace::{ - ComposerKeyAction, DpnsVoteComposerStep, DpnsVoteWorkspace, DraftVoteTiming, -}; -use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; - -pub enum VotingCenterOutcome { - None, - BackToNodes, - Action(Box), -} - -pub struct DpnsVotingCenter { - app_context: Arc, - voters: Vec, - contests: Vec, - vote_operations: DpnsVoteOperationSnapshot, - workspace: DpnsVoteWorkspace, - submitted_operation: Option, - vote_state_refresh_dispatched: bool, - editing_scheduled_key: Option, - editing_scheduled_original: Option, - focus_step_heading: bool, -} - -struct ReviewDraft { - operation: DpnsVoteOperation, - exclusions: Vec, -} - -struct ReviewExclusion { - voter: String, - contest: String, - requested_choice: ResourceVoteChoice, - reason: &'static str, - no_op: bool, -} - -impl DpnsVotingCenter { - pub(crate) fn refresh_vote_operations(&mut self) { - if let Err(error) = self.vote_operations.refresh(&self.app_context) { - tracing::warn!(?error, "Could not refresh voting-center operation state"); - } - } - - pub(crate) fn display_backend_task_result( - &mut self, - context: &BackendTaskContext, - result: &BackendTaskSuccessResult, - ) { - self.refresh_vote_operations(); - if matches!(result, BackendTaskSuccessResult::RefreshedDpnsContests) { - self.vote_state_refresh_dispatched = false; - match self.app_context.ongoing_contested_names() { - Ok(contests) => self.contests = contests, - Err(error) => { - tracing::warn!(?error, "Could not reload refreshed DPNS contests"); - } - } - } - self.submitted_operation = - updated_submitted_operation(self.submitted_operation, context, result); - } - - pub(crate) fn display_backend_task_error(&mut self, context: &BackendTaskContext) { - self.refresh_vote_operations(); - let Some(operation_id) = self.submitted_operation else { - return; - }; - if should_return_to_review_after_error( - operation_id, - self.app_context.network(), - context, - self.vote_operations.operation(operation_id).is_some(), - ) { - self.submitted_operation = None; - self.workspace.step = DpnsVoteComposerStep::Review; - self.focus_step_heading = true; - } - } - - pub fn new( - app_context: &Arc, - preselected_voter: Option, - preselected_contests: Vec, - ) -> Self { - let voters = app_context - .load_local_voting_identities() - .unwrap_or_default(); - let mut workspace = DpnsVoteWorkspace::new(voters.iter().map(|voter| voter.identity.id())); - if let Some(voter_id) = preselected_voter { - workspace.prefilter_node(voter_id); - } - let contests = app_context.ongoing_contested_names().unwrap_or_default(); - let vote_operations = - DpnsVoteOperationSnapshot::load(app_context).unwrap_or_else(|error| { - tracing::warn!( - ?error, - "Could not cache DPNS vote operations for the voting center" - ); - DpnsVoteOperationSnapshot::default() - }); - if !preselected_contests.is_empty() { - for name in preselected_contests { - if contests - .iter() - .any(|contest| contest.normalized_contested_name == name) - { - workspace - .contest_choices - .entry(name) - .or_insert(ResourceVoteChoice::Abstain); - } - } - } - Self { - app_context: Arc::clone(app_context), - voters, - contests, - vote_operations, - workspace, - submitted_operation: None, - vote_state_refresh_dispatched: false, - editing_scheduled_key: None, - editing_scheduled_original: None, - focus_step_heading: true, - } - } - - pub fn for_scheduled_edit(app_context: &Arc, outcome: &DpnsVoteOutcome) -> Self { - let vote = &outcome.target; - let mut center = Self::new( - app_context, - Some(vote.key.voter_id), - vec![vote.contested_name.clone()], - ); - center - .workspace - .contest_choices - .insert(vote.contested_name.clone(), vote.requested_choice); - let VoteTiming::Scheduled(timestamp) = vote.timing else { - return center; - }; - center - .workspace - .node_timing - .insert(vote.key.voter_id, scheduled_offset_from_now(timestamp)); - center.editing_scheduled_key = Some(vote.key.clone()); - center.editing_scheduled_original = Some(vote.clone()); - center - } - - pub fn for_quick_votes( - app_context: &Arc, - voter_id: Identifier, - choices: BTreeMap, - ) -> Self { - let mut center = Self::new( - app_context, - Some(voter_id), - choices.keys().cloned().collect(), - ); - center.workspace.contest_choices = choices; - center.workspace.step = DpnsVoteComposerStep::Review; - center - } - - pub fn for_bulk_choices( - app_context: &Arc, - choices: BTreeMap, - ) -> Self { - let mut center = Self::new(app_context, None, choices.keys().cloned().collect()); - center.workspace.contest_choices = choices; - center - } - - pub fn for_operation(app_context: &Arc, operation_id: DpnsVoteOperationId) -> Self { - let mut center = Self::new(app_context, None, Vec::new()); - center.submitted_operation = Some(operation_id); - center - } - - pub fn show(&mut self, ui: &mut egui::Ui) -> VotingCenterOutcome { - if self.submitted_operation.is_some() { - return self.render_operation(ui); - } - let (enter, escape) = ui.input(|input| { - ( - input.key_pressed(egui::Key::Enter), - input.key_pressed(egui::Key::Escape), - ) - }); - let can_continue = match self.workspace.step { - DpnsVoteComposerStep::Nodes => self.workspace.selected_node_count() > 0, - DpnsVoteComposerStep::Votes => !self.workspace.contest_choices.is_empty(), - DpnsVoteComposerStep::Review => !self.build_review().operation.targets.is_empty(), - }; - match self.workspace.keyboard_action(enter, escape, can_continue) { - ComposerKeyAction::CloseDraft => return VotingCenterOutcome::BackToNodes, - ComposerKeyAction::Advance => { - self.workspace.step = match self.workspace.step { - DpnsVoteComposerStep::Nodes => DpnsVoteComposerStep::Votes, - DpnsVoteComposerStep::Votes | DpnsVoteComposerStep::Review => { - DpnsVoteComposerStep::Review - } - }; - self.focus_step_heading = true; - } - ComposerKeyAction::Submit => { - return self.submit_operation(self.build_review().operation); - } - ComposerKeyAction::None => {} - } - let needs_refresh = self - .selected_current_states() - .iter() - .any(|(_, state)| matches!(state, DpnsCurrentVoteState::Checking)); - if needs_refresh && !self.vote_state_refresh_dispatched { - self.vote_state_refresh_dispatched = true; - return VotingCenterOutcome::Action(Box::new(AppAction::BackendTask( - BackendTask::ContestedResourceTask(ContestedResourceTask::QueryDPNSContests), - ))); - } - - match self.workspace.step { - DpnsVoteComposerStep::Nodes => self.render_nodes(ui), - DpnsVoteComposerStep::Votes => self.render_votes(ui), - DpnsVoteComposerStep::Review => self.render_review(ui), - } - } - - fn render_nodes(&mut self, ui: &mut egui::Ui) -> VotingCenterOutcome { - let dark_mode = ui.style().visuals.dark_mode; - self.step_heading(ui, "Step 1 of 3: Nodes and timing"); - ui.label("Choose which nodes will vote and when each node should submit."); - if self.voters.is_empty() { - ui.separator(); - let go_to_nodes = ui - .vertical_centered(|ui| { - ui.heading("No voting nodes are available"); - ui.label("Load a masternode on the Nodes tab before creating a vote."); - ui.add_space(8.0); - ComponentStyles::add_primary_button_enabled(ui, true, "Go to Nodes").clicked() - }) - .inner; - return if go_to_nodes { - VotingCenterOutcome::BackToNodes - } else { - VotingCenterOutcome::None - }; - } - ui.horizontal_wrapped(|ui| { - ui.label("Set all:"); - timing_combo( - ui, - "voting_center_set_all", - &mut self.workspace.set_all_timing, - ); - if ComponentStyles::add_secondary_button(ui, "Apply", dark_mode).clicked() { - self.workspace.apply_timing_to_selected(); - } - }); - ui.separator(); - for voter in &self.voters { - let voter_id = voter.identity.id(); - let alias = voter - .alias - .clone() - .unwrap_or_else(|| voter_id.to_string(Encoding::Base58)); - let has_voting_key = has_loaded_voting_key(voter); - ui.horizontal_wrapped(|ui| { - let mut selected = self.workspace.is_node_selected(&voter_id); - let checkbox = ui.add_enabled( - has_voting_key, - egui::Checkbox::new(&mut selected, RichText::new(alias).strong()), - ); - if checkbox.changed() { - self.workspace.set_node_selected(voter_id, selected); - } - checkbox - .disabled_tooltip("Load this node's voting private key before selecting it."); - let timing = self - .workspace - .node_timing - .entry(voter_id) - .or_insert(DraftVoteTiming::Now); - ui.add_enabled_ui(selected && has_voting_key, |ui| { - timing_combo(ui, format!("voting_center_node_{voter_id}"), timing); - render_schedule_offset(ui, timing); - }); - if !has_voting_key { - ui.label( - RichText::new("Voting key missing") - .color(DashColors::warning_color(dark_mode)), - ); - } - }); - } - ui.separator(); - let enabled = self.workspace.selected_node_count() > 0; - if ComponentStyles::add_primary_button_enabled(ui, enabled, "Next: Choose votes") - .disabled_tooltip("Choose at least one node before continuing.") - .clicked() - { - self.workspace.step = DpnsVoteComposerStep::Votes; - self.focus_step_heading = true; - } - VotingCenterOutcome::None - } - - fn render_votes(&mut self, ui: &mut egui::Ui) -> VotingCenterOutcome { - let dark_mode = ui.style().visuals.dark_mode; - self.step_heading(ui, "Step 2 of 3: Votes"); - ui.label("Choose one requested vote for each contested name."); - let mut outcome = VotingCenterOutcome::None; - if self.contests.is_empty() { - ui.separator(); - ui.label("No active contests are available. Refresh contests to check again."); - if ComponentStyles::add_secondary_button(ui, "Refresh contests", dark_mode).clicked() { - self.vote_state_refresh_dispatched = true; - outcome = VotingCenterOutcome::Action(Box::new(AppAction::BackendTask( - BackendTask::ContestedResourceTask(ContestedResourceTask::QueryDPNSContests), - ))); - } - } - for contest in &self.contests { - let name = &contest.normalized_contested_name; - ui.separator(); - ui.label(RichText::new(format!("{name}.dash")).strong()); - let states = self.current_states_for_contest(contest); - ui.label( - RichText::new(current_summary(&states)) - .color(DashColors::text_secondary(dark_mode)), - ); - let vote_poll_id = self.app_context.dpns_vote_poll_id(name).ok(); - let controls_enabled = states.iter().any(|(voter_id, state, locked)| { - let lock_is_this_edit = vote_poll_id.is_some_and(|vote_poll_id| { - self.editing_scheduled_key.as_ref() - == Some(&DpnsVoteTargetKey { - network: self.app_context.network(), - voter_id: *voter_id, - vote_poll_id, - }) - }); - (!*locked || lock_is_this_edit) - && matches!(state, DpnsCurrentVoteState::Available(_)) - }); - let selected = self.workspace.contest_choices.get(name).copied(); - ui.add_enabled_ui(controls_enabled, |ui| { - ui.horizontal_wrapped(|ui| { - vote_choice( - ui, - selected, - ResourceVoteChoice::Abstain, - "Abstain", - &mut self.workspace, - name, - ); - vote_choice( - ui, - selected, - ResourceVoteChoice::Lock, - "Lock", - &mut self.workspace, - name, - ); - for candidate in contest.contestants.as_deref().unwrap_or_default() { - vote_choice( - ui, - selected, - ResourceVoteChoice::TowardsIdentity(candidate.id), - &format!("Vote for {}", candidate.name), - &mut self.workspace, - name, - ); - } - }); - }); - for (voter_id, state, locked) in &states { - let voter = self.voter_label(*voter_id); - let status = match (state, locked) { - (_, true) => "This target already has a voting operation in progress.", - (DpnsCurrentVoteState::Checking, false) => { - "DET is checking this node's current vote." - } - (DpnsCurrentVoteState::Unavailable, false) => { - "This node's current vote is unavailable." - } - _ => continue, - }; - ui.label( - RichText::new(format!("{voter}: {status}")) - .color(DashColors::text_secondary(dark_mode)), - ); - } - let can_refresh = states.iter().any(|(_, state, locked)| { - !locked - && matches!( - state, - DpnsCurrentVoteState::Checking | DpnsCurrentVoteState::Unavailable - ) - }); - if can_refresh - && ComponentStyles::add_secondary_button(ui, "Refresh vote state", dark_mode) - .clicked() - { - self.vote_state_refresh_dispatched = true; - outcome = VotingCenterOutcome::Action(Box::new(AppAction::BackendTask( - BackendTask::ContestedResourceTask(ContestedResourceTask::QueryDPNSContests), - ))); - } - } - ui.separator(); - ui.horizontal(|ui| { - if ComponentStyles::add_secondary_button(ui, "Back", dark_mode).clicked() { - self.workspace.step = DpnsVoteComposerStep::Nodes; - self.focus_step_heading = true; - } - let target_count = - self.workspace.selected_node_count() * self.workspace.contest_choices.len(); - let enabled = target_count > 0; - if ComponentStyles::add_primary_button_enabled( - ui, - enabled, - format!("Review {target_count} targets"), - ) - .disabled_tooltip("Choose at least one contested name before continuing.") - .clicked() - { - self.workspace.step = DpnsVoteComposerStep::Review; - self.focus_step_heading = true; - } - if ComponentStyles::add_secondary_button(ui, "Close Voting Center", dark_mode).clicked() - { - outcome = VotingCenterOutcome::BackToNodes; - } - }); - outcome - } - - fn render_review(&mut self, ui: &mut egui::Ui) -> VotingCenterOutcome { - let dark_mode = ui.style().visuals.dark_mode; - self.step_heading(ui, "Step 3 of 3: Review"); - let review = self.build_review(); - for outcome in &review.operation.targets { - let voter = outcome - .target - .voter_alias - .clone() - .unwrap_or_else(|| shorten_identifier(outcome.target.key.voter_id)); - ui.group(|ui| { - ui.label(RichText::new(format!( - "{voter} / {}.dash", - outcome.target.contested_name - )) - .strong()); - ui.label(format!( - "{} β†’ {}", - self.choice_label( - &outcome.target.contested_name, - outcome.target.current_choice - ), - self.choice_label( - &outcome.target.contested_name, - Some(outcome.target.requested_choice) - ) - )); - if self.editing_scheduled_key.as_ref() == Some(&outcome.target.key) { - if let Some(original) = &self.editing_scheduled_original { - ui.label(scheduled_replacement_summary( - original, - &outcome.target, - |name, choice| self.choice_label(name, choice), - )); - } - } else { - ui.label(format_timing(outcome.target.timing)); - } - if outcome.target.current_choice.is_some() { - ui.label( - RichText::new( - "This changes an existing vote. Platform permits only a limited number of vote changes.", - ) - .color(DashColors::warning_color(dark_mode)), - ); - } - }); - } - if review.operation.no_op_count > 0 { - ui.label(format!( - "{} targets already have the requested vote and will not be submitted.", - review.operation.no_op_count - )); - } - for exclusion in &review.exclusions { - ui.label(format!( - "{} / {}.dash β†’ {} was excluded. {}", - exclusion.voter, - exclusion.contest, - self.choice_label(&exclusion.contest, Some(exclusion.requested_choice)), - exclusion.reason - )); - } - ui.label(format!( - "{} targets total. Each submitted vote uses Platform credits.", - review.operation.targets.len() - )); - ui.horizontal(|ui| { - if ComponentStyles::add_secondary_button(ui, "Back", dark_mode).clicked() { - self.workspace.step = DpnsVoteComposerStep::Votes; - self.focus_step_heading = true; - } - }); - if review.operation.targets.is_empty() { - if review.operation.no_op_count > 0 - && review.exclusions.iter().all(|exclusion| exclusion.no_op) - { - ui.label( - "Every selected node already has the requested vote. Nothing will be submitted.", - ); - } else { - ui.label( - "No targets are ready to submit. Refresh unavailable vote state or wait for in-progress targets.", - ); - } - return VotingCenterOutcome::None; - } - let target_count = review.operation.targets.len(); - if ComponentStyles::add_primary_button(ui, format!("Submit {target_count} targets")) - .clicked() - { - return self.submit_operation(review.operation); - } - VotingCenterOutcome::None - } - - fn render_operation(&mut self, ui: &mut egui::Ui) -> VotingCenterOutcome { - let dark_mode = ui.style().visuals.dark_mode; - let Some(operation_id) = self.submitted_operation else { - return VotingCenterOutcome::None; - }; - ui.heading("Voting operation"); - match self.vote_operations.operation(operation_id).cloned() { - Some(operation) => { - for outcome in &operation.targets { - ui.group(|ui| { - let voter = outcome - .target - .voter_alias - .clone() - .unwrap_or_else(|| shorten_identifier(outcome.target.key.voter_id)); - ui.label( - RichText::new(format!( - "{voter} / {}.dash", - outcome.target.contested_name - )) - .strong(), - ); - ui.label(format!( - "{} β†’ {}", - self.choice_label( - &outcome.target.contested_name, - outcome.target.current_choice - ), - self.choice_label( - &outcome.target.contested_name, - Some(outcome.target.requested_choice) - ) - )); - ui.label(format_timing(outcome.target.timing)); - ui.label(format!("Status: {}", status_label(outcome.status))); - ui.label(status_explanation(outcome.status)); - if let Some(hash) = outcome.transition_hash { - let hash = hex::encode(hash); - if ui.small_button("Copy transition hash").clicked() { - ui.ctx().copy_text(hash); - } - } - }); - } - if operation - .targets - .iter() - .any(|outcome| outcome.status == DpnsVoteTargetStatus::Unconfirmed) - && ComponentStyles::add_secondary_button(ui, "Check again", dark_mode).clicked() - { - return VotingCenterOutcome::Action(Box::new(AppAction::BackendTask( - BackendTask::ContestedResourceTask( - ContestedResourceTask::ReconcileDpnsVoteOperation( - operation_id, - self.app_context.network(), - ), - ), - ))); - } - if operation.targets.iter().any(|outcome| { - matches!( - outcome.status, - DpnsVoteTargetStatus::Rejected - | DpnsVoteTargetStatus::FailedBeforeSubmission - | DpnsVoteTargetStatus::Cancelled - | DpnsVoteTargetStatus::NotApplied - ) - }) && ComponentStyles::add_secondary_button(ui, "Review again", dark_mode) - .clicked() - { - self.workspace.contest_choices = operation - .targets - .iter() - .filter(|outcome| { - matches!( - outcome.status, - DpnsVoteTargetStatus::Rejected - | DpnsVoteTargetStatus::FailedBeforeSubmission - | DpnsVoteTargetStatus::Cancelled - | DpnsVoteTargetStatus::NotApplied - ) - }) - .map(|outcome| { - ( - outcome.target.contested_name.clone(), - outcome.target.requested_choice, - ) - }) - .collect(); - for outcome in &operation.targets { - if matches!( - outcome.status, - DpnsVoteTargetStatus::Rejected - | DpnsVoteTargetStatus::FailedBeforeSubmission - | DpnsVoteTargetStatus::Cancelled - | DpnsVoteTargetStatus::NotApplied - ) { - self.workspace - .set_node_selected(outcome.target.key.voter_id, true); - self.workspace.node_timing.insert( - outcome.target.key.voter_id, - match outcome.target.timing { - VoteTiming::Now => DraftVoteTiming::Now, - VoteTiming::Scheduled(timestamp) => { - scheduled_offset_from_now(timestamp) - } - }, - ); - } - } - self.workspace.step = DpnsVoteComposerStep::Review; - self.submitted_operation = None; - self.focus_step_heading = true; - } - if ComponentStyles::add_secondary_button(ui, "Continue in background", dark_mode) - .clicked() - { - return VotingCenterOutcome::BackToNodes; - } - } - None if self.vote_operations.is_loaded() => { - ui.horizontal(|ui| { - ui.spinner(); - ui.label("Queuing votes…"); - }); - } - None => { - ui.label("This operation could not be loaded. Refresh and try again."); - } - } - VotingCenterOutcome::None - } - - fn selected_voters(&self) -> Vec { - self.voters - .iter() - .filter(|voter| self.workspace.is_node_selected(&voter.identity.id())) - .filter(|voter| has_loaded_voting_key(voter)) - .cloned() - .collect() - } - - fn submit_operation(&mut self, operation: DpnsVoteOperation) -> VotingCenterOutcome { - self.submitted_operation = Some(operation.id); - VotingCenterOutcome::Action(Box::new(AppAction::BackendTask( - BackendTask::ContestedResourceTask(ContestedResourceTask::SubmitDpnsVoteOperation( - operation, - self.selected_voters(), - self.editing_scheduled_key.clone(), - self.app_context.network(), - )), - ))) - } - - fn step_heading(&mut self, ui: &mut egui::Ui, text: &str) { - let response = ui.heading(text); - if std::mem::take(&mut self.focus_step_heading) { - response.request_focus(); - } - } - - fn selected_current_states(&self) -> Vec<(Identifier, DpnsCurrentVoteState)> { - self.contests - .iter() - .flat_map(|contest| { - let poll_id = self - .app_context - .dpns_vote_poll_id(&contest.normalized_contested_name) - .ok(); - self.selected_voters().into_iter().filter_map(move |voter| { - let poll_id = poll_id?; - let voter_id = voter.identity.id(); - Some(( - voter_id, - self.app_context - .dpns_current_vote_state(voter_id, poll_id) - .unwrap_or(DpnsCurrentVoteState::Unavailable), - )) - }) - }) - .collect() - } - - fn current_states_for_contest( - &self, - contest: &ContestedName, - ) -> Vec<(Identifier, DpnsCurrentVoteState, bool)> { - let Ok(vote_poll_id) = self - .app_context - .dpns_vote_poll_id(&contest.normalized_contested_name) - else { - return self - .selected_voters() - .into_iter() - .map(|voter| { - ( - voter.identity.id(), - DpnsCurrentVoteState::Unavailable, - false, - ) - }) - .collect(); - }; - self.selected_voters() - .into_iter() - .map(|voter| { - let voter_id = voter.identity.id(); - let key = DpnsVoteTargetKey { - network: self.app_context.network(), - voter_id, - vote_poll_id, - }; - ( - voter_id, - self.app_context - .dpns_current_vote_state(voter_id, vote_poll_id) - .unwrap_or(DpnsCurrentVoteState::Unavailable), - self.vote_operations.target_status(&key).is_some(), - ) - }) - .collect() - } - - fn build_review(&self) -> ReviewDraft { - let mut targets = Vec::new(); - let mut exclusions = Vec::new(); - for voter in self.selected_voters() { - let voter_id = voter.identity.id(); - let draft_timing = self.workspace.node_timing[&voter_id]; - let timing = match draft_timing { - DraftVoteTiming::Now => VoteTiming::Now, - DraftVoteTiming::Scheduled { - days, - hours, - minutes, - } => VoteTiming::Scheduled( - (Utc::now() - + Duration::days(i64::from(days)) - + Duration::hours(i64::from(hours)) - + Duration::minutes(i64::from(minutes))) - .timestamp_millis() as u64, - ), - }; - for (name, requested_choice) in &self.workspace.contest_choices { - let vote_poll_id = match self.app_context.dpns_vote_poll_id(name) { - Ok(vote_poll_id) => vote_poll_id, - Err(_) => { - exclusions.push(ReviewExclusion { - voter: self.voter_label(voter_id), - contest: name.clone(), - requested_choice: *requested_choice, - reason: "Refresh this contest before submitting a vote.", - no_op: false, - }); - continue; - } - }; - let key = DpnsVoteTargetKey { - network: self.app_context.network(), - voter_id, - vote_poll_id, - }; - let existing_status = if self.vote_operations.is_loaded() { - self.vote_operations.target_status(&key) - } else { - exclusions.push(ReviewExclusion { - voter: self.voter_label(voter_id), - contest: name.clone(), - requested_choice: *requested_choice, - reason: "DET could not check whether this target is already in use.", - no_op: false, - }); - continue; - }; - let replacing_schedule = is_explicit_schedule_replacement( - self.editing_scheduled_key.as_ref(), - &key, - existing_status, - timing, - ); - if existing_status.is_some() && !replacing_schedule { - exclusions.push(ReviewExclusion { - voter: self.voter_label(voter_id), - contest: name.clone(), - requested_choice: *requested_choice, - reason: "Another voting operation is still using this target.", - no_op: false, - }); - continue; - } - let current_choice = match self - .app_context - .dpns_current_vote_state(voter_id, vote_poll_id) - { - Ok(DpnsCurrentVoteState::Available(current_choice)) => current_choice, - Ok(DpnsCurrentVoteState::Checking) => { - exclusions.push(ReviewExclusion { - voter: self.voter_label(voter_id), - contest: name.clone(), - requested_choice: *requested_choice, - reason: "DET is still checking this node's current vote.", - no_op: false, - }); - continue; - } - Ok(DpnsCurrentVoteState::Unavailable) | Err(_) => { - exclusions.push(ReviewExclusion { - voter: self.voter_label(voter_id), - contest: name.clone(), - requested_choice: *requested_choice, - reason: "Refresh this node's vote state before submitting it.", - no_op: false, - }); - continue; - } - }; - if current_choice == Some(*requested_choice) { - exclusions.push(ReviewExclusion { - voter: self.voter_label(voter_id), - contest: name.clone(), - requested_choice: *requested_choice, - reason: "This node already has the requested vote, so nothing will be submitted.", - no_op: true, - }); - } - targets.push(DpnsVoteTarget { - key, - voter_alias: voter.alias.clone(), - contested_name: name.clone(), - requested_choice: *requested_choice, - current_choice, - timing, - }); - } - } - ReviewDraft { - operation: DpnsVoteOperation::new(targets), - exclusions, - } - } - - fn voter_label(&self, voter_id: Identifier) -> String { - self.voters - .iter() - .find(|voter| voter.identity.id() == voter_id) - .and_then(|voter| voter.alias.clone()) - .unwrap_or_else(|| shorten_identifier(voter_id)) - } - - fn choice_label(&self, contest_name: &str, choice: Option) -> String { - let contestants = self - .contests - .iter() - .find(|contest| contest.normalized_contested_name == contest_name) - .and_then(|contest| contest.contestants.as_deref()) - .unwrap_or_default(); - choice_label(choice, contestants) - } -} - -fn timing_combo(ui: &mut egui::Ui, id: impl Into, timing: &mut DraftVoteTiming) { - ComboBox::from_id_salt(id.into()) - .selected_text(match timing { - DraftVoteTiming::Now => "Cast now", - DraftVoteTiming::Scheduled { .. } => "Schedule", - }) - .show_ui(ui, |ui| { - ui.selectable_value(timing, DraftVoteTiming::Now, "Cast now"); - if ui - .selectable_label( - matches!(timing, DraftVoteTiming::Scheduled { .. }), - "Schedule", - ) - .clicked() - { - *timing = DraftVoteTiming::Scheduled { - days: 0, - hours: 0, - minutes: 0, - }; - } - }); -} - -fn render_schedule_offset(ui: &mut egui::Ui, timing: &mut DraftVoteTiming) { - if let DraftVoteTiming::Scheduled { - days, - hours, - minutes, - } = timing - { - ui.add(egui::DragValue::new(days).prefix("Days: ").range(0..=14)); - ui.add(egui::DragValue::new(hours).prefix("Hours: ").range(0..=23)); - ui.add( - egui::DragValue::new(minutes) - .prefix("Minutes: ") - .range(0..=59), - ); - } -} - -fn vote_choice( - ui: &mut egui::Ui, - selected: Option, - choice: ResourceVoteChoice, - label: &str, - workspace: &mut DpnsVoteWorkspace, - name: &str, -) { - if ui - .selectable_label(selected == Some(choice), label) - .clicked() - { - workspace.contest_choices.insert(name.to_owned(), choice); - } -} - -fn current_summary(states: &[(Identifier, DpnsCurrentVoteState, bool)]) -> String { - if states.is_empty() { - return "Current vote state is unavailable for selected nodes".to_owned(); - } - let checking = states - .iter() - .filter(|(_, state, locked)| !locked && *state == DpnsCurrentVoteState::Checking) - .count(); - let unavailable = states - .iter() - .filter(|(_, state, locked)| !locked && *state == DpnsCurrentVoteState::Unavailable) - .count(); - let busy = states.iter().filter(|(_, _, locked)| *locked).count(); - let available = states - .iter() - .filter(|(_, state, locked)| !locked && matches!(state, DpnsCurrentVoteState::Available(_))) - .count(); - let not_voted = states - .iter() - .filter(|(_, state, locked)| !locked && *state == DpnsCurrentVoteState::Available(None)) - .count(); - if available == states.len() && not_voted == states.len() { - return "Current across selected nodes: Not voted".to_owned(); - } - if available == states.len() && not_voted == 0 { - return "Current across selected nodes: All already voted".to_owned(); - } - let mut parts = Vec::new(); - if not_voted > 0 { - parts.push(format!("{not_voted} not voted")); - } - let already_voted = available.saturating_sub(not_voted); - if already_voted > 0 { - parts.push(format!("{already_voted} already voted")); - } - if checking > 0 { - parts.push(format!("{checking} checking")); - } - if unavailable > 0 { - parts.push(format!("{unavailable} unavailable")); - } - if busy > 0 { - parts.push(format!("{busy} in progress")); - } - if parts.is_empty() { - "Current across selected nodes: No usable targets".to_owned() - } else { - format!("Current across selected nodes: {}", parts.join(", ")) - } -} - -fn choice_label( - choice: Option, - contestants: &[crate::model::contested_name::Contestant], -) -> String { - match choice { - None => "Not voted".to_owned(), - Some(ResourceVoteChoice::Abstain) => "Abstain".to_owned(), - Some(ResourceVoteChoice::Lock) => "Lock".to_owned(), - Some(ResourceVoteChoice::TowardsIdentity(identity)) => { - let short_id = shorten_identifier(identity); - contestants - .iter() - .find(|contestant| contestant.id == identity) - .map(|contestant| format!("{} ({short_id})", contestant.name)) - .unwrap_or_else(|| format!("Candidate {short_id}")) - } - } -} - -fn shorten_identifier(identifier: Identifier) -> String { - let encoded = identifier.to_string(Encoding::Base58); - if encoded.chars().count() <= 12 { - return encoded; - } - let first = encoded.chars().take(6).collect::(); - let last = encoded - .chars() - .rev() - .take(4) - .collect::() - .chars() - .rev() - .collect::(); - format!("{first}…{last}") -} - -fn format_timing(timing: VoteTiming) -> String { - match timing { - VoteTiming::Now => "When: Cast now".to_owned(), - VoteTiming::Scheduled(timestamp) => match Utc.timestamp_millis_opt(timestamp as i64) { - LocalResult::Single(date_time) => format!( - "When: {} UTC ({})", - date_time.format("%Y-%m-%d %H:%M"), - HumanTime::from(date_time) - ), - _ => "When: The scheduled time is unavailable.".to_owned(), - }, - } -} - -fn scheduled_replacement_summary( - original: &DpnsVoteTarget, - replacement: &DpnsVoteTarget, - choice_label: impl Fn(&str, Option) -> String, -) -> String { - format!( - "Replacing scheduled vote: {} at {} β†’ {} at {}.", - choice_label(&original.contested_name, Some(original.requested_choice)), - scheduled_time_label(original.timing), - choice_label( - &replacement.contested_name, - Some(replacement.requested_choice) - ), - scheduled_time_label(replacement.timing), - ) -} - -fn scheduled_time_label(timing: VoteTiming) -> String { - match timing { - VoteTiming::Now => "now".to_owned(), - VoteTiming::Scheduled(timestamp) => match Utc.timestamp_millis_opt(timestamp as i64) { - LocalResult::Single(date_time) => { - format!("{} UTC", date_time.format("%Y-%m-%d %H:%M")) - } - _ => "an unavailable time".to_owned(), - }, - } -} - -fn scheduled_offset_from_now(timestamp: u64) -> DraftVoteTiming { - let remaining_minutes = timestamp.saturating_sub(Utc::now().timestamp_millis() as u64) / 60_000; - DraftVoteTiming::Scheduled { - days: (remaining_minutes / (24 * 60)) as u32, - hours: ((remaining_minutes / 60) % 24) as u32, - minutes: (remaining_minutes % 60) as u32, - } -} - -fn is_explicit_schedule_replacement( - editing_key: Option<&DpnsVoteTargetKey>, - target_key: &DpnsVoteTargetKey, - existing_status: Option, - replacement_timing: VoteTiming, -) -> bool { - editing_key == Some(target_key) - && existing_status == Some(DpnsVoteTargetStatus::Scheduled) - && matches!(replacement_timing, VoteTiming::Scheduled(_)) -} - -fn should_return_to_review_after_error( - submitted_operation: DpnsVoteOperationId, - network: dash_sdk::dpp::dashcore::Network, - context: &BackendTaskContext, - operation_was_journaled: bool, -) -> bool { - !operation_was_journaled - && context - == &BackendTaskContext::DpnsVoteOperation { - network, - operation_id: submitted_operation, - } -} - -fn updated_submitted_operation( - submitted_operation: Option, - context: &BackendTaskContext, - result: &BackendTaskSuccessResult, -) -> Option { - match (submitted_operation, context, result) { - ( - Some(submitted), - BackendTaskContext::DpnsVoteOperation { - network: submitted_network, - operation_id: submitted_id, - }, - BackendTaskSuccessResult::DpnsVoteOperationUpdated { - network: result_network, - operation_id: result_id, - }, - ) if submitted == *submitted_id && submitted_network == result_network => Some(*result_id), - _ => submitted_operation, - } -} - -fn has_loaded_voting_key(voter: &QualifiedIdentity) -> bool { - voter - .private_keys - .keys_set() - .iter() - .any(|(target, _)| *target == PrivateKeyTarget::PrivateKeyOnVoterIdentity) -} - -fn status_label(status: DpnsVoteTargetStatus) -> &'static str { - match status { - DpnsVoteTargetStatus::Scheduled => "Scheduled", - DpnsVoteTargetStatus::Queued => "Queued", - DpnsVoteTargetStatus::Submitting => "Submitting", - DpnsVoteTargetStatus::Confirming => "Confirming", - DpnsVoteTargetStatus::Confirmed => "Confirmed", - DpnsVoteTargetStatus::Unconfirmed => "Checking result", - DpnsVoteTargetStatus::Rejected => "Rejected", - DpnsVoteTargetStatus::FailedBeforeSubmission => "Failed before submission", - DpnsVoteTargetStatus::Cancelled => "Cancelled", - DpnsVoteTargetStatus::NotApplied => "Not applied", - } -} - -fn status_explanation(status: DpnsVoteTargetStatus) -> &'static str { - match status { - DpnsVoteTargetStatus::Scheduled => "DET will submit this vote at the scheduled time.", - DpnsVoteTargetStatus::Queued => "This vote is waiting to be submitted.", - DpnsVoteTargetStatus::Submitting => "DET is submitting this vote.", - DpnsVoteTargetStatus::Confirming => "The vote was submitted and is being confirmed.", - DpnsVoteTargetStatus::Confirmed => "Platform confirmed this vote.", - DpnsVoteTargetStatus::Unconfirmed => { - "The vote was submitted, but DET could not confirm it yet. Do not submit it again." - } - DpnsVoteTargetStatus::Rejected => { - "Platform rejected this vote. Review the choice before trying again." - } - DpnsVoteTargetStatus::FailedBeforeSubmission => { - "This vote was not submitted. Review it before trying again." - } - DpnsVoteTargetStatus::Cancelled => { - "This scheduled vote was cancelled before it was submitted." - } - DpnsVoteTargetStatus::NotApplied => { - "This vote was not applied. You can safely review it and try again." - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app::TaskResult; - use crate::app_dir::ensure_env_file; - use crate::context::connection_status::ConnectionStatus; - use crate::database::test_helpers::create_database_at_path; - use crate::model::contested_name::Contestant; - use crate::model::user_role::UserRoleCell; - use crate::utils::egui_mpsc::SenderAsync; - use crate::utils::tasks::TaskManager; - use egui_kittest::Harness; - use egui_kittest::kittest::Queryable; - use std::cell::{Cell, RefCell}; - use std::rc::Rc; - - async fn offline_ctx() -> (Arc, tempfile::TempDir) { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let data_dir = temp_dir.path().to_path_buf(); - ensure_env_file(&data_dir); - let db = Arc::new(create_database_at_path(&data_dir.join("data.db")).expect("db")); - let app_kv = AppContext::open_app_kv(&data_dir).expect("app kv"); - let secret_store = AppContext::open_secret_store(&data_dir).expect("secret store"); - let context = AppContext::new( - data_dir, - dash_sdk::dpp::dashcore::Network::Testnet, - db, - Arc::new(TaskManager::new()), - Arc::new(ConnectionStatus::new()), - egui::Context::default(), - app_kv, - secret_store, - UserRoleCell::default(), - ) - .expect("offline AppContext"); - let (sender, _receiver) = tokio::sync::mpsc::channel::(32); - context - .ensure_wallet_backend(SenderAsync::new(sender, context.egui_ctx().clone())) - .await - .expect("wire wallet backend offline"); - (context, temp_dir) - } - - fn contestant(id: Identifier, name: &str) -> Contestant { - Contestant { - id, - name: name.to_owned(), - info: String::new(), - votes: 0, - created_at: None, - created_at_block_height: None, - created_at_core_block_height: None, - document_id: Identifier::from([9; 32]), - } - } - - #[test] - fn candidate_choice_uses_name_and_short_identifier() { - let candidate = Identifier::from([7; 32]); - let label = choice_label( - Some(ResourceVoteChoice::TowardsIdentity(candidate)), - &[contestant(candidate, "dominguez")], - ); - - assert!(label.starts_with("dominguez (")); - assert!(label.contains('…')); - assert!(!label.contains(&candidate.to_string(Encoding::Base58))); - } - - #[test] - fn current_summary_keeps_healthy_targets_usable() { - let states = [ - ( - Identifier::from([1; 32]), - DpnsCurrentVoteState::Available(None), - false, - ), - ( - Identifier::from([2; 32]), - DpnsCurrentVoteState::Unavailable, - false, - ), - ( - Identifier::from([3; 32]), - DpnsCurrentVoteState::Available(Some(ResourceVoteChoice::Lock)), - false, - ), - ]; - - assert_eq!( - current_summary(&states), - "Current across selected nodes: 1 not voted, 1 already voted, 1 unavailable" - ); - } - - #[test] - fn scheduled_time_is_absolute_and_relative() { - let timestamp = (Utc::now() + Duration::hours(2)).timestamp_millis() as u64; - let label = format_timing(VoteTiming::Scheduled(timestamp)); - - assert!(label.starts_with("When: 20")); - assert!(label.contains(" UTC (")); - } - - #[test] - fn only_the_exact_scheduled_edit_target_can_be_replaced() { - let edited = DpnsVoteTargetKey { - network: dash_sdk::dpp::dashcore::Network::Testnet, - voter_id: Identifier::from([1; 32]), - vote_poll_id: Identifier::from([2; 32]), - }; - let other = DpnsVoteTargetKey { - vote_poll_id: Identifier::from([3; 32]), - ..edited.clone() - }; - - assert!(is_explicit_schedule_replacement( - Some(&edited), - &edited, - Some(DpnsVoteTargetStatus::Scheduled), - VoteTiming::Scheduled(10), - )); - assert!(!is_explicit_schedule_replacement( - None, - &edited, - Some(DpnsVoteTargetStatus::Scheduled), - VoteTiming::Scheduled(10), - )); - assert!(!is_explicit_schedule_replacement( - Some(&edited), - &other, - Some(DpnsVoteTargetStatus::Scheduled), - VoteTiming::Scheduled(10), - )); - } - - #[test] - fn scheduled_edit_review_names_the_old_and_new_schedule() { - let original = DpnsVoteTarget { - key: DpnsVoteTargetKey { - network: dash_sdk::dpp::dashcore::Network::Testnet, - voter_id: Identifier::from([1; 32]), - vote_poll_id: Identifier::from([2; 32]), - }, - voter_alias: Some("Eve".to_owned()), - contested_name: "dominguez".to_owned(), - requested_choice: ResourceVoteChoice::Lock, - current_choice: None, - timing: VoteTiming::Scheduled(1_700_000_000_000), - }; - let mut replacement = original.clone(); - replacement.requested_choice = ResourceVoteChoice::Abstain; - replacement.timing = VoteTiming::Scheduled(1_700_003_600_000); - - let summary = scheduled_replacement_summary(&original, &replacement, |_, choice| { - choice_label(choice, &[]) - }); - - assert!(summary.contains("Lock at ")); - assert!(summary.contains("β†’ Abstain at ")); - } - - #[test] - fn matching_pre_journal_error_returns_the_composer_to_review() { - let operation_id = DpnsVoteOperationId::from_bytes([4; 16]); - let context = BackendTaskContext::DpnsVoteOperation { - network: dash_sdk::dpp::dashcore::Network::Testnet, - operation_id, - }; - - assert!(should_return_to_review_after_error( - operation_id, - dash_sdk::dpp::dashcore::Network::Testnet, - &context, - false, - )); - assert!(!should_return_to_review_after_error( - operation_id, - dash_sdk::dpp::dashcore::Network::Testnet, - &context, - true, - )); - assert!(!should_return_to_review_after_error( - operation_id, - dash_sdk::dpp::dashcore::Network::Mainnet, - &context, - false, - )); - } - - #[test] - fn schedule_replacement_tracks_the_authoritative_operation_id() { - let submitted = DpnsVoteOperationId::from_bytes([4; 16]); - let stored = DpnsVoteOperationId::from_bytes([5; 16]); - let context = BackendTaskContext::DpnsVoteOperation { - network: dash_sdk::dpp::dashcore::Network::Testnet, - operation_id: submitted, - }; - let result = crate::backend_task::BackendTaskSuccessResult::DpnsVoteOperationUpdated { - network: dash_sdk::dpp::dashcore::Network::Testnet, - operation_id: stored, - }; - - assert_eq!( - updated_submitted_operation(Some(submitted), &context, &result), - Some(stored) - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn refreshed_contests_replace_the_voting_center_snapshot() { - let (context, _temp_dir) = offline_ctx().await; - let mut center = DpnsVotingCenter::new(&context, None, Vec::new()); - assert!(center.contests.is_empty()); - context - .insert_name_contests_as_normalized_names(vec!["dominguez".to_owned()]) - .expect("seed refreshed contest"); - center.vote_state_refresh_dispatched = true; - - center.display_backend_task_result( - &BackendTaskContext::Other, - &BackendTaskSuccessResult::RefreshedDpnsContests, - ); - - assert_eq!(center.contests.len(), 1); - assert_eq!(center.contests[0].normalized_contested_name, "dominguez"); - assert!(!center.vote_state_refresh_dispatched); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn empty_votes_step_offers_a_contest_refresh_action() { - let (context, _temp_dir) = offline_ctx().await; - let center = Rc::new(RefCell::new(DpnsVotingCenter::new( - &context, - None, - Vec::new(), - ))); - let dispatched = Rc::new(Cell::new(false)); - let center_for_ui = Rc::clone(¢er); - let dispatched_for_ui = Rc::clone(&dispatched); - let mut harness = Harness::builder() - .with_size(egui::vec2(700.0, 400.0)) - .build_ui(move |ui| { - if matches!( - center_for_ui.borrow_mut().render_votes(ui), - VotingCenterOutcome::Action(action) - if matches!( - *action, - AppAction::BackendTask(BackendTask::ContestedResourceTask( - ContestedResourceTask::QueryDPNSContests - )) - ) - ) { - dispatched_for_ui.set(true); - } - }); - - harness.get_by_label("Refresh contests").click(); - harness.run(); - - assert!(dispatched.get()); - } -} diff --git a/src/ui/state/dpns_vote_state.rs b/src/ui/state/dpns_vote_state.rs new file mode 100644 index 000000000..ce0bbfc62 --- /dev/null +++ b/src/ui/state/dpns_vote_state.rs @@ -0,0 +1,79 @@ +//! Per-screen cache of proved DPNS vote state for immediate-mode rendering. + +use std::collections::BTreeMap; + +use dash_sdk::platform::Identifier; + +use crate::backend_task::error::TaskError; +use crate::context::AppContext; +use crate::model::dpns_voting::DpnsCurrentVoteState; + +#[derive(Debug, Clone, Default)] +pub struct DpnsVoteStateSnapshot { + states: BTreeMap<(Identifier, Identifier), DpnsCurrentVoteState>, + loaded: bool, +} + +impl DpnsVoteStateSnapshot { + pub fn load( + app_context: &AppContext, + voter_ids: &[Identifier], + vote_poll_ids: &[Identifier], + ) -> Result { + let mut snapshot = Self::default(); + snapshot.refresh(app_context, voter_ids, vote_poll_ids)?; + Ok(snapshot) + } + + pub fn refresh( + &mut self, + app_context: &AppContext, + voter_ids: &[Identifier], + vote_poll_ids: &[Identifier], + ) -> Result<(), TaskError> { + let mut states = BTreeMap::new(); + for voter_id in voter_ids { + states.extend( + app_context + .dpns_current_vote_states(*voter_id, vote_poll_ids.iter().copied())? + .into_iter() + .map(|(poll_id, state)| ((*voter_id, poll_id), state)), + ); + } + self.states = states; + self.loaded = true; + Ok(()) + } + + pub fn state(&self, voter_id: Identifier, vote_poll_id: Identifier) -> DpnsCurrentVoteState { + if !self.loaded { + return DpnsCurrentVoteState::Unavailable; + } + self.states + .get(&(voter_id, vote_poll_id)) + .copied() + .unwrap_or(DpnsCurrentVoteState::Unavailable) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_lookups_use_only_the_in_memory_snapshot() { + let voter = Identifier::from([1; 32]); + let poll = Identifier::from([2; 32]); + let snapshot = DpnsVoteStateSnapshot { + states: BTreeMap::from([((voter, poll), DpnsCurrentVoteState::Available(None))]), + loaded: true, + }; + + for _ in 0..120 { + assert_eq!( + snapshot.state(voter, poll), + DpnsCurrentVoteState::Available(None) + ); + } + } +} diff --git a/src/ui/state/dpns_vote_workspace.rs b/src/ui/state/dpns_vote_workspace.rs deleted file mode 100644 index 827d07ab5..000000000 --- a/src/ui/state/dpns_vote_workspace.rs +++ /dev/null @@ -1,206 +0,0 @@ -//! Non-rendering state for the shared DPNS Voting Center composer. - -use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; -use dash_sdk::platform::Identifier; -use std::collections::{BTreeMap, BTreeSet}; - -/// Current step of the full-page voting composer. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DpnsVoteComposerStep { - Nodes, - Votes, - Review, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ComposerKeyAction { - None, - Advance, - Submit, - CloseDraft, -} - -/// Per-node timing override in the draft. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DraftVoteTiming { - Now, - Scheduled { days: u32, hours: u32, minutes: u32 }, -} - -/// Shared quick/bulk draft state; renders nothing. -#[derive(Debug, Clone)] -pub struct DpnsVoteWorkspace { - pub step: DpnsVoteComposerStep, - selected_nodes: BTreeSet, - pub node_timing: BTreeMap, - pub contest_choices: BTreeMap, - pub set_all_timing: DraftVoteTiming, -} - -impl DpnsVoteWorkspace { - pub fn new(node_ids: impl IntoIterator) -> Self { - Self { - step: DpnsVoteComposerStep::Nodes, - selected_nodes: BTreeSet::new(), - node_timing: node_ids - .into_iter() - .map(|node_id| (node_id, DraftVoteTiming::Now)) - .collect(), - contest_choices: BTreeMap::new(), - set_all_timing: DraftVoteTiming::Now, - } - } - - /// Restrict the initial draft to one node from a detail-page deep link. - pub fn prefilter_node(&mut self, selected: Identifier) { - self.selected_nodes.clear(); - if let Some(timing) = self.node_timing.get_mut(&selected) { - *timing = DraftVoteTiming::Now; - self.selected_nodes.insert(selected); - } - } - - pub fn selected_node_count(&self) -> usize { - self.selected_nodes.len() - } - - pub fn is_node_selected(&self, node_id: &Identifier) -> bool { - self.selected_nodes.contains(node_id) - } - - pub fn set_node_selected(&mut self, node_id: Identifier, selected: bool) { - if selected { - if self.node_timing.contains_key(&node_id) { - self.selected_nodes.insert(node_id); - } - } else { - self.selected_nodes.remove(&node_id); - } - } - - pub fn apply_timing_to_selected(&mut self) { - for node_id in &self.selected_nodes { - if let Some(timing) = self.node_timing.get_mut(node_id) { - *timing = self.set_all_timing; - } - } - } - - /// Resolve keyboard intent without letting Enter submit before Review. - pub fn keyboard_action( - &self, - enter_pressed: bool, - escape_pressed: bool, - can_continue: bool, - ) -> ComposerKeyAction { - if escape_pressed { - return ComposerKeyAction::CloseDraft; - } - if !enter_pressed || !can_continue { - return ComposerKeyAction::None; - } - match self.step { - DpnsVoteComposerStep::Nodes | DpnsVoteComposerStep::Votes => ComposerKeyAction::Advance, - DpnsVoteComposerStep::Review => ComposerKeyAction::Submit, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// VOTE-TC-021/022: set-all applies timing and an individual override survives. - #[test] - fn set_all_timing_allows_per_node_override() { - let first = Identifier::from([1; 32]); - let second = Identifier::from([2; 32]); - let mut workspace = DpnsVoteWorkspace::new([first, second]); - workspace.set_all_timing = DraftVoteTiming::Scheduled { - days: 1, - hours: 2, - minutes: 3, - }; - workspace.set_node_selected(first, true); - workspace.set_node_selected(second, true); - workspace.apply_timing_to_selected(); - workspace.node_timing.insert(first, DraftVoteTiming::Now); - - assert_eq!(workspace.node_timing[&first], DraftVoteTiming::Now); - assert!(matches!( - workspace.node_timing[&second], - DraftVoteTiming::Scheduled { .. } - )); - } - - /// VOTE-TC-024: a node-detail route selects only that node. - #[test] - fn node_prefilter_excludes_every_other_node() { - let selected = Identifier::from([1; 32]); - let other = Identifier::from([2; 32]); - let mut workspace = DpnsVoteWorkspace::new([selected, other]); - workspace.prefilter_node(selected); - - assert_eq!(workspace.selected_node_count(), 1); - assert_eq!(workspace.node_timing[&selected], DraftVoteTiming::Now); - assert!(!workspace.is_node_selected(&other)); - } - - /// VOTE-TC-020: an unfiltered bulk draft starts with no nodes selected. - #[test] - fn bulk_draft_starts_without_selected_nodes() { - let first = Identifier::from([1; 32]); - let second = Identifier::from([2; 32]); - let workspace = DpnsVoteWorkspace::new([first, second]); - - assert_eq!(workspace.selected_node_count(), 0); - assert!(!workspace.is_node_selected(&first)); - assert!(!workspace.is_node_selected(&second)); - } - - /// VOTE-TC-021: set-all changes timing only for explicitly selected nodes. - #[test] - fn set_all_timing_ignores_unselected_nodes() { - let selected = Identifier::from([1; 32]); - let unselected = Identifier::from([2; 32]); - let mut workspace = DpnsVoteWorkspace::new([selected, unselected]); - workspace.set_node_selected(selected, true); - workspace.set_all_timing = DraftVoteTiming::Scheduled { - days: 1, - hours: 2, - minutes: 3, - }; - - workspace.apply_timing_to_selected(); - - assert!(matches!( - workspace.node_timing[&selected], - DraftVoteTiming::Scheduled { .. } - )); - assert_eq!(workspace.node_timing[&unselected], DraftVoteTiming::Now); - } - - /// VOTE-TC-071: Enter advances drafts but submits only from Review; Escape closes drafts. - #[test] - fn keyboard_actions_respect_composer_step() { - let mut workspace = DpnsVoteWorkspace::new([Identifier::from([1; 32])]); - assert_eq!( - workspace.keyboard_action(true, false, true), - ComposerKeyAction::Advance - ); - workspace.step = DpnsVoteComposerStep::Votes; - assert_eq!( - workspace.keyboard_action(true, false, true), - ComposerKeyAction::Advance - ); - workspace.step = DpnsVoteComposerStep::Review; - assert_eq!( - workspace.keyboard_action(true, false, true), - ComposerKeyAction::Submit - ); - assert_eq!( - workspace.keyboard_action(false, true, true), - ComposerKeyAction::CloseDraft - ); - } -} diff --git a/src/ui/state/mod.rs b/src/ui/state/mod.rs index 31926333e..b744f286f 100644 --- a/src/ui/state/mod.rs +++ b/src/ui/state/mod.rs @@ -9,7 +9,7 @@ pub mod account_summary; pub mod avatar_cache; pub mod contacts_view; pub mod dpns_vote_operations; -pub mod dpns_vote_workspace; +pub mod dpns_vote_state; pub mod global_nav; pub mod hub_selection; pub mod masternodes_view; diff --git a/tests/kittest/masternode_tab.rs b/tests/kittest/masternode_tab.rs index 8a156a611..861aab3be 100644 --- a/tests/kittest/masternode_tab.rs +++ b/tests/kittest/masternode_tab.rs @@ -308,10 +308,10 @@ fn empty_state_renders_canonical_copy() { }); } -/// VOTE-TC-023: operator navigation exposes Nodes, Voting, and Scheduled and -/// opens the shared full-page composer. +/// The Masternodes root contains only its list and detail flow. DPNS owns all +/// voting and scheduled-vote navigation. #[test] -fn voting_navigation_routes_to_shared_workspaces() { +fn masternodes_has_no_operator_voting_subnavigation() { with_isolated_data_dir(|| { let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); let _guard = rt.enter(); @@ -320,44 +320,16 @@ fn voting_navigation_routes_to_shared_workspaces() { let app_context = harness.state().current_app_context().clone(); activate_masternodes_tab(&mut harness, &app_context); - assert!(harness.query_by_label("Nodes").is_some()); - assert!(harness.query_by_label("Voting").is_some()); - assert!(harness.query_by_label("Scheduled").is_some()); - - harness.get_by_label("Voting").click(); - harness.run_steps(3); - assert_eq!( - harness.state().selected_main_screen, - RootScreenType::RootScreenMasternodes - ); - assert!( - harness - .query_by_label("Step 1 of 3: Nodes and timing") - .is_some() - ); - assert!( - harness - .query_by_label("No voting nodes are available") - .is_some(), - "an empty voting workspace must explain why voting cannot start" - ); - assert!( - harness.query_by_label("Go to Nodes").is_some(), - "an empty voting workspace must offer a direct recovery action" - ); - harness.get_by_label("Go to Nodes").click(); - harness.run_steps(2); - assert!( - harness.query_by_label("No masternodes loaded").is_some(), - "the empty-workspace recovery action must return to the Nodes tab" - ); + assert!(harness.query_by_label("Nodes").is_none()); + assert!(harness.query_by_label("Voting").is_none()); + assert!(harness.query_by_label("Scheduled").is_none()); + assert!(harness.query_by_label("No masternodes loaded").is_some()); }); } -/// VOTE-TC-023/075: the retired DPNS scheduled surface redirects to the shared -/// Masternodes scheduled view instead of exposing a second submit path. +/// Scheduled votes remain a DPNS subscreen. #[test] -fn legacy_dpns_scheduled_route_opens_masternodes_scheduled_view() { +fn dpns_scheduled_route_stays_in_dpns() { with_isolated_data_dir(|| { let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); let _guard = rt.enter(); @@ -367,9 +339,9 @@ fn legacy_dpns_scheduled_route_opens_masternodes_scheduled_view() { assert_eq!( harness.state().selected_main_screen, - RootScreenType::RootScreenMasternodes + RootScreenType::RootScreenDPNSScheduledVotes ); - assert!(harness.query_by_label("Scheduled votes").is_some()); + assert!(harness.query_by_label("No scheduled votes.").is_some()); }); } @@ -610,13 +582,10 @@ fn detail_view_opens_from_card_with_sections_and_back() { }); } -/// TC-DPNS-01/02/09/10 β€” the DPNS section is collapsed by default (its body is -/// not rendered), the header carries the open-contest count, and for a node with -/// no voter identity the expanded section shows the actionable missing-voter -/// message with an `Add voting key` action that opens a scoped in-place prompt -/// (not FR-4's load form β€” no ProTxHash field). +/// The detail screen has one plain route to DPNS voting and no inline voting +/// controls or node-prefilter state. #[test] -fn dpns_section_missing_voter_scoped_prompt() { +fn detail_dpns_voting_button_opens_active_contests() { with_isolated_data_dir(|| { let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); let _guard = rt.enter(); @@ -628,42 +597,22 @@ fn dpns_section_missing_voter_scoped_prompt() { harness.get_by_label("Open mn-vote-01").click(); harness.run_steps(3); - // With no voter key the "Add voting key" CTA and its - // actionable message are rendered ABOVE, outside the collapsed-by-default - // DPNS section, so they are visible immediately without expanding - // anything. The empty DPNS header is omitted in this state (no contests - // are possible without a voter). - assert!( - harness - .query_by_label("DPNS name contests to vote on (0)") - .is_none(), - "the empty DPNS header must be omitted when the node has no voter key" - ); + assert!(harness.query_by_label("DPNS Voting").is_some()); assert!( harness - .query_by_label( - "This node has no voting key loaded. Add its voting private key to cast votes." - ) - .is_some(), - "the actionable missing-voter message must be visible without expanding" - ); - assert!( - harness.query_by_label("Add voting key").is_some(), - "missing-voter state must offer an Add voting key action" + .query_by_label_contains("DPNS name contests to vote on") + .is_none() ); + assert!(harness.query_by_label("Add voting key").is_none()); + assert!(harness.query_by_label_contains("Review ").is_none()); - // Click Add voting key β†’ scoped in-place prompt (Save/Cancel), NOT the - // load form (no ProTxHash field) (TC-DPNS-10/11). - harness.get_by_label("Add voting key").click(); + harness.get_by_label("DPNS Voting").click(); harness.run_steps(3); - assert!( - harness.query_by_label("Save").is_some(), - "scoped voter-key prompt must open with a Save action" - ); - assert!( - harness.query_by_label("ProTxHash").is_none(), - "the scoped prompt must not be FR-4's load form (no ProTxHash re-entry)" + assert_eq!( + harness.state().selected_main_screen, + RootScreenType::RootScreenDPNSActiveContests ); + assert!(harness.query_by_label("Active contests").is_some()); }); } From 4d0457098a73cc412fb67019885f831f80b7d96f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:39:35 +0000 Subject: [PATCH 24/39] docs: update changelog for DPNS voting redesign Masternode detail no longer casts votes inline; voting now lives on the redesigned DPNS Active contests screen (cards, batch, scheduling). Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4de229b17..ba45ad3b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,15 +24,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Expert mode is on) for loading and managing masternode and evonode (HP masternode) identities by ProTxHash. Loaded nodes appear as a card list showing type, voter-key readiness, key status, and DPNS-voting status; - opening a card shows a detail view with inline DPNS contested-name voting, - Withdraw / Top up / Transfer actions, key management, and β€” for evonodes - only β€” a link to claim token rewards. The load form accepts an optional - password to encrypt the entered voting/owner/payout keys immediately - instead of only after a separate step; leaving it blank keeps today's - behavior, and protection can always be added later from the key screen. - This replaces loading a masternode or evonode from *Identities β†’ Load - Existing Identity β†’ Show Advanced Options*, which no longer offers those - identity types. + opening a card shows a detail view with Withdraw / Top up / Transfer + actions, key management, a "DPNS Voting" button that opens the DPNS Active + contests screen, and β€” for evonodes only β€” a link to claim token rewards. + The load form accepts an optional password to encrypt the entered + voting/owner/payout keys immediately instead of only after a separate + step; leaving it blank keeps today's behavior, and protection can always + be added later from the key screen. This replaces loading a masternode or + evonode from *Identities β†’ Load Existing Identity β†’ Show Advanced + Options*, which no longer offers those identity types. + +- **DPNS voting redesigned around the Active contests screen**: casting, + batching, and scheduling DPNS name-contest votes across your masternodes + now happens in one place β€” DPNS β†’ Active contests. Contests are grouped + into Needs your vote / Voted / Not votable by your nodes, with a Review + and cast step for casting now or scheduling later, and your remaining + vote changes shown up front (Platform allows four per contest). The + Masternodes detail screen no longer casts votes inline β€” its "DPNS + Voting" button takes you straight to Active contests instead. - **Wallet/identity indicator on more screens (rollout in progress)**: the wallet and identity picker previously shown only at the top of the Identity From c7e6ac41244d8da968fa74ca686abf5c4383ad71 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:04:01 +0000 Subject: [PATCH 25/39] fix(dpns): restore Scheduled-votes nav and rebuild Review-and-cast sheet - Add Scheduled votes as a persistent 4th DPNS nav tab; it was previously reachable only via a one-shot post-schedule-action link. - Rebuild the Review and cast sheet: candidate names instead of raw Base58 IDs, a Cast now / Schedule for later segmented control instead of the legacy per-identity ComboBox, actionable "Open Masternodes" guidance instead of the stale Identities-screen instruction, and a future-time validation warning for scheduling. - Add the Active-contests summary strip and port homograph-safe filter normalization + explanatory tooltip to the Active-contests filter. Co-Authored-By: Claude Sonnet 5 --- .../dpns_subscreen_chooser_panel.rs | 4 + src/ui/dpns/dpns_contested_names_screen.rs | 475 ++++++++++-------- tests/kittest/masternode_tab.rs | 9 +- 3 files changed, 283 insertions(+), 205 deletions(-) diff --git a/src/ui/components/dpns_subscreen_chooser_panel.rs b/src/ui/components/dpns_subscreen_chooser_panel.rs index c51291031..38fe91295 100644 --- a/src/ui/components/dpns_subscreen_chooser_panel.rs +++ b/src/ui/components/dpns_subscreen_chooser_panel.rs @@ -29,6 +29,10 @@ pub fn add_dpns_subscreen_chooser_panel(ui: &mut Ui, app_context: &AppContext) - DPNSSubscreen::Owned, RootScreenType::RootScreenDPNSOwnedNames, ), + ( + DPNSSubscreen::ScheduledVotes, + RootScreenType::RootScreenDPNSScheduledVotes, + ), ] .into_iter() .map(|(subscreen, target)| { diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index 1da314bdd..fc915fb7d 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -2,7 +2,7 @@ use crate::wallet_backend::poison::MutexRecover; use std::sync::{Arc, Mutex}; use tracing::error; -use chrono::{DateTime, LocalResult, TimeZone, Utc}; +use chrono::{DateTime, LocalResult, NaiveDate, NaiveTime, TimeZone, Timelike, Utc}; use chrono_humanize::HumanTime; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; @@ -18,6 +18,7 @@ use crate::backend_task::identity::IdentityTask; use crate::backend_task::{BackendTask, BackendTaskContext}; use crate::context::AppContext; use crate::model::contested_name::{ContestState, ContestedName}; +use crate::model::dpns::normalize_dpns_label; use crate::model::dpns_voting::{ DpnsCurrentVoteState, DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteTarget, DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, @@ -54,6 +55,22 @@ enum ActiveContestGroup { NotVotable, } +const NO_VOTING_NODES_MESSAGE: &str = "No voting-enabled masternodes are loaded. Open the Masternodes tab, load a masternode with its voting key, then try again."; + +fn candidate_choice_label(candidate_name: &str) -> String { + format!("Vote for {candidate_name}") +} + +fn review_vote_choice_label(choice: ResourceVoteChoice, candidate_name: Option<&str>) -> String { + match choice { + ResourceVoteChoice::Lock => "Lock".to_owned(), + ResourceVoteChoice::Abstain => "Abstain".to_owned(), + ResourceVoteChoice::TowardsIdentity(_) => candidate_name + .map(candidate_choice_label) + .unwrap_or_else(|| "Vote for a candidate that is no longer listed.".to_owned()), + } +} + fn classify_vote_states( states: impl IntoIterator, ) -> ActiveContestGroup { @@ -224,6 +241,9 @@ pub struct DPNSScreen { bulk_identity_options: Vec, bulk_vote_handling_status: VoteHandlingStatus, set_all_option: VoteOption, + simple_schedule_date: String, + simple_schedule_hour: u32, + simple_schedule_minute: u32, } impl DPNSScreen { @@ -288,6 +308,7 @@ impl DPNSScreen { // Initialize vote handling pop-up state to hidden let identity_count = voting_identities.len(); let bulk_identity_options = vec![VoteOption::CastNow; identity_count]; + let default_schedule_time = Utc::now() + chrono::Duration::days(1); Self { voting_identities, @@ -319,6 +340,9 @@ impl DPNSScreen { bulk_identity_options, bulk_vote_handling_status: VoteHandlingStatus::NotStarted, set_all_option: VoteOption::CastNow, + simple_schedule_date: default_schedule_time.format("%Y-%m-%d").to_string(), + simple_schedule_hour: default_schedule_time.hour(), + simple_schedule_minute: default_schedule_time.minute(), } } @@ -445,7 +469,10 @@ impl DPNSScreen { let dark_mode = ui.style().visuals.dark_mode; ui.horizontal(|ui| { ui.label(RichText::new("Filter by name:").color(DashColors::text_primary(dark_mode))); - ui.text_edit_singleline(&mut self.active_filter_term); + ui.text_edit_singleline(&mut self.active_filter_term) + .on_hover_text( + "The letters i and l match the digit 1, and the letter o matches 0.", + ); }); ui.label( RichText::new("Each node can change its vote up to four times after its initial vote.") @@ -453,18 +480,11 @@ impl DPNSScreen { ); ui.add_space(8.0); - let filter = self.active_filter_term.to_lowercase(); + let filter = normalize_dpns_label(&self.active_filter_term); let contests = self .contested_names .lock_recover() .iter() - .filter(|contest| { - filter.is_empty() - || contest - .normalized_contested_name - .to_lowercase() - .contains(&filter) - }) .cloned() .collect::>(); let mut groups = [Vec::new(), Vec::new(), Vec::new()]; @@ -477,6 +497,35 @@ impl DPNSScreen { groups[index].push(contest); } + let closes_within_day = groups[0] + .iter() + .filter(|contest| { + contest.end_time.is_some_and(|end_time| { + let remaining = end_time as i64 - Utc::now().timestamp_millis(); + remaining > 0 && remaining <= chrono::Duration::days(1).num_milliseconds() + }) + }) + .count(); + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.label(format!( + "Names still available to your nodes: {available}. Names closing within 24 hours: {closing}.", + available = groups[0].len(), + closing = closes_within_day, + )); + }); + ui.add_space(8.0); + + if !filter.is_empty() { + for group in &mut groups { + group.retain(|contest| { + contest + .normalized_contested_name + .to_lowercase() + .contains(&filter) + }); + } + } + egui::ScrollArea::vertical() .id_salt("active_contest_cards") .show(ui, |ui| { @@ -634,7 +683,7 @@ impl DPNSScreen { let clicked = ui .selectable_label( selected == Some(choice), - format!("Vote for {}", contestant.name), + candidate_choice_label(&contestant.name), ) .clicked(); tally_chip(ui, contestant.votes, dark_mode); @@ -794,31 +843,20 @@ impl DPNSScreen { ui.horizontal(|ui| { let dark_mode = ui.style().visuals.dark_mode; ui.label(RichText::new("Filter by name:").color(DashColors::text_primary(dark_mode))); - ui.text_edit_singleline(&mut self.past_filter_term); + ui.text_edit_singleline(&mut self.past_filter_term) + .on_hover_text( + "The letters i and l match the digit 1, and the letter o matches 0.", + ); }); let contested_names = { let guard = self.contested_names.lock_recover(); let mut cn = guard.clone(); cn.retain(|c| c.awarded_to.is_some() || c.state == ContestState::Locked); - // 1) Filter by `past_filter_term` if !self.past_filter_term.is_empty() { - let mut filter_lc = self.past_filter_term.to_lowercase(); - // Convert o and O to 0 and l to 1 in filter_lc - filter_lc = filter_lc - .chars() - .map(|c| match c { - 'o' | 'O' => '0', - 'l' => '1', - _ => c, - }) - .collect(); + let filter = normalize_dpns_label(&self.past_filter_term); - cn.retain(|c| { - c.normalized_contested_name - .to_lowercase() - .contains(&filter_lc) - }); + cn.retain(|c| c.normalized_contested_name.to_lowercase().contains(&filter)); } self.sort_contested_names(&mut cn); cn @@ -1377,170 +1415,172 @@ impl DPNSScreen { action } - fn show_bulk_schedule_popup_window(&mut self, ui: &mut Ui) -> AppAction { + fn simple_schedule_option(&self) -> Option { + let date = NaiveDate::parse_from_str(&self.simple_schedule_date, "%Y-%m-%d").ok()?; + let time = + NaiveTime::from_hms_opt(self.simple_schedule_hour, self.simple_schedule_minute, 0)?; + let scheduled_at = DateTime::::from_naive_utc_and_offset(date.and_time(time), Utc); + let seconds = scheduled_at.signed_duration_since(Utc::now()).num_seconds(); + if seconds <= 0 { + return None; + } + let total_minutes = u32::try_from((seconds + 59) / 60).ok()?; + Some(VoteOption::Scheduled { + days: total_minutes / (24 * 60), + hours: total_minutes / 60 % 24, + minutes: total_minutes % 60, + }) + } + + fn apply_all_nodes_option(&mut self, option: VoteOption) { + self.set_all_option = option.clone(); + self.bulk_identity_options.fill(option); + } + + fn review_candidate_name(&self, vote: &SelectedVote) -> Option { + let ResourceVoteChoice::TowardsIdentity(candidate_id) = vote.vote_choice else { + return None; + }; + self.contested_names + .lock_recover() + .iter() + .find(|contest| contest.normalized_contested_name == vote.contested_name) + .and_then(|contest| contest.contestants.as_ref()) + .and_then(|contestants| { + contestants + .iter() + .find(|candidate| candidate.id == candidate_id) + }) + .map(|candidate| candidate.name.clone()) + } + + fn show_review_and_cast_window(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; let dark_mode = ui.style().visuals.dark_mode; - ui.heading( - RichText::new("Review selected votes").color(DashColors::text_primary(dark_mode)), - ); - ui.add_space(10.0); - // If self.bulk_vote_handling_status is Complete, show completed message if self.bulk_vote_handling_status == VoteHandlingStatus::Completed { action |= self.show_bulk_vote_handling_complete(ui); return action; } - // If no voting identities are loaded, display a message and return if self.voting_identities.is_empty() { ui.add_space(5.0); - ui.colored_label(Color32::DARK_RED, "No masternode identities loaded. Please go to the Identities screen to load your masternodes."); + ui.colored_label( + DashColors::warning_color(dark_mode), + NO_VOTING_NODES_MESSAGE, + ); ui.add_space(10.0); - let dark_mode = ui.style().visuals.dark_mode; - if ComponentStyles::add_secondary_button(ui, "Close", dark_mode).clicked() { - self.show_bulk_schedule_popup = false; - } + ui.horizontal(|ui| { + if ComponentStyles::add_primary_button(ui, "Open Masternodes").clicked() { + action = AppAction::SetMainScreen(RootScreenType::RootScreenMasternodes); + self.show_bulk_schedule_popup = false; + } + if ComponentStyles::add_secondary_button(ui, "Close", dark_mode).clicked() { + self.show_bulk_schedule_popup = false; + } + }); return action; } - // If no votes are selected, display a message and return if self.selected_votes.is_empty() { ui.add_space(5.0); - ui.colored_label(Color32::DARK_RED, "No votes selected. Please click the votes you want to cast or schedule in the Active Contests screen."); + ui.colored_label( + DashColors::warning_color(dark_mode), + "No votes are ready to review. Choose at least one vote on Active contests, then try again.", + ); ui.add_space(10.0); - let dark_mode = ui.style().visuals.dark_mode; - if ComponentStyles::add_secondary_button(ui, "Close", dark_mode).clicked() { + if ComponentStyles::add_secondary_button(ui, "Back to Active contests", dark_mode) + .clicked() + { self.show_bulk_schedule_popup = false; } return action; } + let mut simple_schedule_valid = true; egui::ScrollArea::vertical().show(ui, |ui| { - // Show which votes were clicked - ui.group(|ui| { - let dark_mode = ui.style().visuals.dark_mode; - ui.heading( - RichText::new("Selected votes").color(DashColors::text_primary(dark_mode)), - ); - ui.separator(); - for sv in &self.selected_votes { - // Convert end_time -> readable - let end_str = if let Some(e) = sv.end_time { - if let LocalResult::Single(dt) = Utc.timestamp_millis_opt(e as i64) { - let iso = dt.format("%Y-%m-%d %H:%M:%S").to_string(); - let rel = HumanTime::from(dt).to_string(); - format!("{} ({})", iso, rel) - } else { - "Invalid timestamp".to_string() - } - } else { - "N/A".to_string() - }; - let display_text = match &sv.vote_choice { - ResourceVoteChoice::TowardsIdentity(id) => id.to_string(Encoding::Base58), - other => other.to_string(), - }; - let dark_mode = ui.style().visuals.dark_mode; - ui.label( - RichText::new(format!( - "{} => {} | Contest ends at {}", - sv.contested_name, display_text, end_str - )) - .color(DashColors::text_primary(dark_mode)), - ); + ui.label(format!( + "Casting on behalf of all my nodes ({count}).", + count = self.voting_identities.len() + )); + ui.separator(); + ui.heading(format!("Votes to cast ({}):", self.selected_votes.len())); + for vote in &self.selected_votes { + let candidate_name = self.review_candidate_name(vote); + let choice = review_vote_choice_label(vote.vote_choice, candidate_name.as_deref()); + ui.label(format!("β€’ {name}.dash β†’ {choice}", name = vote.contested_name)); + } + ui.separator(); + ui.horizontal(|ui| { + ui.label("When:"); + if ui + .selectable_label( + matches!(self.set_all_option, VoteOption::CastNow), + "Cast now", + ) + .clicked() + { + self.apply_all_nodes_option(VoteOption::CastNow); + } + if ui + .selectable_label( + matches!(self.set_all_option, VoteOption::Scheduled { .. }), + "Schedule for later", + ) + .clicked() + && let Some(option) = self.simple_schedule_option() + { + self.apply_all_nodes_option(option); } }); - ui.add_space(10.0); - - // Show each identity + let user pick None / Immediate / Scheduled - let dark_mode = ui.style().visuals.dark_mode; - ui.heading(RichText::new("All my nodes").color(DashColors::text_primary(dark_mode))); - ui.add_space(10.0); - ui.group(|ui| { + if matches!(self.set_all_option, VoteOption::Scheduled { .. }) { + let mut schedule_changed = false; ui.horizontal(|ui| { - let dark_mode = ui.style().visuals.dark_mode; - ui.label( - RichText::new("Cast timing:").color(DashColors::text_primary(dark_mode)), + ui.label("Cast on (UTC):"); + schedule_changed |= ui + .add( + egui::TextEdit::singleline(&mut self.simple_schedule_date) + .desired_width(100.0) + .hint_text("YYYY-MM-DD"), + ) + .changed(); + ui.label("at"); + schedule_changed |= ui + .add( + egui::DragValue::new(&mut self.simple_schedule_hour) + .prefix("Hour: ") + .range(0..=23), + ) + .changed(); + schedule_changed |= ui + .add( + egui::DragValue::new(&mut self.simple_schedule_minute) + .prefix("Minute: ") + .range(0..=59), + ) + .changed(); + }); + simple_schedule_valid = self.simple_schedule_option().is_some(); + if schedule_changed + && let Some(option) = self.simple_schedule_option() + { + self.apply_all_nodes_option(option); + } + if !simple_schedule_valid { + ui.colored_label( + DashColors::warning_color(dark_mode), + "Choose a future date and time before scheduling these votes.", ); + } + ui.colored_label( + DashColors::warning_color(dark_mode), + "Keep Dash Evo Tool running and connected until then, or the scheduled votes will not be cast.", + ); + } - // A ComboBox to pick No Vote / Cast Now / Schedule - ComboBox::from_id_salt("set_all_combo") - .width(120.0) - .selected_text(match self.set_all_option { - VoteOption::NoVote => "Do not use these nodes".to_string(), - VoteOption::CastNow => "Cast now".to_string(), - VoteOption::Scheduled { .. } => "Schedule".to_string(), - }) - .show_ui(ui, |ui| { - if ui - .selectable_label( - matches!(self.set_all_option, VoteOption::NoVote), - "Do not use these nodes", - ) - .clicked() - { - self.set_all_option = VoteOption::NoVote; - } - if ui - .selectable_label( - matches!(self.set_all_option, VoteOption::CastNow), - "Cast now", - ) - .clicked() - { - self.set_all_option = VoteOption::CastNow; - } - if ui - .selectable_label( - matches!(self.set_all_option, VoteOption::Scheduled { .. }), - "Schedule", - ) - .clicked() - { - // Default scheduled values if none set yet - let (d, h, m) = match &self.set_all_option { - VoteOption::Scheduled { - days, - hours, - minutes, - } => (*days, *hours, *minutes), - _ => (0, 0, 0), - }; - self.set_all_option = VoteOption::Scheduled { - days: d, - hours: h, - minutes: m, - }; - } - }); - - // If scheduling, show the days/hours/minutes widgets inline - if let VoteOption::Scheduled { - ref mut days, - ref mut hours, - ref mut minutes, - } = self.set_all_option - { - let dark_mode = ui.style().visuals.dark_mode; - ui.label( - RichText::new("Schedule after:") - .color(DashColors::text_primary(dark_mode)), - ); - ui.add(egui::DragValue::new(days).prefix("Days: ").range(0..=14)); - ui.add(egui::DragValue::new(hours).prefix("Hours: ").range(0..=23)); - ui.add(egui::DragValue::new(minutes).prefix("Min: ").range(0..=59)); - } - - // Button to apply the "Set all" choice to each identity in bulk_identity_options - if ui.button("Apply to all nodes").clicked() { - for option in &mut self.bulk_identity_options { - *option = self.set_all_option.clone(); - } - } - }); - }); + ui.separator(); ui.add_space(10.0); egui::CollapsingHeader::new("Choose per node (advanced)") .default_open(false) @@ -1557,18 +1597,9 @@ impl DPNSScreen { .color(DashColors::text_primary(dark_mode)), ); - // This is a hack - // I'm seeing a panic if I load the app in mainnet context where I have no voting identities, - // and then switch to testnet and pressed "Vote". if self.bulk_identity_options.len() <= i { - let voting_identities = self - .app_context - .load_local_voting_identities() - .unwrap_or_default(); - // Initialize ephemeral bulk-schedule state to hidden - let identity_count = voting_identities.len(); self.bulk_identity_options = - vec![VoteOption::CastNow; identity_count]; + vec![VoteOption::CastNow; self.voting_identities.len()]; } let current_option = &mut self.bulk_identity_options[i]; @@ -1652,12 +1683,16 @@ impl DPNSScreen { }); }); // If any selected votes are scheduled, show a warning - if self - .bulk_identity_options - .iter() - .any(|o| matches!(o, VoteOption::Scheduled { .. })) + if !matches!(self.set_all_option, VoteOption::Scheduled { .. }) + && self + .bulk_identity_options + .iter() + .any(|option| matches!(option, VoteOption::Scheduled { .. })) { - ui.colored_label(Color32::DARK_RED, "NOTE: Dash Evo Tool must remain running and connected for scheduled votes to execute on time."); + ui.colored_label( + DashColors::warning_color(ui.style().visuals.dark_mode), + "Keep Dash Evo Tool running and connected until then, or the scheduled votes will not be cast.", + ); ui.add_space(10.0); } @@ -1671,19 +1706,47 @@ impl DPNSScreen { ui.label("Submitting votes…"); }); } - // "Apply Votes" button - if ComponentStyles::add_primary_button_enabled( - ui, - !operation_in_progress, - if operation_in_progress { - "Submitting votes…" - } else { - "Submit votes" - }, - ) - .disabled_tooltip("The selected votes are already being submitted.") - .clicked() - { + let has_immediate = self + .bulk_identity_options + .iter() + .any(|option| matches!(option, VoteOption::CastNow)); + let has_scheduled = self + .bulk_identity_options + .iter() + .any(|option| matches!(option, VoteOption::Scheduled { .. })); + let submit_label = match (has_immediate, has_scheduled) { + (true, false) => "Cast votes", + (false, true) => "Schedule votes", + _ => "Submit votes", + }; + let (submit_clicked, cancel_clicked) = ui + .with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let submit_clicked = ComponentStyles::add_primary_button_enabled( + ui, + !operation_in_progress && simple_schedule_valid, + if operation_in_progress { + "Submitting votes…" + } else { + submit_label + }, + ) + .disabled_tooltip(if operation_in_progress { + "The selected votes are already being submitted." + } else { + "Choose a future date and time before scheduling these votes." + }) + .clicked(); + let cancel_clicked = ui + .add_enabled( + !operation_in_progress, + ComponentStyles::secondary_button("Cancel", dark_mode), + ) + .disabled_tooltip("Submitted votes cannot be cancelled.") + .clicked(); + (submit_clicked, cancel_clicked) + }) + .inner; + if submit_clicked { action = self.bulk_apply_votes(); if matches!( self.bulk_vote_handling_status, @@ -1696,16 +1759,7 @@ impl DPNSScreen { } } - ui.add_space(5.0); - let dark_mode = ui.style().visuals.dark_mode; - if ui - .add_enabled( - !operation_in_progress, - ComponentStyles::secondary_button("Cancel", dark_mode), - ) - .disabled_tooltip("Submitted votes cannot be cancelled.") - .clicked() - { + if cancel_clicked { self.selected_votes.clear(); self.show_bulk_schedule_popup = false; self.bulk_vote_handling_status = VoteHandlingStatus::NotStarted; @@ -1737,7 +1791,6 @@ impl DPNSScreen { action } - /// The logic that was in BulkScheduleVoteScreen::schedule_votes fn bulk_apply_votes(&mut self) -> AppAction { let mut targets = Vec::new(); let mut selected_voters = Vec::new(); @@ -2221,7 +2274,7 @@ impl ScreenLike for DPNSScreen { .resizable(true) .vscroll(true) .show(ui.ctx(), |ui| { - inner_action |= self.show_bulk_schedule_popup_window(ui); + inner_action |= self.show_review_and_cast_window(ui); }); } @@ -2412,6 +2465,24 @@ mod tests { )); } + #[test] + fn review_choice_uses_the_candidate_name_instead_of_its_identifier() { + let candidate_id = Identifier::from([42; 32]); + let label = review_vote_choice_label( + ResourceVoteChoice::TowardsIdentity(candidate_id), + Some("alice"), + ); + + assert_eq!(label, "Vote for alice"); + assert!(!label.contains(&candidate_id.to_string(Encoding::Base58))); + } + + #[test] + fn missing_voting_nodes_copy_points_to_the_masternodes_tab() { + assert!(NO_VOTING_NODES_MESSAGE.contains("Masternodes tab")); + assert!(!NO_VOTING_NODES_MESSAGE.contains("Identities screen")); + } + #[test] fn scheduled_vote_sweep_error_is_handled_after_cleanup() { let (ctx, _temp_dir) = offline_ctx(); diff --git a/tests/kittest/masternode_tab.rs b/tests/kittest/masternode_tab.rs index 861aab3be..1ce4e67fa 100644 --- a/tests/kittest/masternode_tab.rs +++ b/tests/kittest/masternode_tab.rs @@ -327,16 +327,19 @@ fn masternodes_has_no_operator_voting_subnavigation() { }); } -/// Scheduled votes remain a DPNS subscreen. +/// Scheduled votes remain reachable from the persistent DPNS subscreen bar. #[test] -fn dpns_scheduled_route_stays_in_dpns() { +fn dpns_scheduled_votes_tab_is_clickable() { with_isolated_data_dir(|| { let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); let _guard = rt.enter(); - let mut harness = mount_app(RootScreenType::RootScreenDPNSScheduledVotes); + let mut harness = mount_app(RootScreenType::RootScreenDPNSActiveContests); harness.run_steps(5); + harness.get_by_label("Scheduled votes").click(); + harness.run_steps(3); + assert_eq!( harness.state().selected_main_screen, RootScreenType::RootScreenDPNSScheduledVotes From cf61d47503e4936878368d3ae9fd3522496e3813 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:49:28 +0000 Subject: [PATCH 26/39] fix(dpns): address active voting review gaps --- CHANGELOG.md | 4 +- .../03-test-case-spec.md | 4 +- docs/user-stories.md | 29 ++- src/ui/dpns/dpns_contested_names_screen.rs | 228 +++++++++++++++--- tests/kittest/masternode_tab.rs | 41 +++- 5 files changed, 257 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba45ad3b6..0e829af3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,8 +38,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). batching, and scheduling DPNS name-contest votes across your masternodes now happens in one place β€” DPNS β†’ Active contests. Contests are grouped into Needs your vote / Voted / Not votable by your nodes, with a Review - and cast step for casting now or scheduling later, and your remaining - vote changes shown up front (Platform allows four per contest). The + and cast step for casting now or scheduling later, plus a reminder that + Platform allows up to four changes after the initial vote. The Masternodes detail screen no longer casts votes inline β€” its "DPNS Voting" button takes you straight to Active contests instead. diff --git a/docs/ai-design/2026-07-16-dpns-voting-experience/03-test-case-spec.md b/docs/ai-design/2026-07-16-dpns-voting-experience/03-test-case-spec.md index ae6235c2f..3c012dab3 100644 --- a/docs/ai-design/2026-07-16-dpns-voting-experience/03-test-case-spec.md +++ b/docs/ai-design/2026-07-16-dpns-voting-experience/03-test-case-spec.md @@ -5,7 +5,7 @@ | ID | Description | Preconditions | Steps | Expected outcome | Requirements | |---|---|---|---|---|---| | VOTE-TC-001 | Current vote loads from Platform | Node has a proved Lock vote | Refresh Voting | Row shows `Current vote: Lock` | FR-010, FR-011 | -| VOTE-TC-002 | Existing vote remains visible | Node already voted; contest active | Open Active contests | Contest appears in Voted and change controls are available | FR-012 | +| VOTE-TC-002 | Existing vote remains visible | Node already voted; contest active | Open Active contests | Contest appears in Voted, its proved choice is highlighted, `You voted: {choice}` is visible, and change controls are available | FR-012 | | VOTE-TC-003 | Current choice is a no-op | Current vote is Lock | Select Lock and review | Target is removed; nothing can be submitted | FR-013, FR-025 | | VOTE-TC-004 | Coherent refresh | Contest tally and current vote both changed | Refresh | One snapshot shows both new values | FR-014 | | VOTE-TC-005 | Vote query is per node | One node, 100 contests | Refresh | Identity-votes query runs once for the node, not 100 times | NFR-007 | @@ -20,7 +20,7 @@ | VOTE-TC-010 | Single vote | Active contests, one draft choice | Review and submit | One target is created per selected loaded node for that contest | FR-020, FR-024 | | VOTE-TC-011 | Multi-contest vote | Active contests, three draft choices | Review | Review shows the exact node Γ— contest targets | FR-020, FR-031 | | VOTE-TC-012 | Schedule one choice | Active contests, one draft | Choose Schedule in review | Targets appear in Scheduled with the chosen time | FR-023, FR-050 | -| VOTE-TC-013 | Missing voting key | No loaded node can vote | Open Active contests | Contest appears under Not votable and submit is unavailable | FR-003 | +| VOTE-TC-013 | Missing voting key | No loaded node has a voting key | Open Active contests | An actionable state explains that a voting key is missing, offers `Load a masternode`, and leaves submit unavailable | FR-003 | ## Bulk voting diff --git a/docs/user-stories.md b/docs/user-stories.md index c81b190ee..e81b420cb 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -685,10 +685,11 @@ As a power user, I want to review past DPNS contests so that I can see outcomes As a masternode operator, I want to vote on contested DPNS name registrations so that I can participate in network governance. - See the node's proved current choice before casting, changing, or abstaining. +- Active contests groups cards into Needs your vote, Voted, and Not votable by your nodes; a staged choice takes precedence over the proved highlight until submission. - A node may vote five times in total per contest: the initial vote plus up to four changes. - Choosing the current choice submits nothing. - Evonode/masternode identity required. -- The vote limit is enforced by Platform; DET does not invent a remaining-change count. +- The Active-contests screen explains the four-change limit without inventing a remaining-change count; Platform enforces the limit. ### DPN-006: Schedule votes [Implemented] **Persona:** Priya @@ -696,8 +697,7 @@ As a masternode operator, I want to vote on contested DPNS name registrations so As a masternode operator, I want to schedule votes for later execution so that I can plan my voting strategy in advance. - Set vote to be cast at a future time. -- View and manage scheduled votes under Masternodes β†’ Scheduled; the former DPNS - scheduled-votes entry redirects to this shared operator view. +- View and manage scheduled votes under DPNS β†’ Scheduled votes, which remains available in the persistent DPNS subnavigation. - Scheduled and immediate votes share the same target locks and result states. - An ambiguous result remains visible for checking and is never automatically rebroadcast. @@ -706,9 +706,9 @@ As a masternode operator, I want to schedule votes for later execution so that I As a masternode operator, I want to apply voting choices across multiple contests in bulk so that I do not have to vote on each contest individually. -- "Set all" option for batch vote assignment. -- Nodes are selected explicitly; "Set all" changes timing only for selected nodes. -- Nodes without a loaded voting key remain visible but cannot be selected. +- Review and cast defaults to all loaded voting nodes and Cast now. +- The advanced per-node disclosure can set each node to Cast now, Schedule, or Do not use this node. +- When no loaded node has a voting key, Active contests shows an actionable Load a masternode state instead of vote controls. - Per-node timing overrides and multi-contest selections create exact node Γ— contest targets. - Immediate and scheduled targets submitted together belong to one operation. @@ -1515,13 +1515,12 @@ As a masternode operator, I want a card list of my loaded masternodes showing ty ### MN-003: Open a masternode and vote [Implemented] **Persona:** Priya -As a masternode operator, I want to open a node and vote on the DPNS contests it can vote on, so that I can fulfil my node's governance role. +As a masternode operator, I want to open a node and continue to DPNS voting, so that I can fulfil my node's governance role. -- Clicking a card opens a detail view with a keys summary, the voter identity, and a collapsible DPNS-voting section (collapsed by default, open-contest count shown in its header). -- Every active contest remains visible with the node's proved current vote, including contests where the node already voted. -- Votes (Abstain, Lock, or a candidate) use the shared durable voting operation path. -- The affected controls disable immediately and show progress until the target is confirmed, rejected, or remains under explicit checking. -- A node with no voter identity is told a voting key is required, with a way to add one, instead of a raw error. +- Clicking a card opens a detail view with the keys summary and node actions. +- A single `DPNS Voting` button opens DPNS β†’ Active contests without carrying a node filter, draft, or other routing state. +- Voting takes place on Active contests through the shared durable voting-operation path. +- When no loaded node has a voting key, Active contests explains what is missing and offers a `Load a masternode` action. ### MN-004: Remove a masternode [Implemented] **Persona:** Priya @@ -1585,10 +1584,10 @@ As a masternode operator, I want the Masternodes tab to reset to a clean state w ### MN-011: Refresh masternode and voting state [Implemented] **Persona:** Priya -As a masternode operator, I want a Refresh control on the Masternodes tab, so that I can pull the latest identity and DPNS-contest state without leaving the page. +As a masternode operator, I want a Refresh control on the Masternodes tab, so that I can pull the latest node identity state without leaving the page. -- The card-list toolbar and a node's detail view each expose a Refresh action that re-reads the local cache immediately and dispatches a network re-fetch β€” one identity refresh per loaded node (or the single open node on the detail view) plus a DPNS-contest re-query so vote counts update too. -- Refresh is a no-op when no node is loaded, and the detail-view re-query is skipped for a node that has no voter identity. +- The card-list toolbar and a node's detail view each expose a Refresh action that re-reads the local cache immediately and dispatches one identity refresh per loaded node, or for the single open node on the detail view. +- Refresh is a no-op when no node is loaded. DPNS Active contests owns its separate contest refresh action. ### MN-012: Switch wallet/identity from the Masternodes header [Implemented] **Persona:** Priya diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index fc915fb7d..43623aa1c 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -55,7 +55,8 @@ enum ActiveContestGroup { NotVotable, } -const NO_VOTING_NODES_MESSAGE: &str = "No voting-enabled masternodes are loaded. Open the Masternodes tab, load a masternode with its voting key, then try again."; +const NO_VOTING_NODES_MESSAGE: &str = "None of your loaded nodes has a voting key."; +const NO_VOTING_NODES_DETAIL: &str = "Load a masternode with its voting key to cast votes."; fn candidate_choice_label(candidate_name: &str) -> String { format!("Vote for {candidate_name}") @@ -104,14 +105,37 @@ fn short_identifier(identifier: Identifier) -> String { format!("{}…{}", &encoded[..6], &encoded[encoded.len() - 4..]) } -fn vote_choice_label(choice: ResourceVoteChoice) -> String { +fn vote_choice_label(choice: ResourceVoteChoice, candidate_name: Option<&str>) -> String { match choice { - ResourceVoteChoice::Lock => "Lock name".to_owned(), + ResourceVoteChoice::Lock => "Lock".to_owned(), ResourceVoteChoice::Abstain => "Abstain".to_owned(), - ResourceVoteChoice::TowardsIdentity(identifier) => { - format!("Vote for {}", short_identifier(identifier)) + ResourceVoteChoice::TowardsIdentity(identifier) => candidate_name + .map(candidate_choice_label) + .unwrap_or_else(|| format!("Vote for {}", short_identifier(identifier))), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProvedVoteSummary { + None, + Choice(ResourceVoteChoice), + Mixed, +} + +fn proved_vote_summary( + states: impl IntoIterator, +) -> ProvedVoteSummary { + let mut proved_choice = None; + for state in states { + let DpnsCurrentVoteState::Available(Some(choice)) = state else { + continue; + }; + if proved_choice.is_some_and(|current| current != choice) { + return ProvedVoteSummary::Mixed; } + proved_choice = Some(choice); } + proved_choice.map_or(ProvedVoteSummary::None, ProvedVoteSummary::Choice) } fn target_status_label(status: DpnsVoteTargetStatus) -> &'static str { @@ -253,7 +277,7 @@ impl DPNSScreen { DPNSSubscreen::Active => app_context.ongoing_contested_names().unwrap_or_default(), DPNSSubscreen::Past => app_context.all_contested_names().unwrap_or_default(), DPNSSubscreen::Owned => Vec::new(), - DPNSSubscreen::ScheduledVotes => Vec::new(), + DPNSSubscreen::ScheduledVotes => app_context.all_contested_names().unwrap_or_default(), })); let local_dpns_names = Arc::new(Mutex::new(match dpns_subscreen { @@ -303,7 +327,10 @@ impl DPNSScreen { .map(|identity| identity.identity.id()) .collect::>(); let vote_state = DpnsVoteStateSnapshot::load(app_context, &voter_ids, &vote_poll_ids) - .unwrap_or_default(); + .unwrap_or_else(|error| { + tracing::warn!(?error, "Could not cache proved DPNS vote state"); + DpnsVoteStateSnapshot::default() + }); // Initialize vote handling pop-up state to hidden let identity_count = voting_identities.len(); @@ -382,6 +409,33 @@ impl DPNSScreen { // --------------------------- // Rendering: Empty states // --------------------------- + fn render_no_voting_nodes(&self, ui: &mut Ui) -> AppAction { + let mut action = AppAction::None; + let dark_mode = ui.style().visuals.dark_mode; + ui.vertical_centered(|ui| { + ui.add_space(24.0); + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.set_max_width(520.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(NO_VOTING_NODES_MESSAGE) + .strong() + .color(DashColors::warning_color(dark_mode)), + ); + ui.label( + RichText::new(NO_VOTING_NODES_DETAIL) + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(12.0); + if ComponentStyles::add_primary_button(ui, "Load a masternode").clicked() { + action = AppAction::SetMainScreen(RootScreenType::RootScreenMasternodes); + } + }); + }); + }); + action + } + fn render_no_active_contests_or_owned_names(&mut self, ui: &mut Ui) -> AppAction { let mut app_action = AppAction::None; ui.vertical_centered(|ui| { @@ -529,14 +583,15 @@ impl DPNSScreen { egui::ScrollArea::vertical() .id_salt("active_contest_cards") .show(ui, |ui| { - self.render_contest_group(ui, "Needs your vote", &groups[0], true, true); - self.render_contest_group(ui, "Voted", &groups[1], false, true); + self.render_contest_group(ui, "Needs your vote", &groups[0], true, true, false); + self.render_contest_group(ui, "Voted", &groups[1], false, true, true); self.render_contest_group( ui, "Not votable by your nodes", &groups[2], false, false, + false, ); self.render_voting_activity(ui); }); @@ -581,6 +636,7 @@ impl DPNSScreen { contests: &[ContestedName], default_open: bool, voting_enabled: bool, + show_current_vote: bool, ) { egui::CollapsingHeader::new(format!("{title} ({})", contests.len())) .default_open(default_open) @@ -592,7 +648,7 @@ impl DPNSScreen { let contest_enabled = voting_enabled && self.contest_has_available_target(contest); ui.add_enabled_ui(contest_enabled, |ui| { - self.render_contest_card(ui, contest, contest_enabled); + self.render_contest_card(ui, contest, contest_enabled, show_current_vote); }); ui.add_space(8.0); } @@ -622,13 +678,57 @@ impl DPNSScreen { }) } - fn render_contest_card(&mut self, ui: &mut Ui, contest: &ContestedName, voting_enabled: bool) { + fn proved_vote_for_contest(&self, contest: &ContestedName) -> ProvedVoteSummary { + let Ok(poll_id) = self + .app_context + .dpns_vote_poll_id(&contest.normalized_contested_name) + else { + return ProvedVoteSummary::None; + }; + proved_vote_summary( + self.voting_identities + .iter() + .map(|identity| self.vote_state.state(identity.identity.id(), poll_id)), + ) + } + + fn candidate_name_in_contest( + contest: &ContestedName, + choice: ResourceVoteChoice, + ) -> Option<&str> { + let ResourceVoteChoice::TowardsIdentity(candidate_id) = choice else { + return None; + }; + contest + .contestants + .as_ref()? + .iter() + .find(|candidate| candidate.id == candidate_id) + .map(|candidate| candidate.name.as_str()) + } + + fn render_contest_card( + &mut self, + ui: &mut Ui, + contest: &ContestedName, + voting_enabled: bool, + show_current_vote: bool, + ) { let dark_mode = ui.style().visuals.dark_mode; - let selected = self + let staged = self .selected_votes .iter() .find(|vote| vote.contested_name == contest.normalized_contested_name) .map(|vote| vote.vote_choice); + let proved = if show_current_vote { + self.proved_vote_for_contest(contest) + } else { + ProvedVoteSummary::None + }; + let selected = staged.or(match proved { + ProvedVoteSummary::Choice(choice) => Some(choice), + ProvedVoteSummary::None | ProvedVoteSummary::Mixed => None, + }); let locked_votes = contest.locked_votes.unwrap_or_default(); let abstain_votes = contest.abstain_votes.unwrap_or_default(); @@ -655,6 +755,30 @@ impl DPNSScreen { }); ui.add_space(6.0); + match proved { + ProvedVoteSummary::Choice(choice) => { + let candidate_name = Self::candidate_name_in_contest(contest, choice); + ui.label( + RichText::new(format!( + "You voted: {}.", + vote_choice_label(choice, candidate_name) + )) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(4.0); + } + ProvedVoteSummary::Mixed => { + ui.label( + RichText::new("Your loaded nodes have different current votes.") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(4.0); + } + ProvedVoteSummary::None => {} + } + ui.horizontal_wrapped(|ui| { let clicked = ui .selectable_label(selected == Some(ResourceVoteChoice::Lock), "Lock name") @@ -756,7 +880,14 @@ impl DPNSScreen { ui.label(format!( "{}.dash β€” {} β€” {}", outcome.target.contested_name, - vote_choice_label(outcome.target.requested_choice), + vote_choice_label( + outcome.target.requested_choice, + self.candidate_name( + &outcome.target.contested_name, + outcome.target.requested_choice, + ) + .as_deref(), + ), target_status_label(outcome.status), )); if outcome.status == DpnsVoteTargetStatus::Unconfirmed @@ -1256,7 +1387,10 @@ impl DPNSScreen { }); // Choice row.col(|ui| { - let display_text = vote_choice_label(vote.0.choice); + let candidate_name = + self.candidate_name(&vote.0.contested_name, vote.0.choice); + let display_text = + vote_choice_label(vote.0.choice, candidate_name.as_deref()); ui.add(Label::new(display_text)); }); // Time @@ -1320,10 +1454,16 @@ impl DPNSScreen { ); } ScheduledVoteCastingStatus::Failed => { - ui.colored_label(Color32::DARK_RED, "Failed"); + ui.colored_label( + DashColors::error_color(dark_mode), + "Failed", + ); } ScheduledVoteCastingStatus::Completed => { - ui.colored_label(Color32::DARK_GREEN, "Casted"); + ui.colored_label( + DashColors::success_color(dark_mode), + "Cast", + ); } } }); @@ -1437,14 +1577,14 @@ impl DPNSScreen { self.bulk_identity_options.fill(option); } - fn review_candidate_name(&self, vote: &SelectedVote) -> Option { - let ResourceVoteChoice::TowardsIdentity(candidate_id) = vote.vote_choice else { + fn candidate_name(&self, contested_name: &str, choice: ResourceVoteChoice) -> Option { + let ResourceVoteChoice::TowardsIdentity(candidate_id) = choice else { return None; }; self.contested_names .lock_recover() .iter() - .find(|contest| contest.normalized_contested_name == vote.contested_name) + .find(|contest| contest.normalized_contested_name == contested_name) .and_then(|contest| contest.contestants.as_ref()) .and_then(|contestants| { contestants @@ -1470,9 +1610,10 @@ impl DPNSScreen { DashColors::warning_color(dark_mode), NO_VOTING_NODES_MESSAGE, ); + ui.label(NO_VOTING_NODES_DETAIL); ui.add_space(10.0); ui.horizontal(|ui| { - if ComponentStyles::add_primary_button(ui, "Open Masternodes").clicked() { + if ComponentStyles::add_primary_button(ui, "Load a masternode").clicked() { action = AppAction::SetMainScreen(RootScreenType::RootScreenMasternodes); self.show_bulk_schedule_popup = false; } @@ -1507,7 +1648,7 @@ impl DPNSScreen { ui.separator(); ui.heading(format!("Votes to cast ({}):", self.selected_votes.len())); for vote in &self.selected_votes { - let candidate_name = self.review_candidate_name(vote); + let candidate_name = self.candidate_name(&vote.contested_name, vote.vote_choice); let choice = review_vote_choice_label(vote.vote_choice, candidate_name.as_deref()); ui.label(format!("β€’ {name}.dash β†’ {choice}", name = vote.contested_name)); } @@ -1924,7 +2065,6 @@ impl DPNSScreen { VoteHandlingStatus::Failed(message) => { // This means there was a DET-side error, not Platform-side let dark_mode = ui.style().visuals.dark_mode; - ui.heading(RichText::new("❌").color(DashColors::text_primary(dark_mode))); ui.heading( RichText::new("The votes could not be submitted.") .color(DashColors::text_primary(dark_mode)), @@ -1989,6 +2129,7 @@ impl ScreenLike for DPNSScreen { *dpns_names = self.app_context.local_dpns_names().unwrap_or_default(); } DPNSSubscreen::ScheduledVotes => { + *contested_names = self.app_context.all_contested_names().unwrap_or_default(); let new_scheduled = self.app_context.get_scheduled_votes().unwrap_or_default(); *scheduled_votes = new_scheduled .iter() @@ -2281,11 +2422,10 @@ impl ScreenLike for DPNSScreen { // Render sub-screen match self.dpns_subscreen { DPNSSubscreen::Active => { - let has_any = { - let guard = self.contested_names.lock_recover(); - !guard.is_empty() - }; - if has_any { + let has_any = !self.contested_names.lock_recover().is_empty(); + if self.voting_identities.is_empty() { + inner_action |= self.render_no_voting_nodes(ui); + } else if has_any { self.render_active_contests(ui); } else { inner_action |= self.render_no_active_contests_or_owned_names(ui); @@ -2478,8 +2618,38 @@ mod tests { } #[test] - fn missing_voting_nodes_copy_points_to_the_masternodes_tab() { - assert!(NO_VOTING_NODES_MESSAGE.contains("Masternodes tab")); + fn activity_choice_uses_the_cached_candidate_name() { + let candidate_id = Identifier::from([42; 32]); + let label = vote_choice_label( + ResourceVoteChoice::TowardsIdentity(candidate_id), + Some("alice"), + ); + + assert_eq!(label, "Vote for alice"); + assert!(!label.contains(&candidate_id.to_string(Encoding::Base58))); + } + + #[test] + fn proved_choice_is_highlighted_only_when_loaded_nodes_agree() { + assert_eq!( + proved_vote_summary([ + DpnsCurrentVoteState::Available(Some(ResourceVoteChoice::Abstain)), + DpnsCurrentVoteState::Checking, + ]), + ProvedVoteSummary::Choice(ResourceVoteChoice::Abstain) + ); + assert_eq!( + proved_vote_summary([ + DpnsCurrentVoteState::Available(Some(ResourceVoteChoice::Abstain)), + DpnsCurrentVoteState::Available(Some(ResourceVoteChoice::Lock)), + ]), + ProvedVoteSummary::Mixed + ); + } + + #[test] + fn missing_voting_nodes_copy_is_actionable_and_avoids_the_stale_route() { + assert!(NO_VOTING_NODES_DETAIL.contains("masternode")); assert!(!NO_VOTING_NODES_MESSAGE.contains("Identities screen")); } diff --git a/tests/kittest/masternode_tab.rs b/tests/kittest/masternode_tab.rs index 1ce4e67fa..3f1362ab2 100644 --- a/tests/kittest/masternode_tab.rs +++ b/tests/kittest/masternode_tab.rs @@ -9,7 +9,7 @@ use dash_evo_tool::model::qualified_identity::{ IdentityStatus, IdentityType, PrivateKeyTarget, QualifiedIdentity, }; use dash_evo_tool::model::user_role::UserRole; -use dash_evo_tool::ui::{RootScreenType, ScreenLike}; +use dash_evo_tool::ui::{RootScreenType, Screen, ScreenLike}; use dash_sdk::dpp::identity::Identity; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -348,6 +348,45 @@ fn dpns_scheduled_votes_tab_is_clickable() { }); } +/// VOTE-TC-013 β€” Active contests must expose the actionable no-voter-key state +/// directly; the user cannot open Review and cast without a usable voter. +#[test] +fn active_contests_without_a_voting_key_shows_the_load_action() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = mount_app(RootScreenType::RootScreenDPNSActiveContests); + let app_context = harness.state().current_app_context().clone(); + app_context + .insert_name_contests_as_normalized_names(vec!["alice".to_owned()]) + .expect("seed active contest"); + let active_screen = harness + .state_mut() + .main_screens + .get_mut(&RootScreenType::RootScreenDPNSActiveContests) + .expect("active contests screen"); + let Screen::DPNSScreen(active_screen) = active_screen else { + panic!("active contests root must contain a DPNS screen"); + }; + active_screen.refresh(); + harness.run_steps(5); + + assert!( + harness + .query_by_label("None of your loaded nodes has a voting key.") + .is_some(), + "the no-voter-key explanation must render on Active contests" + ); + harness.get_by_label("Load a masternode").click(); + harness.run_steps(3); + assert_eq!( + harness.state().selected_main_screen, + RootScreenType::RootScreenMasternodes + ); + }); +} + /// TC-FR3-01/15, TC-FR7-01, TC-NFR6-01 β€” with nodes loaded the grid renders one /// card per node (not the empty state), each card is a single accessible click /// target labelled `Open {node}`, the status label pairs with its colour, and From dd81c468d4223a291da325c56b442d8e1bfe058b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:16:44 +0000 Subject: [PATCH 27/39] fix: refresh proved DPNS vote state after updates Reload the screen's vote-state snapshot from the confirmed vote cache when a voting operation completes, so a changed vote renders immediately instead of showing the pre-change choice until the next full contest refresh. Co-Authored-By: OpenAI Codex --- src/ui/dpns/dpns_contested_names_screen.rs | 44 ++++++++++++++++++++++ src/ui/state/dpns_vote_state.rs | 23 +++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index 43623aa1c..a90ad87c7 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -2248,6 +2248,12 @@ impl ScreenLike for DPNSScreen { fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { match backend_task_success_result { BackendTaskSuccessResult::DpnsVoteOperationUpdated { operation_id, .. } => { + if let Err(error) = self.vote_state.reload(&self.app_context) { + tracing::warn!( + ?error, + "Could not reload proved DPNS vote state after an operation update" + ); + } let owns_result = self.pending_vote_operation == Some(operation_id) || (self.pending_vote_operation.is_none() && self.vote_overlay.is_some()); if owns_result { @@ -2581,6 +2587,44 @@ mod tests { ); } + #[test] + fn successful_vote_change_reloads_the_new_proved_choice() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let kv = crate::wallet_backend::DetKv::from_store(Arc::new( + crate::wallet_backend::kv_test_support::InMemoryKv::default(), + )); + let ctx = crate::context::test_support::test_app_context_with_kv( + temp_dir.path(), + Arc::new(kv.clone()), + ); + ctx.set_det_kv_override_for_test(kv); + let voter = Identifier::from([1; 32]); + let poll = Identifier::from([2; 32]); + let old_choice = ResourceVoteChoice::TowardsIdentity(Identifier::from([3; 32])); + ctx.cache_confirmed_dpns_vote(voter, poll, old_choice) + .expect("seed old proved choice"); + + let mut screen = DPNSScreen::new(&ctx, DPNSSubscreen::Active); + screen.vote_state = DpnsVoteStateSnapshot::load(&ctx, &[voter], &[poll]) + .expect("load initial proved choice"); + assert_eq!( + screen.vote_state.state(voter, poll), + DpnsCurrentVoteState::Available(Some(old_choice)) + ); + + ctx.cache_confirmed_dpns_vote(voter, poll, ResourceVoteChoice::Lock) + .expect("cache confirmed vote change"); + screen.display_task_result(BackendTaskSuccessResult::DpnsVoteOperationUpdated { + network: ctx.network(), + operation_id: DpnsVoteOperationId::from_bytes([7; 16]), + }); + + assert_eq!( + screen.vote_state.state(voter, poll), + DpnsCurrentVoteState::Available(Some(ResourceVoteChoice::Lock)) + ); + } + #[test] fn active_contest_groups_prioritize_nodes_that_still_need_a_vote() { assert!(matches!( diff --git a/src/ui/state/dpns_vote_state.rs b/src/ui/state/dpns_vote_state.rs index ce0bbfc62..79fd3a79f 100644 --- a/src/ui/state/dpns_vote_state.rs +++ b/src/ui/state/dpns_vote_state.rs @@ -45,6 +45,29 @@ impl DpnsVoteStateSnapshot { Ok(()) } + pub(crate) fn reload(&mut self, app_context: &AppContext) -> Result<(), TaskError> { + let mut polls_by_voter = BTreeMap::>::new(); + for &(voter_id, vote_poll_id) in self.states.keys() { + polls_by_voter + .entry(voter_id) + .or_default() + .push(vote_poll_id); + } + + let mut states = BTreeMap::new(); + for (voter_id, vote_poll_ids) in polls_by_voter { + states.extend( + app_context + .dpns_current_vote_states(voter_id, vote_poll_ids)? + .into_iter() + .map(|(poll_id, state)| ((voter_id, poll_id), state)), + ); + } + self.states = states; + self.loaded = true; + Ok(()) + } + pub fn state(&self, voter_id: Identifier, vote_poll_id: Identifier) -> DpnsCurrentVoteState { if !self.loaded { return DpnsCurrentVoteState::Unavailable; From 8ca7635a53ec1d5fb15894776b8c65a2fee450b3 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:40:11 +0000 Subject: [PATCH 28/39] fix(dpns-voting): quarantine poisoned legacy rows and unblock stranded Unconfirmed votes - Migrate valid legacy operations while parking foreign-network rows outside the active legacy index so scheduled sweeps keep making progress. - Corroborate a proved-different vote across two reconciliation passes before marking it NotApplied and releasing the target lock. Fixes PR review threads CMT-4 and CMT-5 (findings by claude[bot]). Co-Authored-By: OpenAI Codex --- src/backend_task/contested_names/mod.rs | 172 +++++++++++++++++++----- src/context/dpns_vote_operations.rs | 91 +++++++++++-- 2 files changed, 215 insertions(+), 48 deletions(-) diff --git a/src/backend_task/contested_names/mod.rs b/src/backend_task/contested_names/mod.rs index f5cc5d40e..6100848eb 100644 --- a/src/backend_task/contested_names/mod.rs +++ b/src/backend_task/contested_names/mod.rs @@ -100,8 +100,17 @@ fn missing_voter_outcome( fn classify_reconciled_vote( observed: Option, requested: ResourceVoteChoice, + previous_observation: Option, ) -> Option { - (observed == Some(requested)).then_some(DpnsVoteTargetStatus::Confirmed) + match observed { + Some(choice) if choice == requested => Some(DpnsVoteTargetStatus::Confirmed), + // Two consecutive identical mismatches filter one stale/racing read while + // releasing the lock promptly once Platform consistently proves another vote. + Some(choice) if previous_observation == Some(choice) => { + Some(DpnsVoteTargetStatus::NotApplied) + } + Some(_) | None => None, + } } fn persist_terminal_then_legacy_mirror( @@ -748,36 +757,35 @@ impl AppContext { order_ascending: true, }; match ResourceVote::fetch_many(sdk, query).await { - Ok(votes) - if classify_reconciled_vote( - votes - .get(&poll_id) - .and_then(Option::as_ref) - .map(ResourceVoteGettersV0::resource_vote_choice), + Ok(votes) => { + let observed = votes + .get(&poll_id) + .and_then(Option::as_ref) + .map(ResourceVoteGettersV0::resource_vote_choice); + let reconciled_status = classify_reconciled_vote( + observed, outcome.target.requested_choice, - ) == Some(DpnsVoteTargetStatus::Confirmed) => - { - let mirror_error = persist_terminal_then_legacy_mirror( - || { - self.update_dpns_vote_target( - operation_id, - &outcome.target.key, - DpnsVoteTargetStatus::Confirmed, - None, - ) - }, - || { - if matches!(outcome.target.timing, VoteTiming::Scheduled(_)) { - self.mark_vote_executed( - outcome.target.key.voter_id.as_slice(), - outcome.target.contested_name.clone(), - ) - } else { - Ok(()) - } - }, - )?; - if let Some(error) = mirror_error { + outcome.target.current_choice, + ); + let status = reconciled_status.unwrap_or(DpnsVoteTargetStatus::Unconfirmed); + if !self.update_dpns_vote_reconciliation( + operation_id, + &outcome.target.key, + outcome.target.current_choice, + observed, + status, + )? { + continue; + } + if status != DpnsVoteTargetStatus::Confirmed { + continue; + } + if matches!(outcome.target.timing, VoteTiming::Scheduled(_)) + && let Err(error) = self.mark_vote_executed( + outcome.target.key.voter_id.as_slice(), + outcome.target.contested_name.clone(), + ) + { tracing::warn!( ?error, operation_id = %operation_id, @@ -792,7 +800,6 @@ impl AppContext { outcome.target.requested_choice, )?; } - Ok(_) => {} Err(error) => { let error = TaskError::from(error); tracing::warn!( @@ -1121,21 +1128,118 @@ mod tests { #[test] fn exact_reconciliation_confirms_only_the_requested_choice() { assert_eq!( - classify_reconciled_vote(Some(ResourceVoteChoice::Lock), ResourceVoteChoice::Lock), + classify_reconciled_vote( + Some(ResourceVoteChoice::Lock), + ResourceVoteChoice::Lock, + None, + ), Some(DpnsVoteTargetStatus::Confirmed) ); assert_eq!( - classify_reconciled_vote(Some(ResourceVoteChoice::Abstain), ResourceVoteChoice::Lock), + classify_reconciled_vote( + Some(ResourceVoteChoice::Abstain), + ResourceVoteChoice::Lock, + None, + ), None, "a mismatched row may predate the submitted transition and remains ambiguous" ); assert_eq!( - classify_reconciled_vote(None, ResourceVoteChoice::Lock), + classify_reconciled_vote( + None, + ResourceVoteChoice::Lock, + Some(ResourceVoteChoice::Abstain), + ), None, "an absent exact row remains ambiguous and must not release its lock" ); } + #[test] + fn proved_different_reconciliation_releases_target_lock_after_corroboration() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let context = crate::context::test_support::test_app_context(temp_dir.path()); + let kv = crate::wallet_backend::DetKv::from_store(Arc::new( + crate::wallet_backend::kv_test_support::InMemoryKv::default(), + )); + context.set_det_kv_override_for_test(kv); + let mut operation = DpnsVoteOperation::new(vec![DpnsVoteTarget { + key: DpnsVoteTargetKey { + network: Network::Testnet, + voter_id: Identifier::from([1; 32]), + vote_poll_id: Identifier::from([2; 32]), + }, + voter_alias: None, + contested_name: "dominguez".to_owned(), + requested_choice: ResourceVoteChoice::Lock, + current_choice: Some(ResourceVoteChoice::Abstain), + timing: VoteTiming::Now, + }]); + operation.targets[0].status = DpnsVoteTargetStatus::Confirming; + let operation_id = operation.id; + let key = operation.targets[0].target.key.clone(); + context + .insert_dpns_vote_operation(&mut operation, None) + .expect("persist confirming operation"); + context + .update_dpns_vote_target( + operation_id, + &key, + DpnsVoteTargetStatus::Unconfirmed, + Some(DpnsVoteFailure::ResultUnconfirmed), + ) + .expect("persist unconfirmed operation"); + assert_eq!( + context + .dpns_vote_operation(operation_id) + .unwrap() + .unwrap() + .targets[0] + .target + .current_choice, + None, + "the pre-broadcast choice must not count as reconciliation corroboration" + ); + + let observed = Some(ResourceVoteChoice::Abstain); + let first_status = classify_reconciled_vote(observed, ResourceVoteChoice::Lock, None) + .unwrap_or(DpnsVoteTargetStatus::Unconfirmed); + assert!( + context + .update_dpns_vote_reconciliation(operation_id, &key, None, observed, first_status,) + .expect("persist first observation") + ); + assert_eq!(first_status, DpnsVoteTargetStatus::Unconfirmed); + + let persisted = context.dpns_vote_operation(operation_id).unwrap().unwrap(); + let second_status = classify_reconciled_vote( + observed, + ResourceVoteChoice::Lock, + persisted.targets[0].target.current_choice, + ) + .expect("the repeated different choice must be proved not applied"); + assert!( + context + .update_dpns_vote_reconciliation( + operation_id, + &key, + persisted.targets[0].target.current_choice, + observed, + second_status, + ) + .expect("persist corroborated observation") + ); + + let target = &context + .dpns_vote_operation(operation_id) + .unwrap() + .unwrap() + .targets[0]; + assert_eq!(target.status, DpnsVoteTargetStatus::NotApplied); + assert!(!target.status.holds_lock()); + assert_eq!(context.dpns_vote_target_status(&key).unwrap(), None); + } + #[test] fn terminal_journal_write_precedes_best_effort_legacy_mirror() { let events = RefCell::new(Vec::new()); diff --git a/src/context/dpns_vote_operations.rs b/src/context/dpns_vote_operations.rs index 8cd14f7fc..efa0398c0 100644 --- a/src/context/dpns_vote_operations.rs +++ b/src/context/dpns_vote_operations.rs @@ -9,6 +9,7 @@ use crate::model::dpns_voting::{ }; use crate::wallet_backend::{DetKv, DetScope, KvAdapterError}; use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; @@ -106,7 +107,7 @@ fn migrate_legacy_operations(kv: &DetKv, network: Network) -> Result<(), TaskErr let mut qualified_ids = load_operation_ids(kv, network)?; let mut qualified_changed = false; - let mut retained_legacy_ids = Vec::new(); + let retained_legacy_ids = Vec::<[u8; 16]>::new(); for bytes in &legacy_ids { let bytes = *bytes; let id = DpnsVoteOperationId::from_bytes(bytes); @@ -119,10 +120,8 @@ fn migrate_legacy_operations(kv: &DetKv, network: Network) -> Result<(), TaskErr .ok_or(TaskError::DpnsVoteOperationRecordMissing)?; match operation_matches_network(&operation, network) { Ok(false) => continue, - Err(error) => { - retained_legacy_ids.push(bytes); - return Err(error); - } + Err(TaskError::DpnsVoteJournalNetworkMismatch) => continue, + Err(error) => return Err(error), Ok(true) => {} } kv.put( @@ -505,6 +504,7 @@ fn recover_interrupted_target_statuses(operation: &mut DpnsVoteOperation) -> boo } DpnsVoteTargetStatus::Confirming => { outcome.status = DpnsVoteTargetStatus::Unconfirmed; + outcome.target.current_choice = None; outcome.failure = Some(DpnsVoteFailure::ResultUnconfirmed); changed = true; } @@ -708,11 +708,52 @@ impl AppContext { .find(|outcome| outcome.target.key == *key) { outcome.status = status; + if status == DpnsVoteTargetStatus::Unconfirmed { + outcome.target.current_choice = None; + } outcome.failure = failure; } persist_operation(&kv, self.network, &operation) } + /// Atomically persist one corroborated reconciliation observation. + pub(crate) fn update_dpns_vote_reconciliation( + &self, + operation_id: DpnsVoteOperationId, + key: &DpnsVoteTargetKey, + expected_current_choice: Option, + observed_choice: Option, + status: DpnsVoteTargetStatus, + ) -> Result { + let _guard = self + .dpns_vote_operation_guard + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let kv = self.det_kv()?; + let Some(mut operation): Option = kv + .get(DetScope::Global, &operation_key(self.network, operation_id)) + .map_err(unreadable_operation_err)? + else { + return Ok(false); + }; + let Some(outcome) = operation.targets.iter_mut().find(|outcome| { + outcome.target.key == *key + && outcome.status == DpnsVoteTargetStatus::Unconfirmed + && outcome.target.current_choice == expected_current_choice + }) else { + return Ok(false); + }; + if outcome.target.current_choice == observed_choice && outcome.status == status { + return Ok(true); + } + outcome.target.current_choice = observed_choice; + outcome.status = status; + outcome.failure = (status == DpnsVoteTargetStatus::Unconfirmed) + .then_some(DpnsVoteFailure::ResultUnconfirmed); + persist_operation(&kv, self.network, &operation)?; + Ok(true) + } + /// Atomically claim a queued target before any network or nonce work. pub(crate) fn claim_dpns_vote_target( &self, @@ -1278,26 +1319,48 @@ mod tests { } #[test] - fn mismatched_unresolved_legacy_record_fails_closed() { + fn legacy_migration_quarantines_cross_network_row_without_blocking_siblings() { let kv = kv(); - let operation = operation(DpnsVoteTargetStatus::Unconfirmed); + let mut poisoned = operation(DpnsVoteTargetStatus::Unconfirmed); + poisoned.targets[0].target.key.network = Network::Mainnet; + let mut valid = operation(DpnsVoteTargetStatus::Scheduled); + valid.targets[0].target.key.vote_poll_id = Identifier::from([3; 32]); kv.put( DetScope::Global, - &legacy_operation_key(operation.id), - &operation, + &legacy_operation_key(poisoned.id), + &poisoned, ) .unwrap(); + kv.put(DetScope::Global, &legacy_operation_key(valid.id), &valid) + .unwrap(); kv.put( DetScope::Global, LEGACY_OPERATION_INDEX_KEY, - &vec![operation.id.to_bytes()], + &vec![poisoned.id.to_bytes(), valid.id.to_bytes()], ) .unwrap(); - assert!(matches!( - load_operations(&kv, Network::Mainnet), - Err(TaskError::DpnsVoteJournalNetworkMismatch) - )); + assert_eq!( + load_operations(&kv, Network::Testnet).unwrap(), + vec![valid.clone()] + ); + assert_eq!( + load_operations(&kv, Network::Testnet).unwrap(), + vec![valid], + "a quarantined row must not block or duplicate valid siblings on later sweeps" + ); + assert_eq!( + kv.get::>(DetScope::Global, LEGACY_OPERATION_INDEX_KEY) + .unwrap() + .unwrap_or_default(), + Vec::<[u8; 16]>::new() + ); + assert!( + kv.get::(DetScope::Global, &legacy_operation_key(poisoned.id)) + .unwrap() + .is_some(), + "quarantine must park the foreign record rather than delete it" + ); } #[test] From 76c0ed71f3cf86b476f50702d6b7d0aedd22373b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 22 Jul 2026 07:53:09 +0000 Subject: [PATCH 29/39] fix(dpns-voting): retain quarantined legacy rows instead of dropping them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit migrate_legacy_operations previously overwrote the legacy index with an always-empty retained list, silently discarding foreign-network rows instead of keeping them for a subsequent correct-network migration pass. πŸ€– Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude GPT-5 Codex --- src/context/dpns_vote_operations.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/context/dpns_vote_operations.rs b/src/context/dpns_vote_operations.rs index efa0398c0..2b6777476 100644 --- a/src/context/dpns_vote_operations.rs +++ b/src/context/dpns_vote_operations.rs @@ -107,7 +107,7 @@ fn migrate_legacy_operations(kv: &DetKv, network: Network) -> Result<(), TaskErr let mut qualified_ids = load_operation_ids(kv, network)?; let mut qualified_changed = false; - let retained_legacy_ids = Vec::<[u8; 16]>::new(); + let mut retained_legacy_ids = Vec::<[u8; 16]>::new(); for bytes in &legacy_ids { let bytes = *bytes; let id = DpnsVoteOperationId::from_bytes(bytes); @@ -120,7 +120,10 @@ fn migrate_legacy_operations(kv: &DetKv, network: Network) -> Result<(), TaskErr .ok_or(TaskError::DpnsVoteOperationRecordMissing)?; match operation_matches_network(&operation, network) { Ok(false) => continue, - Err(TaskError::DpnsVoteJournalNetworkMismatch) => continue, + Err(TaskError::DpnsVoteJournalNetworkMismatch) => { + retained_legacy_ids.push(bytes); + continue; + } Err(error) => return Err(error), Ok(true) => {} } @@ -1353,7 +1356,8 @@ mod tests { kv.get::>(DetScope::Global, LEGACY_OPERATION_INDEX_KEY) .unwrap() .unwrap_or_default(), - Vec::<[u8; 16]>::new() + vec![poisoned.id.to_bytes()], + "a quarantined row must remain indexed for a correct-network migration pass" ); assert!( kv.get::(DetScope::Global, &legacy_operation_key(poisoned.id)) @@ -1361,6 +1365,18 @@ mod tests { .is_some(), "quarantine must park the foreign record rather than delete it" ); + + assert_eq!( + load_operations(&kv, Network::Mainnet).unwrap(), + vec![poisoned] + ); + assert_eq!( + kv.get::>(DetScope::Global, LEGACY_OPERATION_INDEX_KEY) + .unwrap() + .unwrap_or_default(), + Vec::<[u8; 16]>::new(), + "the legacy index must drop a quarantined row after correct-network migration" + ); } #[test] From 8abca999c21427172ecea234ab18ea659dc8132a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:15:47 +0000 Subject: [PATCH 30/39] perf(dpns-voting): batch masternode-card current-vote reads into one storage read per node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `masternode_contest_summary` called the singular `dpns_current_vote_state` once per open contest, each re-reading the same per-node proved snapshot β€” O(open-contests) KV reads per node on every Masternodes-list refresh, the exact pattern VOTE-NFR-007 / VOTE-TC-005 forbid and that the batched `dpns_current_vote_states` primitive already exists to avoid. Resolve every open contest's vote-poll id up front, then read all states with a single `dpns_current_vote_states` call. A read failure degrades each contest to `Unavailable`, preserving the prior per-contest fallback semantics. Also collapses the duplicated `is_open_for_voter` filter into one pass. Adds a regression test that drives the masternode-card consumer (not just the primitive) and asserts exactly one snapshot read for five open contests. Co-Authored-By: Claude Opus --- src/context/contested_names_db.rs | 114 ++++++++++++++++++++++++++++-- 1 file changed, 108 insertions(+), 6 deletions(-) diff --git a/src/context/contested_names_db.rs b/src/context/contested_names_db.rs index bd9256f44..715b87dd4 100644 --- a/src/context/contested_names_db.rs +++ b/src/context/contested_names_db.rs @@ -262,17 +262,33 @@ impl AppContext { }; let contests = self.ongoing_contested_names()?; - let open_contest_count = contests - .iter() - .filter(|contest| contest.is_open_for_voter(&voter_id)) - .count(); - let states = contests + let open_polls: Vec> = contests .iter() .filter(|contest| contest.is_open_for_voter(&voter_id)) .map(|contest| { self.dpns_vote_poll_id(&contest.normalized_contested_name) .ok() - .and_then(|poll_id| self.dpns_current_vote_state(voter_id, poll_id).ok()) + }) + .collect(); + let open_contest_count = open_polls.len(); + + // One storage read per node for every open contest's proved state + // (VOTE-NFR-007), instead of one read per contest. A read failure + // degrades each contest to `Unavailable`, matching the prior + // per-contest fallback. + let poll_states = { + let poll_ids: Vec = open_polls.iter().flatten().copied().collect(); + if poll_ids.is_empty() { + BTreeMap::new() + } else { + self.dpns_current_vote_states(voter_id, poll_ids) + .unwrap_or_default() + } + }; + let states = open_polls + .iter() + .map(|poll| { + poll.and_then(|poll_id| poll_states.get(&poll_id).copied()) .unwrap_or(DpnsCurrentVoteState::Unavailable) }) .collect::>(); @@ -475,7 +491,9 @@ mod tests { use super::*; use crate::wallet_backend::DetKv; use crate::wallet_backend::kv_test_support::InMemoryKv; + use platform_wallet_storage::{KvError, KvStore, ObjectId}; use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; fn empty_kv() -> DetKv { DetKv::from_store(Arc::new(InMemoryKv::default())) @@ -712,4 +730,88 @@ mod tests { (false, false) ); } + + /// Counts reads of the per-node current-vote snapshot key so a test can + /// prove how many snapshot loads a caller performs. The `v2:` prefix is + /// the active snapshot key (`current_votes_key`), one per node. + #[derive(Default)] + struct CurrentVotesReadCounter { + inner: InMemoryKv, + snapshot_reads: AtomicUsize, + } + + impl KvStore for CurrentVotesReadCounter { + fn get(&self, scope: &ObjectId, key: &str) -> Result>, KvError> { + if key.starts_with("det:dpns_current_votes:v2:") { + self.snapshot_reads.fetch_add(1, Ordering::Relaxed); + } + self.inner.get(scope, key) + } + + fn put(&self, scope: &ObjectId, key: &str, value: &[u8]) -> Result<(), KvError> { + self.inner.put(scope, key, value) + } + + fn delete(&self, scope: &ObjectId, key: &str) -> Result<(), KvError> { + self.inner.delete(scope, key) + } + + fn list_keys( + &self, + scope: &ObjectId, + prefix: Option<&str>, + ) -> Result, KvError> { + self.inner.list_keys(scope, prefix) + } + } + + /// VOTE-NFR-007 / VOTE-TC-005: the masternode-card summary reads a node's + /// proved current-vote snapshot once, not once per open contest. + #[test] + fn masternode_summary_reads_current_votes_once_per_node() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let store = Arc::new(CurrentVotesReadCounter::default()); + let kv = DetKv::from_store(store.clone()); + let context = crate::context::test_support::test_app_context_with_kv( + temp_dir.path(), + Arc::new(kv.clone()), + ); + context.set_det_kv_override_for_test(kv.clone()); + + let voter = Identifier::from([1; 32]); + + // Seed several open (Ongoing) contests: a contestant dated in the deep + // past pushes each contest past the joinable half-window, and a missing + // `end_time` keeps it in the ongoing set. + for name in ["alice", "bob", "carol", "dave", "erin"] { + let stored = StoredContestedName { + normalized_contested_name: name.to_string(), + contestants: vec![contestant(1, Some(1))], + ..Default::default() + }; + kv.put(DetScope::Global, &contested_name_key(name), &stored) + .unwrap(); + } + + // Seed a proved snapshot so each summary read decodes a real record. + context + .cache_confirmed_dpns_vote( + voter, + Identifier::from([9; 32]), + dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice::Abstain, + ) + .expect("seed current-vote snapshot"); + + let before = store.snapshot_reads.load(Ordering::Relaxed); + let summary = context + .masternode_contest_summary(Some(voter)) + .expect("summary"); + let reads = store.snapshot_reads.load(Ordering::Relaxed) - before; + + assert_eq!(summary.open_contest_count, 5); + assert_eq!( + reads, 1, + "expected one snapshot read per node, got {reads} for 5 open contests" + ); + } } From f60b7ae21efcb6728674a2cefb0ad011bf68136f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:42:18 +0000 Subject: [PATCH 31/39] fix(dpns): surface masternode vote limit Map Platform's typed vote-limit rejection to a dedicated user-facing TaskError while preserving the SDK source for diagnostics. Co-Authored-By: Codex GPT-5.6 --- src/backend_task/error.rs | 80 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 72dd2fa21..3fab2ce33 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -1967,6 +1967,17 @@ pub enum TaskError { )] VotePollNotFound { name: String }, + /// The masternode has used every vote allowed for a contested name. + #[error( + "This node has already cast the maximum {max_times_allowed} votes allowed for this contest and can't vote again. Choose another contest to vote on." + )] + MasternodeVoteLimitReached { + times_already_voted: u16, + max_times_allowed: u16, + #[source] + source_error: Box, + }, + /// The identity does not have an authentication key required to sign documents. #[error( "This identity does not have a key for signing documents. Please add an authentication key." @@ -2900,6 +2911,17 @@ impl From for TaskError { } })) } + ConsensusError::StateError(StateError::MasternodeVotedTooManyTimesError(e)) => { + let (times_already_voted, max_times_allowed) = + (e.times_already_voted(), e.max_times_allowed()); + Some(Box::new(move |source_error| { + TaskError::MasternodeVoteLimitReached { + times_already_voted, + max_times_allowed, + source_error, + } + })) + } ConsensusError::BasicError( BasicError::InvalidInstantAssetLockProofSignatureError(_), ) => Some(Box::new(|source_error| { @@ -3168,6 +3190,7 @@ mod tests { use dash_sdk::dpp::consensus::state::identity::duplicated_identity_public_key_state_error::DuplicatedIdentityPublicKeyStateError; use dash_sdk::dpp::consensus::state::identity::IdentityInsufficientBalanceError; use dash_sdk::dpp::consensus::state::identity::identity_public_key_already_exists_for_unique_contract_bounds_error::IdentityPublicKeyAlreadyExistsForUniqueContractBoundsError; + use dash_sdk::dpp::consensus::state::voting::masternode_voted_too_many_times::MasternodeVotedTooManyTimesError; use dash_sdk::dpp::identity::Purpose; use dash_sdk::platform::Identifier; @@ -4082,6 +4105,63 @@ mod tests { ); } + #[test] + fn from_sdk_error_vote_limit_via_consensus_is_specific() { + let consensus = ConsensusError::from(MasternodeVotedTooManyTimesError::new( + Identifier::random(), + 6, + 5, + )); + let err = TaskError::from(SdkError::from(consensus)); + + assert!( + matches!( + &err, + TaskError::MasternodeVoteLimitReached { + times_already_voted: 6, + max_times_allowed: 5, + .. + } + ), + "Expected MasternodeVoteLimitReached, got: {err:?}" + ); + assert_eq!( + err.to_string(), + "This node has already cast the maximum 5 votes allowed for this contest and can't vote again. Choose another contest to vote on." + ); + } + + #[test] + fn from_sdk_error_vote_limit_via_broadcast_is_specific() { + let consensus = ConsensusError::from(MasternodeVotedTooManyTimesError::new( + Identifier::random(), + 6, + 5, + )); + let broadcast_err = dash_sdk::error::StateTransitionBroadcastError { + code: 40303, + message: "vote limit reached".to_string(), + cause: Some(consensus), + }; + let err = TaskError::from(SdkError::StateTransitionBroadcastError(broadcast_err)); + + assert!( + matches!( + &err, + TaskError::MasternodeVoteLimitReached { + times_already_voted: 6, + max_times_allowed: 5, + .. + } + ), + "Expected MasternodeVoteLimitReached, got: {err:?}" + ); + assert_eq!( + err.to_string(), + "This node has already cast the maximum 5 votes allowed for this contest and can't vote again. Choose another contest to vote on." + ); + } + #[test] fn connection_failed_display_includes_url() { let socket_err = dashcore_rpc::jsonrpc::simple_http::Error::SocketError( From 41196108e4e9abd89f052a129116a1cfee8d1c82 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:47:02 +0000 Subject: [PATCH 32/39] fix(dpns): rename misleading is_open_for_voter to reflect contest-level check Rename the voter-scoped-looking helper to is_votable, remove its unused voter parameter, update the sole caller, and replace voter-map-dependent tests with exhaustive contest-state coverage. Co-Authored-By: Codex GPT-5 --- src/context/contested_names_db.rs | 2 +- src/model/contested_name.rs | 52 +++++++------------------------ 2 files changed, 13 insertions(+), 41 deletions(-) diff --git a/src/context/contested_names_db.rs b/src/context/contested_names_db.rs index d4af4418b..fe82b8c7b 100644 --- a/src/context/contested_names_db.rs +++ b/src/context/contested_names_db.rs @@ -356,7 +356,7 @@ impl AppContext { let contests = self.ongoing_contested_names()?; let open_polls: Vec> = contests .iter() - .filter(|contest| contest.is_open_for_voter(&voter_id)) + .filter(|contest| contest.is_votable()) .map(|contest| { self.dpns_vote_poll_id(&contest.normalized_contested_name) .ok() diff --git a/src/model/contested_name.rs b/src/model/contested_name.rs index 102d72769..7c3e2d143 100644 --- a/src/model/contested_name.rs +++ b/src/model/contested_name.rs @@ -39,8 +39,8 @@ pub struct ContestedName { } impl ContestedName { - /// Whether the contest still accepts this node's initial vote or a change. - pub fn is_open_for_voter(&self, _voter_id: &Identifier) -> bool { + /// Whether the contest's state still accepts votes. + pub fn is_votable(&self) -> bool { self.state.state_is_votable() } @@ -247,7 +247,6 @@ pub struct Contestant { #[cfg(test)] mod tests { use super::*; - use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; fn contest(state: ContestState) -> ContestedName { ContestedName { @@ -285,43 +284,16 @@ mod tests { } #[test] - fn open_for_voter_when_votable_and_not_yet_voted() { - let voter = Identifier::from([7u8; 32]); - assert!(contest(ContestState::Ongoing).is_open_for_voter(&voter)); - assert!(contest(ContestState::Joinable).is_open_for_voter(&voter)); - } - - #[test] - fn not_open_when_state_not_votable() { - let voter = Identifier::from([7u8; 32]); - assert!(!contest(ContestState::Locked).is_open_for_voter(&voter)); - assert!(!contest(ContestState::Unknown).is_open_for_voter(&voter)); - assert!( - !contest(ContestState::WonBy(Identifier::from([9u8; 32]))).is_open_for_voter(&voter) - ); - } - - #[test] - fn existing_vote_remains_actionable_while_contest_is_votable() { - let voter = Identifier::from([7u8; 32]); - let mut c = contest(ContestState::Ongoing); - c.my_votes.insert( - (voter, PrivateKeyTarget::PrivateKeyOnVoterIdentity, 0), - ResourceVoteChoice::Abstain, - ); - assert!(c.is_open_for_voter(&voter)); - } - - #[test] - fn open_when_a_different_voter_already_voted() { - let voter = Identifier::from([7u8; 32]); - let other = Identifier::from([8u8; 32]); - let mut c = contest(ContestState::Ongoing); - c.my_votes.insert( - (other, PrivateKeyTarget::PrivateKeyOnVoterIdentity, 0), - ResourceVoteChoice::Abstain, - ); - assert!(c.is_open_for_voter(&voter)); + fn contest_is_votable_only_in_open_states() { + for (state, expected) in [ + (ContestState::Unknown, false), + (ContestState::Joinable, true), + (ContestState::Ongoing, true), + (ContestState::WonBy(Identifier::from([9u8; 32])), false), + (ContestState::Locked, false), + ] { + assert_eq!(contest(state).is_votable(), expected); + } } // ---------------------------------------------------------------- From 9b45fba6c5ebc0110b39508b04b92e0ea3b5951b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:51:43 +0000 Subject: [PATCH 33/39] fix(dpns): route scheduled-vote sweep completion to hidden Active-contests root dpns_result_needs_hidden_active_contests_route() was missing ScheduledVoteSweepCompleted from its matches!, so the general hidden-route dispatcher (route_dpns_vote_result_to_hidden_active_contests, called unconditionally for every Success result) never refreshed a hidden RootScreenDPNSActiveContests after a scheduled-vote sweep completed. A user who navigated away or had a screen pushed on the stack while a sweep was in flight could return to stale, disabled voting controls after a plain PopScreen. Also narrows the sweep-completion branch's direct visible_screen_mut() refresh so it only fires when Active-contests is actually the visible screen (no screen-stack, no other root selected) instead of unconditionally refreshing whatever screen happens to be visible. Adds scheduled_vote_sweep_completion_routes_when_active_contests_is_hidden covering both the hidden and visible cases. Co-Authored-By: Codex Sol --- src/app.rs | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index 2a7275d1e..f1e7ca47b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -384,6 +384,7 @@ fn dpns_result_needs_hidden_active_contests_route( result, BackendTaskSuccessResult::DpnsVoteOperationUpdated { .. } | BackendTaskSuccessResult::RefreshedDpnsContests + | BackendTaskSuccessResult::ScheduledVoteSweepCompleted { .. } ) && (selected != RootScreenType::RootScreenDPNSActiveContests || !screen_stack_is_empty) } @@ -2834,7 +2835,12 @@ impl App for AppState { ) { self.scheduled_vote_recovery_last_attempt.remove(&network); } - self.visible_screen_mut().refresh(); + if self.selected_main_screen + == RootScreenType::RootScreenDPNSActiveContests + && self.screen_stack.is_empty() + { + self.visible_screen_mut().refresh(); + } } BackendTaskSuccessResult::NetworkContextCreated { network, @@ -3814,6 +3820,30 @@ mod dpns_result_routing_tests { &BackendTaskSuccessResult::RefreshedDpnsContests, )); } + + #[test] + fn scheduled_vote_sweep_completion_routes_when_active_contests_is_hidden() { + let result = BackendTaskSuccessResult::ScheduledVoteSweepCompleted { + network: Network::Testnet, + preserve_eligibility_since_ms: None, + }; + + assert!(dpns_result_needs_hidden_active_contests_route( + RootScreenType::RootScreenWalletsBalances, + true, + &result, + )); + assert!(dpns_result_needs_hidden_active_contests_route( + RootScreenType::RootScreenDPNSActiveContests, + false, + &result, + )); + assert!(!dpns_result_needs_hidden_active_contests_route( + RootScreenType::RootScreenDPNSActiveContests, + true, + &result, + )); + } } #[cfg(test)] From 7de526b08cf6a00e68c8412123c8f45673e875b1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:22:21 +0000 Subject: [PATCH 34/39] fix(dpns): guard scheduled-vote journal cleanup and removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the operation journal authoritative over the legacy scheduled-vote KV mirror for removal, Clear All, and terminal-operation pruning: - remove_scheduled_dpns_vote(): guarded row removal that changes a Scheduled target to Cancelled and persists before touching the mirror, refuses to touch the mirror while a target is Queued/Submitting/ Confirming/Unconfirmed (returns DpnsScheduledVoteAlreadyStarted), and only allows mirror-only deletion when no journal operation still holds the target lock. - clear_all_scheduled_dpns_votes(): guarded Clear All that cancels Scheduled targets, retains mirror rows for anything still in flight, and returns a typed per-target DpnsScheduledVoteClearOutcome instead of unconditionally wiping every mirror row. - insert_dpns_vote_operation_with_scheduled_mirror(): serializes the journal write and the best-effort compatibility-mirror write under the same guard, closing the race where a concurrent Clear All could cancel and prune a schedule before its mirror row ever landed. - prune_terminal_dpns_vote_operations(): now takes no removed-set parameter and derives eligibility from a durable raw-key enumeration of surviving mirror rows instead of an in-memory BTreeSet, so a crash or I/O failure partway through cleanup no longer permanently strands a terminal operation as unprunable. The predicate also no longer treats "not scheduled" as automatic pruning grounds, so immediate-only operations backing Recent voting activity survive a scheduled-only clear. - clear_executed_scheduled_votes(): rewritten around the durable raw-key helper, propagates row-read errors instead of silently treating them as absent, and calls the new no-arg pruning method. - operation_for_scheduled_vote(): prefers the lock-holding operation, falling back to the newest terminal one, instead of first-match. New model types (src/model/dpns_voting.rs): DpnsScheduledVoteKey, DpnsScheduledVoteClearDisposition, DpnsScheduledVoteClearOutcome. New BackendTaskSuccessResult::ScheduledVotesCleared variant. UI wiring (journal-first Scheduled Votes table, button dispatch, Active contests render cache) is a separate follow-up commit β€” this pass is scoped to the backend_task/context layer only, per the DET module placement policy. Co-Authored-By: Codex Sol --- src/backend_task/contested_names/mod.rs | 126 +++-- src/backend_task/mod.rs | 3 +- src/context/dpns_vote_operations.rs | 591 ++++++++++++++++++++---- src/context/identity_db.rs | 318 +++++++++++-- src/model/dpns_voting.rs | 23 + src/wallet_backend/kv_test_support.rs | 44 ++ 6 files changed, 951 insertions(+), 154 deletions(-) diff --git a/src/backend_task/contested_names/mod.rs b/src/backend_task/contested_names/mod.rs index 6100848eb..fd5c9fec5 100644 --- a/src/backend_task/contested_names/mod.rs +++ b/src/backend_task/contested_names/mod.rs @@ -219,21 +219,20 @@ impl AppContext { wrap_scheduled_vote_sweep_result(self.network, result) } ContestedResourceTask::ClearAllScheduledVotes => { - self.cancel_all_scheduled_dpns_vote_targets()?; - if let Err(error) = self.clear_all_scheduled_votes() { - tracing::warn!( - ?error, - "Scheduled DPNS votes were cancelled but the legacy mirror could not be cleared" - ); - } - Ok(BackendTaskSuccessResult::Refresh) + let outcomes = self.clear_all_scheduled_dpns_votes()?; + Ok(BackendTaskSuccessResult::ScheduledVotesCleared(outcomes)) } ContestedResourceTask::ClearExecutedScheduledVotes => { self.clear_executed_scheduled_votes()?; Ok(BackendTaskSuccessResult::Refresh) } ContestedResourceTask::DeleteScheduledVote(voter_id, contested_name) => { - self.delete_scheduled_vote(voter_id.as_slice(), &contested_name)?; + let key = DpnsVoteTargetKey { + network: self.network, + voter_id, + vote_poll_id: self.dpns_vote_poll_id(&contested_name)?, + }; + self.remove_scheduled_dpns_vote(None, &key, &contested_name)?; Ok(BackendTaskSuccessResult::Refresh) } ContestedResourceTask::CancelScheduledDpnsVote { @@ -241,18 +240,7 @@ impl AppContext { key, contested_name, } => { - self.cancel_scheduled_dpns_vote_target(operation_id, &key)?; - if let Err(error) = - self.delete_scheduled_vote(key.voter_id.as_slice(), &contested_name) - { - tracing::warn!( - ?error, - operation_id = %operation_id, - voter_id = %key.voter_id, - contested_name, - "Scheduled DPNS vote was cancelled but the legacy mirror could not be cleared" - ); - } + self.cancel_scheduled_dpns_vote_target(operation_id, &key, &contested_name)?; Ok(BackendTaskSuccessResult::Refresh) } } @@ -324,16 +312,16 @@ impl AppContext { scheduled_vote: &ScheduledDPNSVote, voter: &QualifiedIdentity, ) -> Result { - if let Some(operation) = self.dpns_vote_operations()?.into_iter().find(|operation| { - operation.targets.iter().any(|outcome| { - outcome.target.key.voter_id == scheduled_vote.voter_id - && outcome.target.contested_name == scheduled_vote.contested_name - }) - }) { + let key = DpnsVoteTargetKey { + network: self.network, + voter_id: scheduled_vote.voter_id, + vote_poll_id: self.dpns_vote_poll_id(&scheduled_vote.contested_name)?, + }; + let operations = self.dpns_vote_operations()?; + if let Some(operation) = preferred_operation_for_scheduled_key(&operations, &key)?.cloned() + { for outcome in &operation.targets { - if outcome.target.key.voter_id != scheduled_vote.voter_id - || outcome.target.contested_name != scheduled_vote.contested_name - { + if outcome.target.key != key { continue; } match outcome.status { @@ -466,10 +454,11 @@ impl AppContext { VoteTiming::Now => None, }) .collect::>(); - self.insert_dpns_vote_operation(&mut operation, replacing_scheduled_key.as_ref())?; - if !scheduled_votes.is_empty() - && let Err(error) = self.insert_scheduled_votes(&scheduled_votes) - { + if let Some(error) = self.insert_dpns_vote_operation_with_scheduled_mirror( + &mut operation, + replacing_scheduled_key.as_ref(), + &scheduled_votes, + )? { // The journal is authoritative. The legacy table is a // compatibility mirror, so its failure cannot turn a durable // schedule into a reported failure that invites a duplicate. @@ -949,6 +938,31 @@ impl AppContext { } } +fn preferred_operation_for_scheduled_key<'a>( + operations: &'a [DpnsVoteOperation], + key: &DpnsVoteTargetKey, +) -> Result, TaskError> { + let mut lock_holders = operations.iter().filter(|operation| { + operation + .outcome(key) + .is_some_and(|outcome| outcome.status.holds_lock()) + }); + let lock_holder = lock_holders.next(); + if lock_holders.next().is_some() { + return Err(TaskError::DpnsVoteTargetBusy); + } + Ok(lock_holder.or_else(|| { + operations + .iter() + .filter(|operation| { + operation + .outcome(key) + .is_some_and(|outcome| !outcome.status.holds_lock()) + }) + .max_by_key(|operation| (operation.created_at, operation.id)) + })) +} + fn scheduled_vote_is_due( scheduled_at_ms: u64, executed_successfully: bool, @@ -1064,6 +1078,50 @@ mod tests { assert_eq!(persisted.targets[0].status, DpnsVoteTargetStatus::Queued); } + #[test] + fn scheduled_lookup_prefers_lock_holder_then_newest_terminal() { + let key = DpnsVoteTargetKey { + network: Network::Testnet, + voter_id: Identifier::from([1; 32]), + vote_poll_id: Identifier::from([2; 32]), + }; + let target = DpnsVoteTarget { + key: key.clone(), + voter_alias: None, + contested_name: "dominguez".to_owned(), + requested_choice: ResourceVoteChoice::Lock, + current_choice: None, + timing: VoteTiming::Scheduled(42), + }; + let mut older = DpnsVoteOperation::new(vec![target.clone()]); + older.created_at = 1; + older.targets[0].status = DpnsVoteTargetStatus::Confirmed; + let mut newer = DpnsVoteOperation::new(vec![target.clone()]); + newer.created_at = 2; + newer.targets[0].status = DpnsVoteTargetStatus::Cancelled; + let mut lock_holder = DpnsVoteOperation::new(vec![target]); + lock_holder.created_at = 0; + lock_holder.targets[0].status = DpnsVoteTargetStatus::Scheduled; + let operations = vec![older.clone(), lock_holder.clone(), newer.clone()]; + + assert_eq!( + preferred_operation_for_scheduled_key(&operations, &key) + .unwrap() + .unwrap() + .id, + lock_holder.id + ); + + let terminal_operations = vec![older, newer.clone()]; + assert_eq!( + preferred_operation_for_scheduled_key(&terminal_operations, &key) + .unwrap() + .unwrap() + .id, + newer.id + ); + } + #[test] fn vote_diagnostic_contextualizes_exhausted_dapi_addresses() { let temp_dir = tempfile::tempdir().expect("tempdir"); diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index 6a0adb1d5..bfcabb7d8 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -12,7 +12,7 @@ use crate::backend_task::wallet::WalletTask; use crate::context::AppContext; use crate::context::identity_load_registry::IdentityLoadToken; use crate::model::masternode_input::decode_identity_id; -use crate::model::dpns_voting::DpnsVoteOperationId; +use crate::model::dpns_voting::{DpnsScheduledVoteClearOutcome, DpnsVoteOperationId}; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -511,6 +511,7 @@ pub enum BackendTaskSuccessResult { network: Network, operation_id: DpnsVoteOperationId, }, + ScheduledVotesCleared(Vec), /// The scheduled votes that the `CastDueScheduledVotes` sweep is about to /// cast this cycle, so the Scheduled Votes screen can mark them in progress. ScheduledVotesInProgress(Vec), diff --git a/src/context/dpns_vote_operations.rs b/src/context/dpns_vote_operations.rs index 2b6777476..da3c05f13 100644 --- a/src/context/dpns_vote_operations.rs +++ b/src/context/dpns_vote_operations.rs @@ -1,9 +1,14 @@ //! Durable DPNS vote operation journal and exact-target lock registry. use super::AppContext; +use crate::backend_task::contested_names::ScheduledDPNSVote; use crate::backend_task::error::TaskError; +use crate::context::identity_db::{ + delete_scheduled_vote_in, durable_scheduled_vote_keys, insert_scheduled_votes_in, +}; use crate::model::dpns_voting::{ - DpnsCurrentVoteState, DpnsVoteFailure, DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteTarget, + DpnsCurrentVoteState, DpnsScheduledVoteClearDisposition, DpnsScheduledVoteClearOutcome, + DpnsScheduledVoteKey, DpnsVoteFailure, DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteTarget, DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, failed_before_broadcast_outcome, unavailable_preflight_outcome, }; @@ -313,22 +318,24 @@ fn write_existing_operation( persist_operation(kv, network, operation) } -fn prune_terminal_operations( - kv: &DetKv, - network: Network, - removed_scheduled_votes: &BTreeSet<([u8; 32], String)>, -) -> Result { +fn prune_terminal_operations(kv: &DetKv, network: Network) -> Result { + let surviving_scheduled_votes = durable_scheduled_vote_keys(kv, network)?; let terminal_ids = load_operations(kv, network)? .into_iter() .filter(|operation| { operation.is_complete() && !operation.targets.is_empty() + && operation + .targets + .iter() + .any(|outcome| matches!(outcome.target.timing, VoteTiming::Scheduled(_))) && operation.targets.iter().all(|outcome| { !matches!(outcome.target.timing, VoteTiming::Scheduled(_)) - || removed_scheduled_votes.contains(&( - outcome.target.key.voter_id.to_buffer(), - outcome.target.contested_name.clone(), - )) + || !surviving_scheduled_votes.contains(&DpnsScheduledVoteKey { + network: outcome.target.key.network, + voter_id: outcome.target.key.voter_id, + contested_name: outcome.target.contested_name.clone(), + }) }) }) .map(|operation| operation.id) @@ -477,25 +484,6 @@ fn cancel_scheduled_target( Ok(true) } -fn cancel_all_scheduled_targets(kv: &DetKv, network: Network) -> Result { - let mut cancelled = 0; - for mut operation in load_operations(kv, network)? { - let mut changed = false; - for outcome in &mut operation.targets { - if outcome.status == DpnsVoteTargetStatus::Scheduled { - outcome.status = DpnsVoteTargetStatus::Cancelled; - outcome.failure = None; - changed = true; - cancelled += 1; - } - } - if changed { - write_existing_operation(kv, network, &operation)?; - } - } - Ok(cancelled) -} - fn recover_interrupted_target_statuses(operation: &mut DpnsVoteOperation) -> bool { let mut changed = false; for outcome in &mut operation.targets { @@ -582,6 +570,33 @@ impl AppContext { persist_operation(&kv, self.network, operation) } + /// Persist an operation and its compatibility mirror under one journal guard. + pub(crate) fn insert_dpns_vote_operation_with_scheduled_mirror( + &self, + operation: &mut DpnsVoteOperation, + replacing_scheduled_key: Option<&DpnsVoteTargetKey>, + scheduled_votes: &[ScheduledDPNSVote], + ) -> Result, TaskError> { + if operation + .targets + .iter() + .any(|outcome| outcome.target.key.network != self.network) + { + return Err(TaskError::DpnsVoteTargetBusy); + } + let _guard = self + .dpns_vote_operation_guard + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let kv = self.det_kv()?; + if let Some(key) = replacing_scheduled_key { + replace_scheduled_operation(&kv, self.network, operation, key)?; + } else { + persist_operation(&kv, self.network, operation)?; + } + Ok(insert_scheduled_votes_in(&kv, scheduled_votes).err()) + } + /// Persist updated target statuses while retaining the original operation ID. pub fn update_dpns_vote_operation( &self, @@ -925,50 +940,239 @@ impl AppContext { } /// Remove lock-releasing operation history when the user clears completed votes. - pub(crate) fn prune_terminal_dpns_vote_operations( - &self, - removed_scheduled_votes: &BTreeSet<([u8; 32], String)>, - ) -> Result { + pub(crate) fn prune_terminal_dpns_vote_operations(&self) -> Result { let _guard = self .dpns_vote_operation_guard .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - prune_terminal_operations(&self.det_kv()?, self.network, removed_scheduled_votes) + prune_terminal_operations(&self.det_kv()?, self.network) } - /// Release a not-yet-submitting scheduled target after explicit cancellation. - pub(crate) fn cancel_scheduled_dpns_vote_target( + /// Remove a schedule only after its authoritative journal state permits it. + pub(crate) fn remove_scheduled_dpns_vote( &self, - operation_id: DpnsVoteOperationId, + expected_operation_id: Option, key: &DpnsVoteTargetKey, + contested_name: &str, ) -> Result<(), TaskError> { let _guard = self .dpns_vote_operation_guard .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - if cancel_scheduled_target(&self.det_kv()?, self.network, operation_id, key)? { - Ok(()) - } else { - Err(TaskError::DpnsScheduledVoteAlreadyStarted) + let kv = self.det_kv()?; + let operations = load_operations(&kv, self.network)?; + let lock_index = load_or_rebuild_lock_index(&kv, self.network)?; + let selected = match expected_operation_id { + Some(operation_id) => operations + .iter() + .find(|operation| operation.id == operation_id) + .and_then(|operation| { + operation + .outcome(key) + .map(|outcome| (operation.id, outcome.status)) + }), + None => { + if let Some(operation_id) = lock_index.get(key) { + let operation = operations + .iter() + .find(|operation| operation.id == *operation_id) + .ok_or(TaskError::DpnsVoteOperationRecordMissing)?; + let outcome = operation + .outcome(key) + .ok_or(TaskError::DpnsVoteOperationRecordMissing)?; + Some((operation.id, outcome.status)) + } else { + operations + .iter() + .filter_map(|operation| { + operation + .outcome(key) + .filter(|outcome| !outcome.status.holds_lock()) + .map(|outcome| (operation.created_at, operation.id, outcome.status)) + }) + .max_by_key(|(created_at, operation_id, _)| (*created_at, *operation_id)) + .map(|(_, operation_id, status)| (operation_id, status)) + } + } + }; + + let journal_is_authoritative = match selected { + Some((operation_id, DpnsVoteTargetStatus::Scheduled)) => { + if lock_index + .get(key) + .is_some_and(|owner| *owner != operation_id) + { + return Err(TaskError::DpnsScheduledVoteAlreadyStarted); + } + if !cancel_scheduled_target(&kv, self.network, operation_id, key)? { + return Err(TaskError::DpnsScheduledVoteAlreadyStarted); + } + true + } + Some(( + _, + DpnsVoteTargetStatus::Queued + | DpnsVoteTargetStatus::Submitting + | DpnsVoteTargetStatus::Confirming + | DpnsVoteTargetStatus::Unconfirmed, + )) => return Err(TaskError::DpnsScheduledVoteAlreadyStarted), + Some((operation_id, _)) => { + if lock_index + .get(key) + .is_some_and(|owner| *owner != operation_id) + { + return Err(TaskError::DpnsScheduledVoteAlreadyStarted); + } + true + } + None => { + if lock_index.contains_key(key) { + return Err(TaskError::DpnsScheduledVoteAlreadyStarted); + } + false + } + }; + + let voter = key.voter_id.to_buffer(); + if let Err(error) = delete_scheduled_vote_in(&kv, &voter, contested_name) { + if !journal_is_authoritative { + return Err(error); + } + tracing::warn!( + ?error, + expected_operation_id = ?expected_operation_id, + voter_id = %key.voter_id, + contested_name, + "Scheduled DPNS vote journal was updated but its compatibility mirror remains" + ); } + Ok(()) + } + + /// Release a not-yet-submitting scheduled target after explicit cancellation. + pub(crate) fn cancel_scheduled_dpns_vote_target( + &self, + operation_id: DpnsVoteOperationId, + key: &DpnsVoteTargetKey, + contested_name: &str, + ) -> Result<(), TaskError> { + self.remove_scheduled_dpns_vote(Some(operation_id), key, contested_name) } - /// Release every not-yet-submitting scheduled target on this network. - pub(crate) fn cancel_all_scheduled_dpns_vote_targets(&self) -> Result<(), TaskError> { + /// Clear removable schedules and report in-flight targets retained for safety. + pub(crate) fn clear_all_scheduled_dpns_votes( + &self, + ) -> Result, TaskError> { let _guard = self .dpns_vote_operation_guard .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - cancel_all_scheduled_targets(&self.det_kv()?, self.network)?; - Ok(()) + let kv = self.det_kv()?; + let mut retained = BTreeSet::new(); + let mut outcomes = BTreeMap::< + DpnsScheduledVoteKey, + ( + Option<(bool, u64, DpnsVoteOperationId)>, + DpnsScheduledVoteClearOutcome, + ), + >::new(); + + for mut operation in load_operations(&kv, self.network)? { + let mut changed = false; + for outcome in operation + .targets + .iter_mut() + .filter(|outcome| matches!(outcome.target.timing, VoteTiming::Scheduled(_))) + { + let key = DpnsScheduledVoteKey { + network: outcome.target.key.network, + voter_id: outcome.target.key.voter_id, + contested_name: outcome.target.contested_name.clone(), + }; + let status = outcome.status; + let disposition = match status { + DpnsVoteTargetStatus::Scheduled => { + outcome.status = DpnsVoteTargetStatus::Cancelled; + outcome.failure = None; + changed = true; + DpnsScheduledVoteClearDisposition::Cleared + } + DpnsVoteTargetStatus::Queued + | DpnsVoteTargetStatus::Submitting + | DpnsVoteTargetStatus::Confirming + | DpnsVoteTargetStatus::Unconfirmed => { + retained.insert(key.clone()); + DpnsScheduledVoteClearDisposition::InFlight(status) + } + _ => DpnsScheduledVoteClearDisposition::Cleared, + }; + let rank = (status.holds_lock(), operation.created_at, operation.id); + let should_replace = outcomes + .get(&key) + .is_none_or(|(existing_rank, _)| existing_rank.is_none_or(|old| rank > old)); + if should_replace { + outcomes.insert( + key.clone(), + ( + Some(rank), + DpnsScheduledVoteClearOutcome { + operation_id: Some(operation.id), + key, + disposition, + }, + ), + ); + } + } + if changed { + write_existing_operation(&kv, self.network, &operation)?; + } + } + + let mirror_keys = durable_scheduled_vote_keys(&kv, self.network)?; + for key in mirror_keys { + let journal_is_authoritative = outcomes.contains_key(&key); + outcomes.entry(key.clone()).or_insert_with(|| { + ( + None, + DpnsScheduledVoteClearOutcome { + operation_id: None, + key: key.clone(), + disposition: DpnsScheduledVoteClearDisposition::Cleared, + }, + ) + }); + if retained.contains(&key) { + continue; + } + let voter = key.voter_id.to_buffer(); + if let Err(error) = delete_scheduled_vote_in(&kv, &voter, &key.contested_name) { + if !journal_is_authoritative { + return Err(error); + } + tracing::warn!( + ?error, + voter_id = %key.voter_id, + contested_name = %key.contested_name, + "Scheduled DPNS vote was cleared in the journal but its compatibility mirror remains" + ); + } + } + + let _surviving_mirror_keys = durable_scheduled_vote_keys(&kv, self.network)?; + prune_terminal_operations(&kv, self.network)?; + Ok(outcomes.into_values().map(|(_, outcome)| outcome).collect()) } } #[cfg(test)] mod tests { use super::*; - use crate::model::dpns_voting::{DpnsVoteTarget, VoteTiming}; - use crate::wallet_backend::kv_test_support::InMemoryKv; + use crate::backend_task::contested_names::ScheduledDPNSVote; + use crate::model::dpns_voting::{ + DpnsScheduledVoteClearDisposition, DpnsVoteTarget, VoteTiming, + }; + use crate::wallet_backend::kv_test_support::{FailingKv, InMemoryKv}; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dash_sdk::platform::Identifier; @@ -1058,6 +1262,29 @@ mod tests { operation } + fn scheduled_operation( + status: DpnsVoteTargetStatus, + poll: u8, + contested_name: &str, + ) -> DpnsVoteOperation { + let mut operation = operation(status); + operation.targets[0].target.timing = VoteTiming::Scheduled(42); + operation.targets[0].target.key.vote_poll_id = Identifier::from([poll; 32]); + operation.targets[0].target.contested_name = contested_name.to_owned(); + operation + } + + fn scheduled_vote(operation: &DpnsVoteOperation) -> ScheduledDPNSVote { + let target = &operation.targets[0].target; + ScheduledDPNSVote { + contested_name: target.contested_name.clone(), + voter_id: target.key.voter_id, + choice: target.requested_choice, + unix_timestamp: 42, + executed_successfully: false, + } + } + /// VOTE-TC-040/041: one unresolved target cannot be inserted twice. #[test] fn unresolved_target_rejects_a_competing_operation() { @@ -1203,18 +1430,33 @@ mod tests { #[test] fn cancellation_does_not_overwrite_a_queued_target() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let context = crate::context::test_support::test_app_context(temp_dir.path()); let kv = kv(); - let mut scheduled = operation(DpnsVoteTargetStatus::Scheduled); - scheduled.targets[0].target.timing = VoteTiming::Scheduled(42); + context.set_det_kv_override_for_test(kv); + let mut scheduled = scheduled_operation(DpnsVoteTargetStatus::Scheduled, 2, "dominguez"); let key = scheduled.targets[0].target.key.clone(); - persist_operation(&kv, Network::Testnet, &scheduled).unwrap(); - transition_scheduled_target_to_queued(&kv, Network::Testnet, scheduled.id, &key).unwrap(); + context + .insert_dpns_vote_operation(&mut scheduled, None) + .unwrap(); + context + .insert_scheduled_votes(&[scheduled_vote(&scheduled)]) + .unwrap(); + context + .queue_scheduled_dpns_vote_target(scheduled.id, &key) + .unwrap(); - assert!(!cancel_scheduled_target(&kv, Network::Testnet, scheduled.id, &key).unwrap()); + assert!(matches!( + context + .remove_scheduled_dpns_vote(Some(scheduled.id), &key, "dominguez") + .expect_err("queued targets must not be removed"), + TaskError::DpnsScheduledVoteAlreadyStarted + )); assert_eq!( - load_operations(&kv, Network::Testnet).unwrap()[0].targets[0].status, + context.dpns_vote_operations().unwrap()[0].targets[0].status, DpnsVoteTargetStatus::Queued ); + assert_eq!(context.get_scheduled_votes().unwrap().len(), 1); } #[test] @@ -1235,29 +1477,213 @@ mod tests { #[test] fn cancel_all_preserves_targets_that_are_already_queued() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let context = crate::context::test_support::test_app_context(temp_dir.path()); let kv = kv(); - let mut scheduled = operation(DpnsVoteTargetStatus::Scheduled); - scheduled.targets[0].target.timing = VoteTiming::Scheduled(42); - let mut queued = operation(DpnsVoteTargetStatus::Queued); - queued.targets[0].target.key.vote_poll_id = Identifier::from([3; 32]); - persist_operation(&kv, Network::Testnet, &scheduled).unwrap(); - persist_operation(&kv, Network::Testnet, &queued).unwrap(); + context.set_det_kv_override_for_test(kv); + let mut queued = scheduled_operation(DpnsVoteTargetStatus::Queued, 3, "queued"); + context + .insert_dpns_vote_operation(&mut queued, None) + .unwrap(); + context + .insert_scheduled_votes(&[scheduled_vote(&queued)]) + .unwrap(); + + let outcomes = context.clear_all_scheduled_dpns_votes().unwrap(); + + assert_eq!( + context.dpns_vote_operations().unwrap()[0].targets[0].status, + DpnsVoteTargetStatus::Queued + ); + assert_eq!(context.get_scheduled_votes().unwrap().len(), 1); + assert_eq!(outcomes.len(), 1); + assert_eq!( + outcomes[0].disposition, + DpnsScheduledVoteClearDisposition::InFlight(DpnsVoteTargetStatus::Queued) + ); + } + + #[test] + fn scheduled_removal_persists_cancelled_before_best_effort_mirror_delete() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let store = Arc::new(FailingKv::default()); + let kv = DetKv::from_store(store.clone()); + let context = crate::context::test_support::test_app_context_with_kv( + temp_dir.path(), + Arc::new(kv.clone()), + ); + context.set_det_kv_override_for_test(kv); + let mut scheduled = scheduled_operation(DpnsVoteTargetStatus::Scheduled, 2, "cancel-me"); + let key = scheduled.targets[0].target.key.clone(); + context + .insert_dpns_vote_operation(&mut scheduled, None) + .unwrap(); + context + .insert_scheduled_votes(&[scheduled_vote(&scheduled)]) + .unwrap(); + store.fail_next_deletes_containing("cancel-me", 1); + + context + .remove_scheduled_dpns_vote(Some(scheduled.id), &key, "cancel-me") + .unwrap(); + + assert_eq!( + context.dpns_vote_operations().unwrap()[0].targets[0].status, + DpnsVoteTargetStatus::Cancelled + ); + assert_eq!(context.get_scheduled_votes().unwrap().len(), 1); + } + + #[test] + fn stale_terminal_operation_cannot_remove_a_newer_locked_mirror() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let context = crate::context::test_support::test_app_context(temp_dir.path()); + let kv = kv(); + context.set_det_kv_override_for_test(kv); + let mut terminal = scheduled_operation(DpnsVoteTargetStatus::Confirmed, 2, "same-target"); + let mut current = scheduled_operation(DpnsVoteTargetStatus::Scheduled, 2, "same-target"); + let key = current.targets[0].target.key.clone(); + context + .insert_dpns_vote_operation(&mut terminal, None) + .unwrap(); + context + .insert_dpns_vote_operation(&mut current, None) + .unwrap(); + context + .insert_scheduled_votes(&[scheduled_vote(¤t)]) + .unwrap(); + + assert!(matches!( + context + .remove_scheduled_dpns_vote(Some(terminal.id), &key, "same-target") + .expect_err("stale terminal history must not remove the current schedule"), + TaskError::DpnsScheduledVoteAlreadyStarted + )); + assert_eq!(context.get_scheduled_votes().unwrap().len(), 1); + assert!( + context + .dpns_vote_operations() + .unwrap() + .iter() + .any(|operation| { + operation.id == current.id + && operation.targets[0].status == DpnsVoteTargetStatus::Scheduled + }) + ); + } + #[test] + fn clear_all_cancels_pending_and_retains_every_in_flight_target() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let context = crate::context::test_support::test_app_context(temp_dir.path()); + let kv = kv(); + context.set_det_kv_override_for_test(kv); + let cases = [ + ("scheduled", DpnsVoteTargetStatus::Scheduled), + ("queued", DpnsVoteTargetStatus::Queued), + ("submitting", DpnsVoteTargetStatus::Submitting), + ("confirming", DpnsVoteTargetStatus::Confirming), + ("unconfirmed", DpnsVoteTargetStatus::Unconfirmed), + ("confirmed", DpnsVoteTargetStatus::Confirmed), + ]; + let mut votes = Vec::new(); + for (index, (name, status)) in cases.into_iter().enumerate() { + let mut operation = scheduled_operation(status, index as u8 + 2, name); + votes.push(scheduled_vote(&operation)); + context + .insert_dpns_vote_operation(&mut operation, None) + .unwrap(); + } + votes.push(ScheduledDPNSVote { + contested_name: "legacy".to_owned(), + voter_id: Identifier::from([9; 32]), + choice: ResourceVoteChoice::Lock, + unix_timestamp: 42, + executed_successfully: false, + }); + context.insert_scheduled_votes(&votes).unwrap(); + + let outcomes = context.clear_all_scheduled_dpns_votes().unwrap(); + + let remaining_names = context + .get_scheduled_votes() + .unwrap() + .into_iter() + .map(|vote| vote.contested_name) + .collect::>(); assert_eq!( - cancel_all_scheduled_targets(&kv, Network::Testnet).unwrap(), - 1 + remaining_names, + BTreeSet::from([ + "confirming".to_owned(), + "queued".to_owned(), + "submitting".to_owned(), + "unconfirmed".to_owned(), + ]) ); - let operations = load_operations(&kv, Network::Testnet).unwrap(); - assert!(operations.iter().any(|operation| { - operation.targets[0].target.key == scheduled.targets[0].target.key - && operation.targets[0].status == DpnsVoteTargetStatus::Cancelled + assert_eq!(outcomes.len(), 7); + for (name, status) in [ + ("queued", DpnsVoteTargetStatus::Queued), + ("submitting", DpnsVoteTargetStatus::Submitting), + ("confirming", DpnsVoteTargetStatus::Confirming), + ("unconfirmed", DpnsVoteTargetStatus::Unconfirmed), + ] { + assert!(outcomes.iter().any(|outcome| { + outcome.key.contested_name == name + && outcome.disposition == DpnsScheduledVoteClearDisposition::InFlight(status) + })); + } + for name in ["scheduled", "confirmed", "legacy"] { + assert!(outcomes.iter().any(|outcome| { + outcome.key.contested_name == name + && outcome.disposition == DpnsScheduledVoteClearDisposition::Cleared + })); + } + assert!(outcomes.iter().any(|outcome| { + outcome.key.contested_name == "legacy" && outcome.operation_id.is_none() })); - assert!(operations.iter().any(|operation| { - operation.targets[0].target.key == queued.targets[0].target.key - && operation.targets[0].status == DpnsVoteTargetStatus::Queued + let remaining_operations = context.dpns_vote_operations().unwrap(); + assert_eq!(remaining_operations.len(), 4); + assert!(remaining_operations.iter().all(|operation| { + matches!( + operation.targets[0].status, + DpnsVoteTargetStatus::Queued + | DpnsVoteTargetStatus::Submitting + | DpnsVoteTargetStatus::Confirming + | DpnsVoteTargetStatus::Unconfirmed + ) })); } + #[test] + fn combined_insert_reports_mirror_error_after_persisting_the_journal() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let store = Arc::new(FailingKv::default()); + let kv = DetKv::from_store(store.clone()); + let context = crate::context::test_support::test_app_context_with_kv( + temp_dir.path(), + Arc::new(kv.clone()), + ); + context.set_det_kv_override_for_test(kv); + let mut scheduled = scheduled_operation(DpnsVoteTargetStatus::Scheduled, 2, "mirror-fails"); + let vote = scheduled_vote(&scheduled); + store.fail_next_puts_containing("det:scheduled_vote:", 1); + + let mirror_error = context + .insert_dpns_vote_operation_with_scheduled_mirror(&mut scheduled, None, &[vote]) + .unwrap() + .expect("mirror failure must be returned separately"); + + assert!(matches!( + mirror_error, + TaskError::ScheduledVoteStorage { .. } + )); + assert_eq!( + context.dpns_vote_operations().unwrap(), + vec![scheduled.clone()] + ); + assert!(context.get_scheduled_votes().unwrap().is_empty()); + } + /// A corrupt indexed row must block lock reconstruction rather than being skipped. #[test] fn unreadable_indexed_operation_fails_closed() { @@ -1506,7 +1932,7 @@ mod tests { } #[test] - fn pruning_removes_terminal_scheduled_immediate_and_mixed_records() { + fn pruning_removes_terminal_scheduled_and_mixed_but_retains_immediate_history() { let kv = kv(); let mut scheduled = operation(DpnsVoteTargetStatus::Confirmed); scheduled.targets[0].target.timing = VoteTiming::Scheduled(42); @@ -1523,29 +1949,18 @@ mod tests { mixed_scheduled.target.key.vote_poll_id = Identifier::from([5; 32]); mixed.targets.push(mixed_scheduled); let live = operation(DpnsVoteTargetStatus::Unconfirmed); - let removed_scheduled_votes = BTreeSet::from([ - ( - scheduled.targets[0].target.key.voter_id.to_buffer(), - scheduled.targets[0].target.contested_name.clone(), - ), - ( - mixed.targets[1].target.key.voter_id.to_buffer(), - mixed.targets[1].target.contested_name.clone(), - ), - ]); persist_operation(&kv, Network::Testnet, &scheduled).unwrap(); persist_operation(&kv, Network::Testnet, &immediate).unwrap(); persist_operation(&kv, Network::Testnet, &mixed).unwrap(); persist_operation(&kv, Network::Testnet, &live).unwrap(); - assert_eq!( - prune_terminal_operations(&kv, Network::Testnet, &removed_scheduled_votes).unwrap(), - 3 - ); - assert_eq!( - load_operations_read_only(&kv, Network::Testnet).unwrap(), - vec![live] - ); + assert_eq!(prune_terminal_operations(&kv, Network::Testnet).unwrap(), 2); + let remaining_ids = load_operations_read_only(&kv, Network::Testnet) + .unwrap() + .into_iter() + .map(|operation| operation.id) + .collect::>(); + assert_eq!(remaining_ids, BTreeSet::from([immediate.id, live.id])); assert!( kv.get::( DetScope::Global, @@ -1560,7 +1975,7 @@ mod tests { &operation_key(Network::Testnet, immediate.id), ) .unwrap() - .is_none() + .is_some() ); assert!( kv.get::( diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index 0fa6c242b..1b92abcd1 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -1,6 +1,7 @@ use super::AppContext; use crate::backend_task::contested_names::ScheduledDPNSVote; use crate::backend_task::error::TaskError; +use crate::model::dpns_voting::DpnsScheduledVoteKey; use crate::model::qualified_identity::{ DPNSNameInfo, IdentityStatus, IdentityType, QualifiedIdentity, }; @@ -456,6 +457,26 @@ fn scheduled_vote_keys( .map_err(scheduled_vote_err) } +/// Enumerate scheduled mirror keys without decoding their stored values. +pub(super) fn durable_scheduled_vote_keys( + kv: &DetKv, + network: Network, +) -> std::result::Result, TaskError> { + let mut keys = BTreeSet::new(); + for voter in load_scheduled_vote_voters(kv)? { + for key in scheduled_vote_keys(kv, &voter)? { + if let Some(contested_name) = key.strip_prefix(SCHEDULED_VOTE_KEY_PREFIX) { + keys.insert(DpnsScheduledVoteKey { + network, + voter_id: Identifier::from(voter), + contested_name: contested_name.to_owned(), + }); + } + } + } + Ok(keys) +} + /// Drop `voter` from the Global scheduled-vote voter index. No-op when the /// voter is not present, so repeated calls stay idempotent. fn remove_vote_voter_from_index( @@ -497,6 +518,37 @@ fn delete_scheduled_votes_for_voter( remove_vote_voter_from_index(kv, voter) } +pub(super) fn insert_scheduled_votes_in( + kv: &DetKv, + scheduled_votes: &[ScheduledDPNSVote], +) -> std::result::Result<(), TaskError> { + for vote in scheduled_votes { + let voter = vote.voter_id.to_buffer(); + let stored = StoredScheduledVote::from(vote); + kv.put( + DetScope::Identity(&voter), + &scheduled_vote_key(&vote.contested_name), + &stored, + ) + .map_err(scheduled_vote_err)?; + index_add_vote_voter(kv, &voter)?; + } + Ok(()) +} + +pub(super) fn delete_scheduled_vote_in( + kv: &DetKv, + voter: &[u8; 32], + contested_name: &str, +) -> std::result::Result<(), TaskError> { + kv.delete( + DetScope::Identity(voter), + &scheduled_vote_key(contested_name), + ) + .map_err(scheduled_vote_err)?; + prune_vote_voter_if_empty(kv, voter) +} + impl AppContext { /// Insert (or replace) a local qualified identity in the per-network /// wallet k/v store under [`DetScope::Identity`]. Mirrors pre-C7 @@ -1082,19 +1134,7 @@ impl AppContext { &self, scheduled_votes: &[ScheduledDPNSVote], ) -> std::result::Result<(), TaskError> { - let kv = self.det_kv()?; - for vote in scheduled_votes { - let voter = vote.voter_id.to_buffer(); - let stored = StoredScheduledVote::from(vote); - kv.put( - DetScope::Identity(&voter), - &scheduled_vote_key(&vote.contested_name), - &stored, - ) - .map_err(scheduled_vote_err)?; - index_add_vote_voter(&kv, &voter)?; - } - Ok(()) + insert_scheduled_votes_in(&self.det_kv()?, scheduled_votes) } /// Fetch every scheduled vote queued for this network from the @@ -1140,21 +1180,22 @@ impl AppContext { pub fn clear_executed_scheduled_votes(&self) -> std::result::Result<(), TaskError> { let kv = self.det_kv()?; let voters = load_scheduled_vote_voters(&kv)?; - let mut removed_scheduled_votes = BTreeSet::new(); for voter in &voters { let scope = DetScope::Identity(voter); for key in scheduled_vote_keys(&kv, voter)? { - match kv.get::(scope, &key) { - Ok(Some(stored)) if stored.executed_successfully => { + match kv + .get::(scope, &key) + .map_err(scheduled_vote_err)? + { + Some(stored) if stored.executed_successfully => { kv.delete(scope, &key).map_err(scheduled_vote_err)?; - removed_scheduled_votes.insert((stored.voter_id, stored.contested_name)); } - _ => {} + Some(_) | None => {} } } prune_vote_voter_if_empty(&kv, voter)?; } - self.prune_terminal_dpns_vote_operations(&removed_scheduled_votes)?; + self.prune_terminal_dpns_vote_operations()?; Ok(()) } @@ -1165,13 +1206,7 @@ impl AppContext { contested_name: &str, ) -> std::result::Result<(), TaskError> { let voter = voter_buffer(identity_id)?; - let kv = self.det_kv()?; - kv.delete( - DetScope::Identity(&voter), - &scheduled_vote_key(contested_name), - ) - .map_err(scheduled_vote_err)?; - prune_vote_voter_if_empty(&kv, &voter) + delete_scheduled_vote_in(&self.det_kv()?, &voter, contested_name) } /// Mark a single scheduled vote as executed so future cast loops skip it. @@ -1223,7 +1258,10 @@ impl AppContext { #[cfg(test)] mod tests { use super::*; - use crate::wallet_backend::kv_test_support::InMemoryKv; + use crate::model::dpns_voting::{ + DpnsVoteOperation, DpnsVoteTarget, DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, + }; + use crate::wallet_backend::kv_test_support::{FailingKv, InMemoryKv}; use DetKv; use std::sync::Arc; @@ -1235,6 +1273,42 @@ mod tests { [b; 32] } + fn scheduled_vote( + voter_id: Identifier, + contested_name: &str, + executed_successfully: bool, + ) -> ScheduledDPNSVote { + ScheduledDPNSVote { + contested_name: contested_name.to_owned(), + voter_id, + choice: ResourceVoteChoice::Lock, + unix_timestamp: 42, + executed_successfully, + } + } + + fn scheduled_operation( + voter_id: Identifier, + poll: u8, + contested_name: &str, + status: DpnsVoteTargetStatus, + ) -> DpnsVoteOperation { + let mut operation = DpnsVoteOperation::new(vec![DpnsVoteTarget { + key: DpnsVoteTargetKey { + network: Network::Testnet, + voter_id, + vote_poll_id: Identifier::from([poll; 32]), + }, + voter_alias: None, + contested_name: contested_name.to_owned(), + requested_choice: ResourceVoteChoice::Lock, + current_choice: None, + timing: VoteTiming::Scheduled(42), + }]); + operation.targets[0].status = status; + operation + } + /// Minimal stored-identity blob carrying a synthetic `qi_bytes` /// payload and the chosen type label. Avoids constructing a full /// `QualifiedIdentity` (which needs an SDK identity) for storage-layer @@ -1545,10 +1619,6 @@ mod tests { #[test] fn clearing_executed_votes_preserves_failed_and_cancelled_journal_records() { - use crate::model::dpns_voting::{ - DpnsVoteOperation, DpnsVoteTarget, DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, - }; - let temp_dir = tempfile::tempdir().expect("tempdir"); let context = crate::context::test_support::test_app_context(temp_dir.path()); let kv = empty_kv(); @@ -1680,6 +1750,192 @@ mod tests { assert_eq!(load_scheduled_vote_voters(&kv).unwrap(), vec![voter]); } + #[test] + fn mixed_terminal_operation_is_pruned_only_after_every_scheduled_mirror_is_absent() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let context = crate::context::test_support::test_app_context(temp_dir.path()); + let kv = empty_kv(); + context.set_det_kv_override_for_test(kv); + let voter = Identifier::from(id(1)); + let first_vote = scheduled_vote(voter, "first", false); + let second_vote = scheduled_vote(voter, "second", false); + context + .insert_scheduled_votes(&[first_vote, second_vote]) + .unwrap(); + let mut operation = scheduled_operation(voter, 1, "first", DpnsVoteTargetStatus::Confirmed); + operation.targets.push( + scheduled_operation(voter, 2, "second", DpnsVoteTargetStatus::Confirmed) + .targets + .remove(0), + ); + let mut immediate = + scheduled_operation(voter, 3, "immediate", DpnsVoteTargetStatus::Confirmed) + .targets + .remove(0); + immediate.target.timing = VoteTiming::Now; + operation.targets.push(immediate); + context + .insert_dpns_vote_operation(&mut operation, None) + .unwrap(); + + assert_eq!(context.prune_terminal_dpns_vote_operations().unwrap(), 0); + context + .delete_scheduled_vote(voter.as_slice(), "first") + .unwrap(); + assert_eq!(context.prune_terminal_dpns_vote_operations().unwrap(), 0); + context + .delete_scheduled_vote(voter.as_slice(), "second") + .unwrap(); + assert_eq!(context.prune_terminal_dpns_vote_operations().unwrap(), 1); + assert!(context.dpns_vote_operations().unwrap().is_empty()); + } + + #[test] + fn journal_only_terminal_scheduled_operation_is_prunable() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let context = crate::context::test_support::test_app_context(temp_dir.path()); + let kv = empty_kv(); + context.set_det_kv_override_for_test(kv); + let mut operation = scheduled_operation( + Identifier::from(id(1)), + 1, + "journal-only", + DpnsVoteTargetStatus::Confirmed, + ); + context + .insert_dpns_vote_operation(&mut operation, None) + .unwrap(); + + assert_eq!(context.prune_terminal_dpns_vote_operations().unwrap(), 1); + assert!(context.dpns_vote_operations().unwrap().is_empty()); + } + + #[test] + fn clear_executed_cleanup_recovers_after_voter_index_write_failure() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let store = Arc::new(FailingKv::default()); + let kv = DetKv::from_store(store.clone()); + let context = crate::context::test_support::test_app_context_with_kv( + temp_dir.path(), + Arc::new(kv.clone()), + ); + context.set_det_kv_override_for_test(kv); + let voter = Identifier::from(id(1)); + context + .insert_scheduled_votes(&[scheduled_vote(voter, "confirmed", true)]) + .unwrap(); + let mut operation = + scheduled_operation(voter, 1, "confirmed", DpnsVoteTargetStatus::Confirmed); + context + .insert_dpns_vote_operation(&mut operation, None) + .unwrap(); + store.fail_next_puts_containing(SCHEDULED_VOTE_VOTERS_KEY, 1); + + assert!(context.clear_executed_scheduled_votes().is_err()); + assert_eq!(context.dpns_vote_operations().unwrap(), vec![operation]); + + context.clear_executed_scheduled_votes().unwrap(); + assert!(context.dpns_vote_operations().unwrap().is_empty()); + assert!( + load_scheduled_vote_voters(&context.det_kv().unwrap()) + .unwrap() + .is_empty() + ); + } + + #[test] + fn clear_executed_cleanup_recovers_after_journal_prune_write_failure() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let store = Arc::new(FailingKv::default()); + let kv = DetKv::from_store(store.clone()); + let context = crate::context::test_support::test_app_context_with_kv( + temp_dir.path(), + Arc::new(kv.clone()), + ); + context.set_det_kv_override_for_test(kv); + let voter = Identifier::from(id(1)); + context + .insert_scheduled_votes(&[scheduled_vote(voter, "confirmed", true)]) + .unwrap(); + let mut operation = + scheduled_operation(voter, 1, "confirmed", DpnsVoteTargetStatus::Confirmed); + context + .insert_dpns_vote_operation(&mut operation, None) + .unwrap(); + store.fail_next_puts_containing("dpns_vote_operation_locks_dirty", 1); + + assert!(context.clear_executed_scheduled_votes().is_err()); + assert_eq!(context.dpns_vote_operations().unwrap(), vec![operation]); + + context.clear_executed_scheduled_votes().unwrap(); + assert!(context.dpns_vote_operations().unwrap().is_empty()); + assert!( + load_scheduled_vote_voters(&context.det_kv().unwrap()) + .unwrap() + .is_empty() + ); + } + + #[test] + fn clear_executed_cleanup_fails_closed_on_unreadable_mirror() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let store = Arc::new(FailingKv::default()); + let kv = DetKv::from_store(store.clone()); + let context = crate::context::test_support::test_app_context_with_kv( + temp_dir.path(), + Arc::new(kv.clone()), + ); + context.set_det_kv_override_for_test(kv); + let voter = Identifier::from(id(1)); + context + .insert_scheduled_votes(&[scheduled_vote(voter, "unreadable", true)]) + .unwrap(); + let mut operation = + scheduled_operation(voter, 1, "unreadable", DpnsVoteTargetStatus::Confirmed); + context + .insert_dpns_vote_operation(&mut operation, None) + .unwrap(); + store.fail_next_gets_containing(&scheduled_vote_key("unreadable"), 1); + + assert!(matches!( + context + .clear_executed_scheduled_votes() + .expect_err("an unreadable row must stop cleanup"), + TaskError::ScheduledVoteStorage { .. } + )); + assert_eq!(context.dpns_vote_operations().unwrap(), vec![operation]); + assert_eq!(context.get_scheduled_votes().unwrap().len(), 1); + } + + #[test] + fn pruning_treats_an_unreadable_mirror_key_as_surviving() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let store = Arc::new(FailingKv::default()); + let kv = DetKv::from_store(store.clone()); + let context = crate::context::test_support::test_app_context_with_kv( + temp_dir.path(), + Arc::new(kv.clone()), + ); + context.set_det_kv_override_for_test(kv); + let voter = Identifier::from(id(1)); + context + .insert_scheduled_votes(&[scheduled_vote(voter, "unreadable", true)]) + .unwrap(); + let mut operation = + scheduled_operation(voter, 1, "unreadable", DpnsVoteTargetStatus::Confirmed); + context + .insert_dpns_vote_operation(&mut operation, None) + .unwrap(); + store.fail_next_gets_containing(&scheduled_vote_key("unreadable"), 1); + + assert_eq!(context.prune_terminal_dpns_vote_operations().unwrap(), 0); + assert_eq!(context.dpns_vote_operations().unwrap(), vec![operation]); + assert!( + context.get_scheduled_votes().unwrap().is_empty(), + "the decode-oriented reader should consume and skip the injected row error" + ); + } + // --------------------------------------------------------------- // dashpay private / address_index Identity-scope contracts are // covered in `src/wallet_backend/dashpay.rs`; here we assert the diff --git a/src/model/dpns_voting.rs b/src/model/dpns_voting.rs index 3903415a9..485faead5 100644 --- a/src/model/dpns_voting.rs +++ b/src/model/dpns_voting.rs @@ -166,6 +166,29 @@ pub struct DpnsVoteTargetKey { pub vote_poll_id: Identifier, } +/// Durable identity of one scheduled-vote compatibility mirror row. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct DpnsScheduledVoteKey { + pub network: Network, + pub voter_id: Identifier, + pub contested_name: String, +} + +/// Result of clearing one scheduled target from the compatibility mirror. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DpnsScheduledVoteClearDisposition { + Cleared, + InFlight(DpnsVoteTargetStatus), +} + +/// Typed Clear All result for one scheduled target. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DpnsScheduledVoteClearOutcome { + pub operation_id: Option, + pub key: DpnsScheduledVoteKey, + pub disposition: DpnsScheduledVoteClearDisposition, +} + /// When a target should enter the shared executor. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum VoteTiming { diff --git a/src/wallet_backend/kv_test_support.rs b/src/wallet_backend/kv_test_support.rs index c9eaa4449..e17d7ecce 100644 --- a/src/wallet_backend/kv_test_support.rs +++ b/src/wallet_backend/kv_test_support.rs @@ -85,8 +85,10 @@ impl KvStore for InMemoryKv { pub(crate) struct FailingKv { inner: InMemoryKv, fail_reads: AtomicBool, + fail_gets: Mutex>, puts: AtomicUsize, fail_puts: Mutex>, + fail_deletes: Mutex>, } impl FailingKv { @@ -97,6 +99,11 @@ impl FailingKv { self.fail_reads.store(fail, Ordering::Relaxed); } + /// Fail the next `count` reads whose key contains `key_fragment`. + pub(crate) fn fail_next_gets_containing(&self, key_fragment: &str, count: usize) { + *self.fail_gets.lock().unwrap() = Some((key_fragment.to_owned(), count)); + } + /// How many `put` calls have reached the store. pub(crate) fn put_count(&self) -> usize { self.puts.load(Ordering::Relaxed) @@ -106,6 +113,11 @@ impl FailingKv { pub(crate) fn fail_next_puts_containing(&self, key_fragment: &str, count: usize) { *self.fail_puts.lock().unwrap() = Some((key_fragment.to_owned(), count)); } + + /// Fail the next `count` deletes whose key contains `key_fragment`. + pub(crate) fn fail_next_deletes_containing(&self, key_fragment: &str, count: usize) { + *self.fail_deletes.lock().unwrap() = Some((key_fragment.to_owned(), count)); + } } impl KvStore for FailingKv { @@ -113,6 +125,22 @@ impl KvStore for FailingKv { if self.fail_reads.load(Ordering::Relaxed) { return Err(KvError::LockPoisoned); } + let should_fail = { + let mut failure = self.fail_gets.lock().unwrap(); + let should_fail = failure + .as_ref() + .is_some_and(|(fragment, remaining)| *remaining > 0 && key.contains(fragment)); + if should_fail && let Some((_, remaining)) = failure.as_mut() { + *remaining -= 1; + if *remaining == 0 { + *failure = None; + } + } + should_fail + }; + if should_fail { + return Err(KvError::LockPoisoned); + } self.inner.get(scope, key) } @@ -140,6 +168,22 @@ impl KvStore for FailingKv { } fn delete(&self, scope: &ObjectId, key: &str) -> Result<(), KvError> { + let should_fail = { + let mut failure = self.fail_deletes.lock().unwrap(); + let should_fail = failure + .as_ref() + .is_some_and(|(fragment, remaining)| *remaining > 0 && key.contains(fragment)); + if should_fail && let Some((_, remaining)) = failure.as_mut() { + *remaining -= 1; + if *remaining == 0 { + *failure = None; + } + } + should_fail + }; + if should_fail { + return Err(KvError::LockPoisoned); + } self.inner.delete(scope, key) } From f7f3c6294c752481b8f1aa5ceb75d74a021d2820 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:01:58 +0000 Subject: [PATCH 35/39] fix(dpns): make Scheduled Votes journal-first and fix Clear All feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on 4058b04b's guarded backend layer to close out the UI-side gaps: - Scheduled Votes table rows are now built from the newest journal outcome per (voter, contested_name) via DpnsVoteOperationSnapshot:: scheduled_vote_rows(), falling back to the legacy mirror row only when no journal pair exists. A journaled-but-unmirrored schedule is no longer invisible, and a terminal journal status (Rejected/ FailedBeforeSubmission/Cancelled) is no longer misdisplayed as Pending. - Row Remove dispatches CancelScheduledDpnsVote (guarded journal cancellation) for journal-backed rows, and DeleteScheduledVote (legacy-only deletion) only for true fallback rows with no journal entry. - Cast-now/Remove availability follows the journal's DpnsVoteTargetStatus instead of the old mirror-derived ScheduledVoteCastingStatus; pending Cast-now clicks are deduplicated locally until the journal catches up. - ScheduledVotesCleared now gets a real result-handling arm: routes through the hidden-Active-contests mechanism when appropriate, shows a success/information MessageBanner summarizing cleared vs. still-in- flight targets, and rebuilds the Scheduled Votes rows immediately instead of silently doing nothing (previously swallowed by a wildcard match arm on both app.rs and the screen's display_task_result). - Active-contests render path now builds an ActiveDpnsContestSnapshot once per construction/refresh (Arc-wrapped contests, poll ID computed once each) instead of cloning every ContestedName and rehashing its poll ID from three separate call sites every egui frame. QA follow-ups from independent review of 4058b04b: - remove_scheduled_dpns_vote(None, ...) β€” the actual production path used by DeleteScheduledVote β€” now has test coverage for both the unlocked (mirror deleted) and locked (refused) cases; the locked case now correctly refuses deletion when a journal lock exists. - Removed the redundant durable mirror-key scan in prune_terminal_operations that computed and immediately discarded a duplicate KV enumeration. Co-Authored-By: Codex Sol --- src/app.rs | 105 +++- src/context/dpns_vote_operations.rs | 87 ++- src/ui/dpns/dpns_contested_names_screen.rs | 655 +++++++++++---------- src/ui/state/dpns_contests.rs | 107 ++++ src/ui/state/dpns_vote_operations.rs | 253 +++++++- src/ui/state/mod.rs | 1 + 6 files changed, 884 insertions(+), 324 deletions(-) create mode 100644 src/ui/state/dpns_contests.rs diff --git a/src/app.rs b/src/app.rs index f1e7ca47b..100d74661 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,7 +16,10 @@ use crate::context::connection_status::{ConnectionStatus, OverallConnectionState use crate::context::feature_gate::FeatureGate; use crate::context::migration_status::{MigrationState, MigrationStep}; use crate::database::Database; -use crate::model::dpns_voting::{DpnsVoteOperation, DpnsVoteTargetStatus}; +use crate::model::dpns_voting::{ + DpnsScheduledVoteClearDisposition, DpnsScheduledVoteClearOutcome, DpnsVoteOperation, + DpnsVoteTargetStatus, +}; use crate::model::settings::AppSettings; use crate::ui::components::passphrase_modal; use crate::ui::components::secret_prompt_host::{ActivePrompt, EguiSecretPromptHost, QueuedPrompt}; @@ -350,6 +353,43 @@ fn dpns_vote_feedback(operation: &DpnsVoteOperation) -> (String, MessageType, bo (message, MessageType::Warning, counts.unconfirmed > 0) } +fn scheduled_vote_clear_feedback( + outcomes: &[DpnsScheduledVoteClearOutcome], +) -> (String, MessageType) { + let cleared = outcomes + .iter() + .filter(|outcome| outcome.disposition == DpnsScheduledVoteClearDisposition::Cleared) + .count(); + let in_flight = outcomes.len().saturating_sub(cleared); + let cleared_message = match cleared { + 0 => "No scheduled votes were removed.".to_owned(), + 1 => "1 scheduled vote was removed.".to_owned(), + count => format!("{count} scheduled votes were removed."), + }; + if in_flight == 0 { + let message_type = if cleared == 0 { + MessageType::Info + } else { + MessageType::Success + }; + return (cleared_message, message_type); + } + let retained_message = if in_flight == 1 { + "1 vote already in progress remains listed. Wait for it to finish before trying again." + } else { + return ( + format!( + "{cleared_message} {in_flight} votes already in progress remain listed. Wait for them to finish before trying again." + ), + MessageType::Info, + ); + }; + ( + format!("{cleared_message} {retained_message}"), + MessageType::Info, + ) +} + /// Action id for the SPV-sync block's "Continue in the background" escape button. /// SPV sync is **unbounded** β€” with no peers it stays Connecting/Syncing forever /// with no terminal signal β€” so a button-less hard block would trap the user @@ -385,6 +425,7 @@ fn dpns_result_needs_hidden_active_contests_route( BackendTaskSuccessResult::DpnsVoteOperationUpdated { .. } | BackendTaskSuccessResult::RefreshedDpnsContests | BackendTaskSuccessResult::ScheduledVoteSweepCompleted { .. } + | BackendTaskSuccessResult::ScheduledVotesCleared(_) ) && (selected != RootScreenType::RootScreenDPNSActiveContests || !screen_stack_is_empty) } @@ -2842,6 +2883,15 @@ impl App for AppState { self.visible_screen_mut().refresh(); } } + BackendTaskSuccessResult::ScheduledVotesCleared(outcomes) => { + let (message, message_type) = scheduled_vote_clear_feedback(&outcomes); + MessageBanner::set_global(ctx, message, message_type); + self.visible_screen_mut().display_backend_task_result( + &context, + BackendTaskSuccessResult::ScheduledVotesCleared(outcomes), + ); + self.visible_screen_mut().refresh(); + } BackendTaskSuccessResult::NetworkContextCreated { network, context, @@ -3786,7 +3836,10 @@ mod contact_request_routing_tests { #[cfg(test)] mod dpns_result_routing_tests { use super::*; - use crate::model::dpns_voting::DpnsVoteOperationId; + use crate::model::dpns_voting::{ + DpnsScheduledVoteClearDisposition, DpnsScheduledVoteClearOutcome, DpnsScheduledVoteKey, + DpnsVoteOperationId, + }; #[test] fn correlated_vote_result_routes_when_active_contests_is_hidden() { @@ -3844,6 +3897,54 @@ mod dpns_result_routing_tests { &result, )); } + + #[test] + fn cleared_scheduled_votes_route_when_active_contests_is_hidden() { + assert!(dpns_result_needs_hidden_active_contests_route( + RootScreenType::RootScreenDPNSScheduledVotes, + true, + &BackendTaskSuccessResult::ScheduledVotesCleared(Vec::new()), + )); + } + + #[test] + fn scheduled_vote_clear_feedback_reports_removed_and_retained_counts() { + let outcome = |name: &str, disposition| DpnsScheduledVoteClearOutcome { + operation_id: None, + key: DpnsScheduledVoteKey { + network: Network::Testnet, + voter_id: Identifier::from([name.len() as u8; 32]), + contested_name: name.to_owned(), + }, + disposition, + }; + let outcomes = vec![ + outcome("removed", DpnsScheduledVoteClearDisposition::Cleared), + outcome( + "queued", + DpnsScheduledVoteClearDisposition::InFlight(DpnsVoteTargetStatus::Queued), + ), + outcome( + "submitting", + DpnsScheduledVoteClearDisposition::InFlight(DpnsVoteTargetStatus::Submitting), + ), + ]; + + assert_eq!( + scheduled_vote_clear_feedback(&outcomes), + ( + "1 scheduled vote was removed. 2 votes already in progress remain listed. Wait for them to finish before trying again.".to_owned(), + MessageType::Info, + ) + ); + assert_eq!( + scheduled_vote_clear_feedback(&outcomes[..1]), + ( + "1 scheduled vote was removed.".to_owned(), + MessageType::Success, + ) + ); + } } #[cfg(test)] diff --git a/src/context/dpns_vote_operations.rs b/src/context/dpns_vote_operations.rs index da3c05f13..c98a7ebfb 100644 --- a/src/context/dpns_vote_operations.rs +++ b/src/context/dpns_vote_operations.rs @@ -972,27 +972,19 @@ impl AppContext { .map(|outcome| (operation.id, outcome.status)) }), None => { - if let Some(operation_id) = lock_index.get(key) { - let operation = operations - .iter() - .find(|operation| operation.id == *operation_id) - .ok_or(TaskError::DpnsVoteOperationRecordMissing)?; - let outcome = operation - .outcome(key) - .ok_or(TaskError::DpnsVoteOperationRecordMissing)?; - Some((operation.id, outcome.status)) - } else { - operations - .iter() - .filter_map(|operation| { - operation - .outcome(key) - .filter(|outcome| !outcome.status.holds_lock()) - .map(|outcome| (operation.created_at, operation.id, outcome.status)) - }) - .max_by_key(|(created_at, operation_id, _)| (*created_at, *operation_id)) - .map(|(_, operation_id, status)| (operation_id, status)) + if lock_index.contains_key(key) { + return Err(TaskError::DpnsScheduledVoteAlreadyStarted); } + operations + .iter() + .filter_map(|operation| { + operation + .outcome(key) + .filter(|outcome| !outcome.status.holds_lock()) + .map(|outcome| (operation.created_at, operation.id, outcome.status)) + }) + .max_by_key(|(created_at, operation_id, _)| (*created_at, *operation_id)) + .map(|(_, operation_id, status)| (operation_id, status)) } }; @@ -1159,7 +1151,6 @@ impl AppContext { } } - let _surviving_mirror_keys = durable_scheduled_vote_keys(&kv, self.network)?; prune_terminal_operations(&kv, self.network)?; Ok(outcomes.into_values().map(|(_, outcome)| outcome).collect()) } @@ -1572,6 +1563,60 @@ mod tests { ); } + #[test] + fn legacy_removal_without_expected_operation_deletes_an_unlocked_mirror() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let context = crate::context::test_support::test_app_context(temp_dir.path()); + let kv = kv(); + context.set_det_kv_override_for_test(kv); + let voter_id = Identifier::from([12; 32]); + let key = DpnsVoteTargetKey { + network: context.network, + voter_id, + vote_poll_id: Identifier::from([13; 32]), + }; + context + .insert_scheduled_votes(&[ScheduledDPNSVote { + contested_name: "legacy-only".to_owned(), + voter_id, + choice: ResourceVoteChoice::Lock, + unix_timestamp: 42, + executed_successfully: false, + }]) + .unwrap(); + + context + .remove_scheduled_dpns_vote(None, &key, "legacy-only") + .unwrap(); + + assert!(context.get_scheduled_votes().unwrap().is_empty()); + } + + #[test] + fn legacy_removal_without_expected_operation_preserves_a_locked_mirror() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let context = crate::context::test_support::test_app_context(temp_dir.path()); + let kv = kv(); + context.set_det_kv_override_for_test(kv); + let mut scheduled = + scheduled_operation(DpnsVoteTargetStatus::Scheduled, 12, "locked-target"); + let key = scheduled.targets[0].target.key.clone(); + context + .insert_dpns_vote_operation(&mut scheduled, None) + .unwrap(); + context + .insert_scheduled_votes(&[scheduled_vote(&scheduled)]) + .unwrap(); + + assert!(matches!( + context + .remove_scheduled_dpns_vote(None, &key, "locked-target") + .expect_err("a current journal lock must preserve the mirror"), + TaskError::DpnsScheduledVoteAlreadyStarted + )); + assert_eq!(context.get_scheduled_votes().unwrap().len(), 1); + } + #[test] fn clear_all_cancels_pending_and_retains_every_in_flight_target() { let temp_dir = tempfile::tempdir().expect("tempdir"); diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index ba70fb4f4..6297c469c 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -1,6 +1,6 @@ use crate::wallet_backend::poison::MutexRecover; +use std::collections::BTreeSet; use std::sync::{Arc, Mutex}; -use tracing::error; use chrono::{DateTime, LocalResult, NaiveDate, NaiveTime, TimeZone, Timelike, Utc}; use chrono_humanize::HumanTime; @@ -12,7 +12,7 @@ use eframe::egui::{self, Button, Color32, ComboBox, Label, RichText, Ui}; use egui_extras::{Column, TableBuilder}; use crate::app::{AppAction, DesiredAppAction, scheduled_vote_sweep_is_quiet}; -use crate::backend_task::contested_names::{ContestedResourceTask, ScheduledDPNSVote}; +use crate::backend_task::contested_names::ContestedResourceTask; use crate::backend_task::error::TaskError; use crate::backend_task::identity::IdentityTask; use crate::backend_task::{BackendTask, BackendTaskContext}; @@ -20,8 +20,8 @@ use crate::context::AppContext; use crate::model::contested_name::{ContestState, ContestedName}; use crate::model::dpns::normalize_dpns_label; use crate::model::dpns_voting::{ - DpnsCurrentVoteState, DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteTarget, - DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, + DpnsCurrentVoteState, DpnsScheduledVoteKey, DpnsVoteOperation, DpnsVoteOperationId, + DpnsVoteTarget, DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, }; use crate::model::qualified_identity::{DPNSNameInfo, QualifiedIdentity}; use crate::ui::components::component_trait::Component; @@ -34,7 +34,8 @@ use crate::ui::components::tools_subscreen_chooser_panel::add_tools_subscreen_ch use crate::ui::components::top_panel::{add_top_panel_with_global_nav, subdued_everyday_spec}; use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; use crate::ui::identities::register_dpns_name_screen::RegisterDpnsNameSource; -use crate::ui::state::dpns_vote_operations::DpnsVoteOperationSnapshot; +use crate::ui::state::dpns_contests::{ActiveDpnsContestSnapshot, ActiveDpnsContestView}; +use crate::ui::state::dpns_vote_operations::{DpnsVoteOperationSnapshot, ScheduledDpnsVoteRow}; use crate::ui::state::dpns_vote_state::DpnsVoteStateSnapshot; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use crate::ui::{BackendTaskSuccessResult, MessageType, RootScreenType, ScreenLike, ScreenType}; @@ -153,6 +154,42 @@ fn target_status_label(status: DpnsVoteTargetStatus) -> &'static str { } } +fn scheduled_vote_remove_enabled(status: DpnsVoteTargetStatus) -> bool { + !matches!( + status, + DpnsVoteTargetStatus::Queued + | DpnsVoteTargetStatus::Submitting + | DpnsVoteTargetStatus::Confirming + | DpnsVoteTargetStatus::Unconfirmed + ) +} + +fn scheduled_vote_cast_enabled(status: DpnsVoteTargetStatus, dispatch_pending: bool) -> bool { + !dispatch_pending + && matches!( + status, + DpnsVoteTargetStatus::Scheduled + | DpnsVoteTargetStatus::Rejected + | DpnsVoteTargetStatus::FailedBeforeSubmission + | DpnsVoteTargetStatus::Cancelled + | DpnsVoteTargetStatus::NotApplied + ) +} + +fn scheduled_vote_removal_task(row: &ScheduledDpnsVoteRow) -> ContestedResourceTask { + match &row.journal_target { + Some((operation_id, key)) => ContestedResourceTask::CancelScheduledDpnsVote { + operation_id: *operation_id, + key: key.clone(), + contested_name: row.vote.contested_name.clone(), + }, + None => ContestedResourceTask::DeleteScheduledVote( + row.vote.voter_id, + row.vote.contested_name.clone(), + ), + } +} + fn dpns_operation_id(context: &BackendTaskContext) -> Option { match context { BackendTaskContext::Dispatched { operation, .. } => dpns_operation_id(operation), @@ -187,15 +224,6 @@ pub enum VoteOption { Scheduled { days: u32, hours: u32, minutes: u32 }, } -/// Tracks the casting status for each scheduled vote item. -#[derive(Clone, Copy, PartialEq, Eq)] -pub enum ScheduledVoteCastingStatus { - NotStarted, - InProgress, - Failed, - Completed, -} - #[derive(PartialEq)] pub enum VoteHandlingStatus { NotStarted, @@ -235,9 +263,9 @@ pub struct DPNSScreen { voting_identities: Vec, user_identities: Vec, contested_names: Arc>>, + active_contests: ActiveDpnsContestSnapshot, local_dpns_names: Arc>>, - pub scheduled_votes: Arc>>, - pub scheduled_vote_cast_in_progress: bool, + scheduled_votes: Arc>>, pub selected_votes: Vec, pub app_context: Arc, pending_backend_task: Option, @@ -245,6 +273,7 @@ pub struct DPNSScreen { vote_state: DpnsVoteStateSnapshot, vote_overlay: Option, pending_vote_operation: Option, + pending_scheduled_casts: BTreeSet, clear_vote_overlay_on_error: bool, scheduled_clear_dialog: Option<(bool, ConfirmationDialog)>, @@ -272,13 +301,34 @@ pub struct DPNSScreen { impl DPNSScreen { pub fn new(app_context: &Arc, dpns_subscreen: DPNSSubscreen) -> Self { + let vote_operations = + DpnsVoteOperationSnapshot::load(app_context).unwrap_or_else(|error| { + tracing::warn!( + ?error, + "Could not cache DPNS vote operations for the DPNS screen" + ); + DpnsVoteOperationSnapshot::default() + }); + let legacy_scheduled_votes = app_context.get_scheduled_votes().unwrap_or_default(); + let scheduled_votes = Arc::new(Mutex::new( + vote_operations.scheduled_vote_rows(&legacy_scheduled_votes), + )); + // Load contested names, local dpns, scheduled, etc.: let contested_names = Arc::new(Mutex::new(match dpns_subscreen { - DPNSSubscreen::Active => app_context.ongoing_contested_names().unwrap_or_default(), + DPNSSubscreen::Active => Vec::new(), DPNSSubscreen::Past => app_context.all_contested_names().unwrap_or_default(), DPNSSubscreen::Owned => Vec::new(), DPNSSubscreen::ScheduledVotes => app_context.all_contested_names().unwrap_or_default(), })); + let active_contests = if dpns_subscreen == DPNSSubscreen::Active { + ActiveDpnsContestSnapshot::new( + app_context, + app_context.ongoing_contested_names().unwrap_or_default(), + ) + } else { + ActiveDpnsContestSnapshot::default() + }; let local_dpns_names = Arc::new(Mutex::new(match dpns_subscreen { DPNSSubscreen::Active => Vec::new(), @@ -287,41 +337,11 @@ impl DPNSScreen { DPNSSubscreen::ScheduledVotes => Vec::new(), })); - let scheduled_votes = app_context.get_scheduled_votes().unwrap_or_default(); - let scheduled_votes_with_status = Arc::new(Mutex::new( - scheduled_votes - .iter() - .map(|vote| { - if vote.executed_successfully { - (vote.clone(), ScheduledVoteCastingStatus::Completed) - } else { - (vote.clone(), ScheduledVoteCastingStatus::NotStarted) - } - }) - .collect::>(), - )); - let voting_identities = app_context .load_local_voting_identities() .unwrap_or_default(); let user_identities = app_context.load_local_user_identities().unwrap_or_default(); - let vote_operations = - DpnsVoteOperationSnapshot::load(app_context).unwrap_or_else(|error| { - tracing::warn!( - ?error, - "Could not cache DPNS vote operations for the DPNS screen" - ); - DpnsVoteOperationSnapshot::default() - }); - let vote_poll_ids = contested_names - .lock_recover() - .iter() - .filter_map(|contest| { - app_context - .dpns_vote_poll_id(&contest.normalized_contested_name) - .ok() - }) - .collect::>(); + let vote_poll_ids = active_contests.vote_poll_ids(); let voter_ids = voting_identities .iter() .map(|identity| identity.identity.id()) @@ -341,8 +361,9 @@ impl DPNSScreen { voting_identities, user_identities, contested_names, + active_contests, local_dpns_names, - scheduled_votes: scheduled_votes_with_status, + scheduled_votes, selected_votes: Vec::new(), app_context: app_context.clone(), sort_column: SortColumn::ContestedName, @@ -350,12 +371,12 @@ impl DPNSScreen { active_filter_term: String::new(), past_filter_term: String::new(), owned_filter_term: String::new(), - scheduled_vote_cast_in_progress: false, pending_backend_task: None, vote_operations, vote_state, vote_overlay: None, pending_vote_operation: None, + pending_scheduled_casts: BTreeSet::new(), clear_vote_overlay_on_error: false, scheduled_clear_dialog: None, dpns_subscreen, @@ -535,15 +556,10 @@ impl DPNSScreen { ui.add_space(8.0); let filter = normalize_dpns_label(&self.active_filter_term); - let contests = self - .contested_names - .lock_recover() - .iter() - .cloned() - .collect::>(); + let contests = self.active_contests.contests(); let mut groups = [Vec::new(), Vec::new(), Vec::new()]; - for contest in contests { - let index = match self.contest_group(&contest) { + for contest in contests.iter() { + let index = match self.contest_group(contest.vote_poll_id) { ActiveContestGroup::NeedsVote => 0, ActiveContestGroup::Voted => 1, ActiveContestGroup::NotVotable => 2, @@ -554,7 +570,7 @@ impl DPNSScreen { let closes_within_day = groups[0] .iter() .filter(|contest| { - contest.end_time.is_some_and(|end_time| { + contest.contest.end_time.is_some_and(|end_time| { let remaining = end_time as i64 - Utc::now().timestamp_millis(); remaining > 0 && remaining <= chrono::Duration::days(1).num_milliseconds() }) @@ -573,6 +589,7 @@ impl DPNSScreen { for group in &mut groups { group.retain(|contest| { contest + .contest .normalized_contested_name .to_lowercase() .contains(&filter) @@ -615,11 +632,8 @@ impl DPNSScreen { }); } - fn contest_group(&self, contest: &ContestedName) -> ActiveContestGroup { - let Ok(poll_id) = self - .app_context - .dpns_vote_poll_id(&contest.normalized_contested_name) - else { + fn contest_group(&self, vote_poll_id: Option) -> ActiveContestGroup { + let Some(poll_id) = vote_poll_id else { return ActiveContestGroup::NotVotable; }; classify_vote_states( @@ -633,7 +647,7 @@ impl DPNSScreen { &mut self, ui: &mut Ui, title: &str, - contests: &[ContestedName], + contests: &[&ActiveDpnsContestView], default_open: bool, voting_enabled: bool, show_current_vote: bool, @@ -646,20 +660,23 @@ impl DPNSScreen { } for contest in contests { let contest_enabled = - voting_enabled && self.contest_has_available_target(contest); + voting_enabled && self.contest_has_available_target(contest.vote_poll_id); ui.add_enabled_ui(contest_enabled, |ui| { - self.render_contest_card(ui, contest, contest_enabled, show_current_vote); + self.render_contest_card( + ui, + contest.contest.as_ref(), + contest.vote_poll_id, + contest_enabled, + show_current_vote, + ); }); ui.add_space(8.0); } }); } - fn contest_has_available_target(&self, contest: &ContestedName) -> bool { - let Ok(vote_poll_id) = self - .app_context - .dpns_vote_poll_id(&contest.normalized_contested_name) - else { + fn contest_has_available_target(&self, vote_poll_id: Option) -> bool { + let Some(vote_poll_id) = vote_poll_id else { return false; }; self.voting_identities.iter().any(|identity| { @@ -678,11 +695,8 @@ impl DPNSScreen { }) } - fn proved_vote_for_contest(&self, contest: &ContestedName) -> ProvedVoteSummary { - let Ok(poll_id) = self - .app_context - .dpns_vote_poll_id(&contest.normalized_contested_name) - else { + fn proved_vote_for_contest(&self, vote_poll_id: Option) -> ProvedVoteSummary { + let Some(poll_id) = vote_poll_id else { return ProvedVoteSummary::None; }; proved_vote_summary( @@ -711,6 +725,7 @@ impl DPNSScreen { &mut self, ui: &mut Ui, contest: &ContestedName, + vote_poll_id: Option, voting_enabled: bool, show_current_vote: bool, ) { @@ -721,7 +736,7 @@ impl DPNSScreen { .find(|vote| vote.contested_name == contest.normalized_contested_name) .map(|vote| vote.vote_choice); let proved = if show_current_vote { - self.proved_vote_for_contest(contest) + self.proved_vote_for_contest(vote_poll_id) } else { ProvedVoteSummary::None }; @@ -1300,9 +1315,8 @@ impl DPNSScreen { let guard = self.scheduled_votes.lock_recover(); guard.clone() }; - // Sort by contested_name or time sorted_votes.sort_by(|a, b| { - let order = a.0.contested_name.cmp(&b.0.contested_name); + let order = a.vote.contested_name.cmp(&b.vote.contested_name); if self.sort_order == SortOrder::Descending { order.reverse() } else { @@ -1358,47 +1372,40 @@ impl DPNSScreen { }); }) .body(|mut body| { - for vote in sorted_votes.iter_mut() { - let operation_status = self - .app_context - .dpns_vote_poll_id(&vote.0.contested_name) - .ok() - .and_then(|vote_poll_id| { - self.vote_operations - .target_status(&DpnsVoteTargetKey { - network: self.app_context.network(), - voter_id: vote.0.voter_id, - vote_poll_id, - }) - }); + for scheduled_row in &sorted_votes { + let vote = &scheduled_row.vote; + let pending_key = DpnsScheduledVoteKey { + network: scheduled_row + .journal_target + .as_ref() + .map_or(self.app_context.network(), |(_, key)| key.network), + voter_id: vote.voter_id, + contested_name: vote.contested_name.clone(), + }; body.row(25.0, |mut row| { - // Contested name row.col(|ui| { - ui.add(Label::new(format!("{}.dash", vote.0.contested_name))); + ui.add(Label::new(format!("{}.dash", vote.contested_name))); }); - // Voter row.col(|ui| { let voter = self .voting_identities .iter() - .find(|identity| identity.identity.id() == vote.0.voter_id) + .find(|identity| identity.identity.id() == vote.voter_id) .and_then(|identity| identity.alias.clone()) - .unwrap_or_else(|| short_identifier(vote.0.voter_id)); + .unwrap_or_else(|| short_identifier(vote.voter_id)); ui.add(Label::new(voter)); }); - // Choice row.col(|ui| { let candidate_name = - self.candidate_name(&vote.0.contested_name, vote.0.choice); + self.candidate_name(&vote.contested_name, vote.choice); let display_text = - vote_choice_label(vote.0.choice, candidate_name.as_deref()); + vote_choice_label(vote.choice, candidate_name.as_deref()); ui.add(Label::new(display_text)); }); - // Time row.col(|ui| { let dark_mode = ui.style().visuals.dark_mode; if let LocalResult::Single(dt) = - Utc.timestamp_millis_opt(vote.0.unix_timestamp as i64) + Utc.timestamp_millis_opt(vote.unix_timestamp as i64) { let iso = dt.format("%Y-%m-%d %H:%M:%S").to_string(); let rel_time = HumanTime::from(dt).to_string(); @@ -1419,129 +1426,55 @@ impl DPNSScreen { ); } }); - // Status row.col(|ui| { let dark_mode = ui.style().visuals.dark_mode; - if matches!( - operation_status, - Some(DpnsVoteTargetStatus::Queued) - | Some(DpnsVoteTargetStatus::Submitting) - | Some(DpnsVoteTargetStatus::Confirming) - ) { - ui.label( - RichText::new("Submitting…") - .color(DashColors::text_primary(dark_mode)), - ); - return; - } - if operation_status == Some(DpnsVoteTargetStatus::Unconfirmed) { - ui.colored_label( - DashColors::warning_color(dark_mode), - "Checking result", - ); - return; - } - match vote.1 { - ScheduledVoteCastingStatus::NotStarted => { - ui.label( - RichText::new("Pending") - .color(DashColors::text_primary(dark_mode)), - ); - } - ScheduledVoteCastingStatus::InProgress => { - ui.label( - RichText::new("Casting...") - .color(DashColors::text_primary(dark_mode)), - ); - } - ScheduledVoteCastingStatus::Failed => { - ui.colored_label( - DashColors::error_color(dark_mode), - "Failed", - ); - } - ScheduledVoteCastingStatus::Completed => { - ui.colored_label( - DashColors::success_color(dark_mode), - "Cast", - ); - } - } + ui.label( + RichText::new(target_status_label(scheduled_row.status)) + .color(DashColors::text_primary(dark_mode)), + ); }); - // Actions row.col(|ui| { - let target_is_busy = matches!( - operation_status, - Some(DpnsVoteTargetStatus::Queued) - | Some(DpnsVoteTargetStatus::Submitting) - | Some(DpnsVoteTargetStatus::Confirming) - | Some(DpnsVoteTargetStatus::Unconfirmed) - ); + let remove_enabled = + scheduled_vote_remove_enabled(scheduled_row.status); if ui - .add_enabled(!target_is_busy, Button::new("Remove")) + .add_enabled(remove_enabled, Button::new("Remove")) .disabled_tooltip( "This scheduled vote cannot be removed while its result is being checked.", ) .clicked() { - action = - AppAction::BackendTask(BackendTask::ContestedResourceTask( - ContestedResourceTask::DeleteScheduledVote( - vote.0.voter_id, - vote.0.contested_name.clone(), - ), - )); + action = AppAction::BackendTask( + BackendTask::ContestedResourceTask( + scheduled_vote_removal_task(scheduled_row), + ), + ); } - // If the user wants to do "Cast Now" from here, they can - // if NotStarted or Failed. If in progress or done, disabled. - let cast_button_enabled = matches!( - vote.1, - ScheduledVoteCastingStatus::NotStarted - | ScheduledVoteCastingStatus::Failed - ) && !target_is_busy; - - let cast_button = if cast_button_enabled { - Button::new("Cast now") - } else { - Button::new("Cast now").sense(egui::Sense::hover()) - }; - - if ui.add(cast_button).clicked() && cast_button_enabled { - self.scheduled_vote_cast_in_progress = true; - vote.1 = ScheduledVoteCastingStatus::InProgress; - - // Mark in our Arc as well - if let Ok(mut sched_guard) = self.scheduled_votes.lock() - && let Some(t) = sched_guard.iter_mut().find(|(sv, _)| { - sv.voter_id == vote.0.voter_id - && sv.contested_name == vote.0.contested_name - }) - { - t.1 = ScheduledVoteCastingStatus::InProgress; - } - // dispatch the actual cast - let local_ids = - match self.app_context.load_local_voting_identities() { - Ok(ids) => ids, - Err(e) => { - error!("{}", e); - return; - } - }; - if let Some(found) = local_ids + let cast_button_enabled = scheduled_vote_cast_enabled( + scheduled_row.status, + self.pending_scheduled_casts.contains(&pending_key), + ); + if ui + .add_enabled(cast_button_enabled, Button::new("Cast now")) + .clicked() + && let Some(found) = self + .voting_identities .iter() - .find(|i| i.identity.id() == vote.0.voter_id) - { + .find(|identity| { + identity.identity.id() == vote.voter_id + }) + .cloned() + { + self.pending_scheduled_casts + .insert(pending_key.clone()); action = AppAction::BackendTask( - BackendTask::ContestedResourceTask( - ContestedResourceTask::CastScheduledVote( - vote.0.clone(), - Box::new(found.clone()), - ), + BackendTask::ContestedResourceTask( + ContestedResourceTask::CastScheduledVote( + vote.clone(), + Box::new(found), ), - ); - show_cast_overlay = true; - } + ), + ); + show_cast_overlay = true; } }); }); @@ -1582,6 +1515,19 @@ impl DPNSScreen { let ResourceVoteChoice::TowardsIdentity(candidate_id) = choice else { return None; }; + if let Some(candidate_name) = self + .active_contests + .contest(contested_name) + .and_then(|contest| contest.contestants.as_ref()) + .and_then(|contestants| { + contestants + .iter() + .find(|candidate| candidate.id == candidate_id) + }) + .map(|candidate| candidate.name.clone()) + { + return Some(candidate_name); + } self.contested_names .lock_recover() .iter() @@ -1595,6 +1541,12 @@ impl DPNSScreen { .map(|candidate| candidate.name.clone()) } + fn rebuild_scheduled_vote_rows(&mut self) { + let legacy_votes = self.app_context.get_scheduled_votes().unwrap_or_default(); + *self.scheduled_votes.lock_recover() = + self.vote_operations.scheduled_vote_rows(&legacy_votes); + } + fn show_review_and_cast_window(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; @@ -2108,75 +2060,40 @@ impl DPNSScreen { // --------------------------- impl ScreenLike for DPNSScreen { fn refresh(&mut self) { - self.scheduled_vote_cast_in_progress = false; if let Err(error) = self.vote_operations.refresh(&self.app_context) { tracing::warn!(?error, "Could not refresh cached DPNS vote operations"); } - let mut contested_names = self.contested_names.lock_recover(); - let mut dpns_names = self.local_dpns_names.lock_recover(); - let mut scheduled_votes = self.scheduled_votes.lock_recover(); + self.rebuild_scheduled_vote_rows(); match self.dpns_subscreen { DPNSSubscreen::Active => { - *contested_names = self - .app_context - .ongoing_contested_names() - .unwrap_or_default(); + self.active_contests = ActiveDpnsContestSnapshot::new( + &self.app_context, + self.app_context + .ongoing_contested_names() + .unwrap_or_default(), + ); } DPNSSubscreen::Past => { - *contested_names = self.app_context.all_contested_names().unwrap_or_default(); + *self.contested_names.lock_recover() = + self.app_context.all_contested_names().unwrap_or_default(); } DPNSSubscreen::Owned => { - *dpns_names = self.app_context.local_dpns_names().unwrap_or_default(); + *self.local_dpns_names.lock_recover() = + self.app_context.local_dpns_names().unwrap_or_default(); } DPNSSubscreen::ScheduledVotes => { - *contested_names = self.app_context.all_contested_names().unwrap_or_default(); - let new_scheduled = self.app_context.get_scheduled_votes().unwrap_or_default(); - *scheduled_votes = new_scheduled - .iter() - .map(|newv| { - if newv.executed_successfully { - (newv.clone(), ScheduledVoteCastingStatus::Completed) - } else if let Some(existing) = scheduled_votes.iter().find(|(old, _)| { - old.contested_name == newv.contested_name - && old.voter_id == newv.voter_id - }) { - // preserve old status if InProgress/Failed - match existing.1 { - ScheduledVoteCastingStatus::InProgress => { - (newv.clone(), ScheduledVoteCastingStatus::InProgress) - } - ScheduledVoteCastingStatus::Failed => { - (newv.clone(), ScheduledVoteCastingStatus::Failed) - } - _ => (newv.clone(), ScheduledVoteCastingStatus::NotStarted), - } - } else { - (newv.clone(), ScheduledVoteCastingStatus::NotStarted) - } - }) - .collect(); + *self.contested_names.lock_recover() = + self.app_context.all_contested_names().unwrap_or_default(); } } - drop(contested_names); - drop(dpns_names); - drop(scheduled_votes); let voter_ids = self .voting_identities .iter() .map(|identity| identity.identity.id()) .collect::>(); - let poll_ids = self - .contested_names - .lock_recover() - .iter() - .filter_map(|contest| { - self.app_context - .dpns_vote_poll_id(&contest.normalized_contested_name) - .ok() - }) - .collect::>(); + let poll_ids = self.active_contests.vote_poll_ids(); if let Err(error) = self .vote_state .refresh(&self.app_context, &voter_ids, &poll_ids) @@ -2214,6 +2131,7 @@ impl ScreenLike for DPNSScreen { } fn display_task_error(&mut self, error: &TaskError) -> bool { + self.pending_scheduled_casts.clear(); if self.clear_vote_overlay_on_error { self.vote_overlay.take_and_clear(); self.pending_vote_operation = None; @@ -2225,30 +2143,14 @@ impl ScreenLike for DPNSScreen { "Could not refresh DPNS voting state after a task error" ); } - let handled = scheduled_vote_sweep_is_quiet(error); - if matches!( - error, - TaskError::ScheduledVoteRejected { .. } - | TaskError::ScheduledVoteAllAddressesExhausted { .. } - | TaskError::ScheduledVoteResultUnavailable - | TaskError::ScheduledVoteSweepFailed { .. } - | TaskError::ScheduledVoteSweepAllAddressesExhausted { .. } - ) { - self.scheduled_vote_cast_in_progress = false; - if let Ok(mut guard) = self.scheduled_votes.lock() { - for vote in guard.iter_mut() { - if vote.1 == ScheduledVoteCastingStatus::InProgress { - vote.1 = ScheduledVoteCastingStatus::Failed; - } - } - } - } - handled + self.rebuild_scheduled_vote_rows(); + scheduled_vote_sweep_is_quiet(error) } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { match backend_task_success_result { BackendTaskSuccessResult::DpnsVoteOperationUpdated { operation_id, .. } => { + self.pending_scheduled_casts.clear(); if let Err(error) = self.vote_state.reload(&self.app_context) { tracing::warn!( ?error, @@ -2267,23 +2169,29 @@ impl ScreenLike for DPNSScreen { self.bulk_vote_handling_status = VoteHandlingStatus::Completed; } } + if let Err(error) = self.vote_operations.refresh(&self.app_context) { + tracing::warn!( + ?error, + "Could not refresh scheduled votes after an operation update" + ); + } + self.rebuild_scheduled_vote_rows(); } - BackendTaskSuccessResult::ScheduledVotesInProgress(votes) => { + BackendTaskSuccessResult::ScheduledVotesInProgress(_) => { if let Err(error) = self.vote_operations.refresh(&self.app_context) { tracing::warn!(?error, "Could not refresh scheduled-vote operation state"); } - // The periodic sweep is about to cast these votes; reflect that - // in the list so the user sees them move before results land. - self.scheduled_vote_cast_in_progress = true; - if let Ok(mut guard) = self.scheduled_votes.lock() { - for vote in &votes { - if let Some((_, status)) = guard.iter_mut().find(|(v, _)| { - v.contested_name == vote.contested_name && v.voter_id == vote.voter_id - }) { - *status = ScheduledVoteCastingStatus::InProgress; - } - } + self.rebuild_scheduled_vote_rows(); + } + BackendTaskSuccessResult::ScheduledVotesCleared(_) => { + self.pending_scheduled_casts.clear(); + if let Err(error) = self.vote_operations.refresh(&self.app_context) { + tracing::warn!( + ?error, + "Could not refresh scheduled votes after clearing them" + ); } + self.rebuild_scheduled_vote_rows(); } BackendTaskSuccessResult::RefreshedDpnsContests | BackendTaskSuccessResult::RefreshedOwnedDpnsNames => { @@ -2429,7 +2337,7 @@ impl ScreenLike for DPNSScreen { // Render sub-screen match self.dpns_subscreen { DPNSSubscreen::Active => { - let has_any = !self.contested_names.lock_recover().is_empty(); + let has_any = !self.active_contests.is_empty(); if self.voting_identities.is_empty() { inner_action |= self.render_no_voting_nodes(ui); } else if has_any { @@ -2528,6 +2436,7 @@ impl ScreenLike for DPNSScreen { #[cfg(test)] mod tests { use super::*; + use crate::backend_task::contested_names::ScheduledDPNSVote; use crate::context::connection_status::ConnectionStatus; use crate::database::test_helpers::create_database_at_path; use crate::model::user_role::UserRoleCell; @@ -2556,6 +2465,14 @@ mod tests { (ctx, temp_dir) } + fn pending_scheduled_key(context: &AppContext) -> DpnsScheduledVoteKey { + DpnsScheduledVoteKey { + network: context.network(), + voter_id: Identifier::from([1; 32]), + contested_name: "pending".to_owned(), + } + } + #[test] fn vote_submission_overlay_clears_when_the_operation_finishes() { let (ctx, _temp_dir) = offline_ctx(); @@ -2578,6 +2495,9 @@ mod tests { crate::ui::components::progress_overlay::ProgressOverlay::has_global(ctx.egui_ctx()) ); + screen + .pending_scheduled_casts + .insert(pending_scheduled_key(&ctx)); screen.display_task_result(BackendTaskSuccessResult::DpnsVoteOperationUpdated { network: ctx.network(), operation_id: DpnsVoteOperationId::from_bytes([7; 16]), @@ -2586,6 +2506,7 @@ mod tests { assert!( !crate::ui::components::progress_overlay::ProgressOverlay::has_global(ctx.egui_ctx()) ); + assert!(screen.pending_scheduled_casts.is_empty()); } #[test] @@ -2709,9 +2630,11 @@ mod tests { }), }; - screen.scheduled_vote_cast_in_progress = true; + screen + .pending_scheduled_casts + .insert(pending_scheduled_key(&ctx)); assert!(screen.display_task_error(&error)); - assert!(!screen.scheduled_vote_cast_in_progress); + assert!(screen.pending_scheduled_casts.is_empty()); } #[test] @@ -2719,9 +2642,11 @@ mod tests { let (ctx, _temp_dir) = offline_ctx(); let mut screen = DPNSScreen::new(&ctx, DPNSSubscreen::ScheduledVotes); - screen.scheduled_vote_cast_in_progress = true; + screen + .pending_scheduled_casts + .insert(pending_scheduled_key(&ctx)); assert!(!screen.display_task_error(&TaskError::ScheduledVoteResultUnavailable)); - assert!(!screen.scheduled_vote_cast_in_progress); + assert!(screen.pending_scheduled_casts.is_empty()); let sweep_error = TaskError::ScheduledVoteSweepFailed { network: Network::Regtest, @@ -2729,7 +2654,9 @@ mod tests { }; assert!(!screen.display_task_error(&sweep_error)); - screen.scheduled_vote_cast_in_progress = true; + screen + .pending_scheduled_casts + .insert(pending_scheduled_key(&ctx)); let exhausted_error = TaskError::ScheduledVoteSweepAllAddressesExhausted { network: Network::Regtest, source: Box::new(TaskError::DapiNoAddresses { @@ -2739,6 +2666,136 @@ mod tests { }), }; assert!(!screen.display_task_error(&exhausted_error)); - assert!(!screen.scheduled_vote_cast_in_progress); + assert!(screen.pending_scheduled_casts.is_empty()); + } + + #[test] + fn scheduled_vote_actions_follow_the_journal_status() { + for status in [ + DpnsVoteTargetStatus::Scheduled, + DpnsVoteTargetStatus::Confirmed, + DpnsVoteTargetStatus::Rejected, + DpnsVoteTargetStatus::FailedBeforeSubmission, + DpnsVoteTargetStatus::Cancelled, + DpnsVoteTargetStatus::NotApplied, + ] { + assert!(scheduled_vote_remove_enabled(status), "{status:?}"); + } + for status in [ + DpnsVoteTargetStatus::Queued, + DpnsVoteTargetStatus::Submitting, + DpnsVoteTargetStatus::Confirming, + DpnsVoteTargetStatus::Unconfirmed, + ] { + assert!(!scheduled_vote_remove_enabled(status), "{status:?}"); + } + for status in [ + DpnsVoteTargetStatus::Scheduled, + DpnsVoteTargetStatus::Rejected, + DpnsVoteTargetStatus::FailedBeforeSubmission, + DpnsVoteTargetStatus::Cancelled, + DpnsVoteTargetStatus::NotApplied, + ] { + assert!(scheduled_vote_cast_enabled(status, false), "{status:?}"); + } + for status in [ + DpnsVoteTargetStatus::Queued, + DpnsVoteTargetStatus::Submitting, + DpnsVoteTargetStatus::Confirming, + DpnsVoteTargetStatus::Confirmed, + DpnsVoteTargetStatus::Unconfirmed, + ] { + assert!(!scheduled_vote_cast_enabled(status, false), "{status:?}"); + } + assert!(!scheduled_vote_cast_enabled( + DpnsVoteTargetStatus::Scheduled, + true + )); + } + + #[test] + fn scheduled_vote_removal_dispatches_the_journal_or_legacy_task() { + let operation_id = DpnsVoteOperationId::from_bytes([31; 16]); + let key = DpnsVoteTargetKey { + network: Network::Testnet, + voter_id: Identifier::from([32; 32]), + vote_poll_id: Identifier::from([33; 32]), + }; + let vote = ScheduledDPNSVote { + contested_name: "dispatch".to_owned(), + voter_id: key.voter_id, + choice: ResourceVoteChoice::Lock, + unix_timestamp: 42, + executed_successfully: false, + }; + let journal_row = ScheduledDpnsVoteRow { + vote: vote.clone(), + journal_target: Some((operation_id, key.clone())), + status: DpnsVoteTargetStatus::Scheduled, + }; + let legacy_row = ScheduledDpnsVoteRow { + vote, + journal_target: None, + status: DpnsVoteTargetStatus::Scheduled, + }; + + assert!(matches!( + scheduled_vote_removal_task(&journal_row), + ContestedResourceTask::CancelScheduledDpnsVote { + operation_id: id, + key: task_key, + contested_name, + } if id == operation_id && task_key == key && contested_name == "dispatch" + )); + assert!(matches!( + scheduled_vote_removal_task(&legacy_row), + ContestedResourceTask::DeleteScheduledVote(voter_id, contested_name) + if voter_id == Identifier::from([32; 32]) && contested_name == "dispatch" + )); + } + + #[test] + fn clear_all_result_rebuilds_scheduled_rows_immediately() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let kv = crate::wallet_backend::DetKv::from_store(Arc::new( + crate::wallet_backend::kv_test_support::InMemoryKv::default(), + )); + let ctx = crate::context::test_support::test_app_context_with_kv( + temp_dir.path(), + Arc::new(kv.clone()), + ); + ctx.set_det_kv_override_for_test(kv); + let voter_id = Identifier::from([21; 32]); + let mut operation = DpnsVoteOperation::new(vec![DpnsVoteTarget { + key: DpnsVoteTargetKey { + network: ctx.network(), + voter_id, + vote_poll_id: Identifier::from([22; 32]), + }, + voter_alias: None, + contested_name: "clear-me".to_owned(), + requested_choice: ResourceVoteChoice::Lock, + current_choice: None, + timing: VoteTiming::Scheduled(42), + }]); + ctx.insert_dpns_vote_operation(&mut operation, None) + .expect("insert scheduled operation"); + ctx.insert_scheduled_votes(&[ScheduledDPNSVote { + contested_name: "clear-me".to_owned(), + voter_id, + choice: ResourceVoteChoice::Lock, + unix_timestamp: 42, + executed_successfully: false, + }]) + .expect("insert compatibility mirror"); + let mut screen = DPNSScreen::new(&ctx, DPNSSubscreen::ScheduledVotes); + assert_eq!(screen.scheduled_votes.lock_recover().len(), 1); + + let outcomes = ctx + .clear_all_scheduled_dpns_votes() + .expect("clear scheduled votes"); + screen.display_task_result(BackendTaskSuccessResult::ScheduledVotesCleared(outcomes)); + + assert!(screen.scheduled_votes.lock_recover().is_empty()); } } diff --git a/src/ui/state/dpns_contests.rs b/src/ui/state/dpns_contests.rs new file mode 100644 index 000000000..aaa49f2b8 --- /dev/null +++ b/src/ui/state/dpns_contests.rs @@ -0,0 +1,107 @@ +//! Cached Active-contest data for the DPNS screen. + +use crate::context::AppContext; +use crate::model::contested_name::ContestedName; +use dash_sdk::platform::Identifier; +use std::sync::Arc; + +/// One Active contest with its precomputed Platform vote-poll identifier. +#[derive(Debug, Clone)] +pub struct ActiveDpnsContestView { + pub contest: Arc, + pub vote_poll_id: Option, +} + +/// Immutable render snapshot for the Active contests screen. +#[derive(Debug, Clone, Default)] +pub struct ActiveDpnsContestSnapshot { + contests: Arc<[ActiveDpnsContestView]>, +} + +impl ActiveDpnsContestSnapshot { + /// Build a snapshot while resolving each contest's vote-poll identifier once. + pub(crate) fn new(app_context: &AppContext, contests: Vec) -> Self { + Self::build(contests, |name| app_context.dpns_vote_poll_id(name).ok()) + } + + pub(crate) fn contests(&self) -> Arc<[ActiveDpnsContestView]> { + Arc::clone(&self.contests) + } + + pub(crate) fn is_empty(&self) -> bool { + self.contests.is_empty() + } + + pub(crate) fn vote_poll_ids(&self) -> Vec { + self.contests + .iter() + .filter_map(|view| view.vote_poll_id) + .collect() + } + + pub(crate) fn contest(&self, contested_name: &str) -> Option<&ContestedName> { + self.contests + .iter() + .find(|view| view.contest.normalized_contested_name == contested_name) + .map(|view| view.contest.as_ref()) + } + + fn build( + contests: Vec, + mut poll_id: impl FnMut(&str) -> Option, + ) -> Self { + let contests = contests + .into_iter() + .map(|contest| { + let vote_poll_id = poll_id(&contest.normalized_contested_name); + ActiveDpnsContestView { + contest: Arc::new(contest), + vote_poll_id, + } + }) + .collect::>(); + Self { + contests: Arc::from(contests), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::contested_name::ContestState; + use dash_sdk::platform::Identifier; + use std::collections::BTreeMap; + + fn contest(name: &str) -> ContestedName { + ContestedName { + normalized_contested_name: name.to_owned(), + contestants: None, + locked_votes: None, + abstain_votes: None, + awarded_to: None, + end_time: None, + state: ContestState::Ongoing, + last_updated: None, + my_votes: BTreeMap::new(), + } + } + + #[test] + fn active_contest_snapshot_computes_each_poll_id_once() { + let mut calls = Vec::new(); + + let snapshot = + ActiveDpnsContestSnapshot::build(vec![contest("alice"), contest("bob")], |name| { + calls.push(name.to_owned()); + Some(Identifier::from([calls.len() as u8; 32])) + }); + + assert_eq!(calls, ["alice", "bob"]); + let contests = snapshot.contests(); + assert_eq!(contests.len(), 2); + assert_eq!(contests[0].contest.normalized_contested_name, "alice"); + assert_eq!(contests[0].vote_poll_id, Some(Identifier::from([1; 32]))); + assert_eq!(contests[1].vote_poll_id, Some(Identifier::from([2; 32]))); + } +} diff --git a/src/ui/state/dpns_vote_operations.rs b/src/ui/state/dpns_vote_operations.rs index 46949dce8..0ebcc49a7 100644 --- a/src/ui/state/dpns_vote_operations.rs +++ b/src/ui/state/dpns_vote_operations.rs @@ -1,13 +1,21 @@ //! Per-screen DPNS vote-operation snapshot for immediate-mode render paths. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; +use crate::backend_task::contested_names::ScheduledDPNSVote; use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::dpns_voting::{ - DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteTargetKey, DpnsVoteTargetStatus, + DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, }; +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ScheduledDpnsVoteRow { + pub vote: ScheduledDPNSVote, + pub journal_target: Option<(DpnsVoteOperationId, DpnsVoteTargetKey)>, + pub status: DpnsVoteTargetStatus, +} + #[derive(Debug, Clone, Default)] pub struct DpnsVoteOperationSnapshot { operations: Vec, @@ -43,6 +51,75 @@ impl DpnsVoteOperationSnapshot { self.loaded } + pub(crate) fn scheduled_vote_rows( + &self, + legacy_votes: &[ScheduledDPNSVote], + ) -> Vec { + let mut journal_rows = BTreeMap::< + (dash_sdk::platform::Identifier, String), + ((u64, usize), ScheduledDpnsVoteRow), + >::new(); + for (operation_index, operation) in self.operations.iter().enumerate() { + for outcome in &operation.targets { + let VoteTiming::Scheduled(timestamp) = outcome.target.timing else { + continue; + }; + let pair = ( + outcome.target.key.voter_id, + outcome.target.contested_name.clone(), + ); + let rank = (operation.created_at, operation_index); + if journal_rows + .get(&pair) + .is_some_and(|(current_rank, _)| *current_rank > rank) + { + continue; + } + journal_rows.insert( + pair, + ( + rank, + ScheduledDpnsVoteRow { + vote: ScheduledDPNSVote { + contested_name: outcome.target.contested_name.clone(), + voter_id: outcome.target.key.voter_id, + choice: outcome.target.requested_choice, + unix_timestamp: timestamp, + executed_successfully: outcome.status + == DpnsVoteTargetStatus::Confirmed, + }, + journal_target: Some((operation.id, outcome.target.key.clone())), + status: outcome.status, + }, + ), + ); + } + } + + let journal_pairs = journal_rows.keys().cloned().collect::>(); + let mut rows = journal_rows + .into_values() + .map(|(_, row)| row) + .collect::>(); + rows.extend( + legacy_votes + .iter() + .filter(|vote| { + !journal_pairs.contains(&(vote.voter_id, vote.contested_name.clone())) + }) + .map(|vote| ScheduledDpnsVoteRow { + vote: vote.clone(), + journal_target: None, + status: if vote.executed_successfully { + DpnsVoteTargetStatus::Confirmed + } else { + DpnsVoteTargetStatus::Scheduled + }, + }), + ); + rows + } + fn replace(&mut self, operations: Vec) { self.target_statuses = operations .iter() @@ -58,6 +135,7 @@ impl DpnsVoteOperationSnapshot { #[cfg(test)] mod tests { use super::*; + use crate::backend_task::contested_names::ScheduledDPNSVote; use crate::model::dpns_voting::{DpnsVoteTarget, VoteTiming}; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; @@ -100,4 +178,175 @@ mod tests { assert_eq!(snapshot.operations(), &[live, terminal]); assert!(snapshot.is_loaded()); } + + fn scheduled_operation( + created_at: u64, + status: DpnsVoteTargetStatus, + choice: ResourceVoteChoice, + timestamp: u64, + ) -> DpnsVoteOperation { + let mut operation = DpnsVoteOperation::new(vec![DpnsVoteTarget { + key: DpnsVoteTargetKey { + network: Network::Testnet, + voter_id: Identifier::from([7; 32]), + vote_poll_id: Identifier::from([8; 32]), + }, + voter_alias: Some("node-7".to_owned()), + contested_name: "alice".to_owned(), + requested_choice: choice, + current_choice: None, + timing: VoteTiming::Scheduled(timestamp), + }]); + operation.created_at = created_at; + operation.targets[0].status = status; + operation + } + + fn legacy_vote( + voter_id: Identifier, + name: &str, + choice: ResourceVoteChoice, + timestamp: u64, + executed_successfully: bool, + ) -> ScheduledDPNSVote { + ScheduledDPNSVote { + contested_name: name.to_owned(), + voter_id, + choice, + unix_timestamp: timestamp, + executed_successfully, + } + } + + #[test] + fn scheduled_rows_prefer_the_newest_journal_outcome_over_legacy_data() { + let older = scheduled_operation( + 10, + DpnsVoteTargetStatus::Confirmed, + ResourceVoteChoice::Lock, + 100, + ); + let newer = scheduled_operation( + 20, + DpnsVoteTargetStatus::Rejected, + ResourceVoteChoice::Abstain, + 200, + ); + let expected_id = newer.id; + let expected_key = newer.targets[0].target.key.clone(); + let legacy = legacy_vote( + Identifier::from([7; 32]), + "alice", + ResourceVoteChoice::Lock, + 999, + true, + ); + let mut snapshot = DpnsVoteOperationSnapshot::default(); + snapshot.replace(vec![older, newer]); + + let rows = snapshot.scheduled_vote_rows(&[legacy]); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].journal_target, Some((expected_id, expected_key))); + assert_eq!(rows[0].status, DpnsVoteTargetStatus::Rejected); + assert_eq!(rows[0].vote.choice, ResourceVoteChoice::Abstain); + assert_eq!(rows[0].vote.unix_timestamp, 200); + assert!(!rows[0].vote.executed_successfully); + } + + #[test] + fn scheduled_rows_use_later_persisted_order_when_created_times_match() { + let first = scheduled_operation( + 10, + DpnsVoteTargetStatus::Rejected, + ResourceVoteChoice::Lock, + 100, + ); + let second = scheduled_operation( + 10, + DpnsVoteTargetStatus::Cancelled, + ResourceVoteChoice::Abstain, + 200, + ); + let expected_id = second.id; + let mut snapshot = DpnsVoteOperationSnapshot::default(); + snapshot.replace(vec![first, second]); + + let rows = snapshot.scheduled_vote_rows(&[]); + + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0].journal_target.as_ref().map(|(id, _)| *id), + Some(expected_id) + ); + assert_eq!(rows[0].status, DpnsVoteTargetStatus::Cancelled); + assert_eq!(rows[0].vote.unix_timestamp, 200); + } + + #[test] + fn scheduled_rows_derive_compatibility_execution_only_from_confirmed_status() { + let confirmed = scheduled_operation( + 10, + DpnsVoteTargetStatus::Confirmed, + ResourceVoteChoice::Lock, + 100, + ); + let mut snapshot = DpnsVoteOperationSnapshot::default(); + snapshot.replace(vec![confirmed]); + + let rows = snapshot.scheduled_vote_rows(&[]); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].status, DpnsVoteTargetStatus::Confirmed); + assert!(rows[0].vote.executed_successfully); + } + + #[test] + fn scheduled_rows_append_only_unseen_legacy_pairs() { + let journal = scheduled_operation( + 10, + DpnsVoteTargetStatus::Scheduled, + ResourceVoteChoice::Lock, + 100, + ); + let voter_id = journal.targets[0].target.key.voter_id; + let mut snapshot = DpnsVoteOperationSnapshot::default(); + snapshot.replace(vec![journal]); + let legacy = [ + legacy_vote(voter_id, "alice", ResourceVoteChoice::Abstain, 999, true), + legacy_vote( + Identifier::from([9; 32]), + "confirmed-legacy", + ResourceVoteChoice::Lock, + 300, + true, + ), + legacy_vote( + Identifier::from([10; 32]), + "pending-legacy", + ResourceVoteChoice::Abstain, + 400, + false, + ), + ]; + + let rows = snapshot.scheduled_vote_rows(&legacy); + + assert_eq!(rows.len(), 3); + assert!(rows.iter().any(|row| { + row.vote.contested_name == "alice" + && row.status == DpnsVoteTargetStatus::Scheduled + && row.journal_target.is_some() + })); + assert!(rows.iter().any(|row| { + row.vote.contested_name == "confirmed-legacy" + && row.status == DpnsVoteTargetStatus::Confirmed + && row.journal_target.is_none() + })); + assert!(rows.iter().any(|row| { + row.vote.contested_name == "pending-legacy" + && row.status == DpnsVoteTargetStatus::Scheduled + && row.journal_target.is_none() + })); + } } diff --git a/src/ui/state/mod.rs b/src/ui/state/mod.rs index b744f286f..ddaac43e0 100644 --- a/src/ui/state/mod.rs +++ b/src/ui/state/mod.rs @@ -8,6 +8,7 @@ pub mod account_summary; pub mod avatar_cache; pub mod contacts_view; +pub mod dpns_contests; pub mod dpns_vote_operations; pub mod dpns_vote_state; pub mod global_nav; From fcbc1dc41a59945d075756f32ab78fafe7b5a7de Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:03:22 +0000 Subject: [PATCH 36/39] fix(dpns): unstick the Review and cast window after a failed submission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DPNSScreen::display_task_error() cleared the progress overlay and the pending operation id when a vote submission failed, but left bulk_vote_handling_status on CastingVotes/SchedulingVotes. That status is what show_review_and_cast_window() uses as operation_in_progress, and it disables the Submit button *and* the Cancel button; the egui::Window has no close control either. Only display_task_result() ever moved the status out of the in-flight state, so a failed submission left the modal frozen on "Submitting votes…" with no way to retry, cancel, or dismiss it β€” and the state survives navigating away and back, since the screen instance is kept in AppState::main_screens. Reproduces whenever the backend task returns Err after dispatch, e.g. DpnsCurrentVoteUnavailable: the screen's cached proved-vote snapshot is fresh enough to build targets, then the backend's pre-submission refresh_dpns_vote_states() fails against an unreachable DAPI. Move the status to Failed() alongside the existing overlay cleanup, gated on the same clear_vote_overlay_on_error ownership check so an unrelated task error cannot disturb a submission still in flight. The window already renders Failed inline and re-enables both buttons, and TaskError's Display is the user-facing text by convention. Adds failed_submission_releases_the_review_window (covers both in-flight statuses) and unrelated_error_keeps_the_pending_submission_in_progress. Co-Authored-By: Claude Opus 5 --- src/ui/dpns/dpns_contested_names_screen.rs | 76 ++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index 6297c469c..58409fbfb 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -2136,6 +2136,16 @@ impl ScreenLike for DPNSScreen { self.vote_overlay.take_and_clear(); self.pending_vote_operation = None; self.clear_vote_overlay_on_error = false; + // The review window disables both Submit and Cancel while a + // submission is in flight, so a failed submission must leave that + // state here. Otherwise the window stays on "Submitting votes…" + // with no way to retry or close it. + if matches!( + self.bulk_vote_handling_status, + VoteHandlingStatus::CastingVotes | VoteHandlingStatus::SchedulingVotes + ) { + self.bulk_vote_handling_status = VoteHandlingStatus::Failed(error.to_string()); + } } if let Err(refresh_error) = self.vote_operations.refresh(&self.app_context) { tracing::warn!( @@ -2754,6 +2764,72 @@ mod tests { )); } + /// A failed submission must release the review window's in-flight state. + /// While it is held, the window disables Submit *and* Cancel and offers no + /// close control, so a stuck status leaves the user with no way out. + #[test] + fn failed_submission_releases_the_review_window() { + let (ctx, _temp_dir) = offline_ctx(); + let mut screen = DPNSScreen::new(&ctx, DPNSSubscreen::Active); + let operation_id = DpnsVoteOperationId::from_bytes([11; 16]); + let context = BackendTaskContext::DpnsVoteOperation { + network: ctx.network(), + operation_id, + }; + let error = TaskError::DpnsCurrentVoteUnavailable; + + for in_progress in [ + VoteHandlingStatus::CastingVotes, + VoteHandlingStatus::SchedulingVotes, + ] { + screen.show_bulk_schedule_popup = true; + screen.bulk_vote_handling_status = in_progress; + screen.pending_vote_operation = Some(operation_id); + screen.raise_vote_overlay(ctx.egui_ctx(), "Submitting the selected votes…"); + + screen.display_backend_task_error(&context, &error); + screen.display_task_error(&error); + + assert!( + !crate::ui::components::progress_overlay::ProgressOverlay::has_global( + ctx.egui_ctx() + ) + ); + assert_eq!(screen.pending_vote_operation, None); + assert!( + screen.bulk_vote_handling_status == VoteHandlingStatus::Failed(error.to_string()), + "the review window must leave its in-flight state so Submit and Cancel work again" + ); + } + } + + /// An error belonging to a different operation must not disturb the review + /// window of the submission this screen is still waiting on. + #[test] + fn unrelated_error_keeps_the_pending_submission_in_progress() { + let (ctx, _temp_dir) = offline_ctx(); + let mut screen = DPNSScreen::new(&ctx, DPNSSubscreen::Active); + let error = TaskError::DpnsCurrentVoteUnavailable; + screen.show_bulk_schedule_popup = true; + screen.bulk_vote_handling_status = VoteHandlingStatus::CastingVotes; + screen.pending_vote_operation = Some(DpnsVoteOperationId::from_bytes([11; 16])); + + screen.display_backend_task_error( + &BackendTaskContext::DpnsVoteOperation { + network: ctx.network(), + operation_id: DpnsVoteOperationId::from_bytes([12; 16]), + }, + &error, + ); + screen.display_task_error(&error); + + assert_eq!( + screen.pending_vote_operation, + Some(DpnsVoteOperationId::from_bytes([11; 16])) + ); + assert!(screen.bulk_vote_handling_status == VoteHandlingStatus::CastingVotes); + } + #[test] fn clear_all_result_rebuilds_scheduled_rows_immediately() { let temp_dir = tempfile::tempdir().expect("tempdir"); From cbb4afa09122f8ac5cf936bcf12fde002ce71f06 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:47:33 +0000 Subject: [PATCH 37/39] fix(dpns): make the vote review honest, Remove final, and keyless nodes blocked Three blocking defects in the unified DPNS voting flow. Review and cast lied about what it would send. It printed one bullet per contest and a raw node count, so a multi-node batch never showed its real node x contest targets, the choice already on chain, the per-target timing, or the targets DpnsVoteOperation::new silently drops as no-ops after Submit. The sheet and the submit click now share one resolved plan, so the sheet cannot promise something other than what is sent: every retained target is listed with node, contest, requested choice, current choice and timing, the skipped no-op count is stated, the headline counts the effective targets, and Submit is disabled when nothing would be submitted. No-op suppression now has a single definition, DpnsVoteTarget::is_no_op, shared by the review and the operation; the operation still receives the unfiltered list so its no_op_count keeps driving the post-submit feedback. Remove on a scheduled vote did not remove it. The cancellation write was durable and correct, but scheduled_vote_rows projected every scheduled-timing outcome back into the table including Cancelled, so the row returned on the next refresh with a second Remove button that did nothing. The projection now honours the cancellation and drops the row, while keeping the target's pair in the legacy-suppression set so a mirror row that outlived a best-effort delete cannot resurrect it. Pruning the journal could not have fixed this: a bulk schedule is one operation with many targets, so removing one row leaves the operation incomplete and unprunable. Clear All keeps its bulk prune. A masternode loaded without its voting key reached the composer and only failed at submit time with NoVotingIdentity, because the gate filtered on identity type alone. The DPNS surfaces now keep only identities that satisfy the submit path (QualifiedIdentity::can_cast_masternode_vote), so a read-only node lands on the actionable "no voting key" state with Load a masternode before any vote is composed. VOTE-FR-024, VOTE-FR-025 and VOTE-TC-013 of the DPNS voting experience design. Co-Authored-By: Claude Opus 5 --- docs/user-stories.md | 4 +- src/model/dpns_voting.rs | 13 +- src/model/qualified_identity/mod.rs | 22 + src/ui/dpns/dpns_contested_names_screen.rs | 626 ++++++++++++++++++--- src/ui/state/dpns_vote_operations.rs | 79 ++- 5 files changed, 649 insertions(+), 95 deletions(-) diff --git a/docs/user-stories.md b/docs/user-stories.md index edd1709f2..9c05f8fdf 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -755,6 +755,7 @@ As a masternode operator, I want to schedule votes for later execution so that I - Set vote to be cast at a future time. - View and manage scheduled votes under DPNS β†’ Scheduled votes, which remains available in the persistent DPNS subnavigation. - Scheduled and immediate votes share the same target locks and result states. +- Removing a scheduled vote takes it off the list for good; it does not come back on the next refresh. - An ambiguous result remains visible for checking and is never automatically rebroadcast. ### DPN-007: Batch voting across contests [Implemented] @@ -764,8 +765,9 @@ As a masternode operator, I want to apply voting choices across multiple contest - Review and cast defaults to all loaded voting nodes and Cast now. - The advanced per-node disclosure can set each node to Cast now, Schedule, or Do not use this node. -- When no loaded node has a voting key, Active contests shows an actionable Load a masternode state instead of vote controls. +- When no loaded node has a voting key, Active contests shows an actionable Load a masternode state instead of vote controls. A masternode loaded without its voting key is not a voting node, so it reaches that state instead of the composer. - Per-node timing overrides and multi-contest selections create exact node Γ— contest targets. +- Review and cast lists each of those targets with its node, contest, requested choice, current choice, and timing, and reports how many targets it skipped because the node already holds the requested choice. - Immediate and scheduled targets submitted together belong to one operation. ### DPN-010: Recover an ambiguous vote result [Implemented] diff --git a/src/model/dpns_voting.rs b/src/model/dpns_voting.rs index 485faead5..3c6c646a7 100644 --- a/src/model/dpns_voting.rs +++ b/src/model/dpns_voting.rs @@ -227,6 +227,17 @@ pub struct DpnsVoteTarget { pub timing: VoteTiming, } +impl DpnsVoteTarget { + /// Whether submitting this target would change nothing on Platform. + /// + /// The single definition of no-op suppression: the review step filters on + /// it before the operator commits, and [`DpnsVoteOperation::new`] applies + /// it again when the batch is built. + pub fn is_no_op(&self) -> bool { + self.current_choice == Some(self.requested_choice) + } +} + /// Persistable, user-meaningful failure category without task diagnostics. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum DpnsVoteFailure { @@ -297,7 +308,7 @@ impl DpnsVoteOperation { let original_len = targets.len(); let targets = targets .into_iter() - .filter(|target| target.current_choice != Some(target.requested_choice)) + .filter(|target| !target.is_no_op()) .map(|target| { let status = match target.timing { VoteTiming::Now => DpnsVoteTargetStatus::Queued, diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index 56a5161ad..31cf9e4f1 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -579,6 +579,18 @@ impl QualifiedIdentity { .map_err(|e| format!("Failed to decode QualifiedIdentity: {}", e)) } + /// Whether a masternode vote signed by this identity can reach Platform. + /// + /// True only with a loaded voter identity and its public key β€” the exact + /// precondition the vote submission path enforces. Stricter than + /// [`Self::masternode_key_presence`]'s `voting` flag, which also reports a + /// bare [`Purpose::VOTING`] main key that the vote path cannot sign with. + /// Voting surfaces must gate on this, so a read-only node is refused before + /// the operator composes a vote rather than at submission time. + pub fn can_cast_masternode_vote(&self) -> bool { + self.associated_voter_identity.is_some() + } + /// Which masternode/evonode key roles are loaded for this identity. /// /// Voting presence is signalled by a loaded voter identity @@ -2171,6 +2183,16 @@ mod masternode_key_presence_tests { let presence = qi_with(false, &[]).masternode_key_presence(); assert_eq!(presence, MasternodeKeyPresence::default()); } + + /// VOTE-TC-013: only a loaded voter identity satisfies the vote submission + /// path. A read-only node, and a node carrying nothing but a `VOTING`-purpose + /// main key, must both be refused before a vote is composed. + #[test] + fn only_a_loaded_voter_identity_can_cast_a_masternode_vote() { + assert!(qi_with(true, &[]).can_cast_masternode_vote()); + assert!(!qi_with(false, &[]).can_cast_masternode_vote()); + assert!(!qi_with(false, &[Purpose::VOTING]).can_cast_masternode_vote()); + } } #[cfg(test)] diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index 58409fbfb..ee2fc9a40 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -190,6 +190,106 @@ fn scheduled_vote_removal_task(row: &ScheduledDpnsVoteRow) -> ContestedResourceT } } +/// The loaded nodes that can actually cast a vote. +/// +/// A masternode loaded read-only passes the identity-type filter but has no +/// voting key, so the DPNS surfaces must drop it here β€” otherwise it reaches the +/// composer and only fails once the vote is submitted. +fn loaded_voting_identities(app_context: &AppContext) -> Vec { + app_context + .load_local_voting_identities() + .unwrap_or_default() + .into_iter() + .filter(QualifiedIdentity::can_cast_masternode_vote) + .collect() +} + +/// One reviewed node Γ— contest line, carrying the timing text the operator chose. +struct ReviewEntry { + target: DpnsVoteTarget, + timing_label: String, +} + +impl ReviewEntry { + fn node_label(&self) -> String { + self.target + .voter_alias + .clone() + .unwrap_or_else(|| short_identifier(self.target.key.voter_id)) + } +} + +/// Exactly what a submit click would send, resolved before the review is shown. +struct ReviewPlan { + entries: Vec, + voters: Vec, +} + +impl ReviewPlan { + /// The targets that will really be submitted, no-op targets removed. + fn effective(&self) -> impl Iterator { + self.entries.iter().filter(|entry| !entry.target.is_no_op()) + } + + fn effective_count(&self) -> usize { + self.effective().count() + } + + fn no_op_count(&self) -> usize { + self.entries.len() - self.effective_count() + } + + fn node_count(&self) -> usize { + self.effective() + .map(|entry| entry.target.key.voter_id) + .collect::>() + .len() + } + + fn has_immediate(&self) -> bool { + self.effective() + .any(|entry| matches!(entry.target.timing, VoteTiming::Now)) + } + + fn has_scheduled(&self) -> bool { + self.effective() + .any(|entry| matches!(entry.target.timing, VoteTiming::Scheduled(_))) + } + + fn changes_an_existing_vote(&self) -> bool { + self.effective() + .any(|entry| entry.target.current_choice.is_some()) + } +} + +fn review_headline(effective_count: usize, node_count: usize) -> String { + format!("Votes to submit ({effective_count}) from your nodes ({node_count}).") +} + +fn review_target_line( + node: &str, + name: &str, + requested: &str, + current: &str, + timing: &str, +) -> String { + format!("β€’ {node} β†’ {name}.dash: {requested}. Current vote: {current}. {timing}.") +} + +fn review_skipped_line(no_op_count: usize) -> String { + format!("Targets skipped because the node already has that choice ({no_op_count}).") +} + +fn review_current_choice_label( + current: Option, + candidate_name: Option<&str>, +) -> String { + match current { + Some(choice) => review_vote_choice_label(choice, candidate_name), + None => "Not voted yet".to_owned(), + } +} + fn dpns_operation_id(context: &BackendTaskContext) -> Option { match context { BackendTaskContext::Dispatched { operation, .. } => dpns_operation_id(operation), @@ -337,9 +437,7 @@ impl DPNSScreen { DPNSSubscreen::ScheduledVotes => Vec::new(), })); - let voting_identities = app_context - .load_local_voting_identities() - .unwrap_or_default(); + let voting_identities = loaded_voting_identities(app_context); let user_identities = app_context.load_local_user_identities().unwrap_or_default(); let vote_poll_ids = active_contests.vote_poll_ids(); let voter_ids = voting_identities @@ -1592,18 +1690,61 @@ impl DPNSScreen { return action; } + self.bulk_identity_options + .resize(self.voting_identities.len(), VoteOption::CastNow); + let plan = self.build_review_plan(); + let mut simple_schedule_valid = true; egui::ScrollArea::vertical().show(ui, |ui| { - ui.label(format!( - "Casting on behalf of all my nodes ({count}).", - count = self.voting_identities.len() - )); - ui.separator(); - ui.heading(format!("Votes to cast ({}):", self.selected_votes.len())); - for vote in &self.selected_votes { - let candidate_name = self.candidate_name(&vote.contested_name, vote.vote_choice); - let choice = review_vote_choice_label(vote.vote_choice, candidate_name.as_deref()); - ui.label(format!("β€’ {name}.dash β†’ {choice}", name = vote.contested_name)); + match &plan { + Ok(plan) => { + ui.heading(review_headline(plan.effective_count(), plan.node_count())); + for entry in plan.effective() { + let candidate_name = self.candidate_name( + &entry.target.contested_name, + entry.target.requested_choice, + ); + let requested = review_vote_choice_label( + entry.target.requested_choice, + candidate_name.as_deref(), + ); + let current_candidate_name = entry + .target + .current_choice + .and_then(|choice| { + self.candidate_name(&entry.target.contested_name, choice) + }); + let current = review_current_choice_label( + entry.target.current_choice, + current_candidate_name.as_deref(), + ); + ui.label(review_target_line( + &entry.node_label(), + &entry.target.contested_name, + &requested, + ¤t, + &entry.timing_label, + )); + } + if plan.no_op_count() > 0 { + ui.label(review_skipped_line(plan.no_op_count())); + } + if plan.changes_an_existing_vote() { + ui.colored_label( + DashColors::warning_color(dark_mode), + "Changing a vote uses one of the node's limited vote changes.", + ); + } + if plan.effective_count() == 0 { + ui.colored_label( + DashColors::warning_color(dark_mode), + "Nothing will be submitted. Choose at least one node and a vote it has not already cast.", + ); + } + } + Err(message) => { + ui.colored_label(DashColors::warning_color(dark_mode), message); + } } ui.separator(); ui.horizontal(|ui| { @@ -1691,11 +1832,6 @@ impl DPNSScreen { .color(DashColors::text_primary(dark_mode)), ); - if self.bulk_identity_options.len() <= i { - self.bulk_identity_options = - vec![VoteOption::CastNow; self.voting_identities.len()]; - } - let current_option = &mut self.bulk_identity_options[i]; ComboBox::from_id_salt(format!("combo_bulk_identity_{}", i)) .width(120.0) @@ -1800,35 +1936,36 @@ impl DPNSScreen { ui.label("Submitting votes…"); }); } - let has_immediate = self - .bulk_identity_options - .iter() - .any(|option| matches!(option, VoteOption::CastNow)); - let has_scheduled = self - .bulk_identity_options - .iter() - .any(|option| matches!(option, VoteOption::Scheduled { .. })); - let submit_label = match (has_immediate, has_scheduled) { - (true, false) => "Cast votes", - (false, true) => "Schedule votes", + let has_submittable = plan.as_ref().is_ok_and(|plan| plan.effective_count() > 0); + let submit_label = match plan + .as_ref() + .map(|plan| (plan.has_immediate(), plan.has_scheduled())) + { + Ok((true, false)) => "Cast votes", + Ok((false, true)) => "Schedule votes", _ => "Submit votes", }; + let submit_disabled_reason = if operation_in_progress { + "The selected votes are already being submitted." + } else if !simple_schedule_valid { + "Choose a future date and time before scheduling these votes." + } else if plan.is_err() { + "These votes cannot be submitted yet. Fix the problem shown above and try again." + } else { + "Nothing can be submitted. Choose at least one node and a vote it has not already cast." + }; let (submit_clicked, cancel_clicked) = ui .with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let submit_clicked = ComponentStyles::add_primary_button_enabled( ui, - !operation_in_progress && simple_schedule_valid, + !operation_in_progress && simple_schedule_valid && has_submittable, if operation_in_progress { "Submitting votes…" } else { submit_label }, ) - .disabled_tooltip(if operation_in_progress { - "The selected votes are already being submitted." - } else { - "Choose a future date and time before scheduling these votes." - }) + .disabled_tooltip(submit_disabled_reason) .clicked(); let cancel_clicked = ui .add_enabled( @@ -1885,94 +2022,110 @@ impl DPNSScreen { action } - fn bulk_apply_votes(&mut self) -> AppAction { - let mut targets = Vec::new(); - let mut selected_voters = Vec::new(); - let mut has_immediate = false; + /// Resolve every node Γ— contest target the current review would submit. + /// + /// The review sheet and the submit click share this so the sheet cannot + /// promise something other than what is sent. `Err` carries the message the + /// operator sees, and blocks submission. + fn build_review_plan(&self) -> Result { + let mut entries = Vec::new(); + let mut voters = Vec::new(); for (identity, option) in self .voting_identities .iter() .zip(&self.bulk_identity_options) { - let timing = match option { + let (timing, timing_label) = match option { VoteOption::NoVote => continue, - VoteOption::CastNow => { - has_immediate = true; - VoteTiming::Now - } + VoteOption::CastNow => (VoteTiming::Now, "Cast now".to_owned()), VoteOption::Scheduled { days, hours, minutes, } => { - let now = Utc::now(); let offset = chrono::Duration::days(*days as i64) + chrono::Duration::hours(*hours as i64) + chrono::Duration::minutes(*minutes as i64); - VoteTiming::Scheduled((now + offset).timestamp_millis() as u64) + ( + VoteTiming::Scheduled((Utc::now() + offset).timestamp_millis() as u64), + format!("Scheduled in {days} d {hours} h {minutes} min"), + ) } }; - selected_voters.push(identity.clone()); + voters.push(identity.clone()); for selected_vote in &self.selected_votes { let voter_id = identity.identity.id(); - let vote_poll_id = match self + let vote_poll_id = self .app_context .dpns_vote_poll_id(&selected_vote.contested_name) - { - Ok(vote_poll_id) => vote_poll_id, - Err(error) => { + .map_err(|error| { tracing::warn!( ?error, contested_name = selected_vote.contested_name, "Could not build a DPNS vote target" ); - self.bulk_vote_handling_status = VoteHandlingStatus::Failed( - "This vote could not be prepared. Refresh Active contests and try again." - .to_owned(), - ); - return AppAction::None; - } - }; + "This vote could not be prepared. Refresh Active contests and try again." + .to_owned() + })?; let target_key = DpnsVoteTargetKey { network: self.app_context.network(), voter_id, vote_poll_id, }; if self.vote_operations.target_status(&target_key).is_some() { - self.bulk_vote_handling_status = VoteHandlingStatus::Failed(format!( + return Err(format!( "This node's vote for {} is already in progress. Check its result before submitting again.", selected_vote.contested_name )); - return AppAction::None; } - let current_choice = match self.vote_state.state(voter_id, vote_poll_id) { - DpnsCurrentVoteState::Available(choice) => choice, - DpnsCurrentVoteState::Checking | DpnsCurrentVoteState::Unavailable => { - self.bulk_vote_handling_status = VoteHandlingStatus::Failed( - "Current vote state is unavailable. Refresh voting before applying votes." - .to_owned(), - ); - return AppAction::None; - } + let DpnsCurrentVoteState::Available(current_choice) = + self.vote_state.state(voter_id, vote_poll_id) + else { + return Err( + "Current vote state is unavailable. Refresh voting before applying votes." + .to_owned(), + ); }; - targets.push(DpnsVoteTarget { - key: target_key, - voter_alias: identity.alias.clone(), - contested_name: selected_vote.contested_name.clone(), - requested_choice: selected_vote.vote_choice, - current_choice, - timing, + entries.push(ReviewEntry { + target: DpnsVoteTarget { + key: target_key, + voter_alias: identity.alias.clone(), + contested_name: selected_vote.contested_name.clone(), + requested_choice: selected_vote.vote_choice, + current_choice, + timing, + }, + timing_label: timing_label.clone(), }); } } + Ok(ReviewPlan { entries, voters }) + } - if targets.is_empty() { + fn bulk_apply_votes(&mut self) -> AppAction { + let plan = match self.build_review_plan() { + Ok(plan) => plan, + Err(message) => { + self.bulk_vote_handling_status = VoteHandlingStatus::Failed(message); + return AppAction::None; + } + }; + let has_immediate = plan.has_immediate(); + let ReviewPlan { + entries, + voters: selected_voters, + } = plan; + + if entries.is_empty() { self.bulk_vote_handling_status = VoteHandlingStatus::Failed( "No votes selected. Choose at least one node and contest.".to_owned(), ); return AppAction::None; } - let operation = DpnsVoteOperation::new(targets); + // No-op targets are handed over unfiltered: the operation counts them, + // and that count drives the post-submit feedback. + let operation = + DpnsVoteOperation::new(entries.into_iter().map(|entry| entry.target).collect()); if operation.targets.is_empty() { self.bulk_vote_handling_status = VoteHandlingStatus::Failed( "Every selected node already has the requested vote. Nothing will be submitted." @@ -2103,10 +2256,7 @@ impl ScreenLike for DPNSScreen { } fn refresh_on_arrival(&mut self) { - self.voting_identities = self - .app_context - .load_local_voting_identities() - .unwrap_or_default(); + self.voting_identities = loaded_voting_identities(&self.app_context); self.bulk_identity_options = vec![VoteOption::CastNow; self.voting_identities.len()]; self.set_all_option = VoteOption::CastNow; self.user_identities = self @@ -2475,6 +2625,64 @@ mod tests { (ctx, temp_dir) } + fn kv_ctx() -> (Arc, tempfile::TempDir) { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let kv = crate::wallet_backend::DetKv::from_store(Arc::new( + crate::wallet_backend::kv_test_support::InMemoryKv::default(), + )); + let ctx = crate::context::test_support::test_app_context_with_kv( + temp_dir.path(), + Arc::new(kv.clone()), + ); + ctx.set_det_kv_override_for_test(kv); + (ctx, temp_dir) + } + + /// A masternode identity as the DPNS screen sees it: `voting` decides whether + /// its voter identity β€” the key the vote submission path needs β€” is loaded. + fn masternode_identity( + id: u8, + alias: &str, + voting: bool, + network: Network, + ) -> QualifiedIdentity { + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::IdentityPublicKey; + + let platform_version = PlatformVersion::latest(); + let identity = + Identity::create_basic_identity(Identifier::from([id; 32]), platform_version) + .expect("identity"); + let associated_voter_identity = voting.then(|| { + let voter = Identity::create_basic_identity( + Identifier::from([id.wrapping_add(100); 32]), + platform_version, + ) + .expect("voter identity"); + ( + voter, + IdentityPublicKey::random_key(0, Some(id as u64), platform_version), + ) + }); + QualifiedIdentity { + identity, + associated_voter_identity, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: crate::model::qualified_identity::IdentityType::Masternode, + alias: Some(alias.to_owned()), + private_keys: Default::default(), + dpns_names: vec![], + associated_wallets: std::collections::BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: Default::default(), + status: Default::default(), + network, + } + } + fn pending_scheduled_key(context: &AppContext) -> DpnsScheduledVoteKey { DpnsScheduledVoteKey { network: context.network(), @@ -2830,17 +3038,253 @@ mod tests { assert!(screen.bulk_vote_handling_status == VoteHandlingStatus::CastingVotes); } + /// VOTE-TC-013: a masternode loaded without its voting key must not reach the + /// composer β€” the actionable "no voting key" state has to come first. This is + /// distinct from having no masternode loaded at all. #[test] - fn clear_all_result_rebuilds_scheduled_rows_immediately() { - let temp_dir = tempfile::tempdir().expect("tempdir"); - let kv = crate::wallet_backend::DetKv::from_store(Arc::new( - crate::wallet_backend::kv_test_support::InMemoryKv::default(), - )); - let ctx = crate::context::test_support::test_app_context_with_kv( - temp_dir.path(), - Arc::new(kv.clone()), + fn a_loaded_masternode_without_a_voting_key_cannot_compose_votes() { + let (ctx, _temp_dir) = kv_ctx(); + ctx.insert_local_qualified_identity( + &masternode_identity(41, "read-only-node", false, ctx.network()), + &None, + ) + .expect("insert keyless masternode"); + + let screen = DPNSScreen::new(&ctx, DPNSSubscreen::Active); + + assert!( + !ctx.load_local_voting_identities() + .expect("load voting identities") + .is_empty(), + "the keyless masternode must still be loaded, or this proves nothing" ); - ctx.set_det_kv_override_for_test(kv); + assert!( + screen.voting_identities.is_empty(), + "a node with no voting key cannot satisfy the submit path" + ); + } + + #[test] + fn a_loaded_masternode_with_a_voting_key_can_compose_votes() { + let (ctx, _temp_dir) = kv_ctx(); + ctx.insert_local_qualified_identity( + &masternode_identity(42, "voting-node", true, ctx.network()), + &None, + ) + .expect("insert voting masternode"); + + let screen = DPNSScreen::new(&ctx, DPNSSubscreen::Active); + + assert_eq!(screen.voting_identities.len(), 1); + } + + /// The review sheet must expand every node Γ— contest pair, suppress the + /// targets that already hold the requested choice, and count what it dropped. + #[test] + fn review_plan_expands_every_node_and_contest_and_suppresses_no_ops() { + let (ctx, _temp_dir) = kv_ctx(); + let alpha = ctx.dpns_vote_poll_id("alpha").expect("alpha poll id"); + let beta = ctx.dpns_vote_poll_id("beta").expect("beta poll id"); + let first = masternode_identity(1, "node-one", true, ctx.network()); + let second = masternode_identity(2, "node-two", true, ctx.network()); + ctx.cache_confirmed_dpns_vote(first.identity.id(), alpha, ResourceVoteChoice::Lock) + .expect("seed first node's proved vote"); + ctx.cache_confirmed_dpns_vote(second.identity.id(), beta, ResourceVoteChoice::Abstain) + .expect("seed second node's proved vote"); + + let mut screen = DPNSScreen::new(&ctx, DPNSSubscreen::Active); + screen.voting_identities = vec![first.clone(), second.clone()]; + screen.bulk_identity_options = vec![ + VoteOption::CastNow, + VoteOption::Scheduled { + days: 1, + hours: 2, + minutes: 3, + }, + ]; + screen.selected_votes = vec![ + SelectedVote { + contested_name: "alpha".to_owned(), + vote_choice: ResourceVoteChoice::Lock, + end_time: None, + }, + SelectedVote { + contested_name: "beta".to_owned(), + vote_choice: ResourceVoteChoice::Abstain, + end_time: None, + }, + ]; + screen.vote_state = DpnsVoteStateSnapshot::load( + &ctx, + &[first.identity.id(), second.identity.id()], + &[alpha, beta], + ) + .expect("load proved vote state"); + + let plan = screen.build_review_plan().expect("review plan"); + + assert_eq!(plan.entries.len(), 4, "two nodes across two contests"); + assert_eq!(plan.effective_count(), 2); + assert_eq!(plan.no_op_count(), 2); + assert_eq!(plan.node_count(), 2); + assert!(plan.has_immediate()); + assert!(plan.has_scheduled()); + assert!(!plan.changes_an_existing_vote()); + let retained = plan + .effective() + .map(|entry| { + ( + entry.node_label(), + entry.target.contested_name.clone(), + entry.timing_label.clone(), + ) + }) + .collect::>(); + assert_eq!( + retained, + vec![ + ( + "node-one".to_owned(), + "beta".to_owned(), + "Cast now".to_owned() + ), + ( + "node-two".to_owned(), + "alpha".to_owned(), + "Scheduled in 1 d 2 h 3 min".to_owned() + ), + ] + ); + } + + /// A review whose every target is a no-op must submit nothing. + #[test] + fn review_plan_reports_an_all_no_op_selection_as_nothing_to_submit() { + let (ctx, _temp_dir) = kv_ctx(); + let alpha = ctx.dpns_vote_poll_id("alpha").expect("alpha poll id"); + let node = masternode_identity(3, "node-three", true, ctx.network()); + ctx.cache_confirmed_dpns_vote(node.identity.id(), alpha, ResourceVoteChoice::Lock) + .expect("seed proved vote"); + + let mut screen = DPNSScreen::new(&ctx, DPNSSubscreen::Active); + screen.voting_identities = vec![node.clone()]; + screen.bulk_identity_options = vec![VoteOption::CastNow]; + screen.selected_votes = vec![SelectedVote { + contested_name: "alpha".to_owned(), + vote_choice: ResourceVoteChoice::Lock, + end_time: None, + }]; + screen.vote_state = DpnsVoteStateSnapshot::load(&ctx, &[node.identity.id()], &[alpha]) + .expect("load proved vote state"); + + let plan = screen.build_review_plan().expect("review plan"); + + assert_eq!(plan.effective_count(), 0); + assert_eq!(plan.no_op_count(), 1); + assert_eq!(plan.node_count(), 0); + } + + /// A node that already voted differently is a vote change, and the review + /// must be able to say so. + #[test] + fn review_plan_flags_a_change_to_an_existing_vote() { + let (ctx, _temp_dir) = kv_ctx(); + let alpha = ctx.dpns_vote_poll_id("alpha").expect("alpha poll id"); + let node = masternode_identity(4, "node-four", true, ctx.network()); + ctx.cache_confirmed_dpns_vote(node.identity.id(), alpha, ResourceVoteChoice::Lock) + .expect("seed proved vote"); + + let mut screen = DPNSScreen::new(&ctx, DPNSSubscreen::Active); + screen.voting_identities = vec![node.clone()]; + screen.bulk_identity_options = vec![VoteOption::CastNow]; + screen.selected_votes = vec![SelectedVote { + contested_name: "alpha".to_owned(), + vote_choice: ResourceVoteChoice::Abstain, + end_time: None, + }]; + screen.vote_state = DpnsVoteStateSnapshot::load(&ctx, &[node.identity.id()], &[alpha]) + .expect("load proved vote state"); + + let plan = screen.build_review_plan().expect("review plan"); + + assert_eq!(plan.effective_count(), 1); + assert!(plan.changes_an_existing_vote()); + assert_eq!( + plan.effective() + .next() + .map(|entry| entry.target.current_choice), + Some(Some(ResourceVoteChoice::Lock)) + ); + } + + #[test] + fn review_lines_name_the_node_the_contest_the_choices_and_the_timing() { + let line = review_target_line("node-one", "alice", "Lock", "Not voted yet", "Cast now"); + + assert_eq!( + line, + "β€’ node-one β†’ alice.dash: Lock. Current vote: Not voted yet. Cast now." + ); + assert_eq!( + review_headline(6, 3), + "Votes to submit (6) from your nodes (3)." + ); + assert!(review_skipped_line(2).contains("(2)")); + assert_eq!( + review_current_choice_label(Some(ResourceVoteChoice::Abstain), None), + "Abstain" + ); + assert_eq!(review_current_choice_label(None, None), "Not voted yet"); + } + + /// Removing a scheduled vote must clear its row. The journal cancellation is + /// durable, so the row cannot come back with a Remove button that does nothing. + #[test] + fn removing_a_scheduled_vote_drops_its_row_from_the_table() { + let (ctx, _temp_dir) = kv_ctx(); + let voter_id = Identifier::from([23; 32]); + let key = DpnsVoteTargetKey { + network: ctx.network(), + voter_id, + vote_poll_id: Identifier::from([24; 32]), + }; + let mut operation = DpnsVoteOperation::new(vec![DpnsVoteTarget { + key: key.clone(), + voter_alias: None, + contested_name: "remove-me".to_owned(), + requested_choice: ResourceVoteChoice::Lock, + current_choice: None, + timing: VoteTiming::Scheduled(42), + }]); + ctx.insert_dpns_vote_operation(&mut operation, None) + .expect("insert scheduled operation"); + ctx.insert_scheduled_votes(&[ScheduledDPNSVote { + contested_name: "remove-me".to_owned(), + voter_id, + choice: ResourceVoteChoice::Lock, + unix_timestamp: 42, + executed_successfully: false, + }]) + .expect("insert compatibility mirror"); + let mut screen = DPNSScreen::new(&ctx, DPNSSubscreen::ScheduledVotes); + assert_eq!(screen.scheduled_votes.lock_recover().len(), 1); + + ctx.cancel_scheduled_dpns_vote_target(operation.id, &key, "remove-me") + .expect("remove the scheduled vote"); + screen.display_task_result(BackendTaskSuccessResult::DpnsVoteOperationUpdated { + network: ctx.network(), + operation_id: operation.id, + }); + + assert!( + screen.scheduled_votes.lock_recover().is_empty(), + "a removed schedule must not return to the table" + ); + } + + #[test] + fn clear_all_result_rebuilds_scheduled_rows_immediately() { + let (ctx, _temp_dir) = kv_ctx(); let voter_id = Identifier::from([21; 32]); let mut operation = DpnsVoteOperation::new(vec![DpnsVoteTarget { key: DpnsVoteTargetKey { diff --git a/src/ui/state/dpns_vote_operations.rs b/src/ui/state/dpns_vote_operations.rs index 0ebcc49a7..dec12a268 100644 --- a/src/ui/state/dpns_vote_operations.rs +++ b/src/ui/state/dpns_vote_operations.rs @@ -97,9 +97,13 @@ impl DpnsVoteOperationSnapshot { } let journal_pairs = journal_rows.keys().cloned().collect::>(); + // A cancelled target is a dismissed schedule. Its pair stays in + // `journal_pairs` so a mirror row that outlived a best-effort delete + // cannot bring the dismissed schedule back. let mut rows = journal_rows .into_values() .map(|(_, row)| row) + .filter(|row| row.status != DpnsVoteTargetStatus::Cancelled) .collect::>(); rows.extend( legacy_votes @@ -264,7 +268,7 @@ mod tests { ); let second = scheduled_operation( 10, - DpnsVoteTargetStatus::Cancelled, + DpnsVoteTargetStatus::NotApplied, ResourceVoteChoice::Abstain, 200, ); @@ -279,10 +283,81 @@ mod tests { rows[0].journal_target.as_ref().map(|(id, _)| *id), Some(expected_id) ); - assert_eq!(rows[0].status, DpnsVoteTargetStatus::Cancelled); + assert_eq!(rows[0].status, DpnsVoteTargetStatus::NotApplied); assert_eq!(rows[0].vote.unix_timestamp, 200); } + /// Removing a scheduled vote cancels its journal target. The row must then + /// disappear instead of returning with a Remove button that does nothing. + #[test] + fn cancelled_scheduled_targets_stop_projecting_a_row() { + let cancelled = scheduled_operation( + 10, + DpnsVoteTargetStatus::Cancelled, + ResourceVoteChoice::Lock, + 100, + ); + let mut snapshot = DpnsVoteOperationSnapshot::default(); + snapshot.replace(vec![cancelled]); + + assert!(snapshot.scheduled_vote_rows(&[]).is_empty()); + } + + /// The compatibility-mirror delete is best effort, so a cancelled target + /// must keep suppressing its legacy row even when the mirror survived. + #[test] + fn cancelled_targets_still_suppress_their_legacy_mirror_row() { + let cancelled = scheduled_operation( + 10, + DpnsVoteTargetStatus::Cancelled, + ResourceVoteChoice::Lock, + 100, + ); + let voter_id = cancelled.targets[0].target.key.voter_id; + let mut snapshot = DpnsVoteOperationSnapshot::default(); + snapshot.replace(vec![cancelled]); + let legacy = [ + legacy_vote(voter_id, "alice", ResourceVoteChoice::Lock, 100, false), + legacy_vote( + Identifier::from([11; 32]), + "unrelated", + ResourceVoteChoice::Abstain, + 300, + false, + ), + ]; + + let rows = snapshot.scheduled_vote_rows(&legacy); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].vote.contested_name, "unrelated"); + } + + /// A bulk schedule is one operation with many targets, so cancelling one of + /// them leaves the operation live. Only the cancelled row may disappear. + #[test] + fn cancelling_one_target_keeps_the_other_rows_of_its_operation() { + let mut operation = scheduled_operation( + 10, + DpnsVoteTargetStatus::Cancelled, + ResourceVoteChoice::Lock, + 100, + ); + let mut surviving = operation.targets[0].clone(); + surviving.target.key.vote_poll_id = Identifier::from([12; 32]); + surviving.target.contested_name = "bob".to_owned(); + surviving.status = DpnsVoteTargetStatus::Scheduled; + operation.targets.push(surviving); + let mut snapshot = DpnsVoteOperationSnapshot::default(); + snapshot.replace(vec![operation]); + + let rows = snapshot.scheduled_vote_rows(&[]); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].vote.contested_name, "bob"); + assert_eq!(rows[0].status, DpnsVoteTargetStatus::Scheduled); + } + #[test] fn scheduled_rows_derive_compatibility_execution_only_from_confirmed_status() { let confirmed = scheduled_operation( From a9d5a2c43a1bb3ccbd134c9571f0852720aeee47 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:51:09 +0000 Subject: [PATCH 38/39] refactor(dpns): drop the vote-change note from the review sheet The limited-vote-change note belongs to VOTE-FR-015, which is outside the scope of this blocking-fix pass. The review sheet still lists each target's current choice, so the operator sees what a vote replaces; only the extra advisory line and the ReviewPlan predicate behind it are removed. Co-Authored-By: Claude Opus 5 --- src/ui/dpns/dpns_contested_names_screen.rs | 23 +++++++--------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index ee2fc9a40..e9530b777 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -255,11 +255,6 @@ impl ReviewPlan { self.effective() .any(|entry| matches!(entry.target.timing, VoteTiming::Scheduled(_))) } - - fn changes_an_existing_vote(&self) -> bool { - self.effective() - .any(|entry| entry.target.current_choice.is_some()) - } } fn review_headline(effective_count: usize, node_count: usize) -> String { @@ -1729,12 +1724,6 @@ impl DPNSScreen { if plan.no_op_count() > 0 { ui.label(review_skipped_line(plan.no_op_count())); } - if plan.changes_an_existing_vote() { - ui.colored_label( - DashColors::warning_color(dark_mode), - "Changing a vote uses one of the node's limited vote changes.", - ); - } if plan.effective_count() == 0 { ui.colored_label( DashColors::warning_color(dark_mode), @@ -3129,7 +3118,6 @@ mod tests { assert_eq!(plan.node_count(), 2); assert!(plan.has_immediate()); assert!(plan.has_scheduled()); - assert!(!plan.changes_an_existing_vote()); let retained = plan .effective() .map(|entry| { @@ -3184,10 +3172,10 @@ mod tests { assert_eq!(plan.node_count(), 0); } - /// A node that already voted differently is a vote change, and the review - /// must be able to say so. + /// A node that already voted differently keeps its proved choice on the + /// review line, so the operator sees what the vote replaces. #[test] - fn review_plan_flags_a_change_to_an_existing_vote() { + fn review_plan_carries_the_proved_choice_a_target_replaces() { let (ctx, _temp_dir) = kv_ctx(); let alpha = ctx.dpns_vote_poll_id("alpha").expect("alpha poll id"); let node = masternode_identity(4, "node-four", true, ctx.network()); @@ -3208,13 +3196,16 @@ mod tests { let plan = screen.build_review_plan().expect("review plan"); assert_eq!(plan.effective_count(), 1); - assert!(plan.changes_an_existing_vote()); assert_eq!( plan.effective() .next() .map(|entry| entry.target.current_choice), Some(Some(ResourceVoteChoice::Lock)) ); + assert_eq!( + review_current_choice_label(Some(ResourceVoteChoice::Lock), None), + "Lock" + ); } #[test] From 513af26d055997155e7fa5163fae2ce1e5188c88 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Sat, 1 Aug 2026 10:07:35 +0000 Subject: [PATCH 39/39] chore(deps): bump platform pin to PR #3968 latest tip 762c66cf -> 5931df74 for dash-sdk, rs-sdk-trusted-context-provider, platform-wallet and platform-wallet-storage. Cargo.lock moves only the platform git source revs; no other dependency drifts. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 56 +++++++++++++++++++++++++++--------------------------- Cargo.toml | 8 ++++---- 2 files changed, 32 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8f5225dc4..5663efb47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1877,7 +1877,7 @@ dependencies = [ [[package]] name = "dapi-grpc" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "dash-platform-macros", "futures-core", @@ -1979,7 +1979,7 @@ dependencies = [ [[package]] name = "dash-async" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "thiserror 2.0.18", "tokio", @@ -1989,7 +1989,7 @@ dependencies = [ [[package]] name = "dash-context-provider" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "dash-async", "dpp", @@ -2100,7 +2100,7 @@ dependencies = [ [[package]] name = "dash-platform-macros" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "heck", "quote", @@ -2110,7 +2110,7 @@ dependencies = [ [[package]] name = "dash-sdk" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "arc-swap", "async-trait", @@ -2247,7 +2247,7 @@ dependencies = [ [[package]] name = "dashpay-contract" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "platform-value", "platform-version", @@ -2258,7 +2258,7 @@ dependencies = [ [[package]] name = "data-contracts" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "dashpay-contract", "document-history-contract", @@ -2527,7 +2527,7 @@ dependencies = [ [[package]] name = "document-history-contract" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "platform-value", "platform-version", @@ -2556,7 +2556,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "dpns-contract" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "platform-value", "platform-version", @@ -2567,7 +2567,7 @@ dependencies = [ [[package]] name = "dpp" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "anyhow", "async-trait", @@ -2617,7 +2617,7 @@ dependencies = [ [[package]] name = "dpp-json-convertible-derive" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "proc-macro2", "quote", @@ -2627,7 +2627,7 @@ dependencies = [ [[package]] name = "drive" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "bincode 2.0.1", "byteorder", @@ -2652,7 +2652,7 @@ dependencies = [ [[package]] name = "drive-proof-verifier" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "bincode 2.0.1", "dapi-grpc", @@ -5046,7 +5046,7 @@ dependencies = [ [[package]] name = "keyword-search-contract" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "platform-value", "platform-version", @@ -5259,7 +5259,7 @@ dependencies = [ [[package]] name = "masternode-reward-shares-contract" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "platform-value", "platform-version", @@ -6471,7 +6471,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "platform-encryption" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "aes", "cbc", @@ -6484,7 +6484,7 @@ dependencies = [ [[package]] name = "platform-serialization" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "bincode 2.0.1", "platform-version", @@ -6493,7 +6493,7 @@ dependencies = [ [[package]] name = "platform-serialization-derive" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "proc-macro2", "quote", @@ -6504,7 +6504,7 @@ dependencies = [ [[package]] name = "platform-value" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "base64 0.22.1", "bincode 2.0.1", @@ -6524,7 +6524,7 @@ dependencies = [ [[package]] name = "platform-version" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "bincode 2.0.1", "grovedb-version 5.0.1", @@ -6535,7 +6535,7 @@ dependencies = [ [[package]] name = "platform-versioning" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "proc-macro2", "quote", @@ -6545,7 +6545,7 @@ dependencies = [ [[package]] name = "platform-wallet" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "arc-swap", "async-trait", @@ -6577,7 +6577,7 @@ dependencies = [ [[package]] name = "platform-wallet-storage" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "apple-native-keyring-store", "argon2", @@ -7574,7 +7574,7 @@ dependencies = [ [[package]] name = "rs-dapi-client" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "backon", "chrono", @@ -7600,7 +7600,7 @@ dependencies = [ [[package]] name = "rs-sdk-trusted-context-provider" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "arc-swap", "dash-async", @@ -8842,7 +8842,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "token-history-contract" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "platform-value", "platform-version", @@ -9687,7 +9687,7 @@ dependencies = [ [[package]] name = "wallet-utils-contract" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "platform-value", "platform-version", @@ -10943,7 +10943,7 @@ dependencies = [ [[package]] name = "withdrawals-contract" version = "4.1.0" -source = "git+https://github.com/dashpay/platform?rev=762c66cf7716b9b3264868a49e8d29991c643c7e#762c66cf7716b9b3264868a49e8d29991c643c7e" +source = "git+https://github.com/dashpay/platform?rev=5931df745a74aafa92cbcfc410e34319850a8e8a#5931df745a74aafa92cbcfc410e34319850a8e8a" dependencies = [ "num_enum 0.5.11", "platform-value", diff --git a/Cargo.toml b/Cargo.toml index 5b0271230..9fb64ab4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ eframe = { version = "0.35.0", features = ["persistence", "wgpu"] } base64 = "0.22.1" # TODO: GHSA-7gcf-g7xr-8hxj (serde_with <3.21.0) is unfixable from here β€” the 2.x pin lives in # dashcore-rpc-json (dashpay/rust-dashcore, rpc-json/Cargo.toml). Re-check when these pins move. -dash-sdk = { git = "https://github.com/dashpay/platform", rev = "762c66cf7716b9b3264868a49e8d29991c643c7e", features = [ +dash-sdk = { git = "https://github.com/dashpay/platform", rev = "5931df745a74aafa92cbcfc410e34319850a8e8a", features = [ "core_key_wallet", "core_key_wallet_manager", "core_bincode", @@ -30,12 +30,12 @@ dash-sdk = { git = "https://github.com/dashpay/platform", rev = "762c66cf7716b9b "core_spv", "shielded", ] } -rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "762c66cf7716b9b3264868a49e8d29991c643c7e" } -platform-wallet = { git = "https://github.com/dashpay/platform", rev = "762c66cf7716b9b3264868a49e8d29991c643c7e", features = [ +rs-sdk-trusted-context-provider = { git = "https://github.com/dashpay/platform", rev = "5931df745a74aafa92cbcfc410e34319850a8e8a" } +platform-wallet = { git = "https://github.com/dashpay/platform", rev = "5931df745a74aafa92cbcfc410e34319850a8e8a", features = [ "serde", "shielded", ] } -platform-wallet-storage = { git = "https://github.com/dashpay/platform", rev = "762c66cf7716b9b3264868a49e8d29991c643c7e", features = [ +platform-wallet-storage = { git = "https://github.com/dashpay/platform", rev = "5931df745a74aafa92cbcfc410e34319850a8e8a", features = [ "shielded", ] } zip32 = "0.2.0"