Skip to content

chore: impl ContextProvider - #1

Merged
QuantumExplorer merged 3 commits into
masterfrom
fix/sdk-deadlock
Oct 9, 2024
Merged

chore: impl ContextProvider#1
QuantumExplorer merged 3 commits into
masterfrom
fix/sdk-deadlock

Conversation

@lklimek

@lklimek lklimek commented Oct 9, 2024

Copy link
Copy Markdown
Contributor

Implemented ContextProvider as required by Sdk.

Now it reads contracts from local db instead of querying Platform each time.

@QuantumExplorer QuantumExplorer left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

First PR!

@QuantumExplorer
QuantumExplorer merged commit 81aa4f3 into master Oct 9, 2024
@QuantumExplorer
QuantumExplorer deleted the fix/sdk-deadlock branch October 9, 2024 13:56
PastaPastaPasta added a commit that referenced this pull request Feb 18, 2026
Add Phase 5 (identity creation) and Phase 6 (DPNS name registration) to
the E2E test suite, covering the #1 critical coverage gap. Identity
creation uses wallet balance funding with retry logic (3 attempts, 300s
timeout). DPNS registration generates unique timestamp-based names to
avoid collisions and contested name fees.

Source changes: make select fields pub on AddNewIdentityScreen and
RegisterDpnsNameScreen to work around AccessKit ComboBox limitations,
and expose identities module and AppContext.db for test access.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PastaPastaPasta added a commit that referenced this pull request Feb 18, 2026
Add Phase 5 (identity creation) and Phase 6 (DPNS name registration) to
the E2E test suite, covering the #1 critical coverage gap. Identity
creation uses wallet balance funding with retry logic (3 attempts, 300s
timeout). DPNS registration generates unique timestamp-based names to
avoid collisions and contested name fees.

Source changes: make select fields pub on AddNewIdentityScreen and
RegisterDpnsNameScreen to work around AccessKit ComboBox limitations,
and expose identities module and AppContext.db for test access.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
shumkov added a commit that referenced this pull request Apr 11, 2026
Phase 9b-4b shipped a deterministic deadlock that would trigger on
the first received DashPay payment for any wallet in the map.

**Root cause.** `context::transaction_processing::received_transaction_finality`
holds a `wallet_arc.write()` guard on a specific wallet while
scanning its outputs. Inside the loop it called
`match_transaction_to_contact(self, &address)`, which iterated
every wallet in `app_context.wallets` and did `wallet_arc.read()`
on each one — including the wallet whose write guard is currently
held on the same thread. `std::sync::RwLock` does not support
read-while-write on the same thread; this is a deterministic
deadlock, not a race. The cache helpers
`cache_contact_highest_receive_index_blocking` and
`cache_payment_via_platform_wallet_blocking` went through
`resolve_platform_wallet_by_owner` → `platform_wallet_for_identity` →
`require_platform_wallet` → `get_platform_wallet`, which also
calls `w.read()` on the same wallet — a second deadlock.

**Fix.** The outer loop already holds the wallet guard and already
has `wallet.platform_wallet.as_ref()` in hand. We don't need to
re-iterate or re-lock — resolve the DashPay contact using the
platform wallet on the already-held guard, and pass that
`&PlatformWallet` directly to the persistence helpers.

- `transaction_processing.rs` — call
  `pw.dashpay().match_incoming_dashpay_address_blocking(&address)`
  directly on the already-resolved platform wallet. No calls into
  `app_context.wallets`, no re-locking.
- `platform_wallet_cache.rs` — replace
  `cache_contact_highest_receive_index_blocking` and
  `cache_payment_via_platform_wallet_blocking` with
  `cache_contact_highest_receive_index_with_pw_blocking` and
  `cache_payment_with_pw_blocking` that take `&PlatformWallet`
  directly. Document the deadlock rationale in the module header.
- `incoming_payments.rs` — delete the now-unused
  `match_transaction_to_contact` helper, the dead `async
  process_incoming_payment` (zero call sites), the
  `IncomingPaymentInfo` struct it returned, and the now-unused
  `cache_contact_highest_receive_index` async helper.

Net: fewer LOC, one mutation path, and no more deadlock.

Review finding: rust-quality-engineer MUST FIX #1.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
shumkov added a commit that referenced this pull request Apr 12, 2026
Addresses all critical and important findings from the M2 code review.

Critical #1: write_core SPV height
  Changed `WHERE seed_hash = ?2` to `WHERE wallet_id = ?2` in the
  wallet.last_terminal_block UPDATE. The persister passes wallet_id
  bytes; the old SQL matched against the seed_hash column which holds
  different bytes — 0 rows matched, sync height was silently lost on
  every restart.

Critical #2: handle_wallet_unlocked shielded init
  After register_with_platform_wallet_manager (which may re-key the
  map), use wallet_id from the Wallet struct for subsequent lookups
  (initialize_shielded_wallet, queue_shielded_sync) instead of the
  stale seed_hash variable.

Critical #3: WalletDerivationPath stores wrong key
  Changed qualified_identity_public_key.rs to populate
  wallet_seed_hash with wallet.wallet_id() instead of
  wallet.seed_hash(). Post-v40, determine_wallet_info() returns
  wallet_id bytes, matching the map key.

Important #4/#5: wallet selection + UI validation
  wallets_screen uses wallet_id for persist_selected_wallet_hash
  and the arc-matches validation check.

Finding #6: shielded_wallet_meta in v40 DELETE sweep
  Added to the cache nuke table list.

Wallet.wallet_id is now non-optional (WalletId, not Option<WalletId>).
The wallet migration screen (to be implemented) ensures every wallet
has wallet_id before the main UI loads. WalletArcRef.seed_hash
renamed to wallet_id. No more map_key() fallback — wallet_id is
always the canonical key.

get_wallets() uses [0u8; 32] as sentinel for NULL wallet_id rows
(password wallets pre-migration). The migration screen detects this
sentinel and prompts for unlock.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
shumkov added a commit that referenced this pull request Apr 12, 2026
… stale docs

Review finding #1 (Critical): Silent data loss on proof encoding
  write_asset_locks used unwrap_or_default() on bincode encode failure,
  silently writing an empty blob. Changed to propagate the error via
  SqlitePersistError::Encode so the flush fails visibly instead of
  losing the proof.

Review finding #4 (Important): Dead code cleanup
  Deleted store_asset_lock_transaction and
  update_asset_lock_chain_locked_height from Database — all callers
  were removed in Item 8.1d. Removed unused imports (Hash, serialize).

Review finding #5 (Important): Stale doc comment
  Updated platform_wallet_bridge.rs module docs to reflect the
  current state: WalletId = SHA256(root_pub_key || chain_code),
  both AppContext and PlatformWalletManager keyed consistently.

Review finding #2 (FK mismatch) acknowledged as pre-existing:
  asset_lock_transaction.wallet FK references wallet(seed_hash) but
  stores wallet_id bytes. FKs are off at runtime. Proper fix deferred
  to the wallet table PK migration.

Review finding #3 (no round-trip test) acknowledged: adding a test
  for write_asset_locks + load_asset_locks is a follow-up.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
shumkov added a commit that referenced this pull request Apr 12, 2026
Update evo-tool's persister and backend tasks for the new
SentContactRequestKey / ReceivedContactRequestKey struct types.

Fixes the Critical #1 bug from the 7-agent review: the established
contact reconstruction in load() used reversed incoming_requests
key `(contact_id, owner_id)` instead of `(owner_id, contact_id)`.
With the new struct types, this reversal is impossible — the
compiler enforces correct field assignment:

  ReceivedContactRequestKey { owner_id, sender_id: contact_id }

