Feat/register usernames2 - #4
Merged
Merged
Conversation
QuantumExplorer
changed the base branch from
feat/register-usernames
to
master
October 15, 2024 19:01
QuantumExplorer
changed the base branch from
master
to
feat/register-usernames
October 15, 2024 19:01
thepastaclaw
added a commit
to thepastaclaw/dash-evo-tool
that referenced
this pull request
Feb 17, 2026
1. CRITICAL: Fix BitOrAssign dropping tasks in fetch_unresolved_profiles()
- Changed from action |= AppAction::BackendTask(task) loop (which only
kept the last task) to collecting all tasks into a Vec and dispatching
via AppAction::BackendTasks with Concurrent execution mode.
2. HIGH: Remove dummy [0x02; 33] pubkey in load_payment_history()
- Changed PaymentRecord.to_address from Address to Option<Address>
- Historical records loaded from DB now use None instead of generating
a fake-but-valid P2PKH address from a dummy public key.
3. HIGH: Fix i64 as u64 wrapping for amounts (payments.rs and dashpay.rs)
- Added bounds checking with warning logs for negative amounts,
clamping to 0 instead of silently wrapping.
4. MEDIUM: Eliminate duplicate payment history logic in dashpay.rs
- LoadPaymentHistory task handler now calls the shared
payments::load_payment_history() and converts results, instead of
reimplementing the same logic with different return types.
5. MEDIUM: Fix N+1 database queries in resolve_names_from_local_cache()
- Pre-load all contacts once before the loop and build a HashMap
for O(1) lookups instead of calling load_dashpay_contacts() and
load_dashpay_profile() per request inside the loop.
6. MEDIUM: Fix created_at: None breaking filters in contacts_list.rs
- Contacts from DashPayContactsWithInfo results now get current
timestamp as fallback instead of None, so they work with
'Recent' filter and 'Date added' sort.
7. LOW: Fix 'failed' as failure reason in payment status
- Changed PaymentStatus::Failed to use descriptive 'Transaction failed'
string instead of echoing the literal status column value 'failed'.
8. LOW: Fix i64 as u64 for timestamps in payments.rs
- Added bounds checking with warning for negative timestamps,
consistent with the amount fix.
9. LOW: Both i64 as u64 locations fixed (dashpay.rs duplicate removed
entirely by fix dashpay#4, payments.rs fixed by fixes dashpay#3/dashpay#8).
lklimek
added a commit
that referenced
this pull request
Feb 24, 2026
- Replace assume_checked() with require_network() for address validation (CodeRabbit #2) - Use styled Frame-with-dismiss error display matching Send dialog pattern (CodeRabbit #3) - Don't open dialog when no wallet selected; show MessageBanner instead (CodeRabbit #4) - Extract shared load_bip44_external_addresses() helper to eliminate near-duplicate code between mine and receive dialogs (CodeRabbit #5) - Add backend-side network guard (Regtest/Devnet) for defense-in-depth (CodeRabbit #6) - Rename shadowed ctx binding to refresh_ctx for clarity (CodeRabbit #7) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
lklimek
added a commit
that referenced
this pull request
Feb 24, 2026
… mode (#638) * feat(wallet): add Mine Blocks dialog for Regtest/Devnet dev mode Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add manual test scenarios for mine blocks dialog Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(wallet): close Mine Blocks dialog after Cancel/Mine click The dialog stayed open (in a broken state) after clicking Mine or Cancel because the local `open` variable was written back to `is_open` after the dialog state had already been reset. Pass `is_open` directly to egui's `.open()` and use a separate `close` flag for button-triggered dismissal. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(wallet): address audit findings for Mine Blocks dialog - Wrap `generate_to_address` in `spawn_blocking` to avoid blocking the async runtime thread (HIGH) - Replace `.expect()` on core client lock with `.map_err()?` for graceful error handling instead of panic (HIGH) - Cap block count at 1000 to prevent resource exhaustion on the Core node (HIGH) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(wallet): filter non-numeric input in Mine Blocks block count field Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(wallet): address review comments on Mine Blocks dialog - Replace assume_checked() with require_network() for address validation (CodeRabbit #2) - Use styled Frame-with-dismiss error display matching Send dialog pattern (CodeRabbit #3) - Don't open dialog when no wallet selected; show MessageBanner instead (CodeRabbit #4) - Extract shared load_bip44_external_addresses() helper to eliminate near-duplicate code between mine and receive dialogs (CodeRabbit #5) - Add backend-side network guard (Regtest/Devnet) for defense-in-depth (CodeRabbit #6) - Rename shadowed ctx binding to refresh_ctx for clarity (CodeRabbit #7) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(wallet): address remaining review comments on Mine Blocks dialog - Change MineBlocksSuccess(usize) to MineBlocksSuccess(u64) for type consistency with block_count parameter (Claudius #5) - Align dialog close pattern with Send/Receive: use local `open` variable for egui X button, reset state inside closure for Cancel/Mine buttons (Claudius #6) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
11 tasks
lklimek
added a commit
that referenced
this pull request
Feb 25, 2026
- Remove dead `_is_sync_operation` parameter from `Database::set_platform_address_info` and all call sites (Finding #6) - Add doc comment on `set_platform_sync_info` explaining column name drift: `last_platform_sync_checkpoint` now stores SDK sync height (Finding #4) - Remove trivial `set_platform_address_info_from_sync` delegate and update callers to use `set_platform_address_info` directly (Finding #7) - Combine unnecessary `let provider` + `let mut provider = provider` rebinding into single `let mut` block (Finding #10) - Document UTXO selection race window on `broadcast_and_commit_asset_lock`: std::sync::RwLock guard is !Send so it cannot span async broadcast Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
lklimek
added a commit
that referenced
this pull request
Feb 25, 2026
…rmSyncMode (#635) * refactor(wallet): simplify platform sync by removing PlatformSyncMode Remove the PlatformSyncMode enum (Auto/ForceFull/TerminalOnly) and terminal sync logic (apply_recent_balance_changes, last_terminal_block, last_full_sync_balance). The SDK now handles incremental sync internally via AddressProvider::current_balances() and last_sync_height(). Key changes: - Remove PlatformSyncMode enum from backend_task::wallet - Simplify fetch_platform_address_balances to use new SDK API with stored state (with_stored_state, current_balances, last_sync_height) - Change CoreTask::RefreshWalletInfo to use bool instead of Option<PlatformSyncMode> - Remove last_full_sync_balance from PlatformAddressInfo - Simplify database sync info to 2-tuple (timestamp, height) - Remove set_last_terminal_block from database - Simplify RefreshMode enum (remove PlatformFull, PlatformTerminal, CoreAndPlatformFull, CoreAndPlatformTerminal variants) Note: requires updated dash-sdk with new sync_address_balances API. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: update platform SDK to rev 0fa82e6652 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add manual test scenarios for platform sync simplification Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(wallet): address PR #635 audit findings and extract broadcast helper - DB schema v28: drop obsolete columns (last_terminal_block, last_full_sync_balance), rename last_platform_sync_checkpoint → last_platform_sync_height, with SQLite ≥3.35 runtime check - Store asset lock TX before broadcast to prevent SPV InstantSend race - Defer UTXO removal until after successful broadcast - Replace .unwrap() on RwLock with .map_err() to avoid panics - Remove unused _is_sync_operation param and set_platform_address_info_from_sync wrapper - Fix redundant let-mut rebinding in fetch_platform_address_balances - Extract broadcast_and_commit_asset_lock() on AppContext to consolidate the store→broadcast→cleanup→UTXO-removal pattern from 5 code paths Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor(db): schema v28 — drop obsolete sync columns and clean up DB interface (#653) - Bump DEFAULT_DB_VERSION 27 → 28 - Drop last_terminal_block from wallet table (unused after sync simplification) - Drop last_full_sync_balance from platform_address_balances table - Rename last_platform_sync_checkpoint → last_platform_sync_height - Add runtime SQLite ≥3.35 check (required for DROP COLUMN) - Idempotent migration: checks column existence before each ALTER - Remove unused _is_sync_operation param from set_platform_address_info() - Remove set_platform_address_info_from_sync() wrapper - Fix redundant let-mut rebinding in fetch_platform_address_balances Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * revert(db): remove schema v28 migration from this branch The v28 schema changes (drop obsolete sync columns, rename last_platform_sync_checkpoint → last_platform_sync_height) will be applied separately and should not ship on this branch. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR review comments — remove dead code and document risks - Remove dead `_is_sync_operation` parameter from `Database::set_platform_address_info` and all call sites (Finding #6) - Add doc comment on `set_platform_sync_info` explaining column name drift: `last_platform_sync_checkpoint` now stores SDK sync height (Finding #4) - Remove trivial `set_platform_address_info_from_sync` delegate and update callers to use `set_platform_address_info` directly (Finding #7) - Combine unnecessary `let provider` + `let mut provider = provider` rebinding into single `let mut` block (Finding #10) - Document UTXO selection race window on `broadcast_and_commit_asset_lock`: std::sync::RwLock guard is !Send so it cannot span async broadcast Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * style: apply nightly fmt Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2 tasks
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
Critical #4: register_with_platform_wallet_manager called db.set_wallet_id(&wallet_id, &wallet_id) — passing wallet_id for both the seed_hash (PK lookup) and wallet_id (value to set). The SQL WHERE seed_hash = ?2 matched 0 rows because wallet_id != seed_hash. Root cause: the bulk rename changed the parameter name from seed_hash to wallet_id, but the VALUE is still the real seed_hash (from wallet_seed_snapshot). Renamed parameter back to seed_hash for clarity. Now correctly passes seed_hash for the PK lookup and platform_wallet.wallet_id() for the value. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
4 tasks
lklimek
added a commit
that referenced
this pull request
Jun 24, 2026
…secret at-rest encryption (#865) * docs(secret-seam): Phase-1 design artifacts (UX disclosure + test case spec) UX disclosure spec by Diziet; 30-case TDD test spec by Marvin. Design reference for the secret-storage raw-SecretBytes seam re-architecture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * feat(wallet-backend): add raw-SecretBytes secret seam + typed errors (T2,T4) Crikey, here's the one socket every wallet secret will squeeze through. T2 — new wallet_backend/secret_seam.rs: SecretSeam over raw SecretBytes with put_secret/get_secret/delete_secret, a no-encryption pass-through to the upstream vault TODAY. Every put/get body carries the greppable `TODO(per-secret-encryption):` tag so wiring real per-secret encryption later is a localized change. Prompt-free — the passphrase requirement lives only in the retained legacy readers, never here. No-serialization guard mechanism: compile_fail doctests (no new deps — static_assertions/trybuild stay out of Cargo.toml). One asserts a newtype cannot derive Serialize over a SecretBytes; one asserts serde_json::to_string on a SecretBytes is rejected. If upstream ever adds Serialize to SecretBytes these start compiling and the canary fires (TS-INV-01). TS-INV-02 round-trips a SecretBytes through the real signatures (compiler is the assertion). T4 — TaskError variants (no String fields, typed #[source]): SecretSeam, SecretSeamMissing (loud funds-safety miss), IdentityKeyVault, IdentityKeyMissing. Promote the private assert_no_leak (hex + decimal-array) into a shared wallet_backend/leak_test_support.rs so the seam/sidecar/QI/Debug leak cases reuse one impl instead of copy-pasting. TS-NOLEAK-01: the on-disk vault file holds no raw secret in either form. Tests: 6 seam unit + 2 compile-fail doctests, all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * fix(model): redacting Debug for ClosedSingleKey (T9, 6a2818cd) ClosedSingleKey derived Debug and its encrypted_private_key holds the raw 32 key bytes in the no-password / pre-migration shape — a derived Debug dumped them as a decimal byte array straight into logs. Hand-write a redacting Debug mirroring ClosedKeyItem / SingleKeyEntry: key_hash + lengths, never the bytes. Parents SingleKeyData / SingleKeyWallet are safe by delegation. TS-DBG-01 asserts via the shared assert_no_leak_bytes (hex AND decimal-array — the decimal form is the one the pre-fix Debug leaked) at all three levels. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * feat(model): PrivateKeyData::InVault placeholder + migration probes (T1) Identity private keys get a non-resident home. New PrivateKeyData::InVault appended at bincode index 4 — discriminants 0-3 (AlwaysClear/Clear/Encrypted/ AtWalletDerivationPath) are untouched, so blobs written before it still decode (TS-RESID-02 round-trips all four pre-existing variants + InVault). Redacting Debug/Display arms (carries no bytes — trivially clean). KeyStorage probes: - is_in_vault / public_key_for — a vault placeholder reports true yet still surfaces its public key for display + signing-key selection. - take_plaintext_for_vault — rewrites every Clear/AlwaysClear to InVault and returns the raw bytes (Zeroizing) the migration must store in the vault FIRST (vault-before-blob order). Wallet-derived + encrypted keys untouched — they were never plaintext-at-rest. get/get_resolve_local gain an InVault arm (resolve through the vault, not locally). key_info_screen gains degraded InVault arms (securely-stored notice; full JIT view/sign via dedicated identity-key WalletTasks is the T8 follow-up). Promote the private assert_no_leak + distinctive_secret to the shared leak_test_support helper (no fork). TS-RESID-01 / TS-NOLEAK-03: post-migration KeyStorage has only InVault, and the re-encoded blob leaks neither secret in hex nor decimal-array form. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * feat(model,wallet-backend): WalletMeta+ImportedKey sidecar fields, schema-gated (T5) Non-secret metadata moves out of the per-wallet seed envelope into the sidecar. WalletMeta gains uses_password + password_hint. Because WalletMeta is positional bincode behind the DetKv envelope, #[serde(default)] alone is NOT forward-compatible (R-SCHEMA) — so a real version gate: WALLET_META_VERSION (v2) framed as [version | bincode] at the WalletMetaView boundary, plus a retained decode-only WalletMetaV1. decode_versioned detects v2 / v1-framed / bare-legacy and migrates a v1 blob into v2 (defaults uses_password=false), never positionally misparsing it. The global DetKv SCHEMA_VERSION is deliberately untouched (it governs every payload, not just WalletMeta). TS-META-01 covers all three shapes. ImportedKey gains public_key_bytes (the compressed SEC1 PUBLIC key) so the locked-render cold-boot path can rebuild a protected key's display wallet without the secret — moved out of the SingleKeyEntry vault blob ahead of the raw-seam migration. NON-secret; #[serde(default)] for old entries. write_wallet_meta now carries uses_password/password_hint from the open Wallet; the legacy-table drain (finish_unwire) defaults them (the authoritative flag is read from the envelope at the migrating unlock). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * chore(wallet-backend): satisfy fmt + clippy for the secret-seam batch - leak_test_support: drop redundant inner #![cfg(test)] (mod.rs already gates it). - encrypted_key_storage: factor take_plaintext_for_vault's return into the VaultBoundKey type alias (clippy::type_complexity). - wallet_hydration bench: carry the new WalletMeta password fields. - nightly-fmt whitespace. Gate: cargo +nightly fmt --all clean; cargo clippy --all-features --all-targets -D warnings clean; cargo test --all-features --workspace = 944 lib + 146 + 10 + 3 + 2 pass, 0 fail; 2 compile_fail doctests pass; det-cli standalone smoke (network-info / tools / core-wallets-list) all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * feat(wallet-backend): SecretScope::IdentityKey + seam-first SecretAccess (T3) The chokepoint learns identity keys and goes seam-first for everyone. - SecretScope::IdentityKey { identity_id:[u8;32], target, key_id } (DET-opaque; KeyID is just u32, PrivateKeyTarget is a DET model enum). identity_key_label() builds identity_key_priv.<m|v|o>.<key_id> — a stable one-char target tag keeps the label inside the upstream allowlist. - SecretPlaintext::IdentityKey + expose_identity_key; Plaintext::IdentityKey. Borrowed-only, zeroizing, never resident — same hygiene as the other kinds. - decrypt_jit is now SEAM-FIRST for all three classes: the raw label wins; the retained legacy reader (decrypt_hd_seed / SingleKeyEntry::decrypt) is the migration fallback for HD seeds and single keys. IdentityKey reads raw via the seam → loud IdentityKeyMissing if absent (never silent). - scope_has_passphrase: a migrated raw secret reports false (the password no longer gates it); only a not-yet-migrated legacy entry can still be protected; IdentityKey is always false → prompt-free fast-path → headless/MCP signing works. - DetSigner treats an IdentityKey plaintext as a raw single key (same secp256k1 shape, no derivation tree). Tests: TS-FAST-01 (identity key resolves prompt-free, ask_count 0, can_resolve_without_prompt true), IdentityKeyMissing is loud, TS-LEGACY-01 (legacy envelope served when raw absent), raw-wins-over-legacy precedence. The pre-existing protected-HD/single-key tests now exercise the legacy fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * feat(wallet-backend): identity_key_store + seed/single-key seam-raw writes (T6) Secrets start landing raw. No DET envelope for the new write paths. - New wallet_backend/identity_key_store.rs: IdentityKeyView with store/get/delete + store_all/delete_all over raw 32 bytes via SecretSeam (scope = identity_id, label identity_key_priv.<m|v|o>.<key_id>). NO StoredIdentityKey envelope — the InVault marker in the QI blob is the only on-disk trace. store_all is the migration's vault-first writer (call before the blob rewrite); delete_all backs purge_identity_scope. - WalletSeedView gains set_raw/get_raw/delete_raw (raw 64-byte seed under seed.raw.v1 via the seam) + legacy_envelope_get (retained decode-only reader). - write_seed_envelope now branches: a no-password wallet writes the RAW seed (encrypted_seed_slice() is verbatim the seed); a password wallet keeps the legacy AES-GCM envelope at creation and migrates lazily at unlock (T7). - import_wif_with_passphrase: unprotected import writes RAW 32 bytes under the existing single_key_priv.<addr> label (no SingleKeyEntry framing); protected import keeps the legacy SingleKeyEntry (lazy-migrates at unlock). The locked-render pubkey rides in the ImportedKey sidecar (the T5 field). SingleKeyEntry::decode treats a bare 32-byte blob as unprotected, so a raw-written key still rebuilds + opens at cold boot. Tests: identity_key_store round-trip / scope+target isolation / store_all+ delete_all; seed raw round-trip independent of the legacy label; single-key unprotected import is exactly 32 raw bytes (no framing) and signs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * feat: crash-safe dual-format migration + InVault resolver + vault delete (T7) This is the part that actually moves secrets. Funds-safety ordering throughout. Resolver (mod.rs): resolve_private_key_bytes gains the InVault route — keyed by is_in_vault/public_key_for, it fetches the raw bytes per-use via with_secret(IdentityKey{...}) (prompt-free). No chokepoint wired ⇒ fail closed (WalletLocked); bytes never resident. EAGER migration on load (dialog-free): - Identity keys (identity_db::migrate_identity_keys_to_vault, run per identity in load_identities_filtered): take_plaintext_for_vault → IdentityKeyView store_all (vault FIRST) → rewrite the QI blob with InVault. Vault-write failure restores the resident plaintext for this session and defers; a blob-rewrite failure is re-detected and retried next load. Idempotent. - No-password HD seeds (hydration::reconstruct_wallet): raw seam wins (precedence raw > legacy); a no-password legacy envelope is re-stored raw (set_raw, vault FIRST) then deleted. reconstruct_from_envelope extracted so the raw and legacy paths share the xpub-decode + build tail. LAZY migration on unlock (one prompt, the unlock the user already does): promote_and_maybe_migrate_hd_seed re-stores the just-decrypted legacy seed raw (set_raw before delete) inside the borrowed Zeroizing scope and reports migrated=true; handle_wallet_unlocked then flips WalletMeta.uses_password=false and shows the one-time disclosure (T8 Copy A/D). Delete: forget_wallet_local_state now deletes BOTH the raw seed and the legacy envelope (a wallet may be in either form) — closes a wipe gap where a migrated no-password seed would survive removal. identity_db.clear_identity_vault_keys drains an identity's raw vault keys on single-delete + devnet sweep. Loud, never silent: a seed in neither form ⇒ TaskError::SecretSeamMissing (was WalletNotFound) on both scope_has_passphrase and decrypt_jit. Tests: TS-EAGER-01/04 (no-pw seed migrates + idempotent), TS-CRASH-01 read (raw wins, legacy cleaned), TS-MISS-01 (SecretSeamMissing loud). Updated 5 wallet_lifecycle removal/clear tests to assert the raw seed (the new at-rest form) in BOTH precondition and post-delete. wallet_lifecycle 38, hydration 10, identity_db 16, encrypted_key_storage 4 — all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * feat: key_info_screen JIT identity signing + single-key Copy B disclosure (T8) Real JIT for vault-backed identity keys, and the per-key migration notice. Two new WalletTasks + handlers, opening with_secret(IdentityKey{...}): - DeriveIdentityKeyForDisplay → derive_identity_key_for_display: fetches the raw key JIT, returns only the WIF (Secret). - SignMessageWithIdentityKey → sign_message_with_identity_key: signs in the backend, returns only the public Base64 envelope. New result variants IdentityKeyForDisplay / IdentityMessageSigned (identity- flavored — carry identity_id/target/key_id, not a meaningless seed_hash). key_info_screen: the InVault arms are now real — "View Private Key" queues DeriveIdentityKeyForDisplay and renders the returned WIF/hex via the existing render_decrypted_key_grid; "Sign" queues SignMessageWithIdentityKey. The degraded placeholders are gone. display_task_result handles both new results. Single-key protected lazy migration + Copy B: verify_passphrase now re-stores the just-decrypted protected entry raw under the same label (upsert replaces the AES-GCM framing) and clears the persistent has_passphrase flag, returning a migrated bool. verify_single_key_passphrase surfaces the one-time per-key disclosure (Copy B — text DISTINCT from the wallet Copy A so set_global's dedup keeps both) on migration. decrypt_jit's sign path also lazy-migrates (migrate_single_key_to_raw + in-memory flag flip) — idempotent defense-in-depth. SingleKeyView::clear_passphrase_flag persists the flip to the sidecar. Tests: TS-LAZY-03 — protected single key migrates via the chokepoint, the vault holds raw 32 bytes after, and a second resolve under a never-prompt host is prompt-free with the WIF-plaintext bytes. secret_access 24 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * chore: fmt + clippy for the T3-T8 integration batch - secret_access: drop explicit_auto_deref on set_raw(seed_hash, seed) — a &Zeroizing<[u8;64]> auto-derefs to &[u8;64]. - nightly-fmt whitespace across the touched files. Gate: cargo +nightly fmt --all clean; cargo clippy --all-features --all-targets -D warnings clean; cargo test --all-features --workspace = 957 lib + 146 + 10 + 3 + 2 pass, 0 fail, 1 ignored (funded-testnet TS-SIGN-E2E-01); 2 compile_fail doctests pass; det-cli standalone smoke (network-info / core-wallets-list / tools) all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * fix(wallet-backend): dual-format read for WalletMeta + ImportedKey sidecars The real defect QA caught (PROJ-001/002/003 + SEC-003): appending fields to a positional-bincode DetKv value is format-breaking, and my T5 framing made it WORSE — WalletMeta writes went through kv.put::<Vec<u8>>(versioned-frame) and reads through kv.get::<Vec<u8>>, which type-confuses an OLD kv.put::<WalletMeta> blob (decodes the alias's UTF-8 bytes AS the Vec) → alias/is_main silently lost. ImportedKey appended public_key_bytes with no legacy reader → old keys vanish from the picker. Fix (one policy for both sibling sidecars): drop the hand-rolled version byte (SEC-003: it could collide with a bincode length varint — a 1/2-char alias). Instead lean on the DetKv schema envelope + try-decode-both: - write the current shape directly (kv.put::<WalletMeta> / ::<ImportedKey>); - on read, try the current shape; on a bincode Decode error (an old blob runs out of bytes for the appended fields) fall back to the legacy shape (WalletMetaV1 / ImportedKeyV1, decode-only) and RE-STORE in the new shape. Order is load-bearing and tested: the 6-field struct CANNOT decode a 4-field blob (runs past end), so "new first, then V1" never mis-promotes. A DetKv schema-version mismatch stays a hard error; only Decode triggers the fallback. Removes the now-dead encode_versioned/decode_versioned/WALLET_META_VERSION (PROJ-002 — the unreachable legacy branch + its overclaiming test are gone; the legacy path is now live via the view and tested end-to-end). Tests: model leg (ts_meta_01) asserts the order-sensitivity + the SEC-003 1/2-char-alias collision case; view legs (old_wallet_meta_blob_*, old_imported_key_blob_*) write an OLD blob exactly as the base branch did, read it back through the view preserving every field, and confirm re-store in the new shape. wallet::meta 3, wallet_meta 13, single_key all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * test(identity-db): identity-key migration, deletion, write-fault no-loss (QA-002/003/005) Refactor the eager identity-key migration core out of AppContext into a free fn migrate_keystore_to_vault(secret_store, id, qi, persist) returning a KeystoreMigration outcome, so the funds-safety logic is unit-testable with a bare SecretStore + a controllable persist closure (no full AppContext). QA-002 — migration is vault-FIRST: the persist closure asserts the raw keys are already in the vault and the blob being persisted is InVault-only; the AtWalletDerivationPath key is untouched; zero plaintext remains; idempotent (second run = Nothing). QA-005 — write-fault no-loss (the write half CRASH-01's read half misses): with the vault parent dir chmod'd read-only so store_all fails, the migration restores the resident plaintext keystore byte-for-byte, does NOT call persist, and reports VaultWriteFailed — keys never lost on a mid-write fault. (#[cfg(unix)].) QA-003 — identity-key deletion is scoped + isolated: delete_all over the victim's (target,key_id) set removes its vault keys while a second identity's key under the same (target,key_id) is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * test(wallet-lifecycle): assert lazy-migration secret post-conditions (QA-004) The protected-wallet-unlock test asserted only upstream registration. Add the secret post-conditions the lazy migration is actually for: after handle_wallet_unlocked the raw seed is written and equals the true 64-byte seed, the legacy envelope.v1 is deleted, WalletMeta.uses_password flipped false, and a SECOND resolve through a never-prompt chokepoint over the now-raw vault returns the seed with zero prompts (the migrated wallet is permanently prompt-free). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * test(backend-e2e): TS-SIGN-E2E-01 InVault identity signs + broadcasts (QA-001) New #[ignore] backend-e2e test: migrate the shared identity's plaintext signing keys to the vault (PrivateKeyData::InVault, exactly as the eager load-path migration does), assert residency (zero Clear/AlwaysClear remain), wire the chokepoint, then build + sign + broadcast an IdentityUpdateTransition. Signing runs through the async QualifiedIdentity Signer → resolve_private_key_bytes → with_secret(IdentityKey{..}) — the JIT free-rider path. A successful broadcast + the new key appearing on Platform proves the InVault MASTER key signed live without ever being resident. Requires E2E_WALLET_MNEMONIC + live DAPI/SPV; run command + RUST_MIN_STACK in the header. Compiles + registered in main.rs; left #[ignore] for a manual/live run during QA. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * refactor(wallet-backend): zeroize migration source, flavor identity-key errors, lift signed-message helper PROJ-004 (security): take_plaintext_for_vault now zeroizes the resident Clear/AlwaysClear array BEFORE the InVault overwrite drops it — de-residenting the key is the function's whole purpose, so it must wipe the source, not just the moved-out copy. PROJ-005: IdentityKeyView::store/get/delete now map the generic seam error to the identity-flavored TaskError::IdentityKeyVault (previously a producerless variant), so an identity-key vault failure surfaces with identity-specific banner copy. Wrong-length stays SecretDecryptFailed. QA-DEDUP-01: lift dash_signed_message (the recoverable-envelope builder) from sign_message_with_key.rs to backend_task/wallet/mod.rs as pub(crate); both the wallet-key and identity-key signers now call it instead of two drifting copies. The recovery-header round-trip tests move alongside the shared helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * test(secret-seam): TS-INV-03 audit guard + TS-NOLEAK-02 sidecar no-leak (SEC-001/002) SEC-001 (TS-INV-03): source-text audit over the changed secret-path modules — no Serialize/Encode struct may name a plaintext-key field (SecretBytes, Zeroizing<[u8, [u8;32], [u8;64]). Catches the bare-Vec/array plaintext bypass the compile_fail doctests can't (they only catch an embedded SecretBytes). The module list mirrors the blast-radius table; ciphertext fields are deliberately not flagged. Passes — the invariant holds today and now has a regression guard. SEC-002 (TS-NOLEAK-02): assert the encoded WalletMeta + ImportedKey sidecar blobs contain neither secret (hex AND decimal-array via the shared assert_no_leak_bytes), and that the ImportedKey's PUBLIC key IS present (locked render needs it). Canary coverage — the sidecars structurally hold no secret. Plus a clarifying "// no secret to (de)crypt" note at delete_secret instead of an encryption TODO. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * test(kittest): disclosure-banner copy coverage (QA-007/Diziet) Extract the interim at-rest disclosure copy into pure pub fns (wallet_migration_notice / single_key_migration_notice) + pub INTERIM_AT_REST_DETAILS, re-exported from context, so the exact copy is testable without an AppState and i18n-extractable. Both callsites now use them. New tests/kittest/disclosure_banner.rs (QA-007): Copy A and Copy B each render as Warning banners naming the wallet/key, the ⚠ icon shows (not color-only), the two copies are DISTINCT (so set_global's text-dedup keeps both when a wallet and a key migrate in one session), and all copy (A/B/D) is jargon-free (no AES/vault/seam/encryption/0600). 4 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * docs: comment hygiene + CLAUDE.md seam pointer + user-story softening (QA-DOC/DOC) QA-DOC-01: strip ephemeral review IDs from comments I authored in the secret-seam surface — "Smythe must-fix #3/#4/#5", "Q-HEADLESS", "(F-2)", "6a2818cd" — keeping the rationale prose. (Pre-existing PROJ-010/TC-W-*/F43/F63 in code outside this PR's diff are left untouched to avoid scope creep.) QA-DOC-02: drop the "Promoted from…" history line in leak_test_support.rs (belongs in git, not the module header). QA-DOC-03: secret_access module-header resolution order now lists the unprotected fast-path as an explicit step 2 (cache → unprotected → prompt), matching the three-branch body. DOC-001: CLAUDE.md wallet_backend bullet now points at secret_seam.rs as the single secret chokepoint + the TODO(per-secret-encryption): grep convention + the design dir. DOC-002: user-stories WAL-006 gains the post-migration no-password-prompt note; WAL-025 "modern encrypted vault" → "on-device secret vault" (no longer asserts encryption that is presently absent — the accepted interim regression). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * chore: nightly fmt for the QA-findings batch Whitespace-only reformat (cargo +nightly fmt --all) of the files touched while closing the QA findings. No behavioral change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * test(backend-e2e): seed Clear key so TS-SIGN-E2E-01 exercises the InVault JIT path The shared_identity() fixture registers a wallet-derived identity, so its keys are PrivateKeyData::AtWalletDerivationPath and take_plaintext_for_vault() (which migrates only Clear/AlwaysClear) correctly found nothing — the test panicked in setup before reaching the path under test. Add materialize_master_key_as_clear(): derive the master key's raw bytes from the HD seed through the real with_secret(SecretScope::HdSeed) chokepoint (identity index 0, key 0) and insert_non_encrypted() them as Clear, so the migration carries a genuine plaintext key into the vault as InVault and the JIT signing path produces a signature whose bytes match the on-chain master key. The !taken.is_empty() assertion is unweakened; no signer stub, no mocked broadcast. Stays #[ignore]: the live broadcast additionally needs a funding wallet that derives within its rehydrated window (the e2e funding step hit the known core-wallet gap-window/rehydration limitation, unrelated to the InVault path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * chore(deps): repin platform deps to feat/platform-wallet-secret-protection (fb7953ea) Moves the 4 dashpay/platform branch deps (dash-sdk, rs-sdk-trusted-context-provider, platform-wallet, platform-wallet-storage) — and their 23 transitive platform crates, 27 total — from fix/wallet-core-derived-rehydration@ea0082e6 to feat/platform-wallet-secret-protection@fb7953ea (PR #3953), establishing the green baseline for the secret-handling-hardening work. Done on top of the merge of origin/docs/platform-wallet-migration-design (ac0c3d9), which brought in #864 (headless masternode/evonode withdrawals) and #866 (DPNS blocking overlay). The merged DET tree compiles cleanly against the secret-protection branch — no API breakage. Verified green: cargo build --all-features cargo clippy --all-features --all-targets -- -D warnings cargo +nightly fmt --all -- --check Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(secret): open the vault keyless (file_unprotected) for the Tier-1 baseline PR #3953 ("platform-wallet-secret-protection") hardened upstream `SecretStore::file(path, passphrase)` to reject a blank passphrase (`SecretStoreError::BlankPassphrase`). DET's `open_secret_store` opened the vault with `SecretString::new("")`, so after the repin every AppContext init failed at the secret-store open and 7 secret_seam/secret_access tests broke. Switch to the explicit keyless door `SecretStore::file_unprotected(path)`, which upstream documents for exactly this model: the vault file itself is keyless (at-rest floor = owner-only perms) and per-secret confidentiality comes from Tier-2 object passwords on the individual secrets. Behavior for the Tier-1 baseline is unchanged from the old empty-passphrase open. Restores the green baseline at the fb7953ea pin: build/clippy/fmt clean, the 8 secret_seam/secret_access vault tests pass again. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(secret): add Tier-2 seam capability (protected set/get + scheme probe) Adds the upstream Tier-2 object-password path to the secret seam, the single coherent encrypt/decrypt chokepoint: - `put_secret_protected` / `get_secret_protected` seal/unseal a secret under its OWN object password via upstream `SecretStore::set_secret/get_secret` (Argon2id + XChaCha20-Poly1305). Per-secret, never a shared/per-wallet pw. - `scheme()` reports the at-rest tier (Absent / Unprotected / Protected) of a stored secret WITHOUT the password, via a `get(None)` probe that reads the upstream `NeedsPassword` signal. - The plain `*_secret` methods stay Tier-1 (unprotected) and are documented as such; the 3 `TODO(per-secret-encryption)` markers are resolved — the per- secret encryption IS the upstream envelope selected by the password arg. Additive and behavior-preserving: existing Tier-1 callers are unchanged; the read/migration wiring in SecretAccess lands next. Build/check + the 8 secret_seam/secret_access tests stay green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(secret): adopt Tier-2 per-secret passwords for HD seeds Routes HD-seed at-rest crypto through the upstream Tier-2 object-password envelope instead of DET AES-GCM, KEEPING protection rather than downgrading a password-protected seed to a raw, password-free secret on first unlock. - `WalletSeedView` gains `scheme()` / `set_protected()` / `get_protected()`: a protected seed lives at the `seed.raw.v1` label as a Tier-2 envelope (Argon2id + XChaCha20-Poly1305) sealed under that seed's OWN object password; an unprotected seed stays Tier-1 raw. - `scope_has_passphrase` + `decrypt_jit` are now scheme-driven (via the seam `get(None)` `NeedsPassword` probe): Unprotected → raw, no prompt; Protected → unseal with the JIT-prompted per-seed password; Absent → decode the legacy AES-GCM envelope (decode-only reader) and LAZY re-wrap to Tier-2 (protected) or raw (unprotected), then drop the legacy envelope. Crash-safe: re-store upserts before the legacy delete; the scheme probe prefers the new label. - `promote_and_maybe_migrate_hd_seed` no longer downgrades; it reports "no downgrade" so the unlock callsite's `uses_password=false` finalizer never fires — protection is kept and the metadata stays accurate, with no change to `wallet_lifecycle.rs`. - `is_wrong_passphrase` now also catches the upstream `WrongPassword` so a Tier-2 unseal with a bad object password re-prompts instead of aborting. Per-SECRET model: the session cache is plaintext keyed by `SecretScope`, so remembering seed A never satisfies seed B — each prompts and decrypts only with its own password. Tests: lazy re-wrap keeps protection (legacy gone, raw read of a protected seed fails), Tier-2 wrong-password re-ask, and the A/B different-password isolation. 72 secret tests pass; clippy/fmt green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(secret): clean keep-protection replacement of the downgrade subsystem (HD seed) Supersedes the transitional "inert return" approach with a clean excision of #865's downgrade-to-raw machinery, now that wallet_lifecycle.rs is editable (user WIP stashed). Protected HD seeds STAY protected (Tier-2 object password); nothing downgrades them to a raw, password-free secret. - `wallet_lifecycle.rs`: remove `finish_lazy_seed_migration` (the `uses_password=false` downgrade flip + the "protection removed" notice) and collapse the two `promote_*` methods into one `promote_hd_seed_with_passphrase` (decrypt + cache) — the lazy re-wrap lives in `decrypt_jit`. The unlock callsite no longer finalizes a downgrade. - `finish_unwire::migrate_wallet_meta`: carry the legacy `wallet.uses_password` / `password_hint` into `WalletMeta` (it was defaulting `false`). The persisted flag is now accurate from cold-start (`true` for a protected wallet) and always agrees with the at-rest scheme — no stale/drift-prone metadata. - `protected_wallet_registers_..._on_unlock` acceptance test rewritten to the keep-protection end-state: after the migrating unlock the seed is Tier-2 (scheme=Protected), a raw read fails, `WalletMeta.uses_password` stays true, and a second resolve prompts for the object password. 1009 lib tests pass; clippy -D warnings + fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(secret): adopt Tier-2 keep-protection for imported single keys Extends the Tier-2 keep-protection model from HD seeds to imported single keys, replacing their downgrade-to-raw migration. A protected imported key STAYS protected under its own object password instead of being re-stored raw. - `decrypt_jit` / `scope_has_passphrase` (SingleKey) are scheme-driven (seam `get(None)` → `NeedsPassword` probe): Protected → unseal with the JIT-prompted per-key password; Unprotected → a migrated raw-32 key wins prompt-free, else the not-yet-migrated legacy `SingleKeyEntry` blob's `has_passphrase` decides; the in-band length-32 check disambiguates raw vs legacy-framed. - `migrate_single_key_to_raw` → `migrate_single_key_to_tier2`: lazy re-wrap the just-decrypted protected key to a Tier-2 envelope under the same password (upsert replaces the AES-GCM framing). `has_passphrase` is NOT flipped — protection is kept and the index/persisted flag stay accurate. - `single_key::verify_passphrase` (the unlock-gesture path): re-wraps to Tier-2 instead of downgrading to raw; returns `()` (no migration bool). The `clear_passphrase_flag` finalizer is removed. Downgrade-disclosure machinery retired (Tier-2 keeps protection, nothing to disclose): removed `show_single_key_migration_notice` + the `wallet_migration_notice` / `single_key_migration_notice` / `INTERIM_AT_REST_DETAILS` copy + their re-exports, and the obsolete `tests/kittest/disclosure_banner.rs`. Tests: `ts_lazy_03` rewritten to the keep-protection end-state (vault holds a Tier-2 envelope, password-free read fails, second resolve prompts). 1009 lib tests pass; clippy -D warnings + fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(secret): address Smythe Tier-2 review findings (SEC-001/002/004/005) Smythe verdict on the Tier-2 adoption: SOUND, 0 Critical/High (it closes a prior HIGH-grade protected-seed downgrade-to-obfuscation). Folds in the carry-forward findings (SEC-003 — excise the inert downgrade — already landed in 6dafbda): - SEC-001 (LOW): GC an orphaned legacy `envelope.v1`. The seed Protected read branch (`decrypt_jit`) now best-effort `view.delete(seed_hash)` so an `envelope.v1` left behind by a crash/delete-failure during the re-wrap (which still decrypts under the seed's OLD password) cannot survive forever — the Absent branch, the only other deleter, is never re-entered once Protected. The single-key path migrates in-band (same-label upsert) and has no such orphan. - SEC-004 (LOW): assert the NEGATIVE crypto property. `ts_t2_03` (seed) and the new `ts_t2_sk_iso` (single key) now prove A's object password is REJECTED by B's envelope (`WrongPassword`) — the upstream per-object-salt + AAD binding — not merely that the DET cache is scope-keyed. - SEC-002 (MEDIUM, doc): record loudly that the keyless `file_unprotected` vault is "obfuscation, not confidentiality" for Tier-1 secrets (no-password seeds, raw single keys, identity keys rest on file perms ALONE; only Tier-2 object passwords give real at-rest confidentiality). Documented at `open_secret_store`, reworded `ts_noleak_01` (proves non-literal-plaintext, NOT confidentiality), and in the design note's threat-model residual. - SEC-005 (info): one-line note in `seed_envelope.rs` — the legacy reader is decode-only / local owner-only vault, uses bincode 2.x; the RUSTSEC-2025-0141 bincode 1.3.3 is a transitive dep. No code change. 1010 lib tests pass; clippy -D warnings + fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(migration): note the wallet.uses_password/password_hint schema invariant Smythe's schema-robustness query on `migrate_wallet_meta`'s new SELECT (it reads `uses_password`/`password_hint` unprobed, unlike the probed optional `core_wallet_name`). Verified + documented the invariant rather than adding a needless probe: the wallet-seed migration (`migrate_wallet_seeds_rows_from_conn`) already SELECTs both columns unconditionally and runs FIRST over the same `wallet` table at the same cold-start, so any schema lacking them fails there before the meta pass. The unprobed read here is therefore exactly as robust as the shipped seed migration; `core_wallet_name` stays probed because it is the one droppable column. Comment-only — 1010 lib tests pass, clippy -D + fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(test): eliminate register_wallet_from_seed race in cold-boot test The `ensure_identity_funding_accounts_succeeds_on_cold_booted_watch_only_wallet` test failed in CI (1000+ parallel tests) with: WalletBackend { source: WalletNotFound("70dba4c1d8c5c3854aa02c8f15e0fcd66df6661841d7ae822891fa21aaef48d2") } Root cause: the test wired the backend BEFORE calling register_wallet, which caused register_wallet_upstream to spawn a background subtask that called create_wallet_from_seed_bytes concurrently with the test's own explicit register_wallet_from_seed call. The upstream register_wallet (inside create_wallet_from_seed_bytes) inserts into wallet_manager (step A) and into self.wallets (step B) with async work in between (persister.store + load_persisted + initialize). A concurrent caller that lands between A and B sees WalletAlreadyExists from step A, then get_wallet returns None (step B not yet complete) → resolve_registered_wallet returns WalletNotFound. Under CI load this window is reliably hit. Fix: register the wallet BEFORE wiring the backend. register_wallet_upstream finds no backend and returns early without spawning the subtask. The backend is then wired, and the explicit register_wallet_from_seed call runs race-free (no concurrent subtask competing for the same wallet slot). <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * fix(wallet-backend): keep Tier-2 protected wallets visible at cold boot and stop plaintext key writes Addresses PR #865 review findings on the secret-storage seam. A (BLOCKER): identity write paths no longer serialize plaintext keys. insert/update_local_qualified_identity (and the alias re-encode) now route through encode_identity_blob_vault_first — the write-path twin of the load migration: plaintext keys go into the vault FIRST, the persisted blob carries only InVault placeholders, and a vault-write failure aborts the write (never lands Clear/AlwaysClear bytes in det-app.sqlite). B (HIGH) / C (BLOCKER): cold-boot hydration no longer drops Tier-2-protected wallets. reconstruct_wallet (HD seed) and rebuild_wallet (imported single key) branch on the at-rest SecretScheme before reading the secret. A Protected secret rehydrates CLOSED from the public sidecar (xpub / public_key_bytes) instead of propagating NeedsPassword as fatal, so a keep-protection-migrated wallet stays in the picker across launches. D: the HD Absent-branch legacy-envelope delete is now best-effort (log, don't propagate), matching the Protected branch — a transient delete failure no longer fails an otherwise-successful unlock. E: the eager no-password seed migration wraps the extracted 64-byte seed in Zeroizing so the stack copy wipes on drop. F: resolve_registered_wallet tolerates the registration TOCTOU window with a bounded re-poll before declaring a wallet missing; the fund-routing xpub gate is unchanged. G: present-but-malformed identity-key bytes map to SecretDecryptFailed (with a warn) in both the display and sign tasks, distinct from genuinely-absent IdentityKeyMissing. I/J: refreshed stale doc-comments (single-key has_passphrase, WalletMeta uses_password, wallet_seed_store header) to describe the Tier-2 keep-protection shape, and stripped ephemeral review-finding IDs from secret-path comments. Regression tests cover A, B, and C. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * fix(wallet-backend): seal fresh protected single-key imports Tier-2, typed malformed-identity-key error, skip needless keystore clone Follow-up to PR #865 review on the secret-storage seam. Fresh protected single-key imports now seal Tier-2 at import time instead of writing the legacy DET AES-GCM SingleKeyEntry envelope and migrating lazily on first unlock. import_wif_with_passphrase routes the protected branch through the seam's put_secret_protected, so the storage chokepoint is a single shape from import onward. raw_key_bytes and verify_passphrase branch on the at-rest SecretScheme: a Tier-2 key surfaces SingleKeyPassphraseRequired on a direct read and is verified by unsealing (wrong password -> SingleKeyPassphraseIncorrect, no oracle), while the legacy decode + lazy re-wrap path is retained for pre-existing installs. The legacy AES-GCM SingleKeyEntry remains a decode-only reader. sec_002_import_with_passphrase_encrypts_payload tightens to assert SecretScheme::Protected at import; ts_lazy_03 now starts from a directly-written legacy entry so the legacy->Tier-2 migration stays covered. Present-but-malformed identity-key bytes map to a new typed TaskError::IdentityKeyMalformed (jargon-free "stored but unreadable / re-import to refresh") in both the display and sign tasks, replacing the off-domain SecretDecryptFailed ("recovery phrase") message and staying distinct from the genuinely-absent IdentityKeyMissing. migrate_keystore_to_vault and encode_identity_blob_vault_first skip the KeyStorage clone in the steady-state (already-InVault) case via a new KeyStorage::has_plaintext_for_vault probe, so cold-boot load and identity re-saves no longer clone per identity for no benefit. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * docs(secret-seam): correct drifted docs to Tier-2 keep-protection reality - 01-ux-disclosure.md: full rewrite — the previous doc described the retired drop-protection design (password downgraded to file-permission only, one-time disclosure notices). Replaced with the Tier-2 keep-protection reality: protected secrets re-wrap under the same password, uses_password/has_passphrase stay true, migration is silent, no disclosure notices. Removed candy tally and agent byline. - 02-test-spec.md: update TS-LAZY-01/02/03 expected outcomes to Tier-2: scheme stays Protected, uses_password/has_passphrase stay true, second unlock still prompts (ask_count == 1). Added source-test names (ts_t2_01_*, ts_lazy_03_*). Removed machine-local plan paths, Marvin's note, and future-tense TDD framing. Added section-5 note that raw seam applies only to unprotected secrets. - user-stories.md WAL-006: replace false bullet ("no longer prompts, one-time notice") with the truth: Tier-2 re-seal, wallet keeps prompting, migration is silent. - CLAUDE.md wallet_backend/ bullet: remove dead TODO(per-secret-encryption) grep pointer (zero hits); describe present state — put_secret_protected/ get_secret_protected implemented; keyless-vault residual is deferred tier. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * feat(wallet-backend): optional per-identity at-rest encryption for identity keys (SEC-001) Identity keys default to keyless (Tier-1 raw, prompt-free) so headless/MCP signing of a non-opted-in identity is unchanged byte-for-byte. A user may opt in per identity to seal that identity's keys Tier-2 over the existing seam (Argon2id + XChaCha20-Poly1305) — no new crypto. The at-rest vault scheme is the single source of truth: scope_has_passphrase probes SecretSeam::scheme for the identity-key label (Protected -> prompt, Unprotected -> prompt-free, Absent -> IdentityKeyMissing), and decrypt_jit gains a symmetric Tier-2 arm. A protection-aware IdentityKeyView::store refuses a keyless write over a Protected label (IdentityKeyProtectionDowngrade), with store_unprotected as the deliberate opt-out downgrade. New crash-safe, idempotent migrations IdentityTask::Protect/UnprotectIdentityKeys re-seal an identity's keys keyless<->Tier-2 under one per-identity password. A display-only IdentityMeta sidecar carries the password hint + prompt copy (never the gate), seeded into the chokepoint's identity prompt index at identity load. UI: a collapsible 'Key Protection' section on the Key Info screen (default closed) with danger-gated opt-in (new password + confirm + strength + hint) and opt-out (verify) flows; PassphraseModalConfig gains remember_label so the sign-time prompt says 'key', not 'wallet'. Opted-in signing prompts just-in-time; headless yields SecretPromptUnavailable. Per-identity password isolation (TS-T2-IK-ISO twins TS-T2-SK-ISO). <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * fix(wallet-backend): seal new keys on a protected identity Tier-2, never keyless (SEC-001) Smythe MUST-FIX: a key added to a password-protected identity slipped through the per-label downgrade guard (a new key_id is scheme Absent), so AddKeyToIdentity -> insert_non_encrypted(Clear) -> encode_identity_blob_vault_first -> store_all wrote it Tier-1 keyless — a fully-capable signing key in plaintext on an identity the user believed protected. Two layers close it: (1) an identity-level fail-closed guard in encode_identity_blob_vault_first / migrate_keystore_to_vault refuses to move resident plaintext into the vault when the identity already has any Tier-2 key (IdentityKeyProtectionDowngrade / new KeystoreMigration::ProtectedSkipped), so a keyless write is impossible. (2) add_key_to_identity now seals the new key Tier-2 via SecretAccess::seal_new_identity_key, which prompts once, verifies the password against an existing protected key (so the identity stays under one password, with the standard wrong-pass re-ask), seals the new key, and marks it InVault before the save — headless yields SecretPromptUnavailable (fail closed; signing also fails closed earlier). KeyStorage::mark_in_vault performs the post-seal transition. SEC-002 (SHOULD-FIX): protect_identity_keys now re-enforces the password policy in the backend (validate_protection_password) so a non-UI caller cannot seal under a too-short password. SEC-003/SEC-004 tracked as code comments (store-guard TOCTOU bounded by the single-writer lock + UI in-flight gate; pre-opt-in plaintext may persist in freed filesystem blocks until reused). Tests: secret_access seal-new-key (seals Tier-2 under verified password / headless fails closed with no write / wrong-pass re-asks); identity_db encode+migrate refuse keyless on a protected identity; protect_identity_keys rejects a weak password. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * fix(identity): fail closed before broadcast when adding a key to a protected identity (SEC-001 O-2) Adding a key to a password-protected identity used to seal the new key Tier-2 (or fail closed) only during LOCAL persist, which runs AFTER the on-chain AddKeys broadcast. A headless add therefore broadcast the state transition on-chain and only then failed closed locally (no password) — leaving the key on-chain but never persisted by DET: an on-chain/local divergence. Move the protected-identity precondition BEFORE any on-chain side effect. `add_key_to_identity` now determines up front whether the identity is protected (`protected_identity_verify_scope`) and, if so, prompts for and VERIFIES its object password before building or broadcasting the state transition. Headless (`NullSecretPrompt` → `SecretPromptUnavailable`) or a wrong password returns the typed error before the broadcast, so no state transition is ever sent. The seal then runs after the broadcast with the already-verified password — a single prompt, split across the broadcast. `SecretAccess::seal_new_identity_key` is split into `verify_identity_object_password` (prompt + verify, returns an opaque `VerifiedIdentityPassword` that zeroizes on drop) and `seal_new_identity_key_with_password` (no prompt); the original composes the two and keeps its tests. The d965ca5 encode fail-closed guard (`IdentityKeyProtectionDowngrade`) stays as the defense-in-depth backstop. Also: O-1 — `mark_in_vault`'s bool return is now checked and warns on an unexpected miss (the encode guard still backstops it). O-3 — document that a Mixed identity fails closed on a plain re-save until "Finish protecting" reseals the remaining keys (intended secure behavior). <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * fix(identity): harden SEC-001 identity-key paths (r2 review) Address four thepastaclaw findings on the SEC-001 identity-key code at fcf6da1: - BLOCKING: `seal_identity_keys` now verifies the supplied password opens every already-`Protected` key BEFORE sealing any keyless one. A Mixed-state "Finish protecting" re-run with a different password is rejected up front with `IdentityKeyPassphraseIncorrect` and zero state changes, so an identity can never be split across two passwords. - `get_identity_by_id` now mirrors the bulk-load vault migration, so the single-get read path (and the SEC-001 protect/unprotect tasks that use it) migrates legacy resident `Clear`/`AlwaysClear` keys to the vault on read instead of returning and re-persisting plaintext. - A post-broadcast seal failure in `add_key_to_identity` now surfaces the typed, actionable `IdentityKeyAddedButNotSaved` (key is on-chain; retry after freeing disk space), preserving the upstream cause in the source chain — never a silent loss and never a keyless-write fallback. - The three prompt-meta setters recover a poisoned lock (`unwrap_or_else(|p| p.into_inner())`), matching `forget`/`forget_all`, so prompt-copy metadata can self-heal after a panicked reader instead of silently freezing. Adds regression tests for each (the blocker's split-prevention, read-path migration via an offline AppContext, and the typed orphan-error mapping). <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * docs(single-key): correct has_passphrase on-disk-shape doc to Tier-2-direct The has_passphrase field doc claimed fresh protected imports use a legacy AES-GCM envelope migrated on first unlock; imports seal Tier-2 directly at import time. Align the field doc with the function docstring. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lklimek
added a commit
that referenced
this pull request
Jun 26, 2026
…01 hardening (#867) * docs(secret-seam): Phase-1 design artifacts (UX disclosure + test case spec) UX disclosure spec by Diziet; 30-case TDD test spec by Marvin. Design reference for the secret-storage raw-SecretBytes seam re-architecture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * feat(wallet-backend): add raw-SecretBytes secret seam + typed errors (T2,T4) Crikey, here's the one socket every wallet secret will squeeze through. T2 — new wallet_backend/secret_seam.rs: SecretSeam over raw SecretBytes with put_secret/get_secret/delete_secret, a no-encryption pass-through to the upstream vault TODAY. Every put/get body carries the greppable `TODO(per-secret-encryption):` tag so wiring real per-secret encryption later is a localized change. Prompt-free — the passphrase requirement lives only in the retained legacy readers, never here. No-serialization guard mechanism: compile_fail doctests (no new deps — static_assertions/trybuild stay out of Cargo.toml). One asserts a newtype cannot derive Serialize over a SecretBytes; one asserts serde_json::to_string on a SecretBytes is rejected. If upstream ever adds Serialize to SecretBytes these start compiling and the canary fires (TS-INV-01). TS-INV-02 round-trips a SecretBytes through the real signatures (compiler is the assertion). T4 — TaskError variants (no String fields, typed #[source]): SecretSeam, SecretSeamMissing (loud funds-safety miss), IdentityKeyVault, IdentityKeyMissing. Promote the private assert_no_leak (hex + decimal-array) into a shared wallet_backend/leak_test_support.rs so the seam/sidecar/QI/Debug leak cases reuse one impl instead of copy-pasting. TS-NOLEAK-01: the on-disk vault file holds no raw secret in either form. Tests: 6 seam unit + 2 compile-fail doctests, all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * fix(model): redacting Debug for ClosedSingleKey (T9, 6a2818cd) ClosedSingleKey derived Debug and its encrypted_private_key holds the raw 32 key bytes in the no-password / pre-migration shape — a derived Debug dumped them as a decimal byte array straight into logs. Hand-write a redacting Debug mirroring ClosedKeyItem / SingleKeyEntry: key_hash + lengths, never the bytes. Parents SingleKeyData / SingleKeyWallet are safe by delegation. TS-DBG-01 asserts via the shared assert_no_leak_bytes (hex AND decimal-array — the decimal form is the one the pre-fix Debug leaked) at all three levels. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * feat(model): PrivateKeyData::InVault placeholder + migration probes (T1) Identity private keys get a non-resident home. New PrivateKeyData::InVault appended at bincode index 4 — discriminants 0-3 (AlwaysClear/Clear/Encrypted/ AtWalletDerivationPath) are untouched, so blobs written before it still decode (TS-RESID-02 round-trips all four pre-existing variants + InVault). Redacting Debug/Display arms (carries no bytes — trivially clean). KeyStorage probes: - is_in_vault / public_key_for — a vault placeholder reports true yet still surfaces its public key for display + signing-key selection. - take_plaintext_for_vault — rewrites every Clear/AlwaysClear to InVault and returns the raw bytes (Zeroizing) the migration must store in the vault FIRST (vault-before-blob order). Wallet-derived + encrypted keys untouched — they were never plaintext-at-rest. get/get_resolve_local gain an InVault arm (resolve through the vault, not locally). key_info_screen gains degraded InVault arms (securely-stored notice; full JIT view/sign via dedicated identity-key WalletTasks is the T8 follow-up). Promote the private assert_no_leak + distinctive_secret to the shared leak_test_support helper (no fork). TS-RESID-01 / TS-NOLEAK-03: post-migration KeyStorage has only InVault, and the re-encoded blob leaks neither secret in hex nor decimal-array form. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * feat(model,wallet-backend): WalletMeta+ImportedKey sidecar fields, schema-gated (T5) Non-secret metadata moves out of the per-wallet seed envelope into the sidecar. WalletMeta gains uses_password + password_hint. Because WalletMeta is positional bincode behind the DetKv envelope, #[serde(default)] alone is NOT forward-compatible (R-SCHEMA) — so a real version gate: WALLET_META_VERSION (v2) framed as [version | bincode] at the WalletMetaView boundary, plus a retained decode-only WalletMetaV1. decode_versioned detects v2 / v1-framed / bare-legacy and migrates a v1 blob into v2 (defaults uses_password=false), never positionally misparsing it. The global DetKv SCHEMA_VERSION is deliberately untouched (it governs every payload, not just WalletMeta). TS-META-01 covers all three shapes. ImportedKey gains public_key_bytes (the compressed SEC1 PUBLIC key) so the locked-render cold-boot path can rebuild a protected key's display wallet without the secret — moved out of the SingleKeyEntry vault blob ahead of the raw-seam migration. NON-secret; #[serde(default)] for old entries. write_wallet_meta now carries uses_password/password_hint from the open Wallet; the legacy-table drain (finish_unwire) defaults them (the authoritative flag is read from the envelope at the migrating unlock). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * chore(wallet-backend): satisfy fmt + clippy for the secret-seam batch - leak_test_support: drop redundant inner #![cfg(test)] (mod.rs already gates it). - encrypted_key_storage: factor take_plaintext_for_vault's return into the VaultBoundKey type alias (clippy::type_complexity). - wallet_hydration bench: carry the new WalletMeta password fields. - nightly-fmt whitespace. Gate: cargo +nightly fmt --all clean; cargo clippy --all-features --all-targets -D warnings clean; cargo test --all-features --workspace = 944 lib + 146 + 10 + 3 + 2 pass, 0 fail; 2 compile_fail doctests pass; det-cli standalone smoke (network-info / tools / core-wallets-list) all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * feat(wallet-backend): SecretScope::IdentityKey + seam-first SecretAccess (T3) The chokepoint learns identity keys and goes seam-first for everyone. - SecretScope::IdentityKey { identity_id:[u8;32], target, key_id } (DET-opaque; KeyID is just u32, PrivateKeyTarget is a DET model enum). identity_key_label() builds identity_key_priv.<m|v|o>.<key_id> — a stable one-char target tag keeps the label inside the upstream allowlist. - SecretPlaintext::IdentityKey + expose_identity_key; Plaintext::IdentityKey. Borrowed-only, zeroizing, never resident — same hygiene as the other kinds. - decrypt_jit is now SEAM-FIRST for all three classes: the raw label wins; the retained legacy reader (decrypt_hd_seed / SingleKeyEntry::decrypt) is the migration fallback for HD seeds and single keys. IdentityKey reads raw via the seam → loud IdentityKeyMissing if absent (never silent). - scope_has_passphrase: a migrated raw secret reports false (the password no longer gates it); only a not-yet-migrated legacy entry can still be protected; IdentityKey is always false → prompt-free fast-path → headless/MCP signing works. - DetSigner treats an IdentityKey plaintext as a raw single key (same secp256k1 shape, no derivation tree). Tests: TS-FAST-01 (identity key resolves prompt-free, ask_count 0, can_resolve_without_prompt true), IdentityKeyMissing is loud, TS-LEGACY-01 (legacy envelope served when raw absent), raw-wins-over-legacy precedence. The pre-existing protected-HD/single-key tests now exercise the legacy fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * feat(wallet-backend): identity_key_store + seed/single-key seam-raw writes (T6) Secrets start landing raw. No DET envelope for the new write paths. - New wallet_backend/identity_key_store.rs: IdentityKeyView with store/get/delete + store_all/delete_all over raw 32 bytes via SecretSeam (scope = identity_id, label identity_key_priv.<m|v|o>.<key_id>). NO StoredIdentityKey envelope — the InVault marker in the QI blob is the only on-disk trace. store_all is the migration's vault-first writer (call before the blob rewrite); delete_all backs purge_identity_scope. - WalletSeedView gains set_raw/get_raw/delete_raw (raw 64-byte seed under seed.raw.v1 via the seam) + legacy_envelope_get (retained decode-only reader). - write_seed_envelope now branches: a no-password wallet writes the RAW seed (encrypted_seed_slice() is verbatim the seed); a password wallet keeps the legacy AES-GCM envelope at creation and migrates lazily at unlock (T7). - import_wif_with_passphrase: unprotected import writes RAW 32 bytes under the existing single_key_priv.<addr> label (no SingleKeyEntry framing); protected import keeps the legacy SingleKeyEntry (lazy-migrates at unlock). The locked-render pubkey rides in the ImportedKey sidecar (the T5 field). SingleKeyEntry::decode treats a bare 32-byte blob as unprotected, so a raw-written key still rebuilds + opens at cold boot. Tests: identity_key_store round-trip / scope+target isolation / store_all+ delete_all; seed raw round-trip independent of the legacy label; single-key unprotected import is exactly 32 raw bytes (no framing) and signs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * feat: crash-safe dual-format migration + InVault resolver + vault delete (T7) This is the part that actually moves secrets. Funds-safety ordering throughout. Resolver (mod.rs): resolve_private_key_bytes gains the InVault route — keyed by is_in_vault/public_key_for, it fetches the raw bytes per-use via with_secret(IdentityKey{...}) (prompt-free). No chokepoint wired ⇒ fail closed (WalletLocked); bytes never resident. EAGER migration on load (dialog-free): - Identity keys (identity_db::migrate_identity_keys_to_vault, run per identity in load_identities_filtered): take_plaintext_for_vault → IdentityKeyView store_all (vault FIRST) → rewrite the QI blob with InVault. Vault-write failure restores the resident plaintext for this session and defers; a blob-rewrite failure is re-detected and retried next load. Idempotent. - No-password HD seeds (hydration::reconstruct_wallet): raw seam wins (precedence raw > legacy); a no-password legacy envelope is re-stored raw (set_raw, vault FIRST) then deleted. reconstruct_from_envelope extracted so the raw and legacy paths share the xpub-decode + build tail. LAZY migration on unlock (one prompt, the unlock the user already does): promote_and_maybe_migrate_hd_seed re-stores the just-decrypted legacy seed raw (set_raw before delete) inside the borrowed Zeroizing scope and reports migrated=true; handle_wallet_unlocked then flips WalletMeta.uses_password=false and shows the one-time disclosure (T8 Copy A/D). Delete: forget_wallet_local_state now deletes BOTH the raw seed and the legacy envelope (a wallet may be in either form) — closes a wipe gap where a migrated no-password seed would survive removal. identity_db.clear_identity_vault_keys drains an identity's raw vault keys on single-delete + devnet sweep. Loud, never silent: a seed in neither form ⇒ TaskError::SecretSeamMissing (was WalletNotFound) on both scope_has_passphrase and decrypt_jit. Tests: TS-EAGER-01/04 (no-pw seed migrates + idempotent), TS-CRASH-01 read (raw wins, legacy cleaned), TS-MISS-01 (SecretSeamMissing loud). Updated 5 wallet_lifecycle removal/clear tests to assert the raw seed (the new at-rest form) in BOTH precondition and post-delete. wallet_lifecycle 38, hydration 10, identity_db 16, encrypted_key_storage 4 — all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * feat: key_info_screen JIT identity signing + single-key Copy B disclosure (T8) Real JIT for vault-backed identity keys, and the per-key migration notice. Two new WalletTasks + handlers, opening with_secret(IdentityKey{...}): - DeriveIdentityKeyForDisplay → derive_identity_key_for_display: fetches the raw key JIT, returns only the WIF (Secret). - SignMessageWithIdentityKey → sign_message_with_identity_key: signs in the backend, returns only the public Base64 envelope. New result variants IdentityKeyForDisplay / IdentityMessageSigned (identity- flavored — carry identity_id/target/key_id, not a meaningless seed_hash). key_info_screen: the InVault arms are now real — "View Private Key" queues DeriveIdentityKeyForDisplay and renders the returned WIF/hex via the existing render_decrypted_key_grid; "Sign" queues SignMessageWithIdentityKey. The degraded placeholders are gone. display_task_result handles both new results. Single-key protected lazy migration + Copy B: verify_passphrase now re-stores the just-decrypted protected entry raw under the same label (upsert replaces the AES-GCM framing) and clears the persistent has_passphrase flag, returning a migrated bool. verify_single_key_passphrase surfaces the one-time per-key disclosure (Copy B — text DISTINCT from the wallet Copy A so set_global's dedup keeps both) on migration. decrypt_jit's sign path also lazy-migrates (migrate_single_key_to_raw + in-memory flag flip) — idempotent defense-in-depth. SingleKeyView::clear_passphrase_flag persists the flip to the sidecar. Tests: TS-LAZY-03 — protected single key migrates via the chokepoint, the vault holds raw 32 bytes after, and a second resolve under a never-prompt host is prompt-free with the WIF-plaintext bytes. secret_access 24 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * chore: fmt + clippy for the T3-T8 integration batch - secret_access: drop explicit_auto_deref on set_raw(seed_hash, seed) — a &Zeroizing<[u8;64]> auto-derefs to &[u8;64]. - nightly-fmt whitespace across the touched files. Gate: cargo +nightly fmt --all clean; cargo clippy --all-features --all-targets -D warnings clean; cargo test --all-features --workspace = 957 lib + 146 + 10 + 3 + 2 pass, 0 fail, 1 ignored (funded-testnet TS-SIGN-E2E-01); 2 compile_fail doctests pass; det-cli standalone smoke (network-info / core-wallets-list / tools) all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * fix(wallet-backend): dual-format read for WalletMeta + ImportedKey sidecars The real defect QA caught (PROJ-001/002/003 + SEC-003): appending fields to a positional-bincode DetKv value is format-breaking, and my T5 framing made it WORSE — WalletMeta writes went through kv.put::<Vec<u8>>(versioned-frame) and reads through kv.get::<Vec<u8>>, which type-confuses an OLD kv.put::<WalletMeta> blob (decodes the alias's UTF-8 bytes AS the Vec) → alias/is_main silently lost. ImportedKey appended public_key_bytes with no legacy reader → old keys vanish from the picker. Fix (one policy for both sibling sidecars): drop the hand-rolled version byte (SEC-003: it could collide with a bincode length varint — a 1/2-char alias). Instead lean on the DetKv schema envelope + try-decode-both: - write the current shape directly (kv.put::<WalletMeta> / ::<ImportedKey>); - on read, try the current shape; on a bincode Decode error (an old blob runs out of bytes for the appended fields) fall back to the legacy shape (WalletMetaV1 / ImportedKeyV1, decode-only) and RE-STORE in the new shape. Order is load-bearing and tested: the 6-field struct CANNOT decode a 4-field blob (runs past end), so "new first, then V1" never mis-promotes. A DetKv schema-version mismatch stays a hard error; only Decode triggers the fallback. Removes the now-dead encode_versioned/decode_versioned/WALLET_META_VERSION (PROJ-002 — the unreachable legacy branch + its overclaiming test are gone; the legacy path is now live via the view and tested end-to-end). Tests: model leg (ts_meta_01) asserts the order-sensitivity + the SEC-003 1/2-char-alias collision case; view legs (old_wallet_meta_blob_*, old_imported_key_blob_*) write an OLD blob exactly as the base branch did, read it back through the view preserving every field, and confirm re-store in the new shape. wallet::meta 3, wallet_meta 13, single_key all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * test(identity-db): identity-key migration, deletion, write-fault no-loss (QA-002/003/005) Refactor the eager identity-key migration core out of AppContext into a free fn migrate_keystore_to_vault(secret_store, id, qi, persist) returning a KeystoreMigration outcome, so the funds-safety logic is unit-testable with a bare SecretStore + a controllable persist closure (no full AppContext). QA-002 — migration is vault-FIRST: the persist closure asserts the raw keys are already in the vault and the blob being persisted is InVault-only; the AtWalletDerivationPath key is untouched; zero plaintext remains; idempotent (second run = Nothing). QA-005 — write-fault no-loss (the write half CRASH-01's read half misses): with the vault parent dir chmod'd read-only so store_all fails, the migration restores the resident plaintext keystore byte-for-byte, does NOT call persist, and reports VaultWriteFailed — keys never lost on a mid-write fault. (#[cfg(unix)].) QA-003 — identity-key deletion is scoped + isolated: delete_all over the victim's (target,key_id) set removes its vault keys while a second identity's key under the same (target,key_id) is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * test(wallet-lifecycle): assert lazy-migration secret post-conditions (QA-004) The protected-wallet-unlock test asserted only upstream registration. Add the secret post-conditions the lazy migration is actually for: after handle_wallet_unlocked the raw seed is written and equals the true 64-byte seed, the legacy envelope.v1 is deleted, WalletMeta.uses_password flipped false, and a SECOND resolve through a never-prompt chokepoint over the now-raw vault returns the seed with zero prompts (the migrated wallet is permanently prompt-free). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * test(backend-e2e): TS-SIGN-E2E-01 InVault identity signs + broadcasts (QA-001) New #[ignore] backend-e2e test: migrate the shared identity's plaintext signing keys to the vault (PrivateKeyData::InVault, exactly as the eager load-path migration does), assert residency (zero Clear/AlwaysClear remain), wire the chokepoint, then build + sign + broadcast an IdentityUpdateTransition. Signing runs through the async QualifiedIdentity Signer → resolve_private_key_bytes → with_secret(IdentityKey{..}) — the JIT free-rider path. A successful broadcast + the new key appearing on Platform proves the InVault MASTER key signed live without ever being resident. Requires E2E_WALLET_MNEMONIC + live DAPI/SPV; run command + RUST_MIN_STACK in the header. Compiles + registered in main.rs; left #[ignore] for a manual/live run during QA. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * refactor(wallet-backend): zeroize migration source, flavor identity-key errors, lift signed-message helper PROJ-004 (security): take_plaintext_for_vault now zeroizes the resident Clear/AlwaysClear array BEFORE the InVault overwrite drops it — de-residenting the key is the function's whole purpose, so it must wipe the source, not just the moved-out copy. PROJ-005: IdentityKeyView::store/get/delete now map the generic seam error to the identity-flavored TaskError::IdentityKeyVault (previously a producerless variant), so an identity-key vault failure surfaces with identity-specific banner copy. Wrong-length stays SecretDecryptFailed. QA-DEDUP-01: lift dash_signed_message (the recoverable-envelope builder) from sign_message_with_key.rs to backend_task/wallet/mod.rs as pub(crate); both the wallet-key and identity-key signers now call it instead of two drifting copies. The recovery-header round-trip tests move alongside the shared helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * test(secret-seam): TS-INV-03 audit guard + TS-NOLEAK-02 sidecar no-leak (SEC-001/002) SEC-001 (TS-INV-03): source-text audit over the changed secret-path modules — no Serialize/Encode struct may name a plaintext-key field (SecretBytes, Zeroizing<[u8, [u8;32], [u8;64]). Catches the bare-Vec/array plaintext bypass the compile_fail doctests can't (they only catch an embedded SecretBytes). The module list mirrors the blast-radius table; ciphertext fields are deliberately not flagged. Passes — the invariant holds today and now has a regression guard. SEC-002 (TS-NOLEAK-02): assert the encoded WalletMeta + ImportedKey sidecar blobs contain neither secret (hex AND decimal-array via the shared assert_no_leak_bytes), and that the ImportedKey's PUBLIC key IS present (locked render needs it). Canary coverage — the sidecars structurally hold no secret. Plus a clarifying "// no secret to (de)crypt" note at delete_secret instead of an encryption TODO. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * test(kittest): disclosure-banner copy coverage (QA-007/Diziet) Extract the interim at-rest disclosure copy into pure pub fns (wallet_migration_notice / single_key_migration_notice) + pub INTERIM_AT_REST_DETAILS, re-exported from context, so the exact copy is testable without an AppState and i18n-extractable. Both callsites now use them. New tests/kittest/disclosure_banner.rs (QA-007): Copy A and Copy B each render as Warning banners naming the wallet/key, the ⚠ icon shows (not color-only), the two copies are DISTINCT (so set_global's text-dedup keeps both when a wallet and a key migrate in one session), and all copy (A/B/D) is jargon-free (no AES/vault/seam/encryption/0600). 4 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * docs: comment hygiene + CLAUDE.md seam pointer + user-story softening (QA-DOC/DOC) QA-DOC-01: strip ephemeral review IDs from comments I authored in the secret-seam surface — "Smythe must-fix #3/#4/#5", "Q-HEADLESS", "(F-2)", "6a2818cd" — keeping the rationale prose. (Pre-existing PROJ-010/TC-W-*/F43/F63 in code outside this PR's diff are left untouched to avoid scope creep.) QA-DOC-02: drop the "Promoted from…" history line in leak_test_support.rs (belongs in git, not the module header). QA-DOC-03: secret_access module-header resolution order now lists the unprotected fast-path as an explicit step 2 (cache → unprotected → prompt), matching the three-branch body. DOC-001: CLAUDE.md wallet_backend bullet now points at secret_seam.rs as the single secret chokepoint + the TODO(per-secret-encryption): grep convention + the design dir. DOC-002: user-stories WAL-006 gains the post-migration no-password-prompt note; WAL-025 "modern encrypted vault" → "on-device secret vault" (no longer asserts encryption that is presently absent — the accepted interim regression). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * chore: nightly fmt for the QA-findings batch Whitespace-only reformat (cargo +nightly fmt --all) of the files touched while closing the QA findings. No behavioral change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * test(backend-e2e): seed Clear key so TS-SIGN-E2E-01 exercises the InVault JIT path The shared_identity() fixture registers a wallet-derived identity, so its keys are PrivateKeyData::AtWalletDerivationPath and take_plaintext_for_vault() (which migrates only Clear/AlwaysClear) correctly found nothing — the test panicked in setup before reaching the path under test. Add materialize_master_key_as_clear(): derive the master key's raw bytes from the HD seed through the real with_secret(SecretScope::HdSeed) chokepoint (identity index 0, key 0) and insert_non_encrypted() them as Clear, so the migration carries a genuine plaintext key into the vault as InVault and the JIT signing path produces a signature whose bytes match the on-chain master key. The !taken.is_empty() assertion is unweakened; no signer stub, no mocked broadcast. Stays #[ignore]: the live broadcast additionally needs a funding wallet that derives within its rehydrated window (the e2e funding step hit the known core-wallet gap-window/rehydration limitation, unrelated to the InVault path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * chore(deps): repin platform deps to feat/platform-wallet-secret-protection (fb7953ea) Moves the 4 dashpay/platform branch deps (dash-sdk, rs-sdk-trusted-context-provider, platform-wallet, platform-wallet-storage) — and their 23 transitive platform crates, 27 total — from fix/wallet-core-derived-rehydration@ea0082e6 to feat/platform-wallet-secret-protection@fb7953ea (PR #3953), establishing the green baseline for the secret-handling-hardening work. Done on top of the merge of origin/docs/platform-wallet-migration-design (ac0c3d98), which brought in #864 (headless masternode/evonode withdrawals) and #866 (DPNS blocking overlay). The merged DET tree compiles cleanly against the secret-protection branch — no API breakage. Verified green: cargo build --all-features cargo clippy --all-features --all-targets -- -D warnings cargo +nightly fmt --all -- --check Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(secret): open the vault keyless (file_unprotected) for the Tier-1 baseline PR #3953 ("platform-wallet-secret-protection") hardened upstream `SecretStore::file(path, passphrase)` to reject a blank passphrase (`SecretStoreError::BlankPassphrase`). DET's `open_secret_store` opened the vault with `SecretString::new("")`, so after the repin every AppContext init failed at the secret-store open and 7 secret_seam/secret_access tests broke. Switch to the explicit keyless door `SecretStore::file_unprotected(path)`, which upstream documents for exactly this model: the vault file itself is keyless (at-rest floor = owner-only perms) and per-secret confidentiality comes from Tier-2 object passwords on the individual secrets. Behavior for the Tier-1 baseline is unchanged from the old empty-passphrase open. Restores the green baseline at the fb7953ea pin: build/clippy/fmt clean, the 8 secret_seam/secret_access vault tests pass again. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(secret): add Tier-2 seam capability (protected set/get + scheme probe) Adds the upstream Tier-2 object-password path to the secret seam, the single coherent encrypt/decrypt chokepoint: - `put_secret_protected` / `get_secret_protected` seal/unseal a secret under its OWN object password via upstream `SecretStore::set_secret/get_secret` (Argon2id + XChaCha20-Poly1305). Per-secret, never a shared/per-wallet pw. - `scheme()` reports the at-rest tier (Absent / Unprotected / Protected) of a stored secret WITHOUT the password, via a `get(None)` probe that reads the upstream `NeedsPassword` signal. - The plain `*_secret` methods stay Tier-1 (unprotected) and are documented as such; the 3 `TODO(per-secret-encryption)` markers are resolved — the per- secret encryption IS the upstream envelope selected by the password arg. Additive and behavior-preserving: existing Tier-1 callers are unchanged; the read/migration wiring in SecretAccess lands next. Build/check + the 8 secret_seam/secret_access tests stay green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(secret): adopt Tier-2 per-secret passwords for HD seeds Routes HD-seed at-rest crypto through the upstream Tier-2 object-password envelope instead of DET AES-GCM, KEEPING protection rather than downgrading a password-protected seed to a raw, password-free secret on first unlock. - `WalletSeedView` gains `scheme()` / `set_protected()` / `get_protected()`: a protected seed lives at the `seed.raw.v1` label as a Tier-2 envelope (Argon2id + XChaCha20-Poly1305) sealed under that seed's OWN object password; an unprotected seed stays Tier-1 raw. - `scope_has_passphrase` + `decrypt_jit` are now scheme-driven (via the seam `get(None)` `NeedsPassword` probe): Unprotected → raw, no prompt; Protected → unseal with the JIT-prompted per-seed password; Absent → decode the legacy AES-GCM envelope (decode-only reader) and LAZY re-wrap to Tier-2 (protected) or raw (unprotected), then drop the legacy envelope. Crash-safe: re-store upserts before the legacy delete; the scheme probe prefers the new label. - `promote_and_maybe_migrate_hd_seed` no longer downgrades; it reports "no downgrade" so the unlock callsite's `uses_password=false` finalizer never fires — protection is kept and the metadata stays accurate, with no change to `wallet_lifecycle.rs`. - `is_wrong_passphrase` now also catches the upstream `WrongPassword` so a Tier-2 unseal with a bad object password re-prompts instead of aborting. Per-SECRET model: the session cache is plaintext keyed by `SecretScope`, so remembering seed A never satisfies seed B — each prompts and decrypts only with its own password. Tests: lazy re-wrap keeps protection (legacy gone, raw read of a protected seed fails), Tier-2 wrong-password re-ask, and the A/B different-password isolation. 72 secret tests pass; clippy/fmt green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(secret): clean keep-protection replacement of the downgrade subsystem (HD seed) Supersedes the transitional "inert return" approach with a clean excision of #865's downgrade-to-raw machinery, now that wallet_lifecycle.rs is editable (user WIP stashed). Protected HD seeds STAY protected (Tier-2 object password); nothing downgrades them to a raw, password-free secret. - `wallet_lifecycle.rs`: remove `finish_lazy_seed_migration` (the `uses_password=false` downgrade flip + the "protection removed" notice) and collapse the two `promote_*` methods into one `promote_hd_seed_with_passphrase` (decrypt + cache) — the lazy re-wrap lives in `decrypt_jit`. The unlock callsite no longer finalizes a downgrade. - `finish_unwire::migrate_wallet_meta`: carry the legacy `wallet.uses_password` / `password_hint` into `WalletMeta` (it was defaulting `false`). The persisted flag is now accurate from cold-start (`true` for a protected wallet) and always agrees with the at-rest scheme — no stale/drift-prone metadata. - `protected_wallet_registers_..._on_unlock` acceptance test rewritten to the keep-protection end-state: after the migrating unlock the seed is Tier-2 (scheme=Protected), a raw read fails, `WalletMeta.uses_password` stays true, and a second resolve prompts for the object password. 1009 lib tests pass; clippy -D warnings + fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(secret): adopt Tier-2 keep-protection for imported single keys Extends the Tier-2 keep-protection model from HD seeds to imported single keys, replacing their downgrade-to-raw migration. A protected imported key STAYS protected under its own object password instead of being re-stored raw. - `decrypt_jit` / `scope_has_passphrase` (SingleKey) are scheme-driven (seam `get(None)` → `NeedsPassword` probe): Protected → unseal with the JIT-prompted per-key password; Unprotected → a migrated raw-32 key wins prompt-free, else the not-yet-migrated legacy `SingleKeyEntry` blob's `has_passphrase` decides; the in-band length-32 check disambiguates raw vs legacy-framed. - `migrate_single_key_to_raw` → `migrate_single_key_to_tier2`: lazy re-wrap the just-decrypted protected key to a Tier-2 envelope under the same password (upsert replaces the AES-GCM framing). `has_passphrase` is NOT flipped — protection is kept and the index/persisted flag stay accurate. - `single_key::verify_passphrase` (the unlock-gesture path): re-wraps to Tier-2 instead of downgrading to raw; returns `()` (no migration bool). The `clear_passphrase_flag` finalizer is removed. Downgrade-disclosure machinery retired (Tier-2 keeps protection, nothing to disclose): removed `show_single_key_migration_notice` + the `wallet_migration_notice` / `single_key_migration_notice` / `INTERIM_AT_REST_DETAILS` copy + their re-exports, and the obsolete `tests/kittest/disclosure_banner.rs`. Tests: `ts_lazy_03` rewritten to the keep-protection end-state (vault holds a Tier-2 envelope, password-free read fails, second resolve prompts). 1009 lib tests pass; clippy -D warnings + fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(secret): address Smythe Tier-2 review findings (SEC-001/002/004/005) Smythe verdict on the Tier-2 adoption: SOUND, 0 Critical/High (it closes a prior HIGH-grade protected-seed downgrade-to-obfuscation). Folds in the carry-forward findings (SEC-003 — excise the inert downgrade — already landed in 6dafbdab): - SEC-001 (LOW): GC an orphaned legacy `envelope.v1`. The seed Protected read branch (`decrypt_jit`) now best-effort `view.delete(seed_hash)` so an `envelope.v1` left behind by a crash/delete-failure during the re-wrap (which still decrypts under the seed's OLD password) cannot survive forever — the Absent branch, the only other deleter, is never re-entered once Protected. The single-key path migrates in-band (same-label upsert) and has no such orphan. - SEC-004 (LOW): assert the NEGATIVE crypto property. `ts_t2_03` (seed) and the new `ts_t2_sk_iso` (single key) now prove A's object password is REJECTED by B's envelope (`WrongPassword`) — the upstream per-object-salt + AAD binding — not merely that the DET cache is scope-keyed. - SEC-002 (MEDIUM, doc): record loudly that the keyless `file_unprotected` vault is "obfuscation, not confidentiality" for Tier-1 secrets (no-password seeds, raw single keys, identity keys rest on file perms ALONE; only Tier-2 object passwords give real at-rest confidentiality). Documented at `open_secret_store`, reworded `ts_noleak_01` (proves non-literal-plaintext, NOT confidentiality), and in the design note's threat-model residual. - SEC-005 (info): one-line note in `seed_envelope.rs` — the legacy reader is decode-only / local owner-only vault, uses bincode 2.x; the RUSTSEC-2025-0141 bincode 1.3.3 is a transitive dep. No code change. 1010 lib tests pass; clippy -D warnings + fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(migration): note the wallet.uses_password/password_hint schema invariant Smythe's schema-robustness query on `migrate_wallet_meta`'s new SELECT (it reads `uses_password`/`password_hint` unprobed, unlike the probed optional `core_wallet_name`). Verified + documented the invariant rather than adding a needless probe: the wallet-seed migration (`migrate_wallet_seeds_rows_from_conn`) already SELECTs both columns unconditionally and runs FIRST over the same `wallet` table at the same cold-start, so any schema lacking them fails there before the meta pass. The unprobed read here is therefore exactly as robust as the shipped seed migration; `core_wallet_name` stays probed because it is the one droppable column. Comment-only — 1010 lib tests pass, clippy -D + fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(test): eliminate register_wallet_from_seed race in cold-boot test The `ensure_identity_funding_accounts_succeeds_on_cold_booted_watch_only_wallet` test failed in CI (1000+ parallel tests) with: WalletBackend { source: WalletNotFound("70dba4c1d8c5c3854aa02c8f15e0fcd66df6661841d7ae822891fa21aaef48d2") } Root cause: the test wired the backend BEFORE calling register_wallet, which caused register_wallet_upstream to spawn a background subtask that called create_wallet_from_seed_bytes concurrently with the test's own explicit register_wallet_from_seed call. The upstream register_wallet (inside create_wallet_from_seed_bytes) inserts into wallet_manager (step A) and into self.wallets (step B) with async work in between (persister.store + load_persisted + initialize). A concurrent caller that lands between A and B sees WalletAlreadyExists from step A, then get_wallet returns None (step B not yet complete) → resolve_registered_wallet returns WalletNotFound. Under CI load this window is reliably hit. Fix: register the wallet BEFORE wiring the backend. register_wallet_upstream finds no backend and returns early without spawning the subtask. The backend is then wired, and the explicit register_wallet_from_seed call runs race-free (no concurrent subtask competing for the same wallet slot). <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * fix(wallet-backend): keep Tier-2 protected wallets visible at cold boot and stop plaintext key writes Addresses PR #865 review findings on the secret-storage seam. A (BLOCKER): identity write paths no longer serialize plaintext keys. insert/update_local_qualified_identity (and the alias re-encode) now route through encode_identity_blob_vault_first — the write-path twin of the load migration: plaintext keys go into the vault FIRST, the persisted blob carries only InVault placeholders, and a vault-write failure aborts the write (never lands Clear/AlwaysClear bytes in det-app.sqlite). B (HIGH) / C (BLOCKER): cold-boot hydration no longer drops Tier-2-protected wallets. reconstruct_wallet (HD seed) and rebuild_wallet (imported single key) branch on the at-rest SecretScheme before reading the secret. A Protected secret rehydrates CLOSED from the public sidecar (xpub / public_key_bytes) instead of propagating NeedsPassword as fatal, so a keep-protection-migrated wallet stays in the picker across launches. D: the HD Absent-branch legacy-envelope delete is now best-effort (log, don't propagate), matching the Protected branch — a transient delete failure no longer fails an otherwise-successful unlock. E: the eager no-password seed migration wraps the extracted 64-byte seed in Zeroizing so the stack copy wipes on drop. F: resolve_registered_wallet tolerates the registration TOCTOU window with a bounded re-poll before declaring a wallet missing; the fund-routing xpub gate is unchanged. G: present-but-malformed identity-key bytes map to SecretDecryptFailed (with a warn) in both the display and sign tasks, distinct from genuinely-absent IdentityKeyMissing. I/J: refreshed stale doc-comments (single-key has_passphrase, WalletMeta uses_password, wallet_seed_store header) to describe the Tier-2 keep-protection shape, and stripped ephemeral review-finding IDs from secret-path comments. Regression tests cover A, B, and C. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * fix(wallet-backend): seal fresh protected single-key imports Tier-2, typed malformed-identity-key error, skip needless keystore clone Follow-up to PR #865 review on the secret-storage seam. Fresh protected single-key imports now seal Tier-2 at import time instead of writing the legacy DET AES-GCM SingleKeyEntry envelope and migrating lazily on first unlock. import_wif_with_passphrase routes the protected branch through the seam's put_secret_protected, so the storage chokepoint is a single shape from import onward. raw_key_bytes and verify_passphrase branch on the at-rest SecretScheme: a Tier-2 key surfaces SingleKeyPassphraseRequired on a direct read and is verified by unsealing (wrong password -> SingleKeyPassphraseIncorrect, no oracle), while the legacy decode + lazy re-wrap path is retained for pre-existing installs. The legacy AES-GCM SingleKeyEntry remains a decode-only reader. sec_002_import_with_passphrase_encrypts_payload tightens to assert SecretScheme::Protected at import; ts_lazy_03 now starts from a directly-written legacy entry so the legacy->Tier-2 migration stays covered. Present-but-malformed identity-key bytes map to a new typed TaskError::IdentityKeyMalformed (jargon-free "stored but unreadable / re-import to refresh") in both the display and sign tasks, replacing the off-domain SecretDecryptFailed ("recovery phrase") message and staying distinct from the genuinely-absent IdentityKeyMissing. migrate_keystore_to_vault and encode_identity_blob_vault_first skip the KeyStorage clone in the steady-state (already-InVault) case via a new KeyStorage::has_plaintext_for_vault probe, so cold-boot load and identity re-saves no longer clone per identity for no benefit. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * docs(secret-seam): correct drifted docs to Tier-2 keep-protection reality - 01-ux-disclosure.md: full rewrite — the previous doc described the retired drop-protection design (password downgraded to file-permission only, one-time disclosure notices). Replaced with the Tier-2 keep-protection reality: protected secrets re-wrap under the same password, uses_password/has_passphrase stay true, migration is silent, no disclosure notices. Removed candy tally and agent byline. - 02-test-spec.md: update TS-LAZY-01/02/03 expected outcomes to Tier-2: scheme stays Protected, uses_password/has_passphrase stay true, second unlock still prompts (ask_count == 1). Added source-test names (ts_t2_01_*, ts_lazy_03_*). Removed machine-local plan paths, Marvin's note, and future-tense TDD framing. Added section-5 note that raw seam applies only to unprotected secrets. - user-stories.md WAL-006: replace false bullet ("no longer prompts, one-time notice") with the truth: Tier-2 re-seal, wallet keeps prompting, migration is silent. - CLAUDE.md wallet_backend/ bullet: remove dead TODO(per-secret-encryption) grep pointer (zero hits); describe present state — put_secret_protected/ get_secret_protected implemented; keyless-vault residual is deferred tier. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * feat(wallet-backend): optional per-identity at-rest encryption for identity keys (SEC-001) Identity keys default to keyless (Tier-1 raw, prompt-free) so headless/MCP signing of a non-opted-in identity is unchanged byte-for-byte. A user may opt in per identity to seal that identity's keys Tier-2 over the existing seam (Argon2id + XChaCha20-Poly1305) — no new crypto. The at-rest vault scheme is the single source of truth: scope_has_passphrase probes SecretSeam::scheme for the identity-key label (Protected -> prompt, Unprotected -> prompt-free, Absent -> IdentityKeyMissing), and decrypt_jit gains a symmetric Tier-2 arm. A protection-aware IdentityKeyView::store refuses a keyless write over a Protected label (IdentityKeyProtectionDowngrade), with store_unprotected as the deliberate opt-out downgrade. New crash-safe, idempotent migrations IdentityTask::Protect/UnprotectIdentityKeys re-seal an identity's keys keyless<->Tier-2 under one per-identity password. A display-only IdentityMeta sidecar carries the password hint + prompt copy (never the gate), seeded into the chokepoint's identity prompt index at identity load. UI: a collapsible 'Key Protection' section on the Key Info screen (default closed) with danger-gated opt-in (new password + confirm + strength + hint) and opt-out (verify) flows; PassphraseModalConfig gains remember_label so the sign-time prompt says 'key', not 'wallet'. Opted-in signing prompts just-in-time; headless yields SecretPromptUnavailable. Per-identity password isolation (TS-T2-IK-ISO twins TS-T2-SK-ISO). <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * fix(wallet-backend): seal new keys on a protected identity Tier-2, never keyless (SEC-001) Smythe MUST-FIX: a key added to a password-protected identity slipped through the per-label downgrade guard (a new key_id is scheme Absent), so AddKeyToIdentity -> insert_non_encrypted(Clear) -> encode_identity_blob_vault_first -> store_all wrote it Tier-1 keyless — a fully-capable signing key in plaintext on an identity the user believed protected. Two layers close it: (1) an identity-level fail-closed guard in encode_identity_blob_vault_first / migrate_keystore_to_vault refuses to move resident plaintext into the vault when the identity already has any Tier-2 key (IdentityKeyProtectionDowngrade / new KeystoreMigration::ProtectedSkipped), so a keyless write is impossible. (2) add_key_to_identity now seals the new key Tier-2 via SecretAccess::seal_new_identity_key, which prompts once, verifies the password against an existing protected key (so the identity stays under one password, with the standard wrong-pass re-ask), seals the new key, and marks it InVault before the save — headless yields SecretPromptUnavailable (fail closed; signing also fails closed earlier). KeyStorage::mark_in_vault performs the post-seal transition. SEC-002 (SHOULD-FIX): protect_identity_keys now re-enforces the password policy in the backend (validate_protection_password) so a non-UI caller cannot seal under a too-short password. SEC-003/SEC-004 tracked as code comments (store-guard TOCTOU bounded by the single-writer lock + UI in-flight gate; pre-opt-in plaintext may persist in freed filesystem blocks until reused). Tests: secret_access seal-new-key (seals Tier-2 under verified password / headless fails closed with no write / wrong-pass re-asks); identity_db encode+migrate refuse keyless on a protected identity; protect_identity_keys rejects a weak password. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * fix(identity): fail closed before broadcast when adding a key to a protected identity (SEC-001 O-2) Adding a key to a password-protected identity used to seal the new key Tier-2 (or fail closed) only during LOCAL persist, which runs AFTER the on-chain AddKeys broadcast. A headless add therefore broadcast the state transition on-chain and only then failed closed locally (no password) — leaving the key on-chain but never persisted by DET: an on-chain/local divergence. Move the protected-identity precondition BEFORE any on-chain side effect. `add_key_to_identity` now determines up front whether the identity is protected (`protected_identity_verify_scope`) and, if so, prompts for and VERIFIES its object password before building or broadcasting the state transition. Headless (`NullSecretPrompt` → `SecretPromptUnavailable`) or a wrong password returns the typed error before the broadcast, so no state transition is ever sent. The seal then runs after the broadcast with the already-verified password — a single prompt, split across the broadcast. `SecretAccess::seal_new_identity_key` is split into `verify_identity_object_password` (prompt + verify, returns an opaque `VerifiedIdentityPassword` that zeroizes on drop) and `seal_new_identity_key_with_password` (no prompt); the original composes the two and keeps its tests. The d965ca50 encode fail-closed guard (`IdentityKeyProtectionDowngrade`) stays as the defense-in-depth backstop. Also: O-1 — `mark_in_vault`'s bool return is now checked and warns on an unexpected miss (the encode guard still backstops it). O-3 — document that a Mixed identity fails closed on a plain re-save until "Finish protecting" reseals the remaining keys (intended secure behavior). <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * fix(identity): harden SEC-001 identity-key paths (r2 review) Address four thepastaclaw findings on the SEC-001 identity-key code at fcf6da15: - BLOCKING: `seal_identity_keys` now verifies the supplied password opens every already-`Protected` key BEFORE sealing any keyless one. A Mixed-state "Finish protecting" re-run with a different password is rejected up front with `IdentityKeyPassphraseIncorrect` and zero state changes, so an identity can never be split across two passwords. - `get_identity_by_id` now mirrors the bulk-load vault migration, so the single-get read path (and the SEC-001 protect/unprotect tasks that use it) migrates legacy resident `Clear`/`AlwaysClear` keys to the vault on read instead of returning and re-persisting plaintext. - A post-broadcast seal failure in `add_key_to_identity` now surfaces the typed, actionable `IdentityKeyAddedButNotSaved` (key is on-chain; retry after freeing disk space), preserving the upstream cause in the source chain — never a silent loss and never a keyless-write fallback. - The three prompt-meta setters recover a poisoned lock (`unwrap_or_else(|p| p.into_inner())`), matching `forget`/`forget_all`, so prompt-copy metadata can self-heal after a panicked reader instead of silently freezing. Adds regression tests for each (the blocker's split-prevention, read-path migration via an offline AppContext, and the typed orphan-error mapping). <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * docs(single-key): correct has_passphrase on-disk-shape doc to Tier-2-direct The has_passphrase field doc claimed fresh protected imports use a legacy AES-GCM envelope migrated on first unlock; imports seal Tier-2 directly at import time. Align the field doc with the function docstring. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(dashpay-e2e): use real curve points in tc_045 fixture (QA-008) The bumped secp256k1 now validates curve membership on `PublicKey::from_slice`, and `[0x02; 33]` / `[0x03; 33]` are not points on the curve, so tc_045 paniced with `Secp256k1(InvalidPublicKey)` before it could test anything. Swap the hand-written bytes for two deterministic pubkeys derived from fixed secret keys — stable across runs, valid on the curve, and matching the file's existing secret-key→pubkey idiom. Pure fixture fix; no product behavior involved. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(wallet-backend): return WalletNotFound for an unknown seed hash (QA-002) `GenerateReceiveAddress` for a seed hash that matches no wallet returned the transient `WalletNotLoaded` ("still loading, wait and retry") instead of `WalletNotFound`. The two mean very different things to a user: one is a permanent "this wallet does not exist", the other a momentary boot state. `resolve_wallet` cannot tell them apart on its own — a missing `id_map` entry covers both — and ~24 callers rely on its `WalletNotLoaded` for the genuine cold-boot case, so it must stay. Instead, resolve the existence question one layer up in `generate_receive_address`, where the DET-side wallet store (`self.wallets`) is the source of truth: unknown wallet -> `WalletNotFound`; known-but-not-yet-loaded -> `WalletNotLoaded`. This mirrors the sibling `generate_platform_receive_address`, which already does exactly this. Confirmed against design spec TC-019. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(core-e2e): expect SingleKeyWalletsUnsupported in tc_009 (QA-001) test_tc009 asserted `RefreshSingleKeyWalletInfo` returns `OperationRequiresDashCore` in SPV mode — but single-key wallets are intentionally unsupported this release (PROJ-007 / single-key-mock.md Decision #7: "Every operation returns `Err(TaskError::SingleKeyWalletsUnsupported)`", and refresh is one of those operations). The product correctly returns `SingleKeyWalletsUnsupported`, and the sibling TC-003 already asserts that — so test_tc009 was simply stale and contradicted both. Align its expectation (and its comments) with the by-design behavior. Also corrected TC-003's own header comment, which still described the superseded `OperationRequiresDashCore` outcome while its assertion already checked the right variant. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(identity): compute a meaningful top-up fee after a backend reload (QA-006) A wallet-funded identity top-up reported `actual_fee == 0` after a backend reload. The fee was derived inline as `amount*1000 - (new_balance - balance_before)`, where `balance_before` came from the passed-in (post-reload, stale) `QualifiedIdentity`. When that cached balance lags the real platform balance, the apparent increase exceeds the minted credits and `saturating_sub` collapses the fee to zero — physically impossible, since a top-up can never grow the balance by more than the asset lock mints. Move the computation into `model/fee_estimation.rs` (DET policy: no inline fee math) as `resolve_identity_topup_actual_fee`, and have it fall back to the deterministic estimate whenever the balance delta yields a zero fee — the reliable signal that `balance_before` was stale. The happy path is unchanged (a consistent delta still reports the real processing fee). Adds unit tests for both the consistent-delta and stale-balance branches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(spv-e2e): assert restart-in-place reconnect contract (QA-003) The B-reconnect test asserted `wallet_backend().is_err()` after `stop_spv()`, a leftover from the superseded drop-and-reopen design. The current lifecycle is restart-in-place by intent: `stop_spv` calls `stop_in_place()` and KEEPS the backend (and its `Arc<SqlitePersister>`) wired, so the next Connect fast-paths on the populated slot and restarts the SAME instance — the persister DB is never closed/reopened, making `AlreadyOpen` impossible by construction. This is exactly what the offline unit tests `stop_spv_in_place_keeps_backend_and_disconnects_indicator` and `reconnect_restart_in_place_reuses_backend` lock in, and the latter even names this e2e test as its live-network counterpart. Update the test to assert the real contract over a live network: backend stays wired and unstarted after `stop_spv`, and the reconnect reuses the same instance (`Arc::as_ptr` equality) with sync restarted. Header comment and the reconnect failure message rewritten to describe restart-in-place. Product code is correct as-is; the assertion was stale. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(wallet): gate sends on spendable balance, not confirmed (QA-010) Upstream classifies a UTXO as `confirmed` only once it is in a block, chain- locked, or flagged instant-locked locally; until then — including the window after an IS-lock but before the local flag is applied — it sits in `unconfirmed`. Coin selection draws from `spendable()` (confirmed + unconfirmed), and the "Max" button already reserves against `spendable()`, but several send paths still gated/validated on `confirmed`. The result: "Max" could exceed the validation, and sends coin selection would happily fund were rejected as "Insufficient confirmed balance" while funds showed as pending. Align the UI with the coin selector: - `send_screen::get_core_balance` -> `spendable()` (4 amount validations + the source-selector display). - wallets-screen send dialog validation -> `spendable()` (and drop the now-misleading "confirmed" from the message). - dashpay send_payment balance display + Max -> `spendable()`. No change to actually-correct sites: `snapshot_has_balance` already counts confirmed||unconfirmed, the MCP balances tool exposes all three buckets distinctly, and `.total` displays are intentional. Harness: `wait_for_spendable_balance` polled `.confirmed`, contradicting its own "spendable" contract, so it timed out whenever funding landed as IS-locked / unconfirmed. Poll `.spendable()` (the coin-selector set) and report it in the timeout diagnostic. Audit note: at the pinned platform-wallet rev (fb7953e / key-wallet 981e97f) IS-locked-FLAGGED UTXOs are classified `confirmed`, not `unconfirmed` — the balance has no separate IS-locked bucket. So `spendable()` (= confirmed + unconfirmed) is the correct, safe gate, not an over-count. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(identity-e2e): poll for key visibility after broadcast (QA-004) `identity_in_vault_sign` and `z_broadcast_st_tasks::tc_066` slept a fixed ~1s after broadcasting an IdentityUpdate, then re-fetched once and asserted the new key was visible. That single delay races DAPI propagation — the node serving the re-fetch may not have processed the block yet — so the tests failed spuriously even though the broadcast (and SEC-001 signing) succeeded. Replace the fixed sleep with a bounded poll: re-fetch the identity until the new key appears or a ~10s deadline passes, then assert. Test robustness only; no product change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(harness): retry transient wallet registration with backoff (QA-013) The framework-wallet register and `create_funded_test_wallet` both called `register_wallet` exactly once and panicked on any error. Under the shared- runtime backend-e2e harness the fail-closed sidecar writes (`WalletSeedStorage` / `WalletMetaStorage`) can briefly lose a SQLite race, and registration can surface the typed transient `WalletBackend` ("retry in a moment") signal — a single attempt then aborts init and masks the test under exercise (identity_create / identity_cold_boot). Add `register_wallet_with_retry`: bounded ~30s retry with backoff on the transient variants only (`WalletBackend`, `WalletBackendNotYetWired`, `WalletSeedStorage`, `WalletMetaStorage`); permanent errors surface immediately, and `WalletAlreadyImported` is returned as-is so the framework path keeps its idempotent-reuse branch. Wired into both registration sites. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(wallet-e2e): mark tc_012 address-advance assertion PENDING (QA-005) QA-005 disposition is DEFER: "same address on consecutive GenerateReceiveAddress calls" is correct, funds-safe BIP-44 keypool behavior (upstream `next_unused` returns the lowest UNUSED address until it is used on-chain). The fresh-each-call UX needs a reserve-on-hand-out API that does not exist in the pinned upstream. - Annotate tc_012's `assert_ne!(address1, address2)` as PENDING (commented out with a soft observation log) so the test passes on the current funds-safe behavior. tc_012b's gap-window funds-safety assertion stays active. - Enhance the existing `TODO(PROJ-015)` in `wallet_backend/mod.rs` to cite the fix's 3-layer propagation: dashpay/rust-dashcore#818 (`next_unused_and_reserve`, ready-for-review) → platform surface (`CoreWallet::next_receive_address_and_reserve_for_account`) → DET dep bump + switch `next_receive_address` to the reserving variant. Re-enable the `assert_ne!` once that lands. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(wallet-lifecycle): correct stop_spv rustdoc to restart-in-place (QA-015) The `stop_spv` rustdoc still described the superseded drop-and-reopen design ("drop the wired wallet backend", "WalletBackend::shutdown", "Unwire the backend"), none of which the implementation does. It calls `stop_in_place()` and KEEPS the backend (and its `Arc<SqlitePersister>`) wired, re-arming the start latch and coordinator gate so the next same-network Connect restarts the SAME instance — which is exactly why a reconnect cannot hit `WalletStorageError::AlreadyOpen` (the persister is never closed/reopened). Rewrite the doc to describe the actual restart-in-place semantics and note that full teardown (`WalletBackend::shutdown`, dropping the backend + releasing the persister) happens only on the network-switch and app-close paths, never here. Companion to the QA-003 test/e2e-header fixes. Doc-only; no behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(identity-e2e): widen cold-boot funding to clear top-up minimum (QA-016) `cd_cold_boot_identity_register_and_topup` funded 30M duffs, which after scenario C's asset lock + registration fees left 4,999,703 duffs — 297 below the 5M scenario-D top-up minimum, so scenario D failed on a buffer shortfall (the watch-only-no-private-key bug is already fixed; scenario C passes). Bump the funding to 35M so both transactions clear their network fees. Test-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(dashpay-e2e): defer dashpay backend-e2e module pending upstream (platform#3841) The dashpay backend-e2e tests fail because upstream `platform-wallet` dashpay support is incomplete. The completion lands in dashpay/platform#3841 ("fix(platform-wallet)!: complete dashpay", shumkov, branch feat/dashpay-m1-sync-correctness); we retest once it merges and the DET platform-wallet dep is bumped. - Comment out `mod dashpay_tasks;` in main.rs with a TODO(dashpay-e2e) citing #3841 and the affected tests (tc_032/033/036/037/041/043/044/045/046). - Add a matching deferral note to the dashpay_tasks.rs module doc. This removes 9 dashpay tests AND their SharedDashPayPair registration burst from the run. The QA-008 tc_045 fixture fix stays in the file, dormant until re-enabled. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(harness): widen funded-wallet SPV-pickup budget to 120s (QA-017) QA-013 was verified INNOCENT against the re-run log: the "retrying after backoff" warning logged 0 times, so `register_wallet_with_retry` never fired — all 17 timeouts were in `wait_for_wallet_in_spv` (the 30s SPV-pickup wait), downstream of the retry wrapper. Root cause is throughput saturation: the other fixes (and, before deferral, the dashpay tests) unmasked more funded-wallet registrations, and the suite runs serially (`--test-threads=1`), so as wallets accumulate in the upstream manager each later pickup round (bloom-filter rebuild + re-sync) exceeds the tight 30s budget. Give `create_funded_test_wallet`'s `wait_for_wallet_in_spv` the same 120s headroom the framework wallet already uses, via a named `FUNDED_WALLET_REGISTRATION_TIMEOUT`. Concurrency throttling is unnecessary — the run is already serial. Test-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(identity): fail closed when opt-in protection leaves resident plaintext keys protect_identity_keys could emit IdentityKeysProtected{count:0} when the silent get_identity_by_id vault migration failed (VaultWriteFailed), leaving Clear keys with Absent vault labels that seal_identity_keys skips. Guard the protect boundary with a typed error so the user retries instead of believing the identity is sealed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(identity): prove the protect fail-closed guard is wired into the task (QA-001) The guard's wiring was unverified: deleting the call passed every test because the only fail-closed test invoked the helper directly and the end-to-end test was the happy path. Extract the post-load protect logic into protect_loaded_identity_keys (called by protect_identity_keys after get_identity_by_id) and add a test that drives it on a qi carrying resident plaintext, asserting IdentityKeyProtectionIncomplete. Deleting the guard line now turns that test red (it returns IdentityKeysProtected{count:0}). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(fee-estimation): fall back to estimate when balance_before is stale-HIGH (RUST-001) The real-fee branch was gated only on `delta_fee == 0` (stale-LOW). When `balance_before` is stale-HIGH (`balance_after <= balance_before`), `balance_increase` saturates to 0 and `delta_fee` equals the full minted amount, producing a wildly wrong "fee" (e.g. 5 M duffs → ~5 B-credit fee). Gate the real-fee branch on `0 < delta_fee < expected_credits` so both extremes fall back to the deterministic estimate. Add a unit test for the stale-HIGH case. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(identity-db): zeroize rollback clone after successful vault migration (SEC-002) `before = qi.private_keys.clone()` holds raw identity private-key bytes (Clear/AlwaysClear) as a rollback guard. On the success path it was dropped UN-zeroized, leaving plaintext on the freed heap. Call `before.take_plaintext_for_vault()` immediately after the vault write succeeds — the method already zeroizes each `[u8; 32]` in-…
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>
5 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.