diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d887d355..7cdca5b94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,15 +79,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, 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. - **Wallet/identity indicator on more screens (rollout in progress)**: the wallet and identity picker previously shown only at the top of the Identity 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" 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..dd8b6b8b4 --- /dev/null +++ b/docs/ai-design/2026-07-16-dpns-voting-experience/01-requirements.md @@ -0,0 +1,206 @@ +# DPNS Voting Experience — Requirements + +## Status + +Planning specification. No implementation is authorized by this document. + +## Problem statement + +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 +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 | +| 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. 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 + 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 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 + +- **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..dbb6d7357 --- /dev/null +++ b/docs/ai-design/2026-07-16-dpns-voting-experience/02-ux-spec.md @@ -0,0 +1,143 @@ +# DPNS Voting Experience — UX Specification + +## 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 +├── node list +└── node detail + └── DPNS Voting ──> DPNS / Active contests + +DPNS +├── Active contests (all vote composition and recent activity) +├── Past contests (human-readable outcomes) +├── My usernames +└── Scheduled votes (human-readable node, choice, time, status) +``` + +## Active contests + +Contests render as cards, grouped in this order: + +1. `Needs your vote` +2. `Voted` +3. `Not votable by your nodes` + +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 +┌─ 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] │ +└──────────────────────────────────────────────────────────────┘ +``` + +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. + +## Review and cast sheet + +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 +┌─ 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] │ +└───────────────────────────────────────────────────────────────┘ +``` + +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. + +## Submission and recovery + +`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. + +Recent voting activity appears below the contest groups and uses typed target +states: + +```text +┌─ 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]│ +└───────────────────────────────────────────────────────────────┘ +``` + +`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. + +## Scheduled votes and history + +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. + +## Accessibility and responsive behavior + +- 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 new file mode 100644 index 000000000..3c012dab3 --- /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 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 | +| 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 | + +## Single-contest voting + +| ID | Description | Preconditions | Steps | Expected outcome | Requirements | +|---|---|---|---|---|---| +| 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 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 + +| 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 | 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 + +| 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 | 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 | + +## 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 | 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 | +| 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..dd06596a2 --- /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. + +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: + +- 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 + +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 + +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 + +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 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 + +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. + +## 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. + +### Workstream 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. + +### Workstream 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. + +### Workstream C — Active-contests voting + +- 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 + +- 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 workstream remains independently testable, but the branch is published and +reviewed as one atomic UX change. + +## 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. +- Document DPNS Active contests as the single voting home and keep Scheduled + votes discoverable in the DPNS sub-navigation. diff --git a/docs/user-stories.md b/docs/user-stories.md index fa384ef83..9c05f8fdf 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -740,9 +740,12 @@ 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. +- 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. -- Note: The max 4 vote changes constraint is enforced at the Platform protocol level, not validated in the app UI. +- 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 @@ -750,14 +753,34 @@ 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 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] **Persona:** Priya 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. +- 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. 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] +**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 @@ -1564,11 +1587,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). -- Votes (Abstain, Lock, or a candidate) are cast inline through the existing DPNS voting backend. -- 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 @@ -1632,10 +1656,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/app.rs b/src/app.rs index c79924cd6..100d74661 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,6 +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::{ + 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}; @@ -239,6 +243,153 @@ 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, + cancelled: 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::Cancelled => counts.cancelled += 1, + DpnsVoteTargetStatus::NotApplied => counts.not_applied += 1, + DpnsVoteTargetStatus::Queued + | DpnsVoteTargetStatus::Submitting + | DpnsVoteTargetStatus::Confirming => counts.in_progress += 1, + } + } + 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 { + format!( + "{confirmed} of {target_count} votes were confirmed. Review the remaining {remaining}.", + ) + }; + (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 @@ -264,6 +415,20 @@ fn identity_hub_is_visible(selected: RootScreenType, screen_stack_is_empty: bool selected == RootScreenType::RootScreenIdentityHub && screen_stack_is_empty } +fn dpns_result_needs_hidden_active_contests_route( + selected: RootScreenType, + screen_stack_is_empty: bool, + result: &BackendTaskSuccessResult, +) -> bool { + matches!( + result, + BackendTaskSuccessResult::DpnsVoteOperationUpdated { .. } + | BackendTaskSuccessResult::RefreshedDpnsContests + | BackendTaskSuccessResult::ScheduledVoteSweepCompleted { .. } + | BackendTaskSuccessResult::ScheduledVotesCleared(_) + ) && (selected != RootScreenType::RootScreenDPNSActiveContests || !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). @@ -589,7 +754,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)); @@ -2120,6 +2285,27 @@ impl AppState { } } + fn route_dpns_vote_result_to_hidden_active_contests( + &mut self, + context: &BackendTaskContext, + result: &BackendTaskSuccessResult, + ) { + if !dpns_result_needs_hidden_active_contests_route( + self.selected_main_screen, + self.screen_stack.is_empty(), + result, + ) { + return; + } + if let Some(screen) = self + .main_screens + .get_mut(&RootScreenType::RootScreenDPNSActiveContests) + { + screen.display_backend_task_result(context, result.clone()); + screen.refresh(); + } + } + /// 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() @@ -2503,6 +2689,10 @@ impl App for AppState { let unboxed_message = *message; clear_profile_saving_banner_after_success(ctx, &context, &unboxed_message); self.route_contact_request_result_to_hidden_hub(&unboxed_message); + self.route_dpns_vote_result_to_hidden_active_contests( + &context, + &unboxed_message, + ); match unboxed_message { BackendTaskSuccessResult::None => {} BackendTaskSuccessResult::Refresh => { @@ -2619,19 +2809,58 @@ 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 { + 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 = + MessageBanner::set_global(ctx, message, message_type); + if !diagnostics.is_empty() { + handle.with_details(&diagnostics); + } + if keep_visible { + handle.disable_auto_dismiss(); + } + } + Some(Ok(None)) => { + MessageBanner::set_global( + ctx, + "This node already has that vote. Nothing was submitted.", + MessageType::Info, + ); + } + 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 { + network, + operation_id, + }, ); self.visible_screen_mut().refresh(); } @@ -2647,6 +2876,21 @@ impl App for AppState { ) { self.scheduled_vote_recovery_last_attempt.remove(&network); } + if self.selected_main_screen + == RootScreenType::RootScreenDPNSActiveContests + && self.screen_stack.is_empty() + { + 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, @@ -3087,6 +3331,150 @@ 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 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, + DpnsVoteTargetStatus::Unconfirmed, + DpnsVoteTargetStatus::Rejected, + DpnsVoteTargetStatus::FailedBeforeSubmission, + DpnsVoteTargetStatus::Cancelled, + DpnsVoteTargetStatus::NotApplied, + ]); + + let (message, message_type, keep_visible) = dpns_vote_feedback(&operation); + + assert_eq!(message_type, MessageType::Warning); + assert!(keep_visible); + 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. #[test] @@ -3445,6 +3833,120 @@ mod contact_request_routing_tests { } } +#[cfg(test)] +mod dpns_result_routing_tests { + use super::*; + use crate::model::dpns_voting::{ + DpnsScheduledVoteClearDisposition, DpnsScheduledVoteClearOutcome, DpnsScheduledVoteKey, + DpnsVoteOperationId, + }; + + #[test] + 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_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, + )); + } + + #[test] + fn refreshed_contests_route_when_active_contests_is_hidden() { + assert!(dpns_result_needs_hidden_active_contests_route( + RootScreenType::RootScreenWalletsBalances, + true, + &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, + )); + } + + #[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)] mod spv_overlay_tests { use super::*; diff --git a/src/backend_task/contested_names/mod.rs b/src/backend_task/contested_names/mod.rs index 51d1ca3db..fd5c9fec5 100644 --- a/src/backend_task/contested_names/mod.rs +++ b/src/backend_task/contested_names/mod.rs @@ -5,16 +5,25 @@ 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, + DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, failed_before_broadcast_outcome, + unavailable_preflight_outcome, +}; 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; -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 +35,13 @@ 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, + Option, + Network, + ), + 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 @@ -38,6 +52,11 @@ pub enum ContestedResourceTask { ClearAllScheduledVotes, ClearExecutedScheduledVotes, DeleteScheduledVote(Identifier, String), + CancelScheduledDpnsVote { + operation_id: DpnsVoteOperationId, + key: DpnsVoteTargetKey, + contested_name: String, + }, } #[derive(Debug, Clone, PartialEq)] @@ -49,6 +68,69 @@ pub struct ScheduledDPNSVote { pub executed_successfully: bool, } +fn classify_vote_attempt( + attempt: &Result, + timing: VoteTiming, +) -> (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), + ), + Ok(vote_on_dpns_name::DpnsVoteAttempt::FailedBeforeSubmission(_)) | Err(_) => { + failed_before_broadcast_outcome(timing) + } + } +} + +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, + previous_observation: Option, +) -> Option { + 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( + persist_terminal: impl FnOnce() -> Result<(), TaskError>, + update_legacy_mirror: impl FnOnce() -> Result<(), TaskError>, +) -> Result, TaskError> { + persist_terminal()?; + 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 @@ -76,136 +158,733 @@ 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, 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) .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, + replacing_scheduled_key, + _, + ) => { + self.execute_dpns_vote_operation_with_recovery( + operation, + voters, + replacing_scheduled_key, + 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_with_recovery(operation, vec![*voter], None, sdk) + .await } 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.clear_all_scheduled_votes()?; - 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 { + operation_id, + key, + contested_name, + } => { + self.cancel_scheduled_dpns_vote_target(operation_id, &key, &contested_name)?; Ok(BackendTaskSuccessResult::Refresh) } } } + 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.migrate_dpns_vote_operations()?; + 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(), None, sdk) + .await?; + } + } + *recovered = true; + Ok(()) + } + + 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 { + 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 != key { + continue; + } + match outcome.status { + DpnsVoteTargetStatus::Scheduled => { + 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 + | 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::Cancelled + | DpnsVoteTargetStatus::NotApplied => { + // An explicit Cast now action is a deliberate retry and + // may create a new operation below. + } + } + } + } + + 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, + mut operation: DpnsVoteOperation, + voters: Vec, + replacing_scheduled_key: Option, + sdk: &Sdk, + ) -> Result { + if operation.targets.is_empty() { + return Ok(BackendTaskSuccessResult::DpnsVoteOperationUpdated { + network: self.network, + operation_id: operation.id, + }); + } + let was_persisted = self.dpns_vote_operation(operation.id)?.is_some(); + let mut preflight_unavailable = false; + 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)?; + 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 + .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 => { + (outcome.status, outcome.failure) = + unavailable_preflight_outcome(outcome.target.timing); + preflight_unavailable = true; + } + } + } + } + + if was_persisted { + operation = self + .dpns_vote_operation(operation.id)? + .ok_or(TaskError::DpnsVoteOperationRecordMissing)?; + } 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 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. + 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(_)) + }) { + 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 + .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 { + 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, + status, + failure, + )?; + scheduled_voter_missing |= retryable; + } + } + if scheduled_voter_missing { + return Err(TaskError::IdentityNotFoundLocally); + } + 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 { + if !app_context.claim_dpns_vote_target(operation_id, &target.key)? { + continue; + } + let attempt = app_context + .submit_dpns_vote( + operation_id, + &target.key, + &target.contested_name, + target.requested_choice, + &voter, + &sdk, + ) + .await; + 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) => { + } + 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" + ); + app_context.record_dpns_vote_diagnostic_with_dapi_context( + operation_id, + target.key.clone(), + error, + &sdk, + ); + } + 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" + ); + app_context.record_dpns_vote_diagnostic_with_dapi_context( + operation_id, + target.key.clone(), + error, + &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, + voter_id = %target.key.voter_id, + contested_name = %target.contested_name, + "DPNS vote failed before a confirmed submission" + ); + 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, + ); + } + } + } + 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, + )?; + } + if let Some(error) = retryable_scheduled_error { + return Err(error); + } + } + Ok(()) + } + }) + .buffer_unordered(MAX_CONCURRENT_VOTERS) + .collect::>>() + .await + .into_iter() + .collect::, _>>()?; + + if preflight_unavailable { + return Err(TaskError::DpnsCurrentVoteUnavailable); + } + + Ok(BackendTaskSuccessResult::DpnsVoteOperationUpdated { + network: self.network, + operation_id: operation.id, + }) + } + + 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, + sdk: &Sdk, + ) -> Result { + let Some(operation) = self.dpns_vote_operation(operation_id)? else { + return Ok(BackendTaskSuccessResult::DpnsVoteOperationUpdated { + network: self.network, + 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) => { + 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, + 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, + 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, + )?; + } + 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_with_dapi_context( + operation_id, + outcome.target.key.clone(), + error, + sdk, + ); + } + } + } + Ok(BackendTaskSuccessResult::DpnsVoteOperationUpdated { + network: self.network, + 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) .unwrap_or(0); - let due: Vec = self - .get_scheduled_votes()? - .into_iter() - .filter(|vote| { - scheduled_vote_is_due( - vote.unix_timestamp, - vote.executed_successfully, + 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 !scheduled_target_should_execute( + outcome.status, + scheduled_at, now_ms, preserve_eligibility_since_ms, - ) - }) - .collect(); - if due.is_empty() { + ) { + continue; + } + if outcome.status == DpnsVoteTargetStatus::Scheduled + && !self.queue_scheduled_dpns_vote_target(operation.id, &outcome.target.key)? + { + 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); + } + } + if due_operations.is_empty() { return Ok(BackendTaskSuccessResult::ScheduledVoteSweepCompleted { network: self.network, preserve_eligibility_since_ms, @@ -213,62 +892,39 @@ 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; - 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), - )) + 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 result = app_context + .execute_dpns_vote_operation_with_recovery(operation, voters, None, &sdk) .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); + .map(|_| ()); + (operation_id, result) } + }) + .buffer_unordered(4) + .collect::>() + .await; + let mut first_error = None; + for (operation_id, result) in results { + if let Err(error) = result { + tracing::error!( + error = %error, + operation_id = %operation_id, + "Failed to execute a due DPNS vote operation; leaving it for recovery" + ); + first_error.get_or_insert(error); } } if let Some(error) = first_error { @@ -282,17 +938,29 @@ 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); +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); } - for (_, _, result) in results { - result.map_err(|source| TaskError::ScheduledVoteRejected { source })?; - } - Ok(()) + 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( @@ -307,29 +975,536 @@ 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::*; + 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; + 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), + ))) + } + + 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 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)), - )]); + 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 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"); + 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!( - confirm_scheduled_vote_result(result), - Err(TaskError::ScheduledVoteRejected { .. }) + 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 an_empty_nested_result_is_not_a_successful_scheduled_vote() { + fn scheduled_inner_rejection_needs_attention() { + let attempt = Ok(vote_on_dpns_name::DpnsVoteAttempt::Rejected( + TaskError::DpnsVoteTargetBusy, + )); + assert_eq!( + classify_vote_attempt(&attempt, VoteTiming::Scheduled(42)), + ( + DpnsVoteTargetStatus::Rejected, + Some(DpnsVoteFailure::PlatformRejected) + ) + ); + } + + /// VOTE-TC-034/052: an ambiguous post-broadcast result stays locked. + #[test] + 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, VoteTiming::Scheduled(42)); + assert_eq!(status, DpnsVoteTargetStatus::Unconfirmed); + assert!(status.holds_lock()); + } + + #[test] + fn exact_reconciliation_confirms_only_the_requested_choice() { + assert_eq!( + classify_reconciled_vote( + Some(ResourceVoteChoice::Lock), + ResourceVoteChoice::Lock, + None, + ), + Some(DpnsVoteTargetStatus::Confirmed) + ); + assert_eq!( + 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, + 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()); + 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 [ + DpnsVoteTargetStatus::Unconfirmed, + DpnsVoteTargetStatus::Rejected, + DpnsVoteTargetStatus::FailedBeforeSubmission, + DpnsVoteTargetStatus::Confirmed, + ] { + assert_ne!(status, DpnsVoteTargetStatus::Scheduled); + } + } + + #[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, + database, + Arc::new(TaskManager::new()), + Arc::new(ConnectionStatus::new()), + egui::Context::default(), + app_kv, + secret_store, + crate::model::user_role::UserRoleCell::default(), + ) + .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!( - confirm_scheduled_vote_result(BackendTaskSuccessResult::DPNSVoteResults(Vec::new())), - Err(TaskError::ScheduledVoteResultUnavailable) + error, + TaskError::ScheduledVoteSweepFailed { + network: Network::Testnet, + .. + } + )); + } + + #[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::FailedBeforeSubmission + ); + } + + #[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 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; + 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, )); } 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 bb1391769..15085a737 100644 --- a/src/backend_task/contested_names/query_dpns_contested_resources.rs +++ b/src/backend_task/contested_names/query_dpns_contested_resources.rs @@ -221,6 +221,10 @@ 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; self.refresh_pending_dpns_usernames()?; sender 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..e17832df0 100644 --- a/src/backend_task/contested_names/vote_on_dpns_name.rs +++ b/src/backend_task/contested_names/vote_on_dpns_name.rs @@ -1,14 +1,20 @@ -use crate::app::TaskResult; -use crate::backend_task::BackendTaskSuccessResult; 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; +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; @@ -17,10 +23,22 @@ 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; +#[derive(Debug)] +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. /// /// Caller must pre-normalize the label via `convert_to_homograph_safe_chars` @@ -32,20 +50,92 @@ 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) + } +} + +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::FailedBeforeSubmission(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)), + } +} + +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 { - pub(super) async fn vote_on_dpns_name( + pub(super) async fn submit_dpns_vote( self: &Arc, + operation_id: DpnsVoteOperationId, + key: &DpnsVoteTargetKey, 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 +185,55 @@ 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)); + 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(attempt) = broadcast_after_journal_mark( + || self.mark_dpns_vote_broadcast(operation_id, key), + || state_transition.broadcast(sdk, Some(settings)), + ) + .await + { + return Ok(attempt); } - Ok(BackendTaskSuccessResult::DPNSVoteResults(vote_results)) + match Vote::wait_for_response(sdk, state_transition, Some(settings)).await { + Ok(_) => Ok(DpnsVoteAttempt::Confirmed), + Err(error) => Ok(classify_post_broadcast_error(error)), + } } } @@ -169,4 +277,127 @@ 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(_))); + } + + #[test] + 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(_) + )); + } + + #[test] + 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 prevent the broadcast"); + + 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 48aa18d90..56714f575 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -784,6 +784,66 @@ 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, + }, + + /// 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." + )] + 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." + )] + 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." + )] + 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.")] @@ -2067,6 +2127,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." @@ -3015,6 +3086,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| { @@ -3288,6 +3370,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; @@ -4210,6 +4293,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( diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index e75273dd1..8de8e4b1f 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -12,6 +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::{DpnsScheduledVoteClearOutcome, DpnsVoteOperationId}; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -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}; @@ -327,6 +327,11 @@ pub enum BackendTaskContext { TokenRewardEstimate(IdentityTokenIdentifier), /// The destructive per-network database clear. ClearNetworkDatabase, + /// One durable DPNS vote operation. + DpnsVoteOperation { + network: Network, + operation_id: DpnsVoteOperationId, + }, /// A scheduled-vote sweep for one network. ScheduledVoteSweep { network: Network }, /// Receive-address derivation for one wallet's deposit flow. @@ -470,6 +475,21 @@ impl From<&BackendTask> for BackendTaskContext { .. }) => Self::LegacyRecoveryRestore(*identity_id), BackendTask::SystemTask(SystemTask::ClearNetworkDatabase) => Self::ClearNetworkDatabase, + BackendTask::ContestedResourceTask(ContestedResourceTask::SubmitDpnsVoteOperation( + operation, + _, + _, + network, + )) => Self::DpnsVoteOperation { + network: *network, + operation_id: operation.id, + }, + BackendTask::ContestedResourceTask( + ContestedResourceTask::ReconcileDpnsVoteOperation(operation_id, network), + ) => Self::DpnsVoteOperation { + network: *network, + operation_id: *operation_id, + }, BackendTask::WalletTask(WalletTask::GenerateReceiveAddress { seed_hash }) => { Self::GenerateReceiveAddress { seed_hash: *seed_hash, @@ -530,8 +550,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. @@ -539,6 +557,11 @@ pub enum BackendTaskSuccessResult { network: Network, preserve_eligibility_since_ms: Option, }, + DpnsVoteOperationUpdated { + 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), @@ -927,11 +950,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()), @@ -941,17 +959,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)), @@ -1659,29 +1666,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( @@ -1791,6 +1775,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/contested_names_db.rs b/src/context/contested_names_db.rs index 03caf537b..fe82b8c7b 100644 --- a/src/context/contested_names_db.rs +++ b/src/context/contested_names_db.rs @@ -9,7 +9,11 @@ use super::AppContext; use crate::backend_task::error::TaskError; use crate::model::contested_name::{ - ContestState, Contestant, ContestedName, PendingUsername, pending_usernames_in, + ContestState, Contestant, ContestedName, MasternodeVoteStateSummary, PendingUsername, + pending_usernames_in, +}; +use crate::model::dpns_voting::{ + DpnsCurrentVoteState, DpnsVoteOperation, DpnsVoteTargetStatus, VoteTiming, }; use crate::model::qualified_identity::QualifiedIdentity; use crate::wallet_backend::{DetScope, KvAdapterError}; @@ -35,6 +39,44 @@ 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 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 + ) + }); + (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. @@ -79,6 +121,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); @@ -301,20 +353,52 @@ 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_polls: Vec> = contests .iter() - .filter(|contest| contest.is_open_for_voter(&voter_id)) - .count(); + .filter(|contest| contest.is_votable()) + .map(|contest| { + self.dpns_vote_poll_id(&contest.normalized_contested_name) + .ok() + }) + .collect(); + let open_contest_count = open_polls.len(); - let has_scheduled_vote = self - .get_scheduled_votes()? + // 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::>(); + let needs_vote_count = states .iter() - .any(|vote| vote.voter_id == voter_id && !vote.executed_successfully); + .filter(|state| **state == DpnsCurrentVoteState::Available(None)) + .count(); + let vote_state = vote_state_summary(&states); + + 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, }) } @@ -514,7 +598,9 @@ mod tests { use crate::wallet_backend::kv_test_support::InMemoryKv; use dash_sdk::dpp::identity::Identity; use dash_sdk::dpp::version::PlatformVersion; + 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())) @@ -716,6 +802,193 @@ mod tests { 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 + ); + } + + #[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) + ); + } + + #[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::Cancelled; + + 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) + ); + } + + /// 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" + ); + } + // ---------------------------------------------------------------- // Bridge: a stored contest decodes into a detectable pending username. // ---------------------------------------------------------------- diff --git a/src/context/dpns_vote_operations.rs b/src/context/dpns_vote_operations.rs new file mode 100644 index 000000000..c98a7ebfb --- /dev/null +++ b/src/context/dpns_vote_operations.rs @@ -0,0 +1,2160 @@ +//! 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, DpnsScheduledVoteClearDisposition, DpnsScheduledVoteClearOutcome, + DpnsScheduledVoteKey, DpnsVoteFailure, DpnsVoteOperation, DpnsVoteOperationId, DpnsVoteTarget, + DpnsVoteTargetKey, DpnsVoteTargetStatus, VoteTiming, failed_before_broadcast_outcome, + unavailable_preflight_outcome, +}; +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; + +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 { + 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 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}") +} + +fn operation_err(source: KvAdapterError) -> TaskError { + TaskError::DpnsVoteOperationStorage { source } +} + +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(unreadable_operation_err) +} + +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 qualified_changed = false; + let mut retained_legacy_ids = Vec::<[u8; 16]>::new(); + for bytes in &legacy_ids { + let bytes = *bytes; + 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)?; + match operation_matches_network(&operation, network) { + Ok(false) => continue, + Err(TaskError::DpnsVoteJournalNetworkMismatch) => { + retained_legacy_ids.push(bytes); + continue; + } + Err(error) => 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); + qualified_changed = true; + } + if qualified_changed { + kv.put( + DetScope::Global, + &operation_index_key(network), + &qualified_ids, + ) + .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_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); + 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 load_operations(kv: &DetKv, network: Network) -> 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, + operation: &DpnsVoteOperation, +) -> Result<(), TaskError> { + if !operation_matches_network(operation, network)? { + return Err(TaskError::DpnsVoteJournalNetworkMismatch); + } + 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)?; + 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(()) +} + +fn write_existing_operation( + kv: &DetKv, + network: Network, + operation: &DpnsVoteOperation, +) -> Result<(), TaskError> { + persist_operation(kv, network, operation) +} + +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(_)) + || !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) + .collect::>(); + if terminal_ids.is_empty() { + return Ok(0); + } + kv.put( + DetScope::Global, + &operation_lock_index_dirty_key(network), + &true, + ) + .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( + 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, + 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(_)) + }) +} + +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::Cancelled; + outcome.failure = None; + write_existing_operation(kv, network, &operation)?; + Ok(true) +} + +fn recover_interrupted_target_statuses(operation: &mut DpnsVoteOperation) -> bool { + let mut changed = false; + for outcome in &mut operation.targets { + 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.target.current_choice = None; + outcome.failure = Some(DpnsVoteFailure::ResultUnconfirmed); + changed = true; + } + _ => {} + } + } + changed +} + +fn mark_target_broadcast( + 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::Submitting { + outcome.status = DpnsVoteTargetStatus::Confirming; + write_existing_operation(kv, network, &operation)?; + return Ok(true); + } + Ok(false) +} + +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, + operation: &mut DpnsVoteOperation, + replacing_scheduled_key: Option<&DpnsVoteTargetKey>, + ) -> 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 { + return replace_scheduled_operation(&kv, self.network, operation, key); + } + 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, + 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()?, self.network, 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()?; + 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. + pub(crate) fn migrate_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, self.network)?; + 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, self.network, &operation)?; + operations.push(operation); + } + Ok(()) + } + + /// Load one operation by its stable ID. + pub fn dpns_vote_operation( + &self, + id: DpnsVoteOperationId, + ) -> Result, TaskError> { + let _guard = self + .dpns_vote_operation_guard + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let operation = self + .det_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. + 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(self.network, operation_id)) + .map_err(unreadable_operation_err)? + else { + return Ok(()); + }; + if let Some(outcome) = operation + .targets + .iter_mut() + .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, + 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) + } + + /// Record that a target is entering the ambiguous broadcast phase. + pub(crate) fn mark_dpns_vote_broadcast( + &self, + operation_id: DpnsVoteOperationId, + key: &DpnsVoteTargetKey, + ) -> Result { + 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; + /// 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, outcome.failure) = + unavailable_preflight_outcome(outcome.target.timing); + } + } + let still_queued = outcome.status == DpnsVoteTargetStatus::Queued; + persist_operation(&kv, self.network, &operation)?; + Ok(still_queued) + } + + /// 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 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let kv = self.det_kv()?; + for mut operation in load_operations(&kv, self.network)? { + if recover_interrupted_target_statuses(&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 recover_interrupted_target_statuses(&mut operation) { + persist_operation(&kv, self.network, &operation)?; + } + Ok(()) + } + + pub(crate) fn record_dpns_vote_diagnostic( + &self, + operation_id: DpnsVoteOperationId, + key: DpnsVoteTargetKey, + error: TaskError, + ) { + 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_diagnostic( + &mut diagnostics, + (operation_id, key), + sequence, + Arc::new(error), + DPNS_VOTE_DIAGNOSTIC_LIMIT, + ); + } + + 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() + } + + /// 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) + } + + /// Remove a schedule only after its authoritative journal state permits it. + pub(crate) fn remove_scheduled_dpns_vote( + &self, + 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); + 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 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)) + } + }; + + 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) + } + + /// 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); + 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" + ); + } + } + + prune_terminal_operations(&kv, self.network)?; + Ok(outcomes.into_values().map(|(_, outcome)| outcome).collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + 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; + use platform_wallet_storage::{KvStore, ObjectId}; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[derive(Default)] + struct CountingKv { + inner: InMemoryKv, + puts: AtomicUsize, + operation_gets: AtomicUsize, + } + + impl CountingKv { + 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 { + fn get( + &self, + 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) + } + + 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())) + } + + 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 + } + + 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() { + let kv = kv(); + persist_operation( + &kv, + Network::Testnet, + &operation(DpnsVoteTargetStatus::Submitting), + ) + .unwrap(); + + let error = persist_operation( + &kv, + Network::Testnet, + &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, Network::Testnet, &operation).unwrap(); + + let restored = load_operations(&kv, Network::Testnet).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, + 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, Network::Testnet, &unrelated).unwrap(); + 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), + )); + } + + #[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_counts(); + + 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(); + 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" + ); + } + + #[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 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, 2, "dominguez"); + 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(); + context + .queue_scheduled_dpns_vote_target(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!( + context.dpns_vote_operations().unwrap()[0].targets[0].status, + DpnsVoteTargetStatus::Queued + ); + assert_eq!(context.get_scheduled_votes().unwrap().len(), 1); + } + + #[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 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 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 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"); + 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!( + remaining_names, + BTreeSet::from([ + "confirming".to_owned(), + "queued".to_owned(), + "submitting".to_owned(), + "unconfirmed".to_owned(), + ]) + ); + 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() + })); + 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() { + 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 legacy_migration_quarantines_cross_network_row_without_blocking_siblings() { + let kv = kv(); + 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(poisoned.id), + &poisoned, + ) + .unwrap(); + kv.put(DetScope::Global, &legacy_operation_key(valid.id), &valid) + .unwrap(); + kv.put( + DetScope::Global, + LEGACY_OPERATION_INDEX_KEY, + &vec![poisoned.id.to_bytes(), valid.id.to_bytes()], + ) + .unwrap(); + + 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![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)) + .unwrap() + .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] + 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()); + 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 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"); + 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_scheduled_and_mixed_but_retains_immediate_history() { + let kv = kv(); + 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); + 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).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, + &operation_key(Network::Testnet, scheduled.id), + ) + .unwrap() + .is_none() + ); + assert!( + kv.get::( + DetScope::Global, + &operation_key(Network::Testnet, immediate.id), + ) + .unwrap() + .is_some() + ); + assert!( + kv.get::( + DetScope::Global, + &operation_key(Network::Testnet, mixed.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] + 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); + 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 + ); + assert_eq!( + operation.targets[0].failure, + Some(DpnsVoteFailure::ResultUnconfirmed) + ); + } + + #[test] + fn marking_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 + ); + } + + #[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() + ); + } +} diff --git a/src/context/dpns_vote_state.rs b/src/context/dpns_vote_state.rs new file mode 100644 index 000000000..7d6bf81fb --- /dev/null +++ b/src/context/dpns_vote_state.rs @@ -0,0 +1,470 @@ +//! 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::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; +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 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 { + available: bool, + updated_at: u64, + votes: BTreeMap<[u8; 32], ResourceVoteChoice>, +} + +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> { + 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)); + } + 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(None) +} + +fn save_snapshot( + kv: &DetKv, + network: Network, + voter_id: &Identifier, + snapshot: &StoredCurrentVotes, +) -> Result<(), TaskError> { + kv.put( + DetScope::Identity(&voter_id.to_buffer()), + ¤t_votes_key(network), + snapshot, + ) + .map_err(vote_state_err) +} + +fn snapshot_vote_state( + snapshot: Option<&StoredCurrentVotes>, + 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 { + 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 { + let snapshot = load_snapshot(&self.det_kv()?, self.network, &voter_id)?; + Ok(snapshot_vote_state( + 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() { + 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(); + let network = self.network; + 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, network, &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, network, &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, 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, self.network, &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 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())) + } + + /// 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, Network::Testnet, &voter, &snapshot).unwrap(); + + assert_eq!( + load_snapshot(&kv, Network::Testnet, &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); + } + + #[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 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]); + 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 + ); + } + + #[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/identity_db.rs b/src/context/identity_db.rs index 82de49933..7c6280708 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, PrivateKeyTarget, QualifiedIdentity, }; @@ -12,7 +13,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 @@ -457,6 +458,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( @@ -498,6 +519,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 { /// Whether the previous version's preserved `data.db` holds any identity of /// this network. @@ -1257,19 +1309,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 @@ -1318,15 +1358,19 @@ impl AppContext { 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)?; } - _ => {} + Some(_) | None => {} } } prune_vote_voter_if_empty(&kv, voter)?; } + self.prune_terminal_dpns_vote_operations()?; Ok(()) } @@ -1337,13 +1381,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. @@ -1395,7 +1433,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; @@ -1407,6 +1448,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 @@ -1715,6 +1792,88 @@ mod tests { assert_eq!(voters, vec![v1, v2]); } + #[test] + fn clearing_executed_votes_preserves_failed_and_cancelled_journal_records() { + 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(); @@ -1766,6 +1925,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/context/mod.rs b/src/context/mod.rs index 8828108fd..2593ba39c 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; @@ -18,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; @@ -59,6 +62,10 @@ use crate::model::user_role::{UserRole, UserRoleCell}; const ANIMATION_REFRESH_TIME: std::time::Duration = std::time::Duration::from_millis(100); pub const SDK_THREAD_STACK_SIZE: usize = 4 * 1024 * 1024; // 4 MB stack size for each worker thread +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 @@ -79,6 +86,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, @@ -142,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 @@ -166,6 +223,17 @@ 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<()>, + /// Serializes all nonce-consuming vote submissions per voter across tasks, + /// while bounding unrelated voters globally. + pub(crate) dpns_vote_dispatch: DpnsVoteDispatchCoordinator, + /// 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: 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>, @@ -487,6 +555,8 @@ impl AppContext { cached_settings: RwLock::new(None), pending_dpns_usernames: RwLock::new(HashMap::new()), app_kv, + #[cfg(test)] + det_kv_override: Mutex::new(None), secret_store, subtasks, token_balance_refresh_in_flight: AtomicBool::new(false), @@ -494,6 +564,11 @@ 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(()), + 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), @@ -583,9 +658,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 @@ -2044,4 +2136,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); + } } diff --git a/src/model/contested_name.rs b/src/model/contested_name.rs index 893dcde65..7c3e2d143 100644 --- a/src/model/contested_name.rs +++ b/src/model/contested_name.rs @@ -39,11 +39,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's state still accepts votes. + pub fn is_votable(&self) -> bool { + self.state.state_is_votable() } /// The pending DPNS username this contest represents for `identity_id`, if @@ -199,15 +197,39 @@ pub fn approximate_time_until(decided_at_ms: TimestampMillis, now_ms: u64) -> Op /// /// 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] + Ready, + Checking, + Unavailable, +} + #[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 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 { + /// 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)] @@ -225,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 { @@ -263,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 not_open_when_voter_already_voted() { - 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); + } } // ---------------------------------------------------------------- diff --git a/src/model/dpns_voting.rs b/src/model/dpns_voting.rs new file mode 100644 index 000000000..3c6c646a7 --- /dev/null +++ b/src/model/dpns_voting.rs @@ -0,0 +1,348 @@ +#[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::Cancelled, + 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, +} + +/// 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 { + Now, + 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)) +} + +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 { + pub key: DpnsVoteTargetKey, + pub voter_alias: Option, + pub contested_name: String, + pub requested_choice: ResourceVoteChoice, + pub current_choice: Option, + 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 { + 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, + /// Reconciliation proved that a submitted vote was not applied. + NotApplied, + /// The user cancelled a scheduled vote before it was submitted. + Cancelled, +} + +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.is_no_op()) + .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 0862bb2b2..c102ce87d 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -6,6 +6,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/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 ddbe86efd..e9530b777 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -1,8 +1,8 @@ use crate::wallet_backend::poison::MutexRecover; +use std::collections::BTreeSet; 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; @@ -10,25 +10,33 @@ 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, BackendTasksExecutionMode, DesiredAppAction, scheduled_vote_sweep_is_quiet, -}; -use crate::backend_task::BackendTask; -use crate::backend_task::contested_names::{ContestedResourceTask, ScheduledDPNSVote}; +use crate::app::{AppAction, DesiredAppAction, scheduled_vote_sweep_is_quiet}; +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}; use crate::context::AppContext; use crate::model::contested_name::{ContestState, ContestedName}; +use crate::model::dpns::normalize_dpns_label; +use crate::model::dpns_voting::{ + DpnsCurrentVoteState, DpnsScheduledVoteKey, 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_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}; @@ -41,6 +49,250 @@ pub enum DPNSSubscreen { ScheduledVotes, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum ActiveContestGroup { + NeedsVote, + Voted, + NotVotable, +} + +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}") +} + +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 { + 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, candidate_name: Option<&str>) -> String { + match choice { + ResourceVoteChoice::Lock => "Lock".to_owned(), + ResourceVoteChoice::Abstain => "Abstain".to_owned(), + 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 { + 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 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(), + ), + } +} + +/// 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 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), + BackendTaskContext::DpnsVoteOperation { operation_id, .. } => Some(*operation_id), + _ => None, + } +} + impl DPNSSubscreen { pub fn display_name(&self) -> &'static str { match self { @@ -67,15 +319,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, @@ -95,8 +338,6 @@ pub enum RefreshingStatus { #[derive(Clone, Copy, PartialEq, Eq)] enum SortColumn { ContestedName, - LockedVotes, - AbstainVotes, EndingTime, LastUpdated, AwardedTo, @@ -117,12 +358,19 @@ 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, + vote_operations: DpnsVoteOperationSnapshot, + 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)>, /// Sorting sort_column: SortColumn, @@ -139,21 +387,43 @@ 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, + simple_schedule_date: String, + simple_schedule_hour: u32, + simple_schedule_minute: u32, } 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 => 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(), @@ -162,35 +432,31 @@ 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 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 + .iter() + .map(|identity| identity.identity.id()) + .collect::>(); + let vote_state = DpnsVoteStateSnapshot::load(app_context, &voter_ids, &vote_poll_ids) + .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(); let bulk_identity_options = vec![VoteOption::CastNow; identity_count]; + let default_schedule_time = Utc::now() + chrono::Duration::days(1); Self { 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, @@ -198,8 +464,14 @@ 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, refreshing_status: RefreshingStatus::NotRefreshing, refresh_banner: None, @@ -207,10 +479,11 @@ 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, + 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(), } } @@ -235,8 +508,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), @@ -252,6 +523,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| { @@ -322,7 +620,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) ); } }); @@ -334,360 +632,470 @@ 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.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.") + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(8.0); + + let filter = normalize_dpns_label(&self.active_filter_term); + let contests = self.active_contests.contests(); + let mut groups = [Vec::new(), Vec::new(), Vec::new()]; + for contest in contests.iter() { + let index = match self.contest_group(contest.vote_poll_id) { + ActiveContestGroup::NeedsVote => 0, + ActiveContestGroup::Voted => 1, + ActiveContestGroup::NotVotable => 2, + }; + groups[index].push(contest); + } - 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 closes_within_day = groups[0] + .iter() + .filter(|contest| { + 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 + .contest + .normalized_contested_name .to_lowercase() - .contains(&filter_lc) + .contains(&filter) }); } - self.sort_contested_names(&mut cn); - cn - }; + } - // 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, 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); + }); - 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)), + 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; + } + }); + }); + } + + fn contest_group(&self, vote_poll_id: Option) -> ActiveContestGroup { + let Some(poll_id) = vote_poll_id else { + return ActiveContestGroup::NotVotable; + }; + classify_vote_states( + self.voting_identities + .iter() + .map(|identity| self.vote_state.state(identity.identity.id(), poll_id)), + ) + } + + fn render_contest_group( + &mut self, + ui: &mut Ui, + title: &str, + contests: &[&ActiveDpnsContestView], + default_open: bool, + voting_enabled: bool, + show_current_vote: 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.vote_poll_id); + ui.add_enabled_ui(contest_enabled, |ui| { + 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, vote_poll_id: Option) -> bool { + let Some(vote_poll_id) = vote_poll_id 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, }) - .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; + .is_none() + }) + } - // 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) - }; + 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( + self.voting_identities + .iter() + .map(|identity| self.vote_state.state(identity.identity.id(), poll_id)), + ) + } - 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 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()) + } - // 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_card( + &mut self, + ui: &mut Ui, + contest: &ContestedName, + vote_poll_id: Option, + voting_enabled: bool, + show_current_vote: bool, + ) { + let dark_mode = ui.style().visuals.dark_mode; + 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(vote_poll_id) + } 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(); - // 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 - }); + 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); - 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, - }); - } - } - }); + 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 => {} + } - // ABSTAIN button - row.col(|ui| { - let abstain_votes = contested_name.abstain_votes.unwrap_or(0); - let label_text = format!("{}", abstain_votes); + 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), + candidate_choice_label(&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, + self.candidate_name( + &outcome.target.contested_name, + outcome.target.requested_choice, + ) + .as_deref(), + ), + 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; 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(); - - cn.retain(|c| { - c.normalized_contested_name - .to_lowercase() - .contains(&filter_lc) - }); + let filter = normalize_dpns_label(&self.past_filter_term); + + cn.retain(|c| c.normalized_contested_name.to_lowercase().contains(&filter)); } self.sort_contested_names(&mut cn); cn @@ -722,7 +1130,7 @@ impl DPNSScreen { } }); header.col(|ui| { - if ui.button("Awarded To").clicked() { + if ui.button("Outcome").clicked() { self.toggle_sort(SortColumn::AwardedTo); } }); @@ -813,17 +1221,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)), ); } @@ -991,13 +1403,13 @@ 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() }; - // 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 { @@ -1053,33 +1465,40 @@ impl DPNSScreen { }); }) .body(|mut body| { - for vote in sorted_votes.iter_mut() { + 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(&vote.0.contested_name)); + ui.add(Label::new(format!("{}.dash", vote.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.voter_id) + .and_then(|identity| identity.alias.clone()) + .unwrap_or_else(|| short_identifier(vote.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 candidate_name = + self.candidate_name(&vote.contested_name, vote.choice); + let display_text = + 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(); @@ -1100,91 +1519,55 @@ impl DPNSScreen { ); } }); - // Status row.col(|ui| { let dark_mode = ui.style().visuals.dark_mode; - 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(Color32::DARK_RED, "Failed"); - } - ScheduledVoteCastingStatus::Completed => { - ui.colored_label(Color32::DARK_GREEN, "Casted"); - } - } + ui.label( + RichText::new(target_status_label(scheduled_row.status)) + .color(DashColors::text_primary(dark_mode)), + ); }); - // Actions row.col(|ui| { - if ui.button("Remove").clicked() { - action = - AppAction::BackendTask(BackendTask::ContestedResourceTask( - ContestedResourceTask::DeleteScheduledVote( - vote.0.voter_id, - vote.0.contested_name.clone(), - ), - )); + let remove_enabled = + scheduled_vote_remove_enabled(scheduled_row.status); + if ui + .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( + 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 - ) && !self - .scheduled_vote_cast_in_progress; - - 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) - { - action = AppAction::BackendTask( - BackendTask::ContestedResourceTask( - ContestedResourceTask::CastScheduledVote( - vote.0.clone(), - Box::new(found.clone()), - ), + .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.clone(), + Box::new(found), ), - ); - } + ), + ); + show_cast_overlay = true; } }); }); @@ -1192,373 +1575,416 @@ impl DPNSScreen { }); }); + if show_cast_overlay { + self.raise_vote_overlay(ui.ctx(), "Submitting the scheduled vote to Dash Platform…"); + } + 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) - }; + 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, + }) + } - // 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) - }); + fn apply_all_nodes_option(&mut self, option: VoteOption) { + self.set_all_option = option.clone(); + self.bulk_identity_options.fill(option); + } - 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, - }); - } - } - } + fn candidate_name(&self, contested_name: &str, choice: ResourceVoteChoice) -> Option { + 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() + .find(|contest| contest.normalized_contested_name == 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()) } - // --------------------------- - // Bulk scheduling ephemeral UI - // --------------------------- - fn show_bulk_schedule_popup_window(&mut self, ui: &mut Ui) -> AppAction { + 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; let dark_mode = ui.style().visuals.dark_mode; - ui.heading( - RichText::new("Cast or Schedule 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.label(NO_VOTING_NODES_DETAIL); 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, "Load a masternode").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; } + 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| { - // 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)), - ); + 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.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| { + 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("Select cast method for each node:") - .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("Set all:").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::Scheduled { .. } => "Schedule".to_string(), - }) - .show_ui(ui, |ui| { - if ui - .selectable_label( - matches!(self.set_all_option, VoteOption::NoVote), - "No Vote", - ) - .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 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)); - } - - // Button to apply the "Set all" choice to each identity in bulk_identity_options - if ui.button("Apply to All").clicked() { - for option in &mut self.bulk_identity_options { - *option = self.set_all_option.clone(); - } - } + 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.", + ); + } + + ui.separator(); 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)), - ); + 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)), + ); - // 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]; - } + 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, + }; + } + }); - 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; - } - if ui - .selectable_label( - matches!(current_option, VoteOption::Scheduled { .. }), - "Schedule", - ) - .clicked() + 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 - .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); } - // "Apply Votes" button - if ComponentStyles::add_primary_button(ui, "Apply Votes").clicked() { + 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…"); + }); + } + 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 && has_submittable, + if operation_in_progress { + "Submitting votes…" + } else { + submit_label + }, + ) + .disabled_tooltip(submit_disabled_reason) + .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 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…", + ); } } - ui.add_space(5.0); - let dark_mode = ui.style().visuals.dark_mode; - if ComponentStyles::add_secondary_button(ui, "Cancel", dark_mode).clicked() { + if cancel_clicked { 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 @@ -1578,97 +2004,138 @@ impl DPNSScreen { // handled above } VoteHandlingStatus::Failed(message) => { - ui.colored_label( - Color32::RED, - format!("Error casting/scheduling votes: {}", message), - ); + ui.colored_label(Color32::RED, message); } } action } - /// 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(); - + /// 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) { - match option { - VoteOption::NoVote => {} - VoteOption::CastNow => { - immediate_list.push(identity.clone()); - } + let (timing, timing_label) = match option { + VoteOption::NoVote => continue, + 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); - 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((Utc::now() + offset).timestamp_millis() as u64), + format!("Scheduled in {days} d {hours} h {minutes} min"), + ) + } + }; + voters.push(identity.clone()); + for selected_vote in &self.selected_votes { + let voter_id = identity.identity.id(); + let vote_poll_id = self + .app_context + .dpns_vote_poll_id(&selected_vote.contested_name) + .map_err(|error| { + tracing::warn!( + ?error, + contested_name = selected_vote.contested_name, + "Could not build a DPNS vote target" + ); + "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() { + return Err(format!( + "This node's vote for {} is already in progress. Check its result before submitting again.", + selected_vote.contested_name + )); } + 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(), + ); + }; + 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 }) + } + + 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 immediate_list.is_empty() && scheduled_list.is_empty() { + if entries.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), - )) + // 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." + .to_owned(), + ); + return AppAction::None; } + self.bulk_vote_handling_status = if has_immediate { + VoteHandlingStatus::CastingVotes + } else { + VoteHandlingStatus::SchedulingVotes + }; + self.pending_vote_operation = Some(operation.id); + AppAction::BackendTask(BackendTask::ContestedResourceTask( + ContestedResourceTask::SubmitDpnsVoteOperation( + operation, + selected_voters, + None, + self.app_context.network(), + ), + )) } /// If voting/scheduling is successful, show success message @@ -1678,58 +2145,23 @@ 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); @@ -1770,59 +2202,52 @@ impl DPNSScreen { // --------------------------- impl ScreenLike for DPNSScreen { fn refresh(&mut self) { - self.scheduled_vote_cast_in_progress = false; - 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(); + if let Err(error) = self.vote_operations.refresh(&self.app_context) { + tracing::warn!(?error, "Could not refresh cached DPNS vote operations"); + } + 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 => { - 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(); } } + + let voter_ids = self + .voting_identities + .iter() + .map(|identity| identity.identity.id()) + .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) + { + tracing::warn!(?error, "Could not refresh cached DPNS vote state"); + } } 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 .app_context .load_local_user_identities() @@ -1830,120 +2255,92 @@ 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_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 { - 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; - } - } + self.pending_scheduled_casts.clear(); + 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; + // 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()); } } - handled + 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" + ); + } + 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 { - // 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))); + BackendTaskSuccessResult::DpnsVoteOperationUpdated { operation_id, .. } => { + self.pending_scheduled_casts.clear(); + 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 { + 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; } - } else { - // no errors => all success - self.bulk_schedule_message = Some(( - MessageType::Success, - "Votes all cast successfully.".to_string(), - )); } - - 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; + if let Err(error) = self.vote_operations.refresh(&self.app_context) { + tracing::warn!( + ?error, + "Could not refresh scheduled votes after an operation update" + ); } - self.bulk_schedule_message = - Some((MessageType::Success, "Votes scheduled".to_string())); + self.rebuild_scheduled_vote_rows(); } - BackendTaskSuccessResult::ScheduledVotesInProgress(votes) => { - // 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; - } - } + BackendTaskSuccessResult::ScheduledVotesInProgress(_) => { + if let Err(error) = self.vote_operations.refresh(&self.app_context) { + tracing::warn!(?error, "Could not refresh scheduled-vote operation state"); } + self.rebuild_scheduled_vote_rows(); } - 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::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 => { @@ -1958,11 +2355,6 @@ impl ScreenLike for DPNSScreen { 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 => { @@ -1972,17 +2364,7 @@ impl ScreenLike for DPNSScreen { ContestedResourceTask::QueryDPNSContests, ))), ); - if has_active_contests { - vec![ - refresh_button, - ( - "Cast/Schedule Votes", - DesiredAppAction::Custom("Vote".to_string()), - ), - ] - } else { - vec![refresh_button] - } + vec![refresh_button] } DPNSSubscreen::Past => { let refresh_button = ( @@ -2006,19 +2388,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()), ), ] } @@ -2044,12 +2418,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()) { - // 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 + 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 @@ -2068,26 +2458,39 @@ 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) .show(ui.ctx(), |ui| { - inner_action |= self.show_bulk_schedule_popup_window(ui); + inner_action |= self.show_review_and_cast_window(ui); }); } // 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 { - self.render_table_active_contests(ui); + 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 { + self.render_active_contests(ui); } else { inner_action |= self.render_no_active_contests_or_owned_names(ui); } @@ -2182,6 +2585,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; @@ -2210,6 +2614,218 @@ 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(), + 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(); + 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 + .pending_scheduled_casts + .insert(pending_scheduled_key(&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()) + ); + assert!(screen.pending_scheduled_casts.is_empty()); + } + + #[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!( + 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 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 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")); + } + #[test] fn scheduled_vote_sweep_error_is_handled_after_cleanup() { let (ctx, _temp_dir) = offline_ctx(); @@ -2221,9 +2837,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] @@ -2231,9 +2849,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, @@ -2241,7 +2861,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 { @@ -2251,6 +2873,440 @@ 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" + )); + } + + /// 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); + } + + /// 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 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" + ); + 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()); + 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 keeps its proved choice on the + /// review line, so the operator sees what the vote replaces. + #[test] + 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()); + 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_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] + 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 { + 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/masternodes/card.rs b/src/ui/masternodes/card.rs index 67559c7da..6cd753c9b 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::{ @@ -55,11 +55,39 @@ 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.open_contest_count > 0 { - format!("{} contests to vote on", summary.open_contest_count) + if summary.vote_state == MasternodeVoteStateSummary::Unavailable { + if summary.open_contest_count == 0 { + "DPNS voting status unavailable".to_owned() + } else { + format!( + "Vote state unavailable for {} active contests", + summary.open_contest_count + ) + } + } else if summary.open_contest_count > 0 { + if summary.vote_state == MasternodeVoteStateSummary::Checking { + format!( + "Checking votes 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!( + "{} 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_failed_scheduled_vote { + "Scheduled vote needs attention".to_string() } else if summary.has_scheduled_vote { "Vote scheduled".to_string() } else { @@ -368,9 +396,14 @@ 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, + ..Default::default() }; - 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,20 +419,74 @@ 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, + ..Default::default() }; - 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, + ..Default::default() }; 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 { + 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 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/detail_screen.rs b/src/ui/masternodes/detail_screen.rs index 88bb2b768..93caf5fc2 100644 --- a/src/ui/masternodes/detail_screen.rs +++ b/src/ui/masternodes/detail_screen.rs @@ -6,34 +6,26 @@ 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; #[cfg(test)] use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use eframe::egui::{self, Color32, RichText, Ui}; +#[cfg(test)] use std::collections::BTreeMap; -use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; - use crate::app::AppAction; -use crate::backend_task::contested_names::ContestedResourceTask; -use crate::backend_task::identity::{IdentityInputToLoad, IdentityLoadMode, IdentityTask}; +use crate::backend_task::identity::IdentityTask; use crate::backend_task::{BackendTask, BackendTaskContext, BackendTaskSuccessResult}; use crate::context::AppContext; -use crate::model::contested_name::{ContestedName, MasternodeContestSummary}; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::legacy_recovery::RecoveryItem; use crate::model::qualified_identity::{IdentityType, MasternodeKeyPresence, QualifiedIdentity}; -use crate::model::secret::Secret; use crate::ui::components::MessageBanner; use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; use crate::ui::components::legacy_recovery_section::host_offer; -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; @@ -49,76 +41,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. Entering the key is -/// the only remedy on offer — a voting key held on a separate voter identity -/// that the node's own record does not link to cannot be restored from the -/// previous version's saved data (see issue #942). -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."; - -/// 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})") -} - -/// 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, - /// `(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"]; @@ -176,16 +98,6 @@ 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, - /// Per-contest pending vote choice, keyed by normalized contested name. - vote_selections: BTreeMap, - /// 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, /// The offer to restore keys this node left behind in the previous /// version's saved data (issue #889). @@ -194,18 +106,6 @@ pub struct MasternodeDetailView { #[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() - } - /// Whether a recovery offer is currently on screen for this node. pub(crate) fn has_recovery_offer_for_test(&self) -> bool { self.recovery.has_offer() @@ -244,14 +144,6 @@ impl MasternodeDetailView { 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(|(voter, _)| voter.id()); - let contest_summary = app_context - .masternode_contest_summary(voter_id) - .unwrap_or_default(); - let open_contests = Self::load_open_contests(app_context, voter_id); let recovery = LegacyRecoveryState::new(app_context, identity.identity.id()); Self { app_context: app_context.clone(), @@ -259,10 +151,6 @@ impl MasternodeDetailView { node_id_hex_full, node_id_short, key_presence, - contest_summary, - open_contests, - vote_selections: BTreeMap::new(), - voter_key_prompt: None, remove_dialog: None, recovery, } @@ -307,49 +195,13 @@ impl MasternodeDetailView { self.recovery.absorb_error(context); } - /// Load the contests this node can still vote on. Empty when the node has no - /// voting key (no voter id) or the read fails. - fn load_open_contests( - app_context: &Arc, - voter_id: Option, - ) -> 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() - } - - /// 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(|(voter, _)| voter.id()); - self.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); - } - /// 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) } @@ -419,10 +271,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())); } }); @@ -772,211 +620,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.open_contest_count); - egui::CollapsingHeader::new(header) - .default_open(false) - .show(ui, |ui| { - 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 voting choices + Cast votes, dispatching the existing - /// `VoteOnDPNSNames` backend for the selected choices. - 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 contests: Vec = self - .open_contests - .iter() - .map(|contest| { - let candidates = contest - .contestants - .as_ref() - .map(|list| { - list.iter() - .map(|c| (c.id, c.name.clone(), c.votes)) - .collect() - }) - .unwrap_or_default(); - ContestVoteRow { - name: contest.normalized_contested_name.clone(), - end_time: contest.end_time, - candidates, - } - }) - .collect(); - - 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 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); - } - 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() { - ui.label( - RichText::new(NO_SELECTION_HINT).color(DashColors::text_secondary(dark_mode)), - ); - } - } - - ui.separator(); - let votes: Vec<(String, ResourceVoteChoice)> = self - .vote_selections - .iter() - .filter(|(name, _)| contests.iter().any(|c| &c.name == *name)) - .map(|(name, choice)| (name.clone(), *choice)) - .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) + 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() - { - action = Some(AppAction::BackendTask(BackendTask::ContestedResourceTask( - ContestedResourceTask::VoteOnDPNSNames(votes, vec![self.identity.clone()]), - ))); - } - action + .then(|| { + AppAction::SetMainScreen(crate::ui::RootScreenType::RootScreenDPNSActiveContests) + }) } /// Returns `true` once the node has been removed. @@ -1047,6 +696,7 @@ impl MasternodeDetailView { #[cfg(test)] mod tests { use super::*; + use crate::model::secret::Secret; #[test] fn tc_fr5_01_actions_render_before_keys() { @@ -1066,56 +716,6 @@ 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)"); - } - - #[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 fdf6de331..e0e6f52e8 100644 --- a/src/ui/masternodes/list_screen.rs +++ b/src/ui/masternodes/list_screen.rs @@ -148,11 +148,11 @@ 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) - .unwrap_or_default(); + .unwrap_or_else(|_| MasternodeContestSummary::unavailable()); NodeCardData { node_id, node_id_short, @@ -571,8 +571,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::List | MasternodesView::Detail(_) => {} } } @@ -668,11 +667,13 @@ impl ScreenLike for MasternodesScreen { action |= island_central_panel(ui, |ui| { ui.set_min_width(ui.available_width()); + let mut action = AppAction::None; 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::List => action |= self.render_list_view(ui, network_accent), } + action }); action @@ -1523,36 +1524,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/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 new file mode 100644 index 000000000..dec12a268 --- /dev/null +++ b/src/ui/state/dpns_vote_operations.rs @@ -0,0 +1,427 @@ +//! Per-screen DPNS vote-operation snapshot for immediate-mode render paths. + +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, 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, + 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 + } + + 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::>(); + // 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 + .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() + .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::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; + 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()); + } + + 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::NotApplied, + 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::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( + 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/dpns_vote_state.rs b/src/ui/state/dpns_vote_state.rs new file mode 100644 index 000000000..79fd3a79f --- /dev/null +++ b/src/ui/state/dpns_vote_state.rs @@ -0,0 +1,102 @@ +//! 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(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; + } + 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/mod.rs b/src/ui/state/mod.rs index a0c210c21..c6564d5ab 100644 --- a/src/ui/state/mod.rs +++ b/src/ui/state/mod.rs @@ -9,6 +9,9 @@ pub mod account_summary; pub mod asset_lock_balance; 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; pub mod hub_selection; pub mod legacy_recovery; diff --git a/src/wallet_backend/kv_test_support.rs b/src/wallet_backend/kv_test_support.rs index dbc62f6c0..e17d7ecce 100644 --- a/src/wallet_backend/kv_test_support.rs +++ b/src/wallet_backend/kv_test_support.rs @@ -85,7 +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 { @@ -96,10 +99,25 @@ 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) } + + /// 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)); + } + + /// 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 { @@ -107,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) } @@ -114,10 +148,42 @@ 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) } 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) } diff --git a/tests/kittest/masternode_tab.rs b/tests/kittest/masternode_tab.rs index ec2688da0..4f66d60a1 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; @@ -306,6 +306,85 @@ fn empty_state_renders_canonical_copy() { }); } +/// The Masternodes root contains only its list and detail flow. DPNS owns all +/// voting and scheduled-vote navigation. +#[test] +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(); + + 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_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()); + }); +} + +/// Scheduled votes remain reachable from the persistent DPNS subscreen bar. +#[test] +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::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 + ); + assert!(harness.query_by_label("No scheduled votes.").is_some()); + }); +} + +/// 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 @@ -543,13 +622,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(); @@ -561,42 +637,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()); }); }