Updated: sqlite.rs (write + load + round-trip test),
contact_requests.rs (send_contact_request changeset construction).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
lklimek added a commit that referenced this pull request Jul 10, 2026
…et rewrite (#876)

* fix(withdraw): pre-select only a locally-signable withdrawal key

The Withdraw screen constructor pre-selected a key via the on-chain
lookup `identity.get_first_public_key_matching(TRANSFER, ...)`, which is
unfiltered by local private-key presence. On loaded masternode/evonode
identities where only the Owner key was supplied, this picked a
"ghost" Transfer key with no local private material, so the withdrawal
failed at signing with a raw, unhelpful protocol error.

- model: add `QualifiedIdentity::default_withdrawal_key()` — sourced from
  `available_withdrawal_keys()` (private-key-backed only), Transfer
  preferred with Owner fallback, `None` when nothing is signable.
- ui: constructor now pre-selects via `default_withdrawal_key()`; the
  developer-mode on-chain escape hatch is preserved. When no usable key
  exists the existing empty-state guides the user to add one.
- error: add `TaskError::NoWithdrawalSigningKey` (typed `#[source]`,
  plain-language actionable Display) mapping the SDK
  `DesiredKeyWithTypePurposeSecurityLevelMissing` protocol error as a
  defense-in-depth backstop instead of leaking a raw string.
- tests: 4 model cases (ghost key rejected, private-backed selected,
  owner fallback, transfer preferred) + 2 error-mapping/Display cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(claude): correct secret-storage note on identity-key encryption

Identity keys (imported/loaded, including masternode voting/owner/payout)
are no longer categorically in the deferred keyless tier: they enter
unprotected at load time but can be sealed to Tier-2 per-identity via
IdentityTask::ProtectIdentityKeys (Key Info screen "Add password
protection"). Clarify that the keyless residual is only no-password
secrets and keys the user has not opted to protect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(masternodes): add page-nav model with two-scope selection (A1)

Introduce ui/state/global_nav.rs: PageNavSpec (page-aware segment-1 +
per-page pill composition) and IdentityPillScope (AppGlobalUser vs
PageScopedObject). The PageScopedObject variant carries its own selection
and never writes AppContext::selected_identity_id — the structural FR-6
boundary the global switcher (A2) and the Masternodes page (B7) build on.

Pure state, renders nothing (module-placement discriminator -> ui/state).

Satisfies TC-NAV-13, TC-NAV-14, TC-NAV-15; foundation for TC-NAV-12,
TC-FR6-07.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(masternodes): generalize breadcrumb into page-aware global switcher (A2)

Add ui/components/global_nav_switcher.rs: a page-aware switcher driven by
PageNavSpec, rendering segment-1 (page label + link) plus composable
wallet / identity-or-object pills. GlobalNavEffect generalizes the hub's
BreadcrumbEffect and adds SelectPageObject for the page-scoped pill — kept
distinct from SelectIdentity so a page-scoped selection never writes the
app-global identity (FR-6 boundary at the effect level).

Reuses BreadcrumbPill / IdentityPill / BreadcrumbPillMode verbatim (no new
pill widget). breadcrumb_switcher.rs becomes a thin hub-facing shim that
builds the hub spec, delegates to the generalized render, and maps the
effect back (self-nav to the hub root -> OpenPicker), keeping hub behavior
unchanged — verified by the existing identity_hub_switcher kittests.

Satisfies TC-NAV-01, TC-NAV-02, TC-NAV-03, TC-NAV-13, TC-NAV-16.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(masternodes): render global switcher on root screens + shared applier (A3)

Add the shell glue in top_panel.rs: apply_global_nav_effect (the shared
successor to the hub's apply_breadcrumb_effect — silent app-scoped
wallet/identity writes, no forced navigation) and add_top_panel_with_global_nav
(one-call render-plus-apply), with subdued_everyday_spec / subdued_wallet_only_spec
Phase-A rollout helpers.

Wire the switcher onto four non-Hub root screens with Subdued (unwired)
specs + TODO markers: Identities, DPNS, DashPay (everyday: wallet + identity
pills) and Wallets (wallet-only composition, TC-NAV-15). The Hub keeps its
existing interactive pills via the breadcrumb shim (regression — full
kittest suite green).

Document (comment + test, PROJ-010) that set_selected_hd_wallet reconciles
the app-global identity as a side effect on non-Hub pages, and that combined
with B1's resolution-layer filter it must never reconcile onto an MN/Evonode.

Deferred (documented): tokens/tools screens carry in-header sub-navigation
that needs the FR-GLOBAL-NAV-5 content-panel back-row migration before their
plain breadcrumb can be swapped for the global switcher — a follow-up, not a
mechanical swap.

Satisfies TC-NAV-06, TC-NAV-15, TC-NAV-16; enables TC-NAV-12/17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(masternodes): load-time key encryption plumbing (B0)

FR-8: add IdentityInputToLoad.encryption_password: Option<Secret>. When
Some, load_identity validates the password up front (fast fail) then, after
insert migrates the keyless keys into the vault, seals them Tier-2 through
the existing per-identity protect envelope (protect_identity_keys →
put_secret_protected via the secret_seam chokepoint) — no new crypto, no
second persistence path. When None, the keyless Tier-1 path is unchanged.

Relocate validate_protection_password from protect_identity_keys.rs into
model/identity_key_protection.rs (PROJ-006, DET validation-placement rule);
the seal path and load path both call the model validator.

MCP masternode_identity_load passes encryption_password: None (PROJ-007 —
GUI-only this iteration, requirements §2.3) with a TODO for headless
password parity.

Add typed TaskError variants DuplicateProTxHash { identity_id } and
MalformedProTxHash { input } for later duplicate/malformed rejection (B1/B4),
avoiding string parsing.

Tests: model validator (relocated); an offline-wired-AppContext test proving
a load-time password seals a masternode's voting (V-target), owner and
identity (M-target) keys Tier-2 and round-trips under the password — the
exact call load_identity makes (TC-FR8-01/02/10). Load-form UI is B4;
end-to-end load routing is covered by the network backend-e2e suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(masternodes): drop ephemeral review ID from A3 reconciliation comment

Self-review: replace a transient review-finding ID in the apply_global_nav_effect
reconciliation note with the durable FR reference. Comment-only; no behavior
change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(masternodes): FR-6 resolution-layer boundary + masternode accessor (B1)

FR-6 (R1, release-blocking): keep masternode/evonode identities out of every
everyday-user surface by filtering at the resolution layer, not the display
call sites.

- resolve_selected_identity(): candidate set filtered to IdentityType::User
  before resolving, so neither keep-if-loaded nor the first-loaded fallback
  can ever resolve a masternode — even when a masternode is the only/first
  loaded identity (TC-NAV-12b).
- set_selected_hd_wallet(): the wallet-switch identity reconciliation resolves
  only over the wallet's User identities.
- restore_selected_identity_from_kv(): one-time sanitization — a masternode
  persisted as selected_identity_id in a prior session is cleared on load;
  a User selection is kept (TC-NAV-12c). In-memory only (non-destructive).
- Display sources switched to the established User-only accessor
  load_local_user_identities(): the global switcher's identity pill + dropdown
  and the Identity Hub landing/picker now list User identities only, so the
  wallet-less "no wallet on this device" group can no longer surface an
  MN/Evonode (TC-NAV-17). Masternodes stay in the legacy Identities table
  (unfiltered accessor untouched — locked decision #2).

New context accessor load_local_masternode_identities() (hydrated MN/Evonode)
— the Masternodes-page card list + page-scoped pill source (B3/B7).

Tests: offline-wired-AppContext unit tests (accessors, resolution filter incl.
lone-masternode fallback, stale-MN sanitization) + a Hub kittest asserting a
seeded Masternode+Evonode never appear on the hub while remaining in the
masternode accessor. TC-FR6-01…06, TC-NAV-12b/12c, TC-NAV-17.

Deferred to consuming tasks (documented): FR-7 refresh (TC-FR7-02/03) composes
existing RefreshIdentity + contested-names refresh at the card Refresh button
(B3); the per-node open-contest card read accessor lands in B3 where the card
consumes it and it is testable against the rendered status line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(masternodes): register Expert-gated Masternodes root tab (B2)

Add RootScreenType::RootScreenMasternodes (stable int 28, round-trip test),
ScreenType::Masternodes, Screen::MasternodesScreen, create_screen and all
ScreenLike dispatch arms; register the always-present root screen in app.rs
(gated at runtime by Expert Mode, not a Cargo feature, so the screen exists
to switch into when the gate is on).

Nav: a "Masternodes" left-panel entry gated FeatureGate::DeveloperMode,
positioned directly below the identity cluster (locked decision #3),
independent of the identity-hub feature. Distinct glyph voting.png (TODO:
dedicated node/server icon). The existing per-entry gate skip hides the nav
item and route when Expert Mode is off.

Live de-gating (§10.11): active_root_screen_mut falls the active tab back to
Identities (always registered) if Expert Mode flips off while Masternodes is
selected, so the gated screen is never shown without its gate.

MasternodesScreen is a scaffold (global-nav header + left rail + island
placeholder); the empty state + card grid land in B3, the page-scoped
masternode pill in B7. Network-switch already calls change_context on
main_screens; the sub-screen reset (§10.10) applies once B4/B5 push
sub-screens (noted for B8).

Tests: RootScreenType round-trip (int 28 stable); kittest — nav absent
Expert-off / present Expert-on, and de-gating falls back to Identities.
TC-FR1-01…07, TC-EDGE-05/06.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(masternodes): empty state + card grid + card body (B3)

Render the Masternodes root screen content on top of the B2 scaffold:

- Empty state (FR-2): canonical §7 copy — heading, body, "Load a
  masternode" primary CTA, and the ProTxHash reassurance line.
- Card grid (FR-3): responsive minmax(260,1fr) grid reusing the identity
  picker's visual language via a new `MasternodeCard` (monogram +
  `draw_type_badge`, both now `pub(crate)`). Body adds masternode rows the
  picker lacks: voter readiness, compact `V O P` key status (glyph, not
  colour-only — NFR-6), DPNS status line, and the IdentityStatus dot+label.
- DPNS status precedence (§10.1): open-contest count first, then a pending
  scheduled vote, then "No open contests", via a display-layer
  `AppContext::masternode_contest_summary` read (no new backend concept).
- Key presence: `QualifiedIdentity::masternode_key_presence` maps
  Voting/Owner/Payout to voter-identity / OWNER / TRANSFER keys.
- Top-right Refresh toolbar button (FR-7) reloads the cached node list.
- Whole card is a single accessible click target (`WidgetInfo::labeled`,
  NFR-6); selection/load intents are captured for B4/B5a/B7 wiring.

Traceability: TC-FR2-01…07, TC-FR3-01…15, TC-FR7-01, TC-NFR6-01/03.
Unit tests cover heading/sub-line, DPNS precedence, key tokens, badge, and
the 8 V/O/P combinations; kittest covers empty-state copy and the grid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(masternodes): dedicated load form + ProTxHash validator (B4)

Add the MN/Evonode-only load flow (FR-4), carved out of the generic
add-existing-identity path:

- `ui/masternodes/load_form.rs`: ProTxHash (required), Masternode/Evonode
  segmented toggle (default Masternode, no User option), optional alias,
  V/O/P key inputs (reused hold-to-reveal `PasswordInput`), optional at-load
  encryption password (drives B0's seal), always-visible Warning-tone
  key-storage note, and a Load button gated on a non-empty ProTxHash with the
  §7 disabled tooltip. Switching node type clears all fields (§10.6). No
  auto-derive affordance — masternode keys are never wallet-derived
  (US-6 retired, §Locked-#4).
- `model/masternode_input.rs`: `is_valid_pro_tx_hash` shape validator (hex or
  Base58) for inline on-blur validation; the backend load task remains the
  authoritative existence/duplicate check.
- Masternodes screen gains a List/Load view enum; the empty-state CTA and a
  `+ Load` toolbar button open the form; submit dispatches
  `IdentityTask::LoadIdentity`; cancel/submit return to the list with a fresh
  form on reopen.
- `add_existing_identity_screen`: remove Masternode/Evonode from the
  Advanced-Options Identity-Type dropdown (User-only remains) — no competing
  entry point (§10.2 / TC-FR4-22, FR-6).

Traceability: TC-FR4-01…22 (render/logic; live-network accept/duplicate/
error-banner paths land in B8), TC-EDGE-01/02.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(masternodes): detail view — header, actions, keys, remove (B5a)

Add the node detail view (FR-5), reusing existing screens rather than
reimplementing:

- Fixed section order Header → Actions → Keys → DPNS → Remove (TC-FR5-01,
  the human-requested Actions-above-Keys correction), pinned by a unit test.
- Header: conditional alias, shortened ProTxHash + copy-full-value, type
  badge (shared `draw_type_badge`), IdentityStatus dot + label.
- Actions row (FR-9): Withdraw / Top up / Transfer push the existing
  WithdrawalScreen / TopUpIdentity / TransferScreen scoped to the node's
  QualifiedIdentity (both node types). Evonode-only `Claim token rewards ›`
  cross-link (FR-11), absent for a plain masternode.
- Keys section (FR-10): V/O/P presence, copyable voter-identity id, at-rest
  protection tier (vault-scheme probe), Add-protection offered only Tier-1,
  `Manage keys ›` into the existing key screen.
- DPNS section: collapsible, open-contest count in the header (voting table
  lands in B5b).
- Remove: danger ConfirmationDialog; deletes the node and its voter identity.
- `‹ All masternodes` back row + detail Refresh (FR-7/TC-FR7-04); card click
  opens the detail via a List/Load/Detail view enum.

Deviations (documented): the Evonode claim cross-link routes to the Tokens
area — precise ClaimTokensScreen token-scoping is deferred to B8 where the
evonode reward-token context is resolvable. Add-protection routes into the
reused key screen (which hosts the password-entry seal flow) rather than
duplicating the form.

Traceability: TC-FR5-01…05/07, TC-FR7-04, TC-FR9-01/02, TC-FR11-01/02.
Live-network credit/claim routing and TC-FR8-07 land in B8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(masternodes): Testnet Fill-Random on the load form (B6)

Add the FR-12 dev convenience to the masternode load form:

- New `testnet_fixture` module owns the `.testnet_nodes.yml` structs + loader.
  The loader returns None for BOTH a missing and a malformed file — a malformed
  file is logged at debug and treated as absent (TC-FR12-04, a deliberate
  divergence from the legacy screen which banners the parse error).
- Fill-Random button + hint render only when Expert Mode is on, the network is
  Testnet, and the fixture is present — never shown-but-disabled (TC-FR12-01…06).
  The `dev_mode` gate is a defense-in-depth re-check at the call site
  (TC-FR12-09 decision: added on future-proofing grounds — a plaintext-key dev
  tool stays inside the Expert-Mode envelope).
- Button label follows the node-type toggle (TC-FR12-01/02).
- Autofill pulls from the correct list per type (TC-FR12-07/08): Masternode →
  `masternodes` (Voting + Owner only; the fixture has no payout key, PROJ-003),
  Evonode → `hp_masternodes` (all three keys). The node-type toggle still clears
  autofilled fields (§10.6).
- The fixture loads once when the form opens (Testnet only), not per frame.

Traceability: TC-FR12-01…09.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(masternodes): inline DPNS voting + missing-voter prompt (B5b)

Populate the detail view's collapsible DPNS section (FR-5):

- Collapsed by default; header shows the open-contest count
  (`DPNS name contests to vote on (N)`, TC-DPNS-01/02).
- Voter present + open contests: per-contest Abstain / Lock / Vote-for-candidate
  choices with the candidate list scoped to that contest's contestants; a
  `Cast votes` button dispatches the existing
  `ContestedResourceTask::VoteOnDPNSNames` backend inline (locked decision #1 —
  not a deep-link). TC-DPNS-03/04/05.
- Voter present, zero open contests: exact §7 empty copy (TC-DPNS-08).
- Missing voter identity: the actionable §7 message (never the raw
  NoVotingIdentity error) plus an `Add voting key` action that opens a scoped,
  in-place voter-key prompt with the node context pre-bound — distinct from
  FR-4's load form, no ProTxHash re-entry (TC-DPNS-09/10/11, §10.8). Save
  re-loads this node with just the voting key to update its voter identity.
- Detail Refresh now re-reads both the contest summary and the open-contest
  list.

Active/open contests only — scheduled/past history stays on the DPNS Scheduled
Votes screen (§10.7).

Traceability: TC-DPNS-01…11.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(masternodes): page-scoped nav pill — FR-6 boundary in code (B7)

Wire the Masternodes page into the global-nav switcher with a page-scoped
masternode pill whose selection lives on the page and is NEVER written to
`AppContext::selected_identity_id` — the structural FR-6 boundary in code,
complementing B1's resolution-layer filter.

- New `ui/state/masternodes_view.rs` builds the page's `PageNavSpec`: page-aware
  `Masternodes` segment-1, an interactive wallet pill (funds Top up — FR-9), and
  a page-scoped identity pill via `IdentityPillScope::PageScopedObject`. Empty →
  subdued `(no masternode yet)` placeholder; ≥1 node → interactive dropdown with
  `Choose a masternode` placeholder; the pill reflects the node in detail and
  resets to the placeholder on `‹ All masternodes` (§10.4).
- New `top_panel::add_top_panel_with_global_nav_capturing` surfaces the
  `SelectPageObject` pick to the caller (applying all other effects as usual)
  without ever routing it into the app-global identity selection.
- The Masternodes screen builds the spec each frame from its node list + current
  view and opens the picked node's detail — two-way with the card grid.

TC-NAV-12 / TC-FR6-07 (release-blocking): a masternode selected on the page
never becomes, or resolves as, the app-global identity — verified across
Identities and the Identity Hub with no User identity loaded.

Traceability: TC-NAV-01/03/04/05/07, TC-NAV-12, TC-FR5-06, TC-FR6-07.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(masternodes): cross-cutting integration coverage (B8)

Add the Remove-flow integration kittest (TC-US4-01/02/06/07): the detail danger
button opens a confirmation carrying the `Remove masternode` verb, and
confirming deletes only the target node — its card disappears while other nodes
survive (isolation). Also sets the confirmation's confirm verb to
`Remove masternode` (§7 / TC-US4-02), the one small production touch the test
surfaced.

Deferred to the network/backend-e2e pass (out of kittest reach without live
DAPI or heavy vault fixtures, and flagged as such in the plan): TC-FR8-07
(detail reflecting a load-time Tier-2-sealed node — needs the real
password-at-load seal path), TC-US4-05 voter-row deletion assertion (needs a
seeded voter-identity row), and the live-network credit/vote/claim dispatch
paths behind FR-9/FR-11/DPNS Cast-votes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(user-stories): catalog the Masternodes tab, retire the legacy load story

Adds MN-001..MN-009 (load, card grid, vote, remove, Hub filter, at-load
encryption, credit actions, key management, evonode token-reward cross-link)
and UX-003 (global wallet/identity switcher) per the completed Masternodes
feature. Flips IDN-003 to superseded — its generic-screen masternode load
path was removed when the dedicated tab shipped.

* docs(masternodes): commit final design docs (DOC-002)

Lands the human-accepted requirements, UX spec, test-case spec, and dev
plan under docs/ai-design/, following the existing 2026-04-23-identity-hub-impl
numbered-file convention. Resolves the ~100+ FR-/TC-/US-/§-ID references
already scattered through the feature's code comments and tests, which
pointed at an uncommitted /data/artifacts scratch copy. Internal
cross-file references (requirements.md, ux-spec.md, etc.) are updated to
the new numbered filenames.

* docs(masternodes): trim oversized module docs, catalog global-nav switcher

Shortens the four ui/masternodes/*.rs module doc comments to the
internal-tier length cap (DOC-003) — they weren't published API, so the
5-10 line public-rustdoc relaxation didn't apply. Adds GlobalNavSwitcher
and its top_panel entry point to ui/components/README.md's catalog
(DOC-004), so the next screen needing a page-aware switcher finds it
instead of reimplementing one.

* fix(masternodes): guard identity load against silent overwrite (QA-005/006)

Root-cause storage fix. insert_local_qualified_identity is INSERT OR
REPLACE, so a load with no guard silently clobbers an already-stored
identity and its keys. Thread an IdentityLoadMode through
IdentityInputToLoad so each entry point declares intent:

- RejectIfExists: the masternode load form rejects a duplicate ProTxHash
  with TaskError::DuplicateProTxHash before any network fetch (QA-006).
- MergeIntoExisting: the scoped Add-voting-key prompt merges the new key
  into the stored identity, preserving Owner/Payout it did not resupply
  (QA-005), via merge_existing_keys_into.
- Overwrite: legacy User re-load and headless flows unchanged (default).

Adds get_local_qualified_identity accessor backing the existence check
and merge read. Failing-first TDD: a unit test proving Owner/Payout keys
survive a voting-key-only merge, and an offline test proving a duplicate
ProTxHash is rejected and the first node is left untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(masternodes): key routing, network-switch reset, live refresh

QA-007: the detail Keys section pushed the static read-only KeysScreen.
Render a per-key 'Manage keys' list and route the Add-protection CTA to
KeyInfoScreen (interactive view/sign/seal per key), mirroring
identities_screen.

QA-001: MasternodesScreen had no change_context override, so a network
switch left an open load form or cross-network detail view actionable.
Add an explicit change_context arm that resets to the List view and
reloads from the now-active network.

QA-003: both Refresh buttons only re-read the local cache. Wire them to
dispatch IdentityTask::RefreshIdentity (per loaded node on the list, the
open node on detail) plus a QueryDPNSContests re-query, alongside the
optimistic local re-read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(masternodes): SHOULD-FIX pass + QA-token cleanup + offline tests

- QA-002: remove the legacy Fill-Random HPMN/Masternode bypass from the
  now User-only add-existing-identity screen (it set identity_type to
  Evonode/Masternode directly, defeating the User-only restriction).
- QA-004: the evonode 'Claim token rewards' CTA pushes a real scoped
  ClaimTokensScreen when the node holds exactly one token, falling back to
  My Tokens when the target is ambiguous — no more bare SetMainScreen.
- QA-008: refresh the open detail view after its own backend task, not
  just the card list.
- Diziet-F3: render the missing-voter 'Add voting key' CTA above, outside
  the collapsed DPNS section, so it is visible without expanding.
- QA-009: surface a MessageBanner when node removal fails instead of a
  silent tracing::warn.
- SEC-001: log the testnet-fixture parse error by position only, never
  its Display text (which echoes a private key).
- SEC-002: parse fixture key fields as Secret (redacted/zeroized).
- Strip stale PROJ-003/PROJ-004 markers and all ephemeral QA-xxx review
  IDs from source comments (kept only in commit messages).
- TODOs for the deferred mixed-protection-tier CTA and the
  is_valid_pro_tx_hash/decode_identity_id duplication.
- Offline tests: TC-FR8-07 (protection_tier reflects a Tier-2 seal) and
  TC-US4-05 (Remove deletes the associated voter identity).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(dashpay): close FR-6 boundary bypass via DashPay identity selectors (SEC-005)

DashPay screens (send_payment, contacts_list, profile_screen,
contact_requests, qr_scanner, qr_code_generator, add_contact_screen,
profile_search) built their IdentitySelector and constructor seed from
the unfiltered load_local_qualified_identities() chained with
.syncing_global(...). IdentitySelector::sync_to_global() writes the
picked id straight to AppContext::selected_identity_id — a separate path
from B1's resolve_selected_identity()/restore filters — so a user could
select a masternode/evonode as the app-global operate-as identity from
inside DashPay, bypassing the FR-6/R1 boundary B1 established.

DashPay operates on User identities only, so every identity list in these
screens is sourced from load_local_user_identities() (the same swap B1
made for the global-nav switcher and Identity Hub). This filters the
masternode out of both the selector write-path and the constructor seed.

Adds a kittest (masternode_never_selectable_in_dashpay_screens) exercising
the FR-6 boundary through five DashPay screens — the existing FR-6 kittest
only covered Identities/Identity Hub, which is how this slipped through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(masternodes): Marvin punch-list — in-flight guard + execution tests

- QA-012: gate re-submission while a node-load is in flight. Add a
  load_in_flight flag on MasternodesScreen, set on Submit dispatch and
  cleared on the task result or a new display_task_error override; the
  '+ Load' toolbar button and empty-state CTA show a spinner + disabled
  'Loading…' while set, so a rapid double-submit of a brand-new ProTxHash
  cannot race two loads past the pre-fetch existence check.
- Extend masternode_never_selectable_in_dashpay_screens to QRScanner,
  QRCodeGenerator (both seed selected_identity in new()) and assert
  ProfileSearchScreen's User-filtered data source excludes the masternode
  — FR-6 coverage now spans all 8 DashPay screens.
- Add manage_keys_button_opens_key_info_screen: clicks a per-key
  'Voting key ›' button and asserts a KeyInfoScreen is pushed with its
  'Key Information' heading (execution-level proof of the QA-007 fix).
- Add refresh_from_network unit test: one RefreshIdentity per loaded node
  plus a trailing QueryDPNSContests, None when empty (QA-003).
- Fix two doc-comment lines mangled by the earlier review-ID strip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(changelog): add Masternodes tab and global nav switcher (DOC-005)

Covers the user-facing outcomes of the completed Masternodes feature:
the new Expert-Mode-gated tab (card list, detail view, load-time key
encryption, inline DPNS voting, credit actions, Evonode token-reward
claiming) replacing the old generic load path for masternode/evonode
identities, the resulting Identity Hub / Identities picker filter, and
the wallet/identity switcher now present on every root screen instead
of just the Identity Hub.

* fix(masternodes): default GUI build broken — masternode_input feature-gated

The whole model::masternode_input module was gated behind
load form (ui/masternodes/load_form.rs) imports is_valid_pro_tx_hash from
it unconditionally. So a plain 'cargo build --bin dash-evo-tool' (default
features only — no mcp/cli, the documented quick-start build) failed with
E0432 unresolved import. Every gate this feature ran used --all-features,
which always pulls mcp+cli and masked it.

The module can't be blanket-ungated: its parse/decode helpers return
McpToolError (from the feature-gated mcp module). Fix ungates the module
and the pure is_valid_pro_tx_hash validator (the only thing the GUI needs),
and gates precisely the McpToolError-coupled items — KeyMode, parse_node_type,
parse_key_mode, require_at_least_one_signing_key, decode_identity_id, their
imports, and their tests — behind mcp/cli. The pure validator's tests move
to an always-compiled module so they run in the default build too.

Verified: 'cargo build --bin dash-evo-tool' (no flags) compiles; default-
feature clippy clean; both default and --all-features test paths pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(masternodes): correct global-nav coverage claim (F-003)

CHANGELOG and the components README claimed the global wallet/identity
switcher was on "every screen". It ships Phase-A: rendered on Identities,
DashPay, DPNS, Wallets, Identity Hub, and Masternodes, interactive only
on the Hub and Masternodes (the other four render subdued, read-only
pills), and absent from every other root screen (Contracts, Tokens,
Tools, Network Chooser, Withdraws, ...). Names the actual screens and
notes the rest as a tracked follow-up instead of implying full rollout.

* fix(masternodes): Fable final round — Tier-2 merge seal, load gate, ProTxHash error

F-001 (MUST-FIX): "Add voting key" on a password-protected (Tier-2) node no
longer trips the insert's fail-closed guard. load_identity now verifies the
node's object password UP FRONT (before the network fetch, mirroring
add_key_to_identity's verify-before-broadcast order) and seals the merged
plaintext keys Tier-2 via seal_merged_plaintext_keys just before the at-rest
insert. Two regression tests: a scripted-prompt success path proving the new
key flips InVault and reads back Protected, and a headless NullSecretPrompt
path proving the merge fails closed with SecretPromptUnavailable before fetch.

F-002: the list screen's load_in_flight gate is cleared only on the load's own
LoadedIdentity result variant (not any routed result), with a refresh_on_arrival
backstop so a tab switch mid-load can never strand "+ Load" at "Loading…".

F-005: a malformed identity-id input now surfaces MalformedProTxHash for
masternode/evonode loads (where the field IS a ProTxHash) and keeps
IdentifierParsingError for User loads. Regression test added.

F-006: masternodes/evonodes legitimately have no HD wallet, so the
"saving identity without wallet" warning is gated to User identities; nodes log
at debug instead.

F-004: correct the MCP masternode_identity_load comment — Overwrite is a
destructive full-replace of stored keys, not a merge/refresh; TODO for a future
load-mode param.

F-007: strip ephemeral review-ID prefixes (SEC-*, Diziet-*, Smythe) from
masternode-scope source comments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(masternodes): add load-form back link + remove object pill from breadcrumb

Live-walkthrough fixes on real testnet data.

Fix 1 — load form back link: the load form now renders the same
`‹ All masternodes` back link as the detail view (wireframe C shows it on
both), at the top of the form, returning to the card list. New kittest
`load_form_back_link_returns_to_list` covers it; the existing
`load_form_opens_from_cta_and_cancels` gets a taller headless window so the
bottom Cancel button stays reachable now that the back row is present.

Fix 2 — remove the masternode object/identity pill from the Masternodes
breadcrumb. Masternode/evonode identities are never wallet-linked (wallet_info
is always None — locked decision #4), so pairing a wallet pill with an object
pill implied a wallet↔masternode relationship that does not exist. The
breadcrumb now carries only segment-1 + the interactive wallet pill; node
selection is driven entirely by card-click → detail and the back link. The
Masternodes page switches to add_top_panel_with_global_nav (non-capturing),
matching every other non-object page. The masternodes_page_nav_spec builder
drops its items/selected params.

This does NOT touch the FR-6 boundary, which is enforced structurally at the
resolution layer (B1) independent of any pill. The release-blocking FR-6
boundary tests (TC-NAV-12 / TC-FR6-07) remain unchanged and green. The
PageScopedObject / SelectPageObject / add_top_panel_with_global_nav_capturing
machinery is retained as the documented, tested boundary pattern for future
page-scoped-object features (the global_nav_switcher tests still exercise it);
only the Masternodes page's use of it is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(masternodes): reject load when selected node type mismatches on-chain

Loading a masternode/evonode by ProTxHash trusted the load-form Masternode/
Evonode toggle as ground truth with no cross-check. A regular masternode loaded
with "Evonode" selected was silently accepted, mis-badged "Evonode", and shown
the Evonode-only "Claim token rewards" action.

The load task (authoritative layer) now cross-checks the selected type against
the node's actual on-chain registration. A masternode's Platform identity id is
its ProTxHash, so the node is looked up via Core RPC `protx info`; the `type`
field ("Regular"/"Evo") is classified and compared. On a confirmed mismatch the
load is rejected with a typed, actionable `TaskError::NodeTypeMismatch` naming
both the selected and actual types. When the on-chain type cannot be determined
(Core RPC unreachable — e.g. an SPV-only setup with no Core node) the load
proceeds unverified, so this adds no regression for those users.

Layering per CLAUDE.md: the pure classification (`classify_protx_node_type`)
and rejection decision (`node_type_conflict`) live in `model/masternode_input`
and are exhaustively unit-tested (the reported Evonode-on-regular case
included); the backend task owns the network lookup and enforcement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(masternodes): surface a visible warning when node type is unverified

Follow-up to the node-type cross-check: when Core RPC is unreachable (the
common case for SPV-only users) the node type cannot be verified, and silently
proceeding with an unverified badge reproduced the original UX bug downgraded
from "wrong" to "unverified". The load task now distinguishes the two success
outcomes via a new `BackendTaskSuccessResult::LoadedIdentityTypeUnverified`
variant, and the Masternodes screen surfaces a visible warning banner (not just
a log line) telling the user the badge reflects their selection and to reload
later to confirm. The MCP masternode-load tool reports the same distinction via
a new `node_type_verified` output field.

Regression tests: the pure reject decision (`node_type_conflict`) and the
`NodeTypeMismatch` user message cover the hard-reject path; a kittest drives the
unverified-load result into the live screen and asserts the warning banner is
surfaced to the UI, not merely logged.

Confirmed with team-lead: hard-reject on a confirmed mismatch; a visible (not
log-only) warning on the unverified path. The upstream platform-wallet SPV
masternode-list passthrough (for verifying node type without Core RPC) is
tracked as a separate follow-up against the platform repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* revert(masternodes): drop Fix #3 node-type validation entirely

Reverts c5167787 and 755eee87. Product decision: trust the user's
Masternode/Evonode toggle as-is, with no on-chain node-type verification.

Rationale: the only non-type-gated actions (Withdraw/Top up/Transfer) are
unaffected by a mis-toggle; the sole type-gated action ("Claim token rewards")
degrades to a clean no-op/failed Platform state transition, not a fund-safety
issue — so the toggle working as the user set it is correct behavior, not a
defect. Dropping verification also removes the dependency on Core RPC (being
deleted in the platform-wallet migration) and on fetching the operator identity
(extra scope), leaving the load path simpler and migration-proof.

Removes: TaskError::NodeTypeMismatch, the onchain_node_type Core RPC cross-check,
the classify_protx_node_type/node_type_conflict model helpers, the
LoadedIdentityTypeUnverified result variant + UI warning banner, the MCP
node_type_verified output field, and all associated tests. Fixes #1 (load-form
back link) and #2 (breadcrumb pill removal) in 5db23f72 are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(masternodes): adapt retargeted tab to platform-wallet rewrite APIs

Rebasing the Masternodes tab onto the platform-wallet backend rewrite
(PR #860) surfaced three call sites where the rewrite reshaped an API the
masternode-tab code depended on:

- `AppContext::identity_kv()` was renamed to `det_kv()`; the load-path
  existence check (`get_local_qualified_identity`) now calls the new name.
- `ContestState::state_is_votable()` was dead-code-removed by the rewrite,
  but `ContestedName::is_open_for_voter` (Masternodes card DPNS status)
  relies on it — restored as a live, un-gated method.
- The rewrite dropped the `identity-hub` Cargo feature and renders the
  Identity Hub nav entry unconditionally; the left-panel builder no longer
  gates that entry behind the removed `#[cfg(feature = "identity-hub")]`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(withdraw): screen-level kittest coverage for default_withdrawal_key fix

WithdrawalScreen::new() pre-selecting via default_withdrawal_key() was only
unit-tested at the QualifiedIdentity model layer. Add tests/kittest/withdraw_screen.rs
to verify the fix at the actual screen layer: ghost-key identities (on-chain-only
TRANSFER key) render the no-keys empty state instead of a form, private-key-backed
TRANSFER/OWNER keys are pre-selected AND rendered correctly in the key-selection
ComboBox (via accesskit value, not label). Also locks in a genuine regression the
fix newly exposes: WithdrawalScreen::new() leaks a raw "No key provided when
getting selected wallet" error banner for any identity with no locally-signable
withdrawal key.

* fix(withdraw): skip wallet resolution when no signable key exists

WithdrawalScreen::new() called get_selected_wallet with selected_key=None
after default_withdrawal_key() correctly began returning None for identities
with no locally-signable withdrawal key. With app_context=None that hits the
"No key provided" String Err path, which .or_show_error() posted verbatim
into a user-facing MessageBanner — violating the plain-language error policy.

Guard the call on selected_key being Some, so the raw-string Err branch is
structurally unreachable here instead of avoided by luck.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(masternodes): add user story for network-switch reset behavior

Live QA of the Masternodes tab confirmed MasternodesScreen::reset_for_network_change()
cleanly resets the List view and clears stale banners/form data on a network switch,
with no existing story documenting this PR's fix. Add MN-010.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(withdraw): flip banner-leak test to a regression lock (2edbc18)

WithdrawalScreen::new() now guards get_selected_wallet on selected_key
being Some (2edbc18), so the raw "No key provided..." banner leak for
ghost-key-only identities is fixed. Rename
ghost_key_construction_leaks_raw_error_banner ->
ghost_key_construction_does_not_leak_raw_error_banner and invert the
assertion; also verify the no-keys empty state still renders correctly
for the same identity, so the fix didn't trade the leak for a broken
empty state.

* fix(mcp): stop det-cli double-prefixing the HTTP bearer token

rmcp 1.7's StreamableHttpClientTransportConfig::auth_header takes the
raw token and lets reqwest's bearer_auth prepend "Bearer " itself. The
det-cli client passed format!("Bearer {token}"), so the wire header
became "Authorization: Bearer Bearer <token>". The server middleware
strips one "Bearer " and compares the remaining "Bearer <token>" against
the configured key — never equal — so headless HTTP mode returned 401 on
every request. The auth path had no end-to-end coverage, which is why it
shipped broken.

Pass the raw token and add tests/mcp_http_auth.rs pinning the server's
wire contract: raw token accepted, a double-"Bearer" prefix rejected,
missing credentials rejected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(masternodes): share Expert Mode flag app-wide; node-specific load error

Two live-QA bugs on the Masternodes tab (PR #876).

Bug 1 — Expert Mode didn't reveal the Masternodes nav without a restart.
`developer_mode` was an independent per-network `AppContext` AtomicBool, kept
in sync only by a best-effort loop in the Settings checkbox handler over the
contexts that happened to exist at click time. AppState keeps one context per
network (only the active one at startup; others created lazily on switch), so
the left-nav `FeatureGate::DeveloperMode` gate could read a stale value on a
different context than the toggle mutated — the nav entry stayed hidden until a
restart re-read the persisted flag into the single fresh context. Promote the
flag to one shared `Arc<AtomicBool>`, created once in `AppState::new` and
injected into every `AppContext::new` (startup + `SwitchNetwork` via
`developer_mode_handle()`), so all per-network contexts observe one flag. Drop
the fragile sync loop; request a repaint on toggle since enabling Expert Mode
disables animations (which stops continuous repaints).

Bug 2 — loading a masternode by a valid-but-unregistered ProTxHash showed the
generic "Identity not found — check the ID or name" copy, wrong for a ProTxHash
load form. Add a dedicated `TaskError::MasternodeNotFound` variant and return it
from the masternode/evonode load path instead of `IdentityNotFound`.

Regression tests: `developer_mode_is_shared_across_network_contexts` (RED before
the shared-flag fix), `masternode_not_found_message_is_node_specific`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
lklimek added a commit that referenced this pull request Jul 13, 2026
…lassification, and role-picker UI (#879)

* fix(withdraw): pre-select only a locally-signable withdrawal key

The Withdraw screen constructor pre-selected a key via the on-chain
lookup `identity.get_first_public_key_matching(TRANSFER, ...)`, which is
unfiltered by local private-key presence. On loaded masternode/evonode
identities where only the Owner key was supplied, this picked a
"ghost" Transfer key with no local private material, so the withdrawal
failed at signing with a raw, unhelpful protocol error.

- model: add `QualifiedIdentity::default_withdrawal_key()` — sourced from
  `available_withdrawal_keys()` (private-key-backed only), Transfer
  preferred with Owner fallback, `None` when nothing is signable.
- ui: constructor now pre-selects via `default_withdrawal_key()`; the
  developer-mode on-chain escape hatch is preserved. When no usable key
  exists the existing empty-state guides the user to add one.
- error: add `TaskError::NoWithdrawalSigningKey` (typed `#[source]`,
  plain-language actionable Display) mapping the SDK
  `DesiredKeyWithTypePurposeSecurityLevelMissing` protocol error as a
  defense-in-depth backstop instead of leaking a raw string.
- tests: 4 model cases (ghost key rejected, private-backed selected,
  owner fallback, transfer preferred) + 2 error-mapping/Display cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(claude): correct secret-storage note on identity-key encryption

Identity keys (imported/loaded, including masternode voting/owner/payout)
are no longer categorically in the deferred keyless tier: they enter
unprotected at load time but can be sealed to Tier-2 per-identity via
IdentityTask::ProtectIdentityKeys (Key Info screen "Add password
protection"). Clarify that the keyless residual is only no-password
secrets and keys the user has not opted to protect.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* feat(masternodes): add page-nav model with two-scope selection (A1)

Introduce ui/state/global_nav.rs: PageNavSpec (page-aware segment-1 +
per-page pill composition) and IdentityPillScope (AppGlobalUser vs
PageScopedObject). The PageScopedObject variant carries its own selection
and never writes AppContext::selected_identity_id — the structural FR-6
boundary the global switcher (A2) and the Masternodes page (B7) build on.

Pure state, renders nothing (module-placement discriminator -> ui/state).

Satisfies TC-NAV-13, TC-NAV-14, TC-NAV-15; foundation for TC-NAV-12,
TC-FR6-07.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(masternodes): generalize breadcrumb into page-aware global switcher (A2)

Add ui/components/global_nav_switcher.rs: a page-aware switcher driven by
PageNavSpec, rendering segment-1 (page label + link) plus composable
wallet / identity-or-object pills. GlobalNavEffect generalizes the hub's
BreadcrumbEffect and adds SelectPageObject for the page-scoped pill — kept
distinct from SelectIdentity so a page-scoped selection never writes the
app-global identity (FR-6 boundary at the effect level).

Reuses BreadcrumbPill / IdentityPill / BreadcrumbPillMode verbatim (no new
pill widget). breadcrumb_switcher.rs becomes a thin hub-facing shim that
builds the hub spec, delegates to the generalized render, and maps the
effect back (self-nav to the hub root -> OpenPicker), keeping hub behavior
unchanged — verified by the existing identity_hub_switcher kittests.

Satisfies TC-NAV-01, TC-NAV-02, TC-NAV-03, TC-NAV-13, TC-NAV-16.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(masternodes): render global switcher on root screens + shared applier (A3)

Add the shell glue in top_panel.rs: apply_global_nav_effect (the shared
successor to the hub's apply_breadcrumb_effect — silent app-scoped
wallet/identity writes, no forced navigation) and add_top_panel_with_global_nav
(one-call render-plus-apply), with subdued_everyday_spec / subdued_wallet_only_spec
Phase-A rollout helpers.

Wire the switcher onto four non-Hub root screens with Subdued (unwired)
specs + TODO markers: Identities, DPNS, DashPay (everyday: wallet + identity
pills) and Wallets (wallet-only composition, TC-NAV-15). The Hub keeps its
existing interactive pills via the breadcrumb shim (regression — full
kittest suite green).

Document (comment + test, PROJ-010) that set_selected_hd_wallet reconciles
the app-global identity as a side effect on non-Hub pages, and that combined
with B1's resolution-layer filter it must never reconcile onto an MN/Evonode.

Deferred (documented): tokens/tools screens carry in-header sub-navigation
that needs the FR-GLOBAL-NAV-5 content-panel back-row migration before their
plain breadcrumb can be swapped for the global switcher — a follow-up, not a
mechanical swap.

Satisfies TC-NAV-06, TC-NAV-15, TC-NAV-16; enables TC-NAV-12/17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(masternodes): load-time key encryption plumbing (B0)

FR-8: add IdentityInputToLoad.encryption_password: Option<Secret>. When
Some, load_identity validates the password up front (fast fail) then, after
insert migrates the keyless keys into the vault, seals them Tier-2 through
the existing per-identity protect envelope (protect_identity_keys →
put_secret_protected via the secret_seam chokepoint) — no new crypto, no
second persistence path. When None, the keyless Tier-1 path is unchanged.

Relocate validate_protection_password from protect_identity_keys.rs into
model/identity_key_protection.rs (PROJ-006, DET validation-placement rule);
the seal path and load path both call the model validator.

MCP masternode_identity_load passes encryption_password: None (PROJ-007 —
GUI-only this iteration, requirements §2.3) with a TODO for headless
password parity.

Add typed TaskError variants DuplicateProTxHash { identity_id } and
MalformedProTxHash { input } for later duplicate/malformed rejection (B1/B4),
avoiding string parsing.

Tests: model validator (relocated); an offline-wired-AppContext test proving
a load-time password seals a masternode's voting (V-target), owner and
identity (M-target) keys Tier-2 and round-trips under the password — the
exact call load_identity makes (TC-FR8-01/02/10). Load-form UI is B4;
end-to-end load routing is covered by the network backend-e2e suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(masternodes): drop ephemeral review ID from A3 reconciliation comment

Self-review: replace a transient review-finding ID in the apply_global_nav_effect
reconciliation note with the durable FR reference. Comment-only; no behavior
change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(masternodes): FR-6 resolution-layer boundary + masternode accessor (B1)

FR-6 (R1, release-blocking): keep masternode/evonode identities out of every
everyday-user surface by filtering at the resolution layer, not the display
call sites.

- resolve_selected_identity(): candidate set filtered to IdentityType::User
  before resolving, so neither keep-if-loaded nor the first-loaded fallback
  can ever resolve a masternode — even when a masternode is the only/first
  loaded identity (TC-NAV-12b).
- set_selected_hd_wallet(): the wallet-switch identity reconciliation resolves
  only over the wallet's User identities.
- restore_selected_identity_from_kv(): one-time sanitization — a masternode
  persisted as selected_identity_id in a prior session is cleared on load;
  a User selection is kept (TC-NAV-12c). In-memory only (non-destructive).
- Display sources switched to the established User-only accessor
  load_local_user_identities(): the global switcher's identity pill + dropdown
  and the Identity Hub landing/picker now list User identities only, so the
  wallet-less "no wallet on this device" group can no longer surface an
  MN/Evonode (TC-NAV-17). Masternodes stay in the legacy Identities table
  (unfiltered accessor untouched — locked decision #2).

New context accessor load_local_masternode_identities() (hydrated MN/Evonode)
— the Masternodes-page card list + page-scoped pill source (B3/B7).

Tests: offline-wired-AppContext unit tests (accessors, resolution filter incl.
lone-masternode fallback, stale-MN sanitization) + a Hub kittest asserting a
seeded Masternode+Evonode never appear on the hub while remaining in the
masternode accessor. TC-FR6-01…06, TC-NAV-12b/12c, TC-NAV-17.

Deferred to consuming tasks (documented): FR-7 refresh (TC-FR7-02/03) composes
existing RefreshIdentity + contested-names refresh at the card Refresh button
(B3); the per-node open-contest card read accessor lands in B3 where the card
consumes it and it is testable against the rendered status line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(masternodes): register Expert-gated Masternodes root tab (B2)

Add RootScreenType::RootScreenMasternodes (stable int 28, round-trip test),
ScreenType::Masternodes, Screen::MasternodesScreen, create_screen and all
ScreenLike dispatch arms; register the always-present root screen in app.rs
(gated at runtime by Expert Mode, not a Cargo feature, so the screen exists
to switch into when the gate is on).

Nav: a "Masternodes" left-panel entry gated FeatureGate::DeveloperMode,
positioned directly below the identity cluster (locked decision #3),
independent of the identity-hub feature. Distinct glyph voting.png (TODO:
dedicated node/server icon). The existing per-entry gate skip hides the nav
item and route when Expert Mode is off.

Live de-gating (§10.11): active_root_screen_mut falls the active tab back to
Identities (always registered) if Expert Mode flips off while Masternodes is
selected, so the gated screen is never shown without its gate.

MasternodesScreen is a scaffold (global-nav header + left rail + island
placeholder); the empty state + card grid land in B3, the page-scoped
masternode pill in B7. Network-switch already calls change_context on
main_screens; the sub-screen reset (§10.10) applies once B4/B5 push
sub-screens (noted for B8).

Tests: RootScreenType round-trip (int 28 stable); kittest — nav absent
Expert-off / present Expert-on, and de-gating falls back to Identities.
TC-FR1-01…07, TC-EDGE-05/06.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(masternodes): empty state + card grid + card body (B3)

Render the Masternodes root screen content on top of the B2 scaffold:

- Empty state (FR-2): canonical §7 copy — heading, body, "Load a
  masternode" primary CTA, and the ProTxHash reassurance line.
- Card grid (FR-3): responsive minmax(260,1fr) grid reusing the identity
  picker's visual language via a new `MasternodeCard` (monogram +
  `draw_type_badge`, both now `pub(crate)`). Body adds masternode rows the
  picker lacks: voter readiness, compact `V O P` key status (glyph, not
  colour-only — NFR-6), DPNS status line, and the IdentityStatus dot+label.
- DPNS status precedence (§10.1): open-contest count first, then a pending
  scheduled vote, then "No open contests", via a display-layer
  `AppContext::masternode_contest_summary` read (no new backend concept).
- Key presence: `QualifiedIdentity::masternode_key_presence` maps
  Voting/Owner/Payout to voter-identity / OWNER / TRANSFER keys.
- Top-right Refresh toolbar button (FR-7) reloads the cached node list.
- Whole card is a single accessible click target (`WidgetInfo::labeled`,
  NFR-6); selection/load intents are captured for B4/B5a/B7 wiring.

Traceability: TC-FR2-01…07, TC-FR3-01…15, TC-FR7-01, TC-NFR6-01/03.
Unit tests cover heading/sub-line, DPNS precedence, key tokens, badge, and
the 8 V/O/P combinations; kittest covers empty-state copy and the grid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(masternodes): dedicated load form + ProTxHash validator (B4)

Add the MN/Evonode-only load flow (FR-4), carved out of the generic
add-existing-identity path:

- `ui/masternodes/load_form.rs`: ProTxHash (required), Masternode/Evonode
  segmented toggle (default Masternode, no User option), optional alias,
  V/O/P key inputs (reused hold-to-reveal `PasswordInput`), optional at-load
  encryption password (drives B0's seal), always-visible Warning-tone
  key-storage note, and a Load button gated on a non-empty ProTxHash with the
  §7 disabled tooltip. Switching node type clears all fields (§10.6). No
  auto-derive affordance — masternode keys are never wallet-derived
  (US-6 retired, §Locked-#4).
- `model/masternode_input.rs`: `is_valid_pro_tx_hash` shape validator (hex or
  Base58) for inline on-blur validation; the backend load task remains the
  authoritative existence/duplicate check.
- Masternodes screen gains a List/Load view enum; the empty-state CTA and a
  `+ Load` toolbar button open the form; submit dispatches
  `IdentityTask::LoadIdentity`; cancel/submit return to the list with a fresh
  form on reopen.
- `add_existing_identity_screen`: remove Masternode/Evonode from the
  Advanced-Options Identity-Type dropdown (User-only remains) — no competing
  entry point (§10.2 / TC-FR4-22, FR-6).

Traceability: TC-FR4-01…22 (render/logic; live-network accept/duplicate/
error-banner paths land in B8), TC-EDGE-01/02.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(masternodes): detail view — header, actions, keys, remove (B5a)

Add the node detail view (FR-5), reusing existing screens rather than
reimplementing:

- Fixed section order Header → Actions → Keys → DPNS → Remove (TC-FR5-01,
  the human-requested Actions-above-Keys correction), pinned by a unit test.
- Header: conditional alias, shortened ProTxHash + copy-full-value, type
  badge (shared `draw_type_badge`), IdentityStatus dot + label.
- Actions row (FR-9): Withdraw / Top up / Transfer push the existing
  WithdrawalScreen / TopUpIdentity / TransferScreen scoped to the node's
  QualifiedIdentity (both node types). Evonode-only `Claim token rewards ›`
  cross-link (FR-11), absent for a plain masternode.
- Keys section (FR-10): V/O/P presence, copyable voter-identity id, at-rest
  protection tier (vault-scheme probe), Add-protection offered only Tier-1,
  `Manage keys ›` into the existing key screen.
- DPNS section: collapsible, open-contest count in the header (voting table
  lands in B5b).
- Remove: danger ConfirmationDialog; deletes the node and its voter identity.
- `‹ All masternodes` back row + detail Refresh (FR-7/TC-FR7-04); card click
  opens the detail via a List/Load/Detail view enum.

Deviations (documented): the Evonode claim cross-link routes to the Tokens
area — precise ClaimTokensScreen token-scoping is deferred to B8 where the
evonode reward-token context is resolvable. Add-protection routes into the
reused key screen (which hosts the password-entry seal flow) rather than
duplicating the form.

Traceability: TC-FR5-01…05/07, TC-FR7-04, TC-FR9-01/02, TC-FR11-01/02.
Live-network credit/claim routing and TC-FR8-07 land in B8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(masternodes): Testnet Fill-Random on the load form (B6)

Add the FR-12 dev convenience to the masternode load form:

- New `testnet_fixture` module owns the `.testnet_nodes.yml` structs + loader.
  The loader returns None for BOTH a missing and a malformed file — a malformed
  file is logged at debug and treated as absent (TC-FR12-04, a deliberate
  divergence from the legacy screen which banners the parse error).
- Fill-Random button + hint render only when Expert Mode is on, the network is
  Testnet, and the fixture is present — never shown-but-disabled (TC-FR12-01…06).
  The `dev_mode` gate is a defense-in-depth re-check at the call site
  (TC-FR12-09 decision: added on future-proofing grounds — a plaintext-key dev
  tool stays inside the Expert-Mode envelope).
- Button label follows the node-type toggle (TC-FR12-01/02).
- Autofill pulls from the correct list per type (TC-FR12-07/08): Masternode →
  `masternodes` (Voting + Owner only; the fixture has no payout key, PROJ-003),
  Evonode → `hp_masternodes` (all three keys). The node-type toggle still clears
  autofilled fields (§10.6).
- The fixture loads once when the form opens (Testnet only), not per frame.

Traceability: TC-FR12-01…09.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(masternodes): inline DPNS voting + missing-voter prompt (B5b)

Populate the detail view's collapsible DPNS section (FR-5):

- Collapsed by default; header shows the open-contest count
  (`DPNS name contests to vote on (N)`, TC-DPNS-01/02).
- Voter present + open contests: per-contest Abstain / Lock / Vote-for-candidate
  choices with the candidate list scoped to that contest's contestants; a
  `Cast votes` button dispatches the existing
  `ContestedResourceTask::VoteOnDPNSNames` backend inline (locked decision #1 —
  not a deep-link). TC-DPNS-03/04/05.
- Voter present, zero open contests: exact §7 empty copy (TC-DPNS-08).
- Missing voter identity: the actionable §7 message (never the raw
  NoVotingIdentity error) plus an `Add voting key` action that opens a scoped,
  in-place voter-key prompt with the node context pre-bound — distinct from
  FR-4's load form, no ProTxHash re-entry (TC-DPNS-09/10/11, §10.8). Save
  re-loads this node with just the voting key to update its voter identity.
- Detail Refresh now re-reads both the contest summary and the open-contest
  list.

Active/open contests only — scheduled/past history stays on the DPNS Scheduled
Votes screen (§10.7).

Traceability: TC-DPNS-01…11.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(masternodes): page-scoped nav pill — FR-6 boundary in code (B7)

Wire the Masternodes page into the global-nav switcher with a page-scoped
masternode pill whose selection lives on the page and is NEVER written to
`AppContext::selected_identity_id` — the structural FR-6 boundary in code,
complementing B1's resolution-layer filter.

- New `ui/state/masternodes_view.rs` builds the page's `PageNavSpec`: page-aware
  `Masternodes` segment-1, an interactive wallet pill (funds Top up — FR-9), and
  a page-scoped identity pill via `IdentityPillScope::PageScopedObject`. Empty →
  subdued `(no masternode yet)` placeholder; ≥1 node → interactive dropdown with
  `Choose a masternode` placeholder; the pill reflects the node in detail and
  resets to the placeholder on `‹ All masternodes` (§10.4).
- New `top_panel::add_top_panel_with_global_nav_capturing` surfaces the
  `SelectPageObject` pick to the caller (applying all other effects as usual)
  without ever routing it into the app-global identity selection.
- The Masternodes screen builds the spec each frame from its node list + current
  view and opens the picked node's detail — two-way with the card grid.

TC-NAV-12 / TC-FR6-07 (release-blocking): a masternode selected on the page
never becomes, or resolves as, the app-global identity — verified across
Identities and the Identity Hub with no User identity loaded.

Traceability: TC-NAV-01/03/04/05/07, TC-NAV-12, TC-FR5-06, TC-FR6-07.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(masternodes): cross-cutting integration coverage (B8)

Add the Remove-flow integration kittest (TC-US4-01/02/06/07): the detail danger
button opens a confirmation carrying the `Remove masternode` verb, and
confirming deletes only the target node — its card disappears while other nodes
survive (isolation). Also sets the confirmation's confirm verb to
`Remove masternode` (§7 / TC-US4-02), the one small production touch the test
surfaced.

Deferred to the network/backend-e2e pass (out of kittest reach without live
DAPI or heavy vault fixtures, and flagged as such in the plan): TC-FR8-07
(detail reflecting a load-time Tier-2-sealed node — needs the real
password-at-load seal path), TC-US4-05 voter-row deletion assertion (needs a
seeded voter-identity row), and the live-network credit/vote/claim dispatch
paths behind FR-9/FR-11/DPNS Cast-votes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(user-stories): catalog the Masternodes tab, retire the legacy load story

Adds MN-001..MN-009 (load, card grid, vote, remove, Hub filter, at-load
encryption, credit actions, key management, evonode token-reward cross-link)
and UX-003 (global wallet/identity switcher) per the completed Masternodes
feature. Flips IDN-003 to superseded — its generic-screen masternode load
path was removed when the dedicated tab shipped.

* docs(masternodes): commit final design docs (DOC-002)

Lands the human-accepted requirements, UX spec, test-case spec, and dev
plan under docs/ai-design/, following the existing 2026-04-23-identity-hub-impl
numbered-file convention. Resolves the ~100+ FR-/TC-/US-/§-ID references
already scattered through the feature's code comments and tests, which
pointed at an uncommitted /data/artifacts scratch copy. Internal
cross-file references (requirements.md, ux-spec.md, etc.) are updated to
the new numbered filenames.

* docs(masternodes): trim oversized module docs, catalog global-nav switcher

Shortens the four ui/masternodes/*.rs module doc comments to the
internal-tier length cap (DOC-003) — they weren't published API, so the
5-10 line public-rustdoc relaxation didn't apply. Adds GlobalNavSwitcher
and its top_panel entry point to ui/components/README.md's catalog
(DOC-004), so the next screen needing a page-aware switcher finds it
instead of reimplementing one.

* fix(masternodes): guard identity load against silent overwrite (QA-005/006)

Root-cause storage fix. insert_local_qualified_identity is INSERT OR
REPLACE, so a load with no guard silently clobbers an already-stored
identity and its keys. Thread an IdentityLoadMode through
IdentityInputToLoad so each entry point declares intent:

- RejectIfExists: the masternode load form rejects a duplicate ProTxHash
  with TaskError::DuplicateProTxHash before any network fetch (QA-006).
- MergeIntoExisting: the scoped Add-voting-key prompt merges the new key
  into the stored identity, preserving Owner/Payout it did not resupply
  (QA-005), via merge_existing_keys_into.
- Overwrite: legacy User re-load and headless flows unchanged (default).

Adds get_local_qualified_identity accessor backing the existence check
and merge read. Failing-first TDD: a unit test proving Owner/Payout keys
survive a voting-key-only merge, and an offline test proving a duplicate
ProTxHash is rejected and the first node is left untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(masternodes): key routing, network-switch reset, live refresh

QA-007: the detail Keys section pushed the static read-only KeysScreen.
Render a per-key 'Manage keys' list and route the Add-protection CTA to
KeyInfoScreen (interactive view/sign/seal per key), mirroring
identities_screen.

QA-001: MasternodesScreen had no change_context override, so a network
switch left an open load form or cross-network detail view actionable.
Add an explicit change_context arm that resets to the List view and
reloads from the now-active network.

QA-003: both Refresh buttons only re-read the local cache. Wire them to
dispatch IdentityTask::RefreshIdentity (per loaded node on the list, the
open node on detail) plus a QueryDPNSContests re-query, alongside the
optimistic local re-read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(masternodes): SHOULD-FIX pass + QA-token cleanup + offline tests

- QA-002: remove the legacy Fill-Random HPMN/Masternode bypass from the
  now User-only add-existing-identity screen (it set identity_type to
  Evonode/Masternode directly, defeating the User-only restriction).
- QA-004: the evonode 'Claim token rewards' CTA pushes a real scoped
  ClaimTokensScreen when the node holds exactly one token, falling back to
  My Tokens when the target is ambiguous — no more bare SetMainScreen.
- QA-008: refresh the open detail view after its own backend task, not
  just the card list.
- Diziet-F3: render the missing-voter 'Add voting key' CTA above, outside
  the collapsed DPNS section, so it is visible without expanding.
- QA-009: surface a MessageBanner when node removal fails instead of a
  silent tracing::warn.
- SEC-001: log the testnet-fixture parse error by position only, never
  its Display text (which echoes a private key).
- SEC-002: parse fixture key fields as Secret (redacted/zeroized).
- Strip stale PROJ-003/PROJ-004 markers and all ephemeral QA-xxx review
  IDs from source comments (kept only in commit messages).
- TODOs for the deferred mixed-protection-tier CTA and the
  is_valid_pro_tx_hash/decode_identity_id duplication.
- Offline tests: TC-FR8-07 (protection_tier reflects a Tier-2 seal) and
  TC-US4-05 (Remove deletes the associated voter identity).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(dashpay): close FR-6 boundary bypass via DashPay identity selectors (SEC-005)

DashPay screens (send_payment, contacts_list, profile_screen,
contact_requests, qr_scanner, qr_code_generator, add_contact_screen,
profile_search) built their IdentitySelector and constructor seed from
the unfiltered load_local_qualified_identities() chained with
.syncing_global(...). IdentitySelector::sync_to_global() writes the
picked id straight to AppContext::selected_identity_id — a separate path
from B1's resolve_selected_identity()/restore filters — so a user could
select a masternode/evonode as the app-global operate-as identity from
inside DashPay, bypassing the FR-6/R1 boundary B1 established.

DashPay operates on User identities only, so every identity list in these
screens is sourced from load_local_user_identities() (the same swap B1
made for the global-nav switcher and Identity Hub). This filters the
masternode out of both the selector write-path and the constructor seed.

Adds a kittest (masternode_never_selectable_in_dashpay_screens) exercising
the FR-6 boundary through five DashPay screens — the existing FR-6 kittest
only covered Identities/Identity Hub, which is how this slipped through.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(masternodes): Marvin punch-list — in-flight guard + execution tests

- QA-012: gate re-submission while a node-load is in flight. Add a
  load_in_flight flag on MasternodesScreen, set on Submit dispatch and
  cleared on the task result or a new display_task_error override; the
  '+ Load' toolbar button and empty-state CTA show a spinner + disabled
  'Loading…' while set, so a rapid double-submit of a brand-new ProTxHash
  cannot race two loads past the pre-fetch existence check.
- Extend masternode_never_selectable_in_dashpay_screens to QRScanner,
  QRCodeGenerator (both seed selected_identity in new()) and assert
  ProfileSearchScreen's User-filtered data source excludes the masternode
  — FR-6 coverage now spans all 8 DashPay screens.
- Add manage_keys_button_opens_key_info_screen: clicks a per-key
  'Voting key ›' button and asserts a KeyInfoScreen is pushed with its
  'Key Information' heading (execution-level proof of the QA-007 fix).
- Add refresh_from_network unit test: one RefreshIdentity per loaded node
  plus a trailing QueryDPNSContests, None when empty (QA-003).
- Fix two doc-comment lines mangled by the earlier review-ID strip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(changelog): add Masternodes tab and global nav switcher (DOC-005)

Covers the user-facing outcomes of the completed Masternodes feature:
the new Expert-Mode-gated tab (card list, detail view, load-time key
encryption, inline DPNS voting, credit actions, Evonode token-reward
claiming) replacing the old generic load path for masternode/evonode
identities, the resulting Identity Hub / Identities picker filter, and
the wallet/identity switcher now present on every root screen instead
of just the Identity Hub.

* fix(masternodes): default GUI build broken — masternode_input feature-gated

The whole model::masternode_input module was gated behind
load form (ui/masternodes/load_form.rs) imports is_valid_pro_tx_hash from
it unconditionally. So a plain 'cargo build --bin dash-evo-tool' (default
features only — no mcp/cli, the documented quick-start build) failed with
E0432 unresolved import. Every gate this feature ran used --all-features,
which always pulls mcp+cli and masked it.

The module can't be blanket-ungated: its parse/decode helpers return
McpToolError (from the feature-gated mcp module). Fix ungates the module
and the pure is_valid_pro_tx_hash validator (the only thing the GUI needs),
and gates precisely the McpToolError-coupled items — KeyMode, parse_node_type,
parse_key_mode, require_at_least_one_signing_key, decode_identity_id, their
imports, and their tests — behind mcp/cli. The pure validator's tests move
to an always-compiled module so they run in the default build too.

Verified: 'cargo build --bin dash-evo-tool' (no flags) compiles; default-
feature clippy clean; both default and --all-features test paths pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(masternodes): correct global-nav coverage claim (F-003)

CHANGELOG and the components README claimed the global wallet/identity
switcher was on "every screen". It ships Phase-A: rendered on Identities,
DashPay, DPNS, Wallets, Identity Hub, and Masternodes, interactive only
on the Hub and Masternodes (the other four render subdued, read-only
pills), and absent from every other root screen (Contracts, Tokens,
Tools, Network Chooser, Withdraws, ...). Names the actual screens and
notes the rest as a tracked follow-up instead of implying full rollout.

* fix(masternodes): Fable final round — Tier-2 merge seal, load gate, ProTxHash error

F-001 (MUST-FIX): "Add voting key" on a password-protected (Tier-2) node no
longer trips the insert's fail-closed guard. load_identity now verifies the
node's object password UP FRONT (before the network fetch, mirroring
add_key_to_identity's verify-before-broadcast order) and seals the merged
plaintext keys Tier-2 via seal_merged_plaintext_keys just before the at-rest
insert. Two regression tests: a scripted-prompt success path proving the new
key flips InVault and reads back Protected, and a headless NullSecretPrompt
path proving the merge fails closed with SecretPromptUnavailable before fetch.

F-002: the list screen's load_in_flight gate is cleared only on the load's own
LoadedIdentity result variant (not any routed result), with a refresh_on_arrival
backstop so a tab switch mid-load can never strand "+ Load" at "Loading…".

F-005: a malformed identity-id input now surfaces MalformedProTxHash for
masternode/evonode loads (where the field IS a ProTxHash) and keeps
IdentifierParsingError for User loads. Regression test added.

F-006: masternodes/evonodes legitimately have no HD wallet, so the
"saving identity without wallet" warning is gated to User identities; nodes log
at debug instead.

F-004: correct the MCP masternode_identity_load comment — Overwrite is a
destructive full-replace of stored keys, not a merge/refresh; TODO for a future
load-mode param.

F-007: strip ephemeral review-ID prefixes (SEC-*, Diziet-*, Smythe) from
masternode-scope source comments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(masternodes): add load-form back link + remove object pill from breadcrumb

Live-walkthrough fixes on real testnet data.

Fix 1 — load form back link: the load form now renders the same
`‹ All masternodes` back link as the detail view (wireframe C shows it on
both), at the top of the form, returning to the card list. New kittest
`load_form_back_link_returns_to_list` covers it; the existing
`load_form_opens_from_cta_and_cancels` gets a taller headless window so the
bottom Cancel button stays reachable now that the back row is present.

Fix 2 — remove the masternode object/identity pill from the Masternodes
breadcrumb. Masternode/evonode identities are never wallet-linked (wallet_info
is always None — locked decision #4), so pairing a wallet pill with an object
pill implied a wallet↔masternode relationship that does not exist. The
breadcrumb now carries only segment-1 + the interactive wallet pill; node
selection is driven entirely by card-click → detail and the back link. The
Masternodes page switches to add_top_panel_with_global_nav (non-capturing),
matching every other non-object page. The masternodes_page_nav_spec builder
drops its items/selected params.

This does NOT touch the FR-6 boundary, which is enforced structurally at the
resolution layer (B1) independent of any pill. The release-blocking FR-6
boundary tests (TC-NAV-12 / TC-FR6-07) remain unchanged and green. The
PageScopedObject / SelectPageObject / add_top_panel_with_global_nav_capturing
machinery is retained as the documented, tested boundary pattern for future
page-scoped-object features (the global_nav_switcher tests still exercise it);
only the Masternodes page's use of it is removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(masternodes): reject load when selected node type mismatches on-chain

Loading a masternode/evonode by ProTxHash trusted the load-form Masternode/
Evonode toggle as ground truth with no cross-check. A regular masternode loaded
with "Evonode" selected was silently accepted, mis-badged "Evonode", and shown
the Evonode-only "Claim token rewards" action.

The load task (authoritative layer) now cross-checks the selected type against
the node's actual on-chain registration. A masternode's Platform identity id is
its ProTxHash, so the node is looked up via Core RPC `protx info`; the `type`
field ("Regular"/"Evo") is classified and compared. On a confirmed mismatch the
load is rejected with a typed, actionable `TaskError::NodeTypeMismatch` naming
both the selected and actual types. When the on-chain type cannot be determined
(Core RPC unreachable — e.g. an SPV-only setup with no Core node) the load
proceeds unverified, so this adds no regression for those users.

Layering per CLAUDE.md: the pure classification (`classify_protx_node_type`)
and rejection decision (`node_type_conflict`) live in `model/masternode_input`
and are exhaustively unit-tested (the reported Evonode-on-regular case
included); the backend task owns the network lookup and enforcement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(masternodes): surface a visible warning when node type is unverified

Follow-up to the node-type cross-check: when Core RPC is unreachable (the
common case for SPV-only users) the node type cannot be verified, and silently
proceeding with an unverified badge reproduced the original UX bug downgraded
from "wrong" to "unverified". The load task now distinguishes the two success
outcomes via a new `BackendTaskSuccessResult::LoadedIdentityTypeUnverified`
variant, and the Masternodes screen surfaces a visible warning banner (not just
a log line) telling the user the badge reflects their selection and to reload
later to confirm. The MCP masternode-load tool reports the same distinction via
a new `node_type_verified` output field.

Regression tests: the pure reject decision (`node_type_conflict`) and the
`NodeTypeMismatch` user message cover the hard-reject path; a kittest drives the
unverified-load result into the live screen and asserts the warning banner is
surfaced to the UI, not merely logged.

Confirmed with team-lead: hard-reject on a confirmed mismatch; a visible (not
log-only) warning on the unverified path. The upstream platform-wallet SPV
masternode-list passthrough (for verifying node type without Core RPC) is
tracked as a separate follow-up against the platform repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* revert(masternodes): drop Fix #3 node-type validation entirely

Reverts c5167787 and 755eee87. Product decision: trust the user's
Masternode/Evonode toggle as-is, with no on-chain node-type verification.

Rationale: the only non-type-gated actions (Withdraw/Top up/Transfer) are
unaffected by a mis-toggle; the sole type-gated action ("Claim token rewards")
degrades to a clean no-op/failed Platform state transition, not a fund-safety
issue — so the toggle working as the user set it is correct behavior, not a
defect. Dropping verification also removes the dependency on Core RPC (being
deleted in the platform-wallet migration) and on fetching the operator identity
(extra scope), leaving the load path simpler and migration-proof.

Removes: TaskError::NodeTypeMismatch, the onchain_node_type Core RPC cross-check,
the classify_protx_node_type/node_type_conflict model helpers, the
LoadedIdentityTypeUnverified result variant + UI warning banner, the MCP
node_type_verified output field, and all associated tests. Fixes #1 (load-form
back link) and #2 (breadcrumb pill removal) in 5db23f72 are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(masternodes): adapt retargeted tab to platform-wallet rewrite APIs

Rebasing the Masternodes tab onto the platform-wallet backend rewrite
(PR #860) surfaced three call sites where the rewrite reshaped an API the
masternode-tab code depended on:

- `AppContext::identity_kv()` was renamed to `det_kv()`; the load-path
  existence check (`get_local_qualified_identity`) now calls the new name.
- `ContestState::state_is_votable()` was dead-code-removed by the rewrite,
  but `ContestedName::is_open_for_voter` (Masternodes card DPNS status)
  relies on it — restored as a live, un-gated method.
- The rewrite dropped the `identity-hub` Cargo feature and renders the
  Identity Hub nav entry unconditionally; the left-panel builder no longer
  gates that entry behind the removed `#[cfg(feature = "identity-hub")]`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(withdraw): screen-level kittest coverage for default_withdrawal_key fix

WithdrawalScreen::new() pre-selecting via default_withdrawal_key() was only
unit-tested at the QualifiedIdentity model layer. Add tests/kittest/withdraw_screen.rs
to verify the fix at the actual screen layer: ghost-key identities (on-chain-only
TRANSFER key) render the no-keys empty state instead of a form, private-key-backed
TRANSFER/OWNER keys are pre-selected AND rendered correctly in the key-selection
ComboBox (via accesskit value, not label). Also locks in a genuine regression the
fix newly exposes: WithdrawalScreen::new() leaks a raw "No key provided when
getting selected wallet" error banner for any identity with no locally-signable
withdrawal key.

* fix(withdraw): skip wallet resolution when no signable key exists

WithdrawalScreen::new() called get_selected_wallet with selected_key=None
after default_withdrawal_key() correctly began returning None for identities
with no locally-signable withdrawal key. With app_context=None that hits the
"No key provided" String Err path, which .or_show_error() posted verbatim
into a user-facing MessageBanner — violating the plain-language error policy.

Guard the call on selected_key being Some, so the raw-string Err branch is
structurally unreachable here instead of avoided by luck.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(masternodes): add user story for network-switch reset behavior

Live QA of the Masternodes tab confirmed MasternodesScreen::reset_for_network_change()
cleanly resets the List view and clears stale banners/form data on a network switch,
with no existing story documenting this PR's fix. Add MN-010.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test(withdraw): flip banner-leak test to a regression lock (2edbc18e)

WithdrawalScreen::new() now guards get_selected_wallet on selected_key
being Some (2edbc18e), so the raw "No key provided..." banner leak for
ghost-key-only identities is fixed. Rename
ghost_key_construction_leaks_raw_error_banner ->
ghost_key_construction_does_not_leak_raw_error_banner and invert the
assertion; also verify the no-keys empty state still renders correctly
for the same identity, so the fix didn't trade the leak for a broken
empty state.

* fix(mcp): stop det-cli double-prefixing the HTTP bearer token

rmcp 1.7's StreamableHttpClientTransportConfig::auth_header takes the
raw token and lets reqwest's bearer_auth prepend "Bearer " itself. The
det-cli client passed format!("Bearer {token}"), so the wire header
became "Authorization: Bearer Bearer <token>". The server middleware
strips one "Bearer " and compares the remaining "Bearer <token>" against
the configured key — never equal — so headless HTTP mode returned 401 on
every request. The auth path had no end-to-end coverage, which is why it
shipped broken.

Pass the raw token and add tests/mcp_http_auth.rs pinning the server's
wire contract: raw token accepted, a double-"Bearer" prefix rejected,
missing credentials rejected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(masternodes): share Expert Mode flag app-wide; node-specific load error

Two live-QA bugs on the Masternodes tab (PR #876).

Bug 1 — Expert Mode didn't reveal the Masternodes nav without a restart.
`developer_mode` was an independent per-network `AppContext` AtomicBool, kept
in sync only by a best-effort loop in the Settings checkbox handler over the
contexts that happened to exist at click time. AppState keeps one context per
network (only the active one at startup; others created lazily on switch), so
the left-nav `FeatureGate::DeveloperMode` gate could read a stale value on a
different context than the toggle mutated — the nav entry stayed hidden until a
restart re-read the persisted flag into the single fresh context. Promote the
flag to one shared `Arc<AtomicBool>`, created once in `AppState::new` and
injected into every `AppContext::new` (startup + `SwitchNetwork` via
`developer_mode_handle()`), so all per-network contexts observe one flag. Drop
the fragile sync loop; request a repaint on toggle since enabling Expert Mode
disables animations (which stops continuous repaints).

Bug 2 — loading a masternode by a valid-but-unregistered ProTxHash showed the
generic "Identity not found — check the ID or name" copy, wrong for a ProTxHash
load form. Add a dedicated `TaskError::MasternodeNotFound` variant and return it
from the masternode/evonode load path instead of `IdentityNotFound`.

Regression tests: `developer_mode_is_shared_across_network_contexts` (RED before
the shared-flag fix), `masternode_not_found_message_is_node_specific`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(user-role): introduce UserRole + composable FeatureGate checks (Phase 1)

Introduce the typed persona axis and generalise the feature gate into a
conjunction of heterogeneous checks, with a zero-behaviour-change compat
shim over the retired binary Expert Mode flag. Phase 1 only — the ~43
is_developer_mode() callsites and the role-setting UI are untouched.

- model/user_role.rs: ordered UserRole { Everyday<Power<Developer }, pinned
  discriminants, as_str/from_persisted (sentinel-safe)/at_least/from_u8.
- context/feature_gate.rs: Capability (ShieldedProtocol predicate moved
  verbatim, per-network by construction), Check { MinRole, Capability,
  Experimental }, empty ExperimentalFeature, FeatureGate::checks() table +
  is_available = checks().all(). DeveloperMode stays mapped to >= Power.
- context/mod.rs: re-type the shared app-global atomic Arc<AtomicBool> ->
  Arc<AtomicU8> (UserRole discriminant); user_role()/set_user_role()/
  experimental_enabled()/user_role_handle(); is_developer_mode() and
  enable_developer_mode() kept as >= Power compat shims; animation gate
  re-pointed at >= Power.
- model/settings.rs: replace UserMode/user_mode with Option<UserRole>/
  user_role, reusing the length-prefixed user_mode wire slot (no offset
  shift). "Advanced"/"Beginner"/empty/unknown decode to None (a sentinel),
  never a role — mapping the universal legacy default to a role would
  silently promote every user.
- context/settings_db.rs: seed a role-less (None) blob once from .env
  DEVELOPER_MODE (true -> Power, else Everyday) at get_app_settings, mirroring
  the impure dash-qt autodetect fallback.
- app.rs / backend_task / mcp: seed and share the role atomic from .env.

Tests: UserRole ordering/round-trip/sentinel; canonical wire round-trip;
"Advanced" -> None; role-less blob seeds Power from .env; explicit role not
reseeded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(ai-design): add persona/capability-gating design doc

Commit the design doc referenced by src/model/user_role.rs's doc comment
so the pointer resolves once this branch merges — it previously only
existed on the separate design/persona-capability-gating branch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(user-role): reclassify dev-mode callsites onto role/capability gates (Phase 2/3) (#880)

* feat(user-role): reclassify dev-mode callsites onto role/capability gates (Phase 2)

Walk every is_developer_mode()/FeatureGate::DeveloperMode callsite and
reclassify each per the four-bucket rubric, then wire the Masternodes tab
to its own gate.

- Add FeatureGate::Masternodes (>= Power); repoint the masternodes nav
  entry and the app.rs live de-gating guard onto it.
- Bucket 1 (disclosure): reclassify to user_role().at_least(Power).
- Bucket 2/3 (signing override at state_transition_options + has_keys
  proceed-without-key bypasses): tighten to at_least(Developer). This is
  an intentional behavior change from today's single dev flag (== Power).
- Bucket 4 (experimental/stability: shielded send + tab, DashPay pay/
  subscreens): move to Check::Experimental via new ExperimentalFeature
  {Shielded, DashPay}; experimental_enabled() stays >= Power for now.
- Rename FeatureGate::DeveloperMode -> DeveloperTools (>= Developer) as
  the forward-looking Developer-tier gate; delete the is_developer_mode()
  and enable_developer_mode() compat shims (no callers remain).
- Remove dead AddressInput::with_developer_mode/set_developer_mode and the
  never-set developer_mode field.
- Update kittest role toggles and the shared-role regression test; flip
  user story WAL-022 (system accounts) from developer-mode to Power role.

PROJ-007 sites (button_text.contains("Test") i18n fragility) left as-is
per brief — only their dev-mode gate portion was reclassified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(user-role): QA follow-ups — IDH-005 wording, dead fallback, override test

- docs/user-stories.md IDH-005: retitle to "Bulk identity creation", persona
  Jordan -> Priya,Jordan, and reword the footer/dropdown criteria to the Power
  role, matching the Power reclassification of the test-identities footer.
- withdraw_screen: tighten the on-chain-only key pre-select fallback from
  at_least(Power) to at_least(Developer). Only Developer can actually sign with
  such a key (signing override + the Developer branch of the has_keys gate), so
  the Power-level pre-select was dead and its comment overclaimed. Comment fixed.
- context: add regression test for the state_transition_options signing override
  — Everyday/Power -> None, Developer -> Some with both allow_signing_with_any_*
  flags true.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(user-role): role-picker UI on Settings + Welcome, single-source persistence (Phase 3/3) (#881)

* feat(user-role): role-picker UI on Settings + Welcome, single-source persistence (Phase 3)

Make AppSettings.user_role the single source of truth for the runtime role
atomic and add both role-setting UI surfaces.

Persistence wiring:
- app.rs boot no longer reads .env DEVELOPER_MODE directly; the shared role
  atomic starts at the default and is seeded from get_app_settings() once the
  active context exists (the .env parser stays for the v34 migration).
- settings_db: get_app_settings now persists the one-time .env seed back to the
  DB, so the sentinel slot is consumed exactly once and later .env changes no
  longer move the role. New AppContext::set_and_persist_user_role centralizes
  "set runtime atomic + write canonical AppSettings string" for both surfaces.

UI:
- Network Settings: replace the binary Expert-mode checkbox with a three-way
  UserRole selector — Default view / Detailed view / Developer tools — with a
  per-mode description. Advanced (RPC/SPV) options stay Power-gated; the
  Developer-tools sub-panel now keys off the Developer role.
- Welcome screen: add an experience-level onboarding row (Everyday/Power/
  Developer) writing the same persisted role.

Tests: add env-seed-consumed-once regression; existing role/seed tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(user-role): CLI/MCP boot single-source + role-selector UX polish (Phase 3 QA)

- mcp/server.rs init_app_context (CLI/MCP standalone boot) seeded the role
  atomic straight from .env DEVELOPER_MODE, bypassing AppSettings.user_role — a
  role chosen in the GUI was ignored headless. Now seeds from
  get_app_settings().user_role, matching the GUI boot path (single source of
  truth).
- UserRole gains label()/description() as the shared selector vocabulary; both
  the Settings selector and the Welcome onboarding row now use them, so a role
  picked in one is findable by name in the other. The Everyday description is a
  complete sentence (i18n rule).
- Welcome row now shows the selected mode's description (parity with Settings).
- Settings "Interface mode" selector lifted above the force-collapsing Advanced
  Settings panel so it is always discoverable; its description uses the
  theme-aware text_secondary(dark_mode) getter.
- NetworkChooserScreen::refresh_on_arrival re-syncs selected_role from the
  app-global role so the radios never show a stale value.
- Tests: kittest coverage for both surfaces (set + persist role) and a
  UserRole label/description test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(user-role): document three-role interface-mode system, close #371

Add docs/user-roles.md covering the Default view/Detailed view/Developer
tools model: where to set it (Network Settings "Interface mode" card,
Welcome screen onboarding row), reversibility, and the one-time
DEVELOPER_MODE .env seed (true -> Detailed view, false/unset -> Default
view) that is never re-read once a role is chosen. Link it from README
and .env.example. Fix docs/user-stories.md entries (NET-005, NET-006,
MN-002, NET-015) still describing the retired Expert Mode toggle/Beginner-
Advanced mechanism instead of the shipped role selector.

Note: docs/expert-mode.md (added by aebae01b) never merged into v1.0-dev,
so this is a net-new doc rather than the planned git-mv rewrite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(changelog): document Expert-mode replacement with interface levels

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* fix(user-role): stop settings loss on k/v read failure; gate shielded ops on capability

Bot-review fixes on the persona/capability-gating rollout.

Settings persistence (data loss):
- `load_app_settings_uncached` mapped every k/v read error to
  `AppSettings::default()`, then — seeing a role-less default — seeded a role
  and persisted the whole defaults blob. One transient read failure (poisoned
  lock, SQLite hiccup, schema mismatch) therefore overwrote the user's real
  settings: network, onboarding, SPV prefs, theme. It now returns `Result`, so
  an unreadable blob is never mistaken for "nothing stored". `update_app_settings`
  aborts instead of committing its mutation on top of defaults; `get_app_settings`
  keeps its in-memory defaults fallback for the frame loop but writes nothing back.
- `set_and_persist_user_role` published the role to the runtime atomic before
  persisting and swallowed a persist failure as a log warning, so the UI accepted
  a mode that silently reverted on restart. It now persists first, publishes only
  on success, and returns the error. Both callers surface it: the settings selector
  reverts its radio group and shows a banner; the onboarding row shows a banner
  (its role is re-read from the context each frame).

Feature gating:
- Shielded send sources and shielded destinations called `experimental_enabled`
  directly, bypassing `Capability::ShieldedProtocol`, so a Power/Developer user
  was offered shielded options on networks whose protocol version defines no
  shielded state transitions — while the shielded tab itself was correctly hidden.
  Adds `FeatureGate::ShieldedOperations` (capability AND experimental — the first
  multi-check gate) and routes send_screen + shielded_tab through it.
- Routes the four raw DashPay `experimental_enabled` callsites through a new
  `FeatureGate::DashPayOperations`, so every gating decision goes through the
  single composition point. `FeatureGate::DashPay` (nav entry) is unchanged.

Tests:
- `FailingKv` (kv_test_support): a store whose reads can be armed to fail, counting
  puts — proves a failed read writes nothing back and that the stored blob survives.
- `context::test_support`: shared `AppContext` fixture, lifted out of settings_db's
  test module so feature_gate can reuse it.
- feature_gate gains its first test module: per-role availability, the empty
  conjunction, and the AND semantics of the new multi-check gate. No protocol
  version upstream defines the shielded state transitions today, so the
  "capability met" half of the AND is not yet reachable; a tripwire test fails
  loudly when upstream ships them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(user-role): encapsulate the shared role atomic behind UserRoleCell

`AppContext.user_role` was a raw `Arc<AtomicU8>` threaded through the
constructor and hand-decoded at every read (`UserRole::from_u8(load(..))`)
and write (`store(role as u8, ..)`), with ~20 construction sites spelling
out the atomic encoding.

Introduce `UserRoleCell` in `model/user_role.rs`, next to the enum it
wraps: `get()` / `set()` plus `Clone` as the cheap shared handle that wires
sibling per-network contexts to one value. `UserRole::from_u8` drops to
private — the encoding is now the cell's business alone.

Behaviour-preserving; `user_role_handle()` becomes `user_role_cell()`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: drop the stale DEVELOPER_MODE interface-mode claims

`DEVELOPER_MODE` no longer seeds the interface mode at all — the role of an
account that never chose one is Power, resolved in memory with no `.env` read.
Two living docs still advertised the retired one-time-seed behaviour.

CHANGELOG's `[Unreleased]` entry now states what an account without a chosen
level actually gets (Detailed view, so nothing the old Expert mode showed is
hidden) and that `.env` has no say in it.

README's environment-variable table drops the `DEVELOPER_MODE` row outright:
the app reads no such variable for configuration, so a row in a table of
supported variables is a false claim rather than a stale one. The migration
detail it used to carry — that the key survives only as an input to the
one-shot v34 SPV database upgrade — already lives in docs/user-roles.md, which
the replacement note points at.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Lukasz Klimek <842586+lklimek@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
lklimek added a commit that referenced this pull request Jul 13, 2026
* test: stabilize suite timing and close kittest wiring race

Three test-suite reliability fixes, no product code touched.

1. Ignore 49 wallet_backend secret-storage tests that each pay real
   Argon2id (64 MiB) cost through platform_wallet_storage's public
   SecretStore API. There is no downstream fast-KDF hook yet
   (dashpay/platform#4111 tracks exposing one), so under whole-suite
   parallelism their peak memory pressure drives the host into swap and
   inflates every test's wall-clock. The ~50 sibling vault tests in the
   same modules stay enabled as canary coverage; CI still runs the
   ignored set via `-- --ignored` (see PR note — the workflow edit is
   pending, .github is write-protected here).

2. Close the kittest wallet-backend wiring race. AppState::new spawns
   backend wiring as a background tokio task; a fixed run_steps(N) races
   it, so seeding via insert_local_qualified_identity intermittently
   panicked WalletBackendNotYetWired under load. New shared helper
   support::wait_for_wallet_backend polls the exact precondition
   (wallet_backend().is_ok()) up to 30s. mount_app / fresh_app_context
   and every per-file mount helper that seeds now gate on it instead of
   a fixed step count.

3. No change for the two nextest LEAK flags — reproduced 6x in isolation
   under low load, always PASS, never LEAK. Both are pure synchronous
   unit tests; the flag was nextest's wall-clock leak-timeout heuristic
   false-firing under the same contention finding #1 removes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test: un-ignore 49 Argon2id tests via argon2 opt-level=3; close wallet-registration race

## argon2 opt-level=3 — un-ignore the 49 secret-storage tests

PR #884 marked 49 `wallet_backend::{secret_access,identity_key_store,single_key,
det_signer,hydration,secret_seam,wallet_seed_store}` tests `#[ignore]` because
their real end-to-end `SecretStore` flows each paid a production-strength 64 MiB
Argon2id derivation, running 5-23s under whole-suite contention.

The dominant cost was `argon2` compiled at the default dev opt-level=0: each
derivation ran for seconds AND held its 64 MiB that whole time, so under
parallelism they overlapped into swap pressure. Adding `[profile.dev.package
.argon2] opt-level=3` (+ the `test` profile) shrinks each derivation to tens of
ms and collapses the memory-hold window. Cargo honors `[profile.*]` only from
the workspace root, so platform's own argon2 stanza does not propagate to DET —
this must be declared independently.

Result (forced-fresh): the 49 now run at min 0.14s / mean 0.59s / max 1.53s,
down from 5-23s. All 1772 workspace tests pass. This is a DET-local change with
no dependency pin and no cross-revision instability; the upstream fast-KDF mock
(dashpay/platform#4111) is not required to hit the target.

## Close the wallet-registration race (CI-only failure on e0c81a9)

`context::wallet_lifecycle::tests::cache_shielded_receive_address_publishes_
bound_account_zero_address` (and its sibling `remove_wallet_evicts_shielded_
receive_address`) wired the backend BEFORE `register_wallet`. With the backend
wired, `register_wallet` spawns the fire-and-forget `wallet_upstream_
registration` subtask, which then races the test's explicit
`ensure_upstream_registered`: both call `create_wallet_from_seed_bytes`; the
loser sees `WalletAlreadyExists` then `get_wallet` returns `None` in the
insert gap, exhausting `resolve_registered_wallet`'s retries → `WalletNotFound`.
Production never combines both paths per wallet (fresh uses the subtask;
cold-boot/loaded uses `ensure_upstream_registered`), so this is a test-only
artifact.

Fix: register BEFORE wiring the backend (the pattern already documented in the
cold-boot test), so the subtask never spawns and `ensure_upstream_registered`
is the single upstream writer. Verified 25 iterations (50 test executions)
under single-core pinning + 4x background CPU load, all green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
lklimek added a commit that referenced this pull request Jul 14, 2026
… migration fix, nav pills, disclosure closure (#882)

* docs(user-stories): add missing entries surfaced by v1.0 parity audit

Additions only (no edits/removals) covering gaps found while auditing
v0.10-dev feature parity against PR #860 (DPNS, network/settings,
UX, masternodes, DashPay send/receive, wallet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* refactor(network-chooser): drop dead dashmate_password_input field

The RPC/Dash-Qt backend mode is gone in the SPV-only rewrite, but
NetworkChooserScreen still carried a `dashmate_password_input` that was
constructed, seeded from disk at startup, and re-seeded via a synchronous
`Config::load_from` on every network switch — while never being rendered
anywhere. Delete the field and its disk-read plumbing, along with the now
purposeless `prev_network` sentinel that existed only to trigger the
re-seed. Removes a blocking file read from the network-switch UI path.

`NetworkConfig::core_rpc_password` is left intact: it still round-trips
through the `.env` serializer in `config.rs` (settings-schema scope).

A10 (expert-mode nav refresh) is deferred: PR #879 (UserRole +
composable FeatureGate) is still open and reworks this exact mechanism,
and #880 stacks on it. Investigation found the nav-refresh bug already
fixed on this base by #876 — every AppContext construction path shares
one `Arc<AtomicBool>` developer-mode flag, the nav gate re-reads it each
frame, and the Masternodes screen is always registered — so the existing
comment describes present behavior correctly and needed no edit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: close v1.0 parity audit disclosure gaps (group 8)

Adds the missing paper trail for three undisclosed removals (Masternode
List Diff screen gravestone + CHANGELOG + gaps.md row; shielded per-note
detail CHANGELOG line; three doc sites amended to disclose the QR-removal
notice via CHANGELOG instead of an unshipped in-app notice), strengthens
two under-described disclosures (address-table column, Proof Log
persistence+viewer loss), and formally signs off ten already-disclosed
removals in a new closure record.

* fix(tokens): keep dismissed token balances out of refresh watch sets

"Stop Tracking Balance" was undone by "Refresh My Tokens": the refresh
re-registered the full known-token registry for every local identity, so
a dismissed (identity, token) pair was re-watched upstream and its row
came back. Upstream owns the watch set in memory only, so the dismissal
has to be persisted and re-applied DET-side.

Persist dismissed pairs in the per-network k/v store under
det:token_untracked:v1 and rebuild each identity's watch set as "local
registry minus that identity's dismissals". Re-tracking stays possible
through the paths the UI already promises: re-importing a token clears
its dismissals for every identity, and explicitly checking one balance
clears just that pair. Removing a token from the registry, and the
devnet sweep, prune the dismissal list too.

Regression test drives the real user action against an offline wired
context (stop tracking, then assert the refresh watch set) and was
confirmed RED before the fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(shielded): restore shielded receive-address view and copy

Users could not view or copy their own shielded receive address, so they
could not receive a private transfer at all: the Shielded tab rendered
only a placeholder because the live address read is async-only and the
egui frame loop is synchronous.

Bridge it through the push-snapshot seam this codebase already uses for
shielded/platform balances rather than inventing a new one:

- AppContext::shielded_addresses — frame-safe snapshot, written on the
  async backend side by cache_shielded_receive_address() right after
  ensure_shielded_bound() in bootstrap_wallet_addresses_jit (the seam
  reached from both cold boot and the unlock gesture), read each frame
  via the synchronous shielded_receive_address().
- Evicted on wallet removal: a receive address is a payment destination
  and must not outlive the wallet that owns it.
- model::address::encode_shielded_address() — the pure raw->bech32m
  inverse of parse_shielded_recipient; the MCP tool now shares it.
- Shielded tab renders the address with a hint, hover-for-full, and copy
  on either the address or the Copy button; the truncation is display
  only and the clipboard always receives the full string.

Funds safety: the address comes from the upstream-owned key slot
(PlatformWallet::shielded_default_address), i.e. the same OrchardKeySet
that bind_shielded registered with the NetworkShieldedCoordinator as the
viewing keys it scans with. It is never re-derived DET-side, so a
displayed address is always one the wallet can detect notes for. It is
Orchard account 0 — the only account DET binds and the only one its
spend path (shielded_transfer(.., 0, ..)) can spend from.

Diversified-address generation ("+") stays out of scope: upstream exposes
no per-index accessor (OrchardKeySet::address_at is reachable only via
the crate-private shielded_keys slot). Deriving them DET-side would
duplicate Orchard key handling outside the coordinator seam, and mapping
"+" onto a new ZIP-32 account would strand funds in an account the
single-account spend path cannot spend from. Documented as a TODO and
narrowed in WAL-028.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(identity-hub): wire Contacts actions, real contact list, alias, and pay

The hub's Contacts tab rendered Accept / Decline / Cancel buttons that did
nothing, hardcoded "Active contacts · 0", and offered no way to pay a contact
or rename an identity without a detour through the retired legacy screens.

- Accept / Decline now dispatch AcceptContactRequest / RejectContactRequest.
- Cancel gains a backend task. A DashPay contactRequest document is immutable
  and undeletable (documentsMutable: false, canBeDeleted: false), so a sent
  request cannot be withdrawn from Platform. CancelContactRequest therefore
  re-verifies state, broadcasts a hidden contactInfo document, and records the
  withdrawal in the DET sidecar — the same shape reject_contact_request uses.
  The UI copy says so plainly instead of promising a withdrawal the protocol
  cannot deliver.
- load_contact_requests now consults the sidecar, so a declined or cancelled
  request actually leaves the list instead of reappearing on every reload.
- Active contacts render from LoadContacts, with a working search box.
- Settings tab gains a local alias ("Name on this device") editor.
- Contact rows gain a Pay affordance that opens the existing send-payment
  screen — no new signing or broadcast logic.

Contacts-tab state moves to ui/state/contacts_view.rs per the DET module
placement policy (it renders no egui).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(dashpay): clear a stale rejection marker when re-adding a contact

Declining a request wrote a permanent local marker, so a request from that
person stayed filtered out of the list forever — even after the user
deliberately added them again. Sending a contact request now retires the
marker, since sending is an explicit re-engagement.

Also documents the two new hub stories and the cancel capability in the
user-stories catalog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(migration): import settings, scheduled votes and top-ups from legacy data.db

Upgrading from v0.10-dev booted the app with a blank configuration: the
network reset to Mainnet (a testnet user relaunched straight into mainnet),
theme/onboarding/paths reset, and scheduled DPNS votes were silently dropped
— a real vote-window deadline risk for masternode voters.

Three imports, all idempotent and sentinel-guarded:

- Settings (network, start screen, theme, onboarding, Dash-Qt path, toggles)
  are imported in `AppState::new_inner` *before* the settings blob is read,
  because that read is what selects the active network. It runs synchronously
  there — no AppContext exists yet. The import overwrites an existing blob:
  until now, an upgrading user's first launch wrote a `default()` blob over
  their real preferences, and skipping on "a blob exists" would make that
  reset permanent. The sentinel, not the blob, is the guard.

- Scheduled votes and top-up history are imported by `finish_unwire` under
  their own per-network sentinel, ahead of the wallet-drain gate: an install
  that already drained its wallets under an earlier build still has these rows
  in data.db, and a shared sentinel would declare it "done" and strand them.
  Votes already in the k/v store are left alone so a retry cannot push a stale
  `executed = 0` over a vote the user has since cast. An undecodable vote row
  fails the pass (banner + "Retry now") rather than vanishing silently.

`scheduled_votes` and `top_up` join the detection gate: a masternode voter who
imported identity keys directly has queued votes but no wallet rows at all.
The app-data pass probes those tables before reaching for the wallet backend,
so an install with nothing to import still completes without it.

Readers live in `database/legacy_import.rs` (typed, counters only, no policy);
the "what to do on failure" decision stays in `backend_task/migration`. Legacy
rows are never deleted. The v0.9.0 ladder fixture now carries a vote, a top-up
and settings, asserting they survive the full v5 → current migration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(wallets): remove dead RPC-mode gate on single-key send, surface limitation in-app

Single-key (imported WIF) send and balance/UTXO monitoring remain blocked on
upstream platform-wallet. This lands the honest, user-facing half and corrects
the record on what upstream actually needs.

Feasibility (platform-wallet 44c20e3 / key-wallet 48a07d3): the SPV watch set is
the union of every managed account's address-pool addresses, and balances/UTXOs
come from the funding accounts, so a single imported P2PKH address WOULD be
monitored once it sits in a registered wallet's pool. key-wallet can already
build such a pool without derivation (AddressPool::new_without_generation +
AddressInfo + KeySource::NoKeySource). What is missing is a way to REGISTER it:
PlatformWalletManager::register_wallet is private, the public constructors all
require an HD seed, and the inner WalletManager (which does expose a public
insert_wallet) is reachable only via PlatformWallet::wallet_manager() — i.e.
only when a wallet is already registered, so a single-key-only user has no
handle at all. Unblocked by a public seedless register_watch_only_wallet.

Changes:
- Drop the `is_rpc_mode` gate (hardcoded false; RPC mode no longer exists in
  this SPV-only build) from the single-key detail view and send screen.
- Detail view: Send is explicitly disabled, with the reason and the
  recovery-phrase workaround in a persistent banner and the button tooltip.
- Wallets action bar: selecting a single-key wallet no longer routes into a
  send screen that could only refuse the payment — it states the limitation.
- Send screen: no UI gate; the backend stays the authoritative enforcement
  layer and refuses with the typed TaskError::SingleKeyWalletsUnsupported.
- Correct the stale TODOs in core/mod.rs: the previously-assumed key-wallet
  single-address pool helper is NOT required; only the upstream registration
  entry point is. Refresh is not re-enabled as a button — monitoring is meant
  to be automatic, so that task should be deleted once upstream lands.
- Tests: lock the user-facing copy contract (states the limitation, names a
  self-serve action, no jargon) for both the UI copy and the typed error.
- user-stories.md: WAL-030 restated as automatic monitoring (no refresh
  control) and SND-002 updated; both stay [Gap] with the real blocker named.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(nav): wire the masternode and Wallets global-nav pills

Moves two more pills of the FR-GLOBAL-NAV staged rollout from
subdued/read-only to fully interactive, and removes a dead click sensor.

Masternodes (MN-012, FR-GLOBAL-NAV-3): the page-scoped node pill is now an
interactive dropdown of every loaded masternode/evonode, two-way bound with
the page — opening a card names that node on the pill, picking a node from
the pill opens its detail view. The pill's label follows the node's card
heading and its glyph follows the node type (HeroIdentityKind::type_glyph),
so the grid and the breadcrumb never name a node differently. The selection
stays page-scoped: it maps to SelectPageObject, never SelectIdentity, so a
masternode can never become the app-global identity (FR-6).

Wallets (FR-GLOBAL-NAV-2 rule 2): the wallet pill is interactive and two-way
bound — switching on the pill selects that wallet on the page, and the page's
own selection is what the pill reads back. Arrival now adopts a wallet
switched from another page's pill, ahead of the first-wallet default that
would otherwise silently overrule it.

Connection indicator: the click sensor is downgraded to hover-only; the
tooltip is its whole interaction.

Supporting changes: PageObjectItem carries a type glyph; PageScopedObject
carries page-owned tooltip copy, keeping page wording out of the shared
component; add_top_panel_with_global_nav_capturing returns the raw
GlobalNavEffect so any page can mirror the selection it consumes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(dashpay): narrow the cancel race window and add a way back for hidden contacts

Cancel could hide a contact that was established mid-flight: the reciprocal
check and the contactInfo broadcast are separate Platform round-trips, and a
request arriving in between was never noticed.

- Restructure cancellation as `cancel_flow` over a `CancelOps` trait: the
  reciprocal check is the last read before the write, and a second read right
  after the broadcast detects a reciprocal request that landed inside the
  window and undoes the hide, leaving the new contact visible. The trait makes
  the ordering unit-testable — the race is injected between the two probes.
- Add a "Show hidden contacts" section to the Hub Contacts tab with a per-row
  Unhide (contactInfo broadcast with display_hidden cleared, nickname and note
  preserved), so a hidden contact is never unreachable from the Hub.
- Share one contact-search matcher between the Hub and the legacy DashPay
  contacts screen, which had drifted onto different field sets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(user-stories): record the hidden-contact recovery path in DPY-009

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ui,tokens): re-disable single-key send, ungate DashPay pay buttons, type the token-dismissal seam

Three converged QA findings from the v1.0 parity batch.

single-key send screen: the deleted `is_rpc_mode` gate had been the only
thing disabling the Send button, so the screen shipped an enabled Send that
dispatches a task `CoreTask::SendSingleKeyWalletPayment` always refuses. Its
`display_message` only cleared the busy flag on success-shaped text, so the
button would also have stuck on "Sending..." forever on that refusal. The
screen has no live route today, but the backend handler's TODO names it as
the parked send UI to re-point once upstream lands seedless registration, so
it is kept and made safe rather than deleted: Send is disabled with the same
copy and disabled-hover text as the wallets action bar, every dispatch goes
through one choke point that arms the busy flag, and any task result clears
it. Regression tests cover the refusal, the arming, and the fee-retry dialog.

DashPay: the Identity Hub's "Pay a contact" button was ungated on the premise
that no other send flow is dev-gated. It was — the contacts list, contact
details and profile viewer all gated the same `DashPaySendPayment` screen
behind developer mode, with a stale comment claiming it "requires SPV which
is dev mode only" (SPV is the standard backend now). Ungate all three to
match, and flag the four entry points for explicit role classification when
the UserRole/FeatureGate rework (#879) lands.

tokens: the dismissal API took `(identity, token)` at one seam and
`(token, identity)` at the next, both bare `Identifier`s — a transposition
would have compiled and un-tracked the wrong pair. Thread the existing typed
`IdentityTokenIdentifier` through instead. The on-disk payload keeps its
`(token_id, identity_id)` layout, now pinned by a test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs: drop stale Resync references from shielded_tab doc comments

Resync/Sync buttons were removed (net-improvement automatic sync);
two doc-comments still described the removed action.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(migration): stop a corrupt vote row from blocking the wallet drain

`finish_unwire::run` imported scheduled votes first, unconditionally, and
propagated the vote-row failure with `?`. The vote importer is fatal on an
unreadable row by design, so a single corrupt legacy `scheduled_votes` row
wedged the wallet-seed migration on every launch and every "Retry now" — the
row is never deleted and that path has no Skip. A user with funds behind a bad
vote row could never reach their wallet again.

Decouple the two passes. `run` now holds the app-data result, runs the wallet
drain (extracted into `drain_wallets`) regardless, and only judges the app-data
outcome once funds are reachable. Undecodable vote rows become a per-row
skip-and-count instead of a migration-fatal error: they are surfaced on the new
terminal `MigrationState::SucceededWithUnreadableVotes`, which raises a sticky
Warning banner naming the recovery action, with no dead-end retry. The app-data
sentinel is written once every *importable* row is handled — withholding it
would re-run the import each boot and resurrect votes the user has since cast
and cleared. Hard app-data failures (unreadable file, k/v write) stay fatal and
still leave that sentinel unwritten, but no longer gate the drain.

Both invariants hold: the vote sentinel still runs ahead of the wallet-drain
gate (identity-only voters keep their import), and no vote is lost in silence —
the legacy rows survive in `data.db` and the count reaches the user.

Tests: an end-to-end `run()` over a fixture with real wallet rows AND a corrupt
vote row proves the wallet lands hydrated + upstream-registered while the bad
row is counted (RED before this change: MigrationFailed/ScheduledVotesUnreadable).
The tautological TC-MIG-009 sentinel test is replaced by one that calls `run()`
twice on the same `AppContext` and pins that the second launch re-fires nothing,
including no vote resurrection after the queue is cleared.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor: consolidate six duplicated code paths from the DRY audit

Every duplicate below had two or more implementations of one rule, which is
how the two identity-ID shorteners silently drifted apart.

Identity labels — `display_label` gains the DashPay display-name tier and is
now the one resolver for the hub-wide priority rule (nickname -> display name
-> DPNS handle -> shortened id). `contact_label` delegates to it and the
divergent `abbreviate_id` is gone.

User-visible change (intentional): a profile-less contact rendered as
`US517G59…` on the Contacts tab and `US517…LFx` in its identity pill — the
same identity, two spellings. Both surfaces now use `shorten_id`. Covered by
a test that fails against the old code.

DashPay — the `toUserId` extraction (5 sites) moves to
`model::dashpay::contact_request_recipient`, alongside the existing
`model::dpns` document-extraction precedent; the `contactRequest`
`DocumentQuery` builder (11 sites) moves to a private
`dashpay::contact_request_query`. The hand-rolled `Value::Identifier`
pattern-matches are replaced by the same typed accessor the rest of the
module already used, so the mutual-contact filter and the resolved-request
filter can no longer disagree about what a document's recipient is.

Database — `table_exists` / `column_exists` become the single schema probe in
`database::mod` (4 duplicate impls, 11 inline `pragma_table_info` queries).
The migration modules keep their typed `MigrationError` attribution by
mapping the shared probe's error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(migration): lock the v0.9.3 -> v1.0 upgrade path end to end

Every existing test of the upgrade path starts from an already-normalised
fixture: the schema ladder from v5 or v27, the settings import from a
v0.10-dev `settings` table. Nothing proved the three subsystems compose
from real v0.9.3 raw data (schema v11) in the order `AppState` runs them:
ladder -> boot settings import -> wallet drain, with the drain's network
coming from the imported settings.

Adds that test over a byte-faithful v0.9.3 fixture (v11, no
`single_key_wallet` table, no `core_wallet_name` column, no
`onboarding_completed` column, raw seed with empty salt/nonce, an
Argon2 + AES-GCM protected sibling wallet, a masternode identity, a queued
DPNS vote and a top-up row). Asserts the seed arrives verbatim in the
vault, the protected envelope byte-for-byte, the alias and main flag in
the sidecar, the vote and the top-up history in the k/v store, the
identity row still linked to its wallet — and, the headline regression,
that a testnet user is not relaunched on mainnet. Plus idempotency: a
second launch re-fires nothing and deletes no legacy row.

Each assertion was verified to bite by mutation (dropping the imported
network, the seed drain, the app-data pass and the top-up write each fail
exactly the assertion that should catch them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(tokens): stop concurrent dismissals from clobbering each other

Dismissing and re-tracking a token balance each read the entire
`det:token_untracked` set, mutated a local copy, and wrote the whole blob
back. Both are independent backend tasks, each `tokio::spawn`ed, so two
overlapping calls could both read the pre-mutation set and have the later
write win — the earlier mutation was silently lost and the dismissed token
reappeared on the next refresh.

Give each dismissed `(token, identity)` pair its own presence-marker key
(`det:token_untracked:v2:<token>:<identity>`). Dismiss is now a single
`put`, re-track a single `delete`, and reading the set a prefix scan: no
read-modify-write window remains for a concurrent mutation to slip into.
Token id leads the key, so dropping every dismissal of one token stays a
single prefix scan rather than a full-set rewrite.

Covered by two threaded races (concurrent dismissals; a dismissal racing a
re-track) that lose an update against the previous scheme, plus a
structural test pinning each mutation to one write with no read-back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(dashpay): scope contact results to their identity, split resolution markers

Three defects in the contact-request path, all found by review on #882.

Wrong-identity acts (blocking): accept_contact_request and
reject_contact_request took the counterparty from the fetched document
without ever checking its toUserId was the acting identity — a stale row
clicked after an identity switch could sign a real state transition under
the wrong key. They now go through sender_of_received_request, the mirror
of the check cancel_contact_request already had, and error with
ContactRequestNotAddressedToYou instead. The UI layer is fixed too: the
DashPayContactRequests and DashPayContactsWithInfo results now carry the
identity they were loaded for, and every consumer discards a result whose
identity is no longer selected.

Silent cancellation failure (blocking): mark_withdrawn dropped both an
unavailable wallet backend and a typed storage error, so a cancellation
whose marker never landed still reported success while the request came
back as pending on the next reload. It now returns Result and cancel_flow
propagates it.

Undirected rejection marker: cancel and decline shared one marker, checked
symmetrically for both directions, so cancelling a request to Bob silently
hid the genuine request Bob sent back afterwards, with no recovery path.
The marker is now split by direction (declined / withdrawn), each written
and read only for its own direction; sending a request retires both.

Pre-existing sidecar markers under the old undirected key are inert: a
previously resolved request may list as pending once, which the user can
resolve again — the safe direction to fail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(migration): stop the legacy import from losing top-ups, votes and warnings

Three defects in the legacy-upgrade path, each able to lose user data or the
notice about it. All three are RED-then-GREEN covered.

Top-up history was overwritten, and its failures were made permanent.
`save_top_ups` replaced the whole stored map instead of merging, so a late
migration pass stomped any top-up the user recorded in between. It is now a
read-merge-write (incoming wins on a colliding index), matching what the
top-up flow already did at the callsite. A top-up read or write failure was
also reduced to a `warn!`, so the app-data sentinel was written anyway and
the fast path skipped the retry forever — freezing a one-off k/v error into
permanent loss. The pass now fails on it (every identity is still attempted
first), which withholds the sentinel so the next launch retries.

A malformed SQLite column aborted the whole vote import.
`read_scheduled_votes` decoded five raw columns with `?` before the row-level
skip-and-count logic, so one NULL, type-mismatched or out-of-range value (a
negative `time` fails rusqlite's `u64` range check) discarded every valid vote
already read and turned a warning into a hard `TaskError`. Column decoding is
now per-row, like the domain decoding beside it: log, count `unreadable`,
continue. Same treatment for `read_top_ups`.

The unreadable-vote warning fired exactly once, ever.
The sentinel fast path returned a zero-count outcome on every later launch, so
the banner could never be re-published — a user who was away when it appeared
never heard about it again, while the vote it names may still have an open
deadline. The count now persists in a per-network k/v record, written before
the sentinel (a crash in between re-runs the idempotent import rather than
losing the warning), and is re-published on every launch until the user
acknowledges it via the banner's "Got it" action — a stray dismissal is not an
acknowledgement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(dashpay): stop the Contacts tab losing loads, clicks and accepted accounts

Three defects on the Identity Hub's Contacts tab, one test each, all RED
before the fix.

The tab hydrated with two separate `AppAction::BackendTask`s in one frame.
`AppAction`'s `|=` is last-writer-wins, so `LoadContacts` was dropped on the
floor and the active-contacts section stayed empty until an unrelated refresh
happened to re-fire it. Both loads now travel as one `BackendTasks(Concurrent)`
action, and hydration yields to a click in the same frame rather than
clobbering it — the load guard is untouched until it actually dispatches, so it
simply goes out on the next paint.

Accept, Decline, and Cancel each sign and pay for a state transition, and
nothing stopped a second click from buying a second one while the first was
still in flight. Each request now holds an in-flight guard: its card's buttons
are disabled, and the dispatcher refuses a duplicate even if a click gets
through. Success releases the guard by request ID; a failure carries no ID, so
the hub releases all of them — a row the user can retry beats a row stuck
forever.

Unhiding a contact rewrote the whole `contactInfo` document with an empty
accepted-accounts list, erasing every account the user had accepted. The write
path now takes an `AcceptedAccounts` choice: `Replace` for a caller that owns
the list, `Preserve` for one that does not, which reads the stored accounts
back out of the existing document. Unhide and the contact-details edit form —
neither of which has any say over accepted accounts — now preserve them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: allow-list v093_upgrade.rs's legacy wallet-table fixture read

tc_dev_001_no_live_readers_of_wallet_table failed against the
concurrently-merged v093_upgrade.rs (0fcb6e7e): its second-launch
assertion reads the legacy `wallet` table row count directly from a
scratch fixture database to prove the row survives migration. That's
a test-only fixture-verification read, never a cold-boot read, the
same exemption already granted to wallet_lifecycle/tests.rs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: stabilize suite timing and close kittest wiring race (#884)

* test: stabilize suite timing and close kittest wiring race

Three test-suite reliability fixes, no product code touched.

1. Ignore 49 wallet_backend secret-storage tests that each pay real
   Argon2id (64 MiB) cost through platform_wallet_storage's public
   SecretStore API. There is no downstream fast-KDF hook yet
   (dashpay/platform#4111 tracks exposing one), so under whole-suite
   parallelism their peak memory pressure drives the host into swap and
   inflates every test's wall-clock. The ~50 sibling vault tests in the
   same modules stay enabled as canary coverage; CI still runs the
   ignored set via `-- --ignored` (see PR note — the workflow edit is
   pending, .github is write-protected here).

2. Close the kittest wallet-backend wiring race. AppState::new spawns
   backend wiring as a background tokio task; a fixed run_steps(N) races
   it, so seeding via insert_local_qualified_identity intermittently
   panicked WalletBackendNotYetWired under load. New shared helper
   support::wait_for_wallet_backend polls the exact precondition
   (wallet_backend().is_ok()) up to 30s. mount_app / fresh_app_context
   and every per-file mount helper that seeds now gate on it instead of
   a fixed step count.

3. No change for the two nextest LEAK flags — reproduced 6x in isolation
   under low load, always PASS, never LEAK. Both are pure synchronous
   unit tests; the flag was nextest's wall-clock leak-timeout heuristic
   false-firing under the same contention finding #1 removes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test: un-ignore 49 Argon2id tests via argon2 opt-level=3; close wallet-registration race

## argon2 opt-level=3 — un-ignore the 49 secret-storage tests

PR #884 marked 49 `wallet_backend::{secret_access,identity_key_store,single_key,
det_signer,hydration,secret_seam,wallet_seed_store}` tests `#[ignore]` because
their real end-to-end `SecretStore` flows each paid a production-strength 64 MiB
Argon2id derivation, running 5-23s under whole-suite contention.

The dominant cost was `argon2` compiled at the default dev opt-level=0: each
derivation ran for seconds AND held its 64 MiB that whole time, so under
parallelism they overlapped into swap pressure. Adding `[profile.dev.package
.argon2] opt-level=3` (+ the `test` profile) shrinks each derivation to tens of
ms and collapses the memory-hold window. Cargo honors `[profile.*]` only from
the workspace root, so platform's own argon2 stanza does not propagate to DET —
this must be declared independently.

Result (forced-fresh): the 49 now run at min 0.14s / mean 0.59s / max 1.53s,
down from 5-23s. All 1772 workspace tests pass. This is a DET-local change with
no dependency pin and no cross-revision instability; the upstream fast-KDF mock
(dashpay/platform#4111) is not required to hit the target.

## Close the wallet-registration race (CI-only failure on e0c81a9c)

`context::wallet_lifecycle::tests::cache_shielded_receive_address_publishes_
bound_account_zero_address` (and its sibling `remove_wallet_evicts_shielded_
receive_address`) wired the backend BEFORE `register_wallet`. With the backend
wired, `register_wallet` spawns the fire-and-forget `wallet_upstream_
registration` subtask, which then races the test's explicit
`ensure_upstream_registered`: both call `create_wallet_from_seed_bytes`; the
loser sees `WalletAlreadyExists` then `get_wallet` returns `None` in the
insert gap, exhausting `resolve_registered_wallet`'s retries → `WalletNotFound`.
Production never combines both paths per wallet (fresh uses the subtask;
cold-boot/loaded uses `ensure_upstream_registered`), so this is a test-only
artifact.

Fix: register BEFORE wiring the backend (the pattern already documented in the
cold-boot test), so the subtask never spawns and `ensure_upstream_registered`
is the single upstream writer. Verified 25 iterations (50 test executions)
under single-core pinning + 4x background CPU load, all green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* test(wallets): pin the pill-click mirroring seam on the Wallets page

The Wallets page caches its own wallet handle, but the shared applier the top
panel runs on a pill click writes only the app-global selection. Nothing
covered the seam between the two, so removing the page's mirroring step would
have compiled, passed, and shipped a pill that moves while the page body stays
on the previous wallet — a pill click performs no navigation, so the arrival
re-sync never fires to cover it.

The new test walks the real sequence: run `apply_global_nav_effect`, assert the
app-global selection moved AND the page's cache did not, then mirror and assert
the page caught up. The middle assertion is the regression this seam exists to
prevent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(migration): import legacy v0.9.3 identities and their keys (#885)

* docs(migration): design the v0.9.3 legacy identity import

The schema ladder preserves the legacy `identity` table, but no production
code path imports it into the modern `StoredQualifiedIdentity` k/v store, so
an upgrading v0.9.3 user silently loses every identity and all of its key
material. Specify the import: what moves, where the step plugs in, its
idempotency strategy, the byte contract it must produce, and the test that
locks it.

Also correct the 2026-05-28 migration notes, whose `identity` entry named a
destination that commit b14bf32c had already moved and a version-byte
agreement that is not needed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(migration): import legacy v0.9.3 identities and their keys

A v0.9.3 install that upgrades to v1.0 kept its `identity` rows in
`data.db` but nothing ever read them, so the user booted into an empty
Identities screen — and a masternode owner silently lost the owner and
voting keys they had loaded, since v0.9.3 stores them inside the
identity blob and nowhere else.

Add a third migration pass, under its own per-network sentinel
(`det:migration:identities:<net>:v1`), running after the wallet drain so
the backend is wired, the vault is reachable and `ctx.wallets` is
hydrated for wallet-derived keys to attach to. Reusing the drain's
sentinel would have skipped the import for exactly the installs that
already drained under a build without it.

Key material is never handled here: each decoded identity goes straight
to `AppContext::insert_local_qualified_identity`, which routes keys
through the secret seam and leaves only `InVault` placeholders on disk.
No new secret-handling path is introduced.

Details:
- `legacy_import::read_identities` filters `is_local = 1 AND data IS NOT
  NULL` (v0.9.3's observed-identity cache is not user data) and restores
  `status` from its column — the bincode blob does not carry it, so
  every identity would otherwise read back as `Unknown`.
- Skip-if-present before insert: the writer is INSERT-OR-REPLACE, so a
  retry after a withheld sentinel would otherwise overwrite a user's
  post-import edit with the stale legacy blob.
- A link to an absent or still-locked wallet is preserved, never nulled:
  it is what re-attaches the identity when that wallet is unlocked.
- An undecodable blob is counted and reported, never fatal: it withholds
  the sentinel (an unreadable blob may be a decoder defect a later build
  fixes) but does not block the identities that do decode.
- `identity` joins `LEGACY_TABLES`, so an identity-only install (a
  masternode voter with no HD wallet) now trips legacy detection.

Tests pin the v0.9.3 -> v1.0 contract end to end, including a golden
blob produced by the real v0.9.3 binary (bincode 2.0.0-rc.3) asserted to
decode on this tree (2.0.1) — the one cross-version claim that could not
be settled by reading struct definitions. The no-plaintext-on-disk
assertion reads the stored bytes before any load path runs, because the
eager load-path repair would otherwise mask an importer that wrote
plaintext.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(migration): never let a corrupt vote queue strand identity keys

QA-001 (medium): `run()` unwrapped the app-data result with `?` before
the identity import, so a hard failure in the vote/top-up pass — one
malformed `det:scheduled_vote_voters:v1` blob is enough — returned early
and the identity import never ran. That failure is deterministic and the
app-data sentinel is never written on it, so the pass failed identically
on every subsequent launch: a masternode owner's owner and voting keys
would never reach the vault, on any launch, because of a broken vote
queue they cannot see or repair.

Run the identity import before either DET-owned result is judged, and
fold both outcomes at the terminal-state step. Neither pass gates the
other; a hard failure in either still surfaces to the user's retry
banner, with the identity failure taking precedence when both fail —
keys outrank votes.

QA-002 (low): `read_identities` read `status` and `wallet_index` through
a narrow `row.get::<u8>` / `row.get::<u32>`, so an out-of-range value
raised `IntegralValueOutOfRange` through `?` and took the entire identity
read down with it — keys included. Every other row-level corruption in
that loop (bad id length, bad seed hash, half-filled wallet link,
undecodable blob) is counted as `unreadable` and skipped. The legacy
schema puts no `CHECK` on either column, so an out-of-range value is
storable; widen the read and apply the same row-level policy.

Both fixes carry a regression test confirmed RED against the unfixed
code: the vote-index one imports 0 identities under the old ordering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(migration): honor legacy alias fallback and guard identity-import edge cases

Address three review findings on the legacy v0.9.3 identity import path:

- read_identities now selects the alias column and applies it as a
  fallback only when the decoded blob's own alias is None, matching
  the design doc's documented column-is-fallback contract.
- read_identities rejects rows whose blob-embedded identity id
  disagrees with the row's id column, closing a gap where the
  skip-if-present precheck (keyed on the row id) could diverge from
  the actual vault write (keyed on the blob's id) and silently
  overwrite an unrelated identity.
- finish_unwire::run now checks identities.unreadable before
  unwrapping the app_data result, so a deterministic app-data failure
  (e.g. a corrupt vote-index blob) can no longer mask the
  identity-unreadable banner that tells a masternode owner to reload
  their identity.

Adds regression tests for the alias fallback and id-mismatch cases,
both confirmed red against the prior code before the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(migration): column-authoritative alias, per-row identity decode, combined failure surfacing

Address round-2 review on PR #885 (three blocking findings):

- Alias precedence (finding 1): the v0.9.3 SQL `alias` column is
  authoritative. `set_identity_alias` wrote ONLY the column, and every
  identity loader decoded the blob then unconditionally overwrote `alias`
  with the column value, so a rename or removal left the blob stale and the
  column always won at load. The import now assigns the column
  unconditionally — including a NULL column clearing a stale blob alias —
  instead of a blob-first fallback that would resurrect a renamed-away alias.
  Verified against the `v0.9.3` tag; the design doc claim was backwards and
  is corrected.

- Per-row identity column decode (finding 3): a wrong SQLite storage class
  on any of id/data/status/wallet/wallet_index/alias raised
  `InvalidColumnType` through `?`, discarding every identity already
  accumulated in the batch. Decoding through `decode_identity_columns`
  (mirroring `decode_scheduled_vote_columns`) counts-and-skips the bad row,
  matching the function's row-isolation policy.

- Combined failure surfacing (finding 2): when unreadable identities and a
  hard app-data failure coincided on one launch, the run published only
  `SucceededWithUnreadableIdentities` and returned Ok, swallowing the
  app-data failure with no retry banner — every launch. Added
  `MigrationState::FailedWithUnreadableIdentities { count, error }`, a
  retryable error banner naming both problems, so neither masks the other.
  Funds stay safe (the drain still runs) and neither DET-owned sentinel is
  written, so both retry next launch.

Regression tests added for all three, including a RED-verified malformed-type
test proving the batch survives and an end-to-end both-failures test proving
both signals surface.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

* fix(migration): surface unreadable votes alongside unreadable identities

An unreadable legacy identity permanently hid an unreadable legacy vote.
The identity import withholds its sentinel while any row fails to decode,
so `identities.unreadable > 0` recurs on every launch — and that branch
returned early, ahead of the durable `read_vote_warning` re-publish. The
app-data pass, meanwhile, short-circuits on its own sentinel from the
second launch on and honestly reports zero unreadable votes, so taking the
count from its counters could not have rescued the vote half either. Net
effect: a user with one corrupt identity row and one corrupt vote row was
never told about the vote — on any launch — and could miss a live deadline.

The identity branch now reads the durable vote warning from storage and
publishes both counts on one terminal state,
`SucceededWithUnreadableIdentitiesAndVotes`, rendered as a single sticky
Warning banner naming both remedies. Acknowledging retires only the vote
half; the identity half keeps arriving until a build with a fixed decoder
imports the rows. A k/v read that itself fails is surfaced as the retryable
combined failure rather than dropping either signal.

Adds IDN-016 (identities and keys preserved across an app upgrade), the
user story CLAUDE.md requires for this PR's user-facing migration behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(migration): reconcile legacy keys into partially loaded identities

The identity import skipped a legacy row wholesale whenever the id was
already in the modern store. But presence is not proof every key survived:
before this PR, a masternode could be loaded from only its ProTxHash
(voting/owner/payout keys all optional), persisting a partial key map. When
such an install upgrades, the legacy blob may still hold owner/voting/payout
keys the modern record lacks — and the wholesale skip stranded them. The
skipped row was not counted unreadable, so the sentinel landed and those
legacy-only keys never reached the vault or got retried: a silent loss of a
masternode's control keys, not just a banner glitch.

The importer now fetches the existing modern identity and gap-merges the
legacy blob into it: the modern record stays authoritative (its keys, alias,
protection state, and wallet link always win) and only the keys/associations
it lacks are taken from the blob. It re-persists in place via
update_local_qualified_identity only when the merge actually recovered
something (new `reconciled` counter); an identical record is left untouched,
so a retry can never overwrite a user edit with the stale legacy copy.

The gap-merge is the same "keep what I have, borrow only what I'm missing"
rule load_identity already used for in-place key adds; that private helper is
promoted to QualifiedIdentity::merge_gaps_from (model/, single source) and
reused by both callers. Regression test
`a_present_but_partial_identity_gains_the_legacy_only_keys` stages a partial
modern identity plus a legacy blob carrying an extra Owner key and asserts the
key is merged in and the record re-persisted once; the existing
already-in-store test now proves an identical record is not re-written. Design
doc §7 edge-case table updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(migration): only reconcile bare identities, never keyed ones

The e8b61821 reconcile filled a present identity's gaps from the legacy
blob by inferring "missing" from field absence. Two ways that is unsafe for
a background migration (both raised in review):

1. Protection downgrade — merging a legacy `Clear` key into an identity whose
   other keys are password-protected produces a mixed record. On save,
   `encode_identity_blob_vault_first` refuses it with
   `IdentityKeyProtectionDowngrade`; the migration then errors before writing
   its sentinel and fails identically on every launch.
2. Resurrected removals — absence is not proof of a partial load. "Remove
   private key from DET" deletes a map entry and clearing an alias persists
   `None`. On a pre-sentinel install (or a retry held open by another
   unreadable row) the merge would refill those intentional absences from the
   stale blob, restoring a removed alias or re-adding a deliberately-removed
   private key.

Fix: reconcile only a record that holds NO private keys at all — the one
unambiguous "loaded without its keys" signal (the ProTxHash-only masternode
load). For a bare record, take the legacy key set and fill the missing
masternode role associations; re-persist only when something was recovered.
Any record that already holds keys is left untouched: a protected identity
always holds keys, so it never reaches the vault-first guard (fixes 1), and a
keyed record's absent field is never refilled, so removals are never
resurrected (fixes 2). Alias is never merged in migration. A keyed-but-partial
or protected identity is recovered instead through the interactive load, which
has the identity password.

Reverts the shared `QualifiedIdentity::merge_gaps_from` extraction: the
gap-merge is a load-path-only tool (safe only with a user present), so it goes
back to the private `merge_existing_keys_into` in load_identity. Migration
carries its own bare-record reconcile.

Tests: `a_present_but_bare_identity_gains_the_legacy_only_keys` (bare record
recovers the legacy key + owner association) and
`a_present_keyed_identity_is_left_untouched_never_reconciled` (a keyed record
is skipped — no downgrade, no resurrected alias/key). Design doc §7 updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(migration): revert legacy-identity reconcile to safe skip-if-present

Reconciling legacy-only keys into an already-present identity cannot be
done safely without provenance the model does not carry: field absence is
indistinguishable from a deliberate user removal ("Remove private key from
DET" leaves no tombstone; a cleared alias persists as None), so a blob-first
merge would resurrect removed keys or aliases. Merging a plaintext legacy
key into a protected identity would additionally trip the vault-first
IdentityKeyProtectionDowngrade guard and fail the whole pass.

Revert migrate_identities_from_conn to the original skip-if-present body: an
identity already in the store is skipped wholesale, never re-persisted.
Restore has_local_qualified_identity (presence probe, no decode) as the
skip check. Drop the reconciled counter, the get_existing/update closure
seams, and the reconcile-specific tests.

No data is lost: the legacy data.db is preserved verbatim, so a bare
(partially-loaded) identity's stranded keys remain recoverable by a future
provenance-aware flow. Document the stranding as a known limitation in the
design doc (§7) and track the recovery flow as a follow-up.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WVUVpGxaAsciGSiT7t4kuC

* fix(migration/wallet): QA follow-ups for #885 — resurrection, banners, decode limit, wallet naming (#891)

* fix(identity): bound the bincode decode so a corrupt blob errors, not aborts

QualifiedIdentity::from_bytes decoded legacy identity blobs under
bincode::config::standard(), which resolves to NoLimit. A length
prefix claiming an inflated element count (an ordinary bit-flip or
truncation, no attacker required) makes bincode pre-allocate the
claimed size before reading anything; when that exceeds available
memory the allocator aborts the process (SIGABRT, uncatchable, not a
Result::Err). Live-reproduced during PR #885's grumpy-review: a
minimal probe encoding a 1 TiB length prefix aborted with exit 134.

This defeated the legacy-identity migration's own stated contract
("one bad blob never blocks the identities around it") on exactly the
corruption class ordinary disk bit-rot produces, crash-looping the
app on every cold start until the user manually repaired data.db.

Fix: decode under a bounded Limit (16 MiB, far above any real
QualifiedIdentity) via a shared identity_blob_decode_config() function
used by both from_bytes and its regression test, so a future edit
that weakens the limit is caught rather than silently diverging from
what the test actually pins. With a Limit, bincode checks the claimed
size against the cap before allocating and returns
DecodeError::LimitExceeded -- a normal Err the existing skip-if-present
machinery already handles.

RED-first: temporarily reverted the config to unbounded and confirmed
the new regression test aborts the test process with the exact same
"memory allocation of 1099511627776 bytes failed" / SIGABRT signature
from the review's live repro, before restoring the fix and confirming
green. Full workspace suite (1675 lib tests + kittest/doctests),
clippy --all-features --all-targets -D warnings, and cargo +nightly
fmt --check all pass clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(wallet): name the wallet in the "still loading" error

`TaskError::WalletNotLoaded` was a bare unit variant: with several
wallets loaded, neither the user nor a developer reading logs could tell
which wallet was still loading. It now carries a `wallet_label` — the
alias, or a truncated seed-hash hex when the wallet was never named —
and the message names it.

Both construction sites (`resolve_wallet`, `monitored_receive_addresses`)
resolve the label from the wallet-meta sidecar: the wallet is by
definition missing from `id_map` there, so there is no live handle to
ask. The `id_map` read guard is released before that sidecar read.

The alias-or-hex rule was inlined in `wallet_from_envelope`
(`SeedLengthInvalid`); it moves to `model::wallet::meta::wallet_label` as
the single source of truth for both errors, output unchanged.

* docs(migration): realign the legacy-identity design doc with the shipped code

The doc was written before a ten-commit iteration and only partly updated
afterwards, so three sections described an implementation HEAD never had.

- §5: the sketch unwrapped `app_data` before running the identity import,
  the exact inverse of HEAD. Both DET-owned results are *held* and judged
  after the drain, because an app-data failure is deterministic: unwrapping
  it first would skip the identity import on this launch and on every retry,
  stranding a masternode owner's keys over a corrupt vote queue. Transcribed
  HEAD's held-then-judged flow, including the per-arm terminal states.
- §9 T-ID-01: `LegacyIdentityRow` has no `status` field (the reader folds
  status and alias straight onto `qi`), and the SQL selects a sixth column,
  `alias` — the column, not the blob's stale copy, is authoritative.
- §10: assertion 9 cited `second_launch_after_a_v093_upgrade_changes_nothing`
  as proof of skip-if-present, which that test cannot carry — on the clean
  path the sentinel short-circuits the pass before the check is reached, so
  it would pass against an importer with no such rule at all. Moved to
  `a_retry_after_an_unreadable_identity_preserves_user_edits`, where the
  sentinel is deliberately withheld, and said why.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(migration): stop reporting a readable app-data pass as a failed one

`FailedWithUnreadableIdentities` had two producers, and one of them lied.

Path 1 — the app-data pass hard-fails alongside undecodable identities — is
what the state and its banner describe: "updating the rest of your previous
data did not finish… Choose Retry now to finish updating." True, and the
retry works, because the app-data sentinel is unwritten.

Path 2 — the app-data pass SUCCEEDS and writes its sentinel, and only the
follow-up unreadable-vote-warning k/v read fails — published the same state.
The user was told a pass that had completed did not finish, and offered a
retry that re-runs nothing: on the retry the app-data pass short-circuits on
its own sentinel and the same read fails again, so the false error banner
returns on every launch.

Fall through to the honest `SucceededWithUnreadableIdentities` instead, and
log the read failure with its typed error. Nothing is swallowed: the warning
record is durable, and this branch re-runs on every launch while the identity
sentinel stays unwritten, so the next successful read re-publishes the vote
half. The identity signal — the one the user must act on — reaches them
either way. Reusing the existing variant over adding a new one keeps the
reconciler and the shielded indicator untouched (both already map this state
and the old one to the same badge).

Regression test `an_unreadable_vote_warning_record_does_not_claim_the_app_data
_pass_failed` poisons the warning record with a zero-length bincode body, so
the read fails deterministically while the app-data pass runs clean; it
asserts the honest state AND that the app-data sentinel is written — the very
fact the old banner denied. Confirmed RED before the fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(migration): add the identity-banner kittests the rustdoc promised

Three banner-copy functions in `app.rs` close their rustdoc with "Exposed for
kittest coverage", which is the only thing justifying their `pub` — yet
`tests/kittest/` referenced none of them. The promise now holds:

- `unreadable_identities_banner_warns_without_a_retry_action`
- `unreadable_identities_and_votes_banner_names_both_and_acknowledges`
- `failed_with_unreadable_identities_banner_offers_a_working_retry`

Each asserts the copy renders verbatim and that the action set matches the
outcome: no retry for the two Warning states (the rows are still in the
previous version's storage and decode no better on a second pass), a working
"Retry now" for the one genuine failure, and the vote acknowledgement on the
combined warning so a live deadline cannot be buried by the recurring
identity signal.

Also adds the missing `MigrationStep::Identities` to
`tc_mig_014_running_text_covers_every_step_with_sentence`, which claimed to
cover every step while omitting the one this feature added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(migration): stop the identity import from resurrecting deleted identities

The identity pass wrote its completion sentinel only when every legacy row
decoded. A genuinely corrupt row never decodes, so the sentinel was never
written and the import re-ran on every cold start, forever. Skip-if-present
made that harmless for an identity the user had edited, and did nothing for
one the user had deleted: the next launch re-imported it, restored the alias
the user had cleared, and re-wrote its legacy plaintext keys into the vault —
with no banner to explain it and no way to stop it short of editing data.db.

Write the sentinel unconditionally, exactly as the sibling app-data pass
already does and for the reason it documents. The import becomes a once-only
event, so a deletion is durable. The undecodable rows stay in data.db (never
deleted) and are carried forward by a durable UnreadableIdentitiesWarning
record instead of by an import that retries until it decodes; recovering them
after a decoder fix is an explicit user gesture (#889), not an automatic retry
that costs a deletion.

The durable record is what makes that safe: with the sentinel written the pass
short-circuits and reports zero unreadable rows on every later launch, so the
banner is now published from storage rather than from pass counters.

That record also closes the second hole: the unreadable-identity banner was
sticky with no action button, so the user could be told their signing keys had
not come across and given no way to say "I understand". It now carries a "Got
it" action wired to a new AcknowledgeUnreadableIdentities task, mirroring the
vote flow. Acknowledgement deliberately does NOT double as the sentinel-writer
— hanging the loop-break on a user gesture would leave the resurrection bug
live for anyone who never clicks. The combined banner names both problems, so
its single acknowledgement retires both records.

Tests (both confirmed RED against the unfixed code):
- a_deleted_identity_is_not_resurrected_by_an_unreadable_sibling_row
- unreadable_identity_warning_is_republished_until_acknowledged
- a_second_launch_after_an_unreadable_identity_preserves_user_edits_and_deletions
  (rewritten: proves rename AND deletion survive on a real v0.9.3 database)
- reconciler + kittest coverage that the banner offers the acknowledgement and
  routes it to the right task

Two existing assertions demanded the withheld sentinel — the defect itself —
and were flipped to the corrected contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

* docs(migration): mark the legacy-identity design as shipped and name issue #889

The doc still introduced itself as "design, ready for implementation" against a
pre-implementation base commit, and its closing sections read as open questions,
long after PR #885 shipped every task in §9 and PR #891 landed the QA follow-ups.

- Header states the real status (shipped in #885, follow-ups in #891) and
  surfaces the known limitation up front.
- The §7 known-limitation follow-up now names issue #889 directly instead of
  pointing at "the GitHub issue referenced from PR #885".
- §11 (bincode feasibility spike) is framed as settled, pointing at the golden
  blob it produced (T-ID-06 / V093_MASTERNODE_BLOB_HEX).
- §12 is relabelled as historical design-review findings; the rationale table is
  kept, but it no longer masquerades as a live defect list.

* build(deps): document the deliberate bincode pin (RUSTSEC-2025-0141)

bincode is flagged unmaintained. The advisory is INFO-level and covers every
version, so no bump can clear it: 2.0.1 is the last functional release and 3.0.0
is a tombstone whose lib.rs is a bare `compile_error!`. bincode 1.3.3 also
arrives transitively, so dropping the direct dependency would not silence it
either.

The encoder writes on-disk wallet-secret envelopes and QualifiedIdentity blobs,
so swapping it changes the wire format of data users already hold. Record the
risk acceptance at the pin so the next reviewer does not re-flag it, and so
nobody "fixes" the warning with a bump that would be data-loss-class.

No version or lockfile change.

* fix(shielded): let the Verified badge name the balance it vouches for

The shielded badge maps every post-drain terminal state — including
FailedWithUnreadableIdentities — to Verified, and that mapping is right:
those states only fail passes (app data, identity rows) that run after the
wallet drain and never touch shielded storage, so the balance is as
authoritative as on Success. Downgrading it would lock shielded spends over
a corrupt vote row and claim a shielded failure that never happened.

What was wrong is the copy. "Verified." took its subject from its position
under the balance, so beside the red migration error banner it read as a
blanket "all good" — and handed a translator an adjective with no noun to
agree with, against the project i18n rule. It now names its subject:
"Shielded balance verified."

Also covers the three migration states the exhaustiveness test had missed
(SucceededWithUnreadableIdentities, SucceededWithUnreadableIdentitiesAndVotes,
FailedWithUnreadableIdentities) and records why the green badge under an error
banner is deliberate.

* test(migration): extract the legacy-identity fixture into database::test_helpers

The v0.9.3 identity fixture — table DDL, encodable blob, row INSERT — was
rebuilt in three modules, so a column added to the legacy shape had to be
chased through all of them. It now lives once in database::test_helpers, next
to the legacy wallet and scheduled-vote fixtures already shared from there:
create_legacy_identity_table, basic_legacy_identity_blob, and a
LegacyIdentityFixture builder that states only what a test varies.

Deliberately not merged, because they are not the same fixture:
- v093_upgrade keeps its verbatim v0.9.3 whole-database DDL (its both-or-
  neither wallet CHECK is the point of that module) and its keyed blob builder;
  only its row INSERT now routes through the shared builder.
- The shared DDL omits that CHECK on purpose — the import must survive a
  half-filled wallet link, and no test could stage one if SQLite rejected it.
- The minimal (id, network) identity table used by the top-up/vote scoping
  tests is a different shape and stays where it is.

Also folds the thrice-copied corrupt-row insert in finish_unwire's async tests
into one local helper, and types the fixture's status as IdentityStatus, which
retires v093's raw u8 status arguments (the consts stay as the on-disk
assertions they always were, now including Active).

* docs(legacy-import): state precisely what read_identities logs

The rustdoc promised that nothing about "the decoded identity" is ever logged
because it carries private keys, while the warn branches log the identity's id.
The code is right — an identity id is a public, on-chain handle, and it is what
lets a user tell which identity did not come across; the blob and its decoded
key material are never logged. Only the promise was imprecise, so it now draws
that line explicitly instead of over-claiming.

* fix(migration): name the Identities screen in the unreadable-identity banners

"Load these identities again" named neither a screen nor a control, so an
Everyday User who has never opened that flow had no way to act on it — the
repo's error-message rules require a concrete, self-serviceable action. All
three variants now point at Load Identity on the Identities screen, mirroring
how the vote copy already names the Scheduled Votes screen.

The kittest asserting every variant names both is the regression net: it fails
against the old copy.

* fix(migration): publish a terminal state for every migration failure

`migrate_app_data` propagated the `get_scheduled_votes()` error raw, so a k/v
read failure left `run()` returning a `TaskError` that was not `MigrationFailed`.
`run_migration_task` published `MigrationState::Failed` only for that one
variant, so such an error published nothing and stranded the status on
`Running` — where `run_backend_task` rejects every wallet-touching task with
`WalletStorageNotReady` and the banner offers no retry. That wedges wallets,
identities and sends until the app is restarted.

Type the app-data read into `MigrationError::AppDataImport`, and make the
publish total: `migration_error_chain` coerces any `TaskError` into the typed
`Arc<MigrationError>` chain (a stray error wraps in the new `Unexpected`
variant), so no error can skip the terminal state.

Also from the same review round:

- Drop the `wallet_known` closure seam from `migrate_identities_from_conn`: it
  could not change behaviour, only gate a `tracing::warn!`. The diagnostic moves
  to the caller's insert closure, which already holds the backend, so the
  `WalletBackendUnavailable` gate is unaffected.
- Collapse `write_sentinel` into `write_completion_sentinel`, now the sole
  writer of `MigrationCompletion`, with `network_count` as a parameter.
- `run()`'s "No pass gates another" was imprecise: the two DET-owned passes do
  not gate each other, but the wallet drain is a deliberate prerequisite for the
  identity import. Say so.
- Document why the identity check-and-insert needs no transaction: the migration
  gate serialises every production identity writer.

---------

Co-authored-by: Luka…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants