diff --git a/CHANGELOG.md b/CHANGELOG.md index 26d07c9b7..1cc9de5ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,38 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). before. Each suggestion now shows a small label for its wallet and its type, and the field shows a hint listing the tags you can use when it's empty. + +- **Masternodes tab**: a new "Masternodes" entry in the left nav (visible when + 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. + +- **Wallet/identity indicator on more screens (rollout in progress)**: the + wallet and identity picker previously shown only at the top of the Identity + Hub now also appears at the top of the Identities, DashPay, DPNS, and + Wallets screens. On the Identity Hub and the new Masternodes tab it's fully + interactive — you can change which wallet or identity you're acting as + right there. On the other four it's currently a read-only preview of your + active wallet/identity, with a tooltip on where to change it; making it + interactive there, and adding it to the remaining screens, is tracked as a + follow-up. + ### Changed +- **Masternode and evonode identities no longer appear in the Identity Hub or + Identities picker**: they now live exclusively on the new Masternodes tab, + so you're never offered actions (like registering a username) that don't + apply to a node's collateral/voting identity. + - **Wallet balance breakdown is single-sourced**: the per-account tabs and the wallet header now derive every balance from one place. The Core header total and the Core per-account breakdown are read from the same generation of synced @@ -108,6 +138,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **Expert mode now reveals the Masternodes tab without a restart**: turning on + Expert mode in Settings immediately shows the "Masternodes" entry in the left + nav. Previously the Expert-mode flag was stored separately per network, so the + nav entry could stay hidden (reading a stale value on whichever network context + the app was showing) until the app was restarted. Expert mode is now a single + app-wide flag shared across all networks. + +- **Clearer error when loading a masternode by an unknown ProTxHash**: entering a + valid-looking but unregistered ProTxHash in the masternode load form now says no + masternode or evonode was found for that ProTxHash, instead of the misleading + generic "Identity not found — check the ID or name" message. + +- **Withdrawal key selection**: the Withdraw screen now pre-selects only a key + whose private key you actually hold (a payout/Transfer key preferred, Owner as + fallback). Previously it could pick a key that exists on the identity but whose + private key isn't loaded locally — common on loaded masternode/evonode + identities where only the Owner key was supplied — which made the withdrawal + fail at signing with an unhelpful technical error. When no usable key is + loaded, the screen guides you to add one instead of failing mid-withdrawal. - `WalletBackend` is now initialised eagerly at `AppState` start, eliminating a retry-loop spam on the SDK connection during cold boot. - Wallet store is rehydrated on cold start, resolving a regression where wallets diff --git a/CLAUDE.md b/CLAUDE.md index e5f853028..7fe926ba4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,7 +77,7 @@ Code lives by responsibility, not convenience: - **`backend_task/`** — async business logic, one submodule per domain; the authoritative enforcement layer. `TaskError` and its typed variants live in `backend_task/error.rs`. - **`database/`** — SQLite persistence, one module per domain. - **`context/`** — `AppContext` submodules (`*_db.rs`, lifecycle, settings, status). -- **`wallet_backend/`** — the wallet orchestration seam: adapters, views, backend-side live caches, signers, the secret chokepoint, the event bridge. All wallet secret bytes (HD seed, imported single key, identity private key) enter/leave the vault through ONE chokepoint, `wallet_backend/secret_seam.rs` (raw `SecretBytes`, no DET-side serialization). Per-secret at-rest encryption is implemented via `put_secret_protected`/`get_secret_protected` (Argon2id + XChaCha20-Poly1305, per-secret object-password envelope, AAD bound to `wallet_id ‖ label`); unprotected secrets use `put_secret`/`get_secret` (raw, keyless vault). The keyless-vault residual (identity keys and no-password secrets) is the deferred tier. Design + migration: `docs/ai-design/2026-06-19-secret-storage-seam/`. +- **`wallet_backend/`** — the wallet orchestration seam: adapters, views, backend-side live caches, signers, the secret chokepoint, the event bridge. All wallet secret bytes (HD seed, imported single key, identity private key) enter/leave the vault through ONE chokepoint, `wallet_backend/secret_seam.rs` (raw `SecretBytes`, no DET-side serialization). Per-secret at-rest encryption is implemented via `put_secret_protected`/`get_secret_protected` (Argon2id + XChaCha20-Poly1305, per-secret object-password envelope, AAD bound to `wallet_id ‖ label`); unprotected secrets use `put_secret`/`get_secret` (raw, keyless vault). Identity keys (imported/loaded, including masternode voting/owner/payout) enter unprotected (Tier-1 keyless) at load/creation time — the load flow has no password field — but can be sealed to Tier-2 per-identity afterward via `IdentityTask::ProtectIdentityKeys` (Key Info screen → "Add password protection…"; gated by vault-key scheme, not identity type). The keyless-vault residual is only no-password secrets and keys the user has not opted to protect. Design + migration: `docs/ai-design/2026-06-19-secret-storage-seam/`. - **`ui//`** — screens (`ScreenLike`). UI may *call* `model/` validators for instant feedback but never implements its own validation. - **`ui/components/`** — reusable **Component-pattern widgets ONLY**: a `show()` plus a `ComponentResponse`, a display-only render widget, or component infrastructure. If it does not render egui, it is not a component. - **`ui/state/`** — non-widget UI state: per-screen view-models and async fetch-state caches (e.g. `TrackedAssetLockCache`). Owned by screens, may return `BackendTask`, render nothing. diff --git a/Cargo.toml b/Cargo.toml index ab1e8b8c3..bd7d3f9cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -132,6 +132,11 @@ name = "backend-e2e" path = "tests/backend-e2e/main.rs" required-features = ["testing"] +[[test]] +name = "mcp_http_auth" +path = "tests/mcp_http_auth.rs" +required-features = ["mcp"] + [[bench]] name = "wallet_hydration" harness = false diff --git a/docs/ai-design/2026-07-09-masternode-page-design/01-requirements.md b/docs/ai-design/2026-07-09-masternode-page-design/01-requirements.md new file mode 100644 index 000000000..a55d32e0d --- /dev/null +++ b/docs/ai-design/2026-07-09-masternode-page-design/01-requirements.md @@ -0,0 +1,751 @@ +# Masternodes Page — Requirements + +**Repo:** `dash-evo-tool` · **Branch:** `feat/masternodes-tab` (based on PR #873) · **Date:** 2026-07-09 +**Phase:** Requirements + UX (planning only; no Rust changes) +**Author:** Diziet (Product Designer) + +Companion artifacts: `02-ux-spec.md` (journeys, wireframes), `wireframes.html` (visual mock). +Source of truth for the split rationale: `../identity-hub-parity-audit.md` § "Masternodes as a separate page". + +--- + +## 1. Executive Summary + +**Problem.** Masternode and evonode identity handling is buried inside the generic +*Identities → Load Identity* screen, reachable only by ticking *Show Advanced Options* and +selecting an identity type. That screen serves three audiences through one radio-plus-conditional +form. The masternode/evonode arm is a distinct job for a distinct audience — node operators whose +real payoff is **DPNS contested-name voting** — yet today these identities land in the same table, +and worse, they leak into the everyday-user **Identity Hub** picker where they are offered +user-centric actions (register a username, edit a social profile, add a contact) that are +meaningless for a collateral/voting identity. + +**Solution direction.** Add a dedicated left-nav root tab **"Masternodes"** with a **card layout** +that (a) owns a masternode/evonode-only load flow lifted from the advanced-options arm, (b) lists +loaded masternodes/evonodes as cards showing ProTxHash, type, voter-identity readiness, key status, +and DPNS-voting status, (c) opens a per-node detail/voting view, and (d) is paired with a filter +that keeps masternode/evonode identities **out of the Identity Hub / Identities pickers** so those +surfaces stay everyday-user only. + +**Key actors.** Priya (Power User / masternode operator) is the primary actor. Alex (Everyday User) +is a *contrast* actor — the design must keep the operator surface out of Alex's way and vice versa. + +**No model changes.** `IdentityType` (User/Masternode/Evonode), `associated_voter_identity`, and the +three-way `PrivateKeyTarget` already provide the seam. This is a new view + filter + card layout over +existing model and backend plumbing, not a rewrite. + +--- + +## 2. Stakeholder & Actor Analysis + +### 2.1 Primary actor — Priya (Power User / masternode operator) + +Canonical persona: `docs/personas/power-user.md`. Priya runs a Dash masternode, manages multiple +wallets, and understands ProTxHash, DIP3 owner/voting/operator keys, and derivation paths. + +| Field | Value | +|---|---| +| **Goal** | Load her masternode/evonode identities into DET and use them to vote on DPNS contested names. | +| **Pain today** | The load path is buried behind *Show Advanced Options*; her nodes then sit in the same table as everyday user identities and appear in the Identity Hub with nonsensical user actions. | +| **Success metric** | Time to check masternode key paths **under 10s** (persona success metric); load a node and reach its voting view without touching the generic identity flow. | + +### 2.2 Contrast actor — Alex (Everyday User) + +Canonical persona: `docs/personas/everyday-user.md`. Alex never operates a node. + +| Field | Value | +|---|---| +| **Goal** | Manage a personal identity (username, credits, DashPay). | +| **Relevance** | Alex must **never** be shown masternode load fields or see masternode identities in the Identity Hub picker. The Masternodes tab is a self-contained operator surface Alex can ignore. | + +### 2.3 Secondary / supporting + +- **DPNS contested-name voting** (backend `contested_names/vote_on_dpns_name.rs`, DPNS root screens): + the downstream consumer of masternode voter keys. The Masternodes page hands off to / surfaces this. +- **Wallet subsystem** — the active wallet on the Masternodes page is the **funding source for Top up** + (FR-9), not a key-derivation source. **Correction (investigated 2026-07-09):** voting/owner/payout keys + cannot be auto-derived from a wallet — `derive_keys_from_wallets` is hard-gated to `IdentityType::User` in + `backend_task/identity/load_identity.rs`; masternode keys are Core-side (tied to the node's ProRegTx), not + part of any wallet's identity-auth HD tree. The "Try to derive from loaded wallet" checkbox does NOT carry + over to the load form — see §9 note and US-6 retirement. +- **Secret seam / at-rest storage** — owner/voting/payout keys **enter unprotected (Tier-1 keyless)** at load + time because the load flow has no password field (by design; confirmed also for the `identity_masternode_load` + MCP tool). They are **not permanently plaintext**: a loaded node's keys can be sealed to **Tier-2 per-identity** + encryption afterward via the existing `IdentityTask::ProtectIdentityKeys` (Argon2id + XChaCha20-Poly1305, + per-secret object-password envelope; today reached from the Key Info screen's "Add password protection…"). + See `wallet_backend/secret_seam.rs` and `docs/ai-design/2026-06-19-secret-storage-seam/`. The design's job is + to (a) make this accurate to the user via a non-blocking awareness note, and (b) surface the "add protection" + affordance on the Masternodes page — **not** to design the crypto (which already exists). + +--- + +## 3. Domain Notes + +- **ProTxHash is the identifier.** For masternode/evonode identities the "Identity ID" is the ProTxHash, + conventionally **hex**-encoded (`IdentityType::default_encoding` → Hex for MN/Evonode vs Base58 for User). + The page must label the field **"ProTxHash"**, not "Identity ID". +- **Two node types.** `Masternode` (regular) and `Evonode` (HPMN / high-performance). Model discriminates + via `IdentityType`. Badge colours already exist: Masternode → `PLATFORM_PURPLE`, Evonode → `DASH_BLUE` + (`identity_picker_card.rs::draw_type_badge`). +- **Three key roles (DIP3).** Voting Private Key, Owner Private Key, Payout Address Private Key — the exact + three inputs in the current advanced arm (`add_existing_identity_screen.rs:420-434`). All are optional at + load time; without them the node is view-only. +- **A masternode carries a separate voter sub-identity.** `associated_voter_identity: Option<(Identity, IdentityPublicKey)>`. + Its presence is what enables voting; its absence is the `NoVotingIdentity` error at vote time. The card must + surface **voter-identity readiness** as a first-class status. +- **The real workflow is voting.** A masternode identity exists in DET primarily to vote on DPNS contested + names (and hold owner/payout keys). Vote choices are **Abstain**, **Lock**, or **vote for a candidate**. +- **Identity status** (`IdentityStatus`: Active / Unknown / PendingCreation / NotFound / FailedCreation) applies + and already has a colour mapping (green/gray/orange/red). Reuse it as a status dot on the card. +- **Key-protection tier is per-identity, opt-in.** MN/Evonode keys load as Tier-1 (unprotected/keyless) — the load + form has no password field by design — and can be upgraded to Tier-2 (per-identity password protection) later + via the existing `IdentityTask::ProtectIdentityKeys`. The Masternodes page surfaces this state and the upgrade + affordance; it does not implement encryption. Protection is gated by the vault-key scheme, not identity type. +- **Provider withdraw destination rule.** Withdrawing a provider identity's credits with the **owner** key forces + the destination to the node's **registered Core payout address**; with the **transfer/payout** key the + destination is a **free** address. Existing withdraw-flow behaviour (FR-9) — surfaced, not redesigned. +- **Add-key purpose rule (all identity types).** The add-key purpose selector **excludes OWNER and VOTING** — + Core-registered provider roles that cannot be added via Platform for any identity type. TRANSFER / AUTHENTICATION + / ENCRYPTION / DECRYPTION are addable (FR-10). +- **Evonode token rewards are Evonode-only** (protocol rule). Plain Masternodes have none, so FR-11's "Claim token + rewards" cross-link appears only for `IdentityType::Evonode`. + +--- + +## 4. Functional Requirements + +### FR-1 — Masternodes root tab *(Expert Mode gated — decision, 2026-07-09)* +A new left-nav root entry **"Masternodes"** (icon + label, matching the existing rail style), placed +adjacent to Identities / Identity Hub. Selecting it shows the Masternodes page. It persists as a root +screen and survives network switches like other root tabs. + +**Visibility gate:** the entire tab — nav item AND screen access — is shown only when **Expert Mode** is +ON (`app_context.is_developer_mode()`, user-facing label "Expert mode" per `network_chooser_screen.rs:607`). +With Expert Mode off, the nav item does not render at all (not just disabled/hidden-behind-a-click) and the +route is unreachable, matching how other expert-only surfaces are gated in this codebase (`FeatureGate::DeveloperMode`, +`model/feature_gate.rs:69`). Rationale: masternode/evonode operation is a distinct, node-operator audience +(Priya persona) — Expert Mode is the existing mechanism DET already uses to separate that audience from +Alex (Everyday User), so this reuses an established pattern rather than inventing a new one. + +### FR-2 — Empty state +When no masternode/evonode identities are loaded, show a centered card empty state (matching the +*No Identities Loaded* pattern in `03-identities-empty.png`) explaining what a masternode identity is +for (voting on DPNS contests, holding owner/payout keys) and offering a primary **"Load a masternode"** +action. Include the reassurance line about node connectivity in the existing empty-state voice. + +### FR-3 — Card list of loaded masternodes +Present loaded masternode/evonode identities as a responsive card grid (reusing the +`identity_picker_card.rs` visual language: rounded surface card, monogram, type badge pill). Each card shows: +- **Identifier** — shortened ProTxHash (heading), with alias above it when set. +- **Type badge** — Masternode (purple) / Evonode (blue). +- **Voter-identity readiness** — "Voting ready" (voter identity present) or "No voting key" (absent). +- **Key status** — which of Voting / Owner / Payout keys are loaded (compact indicator, e.g. `V O P` + with present keys emphasised). +- **DPNS-voting status** — a short line: open contests available to vote on, or scheduled/last vote state. +- **Identity status dot** — from `IdentityStatus` (Active/Unknown/NotFound…). +- The whole card is a single click target → opens the detail/voting view (FR-5). + +### FR-4 — Load a masternode/evonode +A dedicated load flow (extracted from the advanced-options arm) with fields: +- **ProTxHash** (required) — labelled and hinted as ProTxHash; accepts hex or Base58. +- **Node type** — segmented toggle Masternode / Evonode (replaces the buried combo box; no "User" option here). +- **Alias** (optional) — local-only label, explicitly "not saved to Dash Platform". +- **Voting Private Key**, **Owner Private Key**, **Payout Address Private Key** — WIF or hex, all optional, + always pasted manually (no auto-derive — see US-6 retirement in §9, these key roles cannot be derived from + any wallet). On Testnet with a `.testnet_nodes.yml` fixture present, a **"Fill Random Masternode/Evonode"** + dev-convenience button (FR-12) can autofill this section from a real test node. +- **Encryption password (optional)** — an optional password field (WIF-style show/hide eye + helper line). When + set, the entered voting/owner/payout (and identity) keys are **sealed encrypted-at-rest at load time** (Tier-2); + when left blank, keys load unprotected (Tier-1 keyless / obfuscation-only) and can be protected later from the + Key Info screen. See **FR-8** for the plumbing this requires. Copy in §7. +- **Key-storage awareness** — a non-blocking inline note (Warning tone, not a blocking gate) explaining that, + without a password, keys are stored unencrypted (obfuscation-only) and protection can be added later. Copy in §7. +- Primary **Load masternode** action, disabled until a ProTxHash is entered, with a disabled-tooltip + explaining why (per `ResponseExt::disabled_tooltip`). + +### FR-5 — Masternode detail / voting view +Opening a card shows a detail view with: +- Header: alias (if any) + shortened ProTxHash + type badge + copy-ProTxHash affordance + identity status. +- **Keys summary** — Voting / Owner / Payout presence, voter-identity ID (shortened, copyable), and the + **protection tier** (Unprotected / Password-protected). When Tier-1, offer an **"Add password protection…"** + action that dispatches the existing `IdentityTask::ProtectIdentityKeys` (no new crypto). This is the recourse + the load-form awareness note points to. Also a **"Manage keys ›"** drill-in (FR-10) into the existing + `KeyInfoScreen`. +- **DPNS voting section** — a **collapsing section, collapsed by default**, with the open-contest **count in + the header** (`DPNS name contests to vote on (3)`) so operators see there's something to act on without + expanding. Expanded, it lists active contested names this node can vote on, each with the three choices + (Abstain / Lock / vote for a candidate identity), plus scheduled and past votes where available. This surfaces + / hands off to the existing DPNS voting backend; it does not re-implement voting logic. +- **Credit actions row** — Withdraw / Top up / Transfer (FR-9), for both Masternode and Evonode. +- **Token rewards cross-link** — **Evonode only** (FR-11): "Claim token rewards ›" routing to the existing + `ClaimTokensScreen`. Hidden for plain Masternode. +- **Remove** — a destructive action (danger button, confirmation dialog) that forgets the masternode from + DET and also removes its associated voter identity (existing behaviour). + +Grouping (top → bottom, to avoid clutter): header · credit-actions row (with the Evonode-only token-rewards +link) · Keys (with "Manage keys ›") · collapsible DPNS voting · Remove. + +### FR-9 — Credit actions on the detail view (Withdraw / Top up / Transfer) *(reuse existing screens)* + +The detail view exposes the node's credit operations as an actions row/dropdown (mirroring the legacy Identities +**Actions** affordance), for **both Masternode and Evonode**: +- **Withdraw** → reuse `withdraw_screen`, scoped to the selected node's `QualifiedIdentity`. +- **Top up** → reuse `top_up_identity_screen`, scoped to the node. +- **Transfer** → reuse `transfer_screen`, scoped to the node. + +**MN/Evonode-specific withdraw behaviour (document, don't redesign):** withdrawing with the **owner** key forces +the destination to the node's **registered Core payout address**; withdrawing with the **transfer/payout** key +allows a **free** destination address. This is existing behaviour of the withdraw flow for provider identities — +surface it, don't reimplement it. + +*Reuse note (Nagatha):* no new operation screens — pass the selected node's `QualifiedIdentity` into the three +existing screens. The only new work is the entry points (row/dropdown) on the detail view. + +### FR-10 — Key-management drill-in (`KeyInfoScreen`) *(reuse existing screen)* + +The Keys section offers **"Manage keys ›"** opening the **existing** `KeyInfoScreen` for the node: view private +key / WIF, sign message, add key, remove key, protect keys. + +**Add-key constraint (not MN-specific — document it):** the add-key **purpose selector excludes OWNER and +VOTING** (those are Core-registered provider roles, un-addable via Platform for **any** identity type). +TRANSFER / AUTHENTICATION / ENCRYPTION / DECRYPTION are addable. This is an existing platform rule, surfaced here +so the operator isn't surprised. + +*Reuse note (Nagatha):* route to the existing `KeyInfoScreen` scoped to the node; no new key UI. + +### FR-11 — Evonode token-rewards cross-link (`ClaimTokensScreen`) *(Evonode only, reuse existing screen)* + +On the detail view of an **Evonode** identity (NOT a plain Masternode), show **"Claim token rewards ›"** routing +to the existing Tokens **`ClaimTokensScreen`** scoped to that identity. **Hidden for Masternode.** + +- Evonode-only is a **protocol rule** — a plain Masternode simply has no such rewards, so the action is shown + only when `identity_type == Evonode`. +- Do **not** rebuild any claim UI on the Masternodes page — it is a cross-link/route only. + +*Reuse note (Nagatha):* conditional entry point (`Evonode` only) that routes to `ClaimTokensScreen` with the +node's identity; no new claim logic. + +### FR-12 — "Fill Random Masternode / Evonode" dev convenience (Testnet only, reuse existing logic) + +Carry forward the existing dev-only quick-fill on the load form (FR-4): a single button, labelled to match the +Node-type toggle ("Fill Random Masternode" / "Fill Random Evonode"), that picks a random **real** testnet node +from a local `.testnet_nodes.yml` fixture and autofills ProTxHash + keys (**Voting + Owner for a Masternode; +Voting + Owner + Payout for an Evonode** — the regular-masternode fixture struct `MasternodeInfo` carries no payout +key, so `fill_random_masternode()` fills Voting + Owner only, verified `add_existing_identity_screen.rs:30-35,979-993`). +It does **not** fabricate a synthetic node — it is a curated-fixture quick-fill for developer testing, same as today. + +- **Source (investigated 2026-07-09):** `add_existing_identity_screen.rs:203-208` — `fill_random_masternode()` + (lines 979-993) and `fill_random_hpmn()` (lines 961-977), reading `load_testnet_nodes_from_yml(".testnet_nodes.yml")` + into a `TestnetNodes { masternodes, hp_masternodes }` fixture struct. +- **Fixture facts (verified 2026-07-09):** `.testnet_nodes.yml` is **gitignored** (`.gitignore:17`), not tracked + in git, and does **not exist** in this repo — it is not shipped, not hardcoded, and not something we currently + have. It **does contain real private keys in plaintext** (`KeyInfo.private_key` for owner/voter/payout, + `serde_yaml_ng`-parsed). It must be supplied locally by the developer; the loader returns `Ok(None)` + gracefully only when the file is **absent** (no error, no crash). A **malformed** file returns `Err(_)`, which the + *legacy* screen banners (`add_existing_identity_screen.rs:151-161`); the new Masternodes load form must map + `Err(_)` → absent button (swallow, no banner) — a deliberate divergence, not verbatim reuse (see test-spec TC-FR12-04). +- **Visibility — MUST be conditional on fixture presence (decision, 2026-07-09):** the button does not render + at all unless `load_testnet_nodes_from_yml(...)` returns `Some(_)` — never shown-but-disabled. On networks + other than Testnet, or when the file is missing/unparseable, the button and its row are simply absent from + the load form; no placeholder, no error state. +- **Gating today (current app):** visible when `show_advanced_options` is on + `network == Testnet` + the yml + fixture loaded successfully. **Not gated by `developer_mode`** on the current screen. +- **Gating on the new page — simplified by FR-1's Expert Mode gate:** because the entire Masternodes tab now + requires Expert Mode (FR-1) to even be reached, an additional per-button `developer_mode` check would be + redundant for normal navigation. The button's own remaining condition is just **Testnet + fixture present** + (dropping the now-unreachable `show_advanced_options` concept, which doesn't exist on this page — see FR-4). + Flag for Nagatha: confirm whether a defense-in-depth `developer_mode` check is still worth adding at the + button call-site (cheap, guards against any future non-nav entry point to this screen) — implementation + judgment call, not re-litigated here. + +*Reuse note (Nagatha):* reuse `fill_random_masternode()`/`fill_random_hpmn()` and the `.testnet_nodes.yml` +loader verbatim; only the entry point (one button instead of two, label follows the Node-type toggle) and the +condition (fixture-presence check controlling render, not just enabled state) are new. + +### FR-6 — Filter masternode/evonode out of user-only pickers +Masternode/evonode identities (`IdentityType != User`) must **not** appear in the Identity Hub picker or the +generic Identities user-identity surfaces. This is the paired correction that keeps the everyday-user surface +coherent (audit § "1 new latent finding"). This is a filter, not a data migration. + +**Extension (decision, §10.2, 2026-07-09):** once FR-4 ships, remove the Masternode/Evonode options from the +legacy buried arm (`add_existing_identity_screen.rs`'s Identity Type dropdown under Show Advanced Options) — +that dropdown becomes User-only. This is a removal, not just a filter, and it prevents two competing entry +points for loading the same kind of identity. + +### FR-7 — Refresh +Provide a refresh affordance (top-right, matching `01-dashpay.png` header Refresh button) that re-fetches +masternode identity + voting state. + +### FR-8 — Optional load-time key encryption password *(needs NEW plumbing — implementation scope)* + +The load form (FR-4) offers an **optional "Encryption password"** field. Its behaviour: +- **Blank (default):** keys load **Tier-1 keyless** (obfuscation-only, not confidential) — current behaviour. The + user can seal them later via the Key Info screen's "Add password protection…" (existing + `IdentityTask::ProtectIdentityKeys`), also surfaced on the Masternodes detail view (FR-5). +- **Set:** the entered voting/owner/payout (and identity) keys are **sealed Tier-2 at load time** + (`put_secret_protected` / `store_protected` — Argon2id + XChaCha20-Poly1305, per-secret object-password + envelope) instead of only post-load. + +**This is not a pure view change — it requires NEW plumbing (flag for the implementation plan / Nagatha):** +- `backend_task/identity/mod.rs::IdentityInputToLoad` (struct at `mod.rs:43`) currently has **no password field** + — it carries only `voting_private_key_input` / `owner_private_key_input` / `payout_address_private_key_input` + (`Secret`), `keys_input`, `derive_keys_from_wallets`, `selected_wallet_seed_hash`. A new optional password + field must be added. +- `backend_task/identity/load_identity.rs` currently persists loaded keys unprotected. When a password is + present it must route the persist through the **existing** seal path (`store_protected` / + `put_secret_protected`, as used by `protect_identity_keys.rs:225` and `add_key_to_identity.rs:265`) so keys + land Tier-2 at load — rather than only reachable post-load via `ProtectIdentityKeys`. +- No **new crypto**: reuse the existing protected-secret envelope. The work is threading the password param + through `IdentityInputToLoad` → `load_identity` → the seal path, and validating it (non-empty when the box is + used; the field is entirely optional). +- **MCP scope (decision, 2026-07-09):** the third `IdentityInputToLoad` constructor — the MCP + `masternode_identity_load` tool (`src/mcp/tools/masternode.rs:180`, a confirmed keyless entry point, §2.3) — + passes `encryption_password: None` this iteration (Tier-1 unchanged; FR-8 is GUI-scoped). A `TODO` records + headless password parity as a follow-up. +- **Model/backend rules:** password is a `Secret`, never logged, never stored; validation of the plaintext + password (e.g. min length, if any) belongs in `model/`, enforcement in the backend task per DET layering. + +### FR-GLOBAL-NAV — Global wallet/identity switcher on every root page *(cross-cutting app-shell)* + +> **Scope note.** This is a **cross-cutting app-shell change larger than the Masternodes page** — it touches +> every root screen's top panel, not just Masternodes. It is recorded here because it is the **foundation the +> Masternodes page sits inside**: the Masternodes tab must render the same global chrome as every other tab. +> Implementation should be scheduled as its own app-shell task; the Masternodes page consumes it. + +The wallet + identity breadcrumb switcher (`src/ui/identity/breadcrumb_switcher.rs`, IDH-003) — today rendered +**only** on the Identity Hub (`hub_screen.rs:195`) — becomes a **global top-nav rendered on every root page** +(Dashpay, Identities, Identity Hub, Masternodes, Contracts, …) through the shared top panel. This realizes the +original IDH-003 design intent, which already states the breadcrumb "is always visible in the topbar of **every +tab**" (design-spec `2026-04-22-identity-dashpay-redesign/design-spec.md` §A.3) but was only wired into the Hub. + +- **FR-GLOBAL-NAV-1 — One switcher, everywhere.** Every root screen renders the three-segment switcher + (`Identities › 💼 wallet › 👤 identity`) in the top island's left region via + `top_panel::add_top_panel_with_breadcrumb` (the seam already exists — both `add_top_panel` and the breadcrumb + variant delegate to the same `render_top_island`). Behavior, styling, tooltips, dropdowns, and placeholder + rules are identical on every page (design-spec §A.3 / §7 / §D). +- **FR-GLOBAL-NAV-2 — Selection interaction model (authoritative).** The switcher reads/writes the **app-scoped** + selection (`AppContext::selected_wallet_hash` / `selected_identity_id`; `HubSelection` holds only within-session + search buffers). Four rules govern how it behaves on every page: + 1. **Silent context change.** Selecting an object (wallet / identity / whatever the page exposes) in the top-nav + silently updates the app-global selection. **No forced navigation** to another tab. *(This supersedes the + earlier "route to Identity Home on identity selection" framing — see §9 resolved question.)* + 2. **Two-way binding where the page consumes the selection.** If the active page actively uses a selected + object, the top-nav pill and the page stay in sync **both ways**. Canonical example: on a + Send/Transfer-from-wallet page, changing the wallet in the top-nav changes the page's source wallet, and + changing the source wallet on the page updates the top-nav pill. Same for identity where a page consumes it. + 3. **Blast-radius control (rollout strategy).** The nav renders on every page immediately, but a given pill is + **interactive only on pages already wired to consume that selection**. On pages not yet wired, that pill + renders **disabled/read-only** — dimmed, no caret, **no visible text tag**; the explanation lives in a + **hover tooltip that tells the user how to change that selection** (e.g. "Change the active wallet from the + Wallets tab", "Updates when you open a masternode"). Leave a `TODO` in code to wire it later. Interactivity + rolls out page-by-page — no requirement to wire every page at once. + 4. **Per-page composition.** Show only the pills that make sense for the page. A page with no identity context + (e.g. a Wallet page) shows **only the wallet pill**, no identity pill. The switcher is composable per page: + `[wallet]`, `[wallet + identity]`, etc. +- **FR-GLOBAL-NAV-3 — The third pill is the identity/object relevant to THIS page's context (page-scoped).** + The third segment is not hard-wired to "the app-global User identity" — it is **whatever identity/object the + current page operates on**: + - **On everyday-user pages** (Dashpay / Identities / Identity Hub): the app-global **User** identity. + - **On the Masternodes page**: the **page-scoped masternode/evonode in view** (`Ⓜ mn-east-01 ▾`). Its dropdown + lists the loaded masternode/evonode identities; it is **interactive and two-way bound** with the card grid + and the detail view (opening a card sets the pill; picking from the pill opens that node). + + On the Masternodes page **both pills are interactive**: the **wallet pill** (funds Top up on this node — FR-9, + two-way bound; NOT a key-derivation source, see §9 auto-derive correction) **and** the **masternode pill** + (the node in view). + + **Why this does NOT violate FR-6 (the boundary — read carefully).** FR-6 forbids MN/Evonode identities from + appearing in the **everyday-user Identity Hub / Identities picker**. The masternode-in-view selection here is a + **separate, page-scoped selection**, distinct from the app-global User-identity selection. Picking a masternode + in the Masternodes switcher does **not** write the app-global user-identity selection and therefore **never** + makes that masternode appear in the identity pill on user pages, nor in the Hub picker. The Masternodes page's + own switcher is not the everyday-user picker FR-6 governs. Two selections, two scopes, one clean boundary. +- **FR-GLOBAL-NAV-4 — Selection filtering already respects FR-6.** Because FR-6 filters MN/Evonode out of the + identity picker/dropdown, the global identity pill's dropdown lists only User identities on every page — no new + leak is introduced by making the switcher global. +- **FR-GLOBAL-NAV-5 — Sub-screen navigation stays in the content panel.** Pushed sub-screens (the load form, a + masternode detail view) show their own lightweight back row **inside the content panel** (e.g. `‹ All masternodes`), + keeping the global switcher single-line and unchanged across navigation (design-spec §A.3 "the topbar stays + single-line"). +- **FR-GLOBAL-NAV-6 — Leftmost breadcrumb is page-aware.** Segment-1 reflects the active tab and links to its + root (`Masternodes › 💼 wallet › 👤 identity`) — confirmed answer to the earlier Q1. + +**Flag for the implementation plan (Nagatha):** the mechanism is a **per-page capability declaring which +selections it consumes**, plus a **two-way binding** between that page and the selection, with +**disabled/tooltip rendering + `TODO` markers** on pages not yet wired. Crucially, the set of selections is more +than one axis: there is the **app-global User-identity selection** (consumed by the everyday-user pages) **and a +distinct page-scoped masternode/evonode selection** (consumed by the Masternodes page). These must be kept +separate so a masternode choice never bleeds into the app-global user-identity selection (FR-6 boundary). This is +the blast-radius-limited rollout — the nav appears everywhere on day one; interactivity per pill lands +page-by-page. + +**Acceptance criteria (US-7 below).** This requirement is **Should** for the Masternodes deliverable but **Must** +as a shared prerequisite: the Masternodes page cannot ship its header without the global switcher existing. + +--- + +## 5. Non-Functional Requirements + +- **NFR-1 Reuse, do not reinvent.** Reuse `identity_picker_card.rs` (card + badge), the empty-state card + pattern, `StyledButton`/`ComponentStyles` buttons, `MessageBanner`, breadcrumb header, and the left nav + rail. Consult `src/ui/components/README.md` before adding any new widget. +- **NFR-2 No model changes.** No changes to `IdentityType`, `associated_voter_identity`, `PrivateKeyTarget`, + or the DPNS voting backend. The page is a view + filter over existing types. +- **NFR-3 Design tokens only.** All colours/spacing/typography via `DashColors` / `Spacing` / `Typography` / + `Shape` — no hardcoded values (see `docs/ux-design-patterns.md`). +- **NFR-4 Key-protection awareness (non-blocking) + reuse the existing protect path.** Surface, do not solve. + MN/Evonode owner/voting/payout keys load **unprotected (Tier-1)** — the load flow has no password field, by + design. The load form shows a one-line, actionable Warning-tone note that says protection can be added after + loading. Do **not** design the encryption (it already exists as Tier-2 `IdentityTask::ProtectIdentityKeys`); + do **not** gate the load flow on it. The detail view **surfaces the existing "Add password protection…" + affordance** for the node's keys (reuse, not new crypto) and reflects the current protection tier. +- **NFR-5 i18n-ready copy.** All proposed strings are complete sentences with named placeholders, no fragment + concatenation (per project string style). +- **NFR-6 Accessibility.** WCAG 2.1 AA: card is a single labelled click target (`WidgetInfo::labeled`), + focus order top-to-bottom, disabled controls carry disabled-tooltips, status is never colour-only (pair the + status dot with a text label). Note egui's limited screen-reader support (documented constraint). +- **NFR-7 Progressive disclosure — REVISED 2026-07-09 (supersedes the original wording).** This is a + Power-User surface; it is a sibling root tab, and **is gated behind Expert Mode** (`is_developer_mode()`) per + FR-1 — the original "not gated behind developer mode" language is corrected by explicit decision. Keep the + everyday-user surfaces (Hub/Identities) unchanged apart from the FR-6 filter. + +--- + +## 6. User Stories & Acceptance Criteria + +**US-1 — Load a masternode by keys.** +*As a masternode operator, I want to load my masternode by its ProTxHash and DIP3 keys on a dedicated page, +so that I don't have to dig through the generic identity-load advanced options.* +- **Given** I open the Masternodes tab with no nodes loaded, **When** I click "Load a masternode", **Then** I + see a form with ProTxHash, a Masternode/Evonode toggle, optional alias, and Voting/Owner/Payout key fields. +- **Given** the form, **When** the ProTxHash field is empty, **Then** the "Load masternode" button is disabled + and its tooltip explains a ProTxHash is required. +- **Given** a valid ProTxHash and (optionally) keys, **When** I click "Load masternode", **Then** the node is + loaded and appears as a card in the Masternodes list. +- **Given** I entered private keys, **When** I view the form, **Then** a non-blocking note tells me the keys are + stored unencrypted at rest on this device. + +**US-2 — See my masternodes at a glance.** +*As a masternode operator, I want a card list of my loaded masternodes showing type, voter readiness, key +status, and voting status, so that I can assess each node in seconds.* +- **Given** ≥1 loaded node, **When** I open the Masternodes tab, **Then** each node is a card showing shortened + ProTxHash, type badge (Masternode/Evonode), voter-identity readiness, key status, DPNS-voting status, and an + identity status dot with a text label. +- **Given** a node with no voter identity, **When** I read its card, **Then** it clearly shows "No voting key". +- **Given** a node with an alias, **When** I read its card, **Then** the alias is the heading and the ProTxHash + is shown beneath it. + +**US-3 — Open a masternode and vote.** +*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.* +- **Given** a card, **When** I click it, **Then** its detail view opens showing keys summary, voter identity, + and the DPNS voting section. +- **Given** the detail view with active contested names, **When** I choose Abstain / Lock / a candidate for a + name, **Then** the vote is dispatched through the existing DPNS voting backend. +- **Given** a node whose voter identity is missing, **When** I open the voting section, **Then** I am told a + voting key is required and how to add one (rather than a raw error). + +**US-4 — Remove a masternode.** +*As a masternode operator, I want to remove a masternode from DET, so that I can stop tracking a node I no +longer operate.* +- **Given** a node's detail view, **When** I click "Remove masternode", **Then** a confirmation dialog with a + specific verb label appears. +- **Given** the confirmation, **When** I confirm, **Then** the masternode and its associated voter identity are + forgotten and the card disappears from the list. + +**US-5 — Keep the everyday surface clean.** +*As an everyday user, I want my Identity Hub to show only my personal identities, so that I'm never offered +node-operator actions that don't apply to me.* +- **Given** loaded masternode/evonode identities, **When** I open the Identity Hub or Identities picker, **Then** + those identities do **not** appear there. +- **Given** the same, **When** I open the Masternodes tab, **Then** they **do** appear there. + +**US-6 — RETIRED (auto-derive does not apply to masternode keys).** +Investigated 2026-07-09: `derive_keys_from_wallets` is hard-gated to `IdentityType::User` in +`backend_task/identity/load_identity.rs` — masternode voting/owner/payout keys are Core-side keys tied to the +node's ProRegTx, not part of any wallet's identity-auth HD tree, so none of the three can be auto-derived. This +story and its "Try to derive from loaded wallet" checkbox do NOT carry over to the load form (FR-4); keys are +always pasted manually there. The wallet pill's real purpose on this page is unrelated — it is the **funding +source for Top up** (FR-9), reflected in US-7's acceptance criteria. + +--- + +**US-7 — Switch wallet/identity from anywhere (silent + two-way, blast-radius-limited).** +*As any user, I want the same wallet/identity switcher on every page, so that I can see and change who I'm acting +as without leaving the current page.* +- **Given** I am on the Masternodes tab (or any root tab), **When** I look at the top panel, **Then** I see the + page-aware switcher `Masternodes › 💼 wallet › 👤 identity` rendered with the Identity Hub's styling. +- **Given** the switcher on the Masternodes tab, **When** I switch wallets from the wallet pill, **Then** the + app-global wallet context updates **in place (no navigation)**, and it becomes the funding source the next + time I use **Top up** on a node (FR-9) — two-way: changing the source wallet from a Top-up flow also updates + the pill. (NOT a key-derivation source — see §9 auto-derive correction; US-6 retired.) +- **Given** the Masternodes tab, **When** I open the **masternode pill** dropdown, **Then** it lists my loaded + masternode/evonode identities, and choosing one opens that node — two-way bound with the card grid and detail + view (opening a card updates the pill; picking from the pill opens the node). +- **Given** I pick a masternode in the Masternodes switcher, **When** I later open an everyday-user page + (Dashpay / Identities / Identity Hub), **Then** the identity pill there shows my app-global **User** identity — + the masternode never appears there (page-scoped selection; FR-6 boundary holds). +- **Given** a page that does not consume a given selection, **When** I hover its pill, **Then** it is dimmed with + no caret and a **tooltip tells me how to change that selection** — there is no visible "read-only" text tag. +- **Given** a page with no identity/object context (e.g. a Wallet page), **When** I look at the switcher, **Then** + it shows only the wallet pill (per-page composition), not a third pill. + +**US-8 — Encrypt my node keys at load time.** +*As a masternode operator, I want to set an optional password when I load my node, so that its private keys are +encrypted at rest immediately instead of only after a separate step.* +- **Given** the load form, **When** I leave the encryption password blank, **Then** the node loads with keys + unprotected (Tier-1) and I can protect them later from the Key Info screen / the detail view. +- **Given** the load form, **When** I enter an encryption password and load the node, **Then** the entered + voting/owner/payout keys are sealed encrypted-at-rest (Tier-2) at load time. +- **Given** the password field, **When** I toggle the show/hide eye, **Then** I can reveal the password only + while pressed (per the password-input pattern), and it is never logged or persisted in plaintext. +- **Given** a node loaded with a password, **When** I view its detail, **Then** its keys read + "password-protected" and the "Add password protection…" action is not offered (already protected). + +**US-9 — Move a node's credits.** +*As a masternode operator, I want to withdraw, top up, and transfer a node's Platform credits from its detail +view, so that I can manage its balance without leaving the Masternodes page.* +- **Given** a node's detail view (Masternode or Evonode), **When** I open the actions row, **Then** I can choose + Withdraw, Top up, or Transfer, each opening the existing screen scoped to this node. +- **Given** a withdraw with the **owner** key, **When** I set it up, **Then** the destination is forced to the + node's registered Core payout address; **Given** the transfer/payout key, **Then** I may choose a free address. + +**US-10 — Manage a node's keys.** +*As a masternode operator, I want to open the key screen for a node, so that I can view a private key/WIF, sign a +message, or add/remove a key.* +- **Given** the Keys section, **When** I click "Manage keys ›", **Then** the existing `KeyInfoScreen` opens for + this node. +- **Given** the add-key purpose selector, **When** I pick a purpose, **Then** OWNER and VOTING are not + offered (Core-registered roles), while TRANSFER / AUTH / ENCRYPTION / DECRYPTION are. + +**US-11 — Claim an evonode's token rewards.** +*As an evonode operator, I want to jump to token-reward claiming from the node's detail view, so that I can +collect rewards my evonode earned.* +- **Given** an **Evonode** detail view, **When** I look at the actions, **Then** "Claim token rewards ›" is + shown and routes to the existing `ClaimTokensScreen` for this identity. +- **Given** a plain **Masternode** detail view, **When** I look at the actions, **Then** the token-rewards + action is **not** shown. + +## 7. Proposed Copy (i18n-ready) + +- Tab label: `Masternodes` +- Empty-state heading: `No masternodes loaded` +- Empty-state body: `Load a masternode or evonode to vote on DPNS name contests and manage its owner and payout keys.` +- Empty-state primary button: `Load a masternode` +- Empty-state reassurance line *(canonical — resolves a wording drift found in test-spec review 2026-07-09; + `wireframes.html`'s wording wins as the human-approved visual mock)*: `Have your node's ProTxHash to hand. + Keys are optional — a node loads read-only without them.` +- Load form title: `Load a masternode` +- Load form subtitle: `Load a masternode or evonode that already exists on the Dash network.` +- ProTxHash label: `ProTxHash` · hint: `Enter the node's ProTxHash. You can find it in your masternode configuration.` +- Node type toggle: `Masternode` / `Evonode` +- Alias label: `Alias (optional)` · hint: `An alias helps you recognize this node inside Dash Evo Tool. It is not saved to the Dash network.` +- Key labels: `Voting private key`, `Owner private key`, `Payout address private key` · placeholder: `Private key (WIF or hex)` +- ~~Auto-derive toggle~~ / ~~No-wallet hint~~ — **removed** (leftover from a superseded pass; found and purged in + test-spec review 2026-07-09). Auto-derive does not apply to these key roles — see §9 correction. There is no + wallet-dependent copy on the load form; the wallet pill's job is unrelated (funds Top up, FR-9). +- Fill Random button *(FR-12, Testnet-only, rendered only when the fixture is present)*: `🎲 Fill Random + Masternode` / `🎲 Fill Random Evonode` (label follows the Node-type toggle) · hint: `Testnet-only dev + convenience — visible only when a local test-node fixture is found.` +- ProTxHash format error *(new, resolves test-spec gap #5, 2026-07-09; inline, on-blur, per project error-copy + rules — what happened + what to do)*: `This doesn't look like a valid ProTxHash. Enter a hex or Base58 + ProTxHash from your masternode configuration.` +- Duplicate-node error *(new, resolves test-spec gap #5; surfaced at submit, MessageBanner Error)*: `This + masternode is already loaded. Open it from the list instead of loading it again.` (Base58/hex ProTxHash or + alias included per the project's Base58-IDs-are-allowed rule, e.g. "…already loaded as `mn-east-01`.") +- Encryption password label: `Encryption password (optional)` · placeholder: `Password to encrypt these keys` · helper: `Set a password to encrypt these keys on this device. Leave it blank to store them unencrypted and add protection later.` +- Key-storage note (Warning tone, actionable): `Set an optional password to encrypt these keys on this device. Without one, they are stored unencrypted and you can add protection later from the key screen.` +- Detail protection-tier labels: `Keys: unprotected` / `Keys: password-protected` +- Add-protection action: `Add password protection…` +- Load button: `Load masternode` · disabled tooltip: `Enter a ProTxHash to continue.` +- Card voter-ready: `Voting ready` / card voter-absent: `No voting key` +- Card voting status examples: `{count} contests to vote on` · `Vote scheduled` · `No open contests` +- Detail remove button: `Remove masternode` · confirm dialog verb: `Remove masternode` +- Voting section empty: `There are no open name contests for this node to vote on right now.` +- Missing voter identity at vote time: `This node has no voting key loaded. Add its voting private key to cast votes.` + +--- + +## 8. Prioritized Backlog (MoSCoW) + +**Must** +- FR-1 Masternodes root tab · FR-2 empty state · FR-3 card list · FR-4 load flow · FR-6 Hub/Identities filter. +- NFR-2 (no model changes), NFR-3 (tokens), NFR-4 (plaintext note), NFR-6 (a11y). + +**Should** +- FR-5 detail/voting view (surfacing existing DPNS voting) · FR-7 refresh. +- ~~US-6 auto-derive parity~~ — **retired**, does not apply to masternode keys (see §9 correction). +- **FR-8 optional load-time encryption password** (needs new plumbing through `IdentityInputToLoad` → + `load_identity` → the existing `store_protected` seal path — implementation scope for Nagatha's plan). +- **FR-9 credit actions** (Withdraw / Top up / Transfer) · **FR-10 Manage-keys drill-in** (`KeyInfoScreen`) · + **FR-11 Evonode-only token-rewards cross-link** (`ClaimTokensScreen`) — all **reuse existing screens** scoped to + the selected node; only new entry points, no new operation UI. +- **FR-12 "Fill Random Masternode/Evonode" dev convenience** (Testnet-only, reuses existing + `fill_random_masternode()`/`fill_random_hpmn()` + `.testnet_nodes.yml` fixture; visible only when the + fixture is present — not shown-but-disabled; the page-level Expert Mode gate from FR-1 covers the rest). + +**Could** +- ~~Per-key "auto-derived vs pasted" provenance indicator~~ — **moot**: no key role on this page is ever + auto-derived (see §9 correction); all three are always pasted. +- Sort/filter of the card grid (by type, voter readiness, open-contest count) — aligns with Priya's + "asset lock table is too compact / no sort or filter" pain point. + +**Should** +- Surface the protection tier + **"Add password protection…"** action on the detail view (reuses the existing + `IdentityTask::ProtectIdentityKeys` — no new crypto). This is the recourse the load-form note points to (NFR-4). + +**Won't (this iteration)** +- Designing/building any **new** key-encryption mechanism — FR-8 reuses the existing Tier-2 envelope + (`store_protected` / `put_secret_protected`); it only threads a password through to it. +- Making the load-time password mandatory — it is strictly optional; blank preserves today's Tier-1 behaviour. +- **Register DPNS name for MN/Evonode — DROPPED (out of scope).** In v0.10-dev this was gated to + `identity_type = 'User'` (`database/identities.rs:344`); for MN/Evonode the button was a **silent no-op**, so + it is not real parity. Adding functional DPNS-name registration for provider identities would be a **new + feature**, not preservation — excluded here. +- Building any new operation screen — FR-9/10/11 **reuse** `withdraw_screen` / `top_up_identity_screen` / + `transfer_screen` / `KeyInfoScreen` / `ClaimTokensScreen` scoped to the node; only entry points are new. +- Registering *new* masternode identities (this page is load/manage of existing on-chain nodes). +- Full in-page DPNS contest browser — the dedicated DPNS root screens remain the canonical voting surface; + the detail view surfaces/hands off, it does not duplicate. + +--- + +## 9. Open Questions & Assumptions + +**Resolved (see §"Locked decisions"):** the four original open questions (nav placement, voting depth, filter +scope, auto-derive scope) are all locked as of 2026-07-09. **Auto-derive scope was subsequently superseded** +by a post-acceptance investigation — see decision 4's strikethrough entry in "Locked decisions" for the +correction (the load form has no auto-derive affordance at all; US-6 retired). + +**Global-nav questions — all RESOLVED:** +1. **Segment-1 label** → **page-aware** (`Masternodes › 💼 wallet › 👤 identity`); segment-1 reflects the active + tab and links to its root. (Confirmed; FR-GLOBAL-NAV-6.) +2. **Interaction on non-identity pages** → **silent context change + two-way binding, NOT route-to-Home.** + Selecting an object in the nav silently updates the app-global selection with no forced navigation; where the + page consumes that object the nav and page stay in sync both ways. On unwired pages the pill is read-only with + a `TODO`. (Confirmed; supersedes the earlier "route to Identity Home" framing — see FR-GLOBAL-NAV-2.) +3. **Third-pill scope** → **page-scoped, page-aware object.** The third pill is the identity/object the current + page operates on: the app-global **User** identity on everyday-user pages; the **page-scoped masternode/evonode + in view** on the Masternodes page (interactive, two-way bound with the card grid + detail). This supersedes the + earlier "Option (a) / read-only on Masternodes" resolution. It keeps FR-6 intact because the masternode + selection is a *separate scope* from the app-global user-identity selection and never leaks into the + everyday-user picker. Rationale in `02-ux-spec.md` §"Global nav — design question". + +**Assumptions (documented):** +- No model or backend-task changes are needed; the page is a filtered view + card layout + relabelled load form. +- ProTxHash display uses the existing `shorten_id` helper and hex encoding for MN/Evonode. +- The plaintext-at-rest note is awareness-only and does not block the flow (per brief + NFR-4). +- Masternode/Evonode badge colours reuse the existing `draw_type_badge` mapping (purple / blue). + +--- + +## 10. Requirements Quality Checklist + +- [x] Primary actor (Priya) has stories addressing her primary goal (load + vote). +- [x] Every user story has testable Given/When/Then acceptance criteria. +- [x] ≥3 real-life scenarios covered across US-1…US-11 (load, glance, vote, remove, clean-surface, global-nav + switching, encrypt-at-load, credit actions, key-mgmt drill-in, evonode token rewards). US-6 retired. +- [x] Edge/failure modes addressed: empty state, missing voter identity, no wallet loaded, disabled load button. +- [x] Priorities justified (Must = core operator flow + surface hygiene; Won't = out-of-scope crypto/registration). +- [x] No requirement without traceable justification (audit + personas + model). +- [x] Assumptions explicit; success metric tied to persona (≤10s to key paths). + +--- + +## Locked decisions (accepted 2026-07-09) + +The human reviewed the wireframes and confirmed all four open questions. Every answer matches the +wireframe as-drawn — no visual changes required. These decisions are now binding for implementation: + +1. **Voting depth = INLINE.** FR-5 / the masternode detail view casts votes **directly in-page** via the + DPNS-contest voting table + **Cast votes** button (wireframe D). It is **not** a deep-link to the DPNS + Active Contests root screen. The detail view surfaces the existing DPNS voting backend inline. +2. **Filter scope = HUB PICKER ONLY.** FR-6 filters masternode/evonode identities out of the **Identity Hub + picker only**. The legacy Identities table (`src/ui/identities/identities_screen.rs`) **keeps** showing + MN/Evonode identities for now. Stripping them from the legacy Identities table is an **explicit deferred + follow-up PR — out of scope for this iteration, and not to be treated as a regression** (supersedes + Open Question §9.3 and the "Won't" note). +3. **Nav placement = BELOW IDENTITY HUB.** Left-nav order: Dashpay / Identities / Identity Hub / **Masternodes** + / Contracts / Dash. Use a distinct node/server glyph (not the person glyph used by Identities). Resolves + Open Question §9.1. +4. ~~**Auto-derive = all three key roles**~~ — **SUPERSEDED (2026-07-09, post-acceptance investigation).** + This decision assumed "matching current behaviour" without verifying that behaviour existed for masternode + keys. It does not: `derive_keys_from_wallets` is hard-gated to `IdentityType::User`; masternode voting/ + owner/payout keys are Core-side keys tied to the ProRegTx, never part of a wallet's HD tree, so none of the + three is derivable today, for any identity type on this page. **Corrected decision: the load form has no + auto-derive affordance; all three keys are always pasted manually (FR-4).** The wallet pill remains + interactive on this page for an unrelated, verified reason — it is the funding source for **Top up** + (FR-9). US-6 is retired, not confirmed. See §4c in 02-ux-spec.md and §9 below for the full correction. + +--- + +🍬 **Findings tally** — surfaced during requirements analysis: **3** (Info severity): +(1) MN/Evonode identities leak into the everyday-user Identity Hub picker → FR-6 filter; +(2) load path is buried behind *Show Advanced Options* → FR-1/FR-4 extraction; +(3) MN/Evonode keys load unprotected (Tier-1) with no recourse shown to the user → NFR-4 actionable awareness note ++ surface the existing Tier-2 "Add password protection…" (`IdentityTask::ProtectIdentityKeys`) on the detail view. + +--- + +## 10. Resolved gaps (test-spec review, 2026-07-09) + +Marvin's Phase 1c test case specification (`03-test-case-spec.md`) surfaced 12 requirement gaps while writing +test cases against this document. Two were copy inconsistencies, fixed directly in §7 and in `02-ux-spec.md` +(empty-state reassurance line canonicalized; the retired auto-derive/no-wallet copy purged from §7). The +remaining decisions, made here so Nagatha's plan and Marvin's test cases build on settled ground rather than +open questions: + +1. **DPNS card status-line precedence (FR-3).** Three possible strings can apply simultaneously + (`{count} contests to vote on`, `Vote scheduled`, `No open contests`). **Precedence: open-contest count + first (it's actionable), then scheduled, then none** — i.e. show `{count} contests to vote on` whenever + `count > 0`, regardless of a pending scheduled vote; only show `Vote scheduled` when `count == 0` and a + vote is pending; otherwise `No open contests`. **"Vote scheduled" is not a new concept** — reuse the same + pending/scheduled-vote state the existing DPNS Scheduled Votes root screen already tracks (no new backend + state; a display-layer read of existing data). +2. **Legacy buried Masternode/Evonode arm (`add_existing_identity_screen.rs`'s Identity Type dropdown under + Show Advanced Options) — REMOVE, don't leave dangling.** Once FR-4 ships its own dedicated load flow, the + old arm's Masternode/Evonode options are removed from that dropdown (User remains). This prevents two + competing entry points for the same action, and matches the "carve masternode handling out of the generic + identity flow" framing this whole design started from. Implementation scope for Nagatha's plan; extends FR-6. +3. **FR-8 password-strength rule.** No new policy is invented. The optional load-time password reuses the + *same* validation (if any) the existing Key Info screen's "Add password protection…" flow already applies, + since FR-8 routes through the identical `store_protected`/`put_secret_protected` seal path (§ FR-8). Nagatha + confirms the existing rule at implementation time rather than this design inventing a new one. +4. **Masternode-pill state when navigating detail → list.** Already implicit in the wireframes, now stated + explicitly: the pill reflects **the current screen's context**, not "last node opened." On the card-list / + empty screens (A, B) it shows the placeholder `Choose a masternode ▾`; only the detail view (D) shows a + specific node. Navigating from D back to the list (via `‹ All masternodes` or the pill's own dropdown) + resets it to the placeholder — matches wireframe B exactly. + +5. **No-wallet-loaded behavior when attempting Top up (FR-9).** Not a new copy/state to invent: Top up is an + **existing reused screen** (`top_up_identity_screen`, per FR-9's reuse note) — whatever it already does + today when no wallet is loaded (block, prompt, or otherwise) is what happens here too, unchanged. This + design adds an entry point to that screen; it does not redefine its no-wallet behavior. +6. **Node-type toggle after Fill-Random autofill (FR-12).** Switching Masternode ↔ Evonode after using + "Fill Random…" **clears** ProTxHash, Alias, and all key fields. A real node's identity is tied to one type + — autofilled (or manually entered) data for one type is never valid for the other, so silently keeping it + would be actively misleading, not a convenience. +7. **Past/scheduled votes on the detail view (FR-5) — out of scope, by design, not a gap.** The collapsible + DPNS section covers **active, open contests only**, exactly as wireframe D draws it. Scheduled/past-vote + history is not duplicated here — it already has a home in the existing DPNS Scheduled Votes root screen. + Keeps the page focused; consistent with "reuse existing screens" rather than re-showing the same data twice. +8. **"Add voting key" (US-3, missing-voter-identity affordance) is a targeted action, not a re-run of FR-4's + load form.** It opens a small, scoped key-input prompt that adds/updates the voter identity on the + **already-loaded** node in place. It is a different flow from FR-4's load form and is therefore exempt from + the duplicate-ProTxHash rejection below (that rejection guards *new* loads, not fixing up an existing one). +9. **Duplicate-ProTxHash load (FR-4) — reject, don't merge or duplicate.** Submitting a ProTxHash that's + already loaded shows the duplicate-node error (§7 copy, added above) and does not create a second card or + silently update the existing one. **Malformed-ProTxHash (FR-4)** — validated inline/on-blur (client-side + shape check, hex or Base58), not only gated on emptiness; error copy added to §7 above. +10. **Network switch mid-sub-screen (load form or detail).** Matches TC-EDGE-05's existing rule ("card list + scoped per active network"): switching network while on the load form or a node's detail view returns to + the Masternodes **list** for the new network, rather than leaving a stale sub-screen referencing an + identity that may not exist there. Consistent with root screens surviving network switches while + identity-scoped sub-screens do not carry a now-foreign identity forward. +11. **Live de-gating fallback (FR-1).** If Expert Mode is turned off while the Masternodes tab is the active + screen (no existing DET precedent found for a dev-gated *root tab* specifically — checked `app.rs` and + found none to reuse), the app falls back to the **Identities** root screen — the nearest neutral, + always-available screen, rather than leaving the user stranded on a tab that just disappeared from the nav. + +No open items remain from Marvin's gap list that require a return to Phase 1a/1b (UX Design) — all eleven are +implementation-detail-level and are resolved above without changing any wireframe screen. diff --git a/docs/ai-design/2026-07-09-masternode-page-design/02-ux-spec.md b/docs/ai-design/2026-07-09-masternode-page-design/02-ux-spec.md new file mode 100644 index 000000000..21d218def --- /dev/null +++ b/docs/ai-design/2026-07-09-masternode-page-design/02-ux-spec.md @@ -0,0 +1,430 @@ +# Masternodes Page — UX Specification + +**Repo:** `dash-evo-tool` · **Date:** 2026-07-09 · **Author:** Diziet +Companion: `01-requirements.md`, `wireframes.html`. Design tokens: `src/ui/theme.rs`, `docs/ux-design-patterns.md`. + +All wireframes are annotated with the **existing component / token** they reuse. Nothing here invents a new +widget where an existing one serves. + +--- + +## 1. Information Architecture + +``` +Left nav (root screens) +├── Dashpay +├── Identities +├── Identity Hub +├── Masternodes ◀── NEW root tab (this design) +├── Contracts +└── … (Dash / network) +``` + +The Masternodes tab is a **sibling root screen** (persists in `AppState.main_screens`, survives network +switch). **Corrected 2026-07-09 (supersedes the original NFR-7):** it IS gated behind **Expert Mode** +(`is_developer_mode()`, user-facing label "Expert mode") — nav item and route both absent when Expert Mode +is off. Node-operator work is a distinct audience (Priya), and Expert Mode is DET's existing mechanism for +separating that audience from Alex (Everyday User) — see FR-1. + +Internal screen stack within Masternodes: +``` +Masternodes (list / empty state) ──"Load a masternode"──▶ Load form ──success──▶ back to list + │ + └──click a card──▶ Masternode detail / voting ──"Remove"──▶ confirm ──▶ back to list +``` +Navigation uses the standard breadcrumb header + `PushScreen`/`PopScreen` for the load form and detail view. + +--- + +## 2. User Journeys + +### 2.1 First-time (empty → load a node) +Persona: Priya. Entry: clicks **Masternodes** in the left nav for the first time. +1. Sees the **empty state** card (§ wireframe A): what a masternode identity is for + a primary + **Load a masternode** button. +2. Clicks it → **Load form** (wireframe C): enters ProTxHash, picks Masternode/Evonode, pastes + Voting/Owner/Payout keys (manual paste only — see §4c, auto-derive does not apply to these key roles). + Reads the plaintext-at-rest note. +3. Clicks **Load masternode** → returns to the list, now showing **one card** (wireframe B). +Success state: a card representing her node, with voter readiness + key status visible. +Failure paths: empty ProTxHash → button disabled with tooltip; load error → `MessageBanner` (Error) with a +user-friendly message and technical detail attached (never a raw error string). + +### 2.2 Returning (list → open → vote) +Persona: Priya, on a later session with nodes already loaded. +1. Opens **Masternodes** → **card list** (wireframe B). Scans type badges, voter readiness, and + "N contests to vote on". +2. Clicks a card → **detail/voting view** (wireframe D). Reviews keys summary + voter identity. +3. In the DPNS voting section, for each open contested name chooses **Abstain / Lock / a candidate**; vote is + dispatched via the existing DPNS voting backend. +Success state: vote recorded (Success `MessageBanner`, auto-dismiss). +Edge case: node has no voter identity → voting section shows an actionable message ("Add its voting private +key to cast votes"), not a raw `NoVotingIdentity` error. + +### 2.3 Housekeeping (remove a node) +1. Detail view → **Remove masternode** (danger button) → confirmation dialog (specific verb, Cancel left / + Remove right, Escape cancels). +2. Confirm → node **and its associated voter identity** are forgotten; back to the list. + +--- + +## 3. Interaction Patterns + +- **Cards** — reuse `IdentityPickerCard` visual language (`src/ui/identity/identity_picker_card.rs`): rounded + `surface` card (`RADIUS_LG`=16), monogram, type-badge pill, single click target with hover elevation and + `WidgetInfo::labeled` a11y. Extend content to masternode-specific rows (voter readiness / key status / + voting status) — same frame, different body. +- **Type badge** — reuse `draw_type_badge`: Masternode → `PLATFORM_PURPLE`, Evonode → `DASH_BLUE`, white text. +- **Node-type toggle** (load form) — segmented control using `unselected_fill(dark_mode)` for the inactive + segment and `DASH_BLUE` for the active, matching the existing "Identity ID & private key / From my wallet / + My username" tab styling in `04-load-masternode-keys.png`. +- **Key inputs** — reuse the existing private-key input widget (WIF-or-hex placeholder, reveal control) already + used by the advanced arm. Password-input reveal rules per `docs/ux-design-patterns.md` §5. +- **Buttons** — `ComponentStyles`: `add_primary_button` (Dash-blue) for Load; `add_secondary_button` (outline) + for Cancel; `add_danger_button` for Remove. `add_primary_button_enabled(false, …)` + `disabled_tooltip` for + the disabled Load state. Top-right **Refresh** uses `add_toolbar_button` on the network accent (as in + `01-dashpay.png`). +- **Collapsing sections** — the DPNS voting section on the detail view is an egui **collapsing header**, + **collapsed by default**, with the open contest **count in the header** (`▸ DPNS name contests to vote on (3)`) + so operators still see there's something to act on without expanding. Expanding (`▾`) reveals the voting table + + Cast-votes button. Use the standard egui `CollapsingHeader` pattern. +- **Status** — identity status dot uses the `IdentityStatus → Color32` mapping (green/gray/orange/red) always + paired with a text label (never colour-only — NFR-6). +- **Global-nav pills** — the existing `BreadcrumbPillMode` already provides the exact three renderings this model + needs: `Interactive` (caret + dropdown, for a consumed selection), `Subdued` (dimmed, no caret + hover tooltip — + the disabled fallback for unwired pills; no visible text tag), `Placeholder` (no value yet, e.g. the empty-state + masternode pill). On Masternodes **both** the wallet pill and the masternode pill are `Interactive` and two-way + bound. A page declares which selections it consumes; consumed pills bind two-way, others render `Subdued` with a + how-to-change tooltip. +- **Messages** — `MessageBanner::set_global`; errors persistent + `.with_details(e)`, success auto-dismiss. +- **Confirmation** — `ConfirmationDialog` with `danger_mode(true)` for Remove. + +--- + +## 4. Navigation / Breadcrumb — global wallet/identity switcher + +**Change (FR-GLOBAL-NAV):** the top island's left region now hosts the **global wallet/identity switcher** on +every root page, not just the Identity Hub. It is the exact three-segment breadcrumb switcher from IDH-003 +(`breadcrumb_switcher.rs`) — `Identities › 💼 wallet › 👤 identity` — rendered via +`top_panel::add_top_panel_with_breadcrumb`. The connection dot sits to its left; Refresh/action buttons stay +top-right. The switcher looks and behaves identically to the Identity Hub (`05-identity-hub-landing.png`). + +- **Which page am I on?** Conveyed by the **left-nav rail highlight** (Masternodes active), not by the header. +- **Sub-screen navigation** (load form, detail) uses a **content-panel back row** (`‹ All masternodes`), keeping + the global switcher single-line and unchanged across navigation (design-spec §A.3). +- The status/connection dot reflects network colour (orange on Testnet, as in the reference screenshots) and + reuses `top_panel::add_connection_indicator`. + +Header on every Masternodes screen: `[●dot] Masternodes › 💼 Main Wallet ▾ › Ⓜ mn-east-01 ▾` — **page-aware** +leftmost crumb (confirmed Q1). On Masternodes **both pills are interactive** (caret ▾): the **wallet pill** +(funds Top up on this node, two-way bound — see §4c, NOT a key-derivation source) **and** the **masternode pill** +(the node in view — its dropdown lists loaded masternodes/evonodes, two-way bound with the card grid + detail +view; see §4b). The third pill is **page-scoped**: it is the masternode-in-view here, and the app-global User +identity on everyday-user pages. + +## 4b. Global nav — design question & resolution + +**Question (from the coordinator):** on non-identity pages (Masternodes, Contracts), what does the wallet/identity +selector scope to? +- **(a)** It always reflects the same app-global wallet + (User) identity context, independent of page. +- **(b)** On the Masternodes page the identity pill instead reflects the selected **masternode/evonode** identity + (page-aware), since masternode identities are `IdentityType != User`. + +**Resolution → the third pill is a page-scoped, page-aware object** (updated; supersedes the earlier +"Option (a) / read-only on Masternodes"). The third segment shows the identity/object the **current page** +operates on: +- **Everyday-user pages** (Dashpay / Identities / Identity Hub): the app-global **User** identity. +- **Masternodes page**: the **masternode/evonode in view** (`Ⓜ mn-east-01 ▾`), interactive and two-way bound with + the card grid and the detail view. Its dropdown lists the loaded masternodes/evonodes. + +**Why this does NOT violate FR-6 (the boundary).** FR-6 governs the **everyday-user Identity Hub / Identities +picker** — it must not list MN/Evonode identities. The masternode-in-view here is a **separate, page-scoped +selection**, distinct from the app-global User-identity selection. Choosing a masternode in the Masternodes +switcher does not touch the app-global user-identity selection, so it **never** appears in the identity pill on +user pages nor in the Hub picker. Two selections, two scopes — the Masternodes page's own switcher is simply not +the everyday-user picker FR-6 constrains. (The masternode pill's dropdown is also *not* the Hub picker; it is the +operator surface, which is exactly where MN/Evonode identities belong.) + +**Two-way binding on Masternodes.** The masternode pill mirrors the page's own selection: opening a card sets the +pill; picking a node from the pill opens that node's detail. The detail view additionally carries a +`‹ All masternodes` content-panel back row to return to the grid. + +### Authoritative selection interaction model (resolved) + +The global nav follows four rules on every page (full text in requirements FR-GLOBAL-NAV-2): + +1. **Silent context change** — selecting an object updates the app-global selection; **no forced navigation**. +2. **Two-way binding where the page consumes the selection** — nav pill and page stay in sync both ways. +3. **Blast-radius control** — a pill is interactive only on pages already wired to consume it; elsewhere it is + **dimmed, no caret, no visible tag**, with a **hover tooltip telling the user how to change that selection** + (+ a `TODO` in code). Interactivity rolls out page-by-page. +4. **Per-page composition** — show only the pills that make sense (a Wallet page shows only the wallet pill). + +**On Masternodes:** **both pills are interactive** — wallet pill = two-way bound (funds Top up on this node, §4c); +masternode pill = the node in view, two-way bound with the card grid + detail (its dropdown lists loaded +masternodes/evonodes). The third pill is page-scoped, so it never leaks a masternode into the user pages (FR-6). + +**Two-way-binding example to carry into implementation (Send/Transfer-from-wallet):** changing the wallet in the +top-nav changes the page's *source wallet*; changing the *source wallet* on the page updates the top-nav wallet +pill. This is the canonical shape for any page that consumes a selection — on Masternodes the source wallet feeds +**Top up** (FR-9), not key derivation (§4c). + +**Confirmed Q1:** leftmost crumb is **page-aware** (`Masternodes › …`), linking to the active tab's root. + +## 4c. Auto-derive finding — corrected (investigation, 2026-07-09) + +**Auto-deriving Voting/Owner/Payout private keys from a loaded wallet does NOT work and was never wired for +Masternode/Evonode identities.** Verified against code: `backend_task/identity/load_identity.rs` gates +`derive_keys_from_wallets` to `IdentityType::User` only; for Masternode/Evonode the three key fields are always +manual paste, verified (not discovered) against the identity's on-chain public keys. This is architectural, not a +missing feature: masternode owner/voting/payout keys are Core-side keys tied to the node's ProRegTx, not part of +any wallet's identity-auth HD derivation tree. Consequence: +- **Load form (wireframe C):** the "Try to derive these keys from a loaded wallet" checkbox is **removed** — it + would be misleading UI chrome (a no-op) on a page that is masternode-only (no User option here). +- **Wallet pill rationale corrected:** the wallet pill on the Masternodes page is NOT a key-derivation source. + Its real, verified purpose is as the **funding source for Top up** (FR-9) — Top up moves DASH from the active + wallet to the node's identity balance, a genuine use of "active wallet" context. The pill stays interactive and + two-way bound for that reason. +- Superseded: **US-6 ("auto-derive parity")** is retired — see 01-requirements.md backlog note. + +--- + +## 5. Accessibility (WCAG 2.1 AA) + +- Card is one labelled click target (`WidgetInfo::labeled(Button, …, "Open {node}")`); Enter activates. +- Focus order: header actions → cards (reading order) / form fields top-to-bottom → primary action last. +- Focus indicator: `BORDER_WIDTH_THICK`, ≥3:1 contrast (theme default). +- No colour-only status: every status dot and badge carries a text label. +- Disabled Load button uses `disabled_tooltip` (NotAllowed cursor) explaining the blocker. +- Contrast: Dash-blue `#008de4` on white for primary buttons; secondary text `#64788c` meets AA on white. +- Known constraint (documented): egui offers no screen-reader annotations beyond `WidgetInfo`. + +--- + +## 6. Responsive Behavior + +- Card grid: `minmax(260px, 1fr)` columns (matches `CARD_MIN_WIDTH`=260), wrapping to 1 column on narrow + widths via `ui.available_width()`; `ScrollArea` for overflow. Empty-state and forms sit inside + `island_central_panel()` responsive margins. +- Load form: single-column, labels above inputs on narrow widths. + +--- + +## 7. ASCII Wireframes + +> **Erratum (PROJ-013, 2026-07-09):** `wireframes.html` still draws the legacy **two** Fill-Random buttons +> ("Fill Random HPMN" / "Fill Random Masternode") and omits the missing-voter "Add voting key" affordance. +> FR-12/§7 (one button, label follows the Node-type toggle) and wireframe D below are canonical; the HTML mock is +> stale for these two details only. + +Legend: `[●]` status dot · `[ Button ]` primary · `( Button )` secondary/outline · `‹ Button ›` danger · +`{MN}`/`{EVO}` type badge pill. + +### (A) Masternodes tab — empty state +Reuses: **global switcher** header (`breadcrumb_switcher.rs` via `add_top_panel_with_breadcrumb`, styled per +`05-identity-hub-landing.png`), empty-state card pattern (`03-identities-empty.png`), `add_primary_button`, +`island_central_panel`. Nav rail highlights **Masternodes**. + +``` +┌───────────────────────────────────────────────────────────────────────────────┐ +│ [●] Masternodes › 💼 Main Wallet ▾ › Ⓜ (no masternode yet) [ Refresh ] │ ← both pills interactive; MN pill is a placeholder (none loaded yet) +├───────────┬───────────────────────────────────────────────────────────────────┤ +│ ▽ Dash │ │ +│ ⦿ Ident. │ ┌───────────────────────────────────────────────────┐ │ +│ ⦿ IdHub │ │ No masternodes loaded │ │ ← empty-state card (surface, RADIUS_LG) +│ ▶Masterno.│ ├───────────────────────────────────────────────────┤ │ +│ ⦿ Contr. │ │ Load a masternode or evonode to vote on DPNS name │ │ +│ ~Dash~ │ │ contests and manage its owner and payout keys. │ │ +│ │ │ │ │ +│ │ │ [ Load a masternode ] │ │ ← primary (DASH_BLUE) +│ │ │ │ │ +│ │ │ Have your node's ProTxHash to hand. Keys are │ │ ← canonical wording, §7 +│ │ │ optional — a node loads read-only without them. │ │ +│ │ └───────────────────────────────────────────────────┘ │ +│ │ │ +└───────────┴───────────────────────────────────────────────────────────────────┘ +``` +Note: **both pills are interactive** on Masternodes. The **wallet pill** (▾) opens the wallet dropdown (two-way +bound — funds Top up on this node, §4c; not a key-derivation source). The **masternode pill** (▾) is the +page-scoped node-in-view selector; here it is a +placeholder because none is loaded yet. This third pill is page-scoped — it shows the app-global User identity on +everyday-user pages, never a masternode there (§4b, FR-6 boundary). + +### (B) Masternodes tab — card list (2–3 cards) +Reuses: `IdentityPickerCard` frame + `draw_type_badge` + monogram + hover elevation; `IdentityStatus` colour +dot; responsive `minmax(260,1fr)` grid. + +``` +┌───────────────────────────────────────────────────────────────────────────────┐ +│ [●] Masternodes › 💼 Main Wallet ▾ › Ⓜ Choose a masternode ▾ [ + Load ][⟳] │ ← both pills interactive; MN pill dropdown mirrors the cards below +├───────────┬───────────────────────────────────────────────────────────────────┤ +│ ▽ Dash │ ┌────────────────────────┐ ┌────────────────────────┐ │ +│ ⦿ Ident. │ │ (M) {MN} │ │ (E) {EVO} │ │ ← monogram + type badge +│ ⦿ IdHub │ │ mn-east-01 │ │ 6f2a…c19b │ │ ← alias (or shortened ProTxHash) +│ ▶Masterno.│ │ 9a3f…d7e2 ·ProTxHash │ │ Evonode │ │ ← sub-line +│ ⦿ Contr. │ │ │ │ │ │ +│ ~Dash~ │ │ ● Voting ready │ │ ▲ No voting key │ │ ← voter readiness (green / warning) +│ │ │ Keys: V O P │ │ Keys: · O · │ │ ← key status (present emphasised) +│ │ │ 3 contests to vote on │ │ No open contests │ │ ← DPNS voting status +│ │ │ ● Active │ │ ● Unknown │ │ ← IdentityStatus dot + label +│ │ └────────────────────────┘ └────────────────────────┘ │ +│ │ ┌────────────────────────┐ │ +│ │ │ (M) {MN} │ │ +│ │ │ mn-west-02 │ │ +│ │ │ b71c…40aa ·ProTxHash │ │ +│ │ │ ● Voting ready │ │ +│ │ │ Keys: V O · │ │ +│ │ │ Vote scheduled │ │ +│ │ │ ● Active │ │ +│ │ └────────────────────────┘ │ +└───────────┴───────────────────────────────────────────────────────────────────┘ +``` + +### (C) Load a masternode form +Reuses: global switcher header; segmented toggle styled like existing tab row (`04-load-masternode-keys.png`); +existing private-key input widget; the password-input pattern (`wallet_unlock.rs` hold-to-reveal, see +`docs/ux-design-patterns.md` §5) for the optional encryption password; `add_primary_button_enabled` + +`disabled_tooltip`; Warning-tone inline note. **New (FR-8):** the optional encryption-password field needs new +plumbing (password threaded through `IdentityInputToLoad` → `load_identity` → existing `store_protected`). +**FR-12 (new, investigated 2026-07-09):** carries forward the "Fill Random Masternode" / "Fill Random HPMN" +dev convenience from `add_existing_identity_screen.rs:203-208` (`fill_random_masternode()` / `fill_random_hpmn()`, +lines 961-993) — picks a random REAL testnet node from a local `.testnet_nodes.yml` fixture and autofills +ProTxHash + keys; it does not fabricate a synthetic node. One button, labelled to match the Node-type toggle +above ("Fill Random Masternode" / "Fill Random Evonode"). **Fixture facts:** `.testnet_nodes.yml` is gitignored, +not tracked, and does not exist in this repo — it must be supplied locally and **does contain real private keys +in plaintext**. **Visibility is conditional, not disabled-state:** the button renders only when +`load_testnet_nodes_from_yml(...)` returns `Some(_)` on Testnet; otherwise it is absent entirely, no placeholder. +Gating is simpler than first assumed: the whole Masternodes tab now requires **Expert Mode** (FR-1), so this +button's own remaining condition is just Testnet + fixture-present — no separate `developer_mode` re-check +needed for normal navigation (Nagatha may still add one at the call-site as defense-in-depth; judgment call). + +``` +┌───────────────────────────────────────────────────────────────────────────────┐ +│ [●] Masternodes › 💼 Main Wallet ▾ › Ⓜ Choose a masternode ▾ │ ← both pills interactive; unchanged during sub-nav +├───────────┬───────────────────────────────────────────────────────────────────┤ +│ nav … │ ‹ All masternodes │ ← content-panel back row (§4b), not in header +│ │ Load a masternode │ +│ │ Load a masternode or evonode that already exists on the network. │ +│ │ │ +│ │ Node type: [ Masternode ] ( Evonode ) │ ← segmented toggle (active=DASH_BLUE) +│ │ │ +│ │ ( 🎲 Fill Random Masternode ) Testnet-only dev convenience — │ ← FR-12, testnet-only; carried over from +│ │ fills a real test node's ProTxHash and keys below. │ add_existing_identity_screen.rs +│ │ │ +│ │ ProTxHash: [___________________________________] (i) │ ← required +│ │ Alias (optional): [___________________________________] (i) │ +│ │ Voting private key: [ Private key (WIF or hex) ] 👁 ⊘ │ +│ │ Owner private key: [ Private key (WIF or hex) ] 👁 ⊘ │ +│ │ Payout addr. key: [ Private key (WIF or hex) ] 👁 ⊘ │ +│ │ Encryption password [ Password to encrypt these keys ] 👁 │ ← OPTIONAL (FR-8); reuses password-input pattern +│ │ (optional): Set a password to encrypt these keys on this │ ← helper line +│ │ device. Leave it blank to store them │ +│ │ unencrypted and add protection later. │ +│ │ │ +│ │ ⚠ Set an optional password to encrypt these keys on this device. │ ← Warning-tone, non-blocking, ACCURATE (FR-8/NFR-4) +│ │ Without one, they are stored unencrypted and you can add │ +│ │ protection later from the key screen. │ +│ │ │ +│ │ [ Load masternode ] ( Cancel ) │ ← primary disabled until ProTxHash set +│ │ Enter a ProTxHash to continue. │ ← disabled tooltip +└───────────┴───────────────────────────────────────────────────────────────────┘ +``` + +### (D) Masternode detail / voting view +Reuses: breadcrumb header; type badge; `shorten_id` + copy affordance; DPNS voting backend (surfaced, not +re-implemented); `add_danger_button` + `ConfirmationDialog`. + +``` +┌───────────────────────────────────────────────────────────────────────────────┐ +│ [●] Masternodes › 💼 Main Wallet ▾ › Ⓜ mn-east-01 ▾ [ Refresh ]│ ← MN pill shows the node in view; two-way bound with the list + this detail +├───────────┬───────────────────────────────────────────────────────────────────┤ +│ nav … │ ‹ All masternodes │ ← content-panel back row (§4b) +│ │ mn-east-01 {MN} 9a3f…d7e2 ⧉ ● Active │ ← alias + badge + ProTxHash(copy) + status +│ │ ─────────────────────────────────────────────────────────────── │ +│ │ Actions: [ Withdraw ] [ Top up ] [ Transfer ] │ ← reuse withdraw / top_up / transfer screens, scoped to this node +│ │ ⓔ Evonode only: ( Claim token rewards › ) │ ← Evonode-only cross-link → ClaimTokensScreen (hidden for Masternode) +│ │ ─────────────────────────────────────────────────────────────── │ +│ │ Keys Keys: unprotected ( Add password │ +│ │ Voting: loaded ✓ Owner: loaded ✓ Payout: loaded ✓ protection…)│ ← surfaces existing IdentityTask::ProtectIdentityKeys +│ │ Voter identity: 4c8e…1b70 ⧉ ( Manage keys › ) │ ← opens existing KeyInfoScreen (view WIF, sign, add/remove key) +│ │ ─────────────────────────────────────────────────────────────── │ +│ │ ▸ DPNS name contests to vote on (3) │ ← COLLAPSIBLE, collapsed by default; count stays visible +│ │ ─────────────────────────────────────────────────────────────── │ +│ │ … expanded (▾), the same header reveals the voting table: │ +│ │ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ │ alice ○ Abstain ○ Lock ○ Vote for: [candidate ▾] │ │ +│ │ │ cooltoken ○ Abstain ○ Lock ○ Vote for: [candidate ▾] │ │ +│ │ │ dashfan ○ Abstain ○ Lock ○ Vote for: [candidate ▾] │ │ +│ │ └─────────────────────────────────────────────────────────────┘ │ +│ │ [ Cast votes ] │ +│ │ ─────────────────────────────────────────────────────────────── │ +│ │ ‹ Remove masternode › │ ← danger + confirmation dialog +└───────────┴───────────────────────────────────────────────────────────────────┘ +``` +**Evonode detail differs by:** the `( Claim token rewards › )` cross-link is shown **only** for Evonode +identities (routes to the existing `ClaimTokensScreen`); a plain Masternode hides it. Everything else is identical +across the two node types. + +**Withdraw destination rule (FR-9):** with the owner key the destination is forced to the node's registered Core +payout address; with the transfer/payout key it is a free address. **Add-key rule (FR-10):** the purpose selector +excludes OWNER/VOTING for all identity types. + +Empty voting state (no voter identity): the contests block is replaced by — +`This node has no voting key loaded. Add its voting private key to cast votes.` with a `( Add voting key )` +secondary action routing back to the load form pre-filled with this ProTxHash. + +--- + +## 8. Component Reuse Summary + +| Screen element | Existing asset reused | +|---|---| +| Global wallet/identity switcher (header) | `breadcrumb_switcher.rs` + `breadcrumb_pill.rs` (`Interactive`/`Subdued`/`Placeholder` modes already exist) + `identity_pill.rs`, via `top_panel::add_top_panel_with_breadcrumb`; app-scoped + page-scoped selection. **On Masternodes:** wallet pill `Interactive` (two-way) **and** masternode pill `Interactive` (two-way with card grid + detail). **New:** a page-scoped masternode selection distinct from the app-global user-identity selection (Nagatha) | +| Root-tab header + status dot + toolbar action | `top_panel::render_top_island` + `add_connection_indicator` + `add_toolbar_button` | +| Sub-screen back row (content panel) | lightweight label/link inside `island_central_panel` (no new widget) | +| Left nav rail (icon + label) | existing nav rail | +| Empty-state card | `03-identities-empty.png` pattern, `surface`/`RADIUS_LG`/`Shadow::medium` | +| Node cards | `IdentityPickerCard` frame, monogram, hover elevation, `WidgetInfo::labeled` | +| Type badge pill | `draw_type_badge` (PLATFORM_PURPLE / DASH_BLUE) | +| Node-type toggle | existing segmented tab styling | +| Key inputs | existing WIF-or-hex private-key input widget | +| Optional encryption password (load form) | password-input pattern (`wallet_unlock.rs` hold-to-reveal). **Backend NEW:** thread password through `IdentityInputToLoad` → `load_identity` → existing `store_protected`/`put_secret_protected` (Nagatha's scope) | +| Buttons | `ComponentStyles` primary / secondary / danger / toolbar | +| Status dot | `IdentityStatus → Color32` mapping (+ text label) | +| Messages / errors | `MessageBanner::set_global` + `.with_details` | +| Add-password-protection action | existing `IdentityTask::ProtectIdentityKeys` (Key Info screen's "Add password protection…"); Masternodes detail view reuses it — no new crypto | +| Credit actions (Withdraw / Top up / Transfer) | existing `withdraw_screen` / `top_up_identity_screen` / `transfer_screen`, scoped to the node's `QualifiedIdentity` (FR-9). Only the entry points are new | +| Manage keys drill-in | existing `KeyInfoScreen` scoped to the node (FR-10): view WIF, sign, add/remove key. Add-key purpose selector excludes OWNER/VOTING | +| Evonode token rewards | existing `ClaimTokensScreen` cross-link, Evonode-only (FR-11). Route, not rebuild | +| Remove confirmation | `ConfirmationDialog` `danger_mode(true)` | +| DPNS voting | existing `contested_names/vote_on_dpns_name.rs` backend + DPNS root screens | + +--- + +## Locked decisions (accepted 2026-07-09) + +Human accepted the wireframes; all four answers match the mock as-drawn, so `wireframes.html` is unchanged. +Binding for implementation: + +1. **Voting depth = INLINE** — wireframe (D) stands: cast votes in the detail view via the DPNS-contest table + + **Cast votes** button. No deep-link to the DPNS Active Contests root screen. +2. **Filter scope = HUB PICKER ONLY** — remove MN/Evonode from the Identity Hub picker only. The legacy + Identities table keeps them; stripping it is a **deferred follow-up PR** (out of scope, not a regression). +3. **Nav placement = BELOW IDENTITY HUB** — order Dashpay / Identities / Identity Hub / Masternodes / Contracts + / Dash, with a distinct node/server glyph (as drawn in the wireframes' rail). +4. ~~**Auto-derive = all three key roles** (Voting / Owner / Payout), matching current behaviour.~~ — + **SUPERSEDED 2026-07-09** (post-acceptance investigation; matches `01-requirements.md` Locked-decisions #4). The + load form has **no auto-derive affordance**: `derive_keys_from_wallets` is hard-gated to `IdentityType::User`, so + masternode Voting/Owner/Payout keys are always pasted manually. See §4c. US-6 retired. + +--- + +**Correction folded in (2026-07-09, per CLAUDE.md update):** MN/Evonode keys are not permanently plaintext — they +load Tier-1 (unprotected, no password field by design) and can be sealed to Tier-2 per-identity afterward via the +existing `IdentityTask::ProtectIdentityKeys`. The load-form note is now actionable ("you can protect this node's +keys after loading it") and the detail view surfaces the protection tier + an "Add password protection…" action +(reuse, no new crypto). + +🍬 **Findings tally (UX)** — **1** usability improvement confirmed (Info): the missing-voter-identity path +must present an actionable "Add voting key" affordance instead of surfacing the raw `NoVotingIdentity` error +(carried into wireframe D empty state and US-3 acceptance criteria). diff --git a/docs/ai-design/2026-07-09-masternode-page-design/03-test-case-spec.md b/docs/ai-design/2026-07-09-masternode-page-design/03-test-case-spec.md new file mode 100644 index 000000000..e5b3e0e05 --- /dev/null +++ b/docs/ai-design/2026-07-09-masternode-page-design/03-test-case-spec.md @@ -0,0 +1,366 @@ +# Masternodes Page — Test Case Specification + +**Repo:** `dash-evo-tool` · **Branch:** `feat/masternodes-tab` · **Date:** 2026-07-09 +**Author:** Marvin (QA) · **Phase:** 1c (Test Case Specification) — spec only, no test code, no Rust changes. + +Derived from `01-requirements.md` (FR-1…FR-12, NFR-1…NFR-7, US-1…US-11) and `02-ux-spec.md` + +`wireframes.html` (final, human-accepted 2026-07-09). Every case cites its traceability so +Nagatha's Phase 1d plan can reference "this task satisfies TC-X, TC-Y, TC-Z." + +Legend: `[AMBIGUOUS]` = requirement as written cannot be reduced to a deterministic pass/fail +assertion; case is recorded for traceability but flagged back to the coordinator, not silently +dropped. Colour mapping for `IdentityStatus` was verified against +`src/model/qualified_identity/mod.rs:147-155` (not left as a doc-only assumption): +Active→Green(0,128,0), Unknown→Gray(128,128,128), PendingCreation→Orange(255,165,0), +NotFound→Red(255,0,0), FailedCreation→Red(255,0,0) — two statuses legitimately share Red, this is +not a spec gap. + +--- + +## US-6 — RETIRED, no test cases + +Auto-derive of Voting/Owner/Payout keys from a loaded wallet is architecturally impossible for +Masternode/Evonode identities (`derive_keys_from_wallets` hard-gated to `IdentityType::User` in +`backend_task/identity/load_identity.rs`). No test cases are written against auto-derive on the +load form. Its *absence* is instead asserted positively under **TC-FR4-01**. + +--- + +## FR-1 — Masternodes root tab (Expert-Mode gated) + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR1-01 | Nav item absent with Expert Mode off | Expert Mode = OFF | Inspect left-nav rail | No "Masternodes" entry exists in the nav item list (assert absence, not merely disabled/hidden-behind-click) | FR-1, NFR-7 | +| TC-FR1-02 | Route unreachable with Expert Mode off | Expert Mode = OFF | Attempt to activate/select the Masternodes root screen via any non-nav path available to the app (e.g. programmatic `AppState` screen switch) | Screen is not reachable / request is a no-op; app remains on prior screen | FR-1 | +| TC-FR1-03 | Nav item present with Expert Mode on | Expert Mode = ON | Inspect left-nav rail | "Masternodes" entry renders, positioned between "Identity Hub" and "Contracts" (locked decision #3) | FR-1, ux-spec §Locked decisions #3 | +| TC-FR1-04 | Nav item functional with Expert Mode on | Expert Mode = ON | Click "Masternodes" nav item | Masternodes root screen (list or empty state) is shown | FR-1, US-1 | +| TC-FR1-05 | Toggling Expert Mode off while tab is active | Expert Mode = ON, Masternodes tab currently visible | Toggle Expert Mode to OFF via Network/Settings screen | Nav item disappears | FR-1 | +| TC-FR1-05b | Falls back to Identities on live de-gating *(RESOLVED — was `[AMBIGUOUS]`; found outside Marvin's original 12-item list, closed 2026-07-09)* | Same as TC-FR1-05 | Same | Active screen falls back to the **Identities** root tab — the nearest neutral, always-available screen (§10.11; no existing DET precedent for a dev-gated root tab was found to reuse instead) | FR-1 | +| TC-FR1-06 | Root screen persists across network switch | Expert Mode = ON, on Masternodes tab | Switch network (Mainnet↔Testnet) | Masternodes remains the active root tab (same persistence behaviour as other root screens in `AppState.main_screens`) | FR-1, ux-spec §1 | +| TC-FR1-07 | Distinct glyph, not the Identities person-glyph | Expert Mode = ON | Compare nav icon for "Masternodes" vs "Identities" | Icons are visually distinct SVG/glyph identifiers (node/server glyph vs person glyph) | FR-1, wireframes.html rail markup | + +--- + +## FR-2 — Empty state + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR2-01 | Empty state renders with zero nodes loaded | Expert Mode ON, 0 MN/Evonode identities loaded | Open Masternodes tab | Centered empty-state card renders (surface, `RADIUS_LG`), matching `03-identities-empty.png` pattern | FR-2, NFR-1 | +| TC-FR2-02 | Empty-state heading exact copy | Same | Read heading text | Text is exactly `No masternodes loaded` | FR-2, §7 copy | +| TC-FR2-03 | Empty-state body exact copy | Same | Read body text | Text is exactly `Load a masternode or evonode to vote on DPNS name contests and manage its owner and payout keys.` | FR-2, §7 copy | +| TC-FR2-04 | Primary CTA present and enabled | Same | Inspect primary button | Button labeled `Load a masternode`, enabled (not disabled) | FR-2, §7 copy | +| TC-FR2-05 | Reassurance-line exact copy *(RESOLVED 2026-07-09 — was `[AMBIGUOUS]`)* | Same | Read the reassurance line below the primary CTA | Text is exactly `Have your node's ProTxHash to hand. Keys are optional — a node loads read-only without them.` — now canonical in 01-requirements.md §7; `02-ux-spec.md`'s ASCII wireframe A corrected to match | FR-2, §7 copy | +| TC-FR2-06 | CTA navigates to load form | Same | Click "Load a masternode" | Load form (FR-4) opens | FR-2, US-1 | +| TC-FR2-07 | Empty state does not render once ≥1 node loaded | ≥1 MN/Evonode identity loaded | Open Masternodes tab | Card grid (FR-3) renders instead of the empty-state card; empty-state card is entirely absent (regression boundary) | FR-2, FR-3 | + +--- + +## FR-3 — Card list of loaded masternodes + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR3-01 | Card grid renders with ≥1 node | ≥1 node loaded | Open tab | Card grid renders, empty state absent | FR-3, US-2 | +| TC-FR3-02 | Heading = shortened ProTxHash when alias unset | Node loaded, no alias | View card | Heading shows `shorten_id`-formatted ProTxHash | FR-3, US-2 | +| TC-FR3-03 | Heading = alias, ProTxHash beneath, when alias set | Node loaded with alias `mn-east-01` | View card | Heading = `mn-east-01`; shortened ProTxHash shown as sub-line beneath | FR-3, US-2 bullet 3 | +| TC-FR3-04 | Masternode badge colour/text | Node type = Masternode | View card | Badge text `Masternode`, fill = `PLATFORM_PURPLE` | FR-3, Domain Notes §3 | +| TC-FR3-05 | Evonode badge colour/text | Node type = Evonode | View card | Badge text `Evonode`, fill = `DASH_BLUE` | FR-3, Domain Notes §3 | +| TC-FR3-06 | Voter-ready indicator | `associated_voter_identity` present | View card | Shows `Voting ready` with green status dot | FR-3, US-2 bullet 2 | +| TC-FR3-07 | No-voting-key indicator | `associated_voter_identity` absent | View card | Shows `No voting key` text (not colour-only — NFR-6), warning/orange dot | FR-3, US-2 bullet 2, NFR-6 | +| TC-FR3-08 | Key-status indicator across all V/O/P combinations | 8 nodes, one per bit-combination of {Voting, Owner, Payout} present/absent | View each card | Compact indicator (e.g. `V O P`) correctly emphasises exactly the present keys per node; all-off and all-on are both rendered distinctly from partial states | FR-3, US-2 | +| TC-FR3-09 | DPNS status: open contests | Node has N>0 open contests it can vote on | View card | Shows `{N} contests to vote on` with correct N | FR-3, §7 copy | +| TC-FR3-10 | DPNS status: no open contests | Node has 0 open contests, no scheduled vote | View card | Shows `No open contests` | FR-3, §7 copy | +| TC-FR3-11 | DPNS status precedence, count-first *(RESOLVED — was `[AMBIGUOUS]`)* | Node simultaneously has ≥1 open contest AND a scheduled vote | View card | Shows `{count} contests to vote on` (open-contest count takes precedence whenever `count > 0`, regardless of a pending scheduled vote); `Vote scheduled` only shown when `count == 0` and a vote is pending, reusing the existing DPNS Scheduled Votes screen's state — no new backend concept (§10.1) | FR-3, §7 copy | +| TC-FR3-12 | IdentityStatus dot + label, all 5 states | One card per `IdentityStatus` value | View each card | Active→green+"Active"; Unknown→gray+"Unknown"; PendingCreation→orange+"Pending Creation"; NotFound→red+"Not Found"; FailedCreation→red+"Creation Failed" (verified mapping, `qualified_identity/mod.rs:147-155`) | FR-3, US-2 bullet 1 | +| TC-FR3-13 | Whole card is a single click target | ≥1 node loaded | Click anywhere on a card body (not just heading) | Detail view for that node opens | FR-3 bullet "whole card...single click target", NFR-6 | +| TC-FR3-14 | Responsive wrap to 1 column | Narrow viewport width | Resize app window narrow | Grid collapses from multi-column (`minmax(260,1fr)`) to a single column; `ScrollArea` handles overflow | ux-spec §6 | +| TC-FR3-15 | Card count matches loaded-identity count | N nodes loaded (N=1, N=5) | Open tab | Exactly N cards render, no duplicates, no omissions (assert count equality against DB row count for MN/Evonode identities) | FR-3, data integrity | + +--- + +## FR-4 — Load a masternode/evonode + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR4-01 | No auto-derive affordance present | Load form open | Inspect full field set | Fields present: ProTxHash, Node-type toggle, Alias, Voting/Owner/Payout key inputs, Encryption password. **No** "Try to derive from wallet" checkbox anywhere on the form (explicit negative assertion — corrects the superseded ux-spec §Locked-decisions #4) | FR-4, ux-spec §4c, US-6 retirement | +| TC-FR4-02 | Node-type toggle defaults to Masternode | Load form freshly opened | Inspect toggle | `Masternode` segment is active/selected by default | FR-4, wireframe C | +| TC-FR4-03 | Selecting Evonode switches active segment and affects submit type | Load form open | Click `Evonode` segment, fill ProTxHash, submit | `Evonode` segment shows active styling; loaded identity has `IdentityType::Evonode` | FR-4 | +| TC-FR4-04 | No "User" option on toggle | Load form open | Inspect toggle | Exactly two segments: Masternode, Evonode — no third "User" option | FR-4 | +| TC-FR4-05 | Load button disabled when ProTxHash empty | Load form open, ProTxHash field empty | Inspect Load button | Button is disabled | FR-4, US-1 bullet 2 | +| TC-FR4-06 | Disabled-tooltip exact text | Same as TC-FR4-05 | Hover disabled Load button | Tooltip text exactly `Enter a ProTxHash to continue.` | FR-4, §7 copy | +| TC-FR4-07 | Load button enables once ProTxHash populated | Load form open | Type a valid ProTxHash | Button becomes enabled (regardless of key fields) | FR-4 | +| TC-FR4-08 | ProTxHash accepts hex | Load form open | Enter a valid hex ProTxHash, submit | Node loads with the entered ProTxHash | FR-4, Domain Notes §3 | +| TC-FR4-09 | ProTxHash accepts Base58 | Load form open | Enter a valid Base58 ProTxHash, submit | Node loads with the entered ProTxHash resolved correctly | FR-4 | +| TC-FR4-10 | Load with ProTxHash only → view-only node | Load form open | Enter ProTxHash, leave all 3 key fields blank, submit | Node loads; detail view Keys section shows Voting/Owner/Payout all absent (read-only per empty-state copy) | FR-4, FR-2 copy "loads read-only without them" | +| TC-FR4-11 | Load with all three keys present | Load form open | Enter ProTxHash + Voting + Owner + Payout keys, submit | Detail view Keys section shows all three `loaded ✓` | FR-4, FR-5 | +| TC-FR4-12 | Load with only Voting key | Load form open | Enter ProTxHash + Voting key only, submit | Detail view shows Voting loaded, Owner/Payout absent | FR-4 | +| TC-FR4-13 | Alias becomes card heading when set | Load form open | Enter ProTxHash + alias `mn-east-01`, submit | Card list shows `mn-east-01` as heading (cross-ref TC-FR3-03) | FR-4, FR-3 | +| TC-FR4-14 | Alias is local-only, not sent to Platform | Load form open | Enter ProTxHash + alias, submit | Alias persisted only in local DB; no outbound state-transition/network call includes the alias value | FR-4, §7 copy "not saved to the Dash network" | +| TC-FR4-15 | Key inputs accept WIF | Load form open | Paste a WIF-formatted private key into Voting field, submit | Key accepted and loaded | FR-4, Domain Notes §3 | +| TC-FR4-16 | Key inputs accept hex | Load form open | Paste a hex-formatted private key into Owner field, submit | Key accepted and loaded | FR-4 | +| TC-FR4-17 | Reveal control is hold/press semantics | Load form open, key entered | Press-and-hold the eye icon on a key field, then release | Plaintext visible while pressed; masked again on release (per password-input hold-to-reveal pattern, not a persistent toggle) | ux-spec §3, docs/ux-design-patterns.md §5 | +| TC-FR4-18 | Warning-tone note always visible | Load form open, regardless of field state | Inspect form | Note text exactly `Set an optional password to encrypt these keys on this device. Without one, they are stored unencrypted and you can add protection later from the key screen.` is always rendered (non-blocking, unconditional) | FR-4, NFR-4, §7 copy | +| TC-FR4-19 | Load-error path shows friendly MessageBanner | Backend load task returns a `TaskError` (e.g. network failure resolving ProTxHash) | Submit a ProTxHash that triggers a backend error | Error `MessageBanner` shown with a user-friendly message; technical detail attached via `.with_details(e)`; no raw error string/stack trace visible in the banner text itself | FR-4, US-1 "Failure paths", CLAUDE.md error-message rules | +| TC-FR4-20 | Successful load returns to list with fresh form state | Load form open | Submit a valid load, later reopen "Load a masternode" | New card appears in list; reopened form has no residual data from the previous submission | FR-4, US-1 bullet 3 | +| TC-FR4-21 | Cancel discards without loading | Load form open, fields populated | Click Cancel | Returns to list; no new card created; no backend load task dispatched | FR-4, wireframe C | +| TC-FR4-22 | Old advanced-options arm is removed *(RESOLVED — was `[AMBIGUOUS]`)* | Legacy `add_existing_identity_screen.rs` Identity Type dropdown | Open Show Advanced Options on the legacy Add Existing Identity screen | Masternode/Evonode options are **removed** from the dropdown (User-only remains) — no duplicate entry point for loading node identities (§10.2, extends FR-6) | FR-4, FR-6 | + +--- + +## FR-5 — Masternode detail / voting view composition + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR5-01 | **Section order — Actions ABOVE Keys (explicit late correction)** | Detail view open for a fully-keyed node | Read section order top→bottom | Order is exactly: Header → Credit-actions row (incl. Evonode-only cross-link) → Keys section (with "Manage keys ›") → collapsible DPNS voting section → Remove. **Actions row must render before the Keys section**, not after. | FR-5 "Grouping (top→bottom)", explicit human-requested change | +| TC-FR5-02 | Header alias line conditional | Node with alias vs. node without alias | Open both detail views | Aliased node shows alias in header; unaliased node's header omits the alias line entirely (no empty placeholder) | FR-5 | +| TC-FR5-03 | Header ProTxHash + copy affordance | Detail view open | Click the copy icon next to the shortened ProTxHash | ProTxHash is written to clipboard (full value, not the shortened display string) | FR-5 | +| TC-FR5-04 | Header badge matches card badge | Same node, compare card vs detail | Open card then detail | Badge colour/text identical between card and detail header | FR-5, FR-3 | +| TC-FR5-05 | Header status + label | Detail view open | Read status | `IdentityStatus` dot + text label shown (never colour-only) | FR-5, NFR-6 | +| TC-FR5-06 | Detail reachable via card click and via masternode pill | ≥1 node loaded | (a) click card (b) select node from masternode-pill dropdown | Both paths open the same detail view for the chosen node | FR-5, FR-GLOBAL-NAV-3 | +| TC-FR5-07 | Back row returns to list | Detail view open | Click `‹ All masternodes` | Returns to card list (content-panel back row, header/global switcher unchanged) | FR-5, FR-GLOBAL-NAV-5 | + +--- + +## FR-6 — Filter masternode/evonode out of user-only pickers (Hub-picker-only scope) + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR6-01 | Masternode excluded from Hub picker | 1 Masternode identity loaded | Open Identity Hub picker | Masternode identity is absent from the picker list | FR-6, US-5 bullet 1 | +| TC-FR6-02 | Evonode excluded from Hub picker | 1 Evonode identity loaded | Open Identity Hub picker | Evonode identity is absent from the picker list | FR-6, US-5 bullet 1 | +| TC-FR6-03 | Masternode still visible in legacy Identities table | 1 Masternode identity loaded | Open legacy `identities_screen.rs` table | Masternode identity IS listed (locked decision #2 — Hub-picker-only scope, not a full removal) | FR-6, ux-spec §Locked decisions #2 | +| TC-FR6-04 | Evonode still visible in legacy Identities table | 1 Evonode identity loaded | Open legacy Identities table | Evonode identity IS listed | FR-6, ux-spec §Locked decisions #2 | +| TC-FR6-05 | User identity unaffected (control case) | 1 User identity loaded alongside MN/Evonode | Open both Hub picker and legacy table | User identity appears in BOTH surfaces (confirms filter is type-scoped, not a blanket removal) | FR-6, US-5 | +| TC-FR6-06 | MN/Evonode present on Masternodes tab | Same identities loaded | Open Masternodes tab | Both MN and Evonode identities appear as cards | FR-6, US-5 bullet 2 | +| TC-FR6-07 | Masternode-pill selection never leaks into app-global user-identity selection | Masternode selected via Masternodes-page pill | Navigate to Dashpay/Identities/Identity Hub | The app-global identity pill/selection there shows the User identity (or none), never the masternode just selected | FR-6 boundary, FR-GLOBAL-NAV-3, US-7 bullet 4 (duplicated for emphasis; full nav coverage under TC-NAV-12) | + +--- + +## FR-7 — Refresh + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR7-01 | Refresh button present, styled per `01-dashpay.png` | Card list open | Inspect top-right toolbar | `Refresh` button present, `add_toolbar_button` styling | FR-7 | +| TC-FR7-02 | Refresh dispatches re-fetch task | Card list open | Click Refresh | A backend task re-fetching masternode identity + voting state is dispatched (assert task variant, not merely a UI repaint) | FR-7 | +| TC-FR7-03 | Refresh reflects updated state | Underlying vote/identity state changed externally since last load | Click Refresh | Card list content updates to reflect the new state (data freshness assertion, not just "no crash") | FR-7 | +| TC-FR7-04 | Refresh also present on detail view | Detail view open | Inspect header | Refresh button present and functional on the detail screen too | FR-7, wireframe D | + +--- + +## FR-8 / US-8 — Optional load-time key encryption password + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR8-01 | Blank password → Tier-1 keyless persistence | Load form open, password field left blank, ≥1 key entered | Submit | Keys persisted via the unprotected path (`put_secret`/`store` — not `put_secret_protected`) | FR-8, US-8 bullet 1 | +| TC-FR8-02 | Non-blank password → Tier-2 sealed persistence at load | Load form open, password set, ≥1 key entered | Submit | Keys persisted via `put_secret_protected`/`store_protected` (Argon2id + XChaCha20-Poly1305) at load time, not only reachable post-load | FR-8, US-8 bullet 2 | +| TC-FR8-03 | Password reveal is hold-to-reveal, not toggle | Password field has a value | Press-and-hold eye icon, then release | Plaintext shown while held; masked again immediately on release | FR-8, ux-spec §3 "password-input pattern" | +| TC-FR8-04 | Password never logged | Load with a distinctive password value, e.g. `Tr0ub4dor&3-QA` | Submit, then inspect application logs (`RUST_LOG` output) | The literal password string never appears in logs | FR-8, "never logged, never stored" | +| TC-FR8-05 | Password/plaintext keys never stored unencrypted when Tier-2 chosen | Same as TC-FR8-02 | Inspect DB/secret-vault contents directly | Only the Argon2id-derived / XChaCha20-Poly1305-sealed envelope is present; no plaintext key material or password readable at rest | FR-8, security | +| TC-FR8-06 | Detail view reflects Tier-1 unprotected state | Node loaded with blank password | Open detail view | Shows `Keys: unprotected`; "Add password protection…" action IS offered | FR-8, FR-5, US-8 bullet 1 | +| TC-FR8-07 | Detail view reflects Tier-2 protected state, no redundant action | Node loaded with a password set | Open detail view | Shows `Keys: password-protected`; "Add password protection…" action is NOT offered (already protected) | FR-8, US-8 bullet 4 | +| TC-FR8-08 | "Add password protection…" reuses existing task | Detail view for a Tier-1 node | Click "Add password protection…" | Dispatches the existing `IdentityTask::ProtectIdentityKeys` (no new crypto path introduced) | FR-8, FR-5, NFR-4 | +| TC-FR8-09 | Password validation matches existing Add-password-protection rule *(RESOLVED — was `[AMBIGUOUS]`)* | Load form open | Enter a password, submit | No new policy is invented: validation is identical to whatever the existing Key Info screen's "Add password protection…" flow already enforces, since FR-8 reuses the same `store_protected`/`put_secret_protected` seal path (§10.3) — confirm the existing rule at implementation time | FR-8 | +| TC-FR8-10 | Identity key also sealed when password set (not just V/O/P) | Load form open, password set, load produces an identity key too | Submit, inspect secret store | The identity key (per FR-8 "(and identity) keys") is sealed Tier-2 alongside voting/owner/payout, not left unprotected | FR-8 | + +--- + +## FR-9 / US-9 — Credit actions (Withdraw / Top up / Transfer) + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR9-01 | Actions row present for Masternode | Masternode detail view | Inspect Actions row | `Withdraw`, `Top up`, `Transfer` all present | FR-9, US-9 bullet 1 | +| TC-FR9-02 | Actions row present for Evonode | Evonode detail view | Inspect Actions row | Same three actions present (FR-9 explicit "both Masternode and Evonode") | FR-9, US-9 bullet 1 | +| TC-FR9-03 | Withdraw scoped to correct identity | Detail view for node X | Click Withdraw | `withdraw_screen` opens with `QualifiedIdentity` == node X (not the app-global User identity or any other node) | FR-9 | +| TC-FR9-04 | Top up scoped to node, wallet = current wallet pill | Detail view for node X, wallet pill = Wallet A | Click Top up | `top_up_identity_screen` opens scoped to node X with source wallet = Wallet A | FR-9, FR-GLOBAL-NAV-3 | +| TC-FR9-05 | Transfer scoped to node | Detail view for node X | Click Transfer | `transfer_screen` opens scoped to node X | FR-9 | +| TC-FR9-06 | Owner-key withdraw forces payout-address destination | Withdraw flow initiated with the node's owner key | Attempt to set a custom destination address | Destination field is fixed to the node's registered Core payout address; not user-editable | FR-9, US-9 bullet 2, Domain Notes §3 | +| TC-FR9-07 | Transfer/payout-key withdraw allows free destination | Withdraw flow initiated with the transfer/payout key | Enter a custom destination address | Destination field accepts any user-chosen address | FR-9, US-9 bullet 2 | +| TC-FR9-08 | Reuse, not reimplementation | All three actions | Trigger each from the Masternodes detail view and independently from wherever else in the app they're already reachable | Same screen struct/type is pushed in both cases (structural reuse assertion — no parallel MN-specific implementation) | FR-9 "reuse existing screens", NFR-1 | + +--- + +## FR-10 / US-10 — Manage-keys drill-in (KeyInfoScreen) + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR10-01 | Drill-in opens existing KeyInfoScreen | Detail view, Keys section | Click "Manage keys ›" | Existing `KeyInfoScreen` opens scoped to this node's identity | FR-10, US-10 bullet 1 | +| TC-FR10-02 | View private key/WIF | KeyInfoScreen open for node | Select a loaded key | Private key/WIF viewable | FR-10 | +| TC-FR10-03 | Sign message | KeyInfoScreen open for node | Use sign-message action | Message signed using node's key | FR-10 | +| TC-FR10-04 | Add-key selector excludes OWNER (Masternode) | KeyInfoScreen for a Masternode | Open add-key purpose selector | `OWNER` not offered | FR-10, US-10 bullet 2, Domain Notes §3 | +| TC-FR10-05 | Add-key selector excludes VOTING (Masternode) | Same | Same | `VOTING` not offered | FR-10, US-10 bullet 2 | +| TC-FR10-06 | Add-key selector excludes OWNER (Evonode) | KeyInfoScreen for an Evonode | Open add-key purpose selector | `OWNER` not offered | FR-10 | +| TC-FR10-07 | Add-key selector excludes VOTING (Evonode) | Same | Same | `VOTING` not offered | FR-10 | +| TC-FR10-08 | Rule applies to User identities too (not MN-specific) | KeyInfoScreen for a User identity | Open add-key purpose selector | `OWNER`/`VOTING` also excluded here (regression check confirming this is a platform-wide rule, not new MN-only logic) | FR-10 "not MN-specific — document it" | +| TC-FR10-09 | TRANSFER offered | Any identity type's KeyInfoScreen | Open selector | `TRANSFER` present | FR-10 | +| TC-FR10-10 | AUTHENTICATION offered | Same | Same | `AUTHENTICATION` present | FR-10 | +| TC-FR10-11 | ENCRYPTION offered | Same | Same | `ENCRYPTION` present | FR-10 | +| TC-FR10-12 | DECRYPTION offered | Same | Same | `DECRYPTION` present | FR-10 | +| TC-FR10-13 | Remove-key reachable | KeyInfoScreen for node with ≥2 keys | Use remove-key action on a non-critical key | Key removed (existing capability, unchanged) | FR-10 | + +--- + +## FR-11 / US-11 — Evonode token-rewards cross-link + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR11-01 | Cross-link shown for Evonode | Evonode detail view | Inspect Actions row | `Claim token rewards ›` shown | FR-11, US-11 bullet 1 | +| TC-FR11-02 | Cross-link hidden for plain Masternode (explicit negative case) | Masternode detail view | Inspect Actions row | `Claim token rewards ›` is **absent** — not disabled, not present-but-greyed | FR-11, US-11 bullet 2 | +| TC-FR11-03 | Cross-link routes to existing ClaimTokensScreen | Evonode detail view | Click `Claim token rewards ›` | Existing `ClaimTokensScreen` opens scoped to this Evonode identity | FR-11 | +| TC-FR11-04 | No new claim UI on Masternodes page | Same | Same | Screen pushed is the same `ClaimTokensScreen` type used elsewhere in the app (structural reuse, no MN-page-local reimplementation) | FR-11, NFR-1 | + +--- + +## FR-12 — "Fill Random Masternode/Evonode" (Testnet-only, fixture-conditional) + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-FR12-01 | Button renders: Testnet + fixture present + Masternode toggle | Network=Testnet, `.testnet_nodes.yml` present and parses, toggle=Masternode | Open load form | `🎲 Fill Random Masternode` button + hint row render | FR-12 | +| TC-FR12-02 | Button label follows toggle: Evonode | Same fixture conditions, toggle=Evonode | Open load form | Button label reads `Fill Random Evonode` (matches toggle, per FR-12 "label follows Node-type toggle") | FR-12 | +| TC-FR12-03 | Button absent when fixture file missing (not disabled) | Network=Testnet, `.testnet_nodes.yml` absent | Open load form | The entire button+hint row is absent — assert widget/element count for that row is 0, not `disabled=true`; form flows directly from Node-type to ProTxHash | FR-12 "MUST be conditional... never shown-but-disabled" | +| TC-FR12-04 | Button absent when fixture unparseable, no crash/no error banner | Network=Testnet, `.testnet_nodes.yml` present but malformed YAML | Open load form | Button absent; no panic; no `MessageBanner` error surfaced for this case. **Note (PROJ-004):** the loader returns `Ok(None)` only for a *missing* file; a *malformed* file returns `Err(_)` which the legacy screen banners — so the new form must **swallow `Err(_)` → absent** (a deliberate divergence from legacy, `tracing::debug!` the parse error), not verbatim reuse | FR-12; loader `Ok(None)` on absent, `Err` on malformed (`add_existing_identity_screen.rs:58-71,151-161`) | +| TC-FR12-05 | Button absent on Mainnet regardless of fixture | Network=Mainnet, fixture present | Open load form | Button absent (Testnet-only gate) | FR-12 | +| TC-FR12-06 | Button absent on Devnet | Network=Devnet, fixture present | Open load form | Button absent | FR-12 | +| TC-FR12-07 | Clicking Fill Random autofills from a real fixture entry *(CORRECTED 2026-07-09, PROJ-003 — Masternode fixture has no payout key)* | Testnet + fixture present, toggle=Masternode | Click `Fill Random Masternode` | ProTxHash + **Voting + Owner** fields populate from one of the fixture's `masternodes` entries (`MasternodeInfo` has no payout field, so `fill_random_masternode()` fills V+O only — Payout stays blank; the Evonode/`hp_masternodes` path fills all three incl. Payout, TC-FR12-08) — not blank, not synthetic | FR-12, `add_existing_identity_screen.rs:30-35,979-993` | +| TC-FR12-08 | Correct fixture list consulted per node type | Testnet + fixture present | Click Fill Random with toggle=Masternode, then separately with toggle=Evonode | Masternode toggle pulls from fixture's `masternodes` list; Evonode toggle pulls from `hp_masternodes` list (verified against `fill_random_masternode()`/`fill_random_hpmn()`) | FR-12 | +| TC-FR12-09 | `[DEFERRED, not ambiguous]` Defense-in-depth `developer_mode` re-check at button call-site | Load form reachable only via Expert-Mode-gated nav (FR-1) | N/A | FR-12 explicitly defers this as an implementation judgment call for Nagatha's plan, not an open requirements question — Nagatha's plan should record its own decision, not this spec | FR-12 "Flag for Nagatha" | +| TC-FR12-10 | Node type toggle clears autofilled fields *(RESOLVED — was `[AMBIGUOUS]`)* | Fields already autofilled via Fill Random for Masternode | Switch toggle to Evonode | ProTxHash, Alias, and all key fields are **cleared** — a real node's identity is tied to one type; data for one is never valid for the other (§10.6) | FR-12, wireframe C | + +--- + +## Global Nav — US-7 / FR-GLOBAL-NAV-1…6 + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-NAV-01 | Page-aware leftmost crumb | On Masternodes tab | Read header crumb | Leftmost segment reads `Masternodes` (not `Identities`), links to the Masternodes root | FR-GLOBAL-NAV-6, US-7 bullet 1 | +| TC-NAV-02 | Same switcher component as Identity Hub | Masternodes tab open | Inspect rendered widget | Same `breadcrumb_switcher.rs` component via `add_top_panel_with_breadcrumb` used, not a bespoke reimplementation | FR-GLOBAL-NAV-1, NFR-1 | +| TC-NAV-03 | Wallet pill Interactive on Masternodes | Any Masternodes screen (list/load/detail) | Inspect wallet pill | Renders `Interactive` mode: caret, clickable | FR-GLOBAL-NAV-3, US-7 | +| TC-NAV-04 | Masternode pill Placeholder when 0 nodes | Empty state | Inspect masternode pill | Renders `Placeholder`: `(no masternode yet)`, italic, no caret | FR-GLOBAL-NAV-3, wireframe A | +| TC-NAV-05 | Masternode pill "Choose a masternode" on list with no selection | ≥1 node loaded, list view, no node opened yet | Inspect masternode pill | `Interactive`, text `Choose a masternode ▾` | wireframe B | +| TC-NAV-06 | Wallet selection is silent, no forced navigation | On Masternodes tab | Select a different wallet from the wallet-pill dropdown | `AppContext::selected_wallet_hash` updates; page remains on Masternodes tab (no navigation away) | FR-GLOBAL-NAV-2 rule 1, US-7 bullet 2 | +| TC-NAV-07 | Wallet-pill → Top-up binding (pill drives page) | Wallet pill changed to Wallet B | Open Top up on a node | Top-up screen's source wallet = Wallet B | FR-GLOBAL-NAV-2 rule 2, US-7 bullet 2 | +| TC-NAV-08 | Top-up → wallet-pill binding (page drives pill) | Top-up screen open with source wallet initially = Wallet A | Change source wallet inside the Top-up flow to Wallet C | Top-nav wallet pill updates to show Wallet C | FR-GLOBAL-NAV-2 rule 2 ("two-way"), US-7 bullet 2 | +| TC-NAV-09 | Card click → masternode-pill binding | List view, ≥2 nodes | Click card for node X | Masternode pill updates to show node X | FR-GLOBAL-NAV-3, US-7 bullet 3 | +| TC-NAV-10 | Masternode-pill selection → detail navigation | List or detail view, ≥2 nodes | Pick node Y from masternode-pill dropdown | Detail view for node Y opens | FR-GLOBAL-NAV-3, US-7 bullet 3 | +| TC-NAV-11 | Masternode-pill dropdown content correctness | 3 nodes loaded | Open masternode-pill dropdown | Exactly the 3 loaded MN/Evonode identities are listed, no others, no duplicates | FR-GLOBAL-NAV-3 | +| TC-NAV-12 | **FR-6 boundary — masternode selection never leaks to user pages (critical)** | Masternode selected via Masternodes-page pill | Navigate to Dashpay, then Identities, then Identity Hub | On every one of these pages, the identity pill/selection reflects the app-global **User** identity (or none) — never the masternode | FR-GLOBAL-NAV-3, FR-6, US-7 bullet 4 | +| TC-NAV-12b | **FR-6 boundary — first-loaded fallback never resolves a masternode** *(added 2026-07-09, PROJ-001)* | Exactly one identity loaded and it is a Masternode/Evonode; nothing explicitly selected | Open Dashpay/Identities/Identity Hub | `resolve_selected_identity()` returns the User identity or `None`, **never** the masternode via the first-loaded fallback (`context/mod.rs:1099-1105` filters MN/Evonode at the resolution layer, not just display) | FR-6, PROJ-001a | +| TC-NAV-12c | **FR-6 boundary — stale persisted MN selection sanitized on load** *(added 2026-07-09, PROJ-001)* | A masternode was persisted as `selected_identity_id` in a prior session (masternodes were Hub-pickable then) | Launch/build with the new FR-6 filter and open an everyday-user page | The stale MN/Evonode selection is cleared on context load; the identity pill shows a User identity or none, and operate-as reads never resolve the masternode | FR-6, PROJ-001b | +| TC-NAV-13 | Unwired-page pill renders Subdued | A page not yet wired to consume a given selection | Inspect its pill | Dimmed, no caret, no visible text tag | FR-GLOBAL-NAV-2 rule 3, US-7 bullet 5 | +| TC-NAV-14 | Subdued pill tooltip explains how to change selection | Same | Hover the Subdued pill | Tooltip text present, non-empty, page-specific (e.g. "Change the active wallet from the Wallets tab") | FR-GLOBAL-NAV-2 rule 3 | +| TC-NAV-15 | Per-page composition: wallet-only page shows one pill | A page with no identity/object context (e.g. a Wallet page) | Inspect switcher | Only the wallet pill renders; no third segment at all | FR-GLOBAL-NAV-2 rule 4, US-7 bullet 6 | +| TC-NAV-16 | Sub-screen nav doesn't disturb the global switcher | On list view, open load form or a node's detail | Compare header before/after | Global switcher stays single-line/unchanged; a separate `‹ All masternodes` back row appears in the content panel instead | FR-GLOBAL-NAV-5 | +| TC-NAV-17 | Everyday-page identity-pill dropdown never lists MN/Evonode | On Dashpay/Identities/Identity Hub, MN+Evonode+User identities all loaded | Open the identity pill's dropdown | Only User identities listed | FR-GLOBAL-NAV-4 | +| TC-NAV-18 | Masternode-pill resets to placeholder on list return *(RESOLVED — was `[AMBIGUOUS]`)* | Detail view for node X open | Click `‹ All masternodes` | Pill resets to `Choose a masternode ▾` placeholder — it reflects the current screen's context (specific node only on the detail view), not "last node opened" (§10.4, matches wireframe B as drawn) | FR-GLOBAL-NAV-3, §4b | + +--- + +## DPNS voting section (FR-5 collapsible + US-3) + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-DPNS-01 | Collapsed by default | Detail view freshly opened | Observe section state | Collapsing section starts collapsed (`▸`) | FR-5, ux-spec §3 | +| TC-DPNS-02 | Count visible in collapsed header | Node has 3 open contests | Read collapsed header | Reads `DPNS name contests to vote on (3)` — count matches actual open-contest count | FR-5 | +| TC-DPNS-03 | Expand reveals per-contest choices | Collapsed section | Click header | Expands (`▾`); each contested name shows Abstain / Lock / Vote-for-candidate | FR-5, US-3 bullet 1 | +| TC-DPNS-04 | Candidate dropdown scoped per contest | Section expanded | Open the "Vote for" dropdown on one contested name | Only candidates registered for that specific name appear (not a global candidate list) | FR-5 | +| TC-DPNS-05 | Cast votes dispatches real backend | Section expanded, choice selected for ≥1 name | Click "Cast votes" | Vote is dispatched through the existing `contested_names/vote_on_dpns_name.rs` backend with the correct name/choice/identity parameters | FR-5, US-3 bullet 2 | +| TC-DPNS-06 | Success feedback auto-dismisses | Vote cast successfully | Observe banner | Success `MessageBanner` shown, auto-dismisses (per journey 2.2) | US-3 | +| TC-DPNS-07 | Scheduled/past votes are out of scope for this section *(RESOLVED — was `[AMBIGUOUS]`)* | Node has both a scheduled vote and past votes | Expand section | Only **active, open contests** render here (exactly as wireframe D draws it); scheduled/past-vote history is not duplicated on this page — it already lives on the existing DPNS Scheduled Votes root screen (§10.7) | FR-5 | +| TC-DPNS-08 | Zero-open-contests empty copy | Node has 0 open contests | Expand section | Body text exactly `There are no open name contests for this node to vote on right now.`, no contest table | §7 copy | +| TC-DPNS-09 | **Missing voter identity → actionable message, not raw error (critical)** | `associated_voter_identity` is `None` | Open/expand voting section | Shows exactly `This node has no voting key loaded. Add its voting private key to cast votes.` plus a secondary `( Add voting key )` action — the raw `NoVotingIdentity` error type/string is never surfaced to the user | US-3 bullet 3, §7 copy, CLAUDE.md error-message rules | +| TC-DPNS-10 | "Add voting key" opens the scoped in-place prompt *(CORRECTED 2026-07-09, PROJ-002 — was "load form opens pre-filled", which contradicted TC-DPNS-11/§10.8)* | Voting section showing the missing-voter-identity state | Click `( Add voting key )` | A **scoped voter-key-input prompt** opens with this node's context pre-bound — it is **not** FR-4's load form (consistent with §10.8 / TC-DPNS-11); no ProTxHash re-entry | ux-spec wireframe D note, §10.8 | +| TC-DPNS-11 | "Add voting key" is a scoped in-place action, not a load-form resubmission *(RESOLVED — was `[AMBIGUOUS]`)* | Load form pre-filled per TC-DPNS-10 | Submit with a new voting key entered | "Add voting key" (US-3) opens a small, scoped key-input prompt that updates the voter identity on the already-loaded node in place — it is a different flow from FR-4's load form, so the duplicate-ProTxHash rejection (TC-EDGE-07) does not apply here (§10.8) | FR-4, wireframe D | + +--- + +## US-4 — Remove a masternode + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-US4-01 | Remove button present | Detail view open | Inspect footer | `Remove masternode` danger button present | US-4 bullet 1, FR-5 | +| TC-US4-02 | Confirmation dialog with specific verb | Detail view open | Click Remove | `ConfirmationDialog` opens, `danger_mode(true)`, verb label `Remove masternode` | US-4 bullet 1, §7 copy | +| TC-US4-03 | Cancel/Remove button placement | Confirmation open | Inspect dialog | Cancel positioned left, Remove positioned right | ux-spec §3 | +| TC-US4-04 | Escape cancels | Confirmation open | Press Escape | Dialog closes, node NOT removed | ux-spec §3 | +| TC-US4-05 | Confirm removes node AND its voter identity | Node with an associated voter identity, confirmation open | Confirm | Both the masternode/evonode identity row AND its associated voter identity row are deleted from the DB (assert both, not just the card disappearing) | US-4 bullet 2, journey 2.3 | +| TC-US4-06 | Returns to list, card gone | Same, post-confirm | Observe screen | Back on card list; removed node's card no longer present | US-4 bullet 2 | +| TC-US4-07 | Isolation — other nodes unaffected | ≥2 nodes loaded, remove one | Confirm removal of node X | Node Y's card, keys, and voter identity remain fully intact (no over-deletion) | US-4, data integrity | + +--- + +## Edge / failure cases + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-EDGE-01 | Enter-key submit bypass with empty ProTxHash | Load form open, ProTxHash empty, focus in another field | Press Enter | No backend load task is dispatched (disabled state cannot be bypassed via keyboard submit) | FR-4, US-1 bullet 2 | +| TC-EDGE-02 | Load error is friendly + non-leaking | Backend load fails (e.g. simulated network error) | Submit a ProTxHash that triggers the failure | `MessageBanner` (Error), persistent (not auto-dismiss), `.with_details(e)` attached, visible message contains no raw SDK/DB error text or stack trace | FR-4, journey 2.1 "Failure paths" | +| TC-EDGE-03 | Missing voter identity at vote time is actionable | Voting section, no voter identity | Attempt to vote | Actionable message shown (cross-ref TC-DPNS-09), never the raw `NoVotingIdentity` error | US-3 bullet 3 | +| TC-EDGE-04 | No wallet loaded, attempt Top up *(RESOLVED 2026-07-09 — was `[AMBIGUOUS]`)* | 0 wallets loaded, Masternodes tab open | Inspect wallet pill and attempt Top up | Behavior matches whatever the existing, reused `top_up_identity_screen` already does with 0 wallets loaded — this design adds an entry point only and does not redefine that screen's no-wallet handling (§10.5) | FR-9 | +| TC-EDGE-05 | Card list scoped per active network | Node A loaded under Testnet, network switched to Mainnet | Switch network, open Masternodes tab | Card list shows only Mainnet-scoped nodes; node A (Testnet) is not shown until switching back | FR-1 "survives network switches", NFR consistency with other root tabs | +| TC-EDGE-06 | Network switch mid-sub-screen returns to the list *(RESOLVED — was `[AMBIGUOUS]`)* | Load form (with Fill Random visible, Testnet) or detail view open | Switch network away from Testnet mid-screen | App returns to the Masternodes **list** for the new network, matching TC-EDGE-05's "card list scoped per active network" rule — no stale sub-screen referencing a now-foreign identity (§10.10) | FR-12, FR-1 | +| TC-EDGE-07 | Duplicate-ProTxHash load is rejected *(RESOLVED — was `[AMBIGUOUS]`)* | Node with ProTxHash P already loaded | Submit the load form again with the same ProTxHash P | Duplicate-node error shown (§7 copy: "This masternode is already loaded…"); no second card created, existing node not silently updated (§10.9) | FR-4 | +| TC-EDGE-08 | Malformed ProTxHash validated inline *(RESOLVED — was `[AMBIGUOUS]`)* | Load form open | Enter a syntactically invalid ProTxHash (wrong length/charset), attempt submit | Client-side format validation fires inline/on-blur (not only gated on emptiness); error copy per §7: "This doesn't look like a valid ProTxHash…" (§10.9) | FR-4 | + +--- + +## NFR checks (execution-verifiable subset) + +| ID | Description | Preconditions | Steps | Expected Outcome | Traces to | +|---|---|---|---|---|---| +| TC-NFR4-01 | Warning note is non-blocking | Load form open | Fill only ProTxHash, leave password/note area untouched | Load button enabled purely on ProTxHash presence; note's existence does not gate submission | NFR-4, FR-4 | +| TC-NFR6-01 | Card has a single accessible label | Card list, ≥1 card | Query accessibility tree (kittest) | Card exposes `WidgetInfo::labeled(Button, ..., "Open {node}")`, single click target | NFR-6 | +| TC-NFR6-02 | Focus order top-to-bottom on load form | Load form open | Tab through fields | Order follows visual top-to-bottom layout, ending on the primary action (Load masternode) last | NFR-6 | +| TC-NFR6-03 | No colour-only status anywhere in the feature | Card list + detail view, all status indicators (voter readiness, identity status, key protection tier) | Inspect each | Every status indicator pairs its colour with a text label | NFR-6 (regression across FR-3/FR-5/FR-8) | +| TC-NFR6-04 | Disabled Load button carries disabled-tooltip semantics | Load form, ProTxHash empty | Inspect button state via kittest | `enabled() == false`; tooltip text retrievable and matches TC-FR4-06 | NFR-6, FR-4 | + +--- + +## Coverage summary + +| Group | Count | +|---|---| +| FR-1 (Expert Mode gating) | 8 | +| FR-2 (Empty state) | 7 | +| FR-3 (Card list) | 15 | +| FR-4 (Load flow) | 22 | +| FR-5 (Detail composition) | 7 | +| FR-6 (Hub-picker filter) | 7 | +| FR-7 (Refresh) | 4 | +| FR-8 / US-8 (Load-time password) | 10 | +| FR-9 / US-9 (Credit actions) | 8 | +| FR-10 / US-10 (Manage-keys drill-in) | 13 | +| FR-11 / US-11 (Evonode cross-link) | 4 | +| FR-12 (Fill Random) | 10 (1 intentionally deferred, see below) | +| Global Nav (US-7 / FR-GLOBAL-NAV) | 20 | +| DPNS voting section (FR-5 / US-3) | 11 | +| US-4 (Remove) | 7 | +| Edge/failure cases | 8 | +| NFR (execution-verifiable subset) | 5 | +| **Total** | **166** (0 open ambiguities remain; +2 = TC-NAV-12b/12c added 2026-07-09 for the PROJ-001 FR-6 resolution-layer boundary) | + +US-6 retired — 0 test cases, documented above. + +--- + +## Ambiguities/gaps surfaced — RESOLVED 2026-07-09 (folded back before Nagatha's plan) + +All 12 gaps originally surfaced in this closing section were resolved by the coordinator in `01-requirements.md` +§10 and reflected in the corresponding test-case rows above (each row now reads "RESOLVED" inline with a +§10.N citation). One item (TC-FR12-09) was reclassified `[DEFERRED, not ambiguous]` — it was always an +intentional implementation judgment call for Nagatha's plan, not an open requirements question. A **13th gap, +TC-FR1-05b** (live de-gating fallback screen), existed in the FR-1 test cases but was omitted from this +closing list in the original pass — found by the coordinator during a direct sweep and resolved in §10.11. +Original list, kept for traceability: + +1. ~~TC-FR2-05~~ — empty-state reassurance-line copy → canonicalized in 01-requirements.md §7 (wireframes.html's wording wins). +2. ~~TC-FR3-11~~ — DPNS status precedence → §10.1 (count-first, then scheduled, then none; reuses existing Scheduled Votes state). +3. ~~TC-FR4-22~~ — legacy advanced-options arm → §10.2 (removed, extends FR-6). +4. ~~TC-FR8-09~~ — password strength rule → §10.3 (reuses existing Add-password-protection validation, no new policy). +5. TC-FR12-09 → reclassified `[DEFERRED, not ambiguous]`; TC-FR12-10 → §10.6 (node-type toggle clears autofilled fields). +6. ~~TC-NAV-18~~ — masternode-pill state on list return → §10.4 (resets to placeholder). +7. ~~TC-DPNS-07~~ — scheduled/past votes → §10.7 (out of scope by design; already covered by the existing Scheduled Votes screen). +8. ~~TC-DPNS-11~~ — "Add voting key" resubmission → §10.8 (scoped in-place action, not a load-form resubmission). +9. ~~TC-EDGE-04~~ — no-wallet Top-up behavior → §10.5 (inherits the existing reused Top-up screen's behavior, unchanged). +10. ~~TC-EDGE-06~~ — network switch mid-sub-screen → §10.10 (returns to the list, matches TC-EDGE-05). +11. ~~TC-EDGE-07~~ — duplicate-ProTxHash load → §10.9 (rejected with a friendly error, §7 copy). +12. ~~TC-EDGE-08~~ — malformed-ProTxHash validation → §10.9 (inline/on-blur, §7 copy). +13. ~~TC-FR1-05b~~ *(found outside this list)* — live de-gating fallback → §10.11 (falls back to Identities). + +None of these were silently dropped — each had a recorded, traceable test-case ID, and each now has a decision ++ an updated expected-outcome to assert against. diff --git a/docs/ai-design/2026-07-09-masternode-page-design/04-dev-plan.md b/docs/ai-design/2026-07-09-masternode-page-design/04-dev-plan.md new file mode 100644 index 000000000..bf4b12b68 --- /dev/null +++ b/docs/ai-design/2026-07-09-masternode-page-design/04-dev-plan.md @@ -0,0 +1,388 @@ +# Masternodes Page — Development Plan (Phase 1d) + +**Repo:** `dash-evo-tool` · **Branch:** `feat/masternodes-tab` (base `v1.0-dev`) · **Date:** 2026-07-09 +**Author:** Nagatha (Software Architect) · **Phase:** 1d — implementation decomposition. Planning only; no Rust written. + +Inputs (all final, human-accepted 2026-07-09): `01-requirements.md` (FR-1…FR-12, FR-GLOBAL-NAV, +NFR-1…NFR-7, US-1…US-11, §10 Resolved gaps), `02-ux-spec.md`, `wireframes.html`, `03-test-case-spec.md` +(164 cases), `v010-masternode-features.md`. Grounded against the live tree: `src/ui/mod.rs`, +`src/ui/components/{top_panel,left_panel}.rs`, `src/ui/identity/breadcrumb_switcher.rs`, +`src/ui/state/hub_selection.rs`, `src/backend_task/identity/{mod,load_identity,protect_identity_keys}.rs`, +`src/model/feature_gate.rs`, `src/app.rs`. + +> **Review fixes folded in (2026-07-09):** Fable/Adams reviewed this plan — APPROVE WITH FIXES, 0 CRITICAL/HIGH, +> 4 MEDIUM + 9 LOW (`fable-review.md`). PROJ-001, -002, -003, -005 (MEDIUM) and PROJ-004, -006, -007, -008, -009, +> -010, -011, -012, -013 (LOW) are folded into the affected tasks below and into `01-requirements.md` / +> `03-test-case-spec.md` / `02-ux-spec.md`. Changes are spec-level; the plan's architecture is unchanged. + +Two structural facts from the live tree that shape the whole plan: +1. **The nav rail already accepts a per-entry `FeatureGate`** (`left_panel.rs:237-243` skips entries whose gate is + unavailable; runtime-gate precedent is the `DashPay` entry). FR-1's Expert-Mode gate is therefore a *data* + change (one gated entry), not new machinery. +2. **The top-panel breadcrumb seam already exists** (`top_panel::add_top_panel_with_breadcrumb` → + `render_top_island`, `top_panel.rs:378-385`). The switcher itself (`breadcrumb_switcher::render`) is, however, + **hub-hardwired**: a literal `"Identities"` segment-1, hub-only pill semantics, and effect application living + inside `hub_screen.rs::apply_breadcrumb_effect`. Phase A generalizes exactly this. + +--- + +## 1. System Layers & Responsibilities + +Per DET's Module Placement Policy. Every task below names its layer(s); this is the map. + +| Layer | This feature's responsibilities | Files (new *n* / modified *m*) | +|---|---|---| +| **model/** (pure, no IO) | ProTxHash shape validation (hex/Base58) as a stateless validator; **relocation** of the existing password-format validator into `model/` (see B0/PROJ-006 — it currently lives, private, in `backend_task/identity/protect_identity_keys.rs:156`, contrary to DET's Validation-placement rule). Global-nav page-scope enum lives in `ui/state`, not here (it is view state, per the discriminator). | `model/masternode_input.rs` *n* (ProTxHash validator); relocate `validate_protection_password` → `model/` *m* | +| **backend_task/** (async, authoritative enforcement) | Thread the optional load-time password through load; authoritative duplicate-ProTxHash rejection; masternode+voting refresh; reuse withdraw/top-up/transfer/protect/vote tasks unchanged. `TaskError` variants for duplicate/malformed. | `backend_task/identity/{mod.rs,load_identity.rs}` *m*; `backend_task/error.rs` *m* | +| **database/** | No new module. MN/Evonode rows already persist as `QualifiedIdentity`. Read paths reuse existing accessors; the per-node contest read (B1/PROJ-012) composes from the existing contested-names store. | — | +| **context/** (glue) | Wrapper read methods: masternode-only identity list for the active network; **User-only accessor consumed at the resolution layer** so `resolve_selected_identity()` and wallet-reconciliation can never resolve a masternode as the everyday-page identity (FR-6, PROJ-001); one-time sanitization of a stale persisted MN/Evonode selection; live-de-gating fallback helper. | `context/mod.rs` (or `context/identity_db.rs`) *m* | +| **ui/state/** (renders nothing) | The page-scoped nav model: which pills a page composes, which selections it consumes, and the **page-scoped masternode selection kept distinct from the app-global user-identity selection** (FR-6 boundary). Masternodes-screen view state. | `ui/state/global_nav.rs` *n*; `ui/state/masternodes_view.rs` *n* | +| **ui/components/** (renders egui) | Generalized global switcher built on the existing `BreadcrumbPill` modes (`Interactive`/`Subdued`/`Placeholder` — no new pill widget). | `ui/components/global_nav_switcher.rs` *n* (extracted from `breadcrumb_switcher.rs`) | +| **ui/masternodes/** (new screen domain) | The Masternodes root screen, empty state, card grid + card body, load form, detail/voting view, Fill-Random, entry points into reused screens. No business logic, no own validation. | `ui/masternodes/{mod,list_screen,load_form,detail_screen,card}.rs` *n* | +| **ui/mod.rs, app.rs** (shell) | New `RootScreenType::RootScreenMasternodes`, `ScreenType::Masternodes`, `Screen::MasternodesScreen`; root-screen registration; nav entry (gated); global-switcher render on every root screen. | `ui/mod.rs` *m*, `app.rs` *m*, `left_panel.rs` *m* | + +**Reuse-first ledger (no new operation screens).** FR-9 → `withdraw_screen` / `top_up_identity_screen` / +`transfer_screen`; FR-10 → `KeyInfoScreen`; FR-11 → `ClaimTokensScreen`; FR-5 protect → `IdentityTask::ProtectIdentityKeys`; +FR-5 voting → `contested_names/vote_on_dpns_name.rs`; FR-12 → `fill_random_masternode()`/`fill_random_hpmn()` + +`.testnet_nodes.yml` loader; FR-3 card → `IdentityPickerCard` frame + `draw_type_badge`; header → `add_top_panel_with_breadcrumb`. +Each is an **entry point**, not a reimplementation — this is the QA dedup contract (TC-FR9-08, TC-FR11-04, TC-NAV-02). + +--- + +## 2. Two-Phase Sequencing (coordinator decision) + +- **Phase A — Global wallet/identity switcher on every root page (app-shell foundation).** Larger than this + feature; lands and is reviewed as its own commit sequence *first*. The nav renders on every root page day one; + interactivity is blast-radius-limited (Subdued + tooltip + `TODO` on unwired pages). The Masternodes page then + *consumes* this, it does not build it. +- **Phase B — The Masternodes page**, built on top, inheriting the global nav. + +Phase B B7 (page nav wiring) has a hard dependency on Phase A. **B1 now also depends on A2** (PROJ-005): the FR-6 +filter lands in the durable context accessor consumed by both switcher generations, so B1 does not patch the file +A2 rewrites. All other Phase B tasks can proceed against a Subdued/placeholder header until B7 lands. + +--- + +## 3. Phase A — Global Nav (app-shell) + +### A1 — Page-nav model & the two-scope selection abstraction *(ui/state)* +**Files:** `ui/state/global_nav.rs` *n* (+ unit tests inline). +**Work:** Define `PageNavSpec` describing, per root page: segment-1 `(label, RootScreenType target)` +(page-aware, FR-GLOBAL-NAV-6); pill composition (`wallet?`, `identity/object?` — FR-GLOBAL-NAV-2 rule 4); and +per-pill consumption mode (`Consumed{two-way}` vs `Unwired{how-to-change tooltip}`, rule 3). Define +`IdentityPillScope` = `AppGlobalUser` **|** `PageScopedObject{ label, dropdown items, selected }` — this enum is +the structural guarantee behind the FR-6 boundary: the page-scoped variant **never** writes +`AppContext::selected_identity_id`. Pure state; renders nothing (placement discriminator → `ui/state`). +**Tests (TDD):** spec resolves correct pill set per page; `PageScopedObject` selection is isolated from app-global +identity; unwired-pill mode carries a non-empty tooltip. +**Satisfies:** TC-NAV-13, TC-NAV-14, TC-NAV-15 (composition/subdued/tooltip logic); foundation for TC-NAV-12, TC-FR6-07. + +### A2 — Generalize `breadcrumb_switcher` into a page-aware global switcher *(ui/components)* +**Files:** `ui/components/global_nav_switcher.rs` *n* (extract/rewrite of `ui/identity/breadcrumb_switcher.rs`); +keep a thin hub-facing shim so `hub_screen.rs` compiles unchanged in behavior. +**Work:** `render(ui, ctx, spec: &PageNavSpec, view_state) -> GlobalNavEffect`. Segment-1 renders `spec` label + +links to `spec` root (replaces the literal `"Identities"`). Pills compose per `spec`: consumed pills render +`Interactive` (caret + dropdown) and emit two-way effects; unwired pills render `Subdued` (dimmed, **no caret, no +visible text tag**) with the how-to-change tooltip and a `// TODO: wire on this page` marker. Reuse +`BreadcrumbPill`/`IdentityPill` and the existing `BreadcrumbPillMode` verbatim — **no new pill widget** (NFR-1). +Generalize `BreadcrumbEffect` → `GlobalNavEffect` (adds `SelectPageObject(...)` for the page-scoped pill, kept +distinct from `SelectIdentity`). The identity-list source for the pill dropdown reads through the **User-only +context accessor** (B1), so the FR-6 filter lives in the accessor, not this component (PROJ-005). +**Tests:** segment-1 label is page-driven; a page-scoped-object selection produces `SelectPageObject`, never +`SelectIdentity`; subdued pill emits no effect on click. +**Satisfies:** TC-NAV-01, TC-NAV-02, TC-NAV-03, TC-NAV-13, TC-NAV-16. + +### A3 — Render the switcher on every root screen + centralize effect application *(app-shell)* +**Files:** each root screen's top-panel call site → `add_top_panel_with_breadcrumb` with its `PageNavSpec`; a shared +`apply_global_nav_effect` (lift `hub_screen.rs::apply_breadcrumb_effect` to a shared helper) so wallet/identity +selection updates the **app-global** selection *silently, with no forced navigation* (rule 1). Every root screen not +yet wired supplies a Subdued spec (+ `TODO`). Hub keeps its existing interactive wallet/identity pills (regression). +**Work:** wiring + one shared effect applier. `SwitchWallet`→`set_selected_hd_wallet` (silent); +`SelectIdentity`→`set_selected_identity` (silent); `SelectPageObject`→ handled by the owning page (B7). +**Reconciliation semantics are not a pure wallet write (PROJ-010):** `set_selected_hd_wallet` +(`context/mod.rs:1152-1175`) reconciles the app-global *identity* to the new wallet's identities as a side effect +(keep-if-owned → first → `None`); on non-Hub pages this cross-axis mutation is real and intentional — A3 must +document it, and combined with B1's resolution-layer filter it must **never** reconcile onto an MN/Evonode. +Blast radius: only Hub (and, after B7, Masternodes) are interactive; all else Subdued. +**Tests:** kittest — switcher present on ≥2 non-Hub root screens; wallet selection does not navigate; +**wallet switch on a non-Hub page reconciles identity per existing rules and never onto a MN/Evonode**; unwired +identity pill on an everyday page never lists MN/Evonode (re-asserted in TC-NAV-17). +**Satisfies:** TC-NAV-06 (silent, no-nav), TC-NAV-15, TC-NAV-16; enables TC-NAV-12/17. + +**Phase A rollout/TODO placement:** every root screen that does not yet consume a selection gets a `Subdued` pill and +an explicit `// TODO: wire wallet/identity selection consumption for ` at its `PageNavSpec` construction — the +page-by-page wiring backlog. Interactivity is opt-in per page; this bounds the app-shell blast radius. + +--- + +## 4. Phase B — Masternodes Page (dependency order) + +### B0 — FR-8 load-time key encryption plumbing *(model + backend_task)* — **no UI** +**Files:** `backend_task/identity/mod.rs` (`IdentityInputToLoad`, add `encryption_password: Option` at +`mod.rs:43`); `backend_task/identity/load_identity.rs` (route persistence); `backend_task/identity/protect_identity_keys.rs` +(the seal path + `validate_protection_password:156`); **`src/mcp/tools/masternode.rs:180`** (the third +`IdentityInputToLoad` constructor — the MCP `masternode_identity_load` tool). +**Work:** when `encryption_password` is `Some`, seal the voting/owner/payout **and identity** keys via the existing +`store_protected`/`put_secret_protected` envelope (Argon2id + XChaCha20-Poly1305) at load time, through the +`wallet_backend/secret_seam.rs` chokepoint — **no new crypto, no second persistence path**. When `None`, current +Tier-1 keyless path is unchanged. Validate the password in the backend (enforcement) reusing the *existing* rule +(§10.3 — do not invent one); password is a `Secret`, never logged, never stored. Add typed `TaskError` variants for +duplicate/malformed ProTxHash here (used by B1/B4) rather than string parsing. +- **Password-validator placement (PROJ-006).** `validate_protection_password` is today a **private fn in + `protect_identity_keys.rs:156`**, not a `model/` validator. Per DET's Validation-placement rule, pure + password-format validation belongs in `model/`: **relocate it to `model/` (or expose `pub(crate)` if relocation + is deferred)** so `load_identity` can call it — small, mechanical, in-scope for B0. +- **MCP constructor decision (PROJ-007).** Adding the field breaks `mcp/tools/masternode.rs:180` at compile time. + **Decision: MCP passes `encryption_password: None` this iteration** (Tier-1 unchanged — matches FR-8's GUI-only + scope; the tool is a confirmed keyless entry point, requirements §2.3). Leave a `TODO` for headless password + parity as a follow-up; do not silently invent MCP password handling. +**Tests (RED-first):** blank→unprotected path; set→`put_secret_protected`; identity key also sealed (TC-FR8-10); +password never appears in logs; no plaintext at rest; MCP path compiles and stays Tier-1. +**Satisfies:** TC-FR8-01, TC-FR8-02, TC-FR8-04, TC-FR8-05, TC-FR8-09, TC-FR8-10. +**Depends on:** nothing. Start immediately (parallel to Phase A). + +### B1 — Context read paths + FR-6 filter at the resolution layer + FR-7 refresh *(context + backend_task)* +**Files:** `context/mod.rs` (or `identity_db.rs`); `backend_task/identity/` (refresh). +**Work:** +- **New accessors:** `load_local_masternode_identities()` (active-network MN/Evonode — card-list + masternode-pill + source) and a **User-only accessor** for the Hub picker + global identity pill. +- **FR-6 filter at the *resolution* layer, not the display call sites (PROJ-001 / PROJ-005 — R1-critical).** The leak + is not confined to the two display sources (`breadcrumb_switcher.rs:148`, `hub_screen.rs:91,215`): + `resolve_selected_identity()` (`context/mod.rs:1099-1105`) falls back to the **first loaded identity over ALL + types** via `model::selected_identity::resolve_selected`, and `set_selected_hd_wallet` (`:1152-1175`) reconciles + the app-global identity the same way. An operator whose only/first loaded identity is a masternode — exactly the + Priya profile — would get it as the everyday-page operate-as identity with `IdentityPillScope` never involved. + **Filter MN/Evonode inside the resolution path** (both the keep-if-loaded check and the first-loaded fallback, over + all-loaded and over the per-wallet reconciliation source) via the User-only accessor. This is the durable seam and + also feeds the two display sources; the legacy `identities_screen.rs` table stays untouched (locked decision #2). +- **One-time stale-selection sanitization (PROJ-001b).** Masternodes are pickable in the Hub *today*, so a persisted + `selected_identity_id` may already point at an MN/Evonode. On context load, if the persisted selection resolves to + `IdentityType != User`, clear it — otherwise the User-filtered pill and the MN-valued selection disagree and + operate-as reads still resolve the masternode. +- **FR-7 refresh** (backend_task): re-fetch MN identity + **voting** state — compose from existing `refresh_identity` + + the contested-names query; **do not** add a parallel fetcher. +- **Card DPNS read accessor (PROJ-012).** The card's per-node open-contest count + scheduled-vote state + (TC-FR3-09/-10/-11, TC-DPNS-02) needs a **read** join over the existing contested-names store — name it here and + compose from that store; if no existing query serves it, this is the second thin new piece alongside the refresh + task (R4). +**Tests:** MN/Evonode absent from the picker/pill list **and** from `resolve_selected_identity()` even when it is the +only/first loaded identity; present in legacy table (control); User identity present in both; **stale MN persisted +selection cleared on load**; MN present in the masternode accessor. +**Satisfies:** TC-FR6-01…06, TC-NAV-12 (new preconditions TC-NAV-12b/12c), TC-NAV-17, TC-FR7-02, TC-FR7-03. +**Depends on:** **A2** (FR-6 filter lands in the durable context accessor both switcher generations consume; sequence +B1 after A2 to avoid the same-file collision — PROJ-005). + +### B2 — Root tab: registration, Expert-Mode gate, nav, de-gating fallback *(ui/mod.rs + app.rs + left_panel)* +**Files:** `ui/mod.rs` (`RootScreenType::RootScreenMasternodes` + `to_int`/`from_int` round-trip with a fresh stable +integer + test, mirroring the IdentityHub precedent at `ui/mod.rs:161,194,205-215`; `ScreenType::Masternodes`; +`Screen::MasternodesScreen`; `create_screen`; `change_context` arm); `app.rs` root-screen registration (mirror the +hub chain at `app.rs:805-821`); `left_panel.rs` nav entry **gated `FeatureGate::DeveloperMode`**, positioned +**below Identity Hub** (locked decision #3), distinct node/server glyph (not `identity.png`). +**Work:** the gate reuses the existing per-entry `gate.is_available()` skip (`left_panel.rs:237-243`) — nav item and +route both absent when Expert Mode is off. **Live de-gating fallback (§10.11):** when Expert Mode flips off while +Masternodes is active, fall back to `RootScreenIdentities` (nearest neutral tab — no existing precedent to reuse, so +this is a small explicit guard in the screen-resolution path, analogous to the persisted-selection fallback at +`app.rs:829-833`). Network switch keeps the tab selected but resets any pushed sub-screen to the list (§10.10). +**Tests:** round-trip of the new variant; nav absent Expert-off / present + positioned Expert-on; distinct glyph id; +survives network switch; de-gating falls back to Identities. +**Satisfies:** TC-FR1-01…07, TC-FR1-05b, TC-EDGE-05, TC-EDGE-06. +**Depends on:** none structurally; do before B3–B7 (they need the screen to exist). + +### B3 — Empty state + card grid + card body *(ui/masternodes)* +**Files:** `ui/masternodes/{list_screen,card}.rs`. +**Work:** Empty state (FR-2) reusing the `03-identities-empty.png` card pattern + exact §7 copy incl. the canonical +reassurance line. Card grid (FR-3) reusing `IdentityPickerCard` frame + `draw_type_badge` (purple/blue), extended +body rows: shortened ProTxHash / alias-as-heading, voter readiness, `V O P` key status across all 8 combinations, +DPNS status line with **count-first precedence** (§10.1 — `{count} contests` when `count>0`, else `Vote scheduled` +when pending, else `No open contests`; reads the per-node contest accessor from B1, reusing the existing +Scheduled-Votes state — no new backend concept), and the `IdentityStatus` dot+label (verified mapping, five states). +Whole card is one labelled click target (`WidgetInfo::labeled`, NFR-6). Responsive `minmax(260,1fr)`. Add the +top-right toolbar **Refresh** button here (FR-7). +**Tests (kittest):** empty↔grid boundary; badge colour/text per type; voter-ready vs no-voting-key; all 8 key combos; +precedence rows; five status states; single-click-target label; count == DB rows; Refresh button present/styled. +**Satisfies:** TC-FR2-01…07, TC-FR3-01…15, TC-FR7-01, TC-NFR6-01, TC-NFR6-03. +**Depends on:** B1 (list + contest read source), B2 (screen). + +### B4 — Load form + validation + legacy-arm removal *(ui/masternodes + model)* +**Files:** `ui/masternodes/load_form.rs` *n*; `model/masternode_input.rs` *n* (ProTxHash validator); +`ui/identities/add_existing_identity_screen.rs` *m* (remove MN/Evonode options from the Advanced-Options Identity-Type +dropdown — User-only remains, §10.2 / TC-FR4-22). +**Work:** MN/Evonode-only form (FR-4): ProTxHash (required), Masternode/Evonode segmented toggle (default Masternode, +**no User option**), optional alias, V/O/P key inputs (reuse existing WIF-or-hex widget, hold-to-reveal), optional +encryption-password field (reuse `wallet_unlock.rs` hold-to-reveal; drives B0), always-visible Warning-tone note +(§7 copy), Load button disabled until ProTxHash present + disabled tooltip. **Validation delegates to model** +(NFR/layer rule): inline/on-blur ProTxHash shape check (hex or Base58) via the new `model/` validator; duplicate +detection is authoritative in the backend (B0/B1 typed error), surfaced as the §7 duplicate copy. Node-type toggle +**clears** ProTxHash/alias/keys (§10.6). **Explicitly no auto-derive affordance** (US-6 retired; assert absence). +**Tests:** field set incl. negative no-auto-derive assertion; toggle default/switch/clear; disabled+tooltip; +hex/Base58 accept; malformed inline; duplicate reject; friendly error banner + `.with_details`; cancel discards; +fresh form on reopen; legacy dropdown MN/Evonode removed. +**Satisfies:** TC-FR4-01…22, TC-EDGE-01, TC-EDGE-02, TC-EDGE-07, TC-EDGE-08, TC-FR8-03, TC-NFR4-01, TC-NFR6-02, TC-NFR6-04. +**Depends on:** B0 (password field target), B2. + +### B5a — Detail view scaffold: header, actions row, keys, remove *(ui/masternodes, reuse-heavy)* +**Files:** `ui/masternodes/detail_screen.rs` *n*. +**Work:** Detail composition in the **corrected order (TC-FR5-01): Header → Actions row → Keys → DPNS → Remove.** +Header: alias (conditional) + shortened ProTxHash + copy-full-value + type badge + status. **Actions row (FR-9):** +entry points pushing the existing `WithdrawalScreen`/`TopUpIdentityScreen`/`TransferScreen` scoped to the node's +`QualifiedIdentity` (both MN and Evonode) — reuse, not reimplementation; Top up sources the wallet-pill wallet. +**Evonode-only** `Claim token rewards ›` cross-link → existing `ClaimTokensScreen` (hidden for Masternode, FR-11). +**Keys section:** V/O/P presence + voter-identity id (copyable) + protection tier (`unprotected`/`password-protected`); +`Add password protection…` → existing `IdentityTask::ProtectIdentityKeys` (offered only Tier-1); `Manage keys ›` → +existing `KeyInfoScreen` (FR-10, add-key selector already excludes OWNER/VOTING for all types — verify, don't add +logic). **Remove:** `ConfirmationDialog danger_mode(true)`, removes node + associated voter identity. +Content-panel `‹ All masternodes` back row (not in the global header). Add the detail-view **Refresh** button (FR-7). +**Tests:** section order (actions above keys); reuse identity scoping for all three credit actions + structural-reuse +assertion; Evonode-only cross-link present/absent; protection-tier display + conditional Add-protection; Manage-keys +opens `KeyInfoScreen`; Remove confirm deletes node+voter, isolation, back row; Refresh present on detail. +**Satisfies:** TC-FR5-01…05, TC-FR5-07, TC-FR7-04, TC-FR9-01…08, TC-FR10-01…13, TC-FR11-01…04, TC-FR8-06, TC-FR8-08, +TC-US4-01…07, TC-EDGE-04. +**Depends on:** B2, B3. **(PROJ-011)** For **TC-FR8-07** (detail reflects a *load-time-sealed* node, no redundant +Add-protection), the Tier-2-at-load precondition requires B0+B4 — that one case is verified in **B8's integration +pass**; B5a covers only the tier-display + conditional-action logic (reachable via a post-load `ProtectIdentityKeys` +seal), so TC-FR8-07 is listed under B8, not here. + +### B5b — Detail view: inline DPNS voting + missing-voter path *(ui/masternodes, reuse voting backend)* +**Files:** `ui/masternodes/detail_screen.rs` (voting section). +**Work:** Collapsible section (**collapsed by default**, open-contest **count in header**). Expanded: per-contest +Abstain/Lock/Vote-for-candidate table + **Cast votes**, dispatching the existing +`contested_names/vote_on_dpns_name.rs` backend inline (locked decision #1 — **not** a deep-link). Candidate dropdown +scoped per contest. Active/open contests only (§10.7 — scheduled/past live on the existing Scheduled Votes screen, +not duplicated). **Missing-voter-identity (US-3/§10.8):** show the actionable §7 copy (never raw `NoVotingIdentity`) ++ `( Add voting key )` → a **scoped, in-place voter-key-input prompt** that updates the voter identity on the +already-loaded node (distinct from B4's load form; exempt from the duplicate-ProTxHash rejection). This is the +**§10.8-resolved design; TC-DPNS-10 is corrected in `03-test-case-spec.md`** from "load form opens pre-filled" to +"scoped prompt opens, node context pre-bound" so the pair TC-DPNS-10 / TC-DPNS-11 no longer contradict (PROJ-002). +Success banner auto-dismisses. +**Tests:** collapsed default; count in header; expand reveals choices; per-contest candidate scoping; Cast votes hits +the real backend with correct params; zero-open empty copy; missing-voter actionable message + scoped in-place prompt +(node pre-bound, NOT a load-form resubmission). +**Satisfies:** TC-DPNS-01…11, TC-EDGE-03. +**Depends on:** B5a. + +### B6 — FR-12 Fill Random (Testnet-only, fixture-conditional) *(ui/masternodes, reuse loader)* +**Files:** `ui/masternodes/load_form.rs`. +**Work:** Reuse `fill_random_masternode()`/`fill_random_hpmn()` + `load_testnet_nodes_from_yml(".testnet_nodes.yml")` +(`add_existing_identity_screen.rs:961-993`). **One** button, label follows the toggle. **Render-conditional, not +disabled:** button+hint row present only when network == Testnet **and** the loader returns `Some(_)`; absent (0 +widgets) otherwise. +- **Autofilled key set differs by node type (PROJ-003 — TC-FR12-07 corrected).** The regular-masternode fixture + struct `MasternodeInfo { pro_tx_hash, owner, voter }` has **no payout field**, so `fill_random_masternode()` fills + **ProTxHash + Voting + Owner only** (Payout stays blank). Only `fill_random_hpmn()` (Evonode / `hp_masternodes`) + fills all three including Payout. This is the honest, low-cost option matching the fixture operators actually have; + do **not** claim three-key autofill for the Masternode toggle. (Requirements FR-12 §7 and TC-FR12-07 corrected to + match.) +- **Malformed-YAML is a deliberate behavior change, not verbatim reuse (PROJ-004 — TC-FR12-04).** The loader returns + `Ok(None)` only for a **missing** file; a **malformed** file returns `Err(_)`, and the *legacy* screen banners it + (`add_existing_identity_screen.rs:151-161`). The new form must **map `Err(_)` → absent button** (swallow; no + banner, no panic) — a conscious divergence from the legacy screen. Add a `tracing::debug!` on the swallowed parse + error so a broken fixture is diagnosable. +- Masternode toggle pulls `masternodes`; Evonode pulls `hp_masternodes`. Autofill respects the node-type clear rule + (§10.6, shared with B4). **TC-FR12-09 decision — see §6:** add the defense-in-depth `is_developer_mode()` check. +**Tests:** render matrix (Testnet+fixture±toggle / missing / malformed→absent+no-banner / Mainnet / Devnet); +Masternode autofill = V+O (Payout blank); Evonode autofill = V+O+P; label follows toggle. +**Satisfies:** TC-FR12-01…08 (with the -07 correction), TC-FR12-09 (recorded decision), TC-FR12-10. +**Depends on:** B4. + +### B7 — Wire the Masternodes page into the global nav *(ui/masternodes + ui/state)* +**Files:** `ui/state/masternodes_view.rs` *n* (page-scoped masternode selection); Masternodes screens' `PageNavSpec`. +**Work:** Provide the page's `PageNavSpec`: page-aware segment-1 `Masternodes`; **wallet pill Interactive + two-way** +(funds Top up — FR-9; changing it on a Top-up flow updates the pill and vice-versa); **masternode pill Interactive + +two-way** using `IdentityPillScope::PageScopedObject` — dropdown lists loaded MN/Evonode (B1 source), opening a card +sets the pill, picking from the pill opens that node's detail; placeholder `(no masternode yet)` on empty, `Choose a +masternode ▾` on list, specific node on detail, **reset to placeholder on `‹ All masternodes`** (§10.4). The +page-scoped selection lives in `masternodes_view.rs`, **never** in `AppContext::selected_identity_id` — this is the +FR-6 boundary in code (complementing B1's resolution-layer filter). +**Tests (critical):** card-click↔pill and pill↔detail two-way; dropdown content correctness; wallet-pill→Top-up and +Top-up→wallet-pill two-way; **masternode selection never leaks to app-global user-identity pill across Dashpay/ +Identities/Hub (TC-NAV-12)**; pill placeholder/choose/reset states. +**Satisfies:** TC-NAV-04, TC-NAV-05, TC-NAV-07, TC-NAV-08, TC-NAV-09, TC-NAV-10, TC-NAV-11, TC-NAV-12, TC-NAV-18, +TC-FR5-06, TC-FR6-07, TC-FR9-04. +**Depends on:** **Phase A (A1–A3)**, B1, B3, B5a. + +### B8 — Cross-cutting integration coverage & QA handoff *(tests/)* +**Files:** `tests/kittest/…`, optionally `tests/e2e/…`. +**Work:** Assemble the kittest/e2e suite mapping the remaining execution-verifiable cases not covered by unit tests +in B0–B7 (a11y sweep, network-switch edge cases, the FR-6 boundary end-to-end, and **TC-FR8-07** load-time-sealed +detail display which needs the full B0+B4+B5a chain — PROJ-011). No production code. QA-facing traceability closure. +**Satisfies:** TC-FR8-07, TC-NFR6-01…04 (sweep), TC-EDGE-05/06 end-to-end, regression net over TC-NAV-12. +**Depends on:** B2–B7. + +--- + +## 5. Task → Test-Case Traceability + +| Task | Layer(s) | Test cases satisfied | +|---|---|---| +| **A1** | ui/state | TC-NAV-13, -14, -15 | +| **A2** | ui/components | TC-NAV-01, -02, -03, -13, -16 | +| **A3** | app-shell | TC-NAV-06, -15, -16 | +| **B0** | model + backend_task | TC-FR8-01, -02, -04, -05, -09, -10 | +| **B1** | context + backend_task | TC-FR6-01…06, TC-NAV-12 (12b/12c), TC-NAV-17, TC-FR7-02, -03 | +| **B2** | ui/mod + app.rs + left_panel | TC-FR1-01…07, TC-FR1-05b, TC-EDGE-05, -06 | +| **B3** | ui/masternodes | TC-FR2-01…07, TC-FR3-01…15, TC-FR7-01, TC-NFR6-01, -03 | +| **B4** | ui/masternodes + model | TC-FR4-01…22, TC-EDGE-01, -02, -07, -08, TC-FR8-03, TC-NFR4-01, TC-NFR6-02, -04 | +| **B5a** | ui/masternodes (reuse) | TC-FR5-01…05, -07, TC-FR7-04, TC-FR9-01…08, TC-FR10-01…13, TC-FR11-01…04, TC-FR8-06, -08, TC-US4-01…07, TC-EDGE-04 | +| **B5b** | ui/masternodes (reuse voting) | TC-DPNS-01…11, TC-EDGE-03 | +| **B6** | ui/masternodes (reuse loader) | TC-FR12-01…10 | +| **B7** | ui/masternodes + ui/state | TC-NAV-04, -05, -07, -08, -09, -10, -11, -12, -18, TC-FR5-06, TC-FR6-07, TC-FR9-04 | +| **B8** | tests/ | TC-FR8-07, TC-NFR6 sweep, TC-EDGE-05/06 e2e, TC-NAV-12 regression | + +All 164 cases map to at least one task. US-6 retired (0 cases). TC-FR7-01 (list Refresh) is now owned by B3 and +TC-FR7-04 (detail Refresh) by B5a — no longer footnote-only (PROJ-009). TC-FR8-07 moved from B5a to B8 (PROJ-011). + +--- + +## 6. Risks & Open Implementation Decisions + +**My decision on TC-FR12-09 (deferred to me by design).** *Add* the defense-in-depth `is_developer_mode()` check at +the Fill-Random button call-site. The whole tab is Expert-gated (FR-1), so under normal navigation the check is +redundant — **the decision therefore stands on future-proofing grounds** (a plaintext-private-key-reading dev tool +should be contained inside the Expert-Mode envelope regardless of any future non-nav entry point into this screen). +*(Corrected premise, PROJ-002: an earlier draft justified this by claiming the DPNS "Add voting key" affordance opens +the load form — under the §10.8-resolved design that affordance opens a **scoped in-place prompt**, not the load form, +so no second path into the load form / Fill-Random exists today. The check is still worth adding on future-proofing +alone; the recorded rationale is corrected.)* + +**Risks to flag before implementation:** + +- **R1 — FR-6 boundary is the highest-severity correctness item (critical, release-blocking).** The page-scoped + masternode selection must never write `AppContext::selected_identity_id` (B7 / A1's `IdentityPillScope`), **and** — + the gap Fable caught (PROJ-001) — the **resolution layer** must not resolve a masternode as the everyday-page + identity via the first-loaded fallback or wallet reconciliation, and a stale MN selection persisted from a prior + session must be sanitized on load. B1 now filters at `resolve_selected_identity()` + the reconciliation source and + clears stale persisted MN selections; TC-NAV-12 gains preconditions 12b ("only a masternode loaded, nothing + selected") and 12c ("masternode persisted as selection from a prior session"). Treat any failure here as blocking. +- **R2 — Phase A blast radius.** Making the switcher global touches every root screen's top panel; regression risk to + the Hub and all tabs. Mitigation: Phase A lands and is reviewed independently; Subdued+`TODO` is the inert default, + so unwired pages cannot misbehave. Do not begin B7 until A1–A3 are merged/green. **Also (PROJ-010):** a wallet + switch reconciles the app-global identity as a side effect — A3 documents and tests this. +- **R3 — FR-8 secret path discipline (security).** The password must seal through the existing + `secret_seam.rs` chokepoint / `protect_loaded_identity_keys` — **no second persistence path, no new crypto**. + Password is a `Secret`; TC-FR8-04/05 (never logged, no plaintext at rest) are security assertions. Reuse the + existing `validate_protection_password` rule (relocated to `model/`, PROJ-006); do not invent a policy (§10.3). +- **R4 — FR-7 refresh + the card contest-read may hide a genuine gap.** The rest of the plan is reuse; the two places + a *new* thin piece may be required are re-fetching **voting** state (refresh) and the **per-node open-contest read** + the card displays (PROJ-012). B1 must verify whether existing tasks/queries cover both; if not, add minimal + composed pieces — do not reimplement contest fetching. +- **R5 — Inline DPNS voting reuse (B5b).** Casting votes inline (locked decision #1) reuses the vote backend, but the + existing DPNS root screen currently *owns* contest fetching/rendering. Extracting a reusable voting-table widget + may be needed; guard against a partial reimplementation that would fail the TC-FR9-08-style structural-reuse intent. +- **R6 — `.testnet_nodes.yml` handles real plaintext private keys.** FR-12's fixture is gitignored, absent from the + repo, and Testnet-only. The render-conditional (not disabled) gate + Testnet + Expert-Mode + (my) dev-mode check + keep it contained; never ship or hardcode the fixture, never log its contents. + +**Artifact erratum (PROJ-013, non-blocking).** `wireframes.html` still draws the legacy **two** Fill-Random buttons +("Fill Random HPMN" / "Fill Random Masternode") and omits the missing-voter "Add voting key" affordance. FR-12/§7 +(one button, label follows toggle) and ux-spec wireframe D are canonical; the HTML mock is stale for these two +details only. Recorded here so no implementer treats the mock as the source of truth for them. + +--- + +🍬 **Findings tally (architecture, Phase 1d + review fold-in):** **6** architectural risks (Info/decision severity) — +R1 (scope-leak boundary, now covering the resolution layer per PROJ-001), R2 (app-shell blast radius + wallet +reconciliation), R3 (secret-path discipline), R4 (refresh + card-contest read gap), R5 (voting-widget reuse seam), +R6 (fixture secret containment). Plus one recorded decision (TC-FR12-09, premise corrected) and **13 Fable findings +folded in** (4 MEDIUM: PROJ-001/-002/-003/-005; 9 LOW: PROJ-004/-006…-013). + +**Task count:** Phase A = 3 · Phase B = 9 (B0, B1, B2, B3, B4, B5a, B5b, B6, B7) + B8 integration = 10 · **13 total.** diff --git a/docs/user-stories.md b/docs/user-stories.md index 5f9ce42ea..598a01679 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -19,6 +19,7 @@ See [docs/personas/](personas/) for full persona descriptions. - [Network and Settings (NET)](#network-and-settings-net) - [Programmatic Access (MCP)](#programmatic-access-mcp) - [User Experience (UX)](#user-experience-ux) +- [Masternodes (MN)](#masternodes-mn) --- @@ -441,12 +442,12 @@ As a power user, I want to load an existing identity by its ID and owner private - Enter identity ID and private key. - Identity details are fetched and displayed. -### IDN-003: Load evonode/masternode identity [Implemented] +### IDN-003: Load evonode/masternode identity [Superseded by MN-001] **Persona:** Priya As a masternode operator, I want to load my evonode identity via protx hash so that I can manage it through the GUI. -- Enter protx hash to load the associated identity. +- Loading now happens on the dedicated [Masternodes tab](#masternodes-mn) (see MN-001); the generic "Load Existing Identity" screen's Identity Type selector offers User only, so this story's original path — loading a Masternode/Evonode from that generic screen — no longer exists. ### IDN-004: Top up identity credits [Implemented] **Persona:** Priya, Jordan @@ -1205,6 +1206,17 @@ As a user, while the app connects to and syncs the Dash chain on startup or afte - The block is scoped to *user-initiated* sync (startup auto-start / Connect): it lowers on its own when the chain becomes usable (Synced) or fails (Error), and an **ambient** reconnect or per-block catch-up afterward does not block a working user. Pressing Connect (or a fresh startup) blocks again. - This is the overlay's first real adopter (PR #863). Unlike the unsafe-to-interrupt operations in UX-001, SPV sync is **unbounded but safe to background** — so its C2 "never trap the user" guarantee is met by the always-on escape, not by operation boundedness. +### UX-003: Global wallet/identity switcher across all tabs [Implemented] +**Persona:** Alex, Priya, Jordan + +As any user, I want the same wallet/identity switcher on every page, so that I can see and change who I'm acting as without leaving the current page. + +- Every root screen renders a page-aware three-segment switcher (e.g. `Masternodes › wallet › identity`) in the top panel; segment 1 reflects and links to the active tab. +- Selecting a wallet or identity updates the app-global selection in place, with no forced navigation; pages that already consume that selection stay in sync both ways. +- The third segment is page-scoped: the app-global User identity on everyday-user pages (Dashpay, Identities, Identity Hub), or the masternode/evonode in view on the Masternodes tab. Picking a masternode there never changes the identity shown on the everyday-user pages (see MN-005's Identity Hub filter). +- On a page that does not yet consume a given pill, that pill renders dimmed with no caret; a hover tooltip explains how to change the selection elsewhere. +- A page with no identity/object context (e.g. a Wallet page) shows only the wallet pill. + ## Identities Hub (IDH) ### IDH-001: First-time identity setup [Implemented] @@ -1257,3 +1269,91 @@ As any persona, my payments, funding movements, and platform actions all live in - Activity tab shell ships with filter chips; a reusable row component for rendering timeline entries will be added once the aggregator lands. - Full aggregation across DashPay payments, funding, and platform ops depends on a backend aggregator; gated behind the `identity-hub-activity-feed` Cargo feature until implemented. + +## Masternodes (MN) + +### MN-001: Load a masternode by keys [Implemented] +**Persona:** Priya + +As a masternode operator, I want to load my masternode by its ProTxHash and DIP3 keys on a dedicated Masternodes page, so that I don't have to dig through the generic identity-load advanced options. + +- Load form collects a ProTxHash (required, hex or Base58), a Masternode/Evonode toggle, an optional local-only alias, and optional Voting/Owner/Payout private keys. +- The "Load masternode" button is disabled with an explanatory tooltip until a ProTxHash is entered; a malformed or already-loaded ProTxHash is rejected with a specific message. +- A non-blocking note explains that entered keys are stored unencrypted at rest unless an encryption password is set (see MN-006). +- On Testnet, when a local test-node fixture is present, a "Fill Random Masternode/Evonode" button autofills the form for developer testing. + +### MN-002: See my masternodes at a glance [Implemented] +**Persona:** Priya + +As a masternode operator, I want a card list of my loaded masternodes showing type, voter readiness, key status, and voting status, so that I can assess each node in seconds. + +- Each card shows a shortened ProTxHash (or alias as heading), a Masternode/Evonode type badge, voter-identity readiness ("Voting ready" / "No voting key"), a compact Voting/Owner/Payout key-status indicator, a DPNS-voting status line, and an identity status dot with a text label. +- An empty state explains what a masternode identity is for and offers a primary "Load a masternode" action when none are loaded. +- The Masternodes tab and its nav entry are visible only with Expert Mode enabled; turning Expert Mode off while the tab is active falls back to the Identities screen. + +### 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. + +- 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. + +### MN-004: Remove a masternode [Implemented] +**Persona:** Priya + +As a masternode operator, I want to remove a masternode from DET, so that I can stop tracking a node I no longer operate. + +- The detail view's "Remove masternode" action shows a confirmation dialog before proceeding. +- Confirming forgets the masternode and its associated voter identity, and the card disappears from the list. + +### MN-005: Keep the everyday surface clean [Implemented] +**Persona:** Alex, Priya + +As an everyday user, I want my Identity Hub to show only my personal identities, so that I'm never offered node-operator actions that don't apply to me. + +- Masternode/Evonode identities are filtered out of the Identity Hub picker; they still appear on the Masternodes tab. +- The legacy "Load Existing Identity" screen's Identity Type selector now offers User only — Masternode/Evonode loading lives solely on the Masternodes tab (MN-001), removing the earlier duplicate entry point. + +### MN-006: Encrypt my node keys at load time [Implemented] +**Persona:** Priya + +As a masternode operator, I want to set an optional password when I load my node, so that its private keys are encrypted at rest immediately instead of only after a separate step. + +- Leaving the load form's "Encryption password" field blank loads the node's keys unprotected (Tier-1), same as before; a password can be added later from the Key Info screen or the node's detail view. +- Entering a password seals the entered voting/owner/payout keys encrypted-at-rest (Tier-2) at load time. +- The detail view's Keys section shows the current protection tier ("Unprotected" / "Password-protected") and offers "Add password protection…" only while unprotected. + +### MN-007: Move a node's credits [Implemented] +**Persona:** Priya + +As a masternode operator, I want to withdraw, top up, and transfer a node's Platform credits from its detail view, so that I can manage its balance without leaving the Masternodes page. + +- The detail view's actions row opens the existing Withdraw, Top Up, and Transfer screens scoped to the selected node (Masternode or Evonode). +- Withdrawing with the owner key forces the destination to the node's registered Core payout address; withdrawing with the transfer/payout key allows any address. + +### MN-008: Manage a node's keys [Implemented] +**Persona:** Priya + +As a masternode operator, I want to open the key screen for a node, so that I can view a private key/WIF, sign a message, or add/remove a key. + +- The detail view's "Manage keys ›" opens the existing Key Info screen scoped to the node. +- The add-key purpose selector excludes OWNER and VOTING (Core-registered roles that cannot be added via Platform); TRANSFER/AUTHENTICATION/ENCRYPTION/DECRYPTION remain available. + +### MN-009: Claim an evonode's token rewards [Implemented] +**Persona:** Priya + +As an evonode operator, I want to jump to token-reward claiming from the node's detail view, so that I can collect rewards my evonode earned. + +- An Evonode's detail view shows "Claim token rewards ›", routing to the existing Claim Tokens screen for that identity. +- The action is hidden entirely on a plain Masternode's detail view. + +### MN-010: Keep the Masternodes tab consistent across a network switch [Implemented] +**Persona:** Priya + +As a masternode operator, I want the Masternodes tab to reset to a clean state when I switch the active network, so that I never act on a node, form, or error that belonged to the network I just left. + +- Switching networks while the Masternodes tab is on the List view (including with a filled-but-unsubmitted Load form) returns to the empty List view for the newly active network — no leftover ProTxHash/alias/key input from the previous network's form. +- Error and status banners raised on the previous network (e.g. a failed load, a disconnect notice) are cleared by the switch rather than lingering over the new network's view. +- Verified by manual walkthrough switching Testnet → Mainnet → Testnet from a dirty Load form; each switch landed cleanly on the empty List with no stale data or banners. diff --git a/src/app.rs b/src/app.rs index 4b7ed37cc..d080297be 100644 --- a/src/app.rs +++ b/src/app.rs @@ -563,6 +563,16 @@ impl AppState { let saved_network = settings.network; + // App-global Expert Mode flag: read once from config and shared into + // every per-network context (including any created later by a network + // switch), so a live toggle is observed everywhere without a restart. + let developer_mode = Arc::new(std::sync::atomic::AtomicBool::new( + crate::config::Config::load_from(&data_dir) + .ok() + .and_then(|c| c.developer_mode) + .unwrap_or(false), + )); + // Build a helper to create AppContext for a given network. let make_context = |network: Network| -> Option> { AppContext::new( @@ -574,6 +584,7 @@ impl AppState { ctx.clone(), Arc::clone(&app_kv), Arc::clone(&secret_store), + Arc::clone(&developer_mode), ) }; @@ -842,6 +853,16 @@ impl AppState { RootScreenType::RootScreenDashPayProfileSearch, Screen::DashPayProfileSearchScreen(dashpay_profile_search_screen), ), + ( + // Always registered — the Masternodes tab is gated at runtime by + // Expert Mode (the nav entry + route), not by a Cargo feature, so + // the screen must always exist to switch into when Expert Mode + // is on. Live de-gating falls back to Identities (see below). + RootScreenType::RootScreenMasternodes, + Screen::MasternodesScreen(crate::ui::masternodes::MasternodesScreen::new( + &active_context, + )), + ), ] .into_iter() .chain({ @@ -1036,6 +1057,15 @@ impl AppState { } pub fn active_root_screen_mut(&mut self) -> &mut Screen { + // Live de-gating (§10.11): if Expert Mode flipped off while the + // Masternodes tab was active, fall back to the neutral Identities tab so + // the Expert-gated screen is never shown without its gate. Identities is + // always registered, so the subsequent lookup cannot fail. + if self.selected_main_screen == RootScreenType::RootScreenMasternodes + && !self.current_app_context().is_developer_mode() + { + self.selected_main_screen = RootScreenType::RootScreenIdentities; + } self.main_screens .get_mut(&self.selected_main_screen) .expect("expected to get screen") diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index dedcc9168..89b7455df 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -425,6 +425,7 @@ mod send_payment_unsupported_options { egui::Context::default(), app_kv, secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .expect("AppContext") } diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index a7bd42057..f9c0f6936 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -16,6 +16,7 @@ use dash_sdk::dpp::consensus::state::state_error::StateError; use dash_sdk::dpp::dashcore; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::platform::Identifier; use thiserror::Error; /// Dash Core RPC error code: wallet file not specified (multi-wallet node). @@ -1070,6 +1071,32 @@ pub enum TaskError { #[error("The identifier you entered could not be read. Please check the format and try again.")] IdentifierParsingError { input: String }, + /// A masternode or evonode with this ProTxHash is already loaded. Carries + /// the resolved identity id so the caller can point the user at the + /// existing node. + #[error( + "This masternode is already loaded. Open it from the list instead of loading it again." + )] + DuplicateProTxHash { identity_id: Identifier }, + + /// The ProTxHash could not be read as a hex ProTxHash or a Base58 identity + /// id. Carries the offending input (data, not a message). + #[error( + "The ProTxHash you entered could not be read. Enter a 64-character hex ProTxHash or the \ + Base58 identity ID." + )] + MalformedProTxHash { input: String }, + + /// A syntactically valid ProTxHash resolved to no masternode or evonode on + /// the network. Carries the resolved identity id so the user can double-check + /// which value was looked up. Distinct from `IdentityNotFound` so the message + /// speaks about a masternode, matching the load form the user is in. + #[error( + "No masternode or evonode was found on the network for this ProTxHash. Check the \ + ProTxHash and try again, or confirm the node is registered on this network." + )] + MasternodeNotFound { identity_id: Identifier }, + /// The identity could not be constructed from the given parameters. #[error("Could not create the identity. Please check your input and try again.")] IdentityCreationError { @@ -1220,6 +1247,17 @@ pub enum TaskError { )] MasterKeyNotFound, + /// No withdrawal-capable key with locally-held private material was available + /// to sign the operation (Platform requires a Transfer or Owner key you control). + #[error( + "This identity does not have a Transfer or Owner key that you can sign with. \ + Open the Key Info screen for this identity, add a key whose private key you hold, then try again." + )] + NoWithdrawalSigningKey { + #[source] + source_error: Box, + }, + // ────────────────────────────────────────────────────────────────────────── // Token query errors // ────────────────────────────────────────────────────────────────────────── @@ -2479,6 +2517,13 @@ impl From for TaskError { SdkError::IdentityNonceNotFound(_) => TaskError::IdentityNonceNotFound { source_error: boxed, }, + // Raised when a withdrawal/transfer is signed with (or falls back to) + // a key whose private material the signer does not hold. + SdkError::Protocol(ProtocolError::DesiredKeyWithTypePurposeSecurityLevelMissing(_)) => { + TaskError::NoWithdrawalSigningKey { + source_error: boxed, + } + } _ => TaskError::SdkError { source_error: boxed, }, @@ -2664,6 +2709,52 @@ mod tests { )); } + #[test] + fn from_sdk_error_missing_signing_key_maps_to_no_withdrawal_signing_key() { + let sdk_err = SdkError::Protocol( + ProtocolError::DesiredKeyWithTypePurposeSecurityLevelMissing( + "specified withdrawal public key cannot be used for signing".to_string(), + ), + ); + let err = TaskError::from(sdk_err); + assert!( + matches!(err, TaskError::NoWithdrawalSigningKey { .. }), + "Expected NoWithdrawalSigningKey, got: {err:?}" + ); + } + + #[test] + fn no_withdrawal_signing_key_display_is_user_friendly() { + let sdk_err = SdkError::Protocol( + ProtocolError::DesiredKeyWithTypePurposeSecurityLevelMissing( + "specified withdrawal public key cannot be used for signing".to_string(), + ), + ); + let msg = TaskError::from(sdk_err).to_string(); + // Includes a concrete, self-serviceable next step. + assert!(msg.contains("Key Info screen"), "no action in: {msg}"); + assert!(msg.contains("try again"), "no retry cue in: {msg}"); + // No jargon and no raw SDK/protocol text leaked into the user message. + let lower = msg.to_lowercase(); + for jargon in [ + "consensus", + "sdk", + "nonce", + "rpc", + "protocol", + "securitylevel", + ] { + assert!( + !lower.contains(jargon), + "jargon '{jargon}' leaked in: {msg}" + ); + } + assert!( + !msg.contains("cannot be used for signing"), + "raw SDK text leaked in: {msg}" + ); + } + #[test] fn from_sdk_error_contract_bounds_conflict() { let contract_id = Identifier::random(); @@ -3746,6 +3837,30 @@ mod tests { ); } + /// mn-live-qa Bug 2: a masternode load that resolves to no node on chain must + /// surface a node-specific message — never the generic identity-not-found + /// copy, whose "ID or name" wording is wrong for a ProTxHash load form. + #[test] + fn masternode_not_found_message_is_node_specific() { + let node_msg = TaskError::MasternodeNotFound { + identity_id: Identifier::random(), + } + .to_string(); + assert!( + node_msg.contains("masternode"), + "Expected a masternode-specific message, got: {node_msg}" + ); + let generic_msg = TaskError::IdentityNotFound.to_string(); + assert!( + !node_msg.contains("ID or name"), + "The node message must not reuse the generic identity 'ID or name' copy: {node_msg}" + ); + assert_ne!( + node_msg, generic_msg, + "MasternodeNotFound must not reuse the IdentityNotFound message" + ); + } + #[test] fn test_identity_token_account_not_frozen_from_consensus_error() { use dash_sdk::dpp::consensus::state::token::IdentityTokenAccountNotFrozenError; diff --git a/src/backend_task/identity/load_identity.rs b/src/backend_task/identity/load_identity.rs index eedf40b9c..776a2b392 100644 --- a/src/backend_task/identity/load_identity.rs +++ b/src/backend_task/identity/load_identity.rs @@ -1,7 +1,8 @@ use super::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; -use crate::backend_task::identity::IdentityInputToLoad; +use crate::backend_task::identity::{IdentityInputToLoad, IdentityLoadMode}; use crate::context::AppContext; +use crate::model::identity_key_protection::validate_protection_password; use crate::model::key_input::verify_key_input; use crate::model::qualified_identity::PrivateKeyTarget::{ self, PrivateKeyOnMainIdentity, PrivateKeyOnVoterIdentity, @@ -38,6 +39,30 @@ use std::sync::{Arc, RwLock}; type WalletKeyMap = BTreeMap<(PrivateKeyTarget, u32), (QualifiedIdentityPublicKey, PrivateKeyData)>; type WalletMatchResult = Option<(WalletSeedHash, u32, WalletKeyMap)>; +/// Merge an already-stored identity's keys and associations into a freshly +/// built one, preserving anything the new (partial) load did not resupply +/// (§10.8, the "Add voting key" in-place update). Keys the new load provides +/// win on collision; keys it omits (e.g. Owner/Payout on a voting-key-only +/// update) are carried over from `existing` rather than lost. The existing +/// alias and identity associations are kept only when the new build lacks them. +fn merge_existing_keys_into(new: &mut QualifiedIdentity, existing: QualifiedIdentity) { + for (key, value) in existing.private_keys.private_keys { + new.private_keys.private_keys.entry(key).or_insert(value); + } + if new.alias.is_none() { + new.alias = existing.alias; + } + if new.associated_voter_identity.is_none() { + new.associated_voter_identity = existing.associated_voter_identity; + } + if new.associated_operator_identity.is_none() { + new.associated_operator_identity = existing.associated_operator_identity; + } + if new.associated_owner_key_id.is_none() { + new.associated_owner_key_id = existing.associated_owner_key_id; + } +} + impl AppContext { pub(super) async fn load_identity( &self, @@ -54,8 +79,17 @@ impl AppContext { keys_input, derive_keys_from_wallets, selected_wallet_seed_hash, + encryption_password, + load_mode, } = input; + // FR-8: validate the load-time encryption password up front, before the + // network fetch, so a too-short password fails fast. The seal path + // re-enforces the same rule authoritatively after insert. + if let Some(password) = &encryption_password { + validate_protection_password(password)?; + } + // Verify the owner private key let owner_private_key_bytes = verify_key_input(owner_private_key_input, "Owner")?; @@ -71,15 +105,63 @@ impl AppContext { { Ok(id) => id, Err(_e) => { + // For masternodes/evonodes the identity id field IS a ProTxHash + // (hex) or Base58 identity id — surface the ProTxHash-specific + // message so the user is told the exact accepted formats. + if identity_type != IdentityType::User { + return Err(TaskError::MalformedProTxHash { + input: identity_id_input, + }); + } return Err(TaskError::IdentifierParsingError { input: identity_id_input, }); } }; + // §10.9 / TC-EDGE-07: a fresh load rejects a ProTxHash already stored, + // before any network fetch — so the existing node's alias/keys/protection + // tier are never silently overwritten. Checked here, at the storage + // layer, so every `RejectIfExists` caller is guarded uniformly. + let existing_stored = self.get_local_qualified_identity(&identity_id)?; + match load_mode { + IdentityLoadMode::RejectIfExists if existing_stored.is_some() => { + return Err(TaskError::DuplicateProTxHash { identity_id }); + } + _ => {} + } + + // An in-place merge into a password-protected (Tier-2) node must + // seal the newly-supplied key Tier-2, or the plaintext key would trip + // the insert's fail-closed guard. Verify the node's object password UP + // FRONT — before the network fetch — so a wrong or headless password + // fails closed with no wasted round-trip and no partial state, mirroring + // add_key_to_identity's verify-before-broadcast / seal-after order. The + // verified password seals the merged plaintext keys just before insert. + let merge_seal_password = match (&load_mode, existing_stored.as_ref()) { + (IdentityLoadMode::MergeIntoExisting, Some(existing)) => { + match self.protected_identity_verify_scope(existing)? { + Some(verify_scope) => Some( + self.wallet_backend()? + .secret_access() + .verify_identity_object_password(&verify_scope) + .await?, + ), + None => None, + } + } + _ => None, + }; + // Fetch the identity using the SDK let identity = match Identity::fetch_by_identifier(sdk, identity_id).await { Ok(Some(identity)) => identity, + // For masternode/evonode loads the input is a ProTxHash, so surface a + // node-specific message instead of the generic identity-not-found copy + // (which talks about an "ID or name" the user never entered here). + Ok(None) if identity_type != IdentityType::User => { + return Err(TaskError::MasternodeNotFound { identity_id }); + } Ok(None) => return Err(TaskError::IdentityNotFound), Err(e) => return Err(TaskError::from(e)), }; @@ -337,7 +419,7 @@ impl AppContext { None }; - let qualified_identity = QualifiedIdentity { + let mut qualified_identity = QualifiedIdentity { identity, associated_voter_identity, associated_operator_identity: None, @@ -359,6 +441,25 @@ impl AppContext { status: IdentityStatus::Active, network: self.network, }; + // §10.8: an in-place update (the "Add voting key" fix-up) merges the + // newly-supplied keys into the already-stored identity's keys instead of + // clobbering them — the new voting key is added while the existing + // Owner/Payout keys (which the update leaves blank) survive. + if load_mode == IdentityLoadMode::MergeIntoExisting + && let Some(existing) = existing_stored + { + merge_existing_keys_into(&mut qualified_identity, existing); + } + + // When merging into a Tier-2 node, seal the newly-merged plaintext + // keys Tier-2 under the already-verified password and mark them InVault + // BEFORE the insert, so the fail-closed guard sees no resident plaintext + // on a protected identity — the same seal-before-persist add_key_to_identity + // performs. The password was verified up front, before the network fetch. + if let Some(password) = &merge_seal_password { + self.seal_merged_plaintext_keys(&mut qualified_identity, password)?; + } + let wallet_info = qualified_identity .determine_wallet_info() .map_err(|e| TaskError::WalletInfoDeterminationFailed { detail: e })?; @@ -375,9 +476,44 @@ impl AppContext { .insert(identity_index, qualified_identity.identity.clone()); } + // FR-8: when a load-time password was supplied, seal the just-inserted + // keyless keys Tier-2 through the existing per-identity protect + // envelope. `insert_local_qualified_identity` migrated the resident + // plaintext into the keyless vault, so `protect_identity_keys` + // (validate → fail-closed guard → seal via the secret_seam chokepoint) + // reloads from the DB and seals them — one path, no new crypto. + if let Some(password) = encryption_password { + self.protect_identity_keys(qualified_identity.identity.id(), password, None)?; + } + Ok(BackendTaskSuccessResult::LoadedIdentity(qualified_identity)) } + /// Seal every resident-plaintext key of `qi` Tier-2 under an + /// already-verified identity object `password`, marking each `InVault`. + /// Called on the in-place merge path when the target node is + /// password-protected, BEFORE the at-rest insert, so the fail-closed guard + /// (`encode_identity_blob_vault_first`) never sees a keyless key on a + /// protected identity. The one seal fallible write per new key is the + /// merge-path twin of `add_key_to_identity`'s post-broadcast seal. + pub(super) fn seal_merged_plaintext_keys( + &self, + qi: &mut QualifiedIdentity, + password: &crate::wallet_backend::VerifiedIdentityPassword, + ) -> Result<(), TaskError> { + let backend = self.wallet_backend()?; + let secret_access = backend.secret_access(); + let id = qi.identity.id().to_buffer(); + // `take_plaintext_for_vault` flips each Clear/AlwaysClear key to `InVault` + // and hands back its raw bytes; sealing each Tier-2 leaves the identity + // fully protected with no keyless residue. + for ((target, key_id), raw) in qi.private_keys.take_plaintext_for_vault() { + secret_access + .seal_new_identity_key_with_password(id, &target, key_id, &raw, password)?; + } + Ok(()) + } + pub(super) async fn match_user_identity_keys_with_wallet( &self, identity: &Identity, @@ -590,3 +726,542 @@ impl AppContext { .collect() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::TaskResult; + use crate::app_dir::ensure_env_file; + use crate::context::connection_status::ConnectionStatus; + use crate::database::test_helpers::create_database_at_path; + use crate::model::qualified_identity::encrypted_key_storage::KeyStorage; + use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; + use crate::model::secret::Secret; + use crate::utils::egui_mpsc::SenderAsync; + use crate::utils::tasks::TaskManager; + use crate::wallet_backend::IdentityKeyView; + use crate::wallet_backend::secret_seam::SecretScheme; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::identity::KeyID; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::IdentityPublicKey; + use platform_wallet_storage::secrets::SecretString; + + const M: PrivateKeyTarget = PrivateKeyTarget::PrivateKeyOnMainIdentity; + const V: PrivateKeyTarget = PrivateKeyTarget::PrivateKeyOnVoterIdentity; + + /// A keyless masternode-shaped identity: an owner key + an identity auth key + /// on the main identity, plus a voting key on the voter identity — the shape + /// `load_identity` builds for a Masternode. Returns the qi and its + /// `(target, key_id)` triple. + fn masternode_shaped_qi() -> (QualifiedIdentity, [(PrivateKeyTarget, KeyID); 3]) { + let pv = PlatformVersion::latest(); + let mut ks = KeyStorage::default(); + let owner = IdentityPublicKey::random_key(1, Some(1), pv); + let voter = IdentityPublicKey::random_key(2, Some(2), pv); + let id_key = IdentityPublicKey::random_key(3, Some(3), pv); + let triple = [(M, owner.id()), (V, voter.id()), (M, id_key.id())]; + ks.private_keys.insert( + (M, owner.id()), + ( + QualifiedIdentityPublicKey::from(owner), + PrivateKeyData::Clear([0xA0; 32]), + ), + ); + ks.private_keys.insert( + (V, voter.id()), + ( + QualifiedIdentityPublicKey::from(voter), + PrivateKeyData::Clear([0xB0; 32]), + ), + ); + ks.private_keys.insert( + (M, id_key.id()), + ( + QualifiedIdentityPublicKey::from(id_key), + PrivateKeyData::Clear([0xC0; 32]), + ), + ); + let identity = + Identity::create_basic_identity(Identifier::random(), pv).expect("basic identity"); + let qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::Masternode, + alias: None, + private_keys: ks, + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + }; + (qi, triple) + } + + /// TC-FR8-01/02/10 — a load-time encryption password seals ALL of a + /// masternode's keys (voting, owner, and identity auth) Tier-2 through the + /// existing per-identity protect envelope. Without a password the same keys + /// stay keyless (Tier-1) after insert. Drives the exact call + /// [`load_identity`] makes when `encryption_password` is `Some`, on an + /// offline wired `AppContext` (no network I/O). + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn load_time_password_seals_voting_owner_and_identity_keys() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let data_dir = temp_dir.path().to_path_buf(); + ensure_env_file(&data_dir); + let db = Arc::new(create_database_at_path(&data_dir.join("data.db")).expect("db")); + let app_kv = AppContext::open_app_kv(&data_dir).expect("app kv"); + let secret_store = AppContext::open_secret_store(&data_dir).expect("secret store"); + let ctx = AppContext::new( + data_dir, + Network::Testnet, + db, + Arc::new(TaskManager::new()), + Arc::new(ConnectionStatus::new()), + egui::Context::default(), + app_kv, + secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("offline testnet AppContext::new"); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + + let (qi, triple) = masternode_shaped_qi(); + let identity_id = qi.identity.id(); + // Insert migrates the resident-plaintext keys into the keyless vault + // (Tier-1), exactly as the load path does before the optional seal. + ctx.insert_local_qualified_identity(&qi, &None) + .expect("insert masternode identity"); + + let backend = ctx.wallet_backend().expect("backend wired"); + let view = IdentityKeyView::new(backend.secret_store(), identity_id.to_buffer()); + + // No-password (None) path: every key is keyless after insert. + for (t, k) in &triple { + assert_eq!( + view.scheme(t, *k).expect("scheme"), + SecretScheme::Unprotected, + "key ({t:?}, {k}) must be keyless before any load-time seal", + ); + } + + // The exact call `load_identity` makes for `encryption_password = Some`. + ctx.protect_identity_keys(identity_id, Secret::new("one-identity-password"), None) + .expect("load-time seal must succeed"); + + let pw = SecretString::new("one-identity-password"); + for (t, k) in &triple { + assert_eq!( + view.scheme(t, *k).expect("scheme"), + SecretScheme::Protected, + "key ({t:?}, {k}) must be sealed Tier-2 after the load-time password", + ); + assert!( + view.get_protected(t, *k, &pw) + .expect("get_protected") + .is_some(), + "sealed key ({t:?}, {k}) must round-trip under the password", + ); + } + + backend.shutdown().await; + } + + /// §10.8 — the testable core of the "Add voting key" in-place + /// update. A voter-key-only rebuild (blank Owner/Payout, so `associated_*` + /// and the Owner/Payout private keys are absent) MUST NOT erase the + /// already-stored Owner and Payout keys: `merge_existing_keys_into` carries + /// over every key the new partial build omitted, while the resupplied voting + /// key wins on collision. + #[test] + fn merge_preserves_owner_and_payout_when_only_voting_key_resupplied() { + // `existing`: a fully-loaded masternode (owner + voter + identity keys). + let (existing, triple) = masternode_shaped_qi(); + let [owner_key, voter_key, idkey_key] = triple; + + // `new`: what the scoped "Add voting key" prompt rebuilds — a voter key + // only. It carries the freshly-entered voting key but nothing else. + let mut new = existing.clone(); + new.alias = None; + new.associated_voter_identity = None; + new.associated_operator_identity = None; + new.associated_owner_key_id = None; + new.private_keys = KeyStorage::default(); + // Resupply ONLY the voting key, with a distinct byte so we can prove the + // new value wins on collision. + let (voter_pk, _) = existing + .private_keys + .private_keys + .get(&voter_key) + .expect("existing voter key") + .clone(); + new.private_keys.private_keys.insert( + voter_key.clone(), + (voter_pk, PrivateKeyData::Clear([0xEE; 32])), + ); + + merge_existing_keys_into(&mut new, existing); + + // Owner and identity-auth keys survive the voter-key-only update. + assert!( + new.private_keys.private_keys.contains_key(&owner_key), + "owner key must survive a voting-key-only update", + ); + assert!( + new.private_keys.private_keys.contains_key(&idkey_key), + "identity-auth key must survive a voting-key-only update", + ); + // The resupplied voting key wins on collision (0xEE, not the old 0xB0). + let (_, merged_voter) = new + .private_keys + .private_keys + .get(&voter_key) + .expect("voter key present after merge"); + assert!( + matches!(merged_voter, PrivateKeyData::Clear(b) if *b == [0xEE; 32]), + "the resupplied voting key must win on collision", + ); + } + + /// §10.9 / TC-EDGE-07 — a fresh load (`RejectIfExists`) of a + /// ProTxHash already stored is rejected with [`TaskError::DuplicateProTxHash`] + /// BEFORE any network fetch, and the already-stored node is left untouched. + /// Runs fully offline: the existence check fires before the SDK is used. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn reject_if_exists_rejects_duplicate_pro_tx_hash_offline() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let data_dir = temp_dir.path().to_path_buf(); + ensure_env_file(&data_dir); + let db = Arc::new(create_database_at_path(&data_dir.join("data.db")).expect("db")); + let app_kv = AppContext::open_app_kv(&data_dir).expect("app kv"); + let secret_store = AppContext::open_secret_store(&data_dir).expect("secret store"); + let ctx = AppContext::new( + data_dir, + Network::Testnet, + db, + Arc::new(TaskManager::new()), + Arc::new(ConnectionStatus::new()), + egui::Context::default(), + app_kv, + secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("offline testnet AppContext::new"); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + + let (qi, triple) = masternode_shaped_qi(); + let identity_id = qi.identity.id(); + ctx.insert_local_qualified_identity(&qi, &None) + .expect("insert first masternode identity"); + + let input = IdentityInputToLoad { + identity_id_input: identity_id.to_string(Encoding::Hex), + identity_type: IdentityType::Masternode, + alias_input: String::new(), + voting_private_key_input: Secret::new(""), + owner_private_key_input: Secret::new(""), + payout_address_private_key_input: Secret::new(""), + keys_input: vec![], + derive_keys_from_wallets: false, + selected_wallet_seed_hash: None, + encryption_password: None, + load_mode: IdentityLoadMode::RejectIfExists, + }; + + let sdk = ctx.sdk(); + let result = ctx.load_identity(&sdk, input).await; + match result { + Err(TaskError::DuplicateProTxHash { identity_id: got }) => { + assert_eq!(got, identity_id, "reject must name the duplicate id"); + } + other => panic!("expected DuplicateProTxHash, got {other:?}"), + } + + // The first node's stored keys are untouched by the rejected load. + let still = ctx + .get_local_qualified_identity(&identity_id) + .expect("read stored identity") + .expect("first node still stored"); + for (t, k) in &triple { + assert!( + still + .private_keys + .private_keys + .contains_key(&(t.clone(), *k)), + "key ({t:?}, {k}) of the first node must survive a rejected duplicate load", + ); + } + + ctx.wallet_backend().expect("backend").shutdown().await; + } + + /// Merge×Tier-2 (success path) — merging a new key into a password-protected + /// (Tier-2) node seals the new key Tier-2 *before* the at-rest insert, so + /// the fail-closed guard (`encode_identity_blob_vault_first`) never rejects + /// it. Drives the exact merge-seal step `load_identity` runs: seed a Tier-2 + /// masternode, add a resident-plaintext voting key (as the merge produces), + /// verify the object password through the app prompt, then + /// `seal_merged_plaintext_keys`. The new key must flip to `InVault`, insert + /// cleanly (no `IdentityKeyProtectionDowngrade`), and read back `Protected`. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn merge_into_tier2_node_seals_new_key_and_insert_succeeds() { + use crate::wallet_backend::secret_prompt::test_support::{ScriptedAnswer, TestPrompt}; + + const PW: &str = "one-identity-password"; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let data_dir = temp_dir.path().to_path_buf(); + ensure_env_file(&data_dir); + let db = Arc::new(create_database_at_path(&data_dir.join("data.db")).expect("db")); + let app_kv = AppContext::open_app_kv(&data_dir).expect("app kv"); + let secret_store = AppContext::open_secret_store(&data_dir).expect("secret store"); + let ctx = AppContext::new( + data_dir, + Network::Testnet, + db, + Arc::new(TaskManager::new()), + Arc::new(ConnectionStatus::new()), + egui::Context::default(), + app_kv, + secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("offline testnet AppContext::new"); + // The scoped merge prompt asks for the node's object password once. + ctx.install_secret_prompt(Arc::new(TestPrompt::new([ScriptedAnswer::once(PW)]))); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + + // Seed a masternode and seal all its keys Tier-2. + let (qi, _triple) = masternode_shaped_qi(); + let identity_id = qi.identity.id(); + ctx.insert_local_qualified_identity(&qi, &None) + .expect("insert masternode identity"); + ctx.protect_identity_keys(identity_id, Secret::new(PW), None) + .expect("seal Tier-2"); + + // Reload the sealed node: every key is now InVault. + let mut existing = ctx + .get_local_qualified_identity(&identity_id) + .expect("read stored identity") + .expect("node stored"); + + // Simulate the merge product: a freshly-supplied resident-plaintext + // voting key on a new key id (what `merge_existing_keys_into` yields). + let pv = PlatformVersion::latest(); + let new_voter = IdentityPublicKey::random_key(9, Some(9), pv); + let new_voter_id = new_voter.id(); + let new_key = (V, new_voter_id); + existing.private_keys.private_keys.insert( + new_key.clone(), + ( + QualifiedIdentityPublicKey::from(new_voter), + PrivateKeyData::Clear([0xDD; 32]), + ), + ); + + // Verify the node's object password up front (as the load path does), + // then seal the merged plaintext key Tier-2 before insert. + let verify_scope = ctx + .protected_identity_verify_scope(&existing) + .expect("verify scope lookup") + .expect("node is Tier-2, so a verify scope exists"); + let password = ctx + .wallet_backend() + .expect("backend wired") + .secret_access() + .verify_identity_object_password(&verify_scope) + .await + .expect("scripted password verifies"); + ctx.seal_merged_plaintext_keys(&mut existing, &password) + .expect("seal merged plaintext key"); + + // The new key flipped to InVault in the in-memory identity... + assert!( + matches!( + existing.private_keys.private_keys.get(&new_key), + Some((_, PrivateKeyData::InVault)), + ), + "the merged voting key must be marked InVault after sealing", + ); + + // ...the at-rest insert now passes the fail-closed guard... + ctx.insert_local_qualified_identity(&existing, &None) + .expect("insert of a Tier-2 node with a sealed new key must succeed"); + + // ...and the new key reads back as a Tier-2 (Protected) sealed secret. + let backend = ctx.wallet_backend().expect("backend wired"); + let view = IdentityKeyView::new(backend.secret_store(), identity_id.to_buffer()); + assert_eq!( + view.scheme(&V, new_voter_id).expect("scheme"), + SecretScheme::Protected, + "the merged voting key must be sealed Tier-2", + ); + assert!( + view.get_protected(&V, new_voter_id, &SecretString::new(PW)) + .expect("get_protected") + .is_some(), + "the sealed voting key must round-trip under the object password", + ); + + backend.shutdown().await; + } + + /// Merge×Tier-2 (headless fail-closed) — a `MergeIntoExisting` load into a Tier-2 + /// node with no interactive prompt (the default `NullSecretPrompt`) fails + /// closed with [`TaskError::SecretPromptUnavailable`] and — critically — + /// BEFORE the network fetch, because the object password is verified up + /// front. No prompt means no way to seal the merged key, so the load is + /// rejected rather than silently dropping to a keyless downgrade. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn merge_into_tier2_node_headless_fails_closed_before_fetch() { + const PW: &str = "one-identity-password"; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let data_dir = temp_dir.path().to_path_buf(); + ensure_env_file(&data_dir); + let db = Arc::new(create_database_at_path(&data_dir.join("data.db")).expect("db")); + let app_kv = AppContext::open_app_kv(&data_dir).expect("app kv"); + let secret_store = AppContext::open_secret_store(&data_dir).expect("secret store"); + let ctx = AppContext::new( + data_dir, + Network::Testnet, + db, + Arc::new(TaskManager::new()), + Arc::new(ConnectionStatus::new()), + egui::Context::default(), + app_kv, + secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("offline testnet AppContext::new"); + // No prompt installed: the default NullSecretPrompt fails closed. + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + + // Seed a Tier-2 masternode. + let (qi, _triple) = masternode_shaped_qi(); + let identity_id = qi.identity.id(); + ctx.insert_local_qualified_identity(&qi, &None) + .expect("insert masternode identity"); + ctx.protect_identity_keys(identity_id, Secret::new(PW), None) + .expect("seal Tier-2"); + + let input = IdentityInputToLoad { + identity_id_input: identity_id.to_string(Encoding::Hex), + identity_type: IdentityType::Masternode, + alias_input: String::new(), + voting_private_key_input: Secret::new(""), + owner_private_key_input: Secret::new(""), + payout_address_private_key_input: Secret::new(""), + keys_input: vec![], + derive_keys_from_wallets: false, + selected_wallet_seed_hash: None, + encryption_password: None, + load_mode: IdentityLoadMode::MergeIntoExisting, + }; + + // The verify happens before the SDK fetch, so this resolves offline. + let sdk = ctx.sdk(); + let result = ctx.load_identity(&sdk, input).await; + assert!( + matches!(result, Err(TaskError::SecretPromptUnavailable)), + "a headless merge into a Tier-2 node must fail closed, got {result:?}", + ); + + // The stored node is untouched — still fully Tier-2. + let backend = ctx.wallet_backend().expect("backend wired"); + let view = IdentityKeyView::new(backend.secret_store(), identity_id.to_buffer()); + assert_eq!( + view.scheme(&M, 1).expect("scheme"), + SecretScheme::Protected, + "a rejected headless merge must leave the node's keys sealed", + ); + + backend.shutdown().await; + } + + /// A malformed identity-id input surfaces the ProTxHash-specific + /// [`TaskError::MalformedProTxHash`] for masternode/evonode loads (where the + /// field IS a ProTxHash), and the generic [`TaskError::IdentifierParsingError`] + /// for User loads — both offline, at the parse arm, before any network fetch. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn malformed_id_routes_to_pro_tx_hash_error_for_nodes_only() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let data_dir = temp_dir.path().to_path_buf(); + ensure_env_file(&data_dir); + let db = Arc::new(create_database_at_path(&data_dir.join("data.db")).expect("db")); + let app_kv = AppContext::open_app_kv(&data_dir).expect("app kv"); + let secret_store = AppContext::open_secret_store(&data_dir).expect("secret store"); + let ctx = AppContext::new( + data_dir, + Network::Testnet, + db, + Arc::new(TaskManager::new()), + Arc::new(ConnectionStatus::new()), + egui::Context::default(), + app_kv, + secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("offline testnet AppContext::new"); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + + let make_input = |identity_type| IdentityInputToLoad { + identity_id_input: "not-a-valid-identifier".to_string(), + identity_type, + alias_input: String::new(), + voting_private_key_input: Secret::new(""), + owner_private_key_input: Secret::new(""), + payout_address_private_key_input: Secret::new(""), + keys_input: vec![], + derive_keys_from_wallets: false, + selected_wallet_seed_hash: None, + encryption_password: None, + load_mode: IdentityLoadMode::Overwrite, + }; + + let sdk = ctx.sdk(); + let node_result = ctx + .load_identity(&sdk, make_input(IdentityType::Masternode)) + .await; + assert!( + matches!(node_result, Err(TaskError::MalformedProTxHash { .. })), + "a masternode load with a malformed id must report MalformedProTxHash, got {node_result:?}", + ); + + let user_result = ctx + .load_identity(&sdk, make_input(IdentityType::User)) + .await; + assert!( + matches!(user_result, Err(TaskError::IdentifierParsingError { .. })), + "a User load with a malformed id must report IdentifierParsingError, got {user_result:?}", + ); + + ctx.wallet_backend().expect("backend").shutdown().await; + } +} diff --git a/src/backend_task/identity/mod.rs b/src/backend_task/identity/mod.rs index aa3451134..54d9daf32 100644 --- a/src/backend_task/identity/mod.rs +++ b/src/backend_task/identity/mod.rs @@ -39,6 +39,29 @@ use dash_sdk::platform::{Identifier, Identity, IdentityPublicKey}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, RwLock}; +/// How a load resolves against an already-stored identity of the same id. +/// +/// The storage layer (`insert_local_qualified_identity`) is `INSERT OR REPLACE`, +/// so a load with no guard silently overwrites an existing record and its keys. +/// This enum lets each entry point declare its intent so the two masternode +/// paths — a *new* load (must reject a duplicate ProTxHash, §10.9) and an +/// *in-place* voter-key update (must merge, not clobber, §10.8) — never share +/// one blind-overwrite path. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum IdentityLoadMode { + /// Overwrite any existing stored identity (legacy behaviour; the User + /// re-load and headless flows that always resubmit their full key set). + #[default] + Overwrite, + /// A fresh load: reject with [`TaskError::DuplicateProTxHash`] when an + /// identity with this id is already stored (§10.9 / TC-EDGE-07). + RejectIfExists, + /// An in-place update: merge the newly-supplied keys into the existing + /// stored identity's keys, preserving keys the caller did not resupply + /// (§10.8 / the "Add voting key" fix-up). Exempt from duplicate rejection. + MergeIntoExisting, +} + #[derive(Debug, Clone, PartialEq)] pub struct IdentityInputToLoad { pub identity_id_input: String, @@ -50,6 +73,14 @@ pub struct IdentityInputToLoad { pub keys_input: Vec, pub derive_keys_from_wallets: bool, pub selected_wallet_seed_hash: Option, + /// Optional load-time key encryption (FR-8). When `Some`, the loaded + /// voting/owner/payout and identity keys are sealed Tier-2 under this + /// password at load time through the existing per-identity protect + /// envelope (Argon2id + XChaCha20-Poly1305) — no new crypto, no second + /// persistence path. When `None`, the keyless Tier-1 path is unchanged. + pub encryption_password: Option, + /// How this load resolves against an already-stored identity of the same id. + pub load_mode: IdentityLoadMode, } /// One chosen identity key, public-only. diff --git a/src/backend_task/identity/protect_identity_keys.rs b/src/backend_task/identity/protect_identity_keys.rs index 9a333d4e1..b4cced4d3 100644 --- a/src/backend_task/identity/protect_identity_keys.rs +++ b/src/backend_task/identity/protect_identity_keys.rs @@ -19,11 +19,11 @@ use platform_wallet_storage::secrets::SecretString; use super::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; +use crate::model::identity_key_protection::validate_protection_password; use crate::model::qualified_identity::encrypted_key_storage::KeyStorage; use crate::model::qualified_identity::identity_meta::IdentityMeta; use crate::model::qualified_identity::{PrivateKeyTarget, QualifiedIdentity}; use crate::model::secret::Secret; -use crate::model::wallet::passphrase::validate_single_key_passphrase; use crate::wallet_backend::IdentityKeyView; use crate::wallet_backend::secret_seam::SecretScheme; @@ -148,16 +148,6 @@ impl AppContext { } } -/// Backend-authoritative password policy for identity-key protection. -/// Re-uses the single-key passphrase validator (the same minimum length the UI -/// shows) so the rule lives in one place and a non-UI caller cannot bypass it. -/// The confirmation match is a UI concern, so the password is passed as its own -/// confirmation here — only the length check is meaningful at this layer. -fn validate_protection_password(password: &Secret) -> Result<(), TaskError> { - let pw = password.expose_secret(); - validate_single_key_passphrase(pw, pw).map_err(TaskError::from) -} - /// Fail-closed guard for the protect boundary: reject an identity that /// still carries resident plaintext (`Clear`/`AlwaysClear`) keys on disk. Such a /// key means the eager load-path vault migration did not complete — its vault @@ -731,6 +721,7 @@ mod tests { egui::Context::default(), app_kv, secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .expect("offline testnet AppContext::new"); let (tx, _rx) = tokio::sync::mpsc::channel::(32); @@ -801,6 +792,7 @@ mod tests { egui::Context::default(), app_kv, secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .expect("offline testnet AppContext::new"); let (tx, _rx) = tokio::sync::mpsc::channel::(32); diff --git a/src/backend_task/migration/finish_unwire.rs b/src/backend_task/migration/finish_unwire.rs index c607991dd..edc7cad62 100644 --- a/src/backend_task/migration/finish_unwire.rs +++ b/src/backend_task/migration/finish_unwire.rs @@ -2880,6 +2880,7 @@ mod tests { egui::Context::default(), app_kv, secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .expect("AppContext") } diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index ede9ce61b..e138476c6 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -624,6 +624,10 @@ impl AppContext { let egui_ctx = self.egui_ctx().clone(); let app_kv = self.app_kv(); let secret_store = self.secret_store(); + // Share the app-global Expert Mode flag so the freshly-switched + // context observes the same value (and live toggles) as the rest + // of the app — never a fresh per-context flag. + let developer_mode = self.developer_mode_handle(); let new_ctx = tokio::task::block_in_place(|| { AppContext::new( data_dir, @@ -634,6 +638,7 @@ impl AppContext { egui_ctx, app_kv, secret_store, + developer_mode, ) }) .ok_or(TaskError::NetworkContextCreationFailed { network })?; diff --git a/src/backend_task/wallet/generate_receive_address.rs b/src/backend_task/wallet/generate_receive_address.rs index 96a1abf46..babe68793 100644 --- a/src/backend_task/wallet/generate_receive_address.rs +++ b/src/backend_task/wallet/generate_receive_address.rs @@ -55,6 +55,7 @@ mod tests { egui::Context::default(), app_kv, secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .expect("offline testnet AppContext::new"); diff --git a/src/bin/det_cli/connect.rs b/src/bin/det_cli/connect.rs index df8c7f81a..3824f953f 100644 --- a/src/bin/det_cli/connect.rs +++ b/src/bin/det_cli/connect.rs @@ -89,7 +89,10 @@ pub(super) async fn connect_http( let mut config = StreamableHttpClientTransportConfig::with_uri(addr); if let Some(token) = bearer { - config = config.auth_header(format!("Bearer {token}")); + // rmcp's `auth_header` takes the raw token and prepends `Bearer ` itself + // (via reqwest's `bearer_auth`). Passing a pre-prefixed value would put + // `Bearer Bearer ` on the wire and fail server-side auth. + config = config.auth_header(token.to_string()); } let transport = StreamableHttpClientTransport::from_config(config); let client = ().serve(transport).await?; diff --git a/src/context/contested_names_db.rs b/src/context/contested_names_db.rs index d729257d3..44630a9d9 100644 --- a/src/context/contested_names_db.rs +++ b/src/context/contested_names_db.rs @@ -192,6 +192,39 @@ impl AppContext { Ok(out) } + /// Summarise a masternode/evonode node's DPNS voting position for its card. + /// + /// `voter_id` is the node's voter-identity id (`associated_voter_identity`); + /// pass `None` for a node with no voting key loaded — it can vote on + /// nothing, so the summary is empty. The open count reads the ongoing + /// contest cache and the scheduled-vote flag reuses the existing DPNS + /// Scheduled Votes state (no new backend concept — §10.1). + pub fn masternode_contest_summary( + &self, + voter_id: Option, + ) -> std::result::Result + { + let Some(voter_id) = voter_id else { + return Ok(crate::model::contested_name::MasternodeContestSummary::default()); + }; + + let open_contest_count = self + .ongoing_contested_names()? + .iter() + .filter(|contest| contest.is_open_for_voter(&voter_id)) + .count(); + + let has_scheduled_vote = self + .get_scheduled_votes()? + .iter() + .any(|vote| vote.voter_id == voter_id && !vote.executed_successfully); + + Ok(crate::model::contested_name::MasternodeContestSummary { + open_contest_count, + has_scheduled_vote, + }) + } + /// Apply a batch of newly-seen normalized names. New names are stored /// as empty contest skeletons; existing names whose `last_updated` is /// older than 30 s are returned alongside new names for the caller to diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index 0539db2a5..d70e8c3d2 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -499,11 +499,23 @@ impl AppContext { let (wallet_hash, wallet_index) = match wallet_and_identity_id_info { Some((seed, idx)) => (Some(*seed), Some(*idx)), None => { - tracing::warn!( - identity_id = %qualified_identity.identity.id(), - alias = ?qualified_identity.alias, - "saving identity without wallet; this needs investigating", - ); + // Masternodes and evonodes are loaded by ProTxHash and have no + // associated HD wallet by design, so a missing wallet is normal + // for them — only a wallet-less User identity is worth flagging. + if qualified_identity.identity_type == IdentityType::User { + tracing::warn!( + identity_id = %qualified_identity.identity.id(), + alias = ?qualified_identity.alias, + "saving identity without wallet; this needs investigating", + ); + } else { + tracing::debug!( + identity_id = %qualified_identity.identity.id(), + alias = ?qualified_identity.alias, + identity_type = ?qualified_identity.identity_type, + "saving masternode/evonode identity without wallet (expected)", + ); + } (None, None) } }; @@ -634,6 +646,55 @@ impl AppContext { }) } + /// The masternode/evonode identities for the active network — the + /// Masternodes-page card list and the page-scoped masternode pill source. + /// The complement of [`Self::load_local_user_identities`] over the FR-6 type + /// boundary. Filters the hydrated full load, so each card's top-up history + /// is available (unlike the pre-decode [`Self::load_local_voting_identities`], + /// which is un-hydrated and named for the DPNS voting flows). + pub fn load_local_masternode_identities( + &self, + ) -> std::result::Result, TaskError> { + Ok(self + .load_local_qualified_identities()? + .into_iter() + .filter(|qi| { + matches!( + qi.identity_type, + IdentityType::Masternode | IdentityType::Evonode + ) + }) + .collect()) + } + + /// Read one stored qualified identity by id, hydrated like the list loads + /// (status, wallet index, network, wallets, secret access). `None` when no + /// identity with `id` is stored. Backs the load-path existence check + /// (duplicate-ProTxHash rejection) and the in-place voter-key merge. + pub fn get_local_qualified_identity( + &self, + id: &Identifier, + ) -> std::result::Result, TaskError> { + let kv = self.det_kv()?; + let id_buf = id.to_buffer(); + let Some(stored) = kv + .get::(DetScope::Identity(&id_buf), IDENTITY_KEY) + .map_err(|source| TaskError::IdentityStorage { source })? + else { + return Ok(None); + }; + let wallets = self.wallets.read().unwrap_or_else(|e| e.into_inner()); + let mut qi = decode_stored_identity(&stored.qi_bytes, self.network)?; + qi.status = IdentityStatus::from_u8(stored.status); + qi.wallet_index = stored.wallet_index; + qi.network = self.network; + qi.associated_wallets = wallets.clone(); + qi.secret_access = self.wallet_backend().ok().map(|b| b.secret_access()); + qi.top_ups = BTreeMap::new(); + self.migrate_identity_keys_to_vault(&kv, &id_buf, &mut qi); + Ok(Some(qi)) + } + /// Internal: read every stored identity via the Global enumeration /// index, decode it, rehydrate the metadata kept outside the bincode /// blob, and apply `keep` as a pre-decode filter on the wrapper. @@ -1716,6 +1777,7 @@ mod tests { egui::Context::default(), app_kv, secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .expect("offline testnet AppContext::new"); let (tx, _rx) = tokio::sync::mpsc::channel::(32); @@ -1845,9 +1907,9 @@ mod tests { } /// The at-rest encode path REFUSES to write a new keyless - /// key onto a password-protected identity (the silent-plaintext leak Smythe - /// found). The encode fails closed and the new key lands NOWHERE — not - /// keyless, not Tier-2. + /// key onto a password-protected identity (a silent-plaintext leak). The + /// encode fails closed and the new key lands NOWHERE — not keyless, not + /// Tier-2. #[test] fn encode_refuses_keyless_key_on_protected_identity() { use crate::wallet_backend::secret_seam::SecretScheme; diff --git a/src/context/mod.rs b/src/context/mod.rs index 606c0efbe..2ea70035e 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -14,7 +14,7 @@ use crate::context::feature_gate::FeatureGate; use crate::context_provider::SpvProvider; use crate::database::Database; use crate::model::fee_estimation::PlatformFeeEstimator; -use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::qualified_identity::{IdentityType, QualifiedIdentity}; use crate::model::request_type::RequestType; use crate::model::wallet::single_key::{SingleKeyHash, SingleKeyWallet}; use crate::model::wallet::{PlatformAddressEntry, PlatformAddressUpdates, Wallet, WalletSeedHash}; @@ -62,7 +62,13 @@ pub(crate) type SettingsCacheGuard<'a> = RwLockWriteGuard<'a, Option` is created once by + /// `AppState` and shared into every per-network `AppContext`, so toggling it + /// on any context is observed by all of them (present and lazily created on a + /// later network switch). Never a per-context `AtomicBool` — that would let + /// the left-nav feature gate read a stale value on whichever context the app + /// renders from. + developer_mode: Arc, pub(crate) db: Arc, pub(crate) sdk: ArcSwap, // SDK context provider (quorum keys via DAPI). Chain sync is SPV-only, @@ -220,6 +226,7 @@ impl AppContext { egui_ctx: egui::Context, app_kv: Arc, secret_store: Arc, + developer_mode: Arc, ) -> Option> { let config = match Config::load_from(&data_dir) { Ok(config) => config, @@ -310,7 +317,7 @@ impl AppContext { let single_key_wallets: BTreeMap>> = BTreeMap::new(); - let developer_mode_enabled = config.developer_mode.unwrap_or(false); + let developer_mode_enabled = developer_mode.load(Ordering::Relaxed); let animate = match developer_mode_enabled { true => { @@ -331,7 +338,7 @@ impl AppContext { let app_context = AppContext { data_dir, network, - developer_mode: AtomicBool::new(developer_mode_enabled), + developer_mode, db, sdk: ArcSwap::from_pointee(sdk), spv_context_provider: spv_provider.into(), @@ -739,6 +746,12 @@ impl AppContext { self.developer_mode.load(Ordering::Relaxed) } + /// A clone of the shared app-global Expert Mode flag, for wiring a + /// newly-created per-network context to the same flag (see the field docs). + pub fn developer_mode_handle(&self) -> Arc { + Arc::clone(&self.developer_mode) + } + /// Repaints the UI if animations are enabled. /// /// Called by UI elements that need to trigger a repaint, such as loading spinners or animated icons. @@ -1080,11 +1093,26 @@ impl AppContext { /// Resolve the active identity every operate-as read uses: the selected /// identity when still loaded, else the first loaded identity, else `None`. + /// + /// FR-6 boundary (resolution layer): the app-global operate-as identity is + /// **always** a User identity. The candidate set is filtered to + /// [`IdentityType::User`] before resolving, so neither the keep-if-loaded + /// check nor the first-loaded fallback can ever resolve a masternode/evonode + /// — even when a masternode is the only or first loaded identity, or was + /// persisted as the selection in a prior session. Masternode/evonode + /// identities are page-scoped (the Masternodes page), never the app-global + /// identity. pub fn resolve_selected_identity(&self) -> Option { let identities = self.load_local_qualified_identities().ok()?; - let ids: Vec = identities.iter().map(|qi| qi.identity.id()).collect(); - let chosen = - crate::model::selected_identity::resolve_selected(self.selected_identity_id(), &ids)?; + let user_ids: Vec = identities + .iter() + .filter(|qi| qi.identity_type == IdentityType::User) + .map(|qi| qi.identity.id()) + .collect(); + let chosen = crate::model::selected_identity::resolve_selected( + self.selected_identity_id(), + &user_ids, + )?; identities.into_iter().find(|qi| qi.identity.id() == chosen) } @@ -1143,9 +1171,17 @@ impl AppContext { } let reconciled = match hash { Some(h) => { + // FR-6 boundary: reconcile only over the wallet's User identities + // so a masternode/evonode is never resolved as the app-global + // identity via this cross-axis wallet-switch side effect. let ids: Vec = self .load_local_qualified_identities_for_wallet(&h) - .map(|v| v.iter().map(|qi| qi.identity.id()).collect()) + .map(|v| { + v.iter() + .filter(|qi| qi.identity_type == IdentityType::User) + .map(|qi| qi.identity.id()) + .collect() + }) .unwrap_or_default(); crate::model::selected_identity::resolve_selected(self.selected_identity_id(), &ids) } @@ -1212,11 +1248,23 @@ impl AppContext { return; }; let stored = backend.get_selected_identity().identity_id; - let loaded: Vec = self + // FR-6 one-time sanitization: masternodes were Hub-pickable in prior + // sessions, so a persisted `selected_identity_id` may point at a + // masternode/evonode. The app-global identity must always be a User + // identity, so keep the persisted selection only if it is a still-loaded + // User identity — otherwise clear it (the count-based hub default takes + // over). In-memory only, matching the non-destructive restore contract: + // a transient empty load leaves the KV blob untouched for the next pass. + let user_ids: Vec = self .load_local_qualified_identities() - .map(|v| v.iter().map(|qi| qi.identity.id()).collect()) + .map(|v| { + v.iter() + .filter(|qi| qi.identity_type == IdentityType::User) + .map(|qi| qi.identity.id()) + .collect() + }) .unwrap_or_default(); - let kept = crate::model::selected_identity::keep_if_loaded(stored, &loaded); + let kept = crate::model::selected_identity::keep_if_loaded(stored, &user_ids); if let Ok(mut g) = self.selected_identity_id.lock() { *g = kept; } @@ -1299,6 +1347,8 @@ pub(crate) const fn default_platform_version(_network: &Network) -> &'static Pla #[cfg(test)] mod tests { + use super::*; + #[test] fn wallet_name_with_spaces_is_url_encoded() { let base = "http://127.0.0.1:9998"; @@ -1308,4 +1358,251 @@ mod tests { assert_eq!(url, "http://127.0.0.1:9998/wallet/my%20test%20wallet"); assert!(!url.contains(' ')); } + + // ── FR-6 resolution-layer boundary (B1) ────────────────────────────────── + + /// Build an offline, wired `AppContext` (no network I/O) so the identity + /// store is a real, writable DB the accessors read from. Returns the temp + /// dir (kept alive by the caller) alongside the context. + async fn offline_ctx() -> (tempfile::TempDir, std::sync::Arc) { + 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 db = + std::sync::Arc::new(create_database_at_path(&data_dir.join("data.db")).expect("db")); + let app_kv = AppContext::open_app_kv(&data_dir).expect("app kv"); + let secret_store = AppContext::open_secret_store(&data_dir).expect("secret store"); + let ctx = AppContext::new( + data_dir, + dash_sdk::dpp::dashcore::Network::Testnet, + db, + std::sync::Arc::new(TaskManager::new()), + std::sync::Arc::new(ConnectionStatus::new()), + egui::Context::default(), + app_kv, + secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("offline testnet AppContext::new"); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + (temp_dir, ctx) + } + + /// Regression (mn-live-qa Bug 1): `developer_mode` is a single app-global + /// flag shared by every per-network `AppContext`. Toggling it on the context + /// for one network must be observable from the context for another — + /// otherwise the left-nav feature gate (`FeatureGate::DeveloperMode`) reads a + /// stale value on whichever per-network context the app renders from, and the + /// Expert-Mode-gated Masternodes tab never appears until an app restart + /// re-reads the persisted flag from config. + #[test] + fn developer_mode_is_shared_across_network_contexts() { + 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::tasks::TaskManager; + use dash_sdk::dpp::dashcore::Network; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let data_dir = temp_dir.path().to_path_buf(); + ensure_env_file(&data_dir); + let db = + std::sync::Arc::new(create_database_at_path(&data_dir.join("data.db")).expect("db")); + let app_kv = AppContext::open_app_kv(&data_dir).expect("app kv"); + let secret_store = AppContext::open_secret_store(&data_dir).expect("secret store"); + let subtasks = std::sync::Arc::new(TaskManager::new()); + let connection_status = std::sync::Arc::new(ConnectionStatus::new()); + let egui_ctx = egui::Context::default(); + // A single app-global developer-mode flag, owned by `AppState` and shared + // into every per-network context (mirrors the real construction path). + let developer_mode = std::sync::Arc::new(AtomicBool::new(false)); + + // The startup context (Mainnet) and a second context (Testnet) built the + // way `AppState` builds one on a live network switch — reusing the shared + // db / kv / secret store / developer-mode flag. + let mainnet = AppContext::new( + data_dir.clone(), + Network::Mainnet, + db.clone(), + subtasks.clone(), + connection_status.clone(), + egui_ctx.clone(), + app_kv.clone(), + secret_store.clone(), + developer_mode.clone(), + ) + .expect("mainnet AppContext::new"); + let testnet = AppContext::new( + data_dir, + Network::Testnet, + db, + subtasks, + connection_status, + egui_ctx, + app_kv, + secret_store, + developer_mode, + ) + .expect("testnet AppContext::new"); + + assert!(!mainnet.is_developer_mode()); + assert!(!testnet.is_developer_mode()); + + // Toggle Expert Mode on ONE context, exactly as the Settings checkbox does. + mainnet.enable_developer_mode(true); + + assert!( + testnet.is_developer_mode(), + "developer mode toggled on one network's context must be visible on \ + another network's context" + ); + } + + /// Seed one wallet-less identity of `identity_type` into the live identity + /// DB and return its id. + fn seed_typed(ctx: &AppContext, byte: u8, identity_type: IdentityType) -> Identifier { + use crate::model::qualified_identity::IdentityStatus; + use crate::model::qualified_identity::encrypted_key_storage::KeyStorage; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + use dash_sdk::dpp::version::PlatformVersion; + + let pv = PlatformVersion::latest(); + let identity = Identity::create_basic_identity(Identifier::from([byte; 32]), pv) + .expect("basic identity"); + let id = identity.id(); + let qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type, + alias: Some(format!("seed-{byte:02x}")), + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: std::collections::BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: std::collections::BTreeMap::new(), + status: IdentityStatus::PendingCreation, + network: ctx.network(), + }; + ctx.insert_local_qualified_identity(&qi, &None) + .expect("seed identity insert"); + id + } + + /// TC-FR6-01…06 + TC-NAV-12b — the User-only / masternode accessors split by + /// type, and `resolve_selected_identity()` never resolves a masternode: not + /// via keep-if-loaded, and not via the first-loaded fallback even when a + /// masternode is the only/first loaded identity. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn fr6_accessors_and_resolution_filter_exclude_masternodes() { + let (_dir, ctx) = offline_ctx().await; + + // TC-NAV-12b: only a masternode loaded, nothing selected → resolve is + // None, never the masternode via the first-loaded fallback. + let mn = seed_typed(&ctx, 0x91, IdentityType::Masternode); + assert_eq!(ctx.selected_identity_id(), None, "nothing selected"); + assert!( + ctx.resolve_selected_identity().is_none(), + "a lone masternode must never resolve as the app-global identity" + ); + assert_eq!( + ctx.load_local_masternode_identities().unwrap().len(), + 1, + "the masternode accessor lists the masternode" + ); + assert!( + ctx.load_local_user_identities().unwrap().is_empty(), + "the User accessor excludes the masternode" + ); + + // Add an Evonode and a User. + let evo = seed_typed(&ctx, 0xE2, IdentityType::Evonode); + let user = seed_typed(&ctx, 0x71, IdentityType::User); + + let user_ids: Vec = ctx + .load_local_user_identities() + .unwrap() + .iter() + .map(|qi| qi.identity.id()) + .collect(); + assert_eq!(user_ids, vec![user], "User accessor lists only the User id"); + + let mn_ids: std::collections::BTreeSet = ctx + .load_local_masternode_identities() + .unwrap() + .iter() + .map(|qi| qi.identity.id()) + .collect(); + assert_eq!( + mn_ids, + [mn, evo].into_iter().collect(), + "masternode accessor lists MN + Evonode, never the User" + ); + + // The control: the unfiltered accessor still lists all three (the legacy + // Identities table reads this — masternodes stay visible there). + assert_eq!( + ctx.load_local_qualified_identities().unwrap().len(), + 3, + "the unfiltered accessor keeps MN/Evonode (legacy table control)" + ); + + // With a User present, resolve falls back to the User, never the MN/Evo. + assert_eq!( + ctx.resolve_selected_identity().map(|qi| qi.identity.id()), + Some(user), + "resolve falls back to the first User, never a masternode", + ); + } + + /// TC-NAV-12c — a masternode persisted as `selected_identity_id` in a prior + /// session is sanitized to `None` on context load; a User selection is kept. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn fr6_stale_masternode_selection_sanitized_on_restore() { + let (_dir, ctx) = offline_ctx().await; + let mn = seed_typed(&ctx, 0xA1, IdentityType::Masternode); + let user = seed_typed(&ctx, 0x72, IdentityType::User); + + // Persist a masternode as the selection (the pre-FR-6 Hub allowed this). + ctx.set_selected_identity(Some(mn)); + assert_eq!(ctx.selected_identity_id(), Some(mn), "MN persisted"); + + // Even before restore, the resolution filter refuses to resolve the MN. + assert_eq!( + ctx.resolve_selected_identity().map(|qi| qi.identity.id()), + Some(user), + "resolution filter alone never resolves the persisted masternode", + ); + + // Context-load sanitization clears the stale MN selection in memory. + ctx.restore_selected_identity_from_kv(); + assert_eq!( + ctx.selected_identity_id(), + None, + "a stale masternode selection is cleared on load", + ); + + // A User selection survives the same sanitization. + ctx.set_selected_identity(Some(user)); + ctx.restore_selected_identity_from_kv(); + assert_eq!( + ctx.selected_identity_id(), + Some(user), + "a User selection is kept by the sanitizer", + ); + } } diff --git a/src/context/settings_db.rs b/src/context/settings_db.rs index 8688afe30..a25cea90d 100644 --- a/src/context/settings_db.rs +++ b/src/context/settings_db.rs @@ -238,6 +238,7 @@ mod tests { egui::Context::default(), app_kv, secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .expect("AppContext") } diff --git a/src/context/wallet_lifecycle/tests.rs b/src/context/wallet_lifecycle/tests.rs index b4ec19c69..81b74ba86 100644 --- a/src/context/wallet_lifecycle/tests.rs +++ b/src/context/wallet_lifecycle/tests.rs @@ -65,6 +65,7 @@ fn offline_testnet_context_with_db( egui_ctx, app_kv, secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .expect("AppContext::new should succeed offline with bundled testnet config"); @@ -689,6 +690,7 @@ async fn issue7_fresh_persistor_bip44_xpub_matches_det_bridge() { egui::Context::default(), app_kv, secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .expect("cold-boot AppContext::new"); let (tx, _rx) = tokio::sync::mpsc::channel::(32); diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 166c26863..85536956c 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -380,6 +380,9 @@ pub async fn init_app_context() -> Result, McpError> { egui::Context::default(), app_kv, secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new( + config.developer_mode.unwrap_or(false), + )), ) .ok_or_else(|| { McpError::internal_error( diff --git a/src/mcp/tools/masternode.rs b/src/mcp/tools/masternode.rs index f3c21d063..badbf63eb 100644 --- a/src/mcp/tools/masternode.rs +++ b/src/mcp/tools/masternode.rs @@ -171,6 +171,23 @@ impl AsyncTool for MasternodeIdentityLoad { keys_input: vec![], derive_keys_from_wallets: false, selected_wallet_seed_hash: None, + // FR-8 load-time key encryption is GUI-only this iteration + // (requirements §2.3): this tool is a confirmed keyless (Tier-1) + // entry point, so it loads unprotected. + // TODO: headless password parity — accept an optional encryption + // password param and thread it here once MCP secret handling is + // designed. + encryption_password: None, + // Headless load uses overwrite/upsert semantics: a repeat load + // REPLACES the stored node's keys with those supplied in this call. + // Keys omitted this time are dropped — this is a full replace, not a + // merge, so a partial-key repeat load is destructive. Duplicate + // rejection and key-preserving merge are GUI-form affordances, not a + // headless contract. + // TODO: headless merge parity — expose a load-mode param so callers + // can request MergeIntoExisting (key-preserving) once MCP secret + // handling for Tier-2 nodes is designed. + load_mode: crate::backend_task::identity::IdentityLoadMode::Overwrite, }; let task = BackendTask::IdentityTask(IdentityTask::LoadIdentity(input)); diff --git a/src/model/contested_name.rs b/src/model/contested_name.rs index 940a5a035..f30666b7b 100644 --- a/src/model/contested_name.rs +++ b/src/model/contested_name.rs @@ -14,6 +14,13 @@ pub enum ContestState { Locked, } +impl ContestState { + /// Whether the contest still accepts votes — `Joinable` or `Ongoing`. + pub fn state_is_votable(&self) -> bool { + matches!(self, ContestState::Joinable | ContestState::Ongoing) + } +} + #[derive(Debug, Encode, Decode, Clone)] pub struct ContestedName { pub normalized_contested_name: String, @@ -27,6 +34,30 @@ pub struct ContestedName { pub my_votes: BTreeMap<(Identifier, PrivateKeyTarget, KeyID), ResourceVoteChoice>, } +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) + } +} + +/// Per-node DPNS voting summary shown on the Masternodes card grid. +/// +/// 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). +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct MasternodeContestSummary { + /// Number of open contests this node can still vote on. + pub open_contest_count: usize, + /// Whether the node has at least one pending (not-yet-executed) scheduled + /// vote, reusing the DPNS Scheduled Votes screen's existing state. + pub has_scheduled_vote: bool, +} + #[derive(Debug, Encode, Decode, Clone)] pub struct Contestant { pub id: Identifier, @@ -38,3 +69,63 @@ pub struct Contestant { pub created_at_core_block_height: Option, pub document_id: Identifier, } + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + + fn contest(state: ContestState) -> ContestedName { + ContestedName { + normalized_contested_name: "alice".to_string(), + contestants: None, + locked_votes: None, + abstain_votes: None, + awarded_to: None, + end_time: None, + state, + last_updated: None, + my_votes: BTreeMap::new(), + } + } + + #[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)); + } +} diff --git a/src/model/identity_key_protection.rs b/src/model/identity_key_protection.rs new file mode 100644 index 000000000..d81d19c67 --- /dev/null +++ b/src/model/identity_key_protection.rs @@ -0,0 +1,44 @@ +//! Stateless password-format validation for identity-key protection (Tier-2). +//! +//! The single source of truth for the identity-key protection password policy, +//! reused by the backend seal path (authoritative enforcement) and by any UI +//! that wants instant feedback (FR-8 / §10.3). Delegates to the shared +//! single-key passphrase length rule so the minimum lives in one place. + +use crate::backend_task::error::TaskError; +use crate::model::secret::Secret; +use crate::model::wallet::passphrase::validate_single_key_passphrase; + +/// Validate an identity-key protection password against the backend policy. +/// +/// Reuses the single-key passphrase rule (the same minimum length the UI +/// shows). The confirmation match is a UI concern, so the password is passed as +/// its own confirmation here — only the length check is meaningful at this +/// layer. +/// +/// # Errors +/// +/// [`TaskError::SingleKeyPassphraseTooShort`] when the password is shorter than +/// the shared minimum length. +pub fn validate_protection_password(password: &Secret) -> Result<(), TaskError> { + let pw = password.expose_secret(); + validate_single_key_passphrase(pw, pw).map_err(TaskError::from) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A too-short password is rejected with the typed error; a compliant one + /// passes — the same policy the backend seal path enforces. + #[test] + fn weak_password_is_rejected_compliant_accepted() { + let err = validate_protection_password(&Secret::new("short")).expect_err("too short"); + assert!( + matches!(err, TaskError::SingleKeyPassphraseTooShort { .. }), + "expected SingleKeyPassphraseTooShort, got {err:?}" + ); + validate_protection_password(&Secret::new("long-enough-password")) + .expect("compliant password accepted"); + } +} diff --git a/src/model/masternode_input.rs b/src/model/masternode_input.rs index 26f1f0d13..d5917bfb6 100644 --- a/src/model/masternode_input.rs +++ b/src/model/masternode_input.rs @@ -135,6 +135,23 @@ pub fn decode_identity_id(input: &str) -> Result bool { + // TODO: this duplicates the Base58-then-hex decode of `decode_identity_id`. + // Fold this into `decode_identity_id(input).is_ok()` once the McpToolError + // dependency in that function is acceptable at every call site. + let trimmed = input.trim(); + !trimmed.is_empty() + && (Identifier::from_string(trimmed, Encoding::Base58).is_ok() + || Identifier::from_string(trimmed, Encoding::Hex).is_ok()) +} + #[cfg(test)] mod tests { use super::*; @@ -287,6 +304,29 @@ mod tests { } } + // ── is_valid_pro_tx_hash — UI on-blur shape check (TC-FR4-08/09) ────── + + #[test] + fn pro_tx_hash_accepts_hex_and_base58() { + let id = Identifier::random(); + assert!(is_valid_pro_tx_hash(&id.to_string(Encoding::Hex))); + assert!(is_valid_pro_tx_hash(&id.to_string(Encoding::Base58))); + } + + #[test] + fn pro_tx_hash_accepts_surrounding_whitespace() { + let id = Identifier::random(); + let padded = format!(" {} ", id.to_string(Encoding::Hex)); + assert!(is_valid_pro_tx_hash(&padded)); + } + + #[test] + fn pro_tx_hash_rejects_empty_and_malformed() { + for bad in ["", " ", "not-a-hash", &"a".repeat(63), &"b".repeat(65)] { + assert!(!is_valid_pro_tx_hash(bad), "expected {bad:?} rejected"); + } + } + #[test] fn identity_id_error_states_what_to_do() { // The error must carry a concrete self-resolution action: the two diff --git a/src/model/mod.rs b/src/model/mod.rs index 20284c646..57e568239 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -7,9 +7,12 @@ pub mod dpns; pub mod fee_estimation; pub mod grovestark_prover; pub mod identity_discovery; +pub mod identity_key_protection; pub mod key_input; -/// Stateless input parsing for the headless masternode/evonode MCP tools. -#[cfg(any(feature = "mcp", feature = "cli"))] +/// Stateless masternode/evonode input parsing and validation (ProTxHash, +/// node type). Pure logic with no mcp/cli dependency — used by both the +/// always-compiled GUI load form and the headless MCP tools, so it must never +/// be feature-gated. pub mod masternode_input; pub mod qualified_contract; pub mod qualified_identity; diff --git a/src/model/qualified_identity/mod.rs b/src/model/qualified_identity/mod.rs index 77fd48c68..4e972d719 100644 --- a/src/model/qualified_identity/mod.rs +++ b/src/model/qualified_identity/mod.rs @@ -85,6 +85,25 @@ impl Display for IdentityType { } } +/// Presence of the three masternode/evonode key roles on a loaded node. +/// +/// A node loads read-only without any keys; each role can be present or absent +/// independently. Used by the Masternodes card grid to render the compact +/// `V O P` key-status indicator (present roles emphasised, absent roles dimmed) +/// — never colour-only (NFR-6). +/// +/// Role → purpose mapping (see `verify_*_key_exists_on_identity` in +/// `backend_task/identity/mod.rs`): +/// * Voting → a `PrivateKeyOnVoterIdentity` key / `associated_voter_identity` +/// * Owner → a main-identity key with [`Purpose::OWNER`] +/// * Payout → a main-identity key with [`Purpose::TRANSFER`] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct MasternodeKeyPresence { + pub voting: bool, + pub owner: bool, + pub payout: bool, +} + #[derive(Debug, Encode, Decode, Clone, Hash, Ord, PartialOrd, Eq, PartialEq)] #[allow(clippy::enum_variant_names)] pub enum PrivateKeyTarget { @@ -506,6 +525,31 @@ impl QualifiedIdentity { .map_err(|e| format!("Failed to decode QualifiedIdentity: {}", e)) } + /// Which masternode/evonode key roles are loaded for this identity. + /// + /// Voting presence is signalled by a loaded voter identity + /// (`associated_voter_identity`) OR any [`Purpose::VOTING`] key; owner by a + /// [`Purpose::OWNER`] key; payout by a [`Purpose::TRANSFER`] key. Intended + /// for masternode/evonode identities — a `User` identity may carry a + /// `TRANSFER` key for withdrawals, which this method would report as + /// `payout`, so callers must scope it to the Masternodes surface. + pub fn masternode_key_presence(&self) -> MasternodeKeyPresence { + let mut presence = MasternodeKeyPresence { + voting: self.associated_voter_identity.is_some(), + owner: false, + payout: false, + }; + for (public_key, _) in self.private_keys.private_keys.values() { + match public_key.identity_public_key.purpose() { + Purpose::VOTING => presence.voting = true, + Purpose::OWNER => presence.owner = true, + Purpose::TRANSFER => presence.payout = true, + _ => {} + } + } + presence + } + /// Resolve the 32-byte private key for `(target, key_id)` without ever /// reading a wallet's parked seed. /// @@ -794,6 +838,25 @@ impl QualifiedIdentity { keys } + /// Returns the key to pre-select for signing a withdrawal. + /// + /// Only keys whose private material is held locally are considered (via + /// [`available_withdrawal_keys`](Self::available_withdrawal_keys)). A + /// `TRANSFER` key is preferred, falling back to an `OWNER` key — mirroring + /// Platform's `TransferPreferred` signing-key selection. Returns `None` when + /// no locally-signable withdrawal key exists, so callers never pre-select an + /// on-chain key the signer cannot actually use. + pub fn default_withdrawal_key(&self) -> Option<&QualifiedIdentityPublicKey> { + let keys = self.available_withdrawal_keys(); + keys.iter() + .find(|qk| qk.identity_public_key.purpose() == Purpose::TRANSFER) + .or_else(|| { + keys.iter() + .find(|qk| qk.identity_public_key.purpose() == Purpose::OWNER) + }) + .copied() + } + pub fn available_transfer_keys(&self) -> Vec<&QualifiedIdentityPublicKey> { let mut keys = vec![]; @@ -886,3 +949,223 @@ impl QualifiedIdentity { Ok(wallet_info) } } + +#[cfg(test)] +mod masternode_key_presence_tests { + use super::*; + use crate::model::qualified_identity::encrypted_key_storage::PrivateKeyData; + use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dash_sdk::dpp::platform_value::BinaryData; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::Identifier; + + /// Build a main-identity public key with an explicit purpose. Only the + /// purpose is read by [`QualifiedIdentity::masternode_key_presence`]; the + /// key type and data are inert placeholders. + fn key_with_purpose(id: KeyID, purpose: Purpose) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_HASH160, + read_only: false, + data: BinaryData::new(vec![0u8; 20]), + disabled_at: None, + }) + } + + /// Assemble a masternode-shaped `QualifiedIdentity`: `voting` attaches a + /// voter identity; each purpose in `main_key_purposes` becomes a + /// main-identity key. + fn qi_with(voting: bool, main_key_purposes: &[Purpose]) -> QualifiedIdentity { + let pv = PlatformVersion::latest(); + let identity = + Identity::create_basic_identity(Identifier::from([1u8; 32]), pv).expect("identity"); + + let mut ks = KeyStorage::default(); + for (i, purpose) in main_key_purposes.iter().enumerate() { + let key = key_with_purpose(i as KeyID, *purpose); + ks.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, key.id()), + ( + QualifiedIdentityPublicKey::from(key), + PrivateKeyData::Clear([0u8; 32]), + ), + ); + } + + let associated_voter_identity = voting.then(|| { + let voter = Identity::create_basic_identity(Identifier::from([2u8; 32]), pv) + .expect("voter identity"); + let voting_key = key_with_purpose(0, Purpose::VOTING); + (voter, voting_key) + }); + + QualifiedIdentity { + identity, + associated_voter_identity, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::Masternode, + alias: None, + private_keys: ks, + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + } + } + + /// TC-FR3-08 — all eight bit-combinations of {Voting, Owner, Payout} are + /// reported exactly, with all-off and all-on distinct from partial states. + #[test] + fn tc_fr3_08_all_vop_combinations() { + for mask in 0u8..8 { + let voting = mask & 0b100 != 0; + let owner = mask & 0b010 != 0; + let payout = mask & 0b001 != 0; + + let mut purposes = Vec::new(); + if owner { + purposes.push(Purpose::OWNER); + } + if payout { + purposes.push(Purpose::TRANSFER); + } + + let presence = qi_with(voting, &purposes).masternode_key_presence(); + assert_eq!( + presence, + MasternodeKeyPresence { + voting, + owner, + payout, + }, + "mask {mask:03b} (V={voting} O={owner} P={payout}) misreported" + ); + } + } + + /// A `Purpose::VOTING` key on the main identity signals voting readiness + /// even without a separately loaded voter identity. + #[test] + fn voting_purpose_key_counts_as_voting_present() { + let presence = qi_with(false, &[Purpose::VOTING]).masternode_key_presence(); + assert!(presence.voting); + assert!(!presence.owner); + assert!(!presence.payout); + } + + /// A node loaded read-only (no keys, no voter identity) reports every role + /// absent. + #[test] + fn read_only_node_has_no_keys() { + let presence = qi_with(false, &[]).masternode_key_presence(); + assert_eq!(presence, MasternodeKeyPresence::default()); + } +} + +#[cfg(test)] +mod withdrawal_key_tests { + use super::*; + use crate::model::qualified_identity::encrypted_key_storage::{KeyStorage, PrivateKeyData}; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::Identifier; + + fn key(id: KeyID, purpose: Purpose) -> IdentityPublicKey { + let mut k = IdentityPublicKey::random_key(id, Some(id as u64), PlatformVersion::latest()); + k.set_id(id); + k.set_purpose(purpose); + k.set_security_level(SecurityLevel::CRITICAL); + k + } + + fn build_identity( + identity_type: IdentityType, + on_chain: Vec, + with_private: Vec, + ) -> QualifiedIdentity { + let public_keys: BTreeMap = + on_chain.into_iter().map(|k| (k.id(), k)).collect(); + let identity = Identity::new_with_id_and_keys( + Identifier::random(), + public_keys, + PlatformVersion::latest(), + ) + .expect("identity"); + + let mut private_keys = BTreeMap::new(); + for k in with_private { + private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, k.id()), + ( + QualifiedIdentityPublicKey::from(k), + PrivateKeyData::Clear([0u8; 32]), + ), + ); + } + + QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type, + alias: None, + private_keys: KeyStorage { private_keys }, + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + } + } + + /// Repro for the withdraw key-selection bug: a TRANSFER key that exists + /// on-chain but whose private material is not held locally must never be + /// pre-selected — the signer cannot use it. + #[test] + fn ghost_transfer_key_is_not_selected() { + let transfer = key(1, Purpose::TRANSFER); + let qi = build_identity(IdentityType::User, vec![transfer], vec![]); + assert!(qi.default_withdrawal_key().is_none()); + } + + #[test] + fn private_backed_transfer_key_is_selected() { + let transfer = key(1, Purpose::TRANSFER); + let qi = build_identity(IdentityType::User, vec![transfer.clone()], vec![transfer]); + let selected = qi.default_withdrawal_key().expect("a key"); + assert_eq!(selected.identity_public_key.id(), 1); + assert_eq!(selected.identity_public_key.purpose(), Purpose::TRANSFER); + } + + #[test] + fn owner_key_is_used_as_fallback_when_no_transfer() { + let owner = key(2, Purpose::OWNER); + let qi = build_identity(IdentityType::Masternode, vec![owner.clone()], vec![owner]); + let selected = qi.default_withdrawal_key().expect("a key"); + assert_eq!(selected.identity_public_key.id(), 2); + assert_eq!(selected.identity_public_key.purpose(), Purpose::OWNER); + } + + #[test] + fn transfer_key_is_preferred_over_owner() { + let owner = key(2, Purpose::OWNER); + let transfer = key(1, Purpose::TRANSFER); + let qi = build_identity( + IdentityType::Masternode, + vec![owner.clone(), transfer.clone()], + vec![owner, transfer], + ); + let selected = qi.default_withdrawal_key().expect("a key"); + assert_eq!(selected.identity_public_key.purpose(), Purpose::TRANSFER); + } +} diff --git a/src/model/settings.rs b/src/model/settings.rs index 19f1b5a66..5a00dc425 100644 --- a/src/model/settings.rs +++ b/src/model/settings.rs @@ -90,6 +90,11 @@ pub enum RootScreenType { /// screens are still wired. Distinct variant so user selection, persistence, and /// left-nav highlighting stay independent. RootScreenIdentityHub, + /// Masternodes section (Expert-Mode gated). Node-operator surface for + /// loading masternode/evonode identities, DPNS-contest voting, and + /// owner/voting/payout key management. Distinct variant so its nav gating, + /// selection, and persistence stay independent of the everyday-user tabs. + RootScreenMasternodes, } impl RootScreenType { @@ -123,6 +128,7 @@ impl RootScreenType { RootScreenType::RootScreenToolsGroveSTARKScreen => 25, RootScreenType::RootScreenToolsAddressBalanceScreen => 26, RootScreenType::RootScreenIdentityHub => 27, + RootScreenType::RootScreenMasternodes => 28, } } @@ -156,6 +162,7 @@ impl RootScreenType { 25 => Some(RootScreenType::RootScreenToolsGroveSTARKScreen), 26 => Some(RootScreenType::RootScreenToolsAddressBalanceScreen), 27 => Some(RootScreenType::RootScreenIdentityHub), + 28 => Some(RootScreenType::RootScreenMasternodes), _ => None, } } @@ -178,6 +185,18 @@ mod root_screen_type_tests { assert_eq!(encoded, 27); } + #[test] + fn masternodes_round_trips() { + let rt = RootScreenType::RootScreenMasternodes; + let encoded = rt.to_int(); + let decoded = RootScreenType::from_int(encoded) + .expect("new masternodes variant must round-trip through from_int"); + assert_eq!(rt, decoded); + // Value 28 is the canonical on-disk encoding — keep it stable so + // persisted user settings continue to round-trip as variants are added. + assert_eq!(encoded, 28); + } + #[test] fn from_int_returns_none_for_unknown_value() { assert!(RootScreenType::from_int(9999).is_none()); diff --git a/src/ui/components/README.md b/src/ui/components/README.md index 0b429da88..7e51af22c 100644 --- a/src/ui/components/README.md +++ b/src/ui/components/README.md @@ -23,6 +23,7 @@ Concise catalog of all reusable UI components. Consult before creating new UI el | Component | File | DomainType | Description | |-----------|------|------------|-------------| | `BreadcrumbPill` | `breadcrumb_pill.rs` | `String` | Label + optional icon + chevron. Three modes: Interactive / Subdued / Placeholder. Reusable anywhere a breadcrumb pill is needed (Identities hub breadcrumb, future wallet breadcrumbs). | +| `global_nav_switcher::render()` | `global_nav_switcher.rs` | `GlobalNavEffect` | Page-aware three-segment switcher (`segment-1 › 💼 wallet › 👤 identity/object`) via `top_panel::add_top_panel_with_global_nav` / the Hub's own `breadcrumb_switcher` shim. Live on Identities, DashPay, DPNS, Wallets, Identity Hub, and Masternodes — interactive on Hub/Masternodes, subdued (read-only) on the other four; remaining root screens (Contracts, Tokens, Tools, Network Chooser, Withdraws, …) still render the plain breadcrumb (FR-GLOBAL-NAV rollout in progress). Composes per page from a `PageNavSpec` (`ui/state/global_nav.rs`) and reuses `BreadcrumbPill`/`IdentityPill`. | ## Display Components @@ -76,6 +77,7 @@ directory. |-----------------|------|-------------| | `island_central_panel()` | `styled.rs` | Responsive central panel, renders global MessageBanners | | `add_location_view()` | `top_panel.rs` | Breadcrumb navigation + connection status | +| `add_top_panel_with_global_nav()` | `top_panel.rs` | Top panel wired to `global_nav_switcher::render()` with subdued pills (`subdued_everyday_spec` / `subdued_wallet_only_spec`); used by Identities, DashPay, DPNS, and Wallets. Masternodes uses the identity-aware capturing variant with interactive pills instead. Every other root screen still calls the plain `add_top_panel()` | | `add_left_panel()` | `left_panel.rs` | Main icon navigation sidebar | | `load_icon()` / `load_svg_icon()` | `icons.rs` | Load & cache embedded raster/SVG icons from `icons/` | | Subscreen panels | `*_subscreen_chooser_panel.rs` | Tab navigation for DPNS, DashPay, Tokens, Tools | diff --git a/src/ui/components/global_nav_switcher.rs b/src/ui/components/global_nav_switcher.rs new file mode 100644 index 000000000..3ffc270f6 --- /dev/null +++ b/src/ui/components/global_nav_switcher.rs @@ -0,0 +1,684 @@ +//! Page-aware global navigation switcher. +//! +//! Generalizes the Identities-hub breadcrumb into a switcher rendered on every +//! root page. Composes `segment-1 link › 💼 wallet pill › 👤 identity/object +//! pill` per a [`PageNavSpec`], owns the wallet / identity dropdown `Popup`s, +//! and returns a typed [`GlobalNavEffect`] for the shell to apply. +//! +//! It is a pure UI component — it reads the app-scoped selection from +//! `AppContext` and reports an effect; the shell applies it (components render, +//! screens decide). Reuses [`BreadcrumbPill`]/[`IdentityPill`] and the existing +//! [`BreadcrumbPillMode`] verbatim — no new pill widget. +//! +//! Per-state modes follow design-spec §A.3 / §7; tooltips are verbatim from +//! design-spec §D (§7.1). Wallet-scoped identity lists use the *stored* +//! `wallet_hash` filter, never `associated_wallets.keys().next()` (R1). + +use crate::context::AppContext; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::WalletSeedHash; +use crate::ui::RootScreenType; +use crate::ui::components::breadcrumb_pill::{BreadcrumbPill, BreadcrumbPillMode}; +use crate::ui::identity::identity_hero_card::HeroIdentityKind; +use crate::ui::identity::identity_pill::{IdentityPill, display_label}; +use crate::ui::state::global_nav::{ + IdentityPillScope, PageNavSpec, PageObjectItem, PillConsumption, +}; +use crate::ui::state::hub_selection::HubSelection; +use crate::ui::theme::DashColors; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::platform::Identifier; +use eframe::egui::{self, RichText, Sense, Ui}; +use std::sync::Arc; + +/// Inline search appears once a wallet's identity list reaches this size (§A.3). +const SEARCH_THRESHOLD: usize = 7; + +/// A typed switcher outcome the shell applies. Generalizes the hub's +/// `BreadcrumbEffect`: adds [`GlobalNavEffect::SelectPageObject`] for the +/// page-scoped object pill, kept distinct from [`GlobalNavEffect::SelectIdentity`] +/// so a page-scoped selection never writes the app-global identity (FR-6). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GlobalNavEffect { + /// No interaction this frame. + None, + /// Segment-1 was clicked — navigate to the page's root screen. + NavigateToRoot(RootScreenType), + /// Switch the operating wallet. + SwitchWallet(WalletSeedHash), + /// Select the app-global User identity. + SelectIdentity(Identifier), + /// Select a page-scoped object (the masternode/evonode in view). **Never** + /// the app-global identity — the structural FR-6 boundary. + SelectPageObject(Identifier), + /// "Set up another wallet" — route to the Wallets screen. + AddWallet, + /// "Add another identity" → create a new identity. + AddIdentityCreate, + /// "Add another identity" → load an existing identity. + AddIdentityLoad, + /// Dev-mode: bulk-create test identities. + CreateTestIdentities, +} + +/// Wallet-pill mode by HD-wallet count: 0 → placeholder, 1 → subdued (info +/// only), ≥2 → interactive (opens the wallet dropdown). §A.3 / §7. +fn wallet_pill_mode(wallet_count: usize) -> BreadcrumbPillMode { + match wallet_count { + 0 => BreadcrumbPillMode::Placeholder, + 1 => BreadcrumbPillMode::Subdued, + _ => BreadcrumbPillMode::Interactive, + } +} + +/// tt-2 — interactive wallet pill (≥2 wallets). Verbatim, design-spec §D. +fn tt_wallet_interactive() -> &'static str { + "Switch between your wallets. Each wallet can own several identities." +} + +/// tt-3 — subdued wallet pill (exactly 1 wallet). Verbatim, design-spec §D #3 +/// (the brief's "…to switch between them." is a paraphrase — this is canonical). +fn tt_wallet_subdued(wallet_name: &str) -> String { + format!( + "This identity is funded by {wallet_name}. Set up another wallet on the Wallets screen \ + to unlock switching." + ) +} + +/// tt-4 — interactive identity pill. Verbatim, design-spec §D. +fn tt_identity(wallet_name: &str) -> String { + format!("Switch between identities in {wallet_name} or add a new one.") +} + +/// Short hex of a seed hash, for a wallet with no alias. +fn short_hex(hash: &WalletSeedHash) -> String { + let mut s = String::with_capacity(10); + for b in hash.iter().take(4) { + s.push_str(&format!("{b:02x}")); + } + s.push('…'); + s +} + +/// Loaded HD wallets as `(seed_hash, display_name)`, sorted by hash for a +/// stable order. Name = alias, else a short hex of the seed hash. +fn gather_wallets(app_context: &Arc) -> Vec<(WalletSeedHash, String)> { + let Ok(wallets) = app_context.wallets.read() else { + return Vec::new(); + }; + wallets + .iter() + .map(|(hash, w)| { + let name = w + .read() + .ok() + .and_then(|w| w.alias.clone()) + .filter(|a| !a.trim().is_empty()) + .unwrap_or_else(|| short_hex(hash)); + (*hash, name) + }) + .collect() +} + +/// Identity display label (Local nickname → DPNS → short id). +fn identity_label(qi: &QualifiedIdentity) -> String { + let dpns = qi.dpns_names.first().map(|n| n.name.as_str()); + display_label( + qi.alias.as_deref(), + dpns, + &qi.identity.id().to_string(Encoding::Base58), + ) +} + +/// First uppercase alphanumeric of the label, for the avatar monogram. +fn monogram_initial(label: &str) -> Option { + label + .chars() + .find(|c| c.is_alphanumeric()) + .map(|c| c.to_ascii_uppercase()) +} + +/// The pill label for a page-scoped object: the selected item's label, or the +/// placeholder when nothing is selected (or the selection is stale — not in +/// `items`). +fn page_object_label( + placeholder: &str, + items: &[PageObjectItem], + selected: Option, +) -> String { + selected + .and_then(|id| items.iter().find(|it| it.id == id)) + .map(|it| it.label.clone()) + .unwrap_or_else(|| placeholder.to_string()) +} + +/// Identity-primary context shared by the wallet + app-global identity pills. +/// Derived once per frame, mirroring the hub's original derivation. +struct AppGlobalContext { + active_id: Option, + pill_identity: Option, + active_is_wallet_less: bool, + active_wallet: Option, + active_wallet_name: String, + scoped: Vec, + no_wallet: Vec, +} + +/// Derive the app-global (identity-primary) context: the active identity, the +/// wallet derived from it, the wallet-scoped identity list, and the no-wallet +/// group. Identical logic to the original hub switcher. +fn derive_app_global_context( + app_context: &Arc, + wallets: &[(WalletSeedHash, String)], +) -> AppGlobalContext { + // FR-6: the app-global identity pill and its dropdown (including the + // wallet-less "no wallet on this device" group) list User identities only — + // masternode/evonode identities never appear on everyday-user surfaces + // (TC-NAV-17). The wallet-scoped list below is wallet-owned, so it is + // User-only by construction (masternodes are wallet-less). + let all_identities = app_context.load_local_user_identities().unwrap_or_default(); + let all_ids: Vec = all_identities.iter().map(|qi| qi.identity.id()).collect(); + let active_id = app_context.selected_identity_id(); + // The identity pill reflects an *explicitly* chosen identity (or a lone + // auto-selected one). In the ≥2-none-chosen picker state it stays a + // placeholder (§7) — never the first-identity fallback, which would + // duplicate a picker-grid label and disagree with "no identity chosen". + let pill_target_id = crate::model::selected_identity::keep_if_loaded(active_id, &all_ids) + .or_else(|| (all_ids.len() == 1).then(|| all_ids[0])); + let pill_identity = pill_target_id + .and_then(|id| all_identities.iter().find(|qi| qi.identity.id() == id)) + .cloned(); + let active_is_wallet_less = pill_identity + .as_ref() + .is_some_and(|qi| qi.wallet_index.is_none()); + + // The wallet segment is DERIVED from the active identity (identity-primary). + // A wallet-less active identity → no active wallet → empty wallet segment, + // so the pill never shows a wallet belonging to a different identity. + let active_wallet = if active_is_wallet_less { + None + } else { + app_context + .selected_wallet_hash() + .filter(|h| wallets.iter().any(|(wh, _)| wh == h)) + .or_else(|| wallets.first().map(|(h, _)| *h)) + }; + let active_wallet_name = active_wallet + .and_then(|h| wallets.iter().find(|(wh, _)| *wh == h)) + .map(|(_, n)| n.clone()) + .unwrap_or_default(); + + // Identities owned by the active wallet (stored `wallet_hash` filter — R1). + let scoped: Vec = active_wallet + .and_then(|h| { + app_context + .load_local_qualified_identities_for_wallet(&h) + .ok() + }) + .unwrap_or_default(); + // Identities with no wallet on this device (imported by id). + let no_wallet: Vec = all_identities + .iter() + .filter(|qi| qi.wallet_index.is_none()) + .cloned() + .collect(); + + AppGlobalContext { + active_id, + pill_identity, + active_is_wallet_less, + active_wallet, + active_wallet_name, + scoped, + no_wallet, + } +} + +/// Render the switcher for `spec`. Reads the app-scoped selection; mutates only +/// the `selection` search buffers; returns the user's effect for the shell to +/// apply. +pub fn render( + ui: &mut Ui, + app_context: &Arc, + spec: &PageNavSpec, + selection: &mut HubSelection, +) -> GlobalNavEffect { + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + let mut effect = GlobalNavEffect::None; + + let wallets = gather_wallets(app_context); + let wallet_count = wallets.len(); + + // Identity-primary derivation is only needed when the third pill is the + // app-global user identity (avoids an extra DB read on pages whose pill is + // page-scoped, e.g. Masternodes). + let app_global = matches!( + spec.identity_pill(), + Some((IdentityPillScope::AppGlobalUser, _)) + ); + let ctx_data = app_global.then(|| derive_app_global_context(app_context, &wallets)); + + ui.horizontal(|ui| { + // --- Segment 1: page-aware link -------------------------------------- + let link = ui.add( + egui::Label::new(RichText::new(spec.segment1_label()).color(DashColors::DASH_BLUE)) + .sense(Sense::click()), + ); + if link.clicked() { + effect = GlobalNavEffect::NavigateToRoot(spec.segment1_target()); + } + + // --- Segment 2: wallet pill ------------------------------------------ + if let Some(consumption) = spec.wallet_pill() { + ui.label(RichText::new("›").color(DashColors::text_secondary(dark_mode))); + render_wallet_pill( + ui, + consumption, + &wallets, + wallet_count, + app_context, + ctx_data.as_ref(), + dark_mode, + &mut effect, + ); + } + + // --- Segment 3: identity / page-object pill -------------------------- + if let Some((scope, consumption)) = spec.identity_pill() { + ui.label(RichText::new("›").color(DashColors::text_secondary(dark_mode))); + match scope { + IdentityPillScope::AppGlobalUser => { + // `ctx_data` is always `Some` here (derived when app_global). + if let Some(data) = ctx_data.as_ref() { + render_app_global_identity_pill( + ui, + consumption, + app_context, + data, + selection, + wallet_count, + dark_mode, + &mut effect, + ); + } + } + IdentityPillScope::PageScopedObject { + placeholder, + items, + selected, + } => { + render_page_object_pill( + ui, + consumption, + placeholder, + items, + *selected, + dark_mode, + &mut effect, + ); + } + } + } + }); + + effect +} + +/// Render the wallet pill. `Consumed` reproduces the count-based +/// placeholder/subdued/interactive logic (identity-primary when `ctx_data` is +/// present); `Unwired` renders a subdued, non-interactive pill with a +/// how-to-change tooltip and emits no effect. +#[allow(clippy::too_many_arguments)] +fn render_wallet_pill( + ui: &mut Ui, + consumption: &PillConsumption, + wallets: &[(WalletSeedHash, String)], + wallet_count: usize, + app_context: &Arc, + ctx_data: Option<&AppGlobalContext>, + dark_mode: bool, + effect: &mut GlobalNavEffect, +) { + // Resolve the active wallet: identity-primary when available, else the + // plain app-scoped selection (or the first wallet). + let (active_wallet, active_wallet_name, active_is_wallet_less) = match ctx_data { + Some(d) => ( + d.active_wallet, + d.active_wallet_name.clone(), + d.active_is_wallet_less, + ), + None => { + let active = app_context + .selected_wallet_hash() + .filter(|h| wallets.iter().any(|(wh, _)| wh == h)) + .or_else(|| wallets.first().map(|(h, _)| *h)); + let name = active + .and_then(|h| wallets.iter().find(|(wh, _)| *wh == h)) + .map(|(_, n)| n.clone()) + .unwrap_or_default(); + (active, name, false) + } + }; + + if let PillConsumption::Unwired { tooltip } = consumption { + // Dimmed, no caret, no visible tag — the value stays visible, the + // explanation lives in the tooltip (FR-GLOBAL-NAV-2 rule 3). + if active_wallet.is_some() { + BreadcrumbPill::new(active_wallet_name) + .with_icon("💼") + .subdued(true) + .with_tooltip(tooltip.clone()) + .show(ui); + } else { + BreadcrumbPill::placeholder("(no wallet yet)") + .with_tooltip(tooltip.clone()) + .show(ui); + } + return; + } + + // Consumed: the original count-based rendering. + let wallet_mode = if active_is_wallet_less { + BreadcrumbPillMode::Placeholder + } else { + wallet_pill_mode(wallet_count) + }; + match wallet_mode { + BreadcrumbPillMode::Placeholder => { + let label = if active_is_wallet_less { + "(no wallet)" + } else { + "(no wallet yet)" + }; + BreadcrumbPill::placeholder(label).show(ui); + } + BreadcrumbPillMode::Subdued => { + BreadcrumbPill::new(active_wallet_name.clone()) + .with_icon("💼") + .subdued(true) + .with_tooltip(tt_wallet_subdued(&active_wallet_name)) + .show(ui); + } + BreadcrumbPillMode::Interactive => { + let resp = BreadcrumbPill::new(active_wallet_name.clone()) + .with_icon("💼") + .with_tooltip(tt_wallet_interactive()) + .show(ui); + if let Some(anchor) = resp.response.clone() { + let popup_id = ui.make_persistent_id("global_nav_wallet_switcher"); + egui::Popup::new(popup_id, ui.ctx().clone(), &anchor, anchor.layer_id) + .open_memory(resp.clicked.then_some(egui::SetOpenCommand::Toggle)) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .frame(egui::Frame::popup(ui.style()).fill(DashColors::popup_fill(dark_mode))) + .show(|ui| { + ui.set_min_width(220.0); + for (h, name) in wallets { + let is_active = active_wallet == Some(*h); + if ui + .selectable_label(is_active, format!("💼 {name}")) + .clicked() + { + *effect = GlobalNavEffect::SwitchWallet(*h); + ui.close(); + } + } + ui.separator(); + if ui.button("Set up another wallet").clicked() { + *effect = GlobalNavEffect::AddWallet; + ui.close(); + } + }); + } + } + } +} + +/// Render the app-global User identity pill. `Consumed` reproduces the hub's +/// identity dropdown (scoped list + no-wallet group + add flows); `Unwired` +/// renders a subdued, non-interactive pill with a how-to-change tooltip. +#[allow(clippy::too_many_arguments)] +fn render_app_global_identity_pill( + ui: &mut Ui, + consumption: &PillConsumption, + app_context: &Arc, + data: &AppGlobalContext, + selection: &mut HubSelection, + wallet_count: usize, + dark_mode: bool, + effect: &mut GlobalNavEffect, +) { + let Some(active_qi) = data.pill_identity.as_ref() else { + // No identity in scope: placeholder reflects whether a wallet exists. + let label = if wallet_count == 0 { + "(no identity yet)" + } else { + "(choose an identity)" + }; + let pill = BreadcrumbPill::placeholder(label); + match consumption { + PillConsumption::Unwired { tooltip } => pill.with_tooltip(tooltip.clone()).show(ui), + PillConsumption::Consumed => pill.show(ui), + }; + return; + }; + + let label = identity_label(active_qi); + let kind: HeroIdentityKind = active_qi.identity_type.into(); + let dpns = active_qi.dpns_names.first().map(|n| n.name.clone()); + let id_b58 = active_qi.identity.id().to_string(Encoding::Base58); + + if let PillConsumption::Unwired { tooltip } = consumption { + // Subdued, non-interactive: the value shows dimmed with no caret. + IdentityPill::new(active_qi.alias.as_deref(), dpns.as_deref(), &id_b58) + .with_avatar(kind, monogram_initial(&label)) + .with_mode(BreadcrumbPillMode::Subdued) + .with_tooltip(tooltip.clone()) + .show(ui); + return; + } + + let resp = IdentityPill::new(active_qi.alias.as_deref(), dpns.as_deref(), &id_b58) + .with_avatar(kind, monogram_initial(&label)) + .with_tooltip(tt_identity(&data.active_wallet_name)) + .show(ui); + + if let Some(anchor) = resp.response.clone() { + let popup_id = ui.make_persistent_id("global_nav_identity_switcher"); + egui::Popup::new(popup_id, ui.ctx().clone(), &anchor, anchor.layer_id) + .open_memory(resp.clicked.then_some(egui::SetOpenCommand::Toggle)) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .frame(egui::Frame::popup(ui.style()).fill(DashColors::popup_fill(dark_mode))) + .show(|ui| { + ui.set_min_width(240.0); + + // Inline search once the scoped list is long (§A.3). + let filter = if data.scoped.len() >= SEARCH_THRESHOLD { + ui.add( + egui::TextEdit::singleline(selection.identity_search_mut()) + .hint_text("Search identities"), + ); + selection.identity_search().trim().to_lowercase() + } else { + String::new() + }; + + for qi in &data.scoped { + let row = identity_label(qi); + if !filter.is_empty() && !row.to_lowercase().contains(&filter) { + continue; + } + let id = qi.identity.id(); + let is_active = data.active_id == Some(id); + if ui.selectable_label(is_active, row).clicked() { + *effect = GlobalNavEffect::SelectIdentity(id); + ui.close(); + } + } + + if !data.no_wallet.is_empty() { + ui.separator(); + ui.label( + RichText::new("Identities without a wallet on this device") + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + for qi in &data.no_wallet { + let id = qi.identity.id(); + let is_active = data.active_id == Some(id); + if ui.selectable_label(is_active, identity_label(qi)).clicked() { + *effect = GlobalNavEffect::SelectIdentity(id); + ui.close(); + } + } + } + + ui.separator(); + if ui.button("Create a new identity").clicked() { + *effect = GlobalNavEffect::AddIdentityCreate; + ui.close(); + } + if ui.button("Load an existing identity").clicked() { + *effect = GlobalNavEffect::AddIdentityLoad; + ui.close(); + } + if app_context.is_developer_mode() + && ui.button("Create multiple test identities").clicked() + { + *effect = GlobalNavEffect::CreateTestIdentities; + ui.close(); + } + }); + } +} + +/// Render the page-scoped object pill (masternode/evonode in view). `Consumed` +/// opens a dropdown of `items` and emits [`GlobalNavEffect::SelectPageObject`] +/// — never `SelectIdentity`; `Unwired` renders a subdued, non-interactive pill. +fn render_page_object_pill( + ui: &mut Ui, + consumption: &PillConsumption, + placeholder: &str, + items: &[PageObjectItem], + selected: Option, + dark_mode: bool, + effect: &mut GlobalNavEffect, +) { + let label = page_object_label(placeholder, items, selected); + + if let PillConsumption::Unwired { tooltip } = consumption { + BreadcrumbPill::new(label) + .subdued(true) + .with_tooltip(tooltip.clone()) + .show(ui); + return; + } + + let resp = BreadcrumbPill::new(label).show(ui); + if let Some(anchor) = resp.response.clone() { + let popup_id = ui.make_persistent_id("global_nav_object_switcher"); + egui::Popup::new(popup_id, ui.ctx().clone(), &anchor, anchor.layer_id) + .open_memory(resp.clicked.then_some(egui::SetOpenCommand::Toggle)) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .frame(egui::Frame::popup(ui.style()).fill(DashColors::popup_fill(dark_mode))) + .show(|ui| { + ui.set_min_width(240.0); + for it in items { + let is_active = selected == Some(it.id); + if ui.selectable_label(is_active, &it.label).clicked() { + *effect = GlobalNavEffect::SelectPageObject(it.id); + ui.close(); + } + } + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(byte: u8) -> Identifier { + Identifier::new([byte; 32]) + } + + #[test] + fn short_hex_is_stable_prefix() { + let h = [0xABu8; 32]; + assert_eq!(short_hex(&h), "abababab…"); + } + + #[test] + fn monogram_initial_picks_first_alphanumeric_uppercase() { + assert_eq!(monogram_initial("alex.dash"), Some('A')); + assert_eq!(monogram_initial(" 9lives"), Some('9')); + assert_eq!(monogram_initial("…"), None); + } + + /// Wallet-pill mode resolver (moved verbatim from the hub switcher). + #[test] + fn wallet_pill_mode_by_count() { + assert_eq!(wallet_pill_mode(0), BreadcrumbPillMode::Placeholder); + assert_eq!(wallet_pill_mode(1), BreadcrumbPillMode::Subdued); + assert_eq!(wallet_pill_mode(2), BreadcrumbPillMode::Interactive); + assert_eq!(wallet_pill_mode(9), BreadcrumbPillMode::Interactive); + } + + /// Verbatim tooltip strings (regression guard for the design-spec wording). + #[test] + fn tooltips_are_verbatim() { + assert_eq!( + tt_wallet_interactive(), + "Switch between your wallets. Each wallet can own several identities." + ); + assert_eq!( + tt_wallet_subdued("Main Wallet"), + "This identity is funded by Main Wallet. Set up another wallet on the Wallets screen \ + to unlock switching." + ); + assert_eq!( + tt_identity("Main Wallet"), + "Switch between identities in Main Wallet or add a new one." + ); + } + + /// TC-NAV-04 foundation — a page-scoped selection resolves the pill label to + /// the selected item; a stale or absent selection falls back to placeholder. + #[test] + fn page_object_label_resolves_selection_else_placeholder() { + let items = vec![ + PageObjectItem { + id: id(1), + label: "mn-east-01".to_string(), + }, + PageObjectItem { + id: id(2), + label: "evo-west-02".to_string(), + }, + ]; + assert_eq!( + page_object_label("(no masternode yet)", &items, Some(id(2))), + "evo-west-02" + ); + // Nothing selected → placeholder. + assert_eq!( + page_object_label("(no masternode yet)", &items, None), + "(no masternode yet)" + ); + // Stale selection (not in items) → placeholder. + assert_eq!( + page_object_label("(no masternode yet)", &items, Some(id(9))), + "(no masternode yet)" + ); + } + + /// TC-NAV-16 foundation — a page-scoped object selection maps to + /// `SelectPageObject`, never `SelectIdentity`. Guards the FR-6 boundary at + /// the effect level (the switcher's PageScopedObject arm emits only this). + #[test] + fn page_object_effect_is_never_select_identity() { + let picked = GlobalNavEffect::SelectPageObject(id(3)); + assert!(matches!(picked, GlobalNavEffect::SelectPageObject(x) if x == id(3))); + assert!(!matches!(picked, GlobalNavEffect::SelectIdentity(_))); + } +} diff --git a/src/ui/components/left_panel.rs b/src/ui/components/left_panel.rs index 35c02f63a..0c4dd38ff 100644 --- a/src/ui/components/left_panel.rs +++ b/src/ui/components/left_panel.rs @@ -72,12 +72,11 @@ pub fn add_left_panel( ), ]; - // Build the final button list. Feature-gated hub entry inserted at the - // position that makes most sense for the section: directly after the - // legacy `Identities` entry so the three identity-related entries cluster - // together while the old ones stay clickable. + // Build the final button list. The hub and Masternodes entries are inserted + // directly after the legacy `Identities` entry so the identity-related + // entries cluster together while the old ones stay clickable. let mut buttons: Vec<(&str, RootScreenType, &str, Option)> = - Vec::with_capacity(legacy_buttons.len() + 1); + Vec::with_capacity(legacy_buttons.len() + 2); for entry in legacy_buttons.iter() { buttons.push(*entry); if entry.1 == RootScreenType::RootScreenIdentities { @@ -87,6 +86,18 @@ pub fn add_left_panel( "identity.png", None, )); + // Masternodes sits directly below the identity cluster (locked + // decision #3), gated behind Expert Mode — the nav item and route + // are both absent when Expert Mode is off (the gate skip below drops + // the entry). + // TODO: swap `voting.png` for a dedicated node/server glyph when one + // is added to `icons/` (distinct from `identity.png`). + buttons.push(( + "Masternodes", + RootScreenType::RootScreenMasternodes, + "voting.png", + Some(FeatureGate::DeveloperMode), + )); } } diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index 9ead2e5c0..fcadc55bf 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -8,6 +8,7 @@ pub mod contract_chooser_panel; pub mod dashpay_subscreen_chooser_panel; pub mod dpns_subscreen_chooser_panel; pub mod entropy_grid; +pub mod global_nav_switcher; pub mod icons; pub mod identity_selector; pub mod info_popup; diff --git a/src/ui/components/top_panel.rs b/src/ui/components/top_panel.rs index f2659d42e..3d612ef2e 100644 --- a/src/ui/components/top_panel.rs +++ b/src/ui/components/top_panel.rs @@ -1,8 +1,11 @@ use crate::app::{AppAction, DesiredAppAction}; use crate::context::AppContext; use crate::context::connection_status::OverallConnectionState; -use crate::ui::ScreenType; +use crate::ui::components::global_nav_switcher::{self, GlobalNavEffect}; +use crate::ui::state::global_nav::{PageNavSpec, PillConsumption}; +use crate::ui::state::hub_selection::HubSelection; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt, Shadow, Shape}; +use crate::ui::{RootScreenType, ScreenType}; use egui::{Align2, FontId, Frame, Margin, Panel, RichText, Ui}; use std::sync::Arc; @@ -338,3 +341,128 @@ pub fn add_top_panel_with_breadcrumb( ) -> AppAction { render_top_island(ui, app_context, breadcrumb, right_buttons) } + +/// Standard how-to-change tooltip for an unwired wallet pill (FR-GLOBAL-NAV-2 +/// rule 3). A single translation unit; keep it a complete sentence. +const TT_WALLET_UNWIRED: &str = "Change the active wallet from the Wallets tab."; +/// Standard how-to-change tooltip for an unwired identity pill. +const TT_IDENTITY_UNWIRED: &str = "Change the active identity from the Identity Hub."; + +/// An everyday-page global-nav spec with both pills subdued (unwired) — the +/// Phase-A rollout default. Segment-1 links to the page's own root. +pub fn subdued_everyday_spec(label: impl Into, target: RootScreenType) -> PageNavSpec { + PageNavSpec::unwired_everyday(label, target, TT_WALLET_UNWIRED, TT_IDENTITY_UNWIRED) +} + +/// A wallet-only global-nav spec (no identity/object pill) with the wallet pill +/// subdued (unwired). For pages with no identity context (e.g. Wallets) — +/// FR-GLOBAL-NAV-2 rule 4. +pub fn subdued_wallet_only_spec(label: impl Into, target: RootScreenType) -> PageNavSpec { + PageNavSpec::new(label, target).with_wallet_pill(PillConsumption::Unwired { + tooltip: TT_WALLET_UNWIRED.to_string(), + }) +} + +/// Apply a generalized global-nav effect: wallet/identity selection updates the +/// **app-global** selection silently (no forced navigation — FR-GLOBAL-NAV-2 +/// rule 1); segment-1 navigation and add-flows route to the existing screens. +/// +/// The shared successor to the hub's `apply_breadcrumb_effect`. The hub keeps +/// its own richer applier (it also resets identity-scoped caches and opens the +/// picker); every other root page uses this one. +pub fn apply_global_nav_effect( + app_context: &Arc, + effect: GlobalNavEffect, +) -> AppAction { + match effect { + GlobalNavEffect::None => AppAction::None, + GlobalNavEffect::NavigateToRoot(target) => AppAction::SetMainScreen(target), + // Silent app-scoped write, NO forced navigation (FR-GLOBAL-NAV-2 rule + // 1). `set_selected_hd_wallet` also reconciles the app-global *identity* + // to the new wallet's identities as a side effect (keep-if-owned → + // first → None). On a non-Hub page this cross-axis mutation is real and + // intentional; combined with the resolution-layer MN/Evonode filter it + // must never reconcile onto a masternode/evonode identity — the FR-6 + // boundary is enforced there, not re-checked here. + GlobalNavEffect::SwitchWallet(hash) => { + app_context.set_selected_hd_wallet(Some(hash)); + AppAction::None + } + GlobalNavEffect::SelectIdentity(id) => { + app_context.set_selected_identity(Some(id)); + AppAction::None + } + // The page-scoped object selection is owned by its page (B7) and never + // writes `AppContext::selected_identity_id`. Unwired pages never emit it. + GlobalNavEffect::SelectPageObject(_) => AppAction::None, + GlobalNavEffect::AddWallet => { + AppAction::SetMainScreen(RootScreenType::RootScreenWalletsBalances) + } + GlobalNavEffect::AddIdentityCreate | GlobalNavEffect::CreateTestIdentities => { + AppAction::AddScreen(ScreenType::AddNewIdentity.create_screen(app_context)) + } + GlobalNavEffect::AddIdentityLoad => { + AppAction::AddScreen(ScreenType::AddExistingIdentity.create_screen(app_context)) + } + } +} + +/// Render the top panel with the global-nav switcher for `spec`, then apply its +/// effect. The one-call entry point every non-Hub root screen uses in place of +/// [`add_top_panel`]. Unwired specs compose no interactive dropdown, so a +/// throwaway per-frame search buffer suffices; interactive pages (the Hub, +/// later Masternodes) own a persistent [`HubSelection`] and wire the effect +/// themselves. +pub fn add_top_panel_with_global_nav( + ui: &mut Ui, + app_context: &Arc, + spec: PageNavSpec, + right_buttons: Vec<(&str, DesiredAppAction)>, +) -> AppAction { + let mut effect = GlobalNavEffect::None; + let mut selection = HubSelection::default(); + let mut action = render_top_island( + ui, + app_context, + |ui| { + effect = global_nav_switcher::render(ui, app_context, &spec, &mut selection); + AppAction::None + }, + right_buttons, + ); + action |= apply_global_nav_effect(app_context, effect); + action +} + +/// Like [`add_top_panel_with_global_nav`], but also returns the page-scoped +/// object the user picked from an interactive page-scoped-object pill, if any. +/// This is the documented consumer of the page-scoped-object boundary pattern +/// (`IdentityPillScope::PageScopedObject` → `SelectPageObject`) for a page whose +/// breadcrumb carries an object pill: all other effects (segment-1 nav, wallet +/// switch) are applied here as usual, while `SelectPageObject` is **only** +/// surfaced to the caller — never written to `AppContext::selected_identity_id` +/// (the FR-6 boundary). Returns `(action, picked_page_object)`. +pub fn add_top_panel_with_global_nav_capturing( + ui: &mut Ui, + app_context: &Arc, + spec: PageNavSpec, + right_buttons: Vec<(&str, DesiredAppAction)>, +) -> (AppAction, Option) { + let mut effect = GlobalNavEffect::None; + let mut selection = HubSelection::default(); + let mut action = render_top_island( + ui, + app_context, + |ui| { + effect = global_nav_switcher::render(ui, app_context, &spec, &mut selection); + AppAction::None + }, + right_buttons, + ); + let picked = match effect { + GlobalNavEffect::SelectPageObject(id) => Some(id), + _ => None, + }; + action |= apply_global_nav_effect(app_context, effect); + (action, picked) +} diff --git a/src/ui/dashpay/add_contact_screen.rs b/src/ui/dashpay/add_contact_screen.rs index 6babadae4..d39f9dda2 100644 --- a/src/ui/dashpay/add_contact_screen.rs +++ b/src/ui/dashpay/add_contact_screen.rs @@ -59,9 +59,7 @@ pub struct AddContactScreen { impl AddContactScreen { pub fn new(app_context: Arc) -> Self { // Seed from the app-scoped selected identity (W3 SYNC); fall back to first. - let identities = app_context - .load_local_qualified_identities() - .unwrap_or_default(); + let identities = app_context.load_local_user_identities().unwrap_or_default(); let selected_identity = app_context .selected_identity_id() .and_then(|id| identities.iter().find(|qi| qi.identity.id() == id).cloned()) @@ -93,9 +91,7 @@ impl AddContactScreen { pub fn new_with_identity_id(app_context: Arc, identity_id: String) -> Self { // Seed from the app-scoped selected identity (W3 SYNC); fall back to first. - let identities = app_context - .load_local_qualified_identities() - .unwrap_or_default(); + let identities = app_context.load_local_user_identities().unwrap_or_default(); let selected_identity = app_context .selected_identity_id() .and_then(|id| identities.iter().find(|qi| qi.identity.id() == id).cloned()) @@ -271,7 +267,7 @@ impl ScreenLike for AddContactScreen { // Identity and Key selector let identities = self .app_context - .load_local_qualified_identities() + .load_local_user_identities() .unwrap_or_default(); if identities.is_empty() { @@ -288,7 +284,8 @@ impl ScreenLike for AddContactScreen { ); ui.separator(); - // Identity selector — SYNC: write-back via syncing_global on user pick. + // Identity selector — SYNC: write-back via syncing_global on user pick (FR-6: + // User-only source, so no masternode can leak to the app-global identity). let response = ui.add( IdentitySelector::new( "contact_sender_identity_selector", diff --git a/src/ui/dashpay/contact_requests.rs b/src/ui/dashpay/contact_requests.rs index 10c1a8f34..22ae1ea02 100644 --- a/src/ui/dashpay/contact_requests.rs +++ b/src/ui/dashpay/contact_requests.rs @@ -94,7 +94,7 @@ impl ContactRequests { }; // Seed from the app-scoped selected identity (W3 SYNC); fall back to first. - if let Ok(identities) = app_context.load_local_qualified_identities() + if let Ok(identities) = app_context.load_local_user_identities() && !identities.is_empty() { use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -273,7 +273,7 @@ impl ContactRequests { // Seed from the app-scoped selected identity if none yet selected (W3 SYNC). if self.selected_identity.is_none() - && let Ok(identities) = self.app_context.load_local_qualified_identities() + && let Ok(identities) = self.app_context.load_local_user_identities() && !identities.is_empty() { use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -362,7 +362,7 @@ impl ContactRequests { // Identity selector or no identities message let identities = self .app_context - .load_local_qualified_identities() + .load_local_user_identities() .unwrap_or_default(); // Header with identity selector on the right (only shown when not embedded) @@ -372,7 +372,8 @@ impl ContactRequests { if !identities.is_empty() { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // SYNC: write-back via syncing_global on user pick. + // SYNC: write-back via syncing_global on user pick (FR-6: the source list is + // User-only, so a masternode/evonode can never leak to the app-global identity). let response = ui.add( IdentitySelector::new( "requests_identity_selector", diff --git a/src/ui/dashpay/contacts_list.rs b/src/ui/dashpay/contacts_list.rs index 910d221e1..0c5ee3efa 100644 --- a/src/ui/dashpay/contacts_list.rs +++ b/src/ui/dashpay/contacts_list.rs @@ -97,7 +97,7 @@ impl ContactsList { }; // Seed from the app-scoped selected identity (W3 SYNC); fall back to first. - if let Ok(identities) = app_context.load_local_qualified_identities() + if let Ok(identities) = app_context.load_local_user_identities() && !identities.is_empty() { let selected_id = app_context.selected_identity_id(); @@ -199,7 +199,7 @@ impl ContactsList { // Seed from the app-scoped selected identity if none yet selected (W3 SYNC). if self.selected_identity.is_none() - && let Ok(identities) = self.app_context.load_local_qualified_identities() + && let Ok(identities) = self.app_context.load_local_user_identities() && !identities.is_empty() { let selected_id = self.app_context.selected_identity_id(); @@ -236,7 +236,7 @@ impl ContactsList { // Identity selector let identities = self .app_context - .load_local_qualified_identities() + .load_local_user_identities() .unwrap_or_default(); // Header section with identity selector on the right @@ -246,7 +246,8 @@ impl ContactsList { if !identities.is_empty() { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // SYNC: write-back via syncing_global on user pick. + // SYNC: write-back via syncing_global on user pick (FR-6: the source list is + // User-only, so a masternode/evonode can never leak to the app-global identity). let response = ui.add( IdentitySelector::new( "contacts_identity_selector", diff --git a/src/ui/dashpay/dashpay_screen.rs b/src/ui/dashpay/dashpay_screen.rs index 0015b7167..fd60c954b 100644 --- a/src/ui/dashpay/dashpay_screen.rs +++ b/src/ui/dashpay/dashpay_screen.rs @@ -5,7 +5,7 @@ use crate::context::AppContext; use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; -use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::top_panel::{add_top_panel_with_global_nav, subdued_everyday_spec}; use crate::ui::{MessageType, RootScreenType, ScreenLike}; use egui::Ui; use std::sync::Arc; @@ -107,10 +107,11 @@ impl ScreenLike for DashPayScreen { DashPaySubscreen::ProfileSearch => vec![], }; - action |= add_top_panel( + // TODO: wire wallet/identity selection consumption for the DashPay page. + action |= add_top_panel_with_global_nav( ui, &self.app_context, - vec![("DashPay", AppAction::None)], + subdued_everyday_spec("DashPay", RootScreenType::RootScreenDashpay), right_buttons, ); diff --git a/src/ui/dashpay/profile_screen.rs b/src/ui/dashpay/profile_screen.rs index 6c0025329..4c3293837 100644 --- a/src/ui/dashpay/profile_screen.rs +++ b/src/ui/dashpay/profile_screen.rs @@ -111,7 +111,7 @@ impl ProfileScreen { // Seed from the app-scoped selected identity (W3 SYNC); fall back to first. // Profile is loaded asynchronously by `LoadProfile` dispatch in `render()`. - if let Ok(identities) = app_context.load_local_qualified_identities() + if let Ok(identities) = app_context.load_local_user_identities() && !identities.is_empty() { use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -184,7 +184,7 @@ impl ProfileScreen { // Seed from the app-scoped selected identity if none yet selected (W3 SYNC). if self.selected_identity.is_none() - && let Ok(identities) = self.app_context.load_local_qualified_identities() + && let Ok(identities) = self.app_context.load_local_user_identities() && !identities.is_empty() { use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -353,7 +353,7 @@ impl ProfileScreen { // Identity selector or no identities message let identities = self .app_context - .load_local_qualified_identities() + .load_local_user_identities() .unwrap_or_default(); // Header with identity selector on the right @@ -362,7 +362,8 @@ impl ProfileScreen { if !identities.is_empty() { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // SYNC: write-back via syncing_global on user pick. + // SYNC: write-back via syncing_global on user pick (FR-6: the source list is + // User-only, so a masternode/evonode can never leak to the app-global identity). let response = ui.add( IdentitySelector::new( "profile_identity_selector", diff --git a/src/ui/dashpay/profile_search.rs b/src/ui/dashpay/profile_search.rs index 6b305c6f4..526ddc4d0 100644 --- a/src/ui/dashpay/profile_search.rs +++ b/src/ui/dashpay/profile_search.rs @@ -78,7 +78,7 @@ impl ProfileSearchScreen { // Use any available identity for viewing (just needed for context) let identities = self .app_context - .load_local_qualified_identities() + .load_local_user_identities() .unwrap_or_default(); if identities.is_empty() { crate::ui::components::MessageBanner::set_global( diff --git a/src/ui/dashpay/qr_code_generator.rs b/src/ui/dashpay/qr_code_generator.rs index 5d9a4888b..711d43f6e 100644 --- a/src/ui/dashpay/qr_code_generator.rs +++ b/src/ui/dashpay/qr_code_generator.rs @@ -68,7 +68,7 @@ impl QRCodeGeneratorScreen { }; // Seed from the app-scoped selected identity (W3 SYNC); fall back to first. - if let Ok(identities) = app_context.load_local_qualified_identities() + if let Ok(identities) = app_context.load_local_user_identities() && !identities.is_empty() { use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -160,7 +160,7 @@ impl QRCodeGeneratorScreen { // Identity selector let identities = self .app_context - .load_local_qualified_identities() + .load_local_user_identities() .unwrap_or_default(); if identities.is_empty() { @@ -186,7 +186,8 @@ impl QRCodeGeneratorScreen { RichText::new("Identity:").color(DashColors::text_primary(dark_mode)), ); ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { - // SYNC: write-back via syncing_global on user pick. + // SYNC: write-back via syncing_global on user pick (FR-6: the source list is + // User-only, so a masternode/evonode can never leak to the app-global identity). let response = ui.add( IdentitySelector::new( "qr_identity_selector", diff --git a/src/ui/dashpay/qr_scanner.rs b/src/ui/dashpay/qr_scanner.rs index 27d50a574..4215eea65 100644 --- a/src/ui/dashpay/qr_scanner.rs +++ b/src/ui/dashpay/qr_scanner.rs @@ -38,9 +38,7 @@ pub struct QRScannerScreen { impl QRScannerScreen { pub fn new(app_context: Arc) -> Self { // Seed from the app-scoped selected identity (W3 SYNC); fall back to first. - let identities = app_context - .load_local_qualified_identities() - .unwrap_or_default(); + let identities = app_context.load_local_user_identities().unwrap_or_default(); let selected_identity = { use dash_sdk::dpp::identity::accessors::IdentityGettersV0; app_context @@ -171,7 +169,7 @@ impl QRScannerScreen { // Identity selector let identities = self .app_context - .load_local_qualified_identities() + .load_local_user_identities() .unwrap_or_default(); if identities.is_empty() { @@ -190,7 +188,8 @@ impl QRScannerScreen { ui.horizontal(|ui| { ui.label("Identity:"); - // SYNC: write-back via syncing_global on user pick. + // SYNC: write-back via syncing_global on user pick (FR-6: the source list is + // User-only, so a masternode/evonode can never leak to the app-global identity). ui.add( IdentitySelector::new( "qr_scanner_identity_selector", diff --git a/src/ui/dashpay/send_payment.rs b/src/ui/dashpay/send_payment.rs index c09f9caeb..4aa8d4ac3 100644 --- a/src/ui/dashpay/send_payment.rs +++ b/src/ui/dashpay/send_payment.rs @@ -490,7 +490,7 @@ impl PaymentHistory { }; // Seed from the app-scoped selected identity (W3 SYNC); fall back to first. - if let Ok(identities) = app_context.load_local_qualified_identities() + if let Ok(identities) = app_context.load_local_user_identities() && !identities.is_empty() { use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -531,7 +531,7 @@ impl PaymentHistory { // Seed from the app-scoped selected identity if none yet selected (W3 SYNC). if self.selected_identity.is_none() - && let Ok(identities) = self.app_context.load_local_qualified_identities() + && let Ok(identities) = self.app_context.load_local_user_identities() && !identities.is_empty() { use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -561,7 +561,7 @@ impl PaymentHistory { // Identity selector or no identities message let identities = self .app_context - .load_local_qualified_identities() + .load_local_user_identities() .unwrap_or_default(); // Header with identity selector on the right @@ -570,7 +570,8 @@ impl PaymentHistory { if !identities.is_empty() { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // SYNC: write-back via syncing_global on user pick. + // SYNC: write-back via syncing_global on user pick (FR-6: the source list is + // User-only, so a masternode/evonode can never leak to the app-global identity). let response = ui.add( IdentitySelector::new( "payment_history_identity_selector", diff --git a/src/ui/dpns/dpns_contested_names_screen.rs b/src/ui/dpns/dpns_contested_names_screen.rs index 805d7b32b..290f441d8 100644 --- a/src/ui/dpns/dpns_contested_names_screen.rs +++ b/src/ui/dpns/dpns_contested_names_screen.rs @@ -23,7 +23,7 @@ use crate::ui::components::dpns_subscreen_chooser_panel::add_dpns_subscreen_choo use crate::ui::components::left_panel::add_left_panel; 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; +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::theme::{ComponentStyles, DashColors, ResponseExt}; @@ -2011,10 +2011,11 @@ impl ScreenLike for DPNSScreen { ); } - let mut action = add_top_panel( + // TODO: wire wallet/identity selection consumption for the DPNS page. + let mut action = add_top_panel_with_global_nav( ui, &self.app_context, - vec![("DPNS", AppAction::None)], + subdued_everyday_spec("DPNS", RootScreenType::RootScreenDPNSActiveContests), right_buttons, ); diff --git a/src/ui/identities/add_existing_identity_screen.rs b/src/ui/identities/add_existing_identity_screen.rs index 4e36660a8..e953ca841 100644 --- a/src/ui/identities/add_existing_identity_screen.rs +++ b/src/ui/identities/add_existing_identity_screen.rs @@ -18,60 +18,12 @@ use crate::ui::identities::funding_common::wallet_selection_combo; use crate::ui::theme::{ComponentStyles, DashColors}; use crate::ui::{MessageType, ScreenLike}; use crate::wallet_backend::poison::RwLockRecover; -use bip39::rand::{prelude::IteratorRandom, thread_rng}; -use dash_sdk::dashcore_rpc::dashcore::Network; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::Identifier; use egui::{Color32, ComboBox, RichText, Ui}; -use serde::Deserialize; -use std::fs; use std::sync::atomic::Ordering; use std::sync::{Arc, RwLock}; -#[derive(Debug, Clone, Deserialize)] -struct MasternodeInfo { - #[serde(rename = "pro-tx-hash")] - pro_tx_hash: String, - owner: KeyInfo, - voter: KeyInfo, -} - -#[derive(Debug, Clone, Deserialize)] -struct HPMasternodeInfo { - #[serde(rename = "protx-tx-hash")] - protx_tx_hash: String, - owner: KeyInfo, - voter: KeyInfo, - payout: KeyInfo, -} - -#[derive(Debug, Clone, Deserialize)] -struct KeyInfo { - #[serde(rename = "private_key")] - private_key: String, -} - -#[derive(Debug, Clone, Deserialize)] -struct TestnetNodes { - masternodes: std::collections::HashMap, - hp_masternodes: std::collections::HashMap, -} - -fn load_testnet_nodes_from_yml(file_path: &str) -> Result, String> { - let file_content = match fs::read_to_string(file_path) { - Ok(content) => content, - Err(_) => return Ok(None), - }; - serde_yaml_ng::from_str::(&file_content) - .map(Some) - .map_err(|e| { - format!( - "Failed to parse YAML file '{}': {}. Please check the file format.", - file_path, e - ) - }) -} - #[derive(Clone, Copy, PartialEq, Eq)] enum LoadIdentityMode { IdentityId, @@ -130,7 +82,6 @@ pub struct AddExistingIdentityScreen { payout_address_private_key_input: PasswordInput, keys_input: Vec, add_identity_status: AddIdentityStatus, - testnet_loaded_nodes: Option, selected_wallet: Option>>, identity_associated_with_wallet: bool, wallet_unlock_popup: WalletUnlockPopup, @@ -150,17 +101,6 @@ pub struct AddExistingIdentityScreen { impl AddExistingIdentityScreen { pub fn new(app_context: &Arc) -> Self { let selected_wallet = app_context.wallets.read_recover().values().next().cloned(); - let (testnet_loaded_nodes, init_error) = if app_context.network == Network::Testnet { - match load_testnet_nodes_from_yml(".testnet_nodes.yml") { - Ok(nodes) => (nodes, None), - Err(e) => (None, Some(e)), - } - } else { - (None, None) - }; - if let Some(err) = init_error { - MessageBanner::set_global(app_context.egui_ctx(), &err, MessageType::Error); - } Self { identity_id_input: String::new(), identity_type: IdentityType::User, @@ -176,7 +116,6 @@ impl AddExistingIdentityScreen { .with_monospace(), keys_input: vec![], add_identity_status: AddIdentityStatus::NotStarted, - testnet_loaded_nodes, selected_wallet, identity_associated_with_wallet: true, wallet_unlock_popup: WalletUnlockPopup::new(), @@ -196,22 +135,6 @@ impl AddExistingIdentityScreen { fn render_by_identity(&mut self, ui: &mut Ui) -> AppAction { let mut action = AppAction::None; - // Advanced: Testnet quick-fill buttons - if self.show_advanced_options - && self.app_context.network == Network::Testnet - && self.testnet_loaded_nodes.is_some() - { - ui.horizontal(|ui| { - if ui.button("Fill Random HPMN").clicked() { - self.fill_random_hpmn(); - } - if ui.button("Fill Random Masternode").clicked() { - self.fill_random_masternode(); - } - }); - ui.add_space(10.0); - } - let wallets_snapshot: Vec<(String, Arc>)> = { let wallets_guard = self.app_context.wallets.read_recover(); wallets_guard @@ -379,21 +302,20 @@ impl AddExistingIdentityScreen { // Advanced: Identity Type selector if self.show_advanced_options { + // This generic screen loads User identities only. Masternode + // and Evonode identities have a dedicated flow on the + // Masternodes tab (`ui/masternodes/load_form.rs`), so the old + // Masternode/Evonode options are removed here to avoid a + // second, competing entry point (§10.2 / TC-FR4-22, FR-6). ui.label("Identity Type:"); ui.with_layout(egui::Layout::top_down(egui::Align::LEFT), |ui| { egui::ComboBox::from_id_salt("identity_type_selector") .selected_text(format!("{:?}", self.identity_type)) .show_ui(ui, |ui| { - ui.selectable_value(&mut self.identity_type, IdentityType::User, "User"); ui.selectable_value( &mut self.identity_type, - IdentityType::Masternode, - "Masternode", - ); - ui.selectable_value( - &mut self.identity_type, - IdentityType::Evonode, - "Evonode", + IdentityType::User, + "User", ); }); }); @@ -944,45 +866,18 @@ impl AddExistingIdentityScreen { .collect(), derive_keys_from_wallets: self.identity_associated_with_wallet, selected_wallet_seed_hash, + // Legacy load screen has no password field; the optional load-time + // encryption (FR-8) is exposed on the new Masternodes load form (B4). + encryption_password: None, + // Legacy User re-load: preserve the historical overwrite/upsert + // behaviour (re-loading to add keys is a supported User workflow). + load_mode: crate::backend_task::identity::IdentityLoadMode::Overwrite, }; AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::LoadIdentity( identity_input, ))) } - fn fill_random_hpmn(&mut self) { - if let Some((name, hpmn)) = self - .testnet_loaded_nodes - .as_ref() - .and_then(|nodes| nodes.hp_masternodes.iter().choose(&mut thread_rng())) - { - self.identity_id_input = hpmn.protx_tx_hash.clone(); - self.identity_type = IdentityType::Evonode; - self.alias_input = name.clone(); - self.voting_private_key_input - .set_text(hpmn.voter.private_key.clone()); - self.owner_private_key_input - .set_text(hpmn.owner.private_key.clone()); - self.payout_address_private_key_input - .set_text(hpmn.payout.private_key.clone()); - } - } - - fn fill_random_masternode(&mut self) { - if let Some((name, masternode)) = self - .testnet_loaded_nodes - .as_ref() - .and_then(|nodes| nodes.masternodes.iter().choose(&mut thread_rng())) - { - self.identity_id_input = masternode.pro_tx_hash.clone(); - self.identity_type = IdentityType::Masternode; - self.alias_input = name.clone(); - self.voting_private_key_input - .set_text(masternode.voter.private_key.clone()); - self.owner_private_key_input - .set_text(masternode.owner.private_key.clone()); - } - } pub fn show_success(&mut self, ui: &mut Ui) -> AppAction { let success_text = self diff --git a/src/ui/identities/identities_screen.rs b/src/ui/identities/identities_screen.rs index 90784d731..73b1b0eab 100644 --- a/src/ui/identities/identities_screen.rs +++ b/src/ui/identities/identities_screen.rs @@ -10,7 +10,7 @@ use crate::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedId use crate::model::wallet::WalletSeedHash; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::{ConfirmationDialog, ConfirmationStatus, island_central_panel}; -use crate::ui::components::top_panel::add_top_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::helpers::clicked_outside_window; use crate::ui::identities::keys::add_key_screen::AddKeyScreen; @@ -1121,10 +1121,11 @@ impl ScreenLike for IdentitiesScreen { )); } - let mut action = add_top_panel( + // TODO: wire wallet/identity selection consumption for the Identities page. + let mut action = add_top_panel_with_global_nav( ui, &self.app_context, - vec![("Identities", AppAction::None)], + subdued_everyday_spec("Identities", RootScreenType::RootScreenIdentities), right_buttons, ); diff --git a/src/ui/identities/withdraw_screen.rs b/src/ui/identities/withdraw_screen.rs index a2d1cb428..493ffe53f 100644 --- a/src/ui/identities/withdraw_screen.rs +++ b/src/ui/identities/withdraw_screen.rs @@ -67,19 +67,39 @@ pub struct WithdrawalScreen { impl WithdrawalScreen { pub fn new(identity: QualifiedIdentity, app_context: &Arc) -> Self { let max_amount = identity.identity.balance(); - let identity_clone = identity.identity.clone(); - let selected_key = identity_clone.get_first_public_key_matching( - Purpose::TRANSFER, - SecurityLevel::full_range().into(), - KeyType::all_key_types().into(), - false, - ); - let selected_wallet = get_selected_wallet(&identity, None, selected_key) - .or_show_error(app_context.egui_ctx()) - .unwrap_or(None); + // Only pre-select a withdrawal key whose private material is held locally + // (TRANSFER preferred, OWNER fallback). Pre-selecting an on-chain-only key + // the signer cannot use is what surfaced the raw signing error. + let selected_key: Option = identity + .default_withdrawal_key() + .map(|qk| qk.identity_public_key.clone()) + .or_else(|| { + // Developer mode may sign with any on-chain key; keep the + // power-user escape hatch instead of leaving the form blank. + app_context + .is_developer_mode() + .then(|| { + identity.identity.get_first_public_key_matching( + Purpose::TRANSFER, + SecurityLevel::full_range().into(), + KeyType::all_key_types().into(), + false, + ) + }) + .flatten() + .cloned() + }); + // With no key there is nothing to resolve a wallet from; skip the call so + // get_selected_wallet's "no key provided" Err path stays unreachable here. + let selected_wallet = match selected_key.as_ref() { + Some(key) => get_selected_wallet(&identity, None, Some(key)) + .or_show_error(app_context.egui_ctx()) + .unwrap_or(None), + None => None, + }; Self { identity, - selected_key: selected_key.cloned(), + selected_key, withdrawal_address: String::new(), withdrawal_address_error: None, withdrawal_amount: None, diff --git a/src/ui/identity/breadcrumb_switcher.rs b/src/ui/identity/breadcrumb_switcher.rs index 159b80413..fcb4c3691 100644 --- a/src/ui/identity/breadcrumb_switcher.rs +++ b/src/ui/identity/breadcrumb_switcher.rs @@ -1,33 +1,26 @@ -//! The Identities-hub breadcrumb switcher (IDH-003). +//! Hub-facing shim over the generalized [`global_nav_switcher`]. //! -//! Composes `Identities` link › wallet pill › identity pill, owns the wallet -//! and identity dropdown `Popup`s, and returns a typed [`BreadcrumbEffect`]. -//! It is a pure UI component — it reads the app-scoped selection from -//! `AppContext` and reports an effect; the hub applies it (components render, -//! screens decide). +//! The Identities hub keeps its original `BreadcrumbEffect` API and behavior: +//! this module builds the hub's [`PageNavSpec`] (interactive wallet + app-global +//! identity pills, `Identities` segment-1), delegates rendering to +//! [`global_nav_switcher::render`], and maps the generalized +//! [`GlobalNavEffect`] back to [`BreadcrumbEffect`]. A self-navigation to the +//! hub root (the `Identities` link) maps to [`BreadcrumbEffect::OpenPicker`], +//! preserving the pre-generalization behavior. //! -//! Per-state modes follow design-spec §A.3 / §7; tooltips are verbatim from -//! design-spec §D (§7.1). Wallet-scoped identity lists use the *stored* -//! `wallet_hash` filter, never `associated_wallets.keys().next()` (R1). +//! [`global_nav_switcher`]: crate::ui::components::global_nav_switcher -use super::identity_hero_card::HeroIdentityKind; -use super::identity_pill::{IdentityPill, display_label}; use crate::context::AppContext; -use crate::model::qualified_identity::QualifiedIdentity; -use crate::ui::components::breadcrumb_pill::{BreadcrumbPill, BreadcrumbPillMode}; +use crate::ui::RootScreenType; +use crate::ui::components::global_nav_switcher::{self, GlobalNavEffect}; +use crate::ui::state::global_nav::{IdentityPillScope, PageNavSpec, PillConsumption}; use crate::ui::state::hub_selection::HubSelection; -use crate::ui::theme::DashColors; -use dash_sdk::dpp::identity::accessors::IdentityGettersV0; -use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::Identifier; -use eframe::egui::{self, RichText, Sense, Ui}; +use eframe::egui::Ui; use std::sync::Arc; use crate::model::wallet::WalletSeedHash; -/// Inline search appears once a wallet's identity list reaches this size (§A.3). -const SEARCH_THRESHOLD: usize = 7; - /// A typed switcher outcome the hub applies. Switching is hub-internal; add /// flows reuse existing `AppAction`s through the hub. #[derive(Debug, Clone, PartialEq, Eq)] @@ -50,354 +43,94 @@ pub enum BreadcrumbEffect { CreateTestIdentities, } -/// Wallet-pill mode by HD-wallet count: 0 → placeholder, 1 → subdued (info -/// only), ≥2 → interactive (opens the wallet dropdown). §A.3 / §7. -fn wallet_pill_mode(wallet_count: usize) -> BreadcrumbPillMode { - match wallet_count { - 0 => BreadcrumbPillMode::Placeholder, - 1 => BreadcrumbPillMode::Subdued, - _ => BreadcrumbPillMode::Interactive, - } -} - -/// tt-2 — interactive wallet pill (≥2 wallets). Verbatim, design-spec §D. -fn tt_wallet_interactive() -> &'static str { - "Switch between your wallets. Each wallet can own several identities." -} - -/// tt-3 — subdued wallet pill (exactly 1 wallet). Verbatim, design-spec §D #3 -/// (the brief's "…to switch between them." is a paraphrase — this is canonical). -fn tt_wallet_subdued(wallet_name: &str) -> String { - format!( - "This identity is funded by {wallet_name}. Set up another wallet on the Wallets screen \ - to unlock switching." - ) +/// The hub's page-nav spec: `Identities` segment-1 linking to the hub root, an +/// interactive (consumed) wallet pill, and the app-global identity pill. +fn hub_spec() -> PageNavSpec { + PageNavSpec::new("Identities", RootScreenType::RootScreenIdentityHub) + .with_wallet_pill(PillConsumption::Consumed) + .with_identity_pill(IdentityPillScope::AppGlobalUser, PillConsumption::Consumed) } -/// tt-4 — interactive identity pill. Verbatim, design-spec §D. -fn tt_identity(wallet_name: &str) -> String { - format!("Switch between identities in {wallet_name} or add a new one.") -} - -/// Short hex of a seed hash, for a wallet with no alias. -fn short_hex(hash: &WalletSeedHash) -> String { - let mut s = String::with_capacity(10); - for b in hash.iter().take(4) { - s.push_str(&format!("{b:02x}")); +/// Map a generalized effect to the hub's `BreadcrumbEffect`. A self-navigation +/// to the hub root is the `Identities` link → open the picker. +fn map_effect(effect: GlobalNavEffect) -> BreadcrumbEffect { + match effect { + GlobalNavEffect::None => BreadcrumbEffect::None, + GlobalNavEffect::NavigateToRoot(RootScreenType::RootScreenIdentityHub) => { + BreadcrumbEffect::OpenPicker + } + // The hub's segment-1 only ever targets the hub itself. + GlobalNavEffect::NavigateToRoot(_) => BreadcrumbEffect::None, + GlobalNavEffect::SwitchWallet(hash) => BreadcrumbEffect::SwitchWallet(hash), + GlobalNavEffect::SelectIdentity(id) => BreadcrumbEffect::SelectIdentity(id), + // The hub never composes a page-scoped object pill. + GlobalNavEffect::SelectPageObject(_) => BreadcrumbEffect::None, + GlobalNavEffect::AddWallet => BreadcrumbEffect::AddWallet, + GlobalNavEffect::AddIdentityCreate => BreadcrumbEffect::AddIdentityCreate, + GlobalNavEffect::AddIdentityLoad => BreadcrumbEffect::AddIdentityLoad, + GlobalNavEffect::CreateTestIdentities => BreadcrumbEffect::CreateTestIdentities, } - s.push('…'); - s -} - -/// Loaded HD wallets as `(seed_hash, display_name)`, sorted by hash for a -/// stable order. Name = alias, else a short hex of the seed hash. -fn gather_wallets(app_context: &Arc) -> Vec<(WalletSeedHash, String)> { - let Ok(wallets) = app_context.wallets.read() else { - return Vec::new(); - }; - wallets - .iter() - .map(|(hash, w)| { - let name = w - .read() - .ok() - .and_then(|w| w.alias.clone()) - .filter(|a| !a.trim().is_empty()) - .unwrap_or_else(|| short_hex(hash)); - (*hash, name) - }) - .collect() } -/// Identity display label (Local nickname → DPNS → short id). -fn identity_label(qi: &QualifiedIdentity) -> String { - let dpns = qi.dpns_names.first().map(|n| n.name.as_str()); - display_label( - qi.alias.as_deref(), - dpns, - &qi.identity.id().to_string(Encoding::Base58), - ) -} - -/// First uppercase alphanumeric of the label, for the avatar monogram. -fn monogram_initial(label: &str) -> Option { - label - .chars() - .find(|c| c.is_alphanumeric()) - .map(|c| c.to_ascii_uppercase()) -} - -/// Render the switcher. Reads the app-scoped selection; mutates only the -/// `selection` search buffers; returns the user's effect for the hub to apply. +/// Render the hub breadcrumb switcher. Delegates to the generalized global-nav +/// switcher with the hub's spec and maps the effect back. pub fn render( ui: &mut Ui, app_context: &Arc, selection: &mut HubSelection, ) -> BreadcrumbEffect { - let dark_mode = ui.ctx().global_style().visuals.dark_mode; - let mut effect = BreadcrumbEffect::None; - - let wallets = gather_wallets(app_context); - let wallet_count = wallets.len(); - - // One per-frame identity load; derive the active identity and the no-wallet - // group from it instead of re-querying. The wallet-scoped list - // still needs its own DB query (the owning `wallet_hash` is not exposed on - // `QualifiedIdentity`, only stored — R1). - let all_identities = app_context - .load_local_qualified_identities() - .unwrap_or_default(); - let all_ids: Vec = all_identities.iter().map(|qi| qi.identity.id()).collect(); - let active_id = app_context.selected_identity_id(); - // The identity pill reflects an *explicitly* chosen identity (or a lone - // auto-selected one). In the ≥2-none-chosen picker state it stays a - // placeholder (§7) — never the first-identity fallback, which would - // duplicate a picker-grid label and disagree with "no identity chosen". - let pill_target_id = crate::model::selected_identity::keep_if_loaded(active_id, &all_ids) - .or_else(|| (all_ids.len() == 1).then(|| all_ids[0])); - let pill_identity = - pill_target_id.and_then(|id| all_identities.iter().find(|qi| qi.identity.id() == id)); - // A wallet-less (imported-by-id) shown identity has no owning wallet. - let active_is_wallet_less = pill_identity.is_some_and(|qi| qi.wallet_index.is_none()); - - // The wallet segment is DERIVED from the active identity (identity-primary). - // A wallet-less active identity → no active wallet → empty wallet segment, - // so the pill never shows a wallet belonging to a different identity. - let active_wallet = if active_is_wallet_less { - None - } else { - app_context - .selected_wallet_hash() - .filter(|h| wallets.iter().any(|(wh, _)| wh == h)) - .or_else(|| wallets.first().map(|(h, _)| *h)) - }; - let active_wallet_name = active_wallet - .and_then(|h| wallets.iter().find(|(wh, _)| *wh == h)) - .map(|(_, n)| n.clone()) - .unwrap_or_default(); - - // Identities owned by the active wallet (stored `wallet_hash` filter — R1). - let scoped: Vec = active_wallet - .and_then(|h| { - app_context - .load_local_qualified_identities_for_wallet(&h) - .ok() - }) - .unwrap_or_default(); - // Identities with no wallet on this device (imported by id). - let no_wallet: Vec = all_identities - .iter() - .filter(|qi| qi.wallet_index.is_none()) - .cloned() - .collect(); - - ui.horizontal(|ui| { - // --- Segment 1: Identities link --------------------------------- - let link = ui.add( - egui::Label::new(RichText::new("Identities").color(DashColors::DASH_BLUE)) - .sense(Sense::click()), - ); - if link.clicked() { - effect = BreadcrumbEffect::OpenPicker; - } - ui.label(RichText::new("›").color(DashColors::text_secondary(dark_mode))); - - // --- Segment 2: wallet pill ------------------------------------- - // A wallet-less active identity has no wallet → empty segment, regardless - // of how many HD wallets exist. - let wallet_mode = if active_is_wallet_less { - BreadcrumbPillMode::Placeholder - } else { - wallet_pill_mode(wallet_count) - }; - match wallet_mode { - BreadcrumbPillMode::Placeholder => { - let label = if active_is_wallet_less { - "(no wallet)" - } else { - "(no wallet yet)" - }; - BreadcrumbPill::placeholder(label).show(ui); - } - BreadcrumbPillMode::Subdued => { - BreadcrumbPill::new(active_wallet_name.clone()) - .with_icon("💼") - .subdued(true) - .with_tooltip(tt_wallet_subdued(&active_wallet_name)) - .show(ui); - } - BreadcrumbPillMode::Interactive => { - let resp = BreadcrumbPill::new(active_wallet_name.clone()) - .with_icon("💼") - .with_tooltip(tt_wallet_interactive()) - .show(ui); - if let Some(anchor) = resp.response.clone() { - let popup_id = ui.make_persistent_id("hub_wallet_switcher"); - egui::Popup::new(popup_id, ui.ctx().clone(), &anchor, anchor.layer_id) - .open_memory(resp.clicked.then_some(egui::SetOpenCommand::Toggle)) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .frame( - egui::Frame::popup(ui.style()).fill(DashColors::popup_fill(dark_mode)), - ) - .show(|ui| { - ui.set_min_width(220.0); - for (h, name) in &wallets { - let is_active = active_wallet == Some(*h); - if ui - .selectable_label(is_active, format!("💼 {name}")) - .clicked() - { - effect = BreadcrumbEffect::SwitchWallet(*h); - ui.close(); - } - } - ui.separator(); - if ui.button("Set up another wallet").clicked() { - effect = BreadcrumbEffect::AddWallet; - ui.close(); - } - }); - } - } - } - - ui.label(RichText::new("›").color(DashColors::text_secondary(dark_mode))); - - // --- Segment 3: identity pill ----------------------------------- - let Some(active_qi) = pill_identity else { - // No identity in scope: placeholder reflects whether a wallet exists. - let label = if wallet_count == 0 { - "(no identity yet)" - } else { - "(choose an identity)" - }; - BreadcrumbPill::placeholder(label).show(ui); - return; - }; - - let label = identity_label(active_qi); - let kind: HeroIdentityKind = active_qi.identity_type.into(); - let dpns = active_qi.dpns_names.first().map(|n| n.name.clone()); - let id_b58 = active_qi.identity.id().to_string(Encoding::Base58); - let resp = IdentityPill::new(active_qi.alias.as_deref(), dpns.as_deref(), &id_b58) - .with_avatar(kind, monogram_initial(&label)) - .with_tooltip(tt_identity(&active_wallet_name)) - .show(ui); - - if let Some(anchor) = resp.response.clone() { - let popup_id = ui.make_persistent_id("hub_identity_switcher"); - egui::Popup::new(popup_id, ui.ctx().clone(), &anchor, anchor.layer_id) - .open_memory(resp.clicked.then_some(egui::SetOpenCommand::Toggle)) - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .frame(egui::Frame::popup(ui.style()).fill(DashColors::popup_fill(dark_mode))) - .show(|ui| { - ui.set_min_width(240.0); - - // Inline search once the scoped list is long (§A.3). - let filter = if scoped.len() >= SEARCH_THRESHOLD { - ui.add( - egui::TextEdit::singleline(selection.identity_search_mut()) - .hint_text("Search identities"), - ); - selection.identity_search().trim().to_lowercase() - } else { - String::new() - }; - - for qi in &scoped { - let row = identity_label(qi); - if !filter.is_empty() && !row.to_lowercase().contains(&filter) { - continue; - } - let id = qi.identity.id(); - let is_active = active_id == Some(id); - if ui.selectable_label(is_active, row).clicked() { - effect = BreadcrumbEffect::SelectIdentity(id); - ui.close(); - } - } - - if !no_wallet.is_empty() { - ui.separator(); - ui.label( - RichText::new("Identities without a wallet on this device") - .small() - .color(DashColors::text_secondary(dark_mode)), - ); - for qi in &no_wallet { - let id = qi.identity.id(); - let is_active = active_id == Some(id); - if ui.selectable_label(is_active, identity_label(qi)).clicked() { - effect = BreadcrumbEffect::SelectIdentity(id); - ui.close(); - } - } - } - - ui.separator(); - if ui.button("Create a new identity").clicked() { - effect = BreadcrumbEffect::AddIdentityCreate; - ui.close(); - } - if ui.button("Load an existing identity").clicked() { - effect = BreadcrumbEffect::AddIdentityLoad; - ui.close(); - } - if app_context.is_developer_mode() - && ui.button("Create multiple test identities").clicked() - { - effect = BreadcrumbEffect::CreateTestIdentities; - ui.close(); - } - }); - } - }); - - effect + map_effect(global_nav_switcher::render( + ui, + app_context, + &hub_spec(), + selection, + )) } #[cfg(test)] mod tests { use super::*; + /// The `Identities` link (self-navigation to the hub root) opens the picker, + /// preserving the pre-generalization hub behavior. #[test] - fn short_hex_is_stable_prefix() { - let h = [0xABu8; 32]; - assert_eq!(short_hex(&h), "abababab…"); - } - - #[test] - fn monogram_initial_picks_first_alphanumeric_uppercase() { - assert_eq!(monogram_initial("alex.dash"), Some('A')); - assert_eq!(monogram_initial(" 9lives"), Some('9')); - assert_eq!(monogram_initial("…"), None); + fn self_navigation_maps_to_open_picker() { + assert_eq!( + map_effect(GlobalNavEffect::NavigateToRoot( + RootScreenType::RootScreenIdentityHub + )), + BreadcrumbEffect::OpenPicker + ); } - /// UT-SWITCH-MODE-01 — wallet-pill mode resolver. + /// The hub never surfaces a page-scoped object selection. #[test] - fn wallet_pill_mode_by_count() { - assert_eq!(wallet_pill_mode(0), BreadcrumbPillMode::Placeholder); - assert_eq!(wallet_pill_mode(1), BreadcrumbPillMode::Subdued); - assert_eq!(wallet_pill_mode(2), BreadcrumbPillMode::Interactive); - assert_eq!(wallet_pill_mode(9), BreadcrumbPillMode::Interactive); + fn page_scoped_effects_are_dropped_on_the_hub() { + assert_eq!( + map_effect(GlobalNavEffect::SelectPageObject(Identifier::new([5; 32]))), + BreadcrumbEffect::None + ); } - /// UT-SWITCH-TT-01 — verbatim tooltip strings (regression guard for the - /// tt-3 design-spec wording; the brief's paraphrase must not creep in). + /// Wallet/identity switches and add flows pass through unchanged. #[test] - fn tooltips_are_verbatim() { + fn common_effects_pass_through() { + assert_eq!( + map_effect(GlobalNavEffect::SwitchWallet([1; 32])), + BreadcrumbEffect::SwitchWallet([1; 32]) + ); + let id = Identifier::new([2; 32]); assert_eq!( - tt_wallet_interactive(), - "Switch between your wallets. Each wallet can own several identities." + map_effect(GlobalNavEffect::SelectIdentity(id)), + BreadcrumbEffect::SelectIdentity(id) ); assert_eq!( - tt_wallet_subdued("Main Wallet"), - "This identity is funded by Main Wallet. Set up another wallet on the Wallets screen \ - to unlock switching." + map_effect(GlobalNavEffect::AddWallet), + BreadcrumbEffect::AddWallet ); assert_eq!( - tt_identity("Main Wallet"), - "Switch between identities in Main Wallet or add a new one." + map_effect(GlobalNavEffect::CreateTestIdentities), + BreadcrumbEffect::CreateTestIdentities ); } } diff --git a/src/ui/identity/hub_screen.rs b/src/ui/identity/hub_screen.rs index 2546acc82..2f5f58113 100644 --- a/src/ui/identity/hub_screen.rs +++ b/src/ui/identity/hub_screen.rs @@ -88,7 +88,9 @@ impl IdentityHubScreen { /// attached separately) and reuse the last-known-good landing instead of /// silently routing the user to onboarding. pub(crate) fn landing(&mut self, ctx: &Context) -> HubLanding { - match self.app_context.load_local_qualified_identities() { + // FR-6: the Identities hub is an everyday-user surface — its landing + // count and picker list User identities only, never masternode/evonode. + match self.app_context.load_local_user_identities() { Ok(identities) => { // Clear any previously-shown error banner now that loading works. self.load_error_banner.take_and_clear(); @@ -211,8 +213,9 @@ impl ScreenLike for IdentityHubScreen { let frame_identities = if matches!(landing, HubLanding::Onboarding) { Vec::new() } else { + // FR-6: User identities only — the picker grid never lists MN/Evonode. self.app_context - .load_local_qualified_identities() + .load_local_user_identities() .unwrap_or_default() }; let view = if matches!(landing, HubLanding::Onboarding) { diff --git a/src/ui/identity/identity_picker_card.rs b/src/ui/identity/identity_picker_card.rs index 5651e42bc..7ccc904b0 100644 --- a/src/ui/identity/identity_picker_card.rs +++ b/src/ui/identity/identity_picker_card.rs @@ -360,7 +360,10 @@ impl IdentityPickerCard { /// Paint a simple circular monogram as a lightweight avatar stand-in. Real /// avatar assets land in a follow-up task (see design-spec §B.14). -fn draw_monogram(ui: &mut Ui, heading: &str, has_social_profile: bool, dark_mode: bool) { +/// +/// Shared with the Masternodes card grid (`ui/masternodes/card.rs`), which +/// reuses the picker's visual language. +pub(crate) fn draw_monogram(ui: &mut Ui, heading: &str, has_social_profile: bool, dark_mode: bool) { let (rect, _response) = ui.allocate_exact_size(Vec2::new(AVATAR_SIZE, AVATAR_SIZE), Sense::hover()); let painter = ui.painter(); @@ -402,7 +405,10 @@ fn draw_monogram(ui: &mut Ui, heading: &str, has_social_profile: bool, dark_mode } /// Paint an identity-type badge pill. Color follows the identity-type. -fn draw_type_badge(ui: &mut Ui, label: &str, dark_mode: bool) { +/// +/// Shared with the Masternodes card grid (`ui/masternodes/card.rs`): +/// `Masternode` → `PLATFORM_PURPLE`, `Evonode` → `DASH_BLUE`, white text. +pub(crate) fn draw_type_badge(ui: &mut Ui, label: &str, dark_mode: bool) { let (fill, stroke_color) = match label { "Masternode" => (DashColors::PLATFORM_PURPLE, DashColors::PLATFORM_PURPLE), "Evonode" => (DashColors::DASH_BLUE, DashColors::DASH_BLUE), diff --git a/src/ui/masternodes/card.rs b/src/ui/masternodes/card.rs new file mode 100644 index 000000000..cc91886b9 --- /dev/null +++ b/src/ui/masternodes/card.rs @@ -0,0 +1,376 @@ +//! Masternode/evonode card for the Masternodes grid — reuses +//! `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::qualified_identity::{IdentityStatus, IdentityType, MasternodeKeyPresence}; +use crate::ui::identity::identity_picker_card::{ + CARD_HEIGHT, CARD_MIN_WIDTH, draw_monogram, draw_type_badge, +}; +use crate::ui::theme::DashColors; +use eframe::egui::{ + self, Color32, CornerRadius, Frame, Margin, Response, RichText, Sense, Stroke, Ui, Vec2, + WidgetInfo, WidgetType, +}; + +/// Heading for a masternode card: the alias when set, otherwise the shortened +/// ProTxHash. Mirrors TC-FR3-02 / TC-FR3-03. +pub fn card_heading(alias: Option<&str>, shortened_pro_tx_hash: &str) -> String { + match alias.map(str::trim).filter(|s| !s.is_empty()) { + Some(alias) => alias.to_string(), + None => shortened_pro_tx_hash.to_string(), + } +} + +/// Sub-line for a masternode card: the shortened ProTxHash shown beneath the +/// heading only when an alias provides the heading. When the ProTxHash is +/// already the heading it is not repeated. Mirrors TC-FR3-03. +pub fn card_sub_line(alias: Option<&str>, shortened_pro_tx_hash: &str) -> Option { + alias + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|_| shortened_pro_tx_hash.to_string()) +} + +/// Voter-readiness label. Green + `Voting ready` when a voting key is loaded, +/// warning + `No voting key` otherwise (§7 copy). +pub fn voter_readiness_label(voting_present: bool) -> &'static str { + if voting_present { + "Voting ready" + } else { + "No voting key" + } +} + +/// DPNS status line with count-first precedence (§10.1): open contests first +/// (actionable), then a pending scheduled vote, then none. +pub fn dpns_status_line(summary: MasternodeContestSummary) -> String { + if summary.open_contest_count > 0 { + format!("{} contests to vote on", summary.open_contest_count) + } else if summary.has_scheduled_vote { + "Vote scheduled".to_string() + } else { + "No open contests".to_string() + } +} + +/// The three `Keys:` role tokens in display order, each paired with whether it +/// is present. Present roles render as their letter, absent roles as `·`. +pub fn key_status_tokens(presence: MasternodeKeyPresence) -> [(&'static str, bool); 3] { + [ + ("V", presence.voting), + ("O", presence.owner), + ("P", presence.payout), + ] +} + +/// Response from [`MasternodeCard::show`]: whether the card was activated, plus +/// the node id echoed from construction so a grid can route selection without +/// tracking indices. +#[derive(Clone, Debug)] +pub struct MasternodeCardResponse { + pub clicked: bool, + pub node_id: String, +} + +/// A single masternode/evonode card. Built from already-resolved display data +/// so the component stays free of `AppContext` and is unit-testable. +#[derive(Clone, Debug)] +pub struct MasternodeCard { + node_id: String, + node_id_short: String, + alias: Option, + node_type: IdentityType, + key_presence: MasternodeKeyPresence, + contest_summary: MasternodeContestSummary, + status: IdentityStatus, +} + +impl MasternodeCard { + pub fn new( + node_id: impl Into, + node_id_short: impl Into, + node_type: IdentityType, + key_presence: MasternodeKeyPresence, + contest_summary: MasternodeContestSummary, + status: IdentityStatus, + ) -> Self { + Self { + node_id: node_id.into(), + node_id_short: node_id_short.into(), + alias: None, + node_type, + key_presence, + contest_summary, + status, + } + } + + pub fn with_alias(mut self, alias: Option) -> Self { + self.alias = alias.filter(|s| !s.trim().is_empty()); + self + } + + /// Heading string — exposed for tests (TC-FR3-02 / 03). + pub fn heading(&self) -> String { + card_heading(self.alias.as_deref(), &self.node_id_short) + } + + /// Sub-line string — exposed for tests. + pub fn sub_line(&self) -> Option { + card_sub_line(self.alias.as_deref(), &self.node_id_short) + } + + /// Badge pill text (`Masternode` / `Evonode`). + pub fn badge_label(&self) -> &'static str { + match self.node_type { + IdentityType::Evonode => "Evonode", + // A User identity never reaches this grid; fall back to Masternode. + _ => "Masternode", + } + } + + pub fn show(&self, ui: &mut Ui) -> MasternodeCardResponse { + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + let border = Stroke::new(1.0, DashColors::border(dark_mode)); + let fill = DashColors::surface(dark_mode); + let heading = self.heading(); + let heading_for_a11y = heading.clone(); + + let frame = Frame::new() + .fill(fill) + .stroke(border) + .corner_radius(CornerRadius::same(16)) + .inner_margin(Margin::symmetric(16, 16)); + + let desired_size = Vec2::new(CARD_MIN_WIDTH, CARD_HEIGHT); + + let inner = frame.show(ui, |ui| { + ui.set_min_size(desired_size); + ui.set_max_width(desired_size.x); + ui.vertical(|ui| { + // Top row: monogram (left) + type badge (right). + ui.horizontal(|ui| { + draw_monogram(ui, &heading, false, dark_mode); + ui.add_space(8.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| { + draw_type_badge(ui, self.badge_label(), dark_mode); + }); + }); + ui.add_space(12.0); + + // Heading + optional ProTxHash sub-line. + ui.label( + RichText::new(&heading) + .color(DashColors::text_primary(dark_mode)) + .strong() + .size(16.0), + ); + if let Some(sub) = self.sub_line() { + ui.label( + RichText::new(sub) + .color(DashColors::text_secondary(dark_mode)) + .size(13.0), + ); + } + ui.add_space(8.0); + + // Voter readiness (colour + text — NFR-6). + let voting = self.key_presence.voting; + let (voter_color, voter_label) = if voting { + ( + DashColors::success_color(dark_mode), + voter_readiness_label(true), + ) + } else { + ( + DashColors::warning_color(dark_mode), + voter_readiness_label(false), + ) + }; + draw_status_row(ui, voter_color, voter_label, dark_mode); + + // Compact key status: `Keys: V O P` (present emphasised). + ui.horizontal(|ui| { + ui.label( + RichText::new("Keys:") + .color(DashColors::text_secondary(dark_mode)) + .size(13.0), + ); + for (letter, present) in key_status_tokens(self.key_presence) { + if present { + ui.label( + RichText::new(letter) + .color(DashColors::text_primary(dark_mode)) + .strong() + .size(13.0), + ); + } else { + ui.label( + RichText::new("·") + .color(DashColors::text_secondary(dark_mode)) + .size(13.0), + ); + } + } + }); + + // DPNS status line (count-first precedence). + ui.label( + RichText::new(dpns_status_line(self.contest_summary)) + .color(DashColors::text_secondary(dark_mode)) + .size(13.0), + ); + ui.add_space(4.0); + + // Identity status dot + label (all five states). + draw_status_row( + ui, + Color32::from(self.status), + &self.status.to_string(), + dark_mode, + ); + }); + }); + + // Whole card = one click target with an accessible label (NFR-6). + let rect = inner.response.rect; + let id = ui.id().with(("masternode-card", &self.node_id)); + let response: Response = ui.interact(rect, id, Sense::click()); + if response.hovered() { + ui.painter().rect_stroke( + rect, + CornerRadius::same(16), + Stroke::new(1.5, DashColors::border_light(dark_mode)), + egui::StrokeKind::Inside, + ); + } + response.widget_info(|| { + WidgetInfo::labeled(WidgetType::Button, true, format!("Open {heading_for_a11y}")) + }); + + MasternodeCardResponse { + clicked: response.clicked(), + node_id: self.node_id.clone(), + } + } +} + +/// Paint a small status dot followed by its text label. The label is always +/// present, so status is never conveyed by colour alone (NFR-6). +fn draw_status_row(ui: &mut Ui, color: Color32, label: &str, dark_mode: bool) { + ui.horizontal(|ui| { + let (rect, _) = ui.allocate_exact_size(Vec2::new(10.0, 10.0), Sense::hover()); + ui.painter().circle_filled(rect.center(), 4.0, color); + ui.add_space(4.0); + ui.label( + RichText::new(label) + .color(DashColors::text_primary(dark_mode)) + .size(13.0), + ); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tc_fr3_02_heading_is_shortened_pro_tx_hash_when_no_alias() { + assert_eq!(card_heading(None, "9a3f…d7e2"), "9a3f…d7e2"); + assert_eq!(card_sub_line(None, "9a3f…d7e2"), None); + } + + #[test] + fn tc_fr3_03_alias_heading_with_pro_tx_hash_sub_line() { + assert_eq!(card_heading(Some("mn-east-01"), "9a3f…d7e2"), "mn-east-01"); + assert_eq!( + card_sub_line(Some("mn-east-01"), "9a3f…d7e2"), + Some("9a3f…d7e2".to_string()) + ); + } + + #[test] + fn blank_alias_falls_through_to_pro_tx_hash() { + assert_eq!(card_heading(Some(" "), "9a3f…d7e2"), "9a3f…d7e2"); + assert_eq!(card_sub_line(Some(" "), "9a3f…d7e2"), None); + } + + #[test] + fn tc_fr3_06_07_voter_readiness_copy() { + assert_eq!(voter_readiness_label(true), "Voting ready"); + assert_eq!(voter_readiness_label(false), "No voting key"); + } + + #[test] + fn tc_fr3_09_dpns_open_contest_count() { + let summary = MasternodeContestSummary { + open_contest_count: 3, + has_scheduled_vote: false, + }; + assert_eq!(dpns_status_line(summary), "3 contests to vote on"); + } + + #[test] + fn tc_fr3_10_dpns_no_open_contests() { + assert_eq!( + dpns_status_line(MasternodeContestSummary::default()), + "No open contests" + ); + } + + #[test] + fn tc_fr3_11_count_takes_precedence_over_scheduled() { + // Both an open contest AND a scheduled vote present → count wins. + let summary = MasternodeContestSummary { + open_contest_count: 2, + has_scheduled_vote: true, + }; + assert_eq!(dpns_status_line(summary), "2 contests to vote on"); + } + + #[test] + fn dpns_scheduled_shown_only_when_no_open_contests() { + let summary = MasternodeContestSummary { + open_contest_count: 0, + has_scheduled_vote: true, + }; + assert_eq!(dpns_status_line(summary), "Vote scheduled"); + } + + #[test] + fn tc_fr3_08_key_tokens_reflect_presence() { + let presence = MasternodeKeyPresence { + voting: true, + owner: false, + payout: true, + }; + assert_eq!( + key_status_tokens(presence), + [("V", true), ("O", false), ("P", true)] + ); + } + + #[test] + fn tc_fr3_04_05_badge_label_by_type() { + let card = MasternodeCard::new( + "id", + "9a3f…d7e2", + IdentityType::Masternode, + MasternodeKeyPresence::default(), + MasternodeContestSummary::default(), + IdentityStatus::Active, + ); + assert_eq!(card.badge_label(), "Masternode"); + + let evo = MasternodeCard::new( + "id", + "9a3f…d7e2", + IdentityType::Evonode, + MasternodeKeyPresence::default(), + MasternodeContestSummary::default(), + IdentityStatus::Active, + ); + assert_eq!(evo.badge_label(), "Evonode"); + } +} diff --git a/src/ui/masternodes/detail_screen.rs b/src/ui/masternodes/detail_screen.rs new file mode 100644 index 000000000..a92a21b98 --- /dev/null +++ b/src/ui/masternodes/detail_screen.rs @@ -0,0 +1,956 @@ +//! Masternode/evonode detail view (FR-5). +//! +//! Section order (header, actions, keys, DPNS voting, remove) is fixed by +//! design; each action pushes an existing screen — no parallel MN-specific +//! reimplementation (NFR-1). See `docs/ai-design/2026-07-09-masternode-page-design/`. + +use std::sync::Arc; + +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use eframe::egui::{self, Color32, RichText, Ui}; + +use std::collections::BTreeMap; + +use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + +use crate::app::AppAction; +use crate::backend_task::BackendTask; +use crate::backend_task::contested_names::ContestedResourceTask; +use crate::backend_task::identity::{IdentityInputToLoad, IdentityLoadMode, IdentityTask}; +use crate::context::AppContext; +use crate::model::contested_name::{ContestedName, MasternodeContestSummary}; +use crate::model::qualified_identity::{ + IdentityType, MasternodeKeyPresence, PrivateKeyTarget, QualifiedIdentity, +}; +use crate::model::secret::Secret; +use crate::ui::components::MessageBanner; +use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; +use crate::ui::components::password_input::PasswordInput; +use crate::ui::identities::keys::key_info_screen::KeyInfoScreen; +use crate::ui::identity::identity_picker_card::draw_type_badge; +use crate::ui::identity::identity_pill::shorten_id; +use crate::ui::theme::{ComponentStyles, DashColors}; +use crate::ui::tokens::claim_tokens_screen::ClaimTokensScreen; +use crate::ui::tokens::tokens_screen::IdentityTokenBasicInfo; +use crate::ui::{MessageType, Screen, ScreenType}; +use crate::wallet_backend::IdentityKeyView; +use crate::wallet_backend::secret_seam::SecretScheme; + +/// §7 copy: shown when the node has no voting key loaded. +const MISSING_VOTER_MESSAGE: &str = + "This node has no voting key loaded. Add its voting private key to cast votes."; +/// §7 copy: shown when the node has a voter identity but no open contests. +const NO_OPEN_CONTESTS_MESSAGE: &str = + "There are no open name contests for this node to vote on right now."; + +/// 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})") +} + +/// The fixed top→bottom section order. Actions must precede Keys (TC-FR5-01). +pub const SECTION_ORDER: [&str; 5] = ["Header", "Actions", "Keys", "DPNS", "Remove"]; + +/// A short, human label for a masternode key button, derived from its purpose +/// and the identity it lives on. Voter-identity keys are always "Voting"; on +/// the main identity, Owner/Payout keys are named by purpose, everything else +/// falls back to its purpose name. +fn key_role_label( + target: &PrivateKeyTarget, + key: &dash_sdk::platform::IdentityPublicKey, +) -> String { + use dash_sdk::dpp::identity::Purpose; + if *target == PrivateKeyTarget::PrivateKeyOnVoterIdentity { + return "Voting".to_string(); + } + match key.purpose() { + Purpose::OWNER => "Owner".to_string(), + Purpose::TRANSFER => "Payout".to_string(), + Purpose::AUTHENTICATION => "Authentication".to_string(), + other => format!("{other:?}"), + } +} + +/// At-rest protection posture of a node's vault keys, reduced to what the detail +/// view needs: the tier label and whether an `Add password protection…` action +/// applies (only when there are unprotected vault keys to seal). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProtectionTier { + /// No vault-stored keys (read-only node or resident-plaintext only). + NoVaultKeys, + /// At least one unprotected (Tier-1) vault key, none protected. + Unprotected, + /// Every vault key is password-protected (Tier-2), or a mix. + Protected, +} + +impl ProtectionTier { + fn label(self) -> &'static str { + match self { + ProtectionTier::Protected => "Keys: password-protected", + // A read-only node has nothing sealed either — "unprotected" is the + // accurate, non-alarming description of its at-rest posture. + _ => "Keys: unprotected", + } + } + + /// `Add password protection…` is offered only when there are Tier-1 keys to + /// seal (§FR-8 / NFR-4). + fn offers_add_protection(self) -> bool { + matches!(self, ProtectionTier::Unprotected) + } +} + +/// Outcome of rendering the detail view for one frame. +pub enum DetailOutcome { + /// No terminal interaction this frame. + None, + /// Return to the card list (`‹ All masternodes`). + Back, + /// The node was removed — return to the list and reload. + Removed, + /// Push a reused screen / navigate. Boxed because `AppAction` is large. + Forward(Box), +} + +/// Masternode/evonode detail view state. +pub struct MasternodeDetailView { + app_context: Arc, + identity: QualifiedIdentity, + 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, +} + +impl MasternodeDetailView { + pub fn new(app_context: &Arc, identity: QualifiedIdentity) -> Self { + let node_id_hex_full = identity.identity.id().to_string(Encoding::Hex); + let node_id_short = shorten_id(&node_id_hex_full); + let key_presence = identity.masternode_key_presence(); + let voter_id = identity + .associated_voter_identity + .as_ref() + .map(|(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); + Self { + app_context: app_context.clone(), + identity, + node_id_hex_full, + node_id_short, + key_presence, + contest_summary, + open_contests, + vote_selections: BTreeMap::new(), + voter_key_prompt: None, + remove_dialog: None, + } + } + + /// 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( + self.identity.clone(), + ))]; + if self.identity.associated_voter_identity.is_some() { + tasks.push(BackendTask::ContestedResourceTask( + ContestedResourceTask::QueryDPNSContests, + )); + } + AppAction::BackendTasks(tasks, crate::app::BackendTasksExecutionMode::Concurrent) + } + + /// The node's identity id — used by the list screen to match the open node. + pub fn node_id(&self) -> dash_sdk::platform::Identifier { + self.identity.identity.id() + } + + fn is_evonode(&self) -> bool { + self.identity.identity_type == IdentityType::Evonode + } + + /// Build an `AddScreen` action for a reused screen type, scoped to this node. + fn push(&self, screen_type: ScreenType) -> AppAction { + AppAction::AddScreen(screen_type.create_screen(&self.app_context)) + } + + fn badge_label(&self) -> &'static str { + if self.is_evonode() { + "Evonode" + } else { + "Masternode" + } + } + + /// Probe the at-rest protection posture of this node's vault keys. + fn protection_tier(&self) -> ProtectionTier { + let Ok(backend) = self.app_context.wallet_backend() else { + return ProtectionTier::NoVaultKeys; + }; + let view = IdentityKeyView::new( + backend.secret_store(), + self.identity.identity.id().to_buffer(), + ); + let (mut protected, mut unprotected) = (0usize, 0usize); + for (target, key_id) in self.identity.private_keys.keys_set() { + match view.scheme(&target, key_id) { + Ok(SecretScheme::Protected) => protected += 1, + Ok(SecretScheme::Unprotected) => unprotected += 1, + _ => {} + } + } + // TODO: a mixed state (some Tier-1, some Tier-2) currently maps to + // Protected, so the aggregate "Add password protection…" CTA is hidden + // even though unprotected keys remain. This is mitigated by the per-key + // Manage-keys list (each unprotected key can still be sealed from its + // KeyInfoScreen); a dedicated "partially protected" tier could re-offer + // the aggregate CTA. + match (protected, unprotected) { + (0, 0) => ProtectionTier::NoVaultKeys, + (0, _) => ProtectionTier::Unprotected, + _ => ProtectionTier::Protected, + } + } + + pub fn show(&mut self, ui: &mut Ui, network_accent: Color32) -> DetailOutcome { + let dark_mode = ui.style().visuals.dark_mode; + let mut outcome = DetailOutcome::None; + + // Back row + Refresh (content-panel, not the global header). FR-7. + ui.horizontal(|ui| { + if ui + .selectable_label(false, RichText::new("‹ All masternodes")) + .clicked() + { + outcome = DetailOutcome::Back; + } + 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())); + } + }); + }); + ui.separator(); + + egui::ScrollArea::vertical().show(ui, |ui| { + self.render_header(ui, dark_mode); + ui.add_space(12.0); + if let Some(action) = self.render_actions_row(ui, dark_mode) { + outcome = DetailOutcome::Forward(Box::new(action)); + } + ui.add_space(12.0); + if let Some(action) = self.render_keys_section(ui, dark_mode) { + outcome = DetailOutcome::Forward(Box::new(action)); + } + ui.add_space(12.0); + if let Some(action) = self.render_dpns_section(ui, dark_mode) { + outcome = DetailOutcome::Forward(Box::new(action)); + } + ui.add_space(12.0); + if self.render_remove_section(ui, dark_mode) { + outcome = DetailOutcome::Removed; + } + }); + + outcome + } + + fn render_header(&self, ui: &mut Ui, dark_mode: bool) { + // Conditional alias line — omitted entirely when unset (TC-FR5-02). + if let Some(alias) = self + .identity + .alias + .as_deref() + .filter(|s| !s.trim().is_empty()) + { + ui.label( + RichText::new(alias) + .size(20.0) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + } + ui.horizontal(|ui| { + ui.label( + RichText::new(&self.node_id_short) + .monospace() + .color(DashColors::text_secondary(dark_mode)), + ); + // Copy the FULL ProTxHash, not the shortened display string (TC-FR5-03). + if ui.button("⧉").on_hover_text("Copy ProTxHash").clicked() { + ui.ctx().copy_text(self.node_id_hex_full.clone()); + } + draw_type_badge(ui, self.badge_label(), dark_mode); + }); + // Status dot + label (never colour-only — TC-FR5-05 / NFR-6). + ui.horizontal(|ui| { + let (rect, _) = + ui.allocate_exact_size(egui::Vec2::new(10.0, 10.0), egui::Sense::hover()); + ui.painter() + .circle_filled(rect.center(), 4.0, Color32::from(self.identity.status)); + ui.add_space(4.0); + ui.label( + RichText::new(self.identity.status.to_string()) + .color(DashColors::text_primary(dark_mode)), + ); + }); + } + + fn render_actions_row(&self, ui: &mut Ui, dark_mode: bool) -> Option { + let mut action = None; + ui.label( + RichText::new("Actions") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.horizontal_wrapped(|ui| { + // All three credit screens are reused, scoped to THIS node (FR-9). + if ui.button("Withdraw").clicked() { + action = Some(self.push(ScreenType::WithdrawalScreen(self.identity.clone()))); + } + if ui.button("Top up").clicked() { + action = Some(self.push(ScreenType::TopUpIdentity(self.identity.clone()))); + } + if ui.button("Transfer").clicked() { + action = Some(self.push(ScreenType::TransferScreen(self.identity.clone()))); + } + // Evonode-only token-rewards cross-link (FR-11); absent for a plain + // masternode (TC-FR11-02). + if self.is_evonode() + && ui + .button("Claim token rewards ›") + .on_hover_text("Claim this evonode's token rewards.") + .clicked() + { + action = Some(self.claim_token_rewards_action(ui.ctx())); + } + }); + action + } + + /// Route the evonode "Claim token rewards" CTA (FR-11). When this + /// evonode holds exactly one token in the local registry, push a + /// `ClaimTokensScreen` scoped to it (the real claim flow). With zero or + /// several tokens the correct target is ambiguous, so fall back to the My + /// Tokens area where the user picks the token to claim. + fn claim_token_rewards_action(&self, ctx: &egui::Context) -> AppAction { + let fallback = + AppAction::SetMainScreen(crate::ui::RootScreenType::RootScreenMyTokenBalances); + let node_id = self.identity.identity.id(); + let mut mine: Vec<_> = self + .app_context + .identity_token_balances() + .unwrap_or_default() + .into_iter() + .filter(|(key, _)| key.identity_id == node_id) + .map(|(_, balance)| balance) + .collect(); + if mine.len() != 1 { + return fallback; + } + let itb = mine.remove(0); + match self.app_context.get_contract_by_token_id(&itb.token_id) { + Ok(Some(contract)) => { + let basic = IdentityTokenBasicInfo { + token_id: itb.token_id, + token_alias: itb.token_alias.clone(), + identity_id: itb.identity_id, + contract_id: itb.data_contract_id, + token_position: itb.token_position, + }; + AppAction::AddScreen(Screen::ClaimTokensScreen(ClaimTokensScreen::new( + basic, + contract, + itb.token_config, + &self.app_context, + ))) + } + _ => { + MessageBanner::set_global( + ctx, + "This token's details aren't available yet. Open My Tokens to claim.", + MessageType::Info, + ); + fallback + } + } + } + + fn render_keys_section(&mut self, ui: &mut Ui, dark_mode: bool) -> Option { + let mut action = None; + ui.label( + RichText::new("Keys") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + + // Compact V/O/P presence (glyph, not colour-only — NFR-6). + ui.horizontal(|ui| { + ui.label(RichText::new("Roles:").color(DashColors::text_secondary(dark_mode))); + for (letter, present) in [ + ("V", self.key_presence.voting), + ("O", self.key_presence.owner), + ("P", self.key_presence.payout), + ] { + let text = if present { + RichText::new(letter) + .strong() + .color(DashColors::text_primary(dark_mode)) + } else { + RichText::new("·").color(DashColors::text_secondary(dark_mode)) + }; + ui.label(text); + } + }); + + // Copyable voter-identity id, when a voter identity is loaded. + if let Some((voter, _)) = self.identity.associated_voter_identity.as_ref() { + let voter_full = voter.id().to_string(Encoding::Base58); + ui.horizontal(|ui| { + ui.label( + RichText::new(format!("Voter identity: {}", shorten_id(&voter_full))) + .color(DashColors::text_secondary(dark_mode)), + ); + if ui + .button("⧉") + .on_hover_text("Copy voter identity ID") + .clicked() + { + ui.ctx().copy_text(voter_full.clone()); + } + }); + } + + // Protection tier + conditional Add-protection (FR-8 / NFR-4). + let tier = self.protection_tier(); + ui.label(RichText::new(tier.label()).color(DashColors::text_secondary(dark_mode))); + + // Per-key "Manage keys" list. Each key opens its own `KeyInfoScreen` — + // the real, interactive per-key screen with view/sign/seal actions — + // not the static read-only `KeysScreen` table. This mirrors + // `identities_screen.rs`: one button per key, each pushing + // `Screen::KeyInfoScreen`. + ui.add_space(4.0); + ui.label( + RichText::new("Manage keys") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + for (target, key) in self.identity_keys() { + if ui + .button(format!("{} key ›", key_role_label(&target, &key))) + .clicked() + { + action = Some(self.open_key_info(target, &key)); + } + } + + // Add-protection CTA (FR-8): the seal form (password entry → + // `IdentityTask::ProtectIdentityKeys`, which seals the whole identity) + // lives inside `KeyInfoScreen`. Open the first held key so the user + // lands directly on the interactive seal flow. + if tier.offers_add_protection() + && let Some((target, key)) = self.first_protectable_key() + && ui.button("Add password protection…").clicked() + { + action = Some(self.open_key_info(target, &key)); + } + action + } + + /// Every key of this node, main-identity keys first then voter-identity + /// keys, each paired with the `PrivateKeyTarget` that scopes it. Backs the + /// per-key "Manage keys" list and the Add-protection routing. + fn identity_keys(&self) -> Vec<(PrivateKeyTarget, dash_sdk::platform::IdentityPublicKey)> { + let mut keys = Vec::new(); + for key in self.identity.identity.public_keys().values() { + keys.push((PrivateKeyTarget::PrivateKeyOnMainIdentity, key.clone())); + } + if let Some((voter, _)) = self.identity.associated_voter_identity.as_ref() { + for key in voter.public_keys().values() { + keys.push((PrivateKeyTarget::PrivateKeyOnVoterIdentity, key.clone())); + } + } + keys + } + + /// The first key whose private material this node actually holds — the only + /// keys that can be sealed. Used to route the Add-protection CTA straight + /// into an interactive `KeyInfoScreen` seal flow. + fn first_protectable_key( + &self, + ) -> Option<(PrivateKeyTarget, dash_sdk::platform::IdentityPublicKey)> { + self.identity_keys().into_iter().find(|(target, key)| { + self.identity + .private_keys + .get_cloned_private_key_data_and_wallet_info(&(target.clone(), key.id())) + .is_some() + }) + } + + /// Build the `AddScreen` action that opens `KeyInfoScreen` for one key, + /// carrying its held private-key data if any. Mirrors the + /// per-key push in `identities_screen.rs`. + fn open_key_info( + &self, + target: PrivateKeyTarget, + key: &dash_sdk::platform::IdentityPublicKey, + ) -> AppAction { + let holding = self + .identity + .private_keys + .get_cloned_private_key_data_and_wallet_info(&(target, key.id())); + AppAction::AddScreen(Screen::KeyInfoScreen(KeyInfoScreen::new( + self.identity.clone(), + key.clone(), + holding, + &self.app_context, + ))) + } + + /// 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 + } + + /// 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, + }; + 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<(String, Vec<(dash_sdk::platform::Identifier, String)>)> = self + .open_contests + .iter() + .map(|contest| { + let candidates = contest + .contestants + .as_ref() + .map(|list| list.iter().map(|c| (c.id, c.name.clone())).collect()) + .unwrap_or_default(); + (contest.normalized_contested_name.clone(), candidates) + }) + .collect(); + + for (name, candidates) in &contests { + ui.separator(); + ui.label( + RichText::new(name) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + let selected = self.vote_selections.get(name).copied(); + ui.horizontal_wrapped(|ui| { + if ui + .selectable_label(selected == Some(ResourceVoteChoice::Abstain), "Abstain") + .clicked() + { + self.vote_selections + .insert(name.clone(), ResourceVoteChoice::Abstain); + } + if ui + .selectable_label(selected == Some(ResourceVoteChoice::Lock), "Lock") + .clicked() + { + self.vote_selections + .insert(name.clone(), ResourceVoteChoice::Lock); + } + // Candidate choices are scoped to THIS contest's contestants. + for (candidate_id, candidate_name) in candidates { + let choice = ResourceVoteChoice::TowardsIdentity(*candidate_id); + if ui + .selectable_label( + selected == Some(choice), + format!("Vote for {candidate_name}"), + ) + .clicked() + { + self.vote_selections.insert(name.clone(), choice); + } + } + }); + } + + ui.separator(); + let votes: Vec<(String, ResourceVoteChoice)> = self + .vote_selections + .iter() + .filter(|(name, _)| contests.iter().any(|(n, _)| n == *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")) + .clicked() + { + action = Some(AppAction::BackendTask(BackendTask::ContestedResourceTask( + ContestedResourceTask::VoteOnDPNSNames(votes, vec![self.identity.clone()]), + ))); + } + action + } + + /// Returns `true` once the node has been removed. + fn render_remove_section(&mut self, ui: &mut Ui, _dark_mode: bool) -> bool { + if ui.button("Remove masternode").clicked() { + self.remove_dialog = Some( + ConfirmationDialog::new( + "Remove masternode", + "This removes the node and its voting identity from this device. \ + You can load it again later with its ProTxHash.", + ) + .danger_mode(true) + // §7 confirm verb (TC-US4-02). + .confirm_text(Some("Remove masternode")), + ); + } + + let mut removed = false; + if let Some(dialog) = self.remove_dialog.as_mut() { + use crate::ui::components::component_trait::Component; + let response = dialog.show(ui); + if let Some(status) = response.inner.dialog_response { + self.remove_dialog = None; + if status == ConfirmationStatus::Confirmed { + removed = self.remove_node(ui.ctx()); + } + } + } + removed + } + + /// Delete the node and its associated voter identity from local storage. + /// On the primary delete failing, surface an actionable error banner rather + /// than failing silently, and keep the detail view open so the user can + /// retry. The secondary voter-identity delete failing is non-fatal (the node + /// is already gone) and only logged. + fn remove_node(&self, ctx: &egui::Context) -> bool { + let node_id = self.identity.identity.id(); + if let Err(e) = self.app_context.delete_local_qualified_identity(&node_id) { + MessageBanner::set_global( + ctx, + "This masternode couldn't be removed from this device. Try again in a moment.", + MessageType::Error, + ) + .with_details(e); + return false; + } + if let Some((voter, _)) = self.identity.associated_voter_identity.as_ref() + && let Err(e) = self + .app_context + .delete_local_qualified_identity(&voter.id()) + { + tracing::warn!("Failed to remove voter identity: {e}"); + } + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tc_fr5_01_actions_render_before_keys() { + let actions = SECTION_ORDER.iter().position(|s| *s == "Actions").unwrap(); + let keys = SECTION_ORDER.iter().position(|s| *s == "Keys").unwrap(); + assert!( + actions < keys, + "Actions must render before Keys (TC-FR5-01)" + ); + } + + #[test] + fn section_order_is_header_actions_keys_dpns_remove() { + assert_eq!( + SECTION_ORDER, + ["Header", "Actions", "Keys", "DPNS", "Remove"] + ); + } + + #[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 protection_tier_label_and_add_gate() { + assert_eq!(ProtectionTier::Unprotected.label(), "Keys: unprotected"); + assert_eq!( + ProtectionTier::Protected.label(), + "Keys: password-protected" + ); + assert_eq!(ProtectionTier::NoVaultKeys.label(), "Keys: unprotected"); + assert!(ProtectionTier::Unprotected.offers_add_protection()); + assert!(!ProtectionTier::Protected.offers_add_protection()); + assert!(!ProtectionTier::NoVaultKeys.offers_add_protection()); + } + + /// TC-FR8-07 — a load-time / after-load Tier-2 seal is reflected by + /// `protection_tier()`: an unsealed keyed node reports `Unprotected`, and + /// once its keys are sealed under a password it reports `Protected` (so the + /// detail view shows "Keys: password-protected" and stops offering + /// Add-protection). Drives the real `IdentityKeyView` scheme path on an + /// offline wired `AppContext` — no network I/O. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn tc_fr8_07_protection_tier_reflects_tier2_seal() { + use crate::app::TaskResult; + use crate::app_dir::ensure_env_file; + use crate::context::connection_status::ConnectionStatus; + use crate::database::test_helpers::create_database_at_path; + use crate::model::qualified_identity::IdentityStatus; + use crate::model::qualified_identity::encrypted_key_storage::{KeyStorage, PrivateKeyData}; + use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; + use crate::utils::egui_mpsc::SenderAsync; + use crate::utils::tasks::TaskManager; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::{Identifier, IdentityPublicKey}; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let data_dir = temp_dir.path().to_path_buf(); + ensure_env_file(&data_dir); + let db = Arc::new(create_database_at_path(&data_dir.join("data.db")).expect("db")); + let app_kv = AppContext::open_app_kv(&data_dir).expect("app kv"); + let secret_store = AppContext::open_secret_store(&data_dir).expect("secret store"); + let ctx = AppContext::new( + data_dir, + Network::Testnet, + db, + Arc::new(TaskManager::new()), + Arc::new(ConnectionStatus::new()), + egui::Context::default(), + app_kv, + secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("offline testnet AppContext::new"); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + + // A masternode-shaped identity carrying one owner key on the main + // identity — enough for `protection_tier` to have a key to inspect. + let pv = PlatformVersion::latest(); + let owner = IdentityPublicKey::random_key(1, Some(1), pv); + let mut ks = KeyStorage::default(); + ks.private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, owner.id()), + ( + QualifiedIdentityPublicKey::from(owner), + PrivateKeyData::Clear([0xA0; 32]), + ), + ); + let identity = + Identity::create_basic_identity(Identifier::random(), pv).expect("basic identity"); + let qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::Masternode, + alias: None, + private_keys: ks, + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: Network::Testnet, + }; + let identity_id = qi.identity.id(); + ctx.insert_local_qualified_identity(&qi, &None) + .expect("insert masternode identity"); + + // Before sealing: the key is keyless (Tier-1) → Unprotected. + let view = MasternodeDetailView::new(&ctx, qi.clone()); + assert_eq!( + view.protection_tier(), + ProtectionTier::Unprotected, + "an unsealed keyed node must report Unprotected", + ); + assert!( + view.protection_tier().offers_add_protection(), + "an unsealed node must offer Add-protection", + ); + + // Seal the node's keys Tier-2 via the real backend task (the same task + // the FR-8 seal flow dispatches). + ctx.run_backend_task( + BackendTask::IdentityTask(IdentityTask::ProtectIdentityKeys { + identity_id, + password: Secret::new("one-identity-password"), + hint: None, + }), + SenderAsync::new( + tokio::sync::mpsc::channel::(4).0, + ctx.egui_ctx().clone(), + ), + ) + .await + .expect("seal task must succeed"); + + // After sealing: the detail view reports Protected and stops offering + // Add-protection. Rebuild the view to re-read the vault scheme. + let view = MasternodeDetailView::new(&ctx, qi); + assert_eq!( + view.protection_tier(), + ProtectionTier::Protected, + "a Tier-2 sealed node must report Protected", + ); + assert!( + !view.protection_tier().offers_add_protection(), + "a sealed node must not re-offer Add-protection", + ); + + ctx.wallet_backend().expect("backend").shutdown().await; + } +} diff --git a/src/ui/masternodes/list_screen.rs b/src/ui/masternodes/list_screen.rs new file mode 100644 index 000000000..52e4a9390 --- /dev/null +++ b/src/ui/masternodes/list_screen.rs @@ -0,0 +1,549 @@ +//! The Masternodes list root screen (FR-2 empty state, FR-3 card grid, FR-7 +//! refresh). Reuses the identity onboarding empty-state pattern and the +//! identity-picker card visual language via [`MasternodeCard`]; a card click +//! opens the detail view. + +use std::sync::Arc; + +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::platform::Identifier; +use eframe::egui::{self, RichText}; + +use crate::app::{AppAction, BackendTasksExecutionMode}; +use crate::backend_task::BackendTask; +use crate::backend_task::contested_names::ContestedResourceTask; +use crate::backend_task::identity::IdentityTask; +use crate::context::AppContext; +use crate::model::contested_name::MasternodeContestSummary; +use crate::model::qualified_identity::{IdentityStatus, IdentityType, MasternodeKeyPresence}; +use crate::ui::components::left_panel::add_left_panel; +use crate::ui::components::styled::island_central_panel; +use crate::ui::components::top_panel::add_top_panel_with_global_nav; +use crate::ui::identity::identity_pill::shorten_id; +use crate::ui::identity::picker::compute_column_count; +use crate::ui::masternodes::card::MasternodeCard; +use crate::ui::masternodes::detail_screen::{DetailOutcome, MasternodeDetailView}; +use crate::ui::masternodes::load_form::{LoadFormOutcome, MasternodeLoadForm}; +use crate::ui::state::masternodes_view::masternodes_page_nav_spec; +use crate::ui::theme::{ComponentStyles, DashColors}; +use crate::ui::{RootScreenType, ScreenLike}; + +/// Minimum horizontal gap between cards in the grid (matches the identity +/// picker grid). +const GRID_GAP: f32 = 16.0; + +/// Pre-resolved display data for one masternode/evonode card. Computed at +/// reload time so the per-frame render never touches the database. +struct NodeCardData { + node_id: Identifier, + node_id_short: String, + alias: Option, + node_type: IdentityType, + key_presence: MasternodeKeyPresence, + contest_summary: MasternodeContestSummary, + status: IdentityStatus, +} + +/// Which sub-view of the Masternodes section is showing. The detail view (B5a) +/// and the page-scoped view-state machine (B7) extend this enum. +enum MasternodesView { + /// Empty state or card grid. + List, + /// The masternode/evonode load form (FR-4). + Load(Box), + /// A node's detail / voting view (FR-5). + Detail(Box), +} + +/// Root screen for the Masternodes section. +pub struct MasternodesScreen { + pub app_context: Arc, + /// Cached card data for the active network, refreshed on arrival, on + /// `refresh`, and on the Refresh button. + nodes: Vec, + /// The active sub-view (list / load / detail). + view: MasternodesView, + /// True while a node-load task is in flight. Gates the entry points that + /// could re-submit a load (the `+ Load` toolbar button and the empty-state + /// CTA) so a rapid double-submit of a brand-new ProTxHash cannot race two + /// loads past the pre-fetch existence check. Cleared on the task's result + /// or error. + load_in_flight: bool, +} + +impl MasternodesScreen { + /// Construct the Masternodes screen. Follows the project convention: + /// constructors handle errors internally and return `Self` (degraded to an + /// empty list if the read fails; the empty state renders). + pub fn new(app_context: &Arc) -> Self { + let mut screen = Self { + app_context: app_context.clone(), + nodes: Vec::new(), + view: MasternodesView::List, + load_in_flight: false, + }; + screen.reload(); + screen + } + + /// Re-read the loaded masternode/evonode identities and their DPNS contest + /// summaries from the local store. A read failure degrades to an empty list + /// rather than surfacing a technical error — the empty state is a safe, + /// meaningful fallback. + fn reload(&mut self) { + let identities = self + .app_context + .load_local_masternode_identities() + .unwrap_or_default(); + + self.nodes = identities + .into_iter() + .map(|qi| { + let node_id = qi.identity.id(); + let node_id_short = shorten_id(&node_id.to_string(Encoding::Hex)); + let voter_id = qi + .associated_voter_identity + .as_ref() + .map(|(identity, _)| identity.id()); + let contest_summary = self + .app_context + .masternode_contest_summary(voter_id) + .unwrap_or_default(); + NodeCardData { + node_id, + node_id_short, + alias: qi.alias.clone(), + node_type: qi.identity_type, + key_presence: qi.masternode_key_presence(), + contest_summary, + status: qi.status, + } + }) + .collect(); + } + + /// Reset the screen after a network switch. A load form or detail + /// view left open belongs to the previous network's node — keeping it + /// actionable would let the user submit a cross-network operation. Drop back + /// to the List view and reload from the now-active network's local store. + pub fn reset_for_network_change(&mut self) { + self.view = MasternodesView::List; + self.load_in_flight = false; + self.reload(); + } + + /// Build a fresh load form, attaching the Testnet Fill-Random fixture only + /// on Testnet (loaded once here, not per frame). + fn new_load_form(&self) -> Box { + let fixture = if self.app_context.network == dash_sdk::dpp::dashcore::Network::Testnet { + crate::ui::masternodes::testnet_fixture::load_testnet_nodes() + } else { + None + }; + Box::new(MasternodeLoadForm::new().with_testnet_fixture(fixture)) + } + + /// Render the centered empty state (FR-2). Returns the action produced by + /// the primary CTA. + fn render_empty_state(&mut self, ui: &mut egui::Ui) -> AppAction { + let dark_mode = ui.style().visuals.dark_mode; + + ui.vertical_centered(|ui| { + ui.add_space(48.0); + ui.label( + RichText::new("No masternodes loaded") + .size(22.0) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(12.0); + ui.label( + RichText::new( + "Load a masternode or evonode to vote on DPNS name contests and manage its \ + owner and payout keys.", + ) + .size(14.0) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(20.0); + // Gate the CTA while a load is in flight. + if self.load_in_flight { + ui.spinner(); + ui.add_enabled(false, egui::Button::new("Loading…")); + } else if ComponentStyles::add_primary_button(ui, "Load a masternode").clicked() { + self.view = MasternodesView::Load(self.new_load_form()); + } + ui.add_space(12.0); + ui.label( + RichText::new( + "Have your node's ProTxHash to hand. Keys are optional — a node loads \ + read-only without them.", + ) + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(48.0); + }); + + AppAction::None + } + + /// Render the responsive card grid (FR-3). Sets `selected_node` when a card + /// is clicked (routed to the detail view in B5a/B7). + fn render_card_grid(&mut self, ui: &mut egui::Ui) -> AppAction { + let available_width = ui.available_width(); + let columns = compute_column_count(available_width).max(1); + let count = self.nodes.len(); + // Capture the clicked node id locally so the render loop only borrows + // `self.nodes` immutably; `selected_node` is written after the loop. + let mut clicked: Option = None; + + egui::ScrollArea::vertical().show(ui, |ui| { + for row_start in (0..count).step_by(columns) { + ui.horizontal(|ui| { + for idx in row_start..(row_start + columns).min(count) { + if idx > row_start { + ui.add_space(GRID_GAP); + } + let node = &self.nodes[idx]; + let card = MasternodeCard::new( + node.node_id.to_string(Encoding::Hex), + node.node_id_short.clone(), + node.node_type, + node.key_presence, + node.contest_summary, + node.status, + ) + .with_alias(node.alias.clone()); + if card.show(ui).clicked { + clicked = Some(node.node_id); + } + } + }); + ui.add_space(GRID_GAP); + } + }); + + if let Some(node_id) = clicked { + self.open_detail(node_id); + } + + AppAction::None + } + + /// Open the detail view for `node_id`. Loads the node's full + /// `QualifiedIdentity` from the local store; a lookup miss leaves the list + /// view unchanged. + fn open_detail(&mut self, node_id: Identifier) { + let Ok(identities) = self.app_context.load_local_masternode_identities() else { + return; + }; + if let Some(identity) = identities + .into_iter() + .find(|qi| qi.identity.id() == node_id) + { + self.view = MasternodesView::Detail(Box::new(MasternodeDetailView::new( + &self.app_context, + identity, + ))); + } + } + + /// Render the detail view; map its outcome to navigation / a reused screen. + fn render_detail_view( + &mut self, + ui: &mut egui::Ui, + network_accent: egui::Color32, + ) -> AppAction { + let outcome = match &mut self.view { + MasternodesView::Detail(detail) => detail.show(ui, network_accent), + _ => return AppAction::None, + }; + match outcome { + DetailOutcome::None => AppAction::None, + DetailOutcome::Back => { + self.view = MasternodesView::List; + AppAction::None + } + DetailOutcome::Removed => { + self.view = MasternodesView::List; + self.reload(); + AppAction::None + } + DetailOutcome::Forward(action) => *action, + } + } + + /// Render the list view: toolbar (`+ Load`, `Refresh`) + empty state or grid. + fn render_list_view(&mut self, ui: &mut egui::Ui, network_accent: egui::Color32) -> AppAction { + let mut inner = AppAction::None; + + // Top-right toolbar: `+ Load` (FR-4 entry) + `Refresh` (FR-7). + ui.horizontal(|ui| { + 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 cache immediately (optimistic) AND + // dispatch a network re-fetch of every loaded node plus the + // DPNS contests — Refresh must reach the network, + // not just re-read the store. + self.reload(); + inner = self.refresh_from_network(); + } + ui.add_space(8.0); + // Gate re-entry into the load form while a load is in flight + //, and surface a spinner so the wait is visible. + if self.load_in_flight { + ui.spinner(); + ui.add_enabled(false, egui::Button::new("Loading…")); + } else if ComponentStyles::add_toolbar_button(ui, "+ Load", network_accent) + .clicked() + { + self.view = MasternodesView::Load(self.new_load_form()); + } + }); + }); + ui.add_space(8.0); + + if self.nodes.is_empty() { + inner |= self.render_empty_state(ui); + } else { + inner |= self.render_card_grid(ui); + } + inner + } + + /// Build the network re-fetch dispatched by the list Refresh button: + /// one `RefreshIdentity` per loaded node, plus a DPNS contests + /// re-query so vote counts refresh too. Returns `None` when no node is + /// loaded (nothing to refresh). + fn refresh_from_network(&self) -> AppAction { + let identities = self + .app_context + .load_local_masternode_identities() + .unwrap_or_default(); + if identities.is_empty() { + return AppAction::None; + } + let mut tasks: Vec = identities + .into_iter() + .map(|qi| BackendTask::IdentityTask(IdentityTask::RefreshIdentity(qi))) + .collect(); + tasks.push(BackendTask::ContestedResourceTask( + ContestedResourceTask::QueryDPNSContests, + )); + AppAction::BackendTasks(tasks, BackendTasksExecutionMode::Concurrent) + } + + /// Render the load form; map its outcome to a backend load task and return + /// to the list on cancel or submit. + fn render_load_view(&mut self, ui: &mut egui::Ui) -> AppAction { + let dev_mode = self.app_context.is_developer_mode(); + let outcome = match &mut self.view { + MasternodesView::Load(form) => form.show(ui, dev_mode), + _ => return AppAction::None, + }; + match outcome { + LoadFormOutcome::None => AppAction::None, + LoadFormOutcome::Cancel => { + self.view = MasternodesView::List; + AppAction::None + } + LoadFormOutcome::Submit(input) => { + self.view = MasternodesView::List; + // Gate re-submission until this load resolves. + self.load_in_flight = true; + AppAction::BackendTask(BackendTask::IdentityTask(IdentityTask::LoadIdentity( + *input, + ))) + } + } + } +} + +impl ScreenLike for MasternodesScreen { + fn refresh(&mut self) { + self.reload(); + } + + fn refresh_on_arrival(&mut self) { + // Backstop for a stranded load gate: if the load result was routed to a + // different screen while this tab was away (tab switched mid-load), + // `display_task_result` never fired here to clear the gate. Clear it on + // return so `+ Load` can never sit at "Loading…" forever. + self.load_in_flight = false; + self.reload(); + } + + fn display_task_result(&mut self, result: crate::backend_task::BackendTaskSuccessResult) { + // Clear the load gate only on the load task's OWN result variant. This + // screen also receives detail-view results (voting, RefreshIdentity) — + // clearing on any of those would re-enable `+ Load` while a real load is + // still in flight, so match the load's result specifically. + if matches!( + result, + crate::backend_task::BackendTaskSuccessResult::LoadedIdentity(_) + ) { + self.load_in_flight = false; + } + self.reload(); + // if a detail view is open, its own backend task (voting, an + // Add-voting-key merge, a RefreshIdentity) just updated the store. + // Re-open the detail view for that node so the on-screen view reflects + // the fresh data instead of the stale clone captured at open time. + if let MasternodesView::Detail(detail) = &self.view { + let node_id = detail.node_id(); + self.open_detail(node_id); + } + } + + fn display_task_error(&mut self, _error: &crate::backend_task::error::TaskError) -> bool { + // A load failed — re-enable the load entry points. Let the + // global banner render the error (return false, do not claim it). + self.load_in_flight = false; + false + } + + fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { + // The Masternodes breadcrumb carries segment-1 + wallet pill only — no + // object/identity pill (locked decision #4: masternodes are never + // wallet-linked, so a wallet↔object pairing would misrepresent the + // relationship). Node selection is driven by card-click → detail and the + // `‹ All masternodes` back link; the FR-6 boundary is enforced at the + // resolution layer (B1), independent of this breadcrumb. + let spec = masternodes_page_nav_spec(); + let mut action = add_top_panel_with_global_nav(ui, &self.app_context, spec, vec![]); + + action |= add_left_panel(ui, &self.app_context, RootScreenType::RootScreenMasternodes); + + let network_accent = + DashColors::network_accent(self.app_context.network, ui.style().visuals.dark_mode); + + action |= island_central_panel(ui, |ui| { + ui.set_min_width(ui.available_width()); + 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), + } + }); + + action + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::TaskResult; + use crate::app_dir::ensure_env_file; + use crate::context::connection_status::ConnectionStatus; + use crate::database::test_helpers::create_database_at_path; + use crate::model::qualified_identity::QualifiedIdentity; + use crate::model::qualified_identity::encrypted_key_storage::KeyStorage; + use crate::utils::egui_mpsc::SenderAsync; + use crate::utils::tasks::TaskManager; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::Identifier; + use std::collections::BTreeMap; + + /// Build an offline, wallet-backend-wired `AppContext` (no network I/O). + async fn offline_ctx() -> (Arc, tempfile::TempDir) { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let data_dir = temp_dir.path().to_path_buf(); + ensure_env_file(&data_dir); + let db = Arc::new(create_database_at_path(&data_dir.join("data.db")).expect("db")); + let app_kv = AppContext::open_app_kv(&data_dir).expect("app kv"); + let secret_store = AppContext::open_secret_store(&data_dir).expect("secret store"); + let ctx = AppContext::new( + data_dir, + Network::Testnet, + db, + Arc::new(TaskManager::new()), + Arc::new(ConnectionStatus::new()), + egui::Context::default(), + app_kv, + secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("offline testnet AppContext::new"); + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wire wallet backend offline"); + (ctx, temp_dir) + } + + fn seed_masternode(ctx: &Arc, byte: u8) { + let pv = PlatformVersion::latest(); + let identity = Identity::create_basic_identity(Identifier::from([byte; 32]), pv) + .expect("basic identity"); + let qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::Masternode, + alias: None, + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::PendingCreation, + network: ctx.network(), + }; + ctx.insert_local_qualified_identity(&qi, &None) + .expect("seed masternode"); + } + + /// The list Refresh builds one `RefreshIdentity` per loaded node plus a + /// single trailing `QueryDPNSContests`, and yields `None` when no node is + /// loaded (nothing to refresh). + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn refresh_from_network_builds_per_node_refresh_plus_contest_requery() { + let (ctx, _tmp) = offline_ctx().await; + + // No nodes loaded → nothing to refresh. + let screen = MasternodesScreen::new(&ctx); + assert!( + matches!(screen.refresh_from_network(), AppAction::None), + "an empty node list must produce no refresh task" + ); + + // Two nodes loaded → two RefreshIdentity + one trailing QueryDPNSContests. + seed_masternode(&ctx, 0x11); + seed_masternode(&ctx, 0x22); + let screen = MasternodesScreen::new(&ctx); + let AppAction::BackendTasks(tasks, mode) = screen.refresh_from_network() else { + panic!("expected BackendTasks"); + }; + assert!(matches!(mode, BackendTasksExecutionMode::Concurrent)); + assert_eq!(tasks.len(), 3, "two node refreshes + one contest re-query"); + let refreshes = tasks + .iter() + .filter(|t| { + matches!( + t, + BackendTask::IdentityTask(IdentityTask::RefreshIdentity(_)) + ) + }) + .count(); + assert_eq!(refreshes, 2, "one RefreshIdentity per loaded node"); + assert!( + matches!( + tasks.last(), + Some(BackendTask::ContestedResourceTask( + ContestedResourceTask::QueryDPNSContests + )) + ), + "the contest re-query must be the trailing task", + ); + + ctx.wallet_backend().expect("backend").shutdown().await; + } +} diff --git a/src/ui/masternodes/load_form.rs b/src/ui/masternodes/load_form.rs new file mode 100644 index 000000000..e75fa32cc --- /dev/null +++ b/src/ui/masternodes/load_form.rs @@ -0,0 +1,526 @@ +//! Masternode/evonode load form (FR-4) — ProTxHash, type toggle, alias, the +//! three optional V/O/P keys, and an optional at-load encryption password +//! (FR-8). No auto-derive: masternode keys never live in a wallet's HD tree. + +use bip39::rand::prelude::IteratorRandom; +use bip39::rand::thread_rng; + +use crate::backend_task::identity::{IdentityInputToLoad, IdentityLoadMode}; +use crate::model::masternode_input::is_valid_pro_tx_hash; +use crate::model::qualified_identity::IdentityType; +use crate::ui::components::password_input::PasswordInput; +use crate::ui::masternodes::testnet_fixture::TestnetNodes; +use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; +use eframe::egui::{self, RichText, Ui}; + +const KEY_PLACEHOLDER: &str = "Private key (WIF or hex)"; +const WARNING_NOTE: &str = "Set an optional password to encrypt these keys on this device. \ + Without one, they are stored unencrypted and you can add protection later from the key \ + screen."; +const PRO_TX_HASH_FORMAT_ERROR: &str = "This doesn't look like a valid ProTxHash. Enter a hex or Base58 ProTxHash from your \ + masternode configuration."; +const LOAD_DISABLED_TOOLTIP: &str = "Enter a ProTxHash to continue."; + +/// Outcome of rendering the load form for one frame. +pub enum LoadFormOutcome { + /// No terminal interaction this frame. + None, + /// The user cancelled — discard the form, return to the list. + Cancel, + /// The user submitted a valid load request. + Submit(Box), +} + +/// Masternode/evonode load form state. Rendered by the Masternodes screen when +/// in the load view; holds its own field state and is dropped on cancel/submit +/// so reopening always yields a fresh form (TC-FR4-20). +pub struct MasternodeLoadForm { + node_type: IdentityType, + pro_tx_hash_input: String, + /// Set once the ProTxHash field has lost focus, gating the inline error so + /// it never flashes while the user is still typing (on-blur semantics). + pro_tx_hash_touched: bool, + alias_input: String, + voting_key: PasswordInput, + owner_key: PasswordInput, + payout_key: PasswordInput, + encryption_password: PasswordInput, + /// Testnet-only Fill-Random fixture (FR-12). `None` off Testnet or when the + /// `.testnet_nodes.yml` file is missing/malformed — the button never shows. + testnet_nodes: Option, +} + +impl Default for MasternodeLoadForm { + fn default() -> Self { + Self::new() + } +} + +impl MasternodeLoadForm { + pub fn new() -> Self { + Self { + node_type: IdentityType::Masternode, + pro_tx_hash_input: String::new(), + pro_tx_hash_touched: false, + alias_input: String::new(), + voting_key: PasswordInput::new() + .with_hint_text(KEY_PLACEHOLDER) + .with_monospace(), + owner_key: PasswordInput::new() + .with_hint_text(KEY_PLACEHOLDER) + .with_monospace(), + payout_key: PasswordInput::new() + .with_hint_text(KEY_PLACEHOLDER) + .with_monospace(), + encryption_password: PasswordInput::new() + .with_hint_text("Password to encrypt these keys"), + testnet_nodes: None, + } + } + + /// Attach the Testnet Fill-Random fixture (FR-12). Pass `None` off Testnet. + pub fn with_testnet_fixture(mut self, testnet_nodes: Option) -> Self { + self.testnet_nodes = testnet_nodes; + self + } + + /// The active node type — exposed for the B6 Fill-Random label + tests. + pub fn node_type(&self) -> IdentityType { + self.node_type + } + + /// Switch the node type, clearing every field when it actually changes: a + /// real node's identity is tied to one type, so autofilled or entered data + /// for one is never valid for the other (§10.6). + fn set_node_type(&mut self, node_type: IdentityType) { + if node_type == self.node_type { + return; + } + self.node_type = node_type; + self.pro_tx_hash_input.clear(); + self.pro_tx_hash_touched = false; + self.alias_input.clear(); + self.voting_key.clear(); + self.owner_key.clear(); + self.payout_key.clear(); + } + + /// The Fill-Random button label, following the node-type toggle (FR-12). + fn fill_random_label(&self) -> &'static str { + match self.node_type { + IdentityType::Evonode => "🎲 Fill Random Evonode", + _ => "🎲 Fill Random Masternode", + } + } + + /// Populate the form from a random fixture entry matching the current node + /// type (TC-FR12-07/08): Masternode pulls from `masternodes` (Voting + Owner + /// only — the fixture has no payout key); Evonode pulls from `hp_masternodes` + /// (all three keys). A no-op when the fixture is absent or the list is empty. + fn fill_random(&mut self) { + let Some(nodes) = self.testnet_nodes.as_ref() else { + return; + }; + match self.node_type { + IdentityType::Evonode => { + if let Some((name, node)) = nodes.hp_masternodes.iter().choose(&mut thread_rng()) { + self.pro_tx_hash_input = node.protx_tx_hash.clone(); + self.alias_input = name.clone(); + self.voting_key + .set_text(node.voter.private_key.expose_secret()); + self.owner_key + .set_text(node.owner.private_key.expose_secret()); + self.payout_key + .set_text(node.payout.private_key.expose_secret()); + self.pro_tx_hash_touched = false; + } + } + _ => { + if let Some((name, node)) = nodes.masternodes.iter().choose(&mut thread_rng()) { + self.pro_tx_hash_input = node.pro_tx_hash.clone(); + self.alias_input = name.clone(); + self.voting_key + .set_text(node.voter.private_key.expose_secret()); + self.owner_key + .set_text(node.owner.private_key.expose_secret()); + // MasternodeInfo has no payout key — leave Payout blank. + self.pro_tx_hash_touched = false; + } + } + } + } + + /// Whether the Load button is enabled: a non-empty ProTxHash. Shape and + /// existence are enforced inline / by the backend respectively — an empty + /// field is the only hard gate on submission (TC-FR4-05/07). + fn can_submit(&self) -> bool { + !self.pro_tx_hash_input.trim().is_empty() + } + + /// Build the backend load input from the current field state. + fn build_input(&mut self) -> IdentityInputToLoad { + let password = self.encryption_password.take_secret(); + let encryption_password = (!password.is_blank()).then_some(password); + IdentityInputToLoad { + identity_id_input: self.pro_tx_hash_input.trim().to_string(), + identity_type: self.node_type, + alias_input: self.alias_input.trim().to_string(), + voting_private_key_input: self.voting_key.take_secret(), + owner_private_key_input: self.owner_key.take_secret(), + payout_address_private_key_input: self.payout_key.take_secret(), + keys_input: vec![], + // No auto-derive: masternode keys are never wallet-derived (§Locked-#4). + derive_keys_from_wallets: false, + selected_wallet_seed_hash: None, + encryption_password, + // A fresh load rejects an already-loaded ProTxHash (§10.9) rather + // than silently overwriting the existing node. + load_mode: IdentityLoadMode::RejectIfExists, + } + } + + /// Render the form. `dev_mode` gates the Testnet-only Fill-Random button as + /// a defense-in-depth check (FR-12 / TC-FR12-09): the whole tab is already + /// Expert-gated, but a plaintext-key-reading dev tool stays inside the + /// Expert-Mode envelope regardless of any future non-nav entry point. + pub fn show(&mut self, ui: &mut Ui, dev_mode: bool) -> LoadFormOutcome { + let dark_mode = ui.style().visuals.dark_mode; + let mut outcome = LoadFormOutcome::None; + + // Back row (content-panel, not the global header) — matches the detail + // view's `‹ All masternodes` link so both views return to the card list + // the same way. Emits `Cancel`, the existing "return to list" outcome. + if ui + .selectable_label(false, RichText::new("‹ All masternodes")) + .clicked() + { + outcome = LoadFormOutcome::Cancel; + } + ui.separator(); + + egui::ScrollArea::vertical().show(ui, |ui| { + ui.label( + RichText::new("Load a masternode") + .size(20.0) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add_space(4.0); + ui.label( + RichText::new( + "Load a masternode or evonode that already exists on the Dash network.", + ) + .size(13.0) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(16.0); + + // Node-type toggle (Masternode / Evonode) — no User option. + ui.horizontal(|ui| { + if ui + .selectable_label(self.node_type == IdentityType::Masternode, "Masternode") + .clicked() + { + self.set_node_type(IdentityType::Masternode); + } + if ui + .selectable_label(self.node_type == IdentityType::Evonode, "Evonode") + .clicked() + { + self.set_node_type(IdentityType::Evonode); + } + }); + ui.add_space(12.0); + + // Fill-Random dev convenience (FR-12): Testnet + fixture present + + // Expert Mode. Entire row is absent otherwise — never shown-disabled. + if dev_mode && self.testnet_nodes.is_some() { + if ui.button(self.fill_random_label()).clicked() { + self.fill_random(); + } + ui.label( + RichText::new( + "Testnet-only dev convenience — visible only when a local test-node \ + fixture is found.", + ) + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(12.0); + } + + // ProTxHash (required) with inline on-blur shape validation. + ui.label( + RichText::new("ProTxHash") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + let response = ui.add( + egui::TextEdit::singleline(&mut self.pro_tx_hash_input) + .hint_text("Enter the node's ProTxHash. You can find it in your masternode configuration.") + .desired_width(f32::INFINITY), + ); + if response.lost_focus() { + self.pro_tx_hash_touched = true; + } + if self.pro_tx_hash_touched + && !self.pro_tx_hash_input.trim().is_empty() + && !is_valid_pro_tx_hash(&self.pro_tx_hash_input) + { + ui.label( + RichText::new(PRO_TX_HASH_FORMAT_ERROR) + .size(12.0) + .color(DashColors::error_color(dark_mode)), + ); + } + ui.add_space(12.0); + + // Alias (optional, local-only). + ui.label( + RichText::new("Alias (optional)") + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.add( + egui::TextEdit::singleline(&mut self.alias_input) + .hint_text( + "An alias helps you recognize this node inside Dash Evo Tool. It is not \ + saved to the Dash network.", + ) + .desired_width(f32::INFINITY), + ); + ui.add_space(12.0); + + // Optional V/O/P key inputs (WIF or hex, hold-to-reveal). + ui.label( + RichText::new("Voting private key") + .color(DashColors::text_primary(dark_mode)), + ); + self.voting_key.show(ui); + ui.add_space(8.0); + ui.label( + RichText::new("Owner private key") + .color(DashColors::text_primary(dark_mode)), + ); + self.owner_key.show(ui); + ui.add_space(8.0); + ui.label( + RichText::new("Payout address private key") + .color(DashColors::text_primary(dark_mode)), + ); + self.payout_key.show(ui); + ui.add_space(12.0); + + // Optional at-load encryption password (FR-8). + ui.label( + RichText::new("Encryption password (optional)") + .color(DashColors::text_primary(dark_mode)), + ); + self.encryption_password.show(ui); + ui.label( + RichText::new( + "Set a password to encrypt these keys on this device. Leave it blank to \ + store them unencrypted and add protection later.", + ) + .size(12.0) + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(12.0); + + // Always-visible Warning-tone key-storage note (NFR-4). + ui.label( + RichText::new(WARNING_NOTE) + .size(12.0) + .color(DashColors::warning_color(dark_mode)), + ); + ui.add_space(16.0); + + // Actions: Cancel + Load. Load disabled until a ProTxHash is present. + ui.horizontal(|ui| { + if ComponentStyles::add_toolbar_button( + ui, + "Cancel", + DashColors::surface_elevated(dark_mode), + ) + .clicked() + { + outcome = LoadFormOutcome::Cancel; + } + + let enabled = self.can_submit(); + let clicked = ComponentStyles::add_primary_button_enabled( + ui, + enabled, + "Load masternode", + ) + .disabled_tooltip(LOAD_DISABLED_TOOLTIP) + .clicked(); + if clicked && enabled { + outcome = LoadFormOutcome::Submit(Box::new(self.build_input())); + } + }); + }); + + outcome + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::secret::Secret; + use crate::ui::masternodes::testnet_fixture::{HpMasternodeInfo, KeyInfo, MasternodeInfo}; + use std::collections::HashMap; + + fn fixture() -> TestnetNodes { + let mut masternodes = HashMap::new(); + masternodes.insert( + "mn-fixture".to_string(), + MasternodeInfo { + pro_tx_hash: "aa".repeat(32), + owner: KeyInfo { + private_key: Secret::new("owner-wif"), + }, + voter: KeyInfo { + private_key: Secret::new("voter-wif"), + }, + }, + ); + let mut hp_masternodes = HashMap::new(); + hp_masternodes.insert( + "evo-fixture".to_string(), + HpMasternodeInfo { + protx_tx_hash: "bb".repeat(32), + owner: KeyInfo { + private_key: Secret::new("hp-owner-wif"), + }, + voter: KeyInfo { + private_key: Secret::new("hp-voter-wif"), + }, + payout: KeyInfo { + private_key: Secret::new("hp-payout-wif"), + }, + }, + ); + TestnetNodes { + masternodes, + hp_masternodes, + } + } + + #[test] + fn tc_fr12_01_02_label_follows_node_type() { + let mut form = MasternodeLoadForm::new(); + assert_eq!(form.fill_random_label(), "🎲 Fill Random Masternode"); + form.set_node_type(IdentityType::Evonode); + assert_eq!(form.fill_random_label(), "🎲 Fill Random Evonode"); + } + + #[test] + fn tc_fr12_07_masternode_fill_populates_voting_and_owner_only() { + let mut form = MasternodeLoadForm::new().with_testnet_fixture(Some(fixture())); + form.fill_random(); + assert_eq!(form.pro_tx_hash_input, "aa".repeat(32)); + assert_eq!(form.alias_input, "mn-fixture"); + assert_eq!(form.voting_key.text(), "voter-wif"); + assert_eq!(form.owner_key.text(), "owner-wif"); + // MasternodeInfo has no payout key — Payout stays blank. + assert!(form.payout_key.is_empty()); + } + + #[test] + fn tc_fr12_08_evonode_fill_populates_all_three_keys() { + let mut form = MasternodeLoadForm::new().with_testnet_fixture(Some(fixture())); + form.set_node_type(IdentityType::Evonode); + form.fill_random(); + assert_eq!(form.pro_tx_hash_input, "bb".repeat(32)); + assert_eq!(form.alias_input, "evo-fixture"); + assert_eq!(form.voting_key.text(), "hp-voter-wif"); + assert_eq!(form.owner_key.text(), "hp-owner-wif"); + assert_eq!(form.payout_key.text(), "hp-payout-wif"); + } + + #[test] + fn fill_random_without_fixture_is_a_noop() { + let mut form = MasternodeLoadForm::new(); + form.fill_random(); + assert!(form.pro_tx_hash_input.is_empty()); + } + + #[test] + fn tc_fr4_02_defaults_to_masternode() { + assert_eq!( + MasternodeLoadForm::new().node_type(), + IdentityType::Masternode + ); + } + + #[test] + fn tc_fr4_05_07_submit_gated_on_non_empty_pro_tx_hash() { + let mut form = MasternodeLoadForm::new(); + assert!(!form.can_submit(), "empty ProTxHash must disable submit"); + form.pro_tx_hash_input = " ".to_string(); + assert!(!form.can_submit(), "whitespace-only must disable submit"); + form.pro_tx_hash_input = "abc".to_string(); + assert!(form.can_submit(), "any non-empty ProTxHash enables submit"); + } + + #[test] + fn tc_edge_type_toggle_clears_fields() { + let mut form = MasternodeLoadForm::new(); + form.pro_tx_hash_input = "deadbeef".to_string(); + form.pro_tx_hash_touched = true; + form.alias_input = "mn-east-01".to_string(); + form.voting_key.set_text("wif"); + + form.set_node_type(IdentityType::Evonode); + + assert_eq!(form.node_type(), IdentityType::Evonode); + assert!(form.pro_tx_hash_input.is_empty()); + assert!(!form.pro_tx_hash_touched); + assert!(form.alias_input.is_empty()); + assert!(form.voting_key.is_empty()); + } + + #[test] + fn selecting_same_type_preserves_fields() { + let mut form = MasternodeLoadForm::new(); + form.pro_tx_hash_input = "deadbeef".to_string(); + form.set_node_type(IdentityType::Masternode); + assert_eq!(form.pro_tx_hash_input, "deadbeef"); + } + + #[test] + fn tc_fr4_build_input_maps_fields_and_omits_blank_password() { + let mut form = MasternodeLoadForm::new(); + form.pro_tx_hash_input = " deadbeef ".to_string(); + form.alias_input = " mn-east-01 ".to_string(); + form.set_node_type(IdentityType::Masternode); + form.pro_tx_hash_input = " deadbeef ".to_string(); + form.alias_input = " mn-east-01 ".to_string(); + + let input = form.build_input(); + assert_eq!(input.identity_id_input, "deadbeef"); + assert_eq!(input.alias_input, "mn-east-01"); + assert_eq!(input.identity_type, IdentityType::Masternode); + assert!( + !input.derive_keys_from_wallets, + "never auto-derive (§Locked-#4)" + ); + assert!(input.selected_wallet_seed_hash.is_none()); + assert!(input.keys_input.is_empty()); + assert!( + input.encryption_password.is_none(), + "a blank password must map to None (Tier-1 keyless)" + ); + } + + #[test] + fn build_input_keeps_non_blank_password() { + let mut form = MasternodeLoadForm::new(); + form.pro_tx_hash_input = "deadbeef".to_string(); + form.encryption_password.set_text("hunter2"); + let input = form.build_input(); + assert!(input.encryption_password.is_some()); + } +} diff --git a/src/ui/masternodes/mod.rs b/src/ui/masternodes/mod.rs new file mode 100644 index 000000000..129aa571a --- /dev/null +++ b/src/ui/masternodes/mod.rs @@ -0,0 +1,14 @@ +//! The Masternodes root screen domain (Expert-Mode gated). +//! +//! Node operators (the Priya persona) load masternode/evonode identities to +//! vote on DPNS name contests and manage owner/voting/payout keys. The page is +//! a sibling root screen behind the Expert-Mode nav gate (FR-1); its identities +//! are page-scoped and never leak into the everyday-user surfaces (FR-6, B1). + +pub mod card; +pub mod detail_screen; +pub mod list_screen; +pub mod load_form; +pub mod testnet_fixture; + +pub use list_screen::MasternodesScreen; diff --git a/src/ui/masternodes/testnet_fixture.rs b/src/ui/masternodes/testnet_fixture.rs new file mode 100644 index 000000000..dcdba5676 --- /dev/null +++ b/src/ui/masternodes/testnet_fixture.rs @@ -0,0 +1,80 @@ +//! Testnet node fixture loader for the Fill-Random dev convenience (FR-12). +//! +//! A local `.testnet_nodes.yml` file (Testnet-only, developer machines) supplies +//! real masternode/evonode ProTxHashes and keys so a developer can populate the +//! load form with one click. The fixture is a dev tool: it is only ever surfaced +//! inside the Expert-Mode-gated Masternodes tab, on Testnet, when the file is +//! present and parses. +//! +//! Divergence from the legacy add-existing-identity screen (§FR-12 / TC-FR12-04): +//! a **malformed** file is swallowed to `None` (logged at `debug`), never +//! surfaced as an error banner — the button simply does not appear. The parse +//! error is logged by *position only* (line/column), never its message, because +//! a serde error's text can echo the offending line — which here holds a real +//! private key. + +use crate::model::secret::Secret; +use serde::Deserialize; +use std::collections::HashMap; + +/// The fixture file name, resolved relative to the process working directory. +pub const TESTNET_NODES_FILE: &str = ".testnet_nodes.yml"; + +#[derive(Debug, Clone, Deserialize)] +pub struct KeyInfo { + /// Parsed as a [`Secret`] so the private key is redacted in `Debug`/logs and + /// zeroized on drop, rather than living as a bare `String`. + pub private_key: Secret, +} + +/// A regular masternode fixture entry. Has **no** payout key — so Fill-Random +/// for a Masternode populates Voting + Owner only (TC-FR12-07). +#[derive(Debug, Clone, Deserialize)] +pub struct MasternodeInfo { + #[serde(rename = "pro-tx-hash")] + pub pro_tx_hash: String, + pub owner: KeyInfo, + pub voter: KeyInfo, +} + +/// A high-performance (evo) masternode fixture entry, carrying all three keys. +#[derive(Debug, Clone, Deserialize)] +pub struct HpMasternodeInfo { + #[serde(rename = "protx-tx-hash")] + pub protx_tx_hash: String, + pub owner: KeyInfo, + pub voter: KeyInfo, + pub payout: KeyInfo, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TestnetNodes { + pub masternodes: HashMap, + pub hp_masternodes: HashMap, +} + +/// Load the testnet node fixture. Returns `None` for **both** a missing and a +/// malformed file — a malformed file is logged at `debug` and treated as absent +/// (TC-FR12-04), so the Fill-Random button never appears on bad input and no +/// error banner is surfaced. +/// +/// The parse error is logged by position only (line/column). Its `Display` text +/// is deliberately NOT logged: serde echoes the offending source line, which in +/// this file is a real private key. +pub fn load_testnet_nodes() -> Option { + let content = std::fs::read_to_string(TESTNET_NODES_FILE).ok()?; + match serde_yaml_ng::from_str::(&content) { + Ok(nodes) => Some(nodes), + Err(e) => { + match e.location() { + Some(loc) => tracing::debug!( + "Ignoring malformed {TESTNET_NODES_FILE} (parse error at line {}, column {})", + loc.line(), + loc.column(), + ), + None => tracing::debug!("Ignoring malformed {TESTNET_NODES_FILE}"), + } + None + } + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index f0b61a44c..cabf7cb55 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -56,6 +56,7 @@ use identities::add_new_identity_screen::AddNewIdentityScreen; use identities::identities_screen::IdentitiesScreen; use identities::register_dpns_name_screen::{RegisterDpnsNameScreen, RegisterDpnsNameSource}; use identity::IdentityHubScreen; +use masternodes::MasternodesScreen; use std::fmt; use std::sync::Arc; use std::sync::RwLock; @@ -81,6 +82,7 @@ pub mod dpns; pub mod helpers; pub mod identities; pub mod identity; +pub mod masternodes; pub mod network_chooser_screen; pub mod state; pub mod theme; @@ -124,6 +126,7 @@ impl From for ScreenType { RootScreenType::RootScreenToolsAddressBalanceScreen => ScreenType::AddressBalance, RootScreenType::RootScreenDashpay => ScreenType::Dashpay, RootScreenType::RootScreenIdentityHub => ScreenType::IdentityHub, + RootScreenType::RootScreenMasternodes => ScreenType::Masternodes, } } } @@ -169,6 +172,8 @@ pub enum ScreenType { Dashpay, /// Unified Identities hub (new four-tab section). IdentityHub, + /// Masternodes section (Expert-Mode gated). + Masternodes, CreateDocument, DeleteDocument, ReplaceDocument, @@ -360,6 +365,9 @@ impl ScreenType { ScreenType::IdentityHub => { Screen::IdentityHubScreen(IdentityHubScreen::new(app_context)) } + ScreenType::Masternodes => { + Screen::MasternodesScreen(MasternodesScreen::new(app_context)) + } ScreenType::CreateDocument => Screen::DocumentActionScreen(DocumentActionScreen::new( app_context.clone(), None, @@ -570,6 +578,9 @@ pub enum Screen { // New unified Identities hub IdentityHubScreen(IdentityHubScreen), + + // Masternodes section (Expert-Mode gated) + MasternodesScreen(MasternodesScreen), } impl Screen { @@ -663,6 +674,14 @@ impl Screen { screen.refresh(); return; } + Screen::MasternodesScreen(screen) => { + screen.app_context = app_context; + // A network switch invalidates any open load form or detail view + // (they belong to the previous network's node). Reset to the List + // view and reload from the now-active network. + screen.reset_for_network_change(); + return; + } _ => {} } @@ -716,6 +735,7 @@ impl Screen { PauseTokensScreen, ResumeTokensScreen; skip: + MasternodesScreen, NetworkChooserScreen, AddNewWalletScreen, TransferScreen, @@ -947,6 +967,7 @@ impl Screen { Screen::DashPayQRGeneratorScreen(_) => ScreenType::DashPayQRGenerator, Screen::DashPayProfileSearchScreen(_) => ScreenType::DashPayProfileSearch, Screen::IdentityHubScreen(_) => ScreenType::IdentityHub, + Screen::MasternodesScreen(_) => ScreenType::Masternodes, } } } @@ -1013,6 +1034,7 @@ macro_rules! delegate_to_screen { Screen::DashPayQRGeneratorScreen($screen) => $call, Screen::DashPayProfileSearchScreen($screen) => $call, Screen::IdentityHubScreen($screen) => $call, + Screen::MasternodesScreen($screen) => $call, } }; } diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index 33456a15d..eb3a5ff4d 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -612,9 +612,14 @@ impl NetworkChooserScreen { ) .clicked() { - for ctx in self.network_contexts.values() { - ctx.enable_developer_mode(self.developer_mode); - } + // Expert Mode is a single app-global flag shared by every + // per-network context, so toggling it on one updates all. + self.current_app_context() + .enable_developer_mode(self.developer_mode); + // Re-render the nav immediately: enabling Expert Mode also + // disables animations, which stops continuous repaints, so + // request one so the Masternodes nav entry appears now. + ui.ctx().request_repaint(); // Persist to config file (non-blocking for UI) if let Ok(mut config) = Config::load_from(&self.data_dir) { diff --git a/src/ui/state/global_nav.rs b/src/ui/state/global_nav.rs new file mode 100644 index 000000000..816fe44de --- /dev/null +++ b/src/ui/state/global_nav.rs @@ -0,0 +1,272 @@ +//! Page-scoped global-nav model: which breadcrumb pills a root page composes, +//! how each pill participates, and the page-scoped object selection kept +//! distinct from the app-global user-identity selection. +//! +//! Renders nothing (module-placement discriminator → `ui/state`). The +//! `global_nav_switcher` component reads a [`PageNavSpec`] and renders it. +//! +//! FR-6 boundary: [`IdentityPillScope::PageScopedObject`] carries its own +//! selection and never writes `AppContext::selected_identity_id` — the switcher +//! maps it to a distinct `SelectPageObject` effect, not `SelectIdentity`. + +use crate::ui::RootScreenType; +use dash_sdk::platform::Identifier; + +/// One selectable object in a page-scoped pill dropdown, e.g. a loaded +/// masternode/evonode identity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PageObjectItem { + /// Identity id of the object (the masternode/evonode identity). + pub id: Identifier, + /// Display label for the dropdown row and the pill when this item is active. + pub label: String, +} + +/// How a breadcrumb pill participates on a given page (FR-GLOBAL-NAV-2 rule 3). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PillConsumption { + /// The page consumes this selection — render interactive, two-way bound. + Consumed, + /// Not yet wired on this page — render subdued (dimmed, no caret, no text + /// tag) with a hover tooltip telling the user how to change the selection. + Unwired { tooltip: String }, +} + +impl PillConsumption { + /// Whether the page consumes this selection (interactive pill). + pub fn is_consumed(&self) -> bool { + matches!(self, Self::Consumed) + } + + /// The how-to-change tooltip for an unwired pill; `None` when consumed. + pub fn tooltip(&self) -> Option<&str> { + match self { + Self::Unwired { tooltip } => Some(tooltip), + Self::Consumed => None, + } + } +} + +/// What the third breadcrumb pill represents on a page (FR-GLOBAL-NAV-3). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IdentityPillScope { + /// The app-global User identity (`AppContext::selected_identity_id`). The + /// switcher reads/writes the app-scoped selection for this variant. + AppGlobalUser, + /// A page-scoped object in view (the masternode/evonode on the Masternodes + /// page). This variant **never** writes `AppContext::selected_identity_id`; + /// the switcher maps its selection to `SelectPageObject`. This is the + /// structural FR-6 boundary in code. + PageScopedObject { + /// Label shown when nothing is selected, e.g. `(no masternode yet)`. + placeholder: String, + /// The objects the dropdown offers. + items: Vec, + /// The currently selected object, if any. + selected: Option, + }, +} + +impl IdentityPillScope { + /// Build a page-scoped object scope with the given placeholder and items. + pub fn page_scoped_object( + placeholder: impl Into, + items: Vec, + selected: Option, + ) -> Self { + Self::PageScopedObject { + placeholder: placeholder.into(), + items, + selected, + } + } + + /// Whether this scope is page-scoped (and therefore never touches the + /// app-global identity selection). + pub fn is_page_scoped(&self) -> bool { + matches!(self, Self::PageScopedObject { .. }) + } + + /// The page-scoped selection, if any. Always `None` for [`AppGlobalUser`], + /// which resolves its selection from `AppContext`, not from this scope — a + /// page-scoped object is never the app-global identity (FR-6 boundary). + /// + /// [`AppGlobalUser`]: IdentityPillScope::AppGlobalUser + pub fn page_scoped_selection(&self) -> Option { + match self { + Self::PageScopedObject { selected, .. } => *selected, + Self::AppGlobalUser => None, + } + } +} + +/// Per-page global-nav composition: the page-aware segment-1 (label + link +/// target) and which pills the page shows / how each participates. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PageNavSpec { + segment1_label: String, + segment1_target: RootScreenType, + wallet_pill: Option, + identity_pill: Option<(IdentityPillScope, PillConsumption)>, +} + +impl PageNavSpec { + /// A spec with the given page-aware segment-1 and no pills yet — compose + /// pills via [`with_wallet_pill`](Self::with_wallet_pill) and + /// [`with_identity_pill`](Self::with_identity_pill). + pub fn new(segment1_label: impl Into, segment1_target: RootScreenType) -> Self { + Self { + segment1_label: segment1_label.into(), + segment1_target, + wallet_pill: None, + identity_pill: None, + } + } + + /// A fully-unwired everyday-page spec (the Phase-A rollout default): both + /// the wallet pill and the app-global identity pill render subdued with + /// how-to-change tooltips. Pages with no identity context use + /// [`new`](Self::new) + [`with_wallet_pill`](Self::with_wallet_pill) alone. + pub fn unwired_everyday( + segment1_label: impl Into, + segment1_target: RootScreenType, + wallet_tooltip: impl Into, + identity_tooltip: impl Into, + ) -> Self { + Self::new(segment1_label, segment1_target) + .with_wallet_pill(PillConsumption::Unwired { + tooltip: wallet_tooltip.into(), + }) + .with_identity_pill( + IdentityPillScope::AppGlobalUser, + PillConsumption::Unwired { + tooltip: identity_tooltip.into(), + }, + ) + } + + /// Add the wallet pill with the given participation mode. + pub fn with_wallet_pill(mut self, consumption: PillConsumption) -> Self { + self.wallet_pill = Some(consumption); + self + } + + /// Add the third (identity/object) pill with its scope and participation. + pub fn with_identity_pill( + mut self, + scope: IdentityPillScope, + consumption: PillConsumption, + ) -> Self { + self.identity_pill = Some((scope, consumption)); + self + } + + /// The page-aware segment-1 label (e.g. `Masternodes`). + pub fn segment1_label(&self) -> &str { + &self.segment1_label + } + + /// The root screen segment-1 links to. + pub fn segment1_target(&self) -> RootScreenType { + self.segment1_target + } + + /// The wallet-pill participation, or `None` if the page shows no wallet pill. + pub fn wallet_pill(&self) -> Option<&PillConsumption> { + self.wallet_pill.as_ref() + } + + /// The third-pill scope + participation, or `None` if the page shows no + /// identity/object pill (per-page composition — FR-GLOBAL-NAV-2 rule 4). + pub fn identity_pill(&self) -> Option<&(IdentityPillScope, PillConsumption)> { + self.identity_pill.as_ref() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(byte: u8) -> Identifier { + Identifier::new([byte; 32]) + } + + /// Foundation for TC-NAV-01 — segment-1 is page-driven (label + target). + #[test] + fn segment1_is_page_aware() { + let spec = PageNavSpec::new("Masternodes", RootScreenType::RootScreenIdentityHub); + assert_eq!(spec.segment1_label(), "Masternodes"); + assert_eq!( + spec.segment1_target(), + RootScreenType::RootScreenIdentityHub + ); + } + + /// TC-NAV-15 — a page with no identity/object context shows only the wallet + /// pill; there is no third segment at all. + #[test] + fn wallet_only_page_has_no_identity_pill() { + let spec = PageNavSpec::new("Wallets", RootScreenType::RootScreenWalletsBalances) + .with_wallet_pill(PillConsumption::Consumed); + assert!(spec.wallet_pill().is_some()); + assert!(spec.identity_pill().is_none()); + } + + /// TC-NAV-13 / TC-NAV-14 — an unwired pill's participation carries a + /// non-empty how-to-change tooltip; a consumed pill carries none. + #[test] + fn unwired_pill_carries_nonempty_tooltip() { + let unwired = PillConsumption::Unwired { + tooltip: "Change the active wallet from the Wallets tab.".to_string(), + }; + assert!(!unwired.is_consumed()); + assert_eq!( + unwired.tooltip(), + Some("Change the active wallet from the Wallets tab.") + ); + + let consumed = PillConsumption::Consumed; + assert!(consumed.is_consumed()); + assert!(consumed.tooltip().is_none()); + } + + /// TC-FR6-07 foundation — the page-scoped object selection is isolated from + /// the app-global identity: `PageScopedObject` exposes its own selection, + /// `AppGlobalUser` never does. + #[test] + fn page_scoped_object_is_isolated_from_app_global() { + let scope = IdentityPillScope::page_scoped_object( + "(no masternode yet)", + vec![PageObjectItem { + id: id(7), + label: "mn-east-01".to_string(), + }], + Some(id(7)), + ); + assert!(scope.is_page_scoped()); + assert_eq!(scope.page_scoped_selection(), Some(id(7))); + + let app_global = IdentityPillScope::AppGlobalUser; + assert!(!app_global.is_page_scoped()); + assert_eq!(app_global.page_scoped_selection(), None); + } + + /// The Phase-A rollout default composes both pills subdued with tooltips. + #[test] + fn unwired_everyday_composes_both_subdued() { + let spec = PageNavSpec::unwired_everyday( + "Contracts", + RootScreenType::RootScreenDocumentQuery, + "Change the active wallet from the Wallets tab.", + "Change the active identity from the Identity Hub.", + ); + let wallet = spec.wallet_pill().expect("wallet pill present"); + assert!(!wallet.is_consumed()); + assert!(wallet.tooltip().is_some_and(|t| !t.is_empty())); + + let (scope, consumption) = spec.identity_pill().expect("identity pill present"); + assert_eq!(*scope, IdentityPillScope::AppGlobalUser); + assert!(!consumption.is_consumed()); + assert!(consumption.tooltip().is_some_and(|t| !t.is_empty())); + } +} diff --git a/src/ui/state/masternodes_view.rs b/src/ui/state/masternodes_view.rs new file mode 100644 index 000000000..c0973360d --- /dev/null +++ b/src/ui/state/masternodes_view.rs @@ -0,0 +1,55 @@ +//! Page-scoped view-model for the Masternodes global-nav breadcrumb (B7). +//! +//! Builds the Masternodes page's [`PageNavSpec`]: a page-aware `Masternodes` +//! segment-1 and an **interactive** wallet pill (funds Top up — FR-9). +//! +//! The page deliberately carries **no** object/identity pill. Masternode and +//! evonode identities are never wallet-linked (`wallet_info` is always `None` +//! for them — locked decision #4). The breadcrumb's "wallet pill + object pill" +//! pairing expresses a genuine wallet↔identity relationship elsewhere in the +//! app (a wallet switch can reconcile a User identity); applying it here would +//! falsely imply a wallet↔masternode relationship that does not exist. Node +//! selection is driven entirely by card-click → detail and the detail / +//! load-form `‹ All masternodes` back link. Renders nothing (module-placement +//! discriminator → `ui/state`). +//! +//! The FR-6 boundary (a masternode never becoming the app-global identity) is +//! enforced structurally at the resolution layer (B1), independent of whether +//! any pill renders in this breadcrumb. + +use crate::ui::RootScreenType; +use crate::ui::state::global_nav::{PageNavSpec, PillConsumption}; + +/// Build the Masternodes page's global-nav spec: a page-aware `Masternodes` +/// segment-1 plus an interactive wallet pill. No object/identity pill — see the +/// module docs for why (locked decision #4). +pub fn masternodes_page_nav_spec() -> PageNavSpec { + PageNavSpec::new("Masternodes", RootScreenType::RootScreenMasternodes) + .with_wallet_pill(PillConsumption::Consumed) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The Masternodes breadcrumb exposes segment-1 and an interactive wallet + /// pill, and — deliberately — NO object/identity pill (locked decision #4: + /// masternodes are never wallet-linked, so a wallet↔object pairing would + /// misrepresent the relationship). Card-click/detail drive node selection. + #[test] + fn spec_has_segment1_and_wallet_pill_but_no_object_pill() { + let spec = masternodes_page_nav_spec(); + assert_eq!(spec.segment1_label(), "Masternodes"); + assert_eq!( + spec.segment1_target(), + RootScreenType::RootScreenMasternodes + ); + // Wallet pill interactive (FR-9 Top up). + assert!(spec.wallet_pill().expect("wallet pill").is_consumed()); + // No object/identity pill on this page. + assert!( + spec.identity_pill().is_none(), + "the Masternodes breadcrumb must carry no object/identity pill", + ); + } +} diff --git a/src/ui/state/mod.rs b/src/ui/state/mod.rs index ffc501edd..0d6a64f97 100644 --- a/src/ui/state/mod.rs +++ b/src/ui/state/mod.rs @@ -7,7 +7,9 @@ pub mod account_summary; pub mod avatar_cache; +pub mod global_nav; pub mod hub_selection; +pub mod masternodes_view; pub mod tracked_asset_lock_cache; pub use avatar_cache::AvatarCache; diff --git a/src/ui/tokens/tokens_screen/mod.rs b/src/ui/tokens/tokens_screen/mod.rs index c1daa36bc..d9318957e 100644 --- a/src/ui/tokens/tokens_screen/mod.rs +++ b/src/ui/tokens/tokens_screen/mod.rs @@ -3086,6 +3086,7 @@ mod tests { egui::Context::default(), app_kv, secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); @@ -3387,6 +3388,7 @@ mod tests { egui::Context::default(), app_kv, secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); @@ -3502,6 +3504,7 @@ mod tests { egui::Context::default(), app_kv, secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .expect("Expected to create AppContext"); let mut token_creator_ui = TokensScreen::new(&app_context, TokensSubscreen::TokenCreator); diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 0ff2fa3f7..2540cc712 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -21,7 +21,7 @@ use crate::ui::components::confirmation_dialog::{ConfirmationDialog, Confirmatio use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::password_input::PasswordInput; use crate::ui::components::styled::island_central_panel; -use crate::ui::components::top_panel::add_top_panel; +use crate::ui::components::top_panel::{add_top_panel_with_global_nav, subdued_wallet_only_spec}; use crate::ui::components::wallet_unlock_popup::{WalletUnlockPopup, WalletUnlockResult}; use crate::ui::helpers::clicked_outside_window; use crate::ui::helpers::copy_text_to_clipboard; @@ -2423,10 +2423,11 @@ impl ScreenLike for WalletsBalancesScreen { DesiredAppAction::Custom("RefreshSKWallet".to_string()), )); } - let mut action = add_top_panel( + // TODO: wire wallet selection consumption for the Wallets page. + let mut action = add_top_panel_with_global_nav( ui, &self.app_context, - vec![("Wallets", AppAction::None)], + subdued_wallet_only_spec("Wallets", RootScreenType::RootScreenWalletsBalances), right_buttons, ); diff --git a/tests/backend-e2e/framework/harness.rs b/tests/backend-e2e/framework/harness.rs index 4aaff7485..7398167ce 100644 --- a/tests/backend-e2e/framework/harness.rs +++ b/tests/backend-e2e/framework/harness.rs @@ -262,6 +262,7 @@ impl BackendTestContext { egui_ctx, app_kv, secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .expect("Failed to create AppContext for testnet"); diff --git a/tests/backend-e2e/identity_masternode_withdraw.rs b/tests/backend-e2e/identity_masternode_withdraw.rs index 3bb5da1f1..7e4aca52d 100644 --- a/tests/backend-e2e/identity_masternode_withdraw.rs +++ b/tests/backend-e2e/identity_masternode_withdraw.rs @@ -25,7 +25,7 @@ use crate::framework::harness::ctx; use crate::framework::task_runner::run_task; use dash_evo_tool::backend_task::error::TaskError; -use dash_evo_tool::backend_task::identity::{IdentityInputToLoad, IdentityTask}; +use dash_evo_tool::backend_task::identity::{IdentityInputToLoad, IdentityLoadMode, IdentityTask}; use dash_evo_tool::backend_task::{BackendTask, BackendTaskSuccessResult}; use dash_evo_tool::mcp::server::DashMcpService; use dash_evo_tool::mcp::tools::masternode::{ @@ -93,6 +93,8 @@ fn load_task( keys_input: vec![], derive_keys_from_wallets: false, selected_wallet_seed_hash: None, + encryption_password: None, + load_mode: IdentityLoadMode::Overwrite, }; BackendTask::IdentityTask(IdentityTask::LoadIdentity(input)) } diff --git a/tests/backend-e2e/identity_tasks.rs b/tests/backend-e2e/identity_tasks.rs index 0d313a7d8..63b751464 100644 --- a/tests/backend-e2e/identity_tasks.rs +++ b/tests/backend-e2e/identity_tasks.rs @@ -5,7 +5,8 @@ use crate::framework::harness::ctx; use crate::framework::identity_helpers::build_identity_registration; use crate::framework::task_runner::{run_on_large_stack, run_task, run_task_with_nonce_retry}; use dash_evo_tool::backend_task::identity::{ - IdentityInputToLoad, IdentityTask, IdentityTopUpInfo, TopUpIdentityFundingMethod, + IdentityInputToLoad, IdentityLoadMode, IdentityTask, IdentityTopUpInfo, + TopUpIdentityFundingMethod, }; use dash_evo_tool::backend_task::wallet::WalletTask; use dash_evo_tool::backend_task::{BackendTask, BackendTaskSuccessResult}; @@ -428,6 +429,8 @@ async fn tc_027_load_identity() { keys_input: vec![], derive_keys_from_wallets: true, selected_wallet_seed_hash: Some(si.wallet_seed_hash), + encryption_password: None, + load_mode: IdentityLoadMode::Overwrite, }; let result = run_task( @@ -533,6 +536,8 @@ async fn tc_030_load_nonexistent_identity() { keys_input: vec![], derive_keys_from_wallets: false, selected_wallet_seed_hash: None, + encryption_password: None, + load_mode: IdentityLoadMode::Overwrite, }; let result = run_task( diff --git a/tests/backend-e2e/spv_reconnect.rs b/tests/backend-e2e/spv_reconnect.rs index 4a34520f5..d645e1143 100644 --- a/tests/backend-e2e/spv_reconnect.rs +++ b/tests/backend-e2e/spv_reconnect.rs @@ -75,6 +75,7 @@ async fn spv_reconnect_succeeds_without_already_open() { egui_ctx.clone(), app_kv, secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .expect("create isolated AppContext"), ); diff --git a/tests/backend-e2e/wallet_reregistration.rs b/tests/backend-e2e/wallet_reregistration.rs index 33124be3d..5e9b1597d 100644 --- a/tests/backend-e2e/wallet_reregistration.rs +++ b/tests/backend-e2e/wallet_reregistration.rs @@ -210,6 +210,7 @@ async fn cold_process_boot_from_migrated_state_registers_and_shows_balance() { egui_ctx, app_kv, secret_store, + std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), ) .expect("create cold-boot AppContext"); diff --git a/tests/kittest/dashpay_screen.rs b/tests/kittest/dashpay_screen.rs index 85a1b62db..b440f5b1f 100644 --- a/tests/kittest/dashpay_screen.rs +++ b/tests/kittest/dashpay_screen.rs @@ -17,6 +17,7 @@ use dash_evo_tool::ui::dashpay::add_contact_screen::AddContactScreen; use dash_evo_tool::ui::dashpay::contact_requests::ContactRequests; use dash_evo_tool::ui::dashpay::contacts_list::ContactsList; use dash_evo_tool::ui::dashpay::profile_screen::ProfileScreen; +use dash_evo_tool::ui::dashpay::profile_search::ProfileSearchScreen; use dash_evo_tool::ui::dashpay::qr_code_generator::QRCodeGeneratorScreen; use dash_evo_tool::ui::dashpay::qr_scanner::QRScannerScreen; use dash_evo_tool::ui::dashpay::send_payment::PaymentHistory; @@ -286,6 +287,128 @@ fn add_contact_defaults_to_app_scoped_identity() { }); } +/// Seed a wallet-less masternode identity into the local DB. +fn seed_dp_masternode(app_context: &Arc, byte: u8, alias: &str) -> Identifier { + let pv = PlatformVersion::latest(); + let identity = + Identity::create_basic_identity(Identifier::from([byte; 32]), pv).expect("basic identity"); + let id = identity.id(); + let qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::Masternode, + alias: Some(alias.to_string()), + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::PendingCreation, + network: app_context.network(), + }; + app_context + .insert_local_qualified_identity(&qi, &None) + .expect("seed dashpay masternode"); + id +} + +/// FR-6 boundary through a DashPay screen: a masternode/evonode must +/// never become selectable in a DashPay identity selector, and so can never be +/// written to the app-global identity via the selector's `syncing_global` +/// write-back. The DashPay selectors source their list from the User-filtered +/// accessor. With ONLY a masternode stored, a DashPay screen must seed no +/// identity (the masternode is not eligible) — before the fix it fell back to +/// the masternode as `identities.first()`, leaking it into the sync path. +#[test] +fn masternode_never_selectable_in_dashpay_screens() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let (_h, ctx) = build_ctx(); + + // Only a masternode is stored — no User identity. + let mn_id = seed_dp_masternode(&ctx, 0x4D, "mn-dashpay-leak"); + + // It IS in the unfiltered store (proving the screens filter, not that + // the DB is empty). + assert_eq!( + ctx.load_local_qualified_identities().expect("load").len(), + 1, + "the masternode must be present in the unfiltered store" + ); + assert!( + ctx.selected_identity_id().is_none(), + "no app-global identity should be selected initially" + ); + + // Every DashPay screen that carries a `syncing_global` selector must + // seed NO identity from a masternode-only store. + assert!( + ContactsList::new(ctx.clone()).selected_identity.is_none(), + "ContactsList must not seed a masternode as the selected identity" + ); + assert!( + ContactRequests::new(ctx.clone()) + .selected_identity + .is_none(), + "ContactRequests must not seed a masternode as the selected identity" + ); + assert!( + PaymentHistory::new(ctx.clone()).selected_identity.is_none(), + "PaymentHistory must not seed a masternode as the selected identity" + ); + assert!( + ProfileScreen::new(ctx.clone()).selected_identity.is_none(), + "ProfileScreen must not seed a masternode as the selected identity" + ); + assert!( + AddContactScreen::new(ctx.clone()) + .selected_identity + .is_none(), + "AddContactScreen must not seed a masternode as the selected identity" + ); + assert!( + QRScannerScreen::new(ctx.clone()) + .selected_identity + .is_none(), + "QRScannerScreen must not seed a masternode as the selected identity" + ); + assert!( + QRCodeGeneratorScreen::new(ctx.clone()) + .selected_identity + .is_none(), + "QRCodeGeneratorScreen must not seed a masternode as the selected identity" + ); + + // ProfileSearchScreen holds no selected identity — it only ever reads + // identities through the User-filtered accessor (as its profile-viewing + // context), so a masternode can never surface there. Assert that data + // source excludes the masternode. + let _ = ProfileSearchScreen::new(ctx.clone()); + assert!( + ctx.load_local_user_identities() + .expect("user identities") + .is_empty(), + "the User-filtered accessor ProfileSearchScreen reads must exclude the masternode" + ); + + // The FR-6 invariant: no masternode ever reached the app-global identity. + assert!( + ctx.selected_identity_id().is_none(), + "a masternode must never be written to the app-global identity via DashPay" + ); + assert_ne!( + ctx.selected_identity_id(), + Some(mn_id), + "the app-global identity must never be the masternode" + ); + }); +} + /// Non-Contacts subscreens have no classifier embedded, so they must not /// claim the error — it belongs to the global banner there. #[test] diff --git a/tests/kittest/global_nav_switcher.rs b/tests/kittest/global_nav_switcher.rs new file mode 100644 index 000000000..d9acbd888 --- /dev/null +++ b/tests/kittest/global_nav_switcher.rs @@ -0,0 +1,206 @@ +//! IT-GLOBAL-NAV — the page-aware generalized global-nav switcher (A2). +//! +//! Renders `global_nav_switcher::render` directly with a custom `PageNavSpec` +//! (no root screen consumes it until A3), exercising the parts that differ from +//! the hub: a page-driven segment-1 label and a page-scoped object pill. + +use crate::support::{fresh_app_context, mount_app, with_isolated_data_dir}; +use dash_evo_tool::app::AppAction; +use dash_evo_tool::ui::RootScreenType; +use dash_evo_tool::ui::components::global_nav_switcher::{self, GlobalNavEffect}; +use dash_evo_tool::ui::components::top_panel::apply_global_nav_effect; +use dash_evo_tool::ui::state::global_nav::{IdentityPillScope, PageNavSpec, PillConsumption}; +use dash_evo_tool::ui::state::hub_selection::HubSelection; +use dash_sdk::platform::Identifier; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +/// TC-NAV-01 foundation — segment-1 is page-driven: a spec labelled +/// `Masternodes` renders that label, not the hub's literal `Identities`. +#[test] +fn segment1_label_is_page_driven() { + with_isolated_data_dir(|| { + let (_rt, app_context) = fresh_app_context(); + let mut harness = Harness::builder() + .with_size(egui::vec2(900.0, 200.0)) + .build_ui(move |ui| { + let mut selection = HubSelection::default(); + let spec = + PageNavSpec::new("Masternodes", RootScreenType::RootScreenWalletsBalances) + .with_wallet_pill(PillConsumption::Consumed); + global_nav_switcher::render(ui, &app_context, &spec, &mut selection); + }); + harness.run(); + + assert!( + harness.query_by_label("Masternodes").is_some(), + "segment-1 must render the page-driven label" + ); + assert!( + harness.query_by_label("Identities").is_none(), + "segment-1 must NOT hardcode the hub's Identities label" + ); + }); +} + +/// TC-NAV-16 foundation — the page-scoped object pill renders its placeholder +/// when nothing is selected (never the app-global identity). +#[test] +fn page_scoped_pill_renders_placeholder_when_empty() { + with_isolated_data_dir(|| { + let (_rt, app_context) = fresh_app_context(); + let mut harness = Harness::builder() + .with_size(egui::vec2(900.0, 200.0)) + .build_ui(move |ui| { + let mut selection = HubSelection::default(); + let spec = + PageNavSpec::new("Masternodes", RootScreenType::RootScreenWalletsBalances) + .with_wallet_pill(PillConsumption::Consumed) + .with_identity_pill( + IdentityPillScope::page_scoped_object( + "(no masternode yet)", + vec![], + None, + ), + PillConsumption::Consumed, + ); + global_nav_switcher::render(ui, &app_context, &spec, &mut selection); + }); + harness.run(); + + assert!( + harness + .query_by_label_contains("(no masternode yet)") + .is_some(), + "the page-scoped pill must show its placeholder when empty" + ); + }); +} + +/// TC-NAV-13 — an unwired pill renders subdued (non-interactive): the wallet +/// placeholder still shows, but with no dropdown wiring. Here it renders on a +/// spec whose wallet pill is unwired; the value/placeholder is visible. +#[test] +fn unwired_wallet_pill_renders_placeholder() { + with_isolated_data_dir(|| { + let (_rt, app_context) = fresh_app_context(); + let mut harness = Harness::builder() + .with_size(egui::vec2(900.0, 200.0)) + .build_ui(move |ui| { + let mut selection = HubSelection::default(); + let spec = PageNavSpec::new("Contracts", RootScreenType::RootScreenWalletsBalances) + .with_wallet_pill(PillConsumption::Unwired { + tooltip: "Change the active wallet from the Wallets tab.".to_string(), + }); + global_nav_switcher::render(ui, &app_context, &spec, &mut selection); + }); + harness.run(); + + // No wallets loaded in the fresh context → the unwired wallet pill shows + // the no-wallet placeholder. + assert!( + harness.query_by_label_contains("(no wallet yet)").is_some(), + "the unwired wallet pill must still show its placeholder value" + ); + }); +} + +/// TC-NAV-06 (A3) — applying a wallet switch is silent (no forced navigation) +/// and reconciles the app-global identity as a documented side effect. With no +/// identities loaded, reconciliation resolves to `None` (keep-if-owned → first +/// → None). The FR-6 MN/Evonode exclusion is enforced at the resolution layer +/// in B1; here we assert the silent reconciliation exists and never navigates. +#[test] +fn switch_wallet_is_silent_and_reconciles_identity() { + with_isolated_data_dir(|| { + let (_rt, app_context) = fresh_app_context(); + let action = + apply_global_nav_effect(&app_context, GlobalNavEffect::SwitchWallet([0x11; 32])); + assert_eq!( + action, + AppAction::None, + "switching wallet must not navigate" + ); + assert_eq!(app_context.selected_wallet_hash(), Some([0x11; 32])); + assert_eq!( + app_context.selected_identity_id(), + None, + "reconciliation with no owned identities resolves to None" + ); + }); +} + +/// Segment-1 activation navigates to the page root via `SetMainScreen`. +#[test] +fn navigate_to_root_sets_main_screen() { + with_isolated_data_dir(|| { + let (_rt, app_context) = fresh_app_context(); + let action = apply_global_nav_effect( + &app_context, + GlobalNavEffect::NavigateToRoot(RootScreenType::RootScreenIdentities), + ); + assert_eq!( + action, + AppAction::SetMainScreen(RootScreenType::RootScreenIdentities) + ); + }); +} + +/// TC-FR6-07 (A3) — a page-scoped object selection never writes the app-global +/// identity, even at the shared applier: the FR-6 boundary holds end-to-end. +#[test] +fn select_page_object_never_writes_app_global_identity() { + with_isolated_data_dir(|| { + let (_rt, app_context) = fresh_app_context(); + assert_eq!(app_context.selected_identity_id(), None); + let action = apply_global_nav_effect( + &app_context, + GlobalNavEffect::SelectPageObject(Identifier::new([7; 32])), + ); + assert_eq!(action, AppAction::None); + assert_eq!( + app_context.selected_identity_id(), + None, + "a page-scoped object must not touch the app-global identity (FR-6)" + ); + }); +} + +/// The global switcher renders on non-Hub root screens. Identities (everyday) +/// shows both the wallet and identity placeholders; Wallets (wallet-only, +/// TC-NAV-15) shows only the wallet placeholder, no identity segment. +#[test] +fn switcher_present_on_identities_root() { + with_isolated_data_dir(|| { + let harness = mount_app(RootScreenType::RootScreenIdentities); + assert!( + harness.query_by_label_contains("(no wallet yet)").is_some(), + "the global switcher's wallet placeholder must render on Identities" + ); + assert!( + harness + .query_by_label_contains("(no identity yet)") + .is_some(), + "the everyday spec must render the identity placeholder on Identities" + ); + }); +} + +/// TC-NAV-15 — the Wallets page composes a wallet-only switcher: the wallet +/// placeholder renders, and there is no identity segment at all. +#[test] +fn switcher_wallet_only_on_wallets_root() { + with_isolated_data_dir(|| { + let harness = mount_app(RootScreenType::RootScreenWalletsBalances); + assert!( + harness.query_by_label_contains("(no wallet yet)").is_some(), + "the global switcher's wallet placeholder must render on Wallets" + ); + assert!( + harness + .query_by_label_contains("(no identity yet)") + .is_none(), + "the wallet-only spec must render no identity segment (composition)" + ); + }); +} diff --git a/tests/kittest/identity_hub_switcher.rs b/tests/kittest/identity_hub_switcher.rs index eeedcdddb..2a2953eb9 100644 --- a/tests/kittest/identity_hub_switcher.rs +++ b/tests/kittest/identity_hub_switcher.rs @@ -38,6 +38,17 @@ const PICKER_HEADING: &str = "Pick an identity"; /// Seed one wallet-less basic identity (alias = `alias`, id = `[byte; 32]`) /// into the live per-network identity DB, and return its `Identifier`. fn seed_identity(app_context: &Arc, byte: u8, alias: &str) -> Identifier { + seed_identity_typed(app_context, byte, alias, IdentityType::User) +} + +/// Seed one wallet-less identity of `identity_type` (alias = `alias`, +/// id = `[byte; 32]`) into the live per-network identity DB. +fn seed_identity_typed( + app_context: &Arc, + byte: u8, + alias: &str, + identity_type: IdentityType, +) -> Identifier { let pv = PlatformVersion::latest(); let identity = Identity::create_basic_identity(Identifier::from([byte; 32]), pv).expect("basic identity"); @@ -47,7 +58,7 @@ fn seed_identity(app_context: &Arc, byte: u8, alias: &str) -> Identi associated_voter_identity: None, associated_operator_identity: None, associated_owner_key_id: None, - identity_type: IdentityType::User, + identity_type, alias: Some(alias.to_string()), private_keys: KeyStorage::default(), dpns_names: vec![], @@ -244,6 +255,63 @@ fn qa_001_wallet_less_selection_clears_derived_wallet() { }); } +/// TC-FR6-01/02 + TC-NAV-17 — the Identity Hub is an everyday-user surface: +/// its picker and identity-pill sources list User identities only. A seeded +/// Masternode + Evonode never appear; the User identity does. Exactly one User +/// is seeded so the hub lands on that identity's Home (not the picker), which is +/// itself the assertion that the two node identities did not inflate the +/// everyday-user identity count. +#[test] +fn fr6_hub_excludes_masternode_and_evonode() { + 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::RootScreenIdentityHub); + let app_context = harness.state().current_app_context().clone(); + + seed_identity_typed( + &app_context, + 0x91, + "Node Masternode", + IdentityType::Masternode, + ); + seed_identity_typed(&app_context, 0xE2, "Node Evonode", IdentityType::Evonode); + let user = seed_identity(&app_context, 0x71, "Real User"); + harness.run_steps(5); + + // The everyday-user accessor lists only the User identity. + let user_only = app_context + .load_local_user_identities() + .expect("load user identities"); + assert_eq!( + user_only.len(), + 1, + "hub sources list only the User identity" + ); + assert_eq!(user_only[0].identity.id(), user); + + // The node identities are absent from the hub surface by alias. + assert!( + harness.query_by_label_contains("Node Masternode").is_none(), + "a masternode must not appear on the Identity Hub" + ); + assert!( + harness.query_by_label_contains("Node Evonode").is_none(), + "an evonode must not appear on the Identity Hub" + ); + // But both remain in the masternode accessor (Masternodes-page source). + assert_eq!( + app_context + .load_local_masternode_identities() + .expect("load masternode identities") + .len(), + 2, + "MN + Evonode remain available to the Masternodes page" + ); + }); +} + /// Replaces a sham tautology test — the no-wallet group is identified /// by `wallet_index.is_none()` on real loaded identities: a seeded wallet-less /// identity appears in that filtered group (the exact predicate the breadcrumb diff --git a/tests/kittest/main.rs b/tests/kittest/main.rs index 7082c998f..20bdfc19f 100644 --- a/tests/kittest/main.rs +++ b/tests/kittest/main.rs @@ -3,6 +3,7 @@ mod confirmation_dialog; mod contract_screen; mod create_asset_lock_screen; mod dashpay_screen; +mod global_nav_switcher; mod identities_screen; mod identity_hub; mod identity_hub_activity; @@ -13,6 +14,7 @@ mod identity_hub_switcher; mod identity_selector; mod import_single_key; mod info_popup; +mod masternode_tab; mod message_banner; mod migration_banner; mod network_chooser; @@ -26,3 +28,4 @@ mod support; mod tokens_screen; mod tools_screen; mod wallets_screen; +mod withdraw_screen; diff --git a/tests/kittest/masternode_tab.rs b/tests/kittest/masternode_tab.rs new file mode 100644 index 000000000..ccd76edcc --- /dev/null +++ b/tests/kittest/masternode_tab.rs @@ -0,0 +1,774 @@ +//! IT-MN-TAB — Masternodes root tab: Expert-Mode nav gate + live de-gating (B2), +//! empty state + card grid (B3). + +use crate::support::{mount_app, with_isolated_data_dir}; +use dash_evo_tool::context::AppContext; +use dash_evo_tool::model::qualified_identity::encrypted_key_storage::KeyStorage; +use dash_evo_tool::model::qualified_identity::{IdentityStatus, IdentityType, QualifiedIdentity}; +use dash_evo_tool::ui::{RootScreenType, ScreenLike}; +use dash_sdk::dpp::identity::Identity; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::platform::Identifier; +use egui_kittest::kittest::Queryable; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// Seed one wallet-less masternode/evonode identity into the live per-network +/// identity DB (alias = `alias`, id = `[byte; 32]`, no keys → read-only node). +fn seed_node(app_context: &Arc, byte: u8, alias: &str, node_type: IdentityType) { + let pv = PlatformVersion::latest(); + let identity = + Identity::create_basic_identity(Identifier::from([byte; 32]), pv).expect("basic identity"); + let _ = identity.id(); + let qi = QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: node_type, + alias: Some(alias.to_string()), + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::PendingCreation, + network: app_context.network(), + }; + app_context + .insert_local_qualified_identity(&qi, &None) + .expect("seed masternode insert"); +} + +/// Seed a masternode that has an associated voter identity, inserting BOTH the +/// node and its (separately stored) voter identity into the local DB. Returns +/// the voter identity's id so a test can assert its deletion. The node id is +/// `[byte; 32]`; the voter id is `[byte ^ 0xFF; 32]`. +fn seed_node_with_voter(app_context: &Arc, byte: u8, alias: &str) -> Identifier { + let pv = PlatformVersion::latest(); + let voter_id = Identifier::from([byte ^ 0xFF; 32]); + let voter_identity = + Identity::create_basic_identity(voter_id, pv).expect("voter basic identity"); + let voter_key = dash_sdk::platform::IdentityPublicKey::random_key(1, Some(1), pv); + + // The voter identity is stored as its own local record so its deletion on + // node removal is observable. + let voter_qi = QualifiedIdentity { + identity: voter_identity.clone(), + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: Some(format!("{alias}-voter")), + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: app_context.network(), + }; + app_context + .insert_local_qualified_identity(&voter_qi, &None) + .expect("seed voter insert"); + + let node_identity = Identity::create_basic_identity(Identifier::from([byte; 32]), pv) + .expect("node basic identity"); + let node_qi = QualifiedIdentity { + identity: node_identity, + associated_voter_identity: Some((voter_identity, voter_key)), + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::Masternode, + alias: Some(alias.to_string()), + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::PendingCreation, + network: app_context.network(), + }; + app_context + .insert_local_qualified_identity(&node_qi, &None) + .expect("seed node-with-voter insert"); + voter_id +} + +/// Seed a masternode whose associated voter identity carries one public key, +/// so the detail view's "Manage keys" list renders a deterministic +/// "Voting key ›" button (voter-target keys are always labelled "Voting"). +fn seed_node_with_voter_key(app_context: &Arc, byte: u8, alias: &str) { + let pv = PlatformVersion::latest(); + let mut voter_identity = + Identity::create_basic_identity(Identifier::from([byte ^ 0xFF; 32]), pv) + .expect("voter basic identity"); + let voter_key = dash_sdk::platform::IdentityPublicKey::random_key(1, Some(1), pv); + voter_identity.add_public_key(voter_key.clone()); + + let node_identity = Identity::create_basic_identity(Identifier::from([byte; 32]), pv) + .expect("node basic identity"); + let node_qi = QualifiedIdentity { + identity: node_identity, + associated_voter_identity: Some((voter_identity, voter_key)), + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::Masternode, + alias: Some(alias.to_string()), + private_keys: KeyStorage::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::PendingCreation, + network: app_context.network(), + }; + app_context + .insert_local_qualified_identity(&node_qi, &None) + .expect("seed node-with-voter-key insert"); +} + +/// TC-FR1-01…04 — the Masternodes nav entry is absent when Expert Mode is off +/// and present when it is on. Toggling `enable_developer_mode` flips the gate; +/// the nav rail re-evaluates the per-entry `FeatureGate::DeveloperMode` skip +/// each frame. Counted with `query_all_by_label` because the nav button exposes +/// both a Button and an inner Label node for the same text. +#[test] +fn nav_gated_by_expert_mode() { + with_isolated_data_dir(|| { + let mut harness = mount_app(RootScreenType::RootScreenIdentities); + let app_context = harness.state().current_app_context().clone(); + + app_context.enable_developer_mode(false); + harness.run_steps(3); + assert_eq!( + harness.query_all_by_label("Masternodes").count(), + 0, + "the Masternodes nav entry must be absent when Expert Mode is off" + ); + + app_context.enable_developer_mode(true); + harness.run_steps(3); + assert!( + harness.query_all_by_label("Masternodes").count() >= 1, + "the Masternodes nav entry must appear when Expert Mode is on" + ); + }); +} + +/// TC-EDGE-05/06 (§10.11) — live de-gating: with the Masternodes tab active, +/// flipping Expert Mode off falls the active tab back to the neutral Identities +/// tab (the gated screen is never shown without its gate). Drives the guard in +/// `active_root_screen_mut` directly by selecting the tab, then revoking the +/// gate. +#[test] +fn de_gating_falls_back_to_identities() { + 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(); + + // Expert Mode on, select the Masternodes tab — it stays selected. + app_context.enable_developer_mode(true); + harness.state_mut().selected_main_screen = RootScreenType::RootScreenMasternodes; + harness.run_steps(3); + assert_eq!( + harness.state().selected_main_screen, + RootScreenType::RootScreenMasternodes, + "the Masternodes tab stays active while Expert Mode is on" + ); + + // Flip Expert Mode off — the active tab must fall back to Identities. + app_context.enable_developer_mode(false); + harness.run_steps(3); + assert_eq!( + harness.state().selected_main_screen, + RootScreenType::RootScreenIdentities, + "de-gating must fall the active tab back to Identities" + ); + }); +} + +/// Enable Expert Mode, activate the Masternodes tab, and reload its cached list +/// (the direct field-set bypasses `set_main_screen`, so drive the screen's +/// arrival refresh explicitly — the same call `set_main_screen` makes). +fn activate_masternodes_tab( + harness: &mut egui_kittest::Harness<'static, dash_evo_tool::app::AppState>, + app_context: &Arc, +) { + app_context.enable_developer_mode(true); + harness.state_mut().selected_main_screen = RootScreenType::RootScreenMasternodes; + harness + .state_mut() + .active_root_screen_mut() + .refresh_on_arrival(); + harness.run_steps(3); +} + +/// TC-FR2-01…05 — with zero nodes loaded the Masternodes tab renders the empty +/// state with the exact canonical §7 copy: heading, body, primary CTA, and the +/// reassurance line. +#[test] +fn empty_state_renders_canonical_copy() { + 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("No masternodes loaded").is_some(), + "empty-state heading must render (TC-FR2-02)" + ); + assert!( + harness + .query_by_label( + "Load a masternode or evonode to vote on DPNS name contests and manage its \ + owner and payout keys." + ) + .is_some(), + "empty-state body copy must render verbatim (TC-FR2-03)" + ); + assert!( + harness.query_by_label("Load a masternode").is_some(), + "empty-state primary CTA must render (TC-FR2-04)" + ); + assert!( + harness + .query_by_label( + "Have your node's ProTxHash to hand. Keys are optional — a node loads \ + read-only without them." + ) + .is_some(), + "empty-state reassurance line must render verbatim (TC-FR2-05)" + ); + }); +} + +/// 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 +/// the top-right Refresh toolbar button is present. +#[test] +fn card_grid_renders_seeded_nodes() { + 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(); + + seed_node(&app_context, 0x91, "mn-east-01", IdentityType::Masternode); + seed_node(&app_context, 0x92, "evo-west-02", IdentityType::Evonode); + activate_masternodes_tab(&mut harness, &app_context); + + // Empty state is gone; both node headings render (TC-FR3-01/15). + assert!( + harness.query_by_label("No masternodes loaded").is_none(), + "empty state must not render once nodes are loaded (TC-FR2-07)" + ); + assert!( + harness.query_by_label("mn-east-01").is_some(), + "masternode card heading must render" + ); + assert!( + harness.query_by_label("evo-west-02").is_some(), + "evonode card heading must render" + ); + + // Each card is one accessible click target labelled `Open {node}` + // (TC-NFR6-01). + assert!( + harness.query_by_label("Open mn-east-01").is_some(), + "card must expose a single accessible `Open {{node}}` label" + ); + + // Status label pairs colour with text — never colour-only (TC-NFR6-03). + assert!( + harness.query_all_by_label("Pending Creation").count() >= 1, + "identity-status label must render as text alongside its dot" + ); + + // Top-right Refresh toolbar button (TC-FR7-01). + assert!( + harness.query_all_by_label("Refresh").count() >= 1, + "Refresh toolbar button must be present on the card list" + ); + }); +} + +/// TC-FR4-01/02/04/18/21 — the empty-state CTA opens the load form with the +/// full MN/Evonode field set (ProTxHash, both type segments, key inputs, +/// always-visible Warning note) and no User segment; Cancel returns to the list. +#[test] +fn load_form_opens_from_cta_and_cancels() { + 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); + + // Open the load form from the empty-state primary CTA. + harness.get_by_label("Load a masternode").click(); + harness.run_steps(3); + + // Field set present (TC-FR4-01/02): ProTxHash + both type segments + the + // submit button. The empty-state heading is gone. + assert!( + harness.query_by_label("No masternodes loaded").is_none(), + "empty state must be replaced by the load form" + ); + assert!( + harness.query_by_label("ProTxHash").is_some(), + "ProTxHash field label must render" + ); + assert!( + harness.query_all_by_label("Masternode").count() >= 1, + "Masternode type segment must render" + ); + assert!( + harness.query_by_label("Evonode").is_some(), + "Evonode type segment must render (TC-FR4-04: no User segment)" + ); + assert!( + harness.query_by_label("Load masternode").is_some(), + "the Load submit button must render" + ); + + // Warning-tone key-storage note is always visible (TC-FR4-18). + assert!( + harness + .query_by_label( + "Set an optional password to encrypt these keys on this device. Without one, \ + they are stored unencrypted and you can add protection later from the key \ + screen." + ) + .is_some(), + "the always-visible Warning-tone key-storage note must render verbatim" + ); + + // Cancel returns to the list / empty state (TC-FR4-21). The Cancel + // button sits at the bottom of the scrollable form; give the harness a + // taller window so the whole form (back link + fields + actions row) + // fits and the button is reachable in this headless viewport. + harness.set_size(egui::vec2(1280.0, 1200.0)); + harness.run_steps(2); + harness.get_by_label("Cancel").click(); + harness.run_steps(3); + assert!( + harness.query_by_label("No masternodes loaded").is_some(), + "Cancel must return to the list without loading" + ); + }); +} + +/// The load form carries the same `‹ All masternodes` back link as the detail +/// view (wireframe C): it renders at the top of the form and returns to the list +/// without loading anything. This is the always-visible navigation affordance, +/// distinct from the bottom Cancel button. +#[test] +fn load_form_back_link_returns_to_list() { + 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); + + // Open the load form from the empty-state primary CTA. + harness.get_by_label("Load a masternode").click(); + harness.run_steps(3); + assert!( + harness.query_by_label("No masternodes loaded").is_none(), + "empty state must be replaced by the load form" + ); + + // The back link is present at the top of the form and returns to the + // list (empty state) without loading. + assert!( + harness.query_by_label("‹ All masternodes").is_some(), + "the load form must render the `‹ All masternodes` back link" + ); + harness.get_by_label("‹ All masternodes").click(); + harness.run_steps(3); + assert!( + harness.query_by_label("No masternodes loaded").is_some(), + "the back link must return to the list without loading" + ); + }); +} + +/// TC-FR5-01/02/07, TC-FR9-01/02, TC-FR11-01/02, TC-FR7-04 — clicking a card +/// opens the detail view with the ordered sections (Actions row present with all +/// three credit actions), the Evonode-only claim cross-link shown for an evonode +/// but absent for a masternode, a detail Refresh button, and the `‹ All +/// masternodes` back row returning to the list. +#[test] +fn detail_view_opens_from_card_with_sections_and_back() { + 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(); + seed_node(&app_context, 0x93, "mn-detail-01", IdentityType::Masternode); + seed_node(&app_context, 0x94, "evo-detail-02", IdentityType::Evonode); + activate_masternodes_tab(&mut harness, &app_context); + + // Open the masternode's detail view. + harness.get_by_label("Open mn-detail-01").click(); + harness.run_steps(3); + + // Section presence + Actions row (TC-FR5-01, TC-FR9-01). The alias shows + // both in the header and the page-scoped pill, so count ≥ 1. + assert!( + harness.query_all_by_label("mn-detail-01").count() >= 1, + "header alias" + ); + assert!( + harness.query_by_label("Actions").is_some(), + "Actions section" + ); + assert!( + harness.query_by_label("Withdraw").is_some(), + "Withdraw action" + ); + assert!(harness.query_by_label("Top up").is_some(), "Top up action"); + assert!( + harness.query_by_label("Transfer").is_some(), + "Transfer action" + ); + assert!(harness.query_by_label("Keys").is_some(), "Keys section"); + assert!( + harness.query_by_label("Remove masternode").is_some(), + "Remove action" + ); + assert!( + harness.query_all_by_label("Refresh").count() >= 1, + "detail Refresh button (TC-FR7-04)" + ); + // Claim cross-link absent for a plain masternode (TC-FR11-02). + assert!( + harness.query_by_label("Claim token rewards ›").is_none(), + "masternode detail must not show the evonode claim cross-link" + ); + + // Back row returns to the card list (TC-FR5-07). + harness.get_by_label("‹ All masternodes").click(); + harness.run_steps(3); + assert!( + harness.query_all_by_label("mn-detail-01").count() >= 1, + "back row returns to the card grid" + ); + + // The evonode's detail view shows the claim cross-link (TC-FR11-01). + harness.get_by_label("Open evo-detail-02").click(); + harness.run_steps(3); + assert!( + harness.query_by_label("Claim token rewards ›").is_some(), + "evonode detail must show the claim cross-link" + ); + }); +} + +/// 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). +#[test] +fn dpns_section_missing_voter_scoped_prompt() { + 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(); + seed_node(&app_context, 0x95, "mn-vote-01", IdentityType::Masternode); + activate_masternodes_tab(&mut harness, &app_context); + 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( + "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" + ); + + // 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.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)" + ); + }); +} + +/// TC-NAV-12 / TC-FR6-07 (release-blocking) — selecting a masternode on the +/// Masternodes page (opening its detail via a card click) must NEVER write the +/// app-global identity selection. With no User identity loaded, the +/// app-global selection must stay `None` even after a masternode is in view, and +/// stay `None` after navigating away to Identities/Identity Hub. +#[test] +fn masternode_selection_never_leaks_to_app_global_identity() { + 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(); + seed_node(&app_context, 0x96, "mn-leak-01", IdentityType::Masternode); + + // Baseline: no app-global identity selected. + assert!( + app_context.selected_identity_id().is_none(), + "no app-global identity should be selected initially" + ); + + // Open the masternode's detail — this sets the page-scoped selection. + activate_masternodes_tab(&mut harness, &app_context); + harness.get_by_label("Open mn-leak-01").click(); + harness.run_steps(3); + + // FR-6 boundary: the masternode must NOT have become the app-global + // identity, nor resolve as one. + assert!( + app_context.selected_identity_id().is_none(), + "masternode selection must not write the app-global identity id" + ); + assert!( + app_context.resolve_selected_identity().is_none(), + "a masternode must never resolve as the app-global identity" + ); + + // Navigate away to Identities and Identity Hub — still no leak. + harness.state_mut().selected_main_screen = RootScreenType::RootScreenIdentities; + harness.run_steps(3); + assert!( + app_context.selected_identity_id().is_none(), + "the app-global identity stays unset on Identities after MN selection" + ); + + harness.state_mut().selected_main_screen = RootScreenType::RootScreenIdentityHub; + harness.run_steps(3); + assert!( + app_context.resolve_selected_identity().is_none(), + "the app-global identity stays unset on the Hub after MN selection" + ); + }); +} + +/// TC-US4-01/02/06/07 — the detail Remove flow: the danger button opens a +/// confirmation with the `Remove masternode` verb; confirming deletes only the +/// target node (its card disappears) and leaves other nodes intact (isolation). +#[test] +fn remove_flow_deletes_only_target_node() { + 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(); + seed_node(&app_context, 0x97, "mn-remove-me", IdentityType::Masternode); + seed_node(&app_context, 0x98, "mn-keep-me", IdentityType::Masternode); + activate_masternodes_tab(&mut harness, &app_context); + + // Two nodes present in the DB. + assert_eq!( + app_context + .load_local_masternode_identities() + .expect("load") + .len(), + 2 + ); + + // Open the target node's detail and click the danger Remove button + // (unique before the dialog opens). + harness.get_by_label("Open mn-remove-me").click(); + harness.run_steps(3); + harness.get_by_label("Remove masternode").click(); + harness.run_steps(3); + + // The confirmation is open with the `Remove masternode` verb; confirm by + // clicking the danger button (last node with that label — the section + // button, dialog title, and confirm button all share the verb). + let confirm = harness + .query_all_by_label("Remove masternode") + .last() + .expect("confirm button present"); + confirm.click(); + harness.run_steps(3); + + // Only the target node was deleted; the other remains (isolation). + let remaining = app_context + .load_local_masternode_identities() + .expect("load"); + assert_eq!(remaining.len(), 1, "exactly one node removed"); + assert_eq!( + remaining[0].alias.as_deref(), + Some("mn-keep-me"), + "the untargeted node must survive" + ); + assert!( + harness.query_by_label("mn-remove-me").is_none(), + "removed node's card must be gone" + ); + assert!( + harness.query_all_by_label("mn-keep-me").count() >= 1, + "kept node's card must remain" + ); + }); +} + +/// TC-US4-05 — removing a node also deletes its associated voter identity from +/// local storage, not just the node record. Seeds a masternode whose voter +/// identity is stored separately, removes the node through the confirm flow, +/// and asserts both the node and the voter identity are gone. +#[test] +fn remove_flow_deletes_associated_voter_identity() { + 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(); + let voter_id = seed_node_with_voter(&app_context, 0xA1, "mn-with-voter"); + activate_masternodes_tab(&mut harness, &app_context); + + // Precondition: both the node and its voter identity are stored. + assert_eq!( + app_context + .load_local_masternode_identities() + .expect("load") + .len(), + 1, + "the masternode must be seeded" + ); + assert!( + app_context + .get_local_qualified_identity(&voter_id) + .expect("voter read") + .is_some(), + "the voter identity must be seeded" + ); + + // Open the node's detail and confirm removal. + harness.get_by_label("Open mn-with-voter").click(); + harness.run_steps(3); + harness.get_by_label("Remove masternode").click(); + harness.run_steps(3); + let confirm = harness + .query_all_by_label("Remove masternode") + .last() + .expect("confirm button present"); + confirm.click(); + harness.run_steps(3); + + // Both the node and its voter identity are deleted. + assert_eq!( + app_context + .load_local_masternode_identities() + .expect("load") + .len(), + 0, + "the masternode must be removed" + ); + assert!( + app_context + .get_local_qualified_identity(&voter_id) + .expect("voter read") + .is_none(), + "the associated voter identity must be removed too (TC-US4-05)" + ); + }); +} + +/// Execution-level: clicking a per-key "Manage keys" button in the masternode +/// detail view opens the interactive `KeyInfoScreen` (not the static read-only +/// `KeysScreen`). Seeds a node whose voter identity carries one key so +/// a deterministic "Voting key ›" button renders, clicks it, and asserts a +/// `KeyInfoScreen` is pushed and its "Key Information" heading renders. +#[test] +fn manage_keys_button_opens_key_info_screen() { + use dash_evo_tool::ui::Screen; + + 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(); + seed_node_with_voter_key(&app_context, 0x71, "mn-keys-01"); + activate_masternodes_tab(&mut harness, &app_context); + + harness.get_by_label("Open mn-keys-01").click(); + harness.run_steps(3); + + // The per-key "Manage keys" list renders a Voting key button. + assert!( + harness.query_by_label("Voting key ›").is_some(), + "the Keys section must render a per-key 'Voting key ›' button" + ); + // No KeyInfoScreen is on the stack yet. + assert!( + !harness + .state() + .screen_stack + .iter() + .any(|s| matches!(s, Screen::KeyInfoScreen(_))), + "no KeyInfoScreen should be open before the click" + ); + + harness.get_by_label("Voting key ›").click(); + harness.run_steps(3); + + // Clicking it pushes the interactive KeyInfoScreen. + assert!( + matches!( + harness.state().screen_stack.last(), + Some(Screen::KeyInfoScreen(_)) + ), + "clicking a per-key button must push KeyInfoScreen" + ); + assert!( + harness.query_by_label("Key Information").is_some(), + "the pushed KeyInfoScreen must render its 'Key Information' heading" + ); + }); +} diff --git a/tests/kittest/withdraw_screen.rs b/tests/kittest/withdraw_screen.rs new file mode 100644 index 000000000..c8ab57688 --- /dev/null +++ b/tests/kittest/withdraw_screen.rs @@ -0,0 +1,353 @@ +//! Kittest coverage for `WithdrawalScreen` key pre-selection. +//! +//! `WithdrawalScreen::new()` used to pre-select a signing key via +//! `Identity::get_first_public_key_matching(Purpose::TRANSFER, ...)`, scanning +//! on-chain keys unfiltered by local private-key presence — a "ghost key" (an +//! on-chain TRANSFER key with no local private material, e.g. a loaded +//! masternode identity with the payout field left blank) could get silently +//! pre-selected even though the signer can never use it. The fix +//! (`identity.default_withdrawal_key()`, TRANSFER-preferred / OWNER-fallback, +//! private-key-backed only) is unit-tested at the model layer in +//! `src/model/qualified_identity/mod.rs::withdrawal_key_tests`. This file +//! verifies the fix holds when `WithdrawalScreen` is actually constructed and +//! rendered — the layer the model-only tests never touched. +//! +//! `selected_key` is a private field, so it can't be asserted directly from +//! this external integration-test crate. Instead: +//! - the ghost-key case is asserted through the screen's *observable* +//! behavior: the "no signable key" empty state renders instead of the +//! withdraw form, and (see `ghost_key_leaks_raw_error_banner` below) a +//! genuine regression this fix newly exposes at the constructor; +//! - the happy-path / owner-fallback cases are asserted through the rendered +//! ComboBox's `selected_text`, closing the "constructor picks one thing, +//! widget renders another" gap the original bug lived in. + +use crate::support::with_isolated_data_dir; +use dash_evo_tool::context::AppContext; +use dash_evo_tool::model::qualified_identity::encrypted_key_storage::{KeyStorage, PrivateKeyData}; +use dash_evo_tool::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; +use dash_evo_tool::model::qualified_identity::{ + IdentityStatus, IdentityType, PrivateKeyTarget, QualifiedIdentity, +}; +use dash_evo_tool::ui::ScreenLike; +use dash_evo_tool::ui::components::MessageBanner; +use dash_evo_tool::ui::helpers::format_key_label; +use dash_evo_tool::ui::identities::withdraw_screen::WithdrawalScreen; +use dash_sdk::dpp::identity::identity_public_key::accessors::v0::{ + IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, +}; +use dash_sdk::dpp::identity::{Identity, KeyID, Purpose, SecurityLevel}; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::platform::{Identifier, IdentityPublicKey}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// Builds an on-chain public key. Mirrors the `key()` helper in +/// `model/qualified_identity/mod.rs::withdrawal_key_tests` so the two test +/// suites stay in lockstep on fixture shape. +fn key(id: KeyID, purpose: Purpose) -> IdentityPublicKey { + let mut k = IdentityPublicKey::random_key(id, Some(id as u64), PlatformVersion::latest()); + k.set_id(id); + k.set_purpose(purpose); + k.set_security_level(SecurityLevel::CRITICAL); + k +} + +/// Builds a `QualifiedIdentity` with `on_chain` public keys, of which +/// `with_private` additionally get local `Clear` private material. Same +/// fixture shape as the model-layer `withdrawal_key_tests::build_identity` +/// helper, extended with a live `AppContext`'s network so the screen renders. +fn build_identity( + app_context: &Arc, + identity_type: IdentityType, + on_chain: Vec, + with_private: Vec, +) -> QualifiedIdentity { + let public_keys: BTreeMap = + on_chain.into_iter().map(|k| (k.id(), k)).collect(); + let identity = Identity::new_with_id_and_keys( + Identifier::random(), + public_keys, + PlatformVersion::latest(), + ) + .expect("identity"); + + let mut private_keys = BTreeMap::new(); + for k in with_private { + private_keys.insert( + (PrivateKeyTarget::PrivateKeyOnMainIdentity, k.id()), + ( + QualifiedIdentityPublicKey::from(k), + PrivateKeyData::Clear([0u8; 32]), + ), + ); + } + + QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type, + alias: None, + private_keys: KeyStorage { private_keys }, + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + status: IdentityStatus::Active, + network: app_context.network(), + } +} + +/// Mounts `WithdrawalScreen::ui()` directly (it manages its own top/left/central +/// panels, same as when driven through `AppState`) and runs one settle pass. +fn mount_withdrawal_screen(screen: WithdrawalScreen) -> Harness<'static, WithdrawalScreen> { + let mut harness = Harness::builder() + .with_size(egui::vec2(1100.0, 850.0)) + .build_ui_state( + |ui, screen: &mut WithdrawalScreen| { + screen.ui(ui); + }, + screen, + ); + harness.run(); + harness +} + +/// Builds a fresh, isolated `AppContext` via the same `AppState::new` factory +/// other kittests use, without mounting a root screen. +fn fresh_context() -> (tokio::runtime::Runtime, Arc) { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let guard = rt.enter(); + let mut bootstrap = Harness::builder().with_max_steps(20).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("AppState builds") + .with_animations(false) + }); + bootstrap.run_steps(5); + let app_context = bootstrap.state().current_app_context().clone(); + drop(bootstrap); + drop(guard); + (rt, app_context) +} + +/// Ghost-key repro: an identity whose only on-chain key is a `Purpose::TRANSFER` +/// key with no local private material (like a loaded masternode identity with +/// the payout field left blank). The withdraw form must never render for it — +/// `available_withdrawal_keys()` (private-key-backed only) is empty, so the +/// screen shows the "no eligible key" empty state, not a Confirm-enabled form. +#[test] +fn ghost_transfer_key_shows_no_keys_empty_state_not_a_form() { + with_isolated_data_dir(|| { + let (_rt, app_context) = fresh_context(); + + let transfer = key(1, Purpose::TRANSFER); + let identity = build_identity( + &app_context, + IdentityType::Masternode, + vec![transfer], + vec![], + ); + + let screen = WithdrawalScreen::new(identity, &app_context); + let harness = mount_withdrawal_screen(screen); + + assert!( + harness + .query_by_label_contains("You do not have any withdrawal keys loaded") + .is_some(), + "a ghost-key-only identity must show the no-keys empty state" + ); + assert!( + harness + .query_by_label_contains("Amount to withdraw") + .is_none(), + "the withdraw form (amount/address/key-selection) must never render \ + for an identity with no locally-signable withdrawal key" + ); + assert!( + harness.query_by_label_contains("Estimated fee").is_none(), + "the fee-estimate / Confirm section must never render without a signable key" + ); + }); +} + +/// Regression lock for a bug this fix (default_withdrawal_key) briefly +/// introduced and a follow-up fix (2edbc18e, "skip wallet resolution when no +/// signable key exists") closed: constructing `WithdrawalScreen` for a +/// ghost-key-only identity used to leak a raw, technical error banner +/// ("No key provided when getting selected wallet") from +/// `get_selected_wallet()`. Before the original fix `selected_key` was almost +/// always `Some(..)` (unfiltered on-chain scan), so that `Err` branch in +/// `get_selected_wallet` was rarely reached; once `selected_key` correctly +/// started becoming `None` whenever no locally-signable key exists, every such +/// identity tripped this internal-string leak on screen open — violating this +/// project's own error-message conventions (CLAUDE.md "Error messages": no +/// raw/internal strings in user-facing banners, must be actionable). +/// `WithdrawalScreen::new()` now only calls `get_selected_wallet` when +/// `selected_key` is `Some`, making that `Err` path structurally unreachable +/// here rather than merely avoided by luck — and the no-keys empty state must +/// still render correctly for the same identity. +#[test] +fn ghost_key_construction_does_not_leak_raw_error_banner() { + with_isolated_data_dir(|| { + let (_rt, app_context) = fresh_context(); + // Bootstrap (AppState::new + a few settle frames) can itself raise + // unrelated startup banners (e.g. connection-status warnings); clear + // them so this test observes only what `WithdrawalScreen::new()` adds. + MessageBanner::clear_all_global(app_context.egui_ctx()); + + let transfer = key(1, Purpose::TRANSFER); + let identity = build_identity( + &app_context, + IdentityType::Masternode, + vec![transfer], + vec![], + ); + + assert!( + !MessageBanner::has_global(app_context.egui_ctx()), + "precondition: no banner before construction" + ); + + let screen = WithdrawalScreen::new(identity, &app_context); + + assert!( + !MessageBanner::has_global(app_context.egui_ctx()), + "WithdrawalScreen::new() must not leak get_selected_wallet's raw \ + \"No key provided...\" error as a global banner just from opening \ + the screen for an identity with no signable key (regression: \ + 2edbc18e guards the call on selected_key being Some)" + ); + + // The no-keys empty state must still render correctly for this identity + // — the fix must not have traded the banner leak for a broken empty state. + let harness = mount_withdrawal_screen(screen); + assert!( + harness + .query_by_label_contains("You do not have any withdrawal keys loaded") + .is_some(), + "the no-keys empty state must still render after the banner-leak fix" + ); + assert!( + harness + .query_by_label_contains("Amount to withdraw") + .is_none(), + "the withdraw form must still not render for a ghost-key-only identity" + ); + }); +} + +/// Happy path: an identity with a TRANSFER key backed by real local private +/// material. `selected_key` must be `Some` and the rendered key-selection +/// ComboBox must display that exact key — the constructor and the widget must +/// agree, closing the exact gap the original bug lived in. +#[test] +fn private_backed_transfer_key_is_selected_and_rendered_in_combo() { + with_isolated_data_dir(|| { + let (_rt, app_context) = fresh_context(); + MessageBanner::clear_all_global(app_context.egui_ctx()); + + let transfer = key(1, Purpose::TRANSFER); + let identity = build_identity( + &app_context, + IdentityType::User, + vec![transfer.clone()], + vec![transfer.clone()], + ); + + let screen = WithdrawalScreen::new(identity, &app_context); + assert!( + !MessageBanner::has_global(app_context.egui_ctx()), + "no error banner expected when a private-key-backed key is pre-selected" + ); + + let mut harness = mount_withdrawal_screen(screen); + + assert!( + harness + .query_by_label_contains("Amount to withdraw") + .is_some(), + "the withdraw form must render when a locally-signable key exists" + ); + assert!( + harness + .query_by_label_contains("You do not have any withdrawal keys loaded") + .is_none() + ); + + // Reveal key selection (section 3) via "Show Advanced Options". + harness.get_by_label("Show Advanced Options").click(); + harness.run_steps(3); + + assert!( + harness + .query_by_label_contains("3. Select the key to sign with") + .is_some(), + "the key-selection section must render once Advanced Options is shown" + ); + + // The ComboBox exposes its current selection via accesskit's `value` + // (a plain Label like "Withdraw" uses `label`; egui's ComboBox does not). + let expected_label = format_key_label(&transfer); + assert!( + harness.query_by_value(&expected_label).is_some(), + "the key-selection ComboBox must display the pre-selected TRANSFER \ + key's label ({expected_label}); constructor and widget disagreeing \ + is exactly the class of bug this fix targets" + ); + assert!( + harness.query_by_value("Select Key…").is_none(), + "the combo must not show the unselected placeholder when a key was pre-selected" + ); + }); +} + +/// Owner fallback: a masternode-type identity with only a private-key-backed +/// OWNER key, no TRANSFER key at all. `default_withdrawal_key()` must fall +/// back to OWNER, and the rendered ComboBox must reflect that exact key. +#[test] +fn owner_key_fallback_is_selected_and_rendered_in_combo_when_no_transfer_key() { + with_isolated_data_dir(|| { + let (_rt, app_context) = fresh_context(); + MessageBanner::clear_all_global(app_context.egui_ctx()); + + let owner = key(2, Purpose::OWNER); + let identity = build_identity( + &app_context, + IdentityType::Masternode, + vec![owner.clone()], + vec![owner.clone()], + ); + + let screen = WithdrawalScreen::new(identity, &app_context); + assert!( + !MessageBanner::has_global(app_context.egui_ctx()), + "no error banner expected when the OWNER-fallback key is pre-selected" + ); + + let mut harness = mount_withdrawal_screen(screen); + + assert!( + harness + .query_by_label_contains("Amount to withdraw") + .is_some(), + "the withdraw form must render for the OWNER-fallback case" + ); + + harness.get_by_label("Show Advanced Options").click(); + harness.run_steps(3); + + let expected_label = format_key_label(&owner); + assert!( + harness.query_by_value(&expected_label).is_some(), + "the key-selection ComboBox must display the pre-selected OWNER \ + fallback key's label ({expected_label})" + ); + assert!(harness.query_by_value("Select Key…").is_none()); + }); +} diff --git a/tests/mcp_http_auth.rs b/tests/mcp_http_auth.rs new file mode 100644 index 000000000..d1623484f --- /dev/null +++ b/tests/mcp_http_auth.rs @@ -0,0 +1,75 @@ +//! HTTP bearer-auth contract for the MCP server. +//! +//! Pins the wire format the server's `bearer_auth` middleware accepts, so the +//! `det-cli` HTTP client and the headless server can never drift apart again. +//! Regression guard for the double-`Bearer` bug: rmcp's client `auth_header` +//! takes a raw token and prepends `Bearer ` itself, so a client that also +//! prepends puts `Bearer Bearer ` on the wire and is rejected. + +#![cfg(feature = "mcp")] + +use std::net::SocketAddr; + +use axum::{Router, middleware, routing::get}; +use dash_evo_tool::mcp::auth::{ApiKey, bearer_auth}; + +const TOKEN: &str = "test-api-key-0123456789"; + +async fn spawn_server() -> SocketAddr { + let api_key = ApiKey(std::sync::Arc::from(TOKEN)); + let protected = Router::new() + .route("/mcp", get(|| async { "ok" })) + .route_layer(middleware::from_fn_with_state(api_key, bearer_auth)); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve( + listener, + protected.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); + }); + addr +} + +/// The raw token — exactly what rmcp's `auth_header` puts through +/// reqwest's `bearer_auth` — is accepted. +#[tokio::test] +async fn raw_token_is_accepted() { + let addr = spawn_server().await; + let resp = reqwest::Client::new() + .get(format!("http://{addr}/mcp")) + .bearer_auth(TOKEN) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::OK); +} + +/// A pre-prefixed token yields `Authorization: Bearer Bearer ` on the +/// wire and MUST be rejected — this is the exact shape of the old client bug. +#[tokio::test] +async fn double_bearer_prefix_is_rejected() { + let addr = spawn_server().await; + let resp = reqwest::Client::new() + .get(format!("http://{addr}/mcp")) + .bearer_auth(format!("Bearer {TOKEN}")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED); +} + +/// No credentials at all is rejected. +#[tokio::test] +async fn missing_authorization_is_rejected() { + let addr = spawn_server().await; + let resp = reqwest::Client::new() + .get(format!("http://{addr}/mcp")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), reqwest::StatusCode::UNAUTHORIZED); +}