feat: platform-wallet backend rewrite (spec + implementation)#860
feat: platform-wallet backend rewrite (spec + implementation)#860lklimek wants to merge 358 commits into
Conversation
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
refactor: complete data.db unwire — shielded + DashPay (stacked on #860)
Doc sync —
|
The Connect handler only logged a warning when ensure_wallet_backend_and_start_spv failed at the (fallible) wiring step, leaving the user with no feedback and the indicator on Disconnected. Flip the SPV indicator to Error and show an actionable banner on failure; this also makes the previously-dead error-surfacing path reachable via wiring failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SPV-start fix (PROJ-001, CRITICAL) + consolidated gap report —
|
TaskError::WalletStorage rendered "check disk space and restart" for every storage failure, including a forward-version database (schema written by a newer build), where that advice is wrong and leaves the user stuck. Add a dedicated typed variant matched on the upstream WalletStorageError, with an actionable message telling the user to update the app. Disk/IO failures keep the original copy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stream-owned caches Bump platform to 08b0ed9 and adapt DetKv to the ObjectId-scoped KvStore via a DET-side DetScope (Global/Wallet wired; Identity/Token reserved for Wave 2). Isolate the redundant platform-address and token-balance caches behind PlatformAddressView/TokenBalanceView seams with reserved upstream-reading impls (stubbed pending platform todos e817b66a / f5897abd); caches stay active, no behaviour change. Removal is a one-line swap once upstream exposes public readers. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ance seam Map the upstream divergent-migration open failure to an actionable typed error instead of the misleading "check disk space" message (extends the WB-001 pattern). Route the devnet token sweep through TokenBalanceView and dedupe the key prefix so the seam is honoured everywhere. Add a dev note on the on-disk schema break requiring a local wallet-DB reset on this branch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…alance reader) The kv module dropped ObjectKind and KvError::ObjectNotFound upstream: meta_* writes now succeed without a parent row (AFTER DELETE triggers reap orphans), so DET's ObjectNotFound-promotion path is dead. Removed the ObjectKindLite mirror + variant, collapsed map_kv_error to a plain Store wrap, and repurposed the K9 test to assert the generic Store arm. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ream Upstream (35e4a2f) now exposes IdentitySyncManager::state_for_identity/all_state for per-(identity,token) balances, so DET's duplicate det:token_balance cache is removed and TokenBalanceView reads upstream via a blocking snapshot bridge. Pre-sync balances show "syncing" (not zero); DET no longer persists/zero-seeds. The upstream identity-sync registry was never populated (start() ran but no register_identity), so reading all_state() alone would be empty forever. The balance backend tasks now register each local identity's watched-token set, force a sync pass, and republish a lock-free DET-typed snapshot the egui frame reads infallibly. Token mutation tasks apply their proof-derived balance into the snapshot for instant feedback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With the upstream meta_* FK relaxed (35e4a2f), move det:identity / top_ups / scheduled_vote to Identity(id) and dashpay private/address_index to Identity(owner). Add a Global identity-id index for enumeration; rely on the upstream soft-cascade trigger for meta_identity cleanup where DET deletes the upstream identity row, explicit cleanup otherwise. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DetScope::Identity is now active (identities, top-ups, scheduled-votes, dashpay private/address_index); det:token_balance removed (read upstream); add the two enumeration-index keys and fix kv-keys.md summary counts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
DetKv per-object refactor + platform bump to
|
After the platform-wallet migration the SPV sync UI read a hardcoded SpvStatusSnapshot::default() — an inert, empty snapshot — so the progress bars, phase summary, peer count and status labels stayed blank even while chain sync was actively running. EventBridge::on_progress already receives the full upstream SyncProgress (per-phase current/target heights, percentage) but only collapsed it into a coarse SpvStatus and threw the heights away. Now it publishes the live SyncProgress into ConnectionStatus, keeping ConnectionStatus the single source of truth for connection health. The network and wallet screens read the live snapshot via ConnectionStatus::spv_status_snapshot(), driving the existing determinate per-phase progress bars (full height/target → "X / Y, NN%"). Also fixed two related dead-default reads in the network chooser: the network selector now disables mid-sync and the developer "Clear SPV Data" control now gates on live status. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Today the button only pruned DET's local My Tokens ordering, so the upstream IdentitySyncManager kept watching the (identity, token) pair and the row marched right back on the next balance refresh. Not exactly the spirit of "stop tracking" — more like a game of whack-a-token. Now it un-watches for real. New WalletBackend::unwatch_identity_token reads the identity's current watched set, drops the one token, and replaces it via the upstream update_watched_tokens (preserving the rest of the cache). A new TokenTask::StopTrackingTokenBalance does the un-watch plus the local prune, and the confirmation dialog dispatches it instead of mutating state synchronously. The background sync no longer re-adds the pair; an explicit "Refresh all my tokens" still re-tracks everything known, by design. Upstream Identifier types stay inside the wallet_backend seam. Adds user story TOK-018. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
get_platform_activation_height returned Ok(1) for every network, which is fine for devnet but wildly optimistic for mainnet/testnet. Now it returns the Core block height at which Platform actually activated (the mn_rr L1 locked height) per network, mirroring the SDK's own trusted context provider: mainnet 2_132_092, testnet 1_090_319, devnet/regtest 1. The previously-ignored network field is now used (renamed from _network). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
expires_at was always None. Now it's derived as created_at plus the same DASHPAY_REQUEST_EXPIRY_DAYS window the status derivation already uses, both in milliseconds (matching upstream's TimestampMillis created_at). Factored the threshold into request_expiry_threshold_ms so the derivation and the status check share one source of truth, with checked arithmetic that yields None rather than wrapping on an impossible overflow. Covered by two new unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The tool used to hand back a pre-formatted text blob with a hardcoded limit — friendly to humans, hostile to machines. Now it returns structured entries (document_id, owner_id, amount_credits, status, address, transaction_index, created_at_ms, updated_at_ms) plus a total and a next_cursor, and accepts limit / start_after pagination params. DET-side only: adds a PlatformInfoTaskRequestType::Withdrawals request variant and a PlatformInfoTaskResult::Withdrawals result variant carrying a new WithdrawalRecord type, with a shared extract_withdrawal_record helper. The GUI keeps its existing text variants untouched. Status strings are a stable lowercase machine form, distinct from the human Display. Docs updated with the new params and a pagination example. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ess_usage block PROJ-003: update_payment_status was a logging no-op that never persisted status transitions. Wire it to the same WalletBackend mirror path mirror_sent_payment_to_backend uses: read the existing PaymentEntry, update its status (preserving counterparty/amount/memo under upstream's last-write-wins record), and stamp confirmed_at on confirmation. Add a pure det_status_to_upstream mapper (Broadcast collapses to upstream Pending) with unit coverage. Signature now takes owner + tx_id, the keys the upstream record is addressed by; the function had no callers. PROJ-003 sibling (check_address_usage): an upstream usage reader exists at platform 35e4a2f (account_address_pools_blocking -> is_used, SPV-tracked), but it is keyed by (wallet_id, AccountType), not arbitrary address, and the only DashPay addresses with derivation context are contact-SEND addresses that never live in our managed pools. Wiring it would still report all-unused — fabricating usage corrupts gap-limit math. Left the honest all-unused stub with a precise BLOCKED rationale in the doc comment. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The send_contact_request path derived the published contact xpub from a placeholder (the sender's 32-byte ECDH private key used as the HD seed), so the receiving addresses had no relation to the wallet's real HD tree. Contacts would pay into addresses we never derive — mis-associated and unrecoverable funds (the TC-037/043 symptom substrate). Route derivation through upstream platform_wallet::derive_contact_xpub (single source of truth) behind a wallet_backend seam. The real 64-byte HD seed is read via Wallet::seed_bytes() and only the published byte material + account reference cross out of the seam — key_wallet / ExtendedPubKey / ContactXpubData types stay inside it (M-DONT-LEAK-TYPES). Adds a typed TaskError::ContactWalletSeedUnavailable (no String message fields) for the locked-wallet case. Seam tests pin the upstream xpub against DET's receive-side derivation: they agree on mainnet and a documented test + TODO flag the testnet coin-type divergence (upstream 1' vs DET's hardcoded 5') that the receive side must still migrate. SECURITY REVIEW + LIVE NETWORK TEST REQUIRED before merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n (SEC-001) The receive-side DashPay derivation hardcoded coin-type 5' on every network, so on testnet the locally re-derived scanning xpub (5') no longer matched the send-side xpub the contact pays into (1' per the spec). Funds landed on addresses the recipient's wallet never scanned. Add a canonical coin_type_for_network() helper alongside the existing DASH_COIN_TYPE / DASH_TESTNET_COIN_TYPE constants and thread it through every DashPay HD path: the DIP-14 contact xpub, the DIP-15 root encryption key, the auto-accept proof key, and the representational known-address path. The helper mirrors key_wallet::dip9 and upstream AccountType::derivation_path exactly (5' Mainnet, 1' Testnet/Devnet/Regtest), so send and receive now agree on every network. Tests: flip the testnet divergence pin to assert send==receive equality, add parent_fingerprint to the mainnet and testnet pins, and add a both-network full-field (public_key, chain_code, parent_fingerprint) equality test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mock/stub findings + SPV progress + DashPay fund-routing —
|
Fast-forward the dashpay/platform git pin from 35e4a2f to ffdc28b8 (PR #3625 head, 15 commits ahead, 0 behind). Bumps all four direct crates pinned to the platform rev: - dash-sdk - rs-sdk-trusted-context-provider - platform-wallet - platform-wallet-storage All now resolve to v3.1.0-dev.8. The transitive grovedb pin moves 60f29685 → 5eb7a538 (pulled in by upstream merge of v3.1-dev, #3732 Orchard genesis shielded pool); grovestark is a separate repo and is untouched. No source changes required — the bump is API-clean for everything DET consumes across the wallet_backend seam: - CMT-001 (single-read KV get, size-cap TOCTOU): internal to upstream SqlitePersister; the KvStore::get/put/delete/list_keys signatures are unchanged, so DetKv (src/wallet_backend/kv.rs) and its in-memory test impl still satisfy the trait. All 9 DetKv adapter tests pass; the [SCHEMA_VERSION(1) | bincode] envelope is intact. - 73eb0ae0 (SPV deadlock-on-stop): behavioral fix; PlatformWalletManager shutdown surface (WalletBackend::shutdown → pwm.shutdown) unchanged. Build, clippy (-D warnings) and fmt all clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Re-ran the consolidated gap audit against the live tree at 450214e, verifying each gap line by line rather than trusting commit messages. Flipped to RESOLVED (5): PROJ-001 (SPV start wired, 36f5a98), PROJ-003 (update_payment_status persists, 3ac9b3b), PROJ-004 (contact-request xpub from real HD seed, 6c520a3 + SEC-001 coin-type follow-up 450214e), PROJ-006 (real per-network activation heights, 7e2553e), PROJ-014 (start-path offline tests). Plus 4 appendix/seed items: tokens un-watch (5a04735), expires_at derivation (a7327e7), MCP withdrawals pagination (5ba4554), SPV progress bar (bd0ed0e). Added (2): PROJ-022 (UpstreamPlatformAddresses reserved swap-target with unimplemented!() read methods — deferred-by-design, parallels PROJ-010); PROJ-023 (string-based error matching in add-contact UI — pre-existing convention violation, RUST-002/issue #660). Plus PROJ-024 documented BLOCKED-BY-DESIGN (check_address_usage all-unused). Merge-blocker verdict: no CRITICAL open. PROJ-005 (HIGH, platform pin tracks unreleased dev rev — moved 17653ba8 -> 35e4a2f6) is the sole remaining release gate. Totals: 22 confirmed, 17 open, 5 resolved. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Platform dep bump →
|
…-025) DET's send_contact_request_with_proof called sdk.document_create but never updated ManagedIdentity.sent_contact_requests locally. The upstream add_incoming_contact_request gate only promotes a contact to established_contacts when sent_contact_requests[peer] already exists; without this call dashpay_sync never auto-populates established_contacts, so established_contact_pairs() always returns empty and contact receiving-account registration is silently skipped for every user who sends from DET. Fix: after document_create succeeds, call WalletBackend::record_sent_contact_request which acquires the wallet-manager write lock, finds the ManagedIdentity by owner_id, and calls managed.add_sent_contact_request(contact_request, persister). The persister write is best-effort (logs on failure); the state-transition was already committed to Platform so this is non-fatal. accept_contact_request delegates to send_contact_request_with_proof and therefore inherits the fix automatically. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Documents the root cause and resolution of QA-025 (HIGH): DET's custom send path never wrote to sent_contact_requests, silently breaking the auto-establishment gate in dashpay_sync for all users who sent contact requests from DET. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…process (QA-025 Option A) sync_contact_requests skips a received document when sent[sender] already exists (guard: sent || incoming || established → continue). After d34ffae populated sent[A] via record_sent_contact_request, dashpay_sync would skip A's incoming CR, leaving established_contacts empty and registration at 0. Fix (Option A, accept path only): in accept_contact_request, BEFORE calling send_contact_request, parse the fetched incoming document into a ContactRequest and call WalletBackend::record_incoming_contact_request — which calls managed.add_incoming_contact_request(A_cr, persister) so incoming[A] is populated. Then when send_contact_request_with_proof fires record_sent_contact_request → add_sent_contact_request(B→A), it finds incoming[A] and auto-establishes established_contacts[A] in-process. auto-establish sequence: 1. record_incoming_contact_request → incoming[A] populated 2. send_contact_request_with_proof → record_sent_contact_request → add_sent_contact_request(B→A) → incoming[A] found → established[A] set 3. established_contact_pairs() → [(owner, A)] → registration runs → count > 0 Only applied to the accept path where an incoming CR exists. Plain outbound send does not have a pre-existing incoming entry and uses a separate auto-establishment path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…wallet-migration-design
The Connect → Disconnect → Connect sequence was failing with
`WalletStorageError::AlreadyOpen` on the second connect.
Root cause: `WalletBackend::shutdown()` called only `pwm.shutdown()`,
which quiesces coordinators and joins the event adapter but does NOT
stop the SPV background task. That task holds `Arc<SpvRuntime>`, which
transitively keeps `Arc<SqlitePersister>` alive via the chain:
SpvRuntime → event_manager → BalanceUpdateHandler → wallets
→ Arc<PlatformWallet> → WalletPersister::inner
The upstream `platform-wallet-storage` crate uses a process-global
`REGISTRY` (`OnceLock<Mutex<HashSet<PathBuf>>>`) that registers a path
on `SqlitePersister::open` and removes it only on `Drop<SqlitePersister>`.
While the SPV task runs the path stays registered, so the reconnect's
`WalletBackend::new` fails with `AlreadyOpen`.
Fix: call `self.inner.pwm.spv().stop().await` **before**
`pwm.shutdown()`. `SpvRuntime::stop()` takes the SPV client, signals
the run loop to exit, and joins/aborts the background task (with a 15 s
abort fallback). Once the task is gone, its transitive ref chain is
released; when the `WalletBackend` itself drops the remaining structural
refs (inside `PlatformWalletManager`) drop synchronously, clearing the
REGISTRY entry before the reconnect opens the same path.
Ordering is safe: stopping the SPV producer before the coordinator
consumers is correct — no new events can arrive, and any in-flight
`sync_now` pass completes before `quiesce()` returns.
Also improve the regression test:
- Replace the misleading `Weak<WalletBackend>::strong_count` polling
band-aid (it was watching the wrong Arc — the SPV task never held
`Arc<WalletBackend>`) with a direct `SqlitePersister::open` assertion
immediately after `stop_spv()`.
- Add a comment explaining the offline limitation (the task exits fast
offline, so the test may pass without the fix in offline mode; the
authoritative guard is the online backend-e2e suite).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…h-only fix) `provision_identity_funding_account` was calling `kw.add_account(account_type, None)` which internally calls `root_extended_priv_key()` on the live watch-only wallet to derive a hardened account path. Watch-only wallets have no private key, so this fails with "Watch-only wallet has no private key" every time. Fix (mirrors `register_contact_receiving_accounts`): - Add `seed: &[u8; 64]` parameter to `provision_identity_funding_account` and `ensure_identity_funding_accounts`. - When the account is not yet in the watch-only wallet, build a short-lived `UpstreamWallet::from_seed_bytes` (signable), derive the hardened xpub via `seed_wallet.derive_extended_public_key(&account_type.derivation_path(network))`, and pass it as `Some(account_xpub)` to `kw.add_account`. The seed wallet is dropped at end of scope. - Move the `ensure_identity_funding_accounts` call INSIDE the `with_secret_session` closure in all three callers (`create_asset_lock_proof`, `register_identity`, `top_up_identity`) so the HD seed from the session is available for derivation. Each caller extracts the seed via `session.plaintext().expose_hd_seed().ok_or(WalletStateInconsistent)?`. If the scope is `HdSeed` (which all three callers enforce via `hd_scope`), `expose_hd_seed()` always returns `Some`; `None` is an unreachable invariant violation mapped to `WalletStateInconsistent`. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…count provisioning (C.7) Adds an offline deterministic test that verifies `ensure_identity_funding_accounts` succeeds on a watch-only wallet (the live state DET always operates in). Before commit 98bc491 the function called `kw.add_account(account_type, None)` on the seedless upstream wallet, reaching the hardened-derivation gate and failing: "Watch-only wallet has no private key" After the fix it derives the account xpub from a short-lived signable wallet built from the provided seed bytes and calls `kw.add_account(account_type, Some(xpub))`, which succeeds on any wallet regardless of private-key availability. Deterministic: the live wallet is unconditionally watch-only — no timing dependency, no network required. Also verifies idempotency (second call is a no-op). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous test (b851790) was incorrect: `register_wallet_from_seed` creates a FULL upstream wallet with private keys, so `add_account(None)` succeeds and the pre-fix failure was never reproduced. Replace with a proper two-boot cold-boot test: Boot 1 — writes wallet-meta sidecar (xpub bridge for fund-routing gate) and upstream persister from seed (`WalletAccountCreationOptions::Default` creates IdentityRegistration in the manifest, but NOT IdentityTopUp{n}). Boot 2 (cold) — `WalletBackend::new` over a copied data dir runs `load_from_persistor_seedless` → upstream `Wallet::new_watch_only`: the wallet has its BIP44/BIP32/IdentityRegistration accounts from the persisted manifest but **no root private key**. `IdentityTopUp{3}` is absent from the manifest, so the provisioning branch is taken. Verified fails-before / passes-after: Before fix: FAILED with WalletBackend { source: AssetLockTransaction( "Invalid parameter: Watch-only wallet has no private key") } After fix: PASSED in 21s offline; idempotency call also Ok. Deterministic: cold-booted wallet has no root private key by design; IdentityTopUp{n} is absent from the default manifest on every first use. No network or timing dependency. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…andler Delete the SPV-dead MN-list-diff screen (4481 LOC), all MnListTask backend variants, CoreP2PHandler, backend-e2e tests, and every reference across app.rs, ui/mod.rs, left_panel.rs, and the tools-subscreen chooser. All remaining Tools subscreens are SPV-safe, so the requires_core_rpc gate and its unit tests are also removed. Files deleted (5 559 LOC): src/ui/tools/masternode_list_diff_screen.rs src/backend_task/mnlist.rs src/components/core_p2p_handler.rs src/components/mod.rs tests/backend-e2e/mnlist_tasks.rs tests/backend-e2e/framework/mnlist_helpers.rs build+clippy+tests green (998 tests, 0 failed). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Session update — 5 commits pushed (
|
…C/D) tests
Scenario B — `spv_reconnect_succeeds_without_already_open`
Isolated AppContext; drives connect → stop_spv → reconnect via
ensure_wallet_backend_and_start_spv. Asserts no AlreadyOpen and SPV
peers found on both legs. Regression for the WalletBackend::shutdown
fix that joins the SpvRuntime run-loop before re-opening the persister.
Funding: none — only needs testnet egress (TCP 19999).
Scenarios C+D — `cd_cold_boot_identity_register_and_topup`
Shared harness; creates a funded test wallet, registers an identity
via RegisterIdentityFundingMethod::FundWithWallet (scenario C), then
tops it up via TopUpIdentityFundingMethod::FundWithWallet (scenario D).
Both paths call ensure_identity_funding_accounts which provisions
IdentityTopUp{n} on a watch-only upstream wallet — the pre-fix
add_account(type, None) path that failed with "Watch-only wallet has
no private key" (fix: 98bc491). Deterministic offline regression
lives in wallet_lifecycle.rs (acdd133); this e2e test validates the
full BackendTask integration on a live testnet.
Funding: ≥ 0.6 tDASH in framework wallet.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ch event wait The old loop polled overall_state (Synced) every 1s. In headless/MCP mode the DAPI availability counter — gated by overall_state == Synced — is only refreshed by the GUI frame-loop trigger_refresh call, which never runs there. Result: ensure_spv_synced blocked for the full 600s backstop even after the chain was fully synced (visible as MCP tool hangs on headless det-cli). Fix: wait on SpvStatus::Running, which is push-based (set by the wallet- backend EventBridge on_progress callback, not by the poll loop). SpvStatus:: Running is the correct gate: it means chain headers + filters are synced and proof-verifying DAPI calls can proceed. DAPI availability is a separate concern not required at this chokepoint. Changes: - connection_status.rs: add tokio::sync::watch::Sender<SpvStatus> field; set_spv_status() broadcasts every transition (deduped via send_if_modified); subscribe_spv_status() creates on-demand receivers; reset() mirrors Idle for network-switch coherence; begin_spv_stop bypasses (Stopping is not a waited-on state — intentional). - resolve.rs: subscribe before calling ensure_wallet_backend_and_start_spv (no lost wakeup); borrow_and_update loop → Running=Ok, Error=Err, others keep waiting; tokio::time::timeout(600s) backstop preserved; remove SPV_WAIT_POLL_INTERVAL and OverallConnectionState import. Unit tests (all synchronous, no network): - subscribe_spv_status_wakes_on_running - subscribe_after_running_reads_immediately (lost-wakeup guard) - subscribe_spv_status_wakes_on_error - reset_mirrors_idle_to_watch Edge-case parity preserved (no behavior change flagged): - Stopping is bypassed in the watch (begin_spv_stop writes atomic directly); waiters see Stopping only if it passes through set_spv_status — it does not, so they keep waiting to the backstop. Identical to the old poll behavior. - Network switch reset → Idle sent to watch; waiters continue. - Multiple concurrent waiters: watch broadcasts (fine). - Error returns immediately. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause (Marvin §B-2): WalletBackend::shutdown() stops the SPV
run-loop (releasing its Arc<SqlitePersister>), but
PlatformWalletManager::shutdown() only quiesce()s the three sync
coordinators (identity_sync, platform_address_sync, shielded_sync).
quiesce() is cancel-and-drain, NOT join — each coordinator runs on a
detached std::thread that is never joined and transiently holds
Arc<SqlitePersister>. After shutdown().await returns and the
Arc<WalletBackend> drops, those threads are still winding down, so
SqlitePersister::Drop has not run and the path stays registered in the
process-global REGISTRY. An immediate reconnect's
SqlitePersister::open(path) then fails with WalletStorageError::AlreadyOpen.
Fix: add await_persister_released() — a bounded-poll barrier that
probes SqlitePersister::open(path) after shutdown + Arc drop. A
successful open proves the coordinator threads dropped their last Arc
clone (REGISTRY is clear); the probe is dropped immediately (re-freeing
the path) and stop_spv proceeds. AlreadyOpen → sleep 20ms + retry.
Any other error (IO, schema, etc.) is not this barrier's concern —
logged and returned so the reconnect path surfaces it properly. Bounded
at 5 s / warn + proceed to never hang the disconnect path.
Structural matching only: matches WalletStorageError::AlreadyOpen { .. }
— no string parsing.
New unit test: await_persister_released_waits_for_registry_release
— holds a SqlitePersister open (path in REGISTRY), spawns the barrier
concurrently, proves barrier is still looping while held, releases,
proves barrier returns promptly, proves path is free after. Fully
deterministic — no reliance on coordinator-thread timing.
TODO(upstream): make PlatformWalletManager::shutdown() join coordinator
threads (keep JoinHandle / signal oneshot at thread end) so the barrier
is unnecessary. Filed as code comment in await_persister_released.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Pushed The B-2 barrier is verified as an interim stopgap (closes the common coordinator-thread drain race). The durable fix — a reusable cancel + await-exit shutdown primitive ( 🤖 Co-authored by Claudius the Magnificent AI Agent |
… stays live) Implements the DET-side foundation for Option A (keep-alive + SPV/coordinator restart-in-place), staged so the branch is Q3-safe at every commit. The live reconnect path is UNCHANGED (drop+rebuild + await_persister_released barrier), which is immune to the upstream platform_address_sync restart race because it builds fresh coordinator instances each reconnect. What this adds (dormant until the flip): - coordinator_gate.rs: make CoordinatorGate re-armable. action is now Mutex<Option<StartAction>> (was OnceLock) and a reset() clears action + fired + masternodes_ready, so a reused gate re-fires the coordinators on a reconnect. masternodes_ready is cleared because a restart re-syncs the masternode list from scratch; coordinators must re-wait for Synced or they fire proofs before quorums exist and self-ban. - wallet_backend/mod.rs: StartLatch::reset() re-arms the one-shot start latch; WalletBackend::stop_in_place() stops SPV (pwm.spv().stop()) and quiesces the 3 coordinators via their Arc accessors WITHOUT pwm.shutdown() (which would cancel+join the non-restartable wallet-event adapter), then re-arms the start latch + gate so start() can restart on the SAME backend. ensure_wallet_backend already fast-paths on a populated slot, so a reconnect reuses the instance. - Tests: reset_re_arms_gate_for_restart_in_place, start_latch_reset_allows_restart (run normally); reconnect_restart_in_place_reuses_backend (#[ignore], asserts same-backend reuse + restart, no AlreadyOpen). HARD DEPENDENCY (why this is gated, not wired): restart-in-place re-start()s the SAME platform_address_sync instance, which lacks the background_generation guard its siblings have in the pinned platform rev 925b109. Against the guard-less rev a rapid reconnect can leak an uncancellable / duplicate platform-address loop (Q3). So stop_spv is NOT switched to restart-in-place and the barrier is NOT deleted here. A TODO at the stop_spv flip site documents the activation: land the upstream guard in branch fix/wallet-core-derived-rehydration (dashpay/platform #3828 tracks it), cargo update the platform crates to a rev that contains it, then flip stop_spv to stop_in_place and delete await_persister_released. Out of scope (noted): DET-subtask cancellation (Marvin V-1) — under keep-alive the retained persister Arc makes a lingering subtask harmless for AlreadyOpen; still worth doing later for clean app-exit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(overlay): requirements + UX spec for blocking progress overlay Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(overlay): test case specification 49 TCs covering FR-1..FR-10, NFR-1..NFR-6, and R-7 kittest checklist. Items depending on the FR-10 concurrent-overlay architecture decision (stack vs. replace vs. reject) and the stuck-overlay threshold (R-4) are marked [depends on 1d] for Nagatha to resolve. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(overlay): development plan and architecture decisions Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(overlay): generic button facility + Component trait conformance Folds in two user-mandated redesigns of the blocking progress overlay that the prior session did not land: Redirect 1 — generic button facility (no first-class Cancel). The overlay knows nothing about cancellation. `OVERLAY_CANCEL_ACTION_ID`, `with_cancel`, `CANCEL_LABEL`, and the Esc->Cancel routing are gone. A caller attaches a generic button via `OverlayConfig::with_button(id, label)` / `OverlayHandle::with_button(id, label)`, choosing its own opaque action id and label. A click enqueues the id; the owning screen drains it via `take_actions` and runs whatever logic it wants — including its own cancellation. Esc/Tab/Enter are swallowed so a hard block is never keyboard-dismissable. Redirect 2 — `Component` trait conformance (placement legitimacy for `src/ui/components/`). `ProgressOverlay` is now a struct holding `state: Option<OverlayState>`; `Component::show` renders that instance's card and returns `ProgressOverlayResponse` (`DomainType = String`, the clicked action id), with `current_value()` reporting the last clicked id. The global `render_global` path is preserved as the production entry point; the instance `show()` is additive, mirroring `MessageBanner`. Also: clamp the card to the window so it never runs off-screen in a narrow window (FR-6); settle the centered card in the kittest click/focus cases before interacting (anchored CENTER_CENTER needs a few frames to cache its size). Docs: dev-plan gains a post-outage note superseding D-5/FR-7; test-spec reframes the Cancel-specific cases to the generic-button model. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(overlay): align D-5 and risk notes to the generic-button redesign Rewrites the D-5 decision body and §8 risk #3 in place to drop the stale `with_cancel`/`OVERLAY_CANCEL_ACTION_ID` framing and describe the generic `with_button(id, label)` facility instead — consistent with the post-outage note added at the top of the plan. Documentation only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(overlay): add ignored probe proving button-less keyboard-block gap (QA-001) TC-OVL-029 only exercises a with-button overlay, where the first button steals focus on raise, so typing is blocked incidentally rather than by the overlay's input handling. This probe raises a button-less hard block over an already-focused field (the J-2 broadcast / J-4 migration case) and asserts FR-8 AC-8.2: typed input must not reach the field beneath. The probe currently FAILS — render_global filters Tab/Enter/Esc only after the beneath widgets have consumed input that frame, and a button-less overlay has no first button to steal focus, so keystrokes leak into the focused field beneath. Marked #[ignore] so the suite stays green; un-ignore once the overlay claims keyboard focus / consumes text while active. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(overlay): frame-start input claim (QA-001) + clear action queue on switch (SEC-007) Implements two QA-wave findings from the design addendum (§1 A-2, §2 A-4): - QA-001 (HIGH) — button-less keyboard/text leak. `render_global`'s key filter runs at end-of-frame, one frame too late: a button-less hard block raised over an already-focused field let typed characters reach the field beneath (the J-2 broadcast / J-4 migration case). New `ProgressOverlay::claim_input(ctx)`, called near the top of `AppState::update` (before the panels) and gated on no active secret prompt, releases beneath text-edit focus and strips `Event::Text` plus the navigation/confirm keys (Tab, Enter, Escape, Space, arrows). The `#[ignore]`d probe `qa_buttonless_overlay_blocks_typing_into_focused_field_beneath` is un-ignored and now passes. - SEC-007 — `clear_all_global` (network switch) now also drains the action queue, so a click queued just before the switch cannot survive into the new context and be mis-dispatched. Adds inline unit tests: `claim_input` strips text + nav/confirm keys while a block is up and is a no-op when idle; `clear_all_global` clears the queue. Scope note: this is a partial pass on the QA list. The end-of-frame filter in `render_global` is kept as belt-and-suspenders and is NOT yet gated on a secret prompt (marked TODO at the call site — blocker #2's full fix removes it and routes the keyboard tests through `claim_input`). Still outstanding from the addendum / task: A-1 no-progress watchdog, A-3 keyed `OverlayHandle::take_actions` + `sweep_orphan_actions`, instance `Component::show` focus-trap separation, secondary-button styling, 30s clock seam, Foreground layering, and doc sync. Also adds Nagatha's `04-design-addendum.md` (the authoritative spec). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(overlay): QA-wave hardening — watchdog, keyed dispatch, secondary buttons, Foreground, focus separation Implements the design addendum (§1/§2) plus the rest of the QA fix list and the three cross-finding reconciliations. All on top of the earlier claim_input/SEC-007 pass. Addendum §1 (safety-valve / A-1): - 120 s no-progress watchdog: STUCK_OVERLAY_WATCHDOG_THRESHOLD, OverlayState { last_progress_at, watchdog_logged }, watchdog_tripped() clock seam, escalated STUCK_WATCHDOG_REASSURANCE (replaces the soft line, never stacks), one-shot tracing::error! (no flaky time-based panic). last_progress_at is bumped on a real content change, reusing log_overlay_state's change detection, so a progressing multi-step flow never trips it. Addendum §2 (action-dispatch / A-3, SEC-007/A-4): - Actions are keyed: OverlayAction { key, action_id }. OverlayHandle::take_actions() drains only its own ids (FIFO); clear() purges its key's pending ids; the static take_actions is demoted to sweep_orphan_actions() (dead-owner ids only). app's drain logs orphans. clear_all_global already clears the queue (SEC-007). Reconciliations (lead brief): 1. SEC-004/F-1 — claim_input is gated on no active secret prompt at the app site, and render_global no longer strips keyboard at all (the gated claim_input is the sole keyboard block); release-beneath-focus is button-less only (stop_text_input clears ANY focus, which would steal a button's focus otherwise). 2. QA-002 — claim_input strips Space (and render_global's removal means the kittest keyboard path runs through claim_input). TC-OVL-044 now also presses Space. 3. QA-003 — render_card/render_buttons take trap_focus; the instance Component::show passes false so it never seizes the host screen's focus or installs the lock. Rest of the list: - SEC-002: overlay dim/sink/card raised to Order::Foreground (above ComboBox / autocomplete / SelectionDialog popups); passphrase modal also raised to Foreground so it stays above the overlay (R-1, TC-OVL-048). - F-3/4/7: ButtonStyle { Primary, Secondary }, with_secondary_button on OverlayConfig/OverlayHandle/instance, ConfirmationDialog-style right_to_left layout (primary right, secondary left). - SEC-005: corrected the Send+Sync note to the real invariant (UI-thread-only ops). - F-6: Elapsed uses a named placeholder. SEC-006: log-content doc note on show_global. - QA-007: instance clear() makes the empty-response path reachable. - QA-008: TC-OVL-013b asserts elapsed >= 2s; TC-OVL-021 also bounds vertically. Tests: un-ignored qa_buttonless probe; new inline tests (watchdog threshold/clock-reset/ one-shot, keyed FIFO/isolation/orphan-sweep, QA-007); new kittest reconciliations (render_global keeps keyboard for the prompt; instance show leaves host focus navigable). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(overlay): sync requirements/dev-plan to the shipped design + add UX user story - 01-requirements-ux.md: add a supersession callout flagging the Cancel-era items now overtaken by the generic-button + watchdog + claim_input redesign (FR-7, AC-7.3/7.4, NFR-3 AC-3b, AC-8.4, AC-10.5, J-1/J-2/J-3, §6.3-6.5), pointing at the dev-plan post-outage note, the addendum, and the code as source of truth. - 03-dev-plan.md: drop OVERLAY_CANCEL_ACTION_ID from the §2 re-export row; mark the §3 API block superseded (real surface is with_button/with_secondary_button, keyed take_actions/sweep_orphan_actions, OptionOverlayExt::raise, the watchdog); fix the §4.1 drain comment; update the §9 D-4/D-5 rows. - user-stories.md: add UX-001 (blocking please-wait overlay; cannot fire a conflicting second action), tagged across personas, [Implemented]. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(overlay): close re-QA coverage residuals RQ-1/RQ-2/RQ-3 RQ-1 (security) — the app.rs secret-prompt gate had no test; deleting `if self.active_secret_prompt.is_none()` left every test green. Extracted the gate into `AppState::claim_overlay_input` (called from `update`) and added a `#[cfg(feature = "testing")]` seam (`AppState::test_set_secret_prompt_active`, `ActivePrompt::test_stub`). New AppState-level kittest `rq1_appstate_secret_prompt_gate_keeps_prompt_typeable_over_overlay` drives the REAL `update()` loop with a prompt active over a button-less overlay and asserts the prompt input keeps focus AND accepts typed text (types a passphrase + Enter, the prompt submits and closes). Deleting the gate makes `claim_input` (button-less → `stop_text_input`) steal focus and strip the keys, failing both assertions. Extended `tc_ovl_048` to assert prompt interactivity (submit button renders + input holds focus), not just visibility. RQ-2 — added a `#[cfg(feature = "testing")]` clock seam `OverlayHandle::backdate` (shifts `created_at` + `last_progress_at` into the past). New kittest `tc_ovl_047b_threshold_reveals_via_clock_seam` renders past 30 s and 120 s and asserts: the soft "This is taking longer than usual." line + Elapsed force-reveal, then `STUCK_WATCHDOG_REASSURANCE` REPLACING the soft line (never both) — the addendum §1 obligation that was previously only flag-checked. RQ-3 — reframed the `tc_ovl_047` doc comment (the escape-hatch button is a deliberate v1 non-feature per addendum §1, not a deferred T7 TODO); added a "(superseded)" note to 01-requirements-ux.md's "what to reuse" list where it still cited `with_cancel`/`with_action`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(overlay): close QA residuals — README catalog entry, requirements Cancel reconciliation, T7 TODO Post-gate cleanup on the blocking progress overlay (gate green): - README: add a ProgressOverlay row to the Feedback Components table, covering show_global/render_global, with_button(id, label), the 120s watchdog, and companions OverlayConfig/OverlayHandle/OptionOverlayExt/ ProgressOverlayResponse. - 01-requirements-ux.md: reconcile the remaining literal-Cancel acceptance criteria (intro line, AC-7.3, AC-8.4, the §6.5 "Visible, cancelable" row, R-3) to the shipped generic-button model, matching the top supersession callout — Esc/Tab/Enter/Space are swallowed and there is no built-in Cancel. - app.rs: mark drain_overlay_actions with a TODO(T7) recording that an overlay button can only stop waiting (not abort) until the BackendTask system gains cooperative cancellation; until then the 120s watchdog (see progress_overlay.rs) bounds every block. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(overlay): hard-block the UI during startup/Connect SPV sync Raises the blocking ProgressOverlay while a startup- or Connect-initiated SPV sync runs, and lowers it when the chain becomes usable (Synced) or fails (Error). Honors the overlay's C1/C2 caller contract. SPV sync is UNBOUNDED — it can wait indefinitely for peers — so a button-less block would trap the user. The block therefore carries a "Continue in the background" escape (`SYNC_CONTINUE_BACKGROUND_ACTION`); clicking it lowers the block while sync proceeds safely in the background (read-only — nothing is stranded). C1: the block also always lowers on its own at a terminal state. - `AppState`: `sync_overlay`/`sync_block_active`/`sync_overlay_dismissed` fields; armed on boot auto-start and on the manual `StartSpv` (Connect); reset on network switch so the handle never goes stale. - New per-frame `update_sync_overlay` driver (called beside `update_connection_banner`) applies a pure, unit-tested policy `sync_block_step` (Block / Release / Idle) and drains the escape click. - Pure decision + descriptions are i18n-clean single sentences. Tests: 6 inline unit tests of `sync_block_step` (inactive→Idle; active+not-usable →Block; terminal→Release for both dismissed states; dismissed→Idle; stable action id; sentence descriptions). New `#[cfg(feature = "testing")]` integration kittest `task9_sync_overlay_blocks_lowers_on_synced_and_on_escape` drives the real `update_sync_overlay` against a forced connection state: asserts the block raises while connecting, lowers on Synced (C1), and lowers on the escape click (C2 — user never trapped). Adds `ConnectionStatus::set_overall_state` + AppState `test_activate_sync_block`/`test_drive_sync_overlay` test seams. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(overlay): align SPV-sync block to the approved spec Reworks the SPV-sync overlay wiring (introduced in the previous commit) to the user-approved design. Net behaviour: while the active context is Connecting or Syncing the overlay hard-blocks the UI, lowering when the chain becomes usable (Synced), fails (Error), or drops (Disconnected). Changes vs the first cut: - Keyed purely to the live connection state + a per-episode dismissal flag — drops the separate "armed" flag, so any sync episode (startup, Connect, or reconnect) blocks. Pure policy renamed `sync_block_step` -> `spv_block_step` (Block/Release/Stand); Disconnected now Releases + re-arms. - Escape is now an always-visible SECONDARY button "Continue in the background" (id renamed `spv:sync:continue_background`); fields renamed to `spv_overlay`/`spv_overlay_dismissed`; method renamed `update_spv_overlay` and driven BEFORE `update_connection_banner`. - Live content: description = `spv_phase_summary(progress)` (else a generic connecting line), plus a "Step N of 5" counter via new `connection_status::spv_phase_step` (Headers=1 … Blocks=5). Raises once per episode, then updates in place. - Suppresses the redundant Connecting/Syncing connection-banner text while the overlay is up (don't double-shout); keeps Error/Disconnected banners. C1/C2 contract preserved: SPV sync is UNBOUNDED, so the escape (lower while sync continues safely in the background — read-only, nothing stranded) guarantees the user is never trapped; episode-ending states always release. Tests updated: 4 inline `spv_block_step` unit tests; the integration kittest `task9_spv_overlay_blocks_lowers_on_synced_and_on_escape` now also asserts the secondary escape button, re-raise for a fresh episode, no re-raise within a dismissed episode, and re-raise after the episode ends. Test seams renamed to `AppState::test_drive_spv_overlay` (+ `ConnectionStatus::set_overall_state`). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(overlay): reconcile SPV-sync block decision (F-SPV-1) + phase-step test (F-SPV-2) F-SPV-1 — the user-authorized SPV-sync hard-block + always-visible "Continue in the background" escape contradicted three docs written for the standalone overlay. Reconcile the docs to the decision (the feature is correct; the docs were stale) so a future dev does not "correctly" remove the button per old docs: - docs/user-stories.md: carve out the SPV-sync exception in UX-001's "no background/dismiss button" guarantee, and add UX-002 — the blocking SPV-sync overlay with the always-on "Continue in the background" escape (tagged across personas, [Implemented]). - 01-requirements-ux.md §5: supersession note — the user chose to block the startup/Connect get-connected sync; the power-user concern is mitigated by the escape (sync is read-only and safe to background); this is the overlay's first adopter. - 04-design-addendum.md A-1: record that A-1's "ship NO dismiss/background button in v1" was scoped to unsafe-to-interrupt ops whose safety rests on boundedness; for the unbounded-but-read-only SPV-sync adopter the C2 "never trap the user" guarantee is met by the always-on escape, which must NOT be removed. F-SPV-2 — the granular phase progress (spv_phase_summary description + "Step N of 5" via spv_phase_step) was already wired in the previous commit; adds a unit test locking the active-phase → step mapping and the summary text. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(overlay): scope SPV block to user-initiated sync + de-jargon copy (F-SPV-A/B/E) F-SPV-A (sev-2/1 regression, introduced by the prior refactor) — the SPV block fired on ANY Connecting/Syncing, so an ambient mid-session reconnect, or the SPV engine flipping Synced→Syncing as it processes each new block (event_bridge on_progress maps !is_synced() → Syncing), would hard-block a working user. Re-introduce a startup/Connect-SCOPED arming gate: - `spv_block_armed` flag, armed only on boot auto-start and the Connect button (AppAction::StartSpv); reset on network switch. - `spv_block_step(armed, dismissed, state)`: !armed → Idle (never block); armed + Synced/Error → Disarm (lower + clear armed); armed + Connecting/Syncing/ Disconnected → Block (or Stand if dismissed). Once disarmed, ambient sync never re-blocks until the next user-initiated episode. F-SPV-B (sev-2) — the block description showed blockchain jargon ("Headers: 12345 / 27000 (45%)") to the Everyday User. Replace with plain complete sentences ("Connecting to the Dash network." / "Syncing with the Dash network."); keep the jargon-free "Step N of 5" counter (via spv_phase_step) as the determinate granularity. spv_phase_summary stays (still used by wallets_screen); it is just no longer the overlay description. UX-002 acceptance criterion updated to stop enshrining the jargon. F-SPV-E (sev-4) — AppAction::StartSpv set an orphaned Info banner whose handle was dropped (could not be cleared by the overlay's banner suppression). Dropped it; the block conveys "connecting" and the error path still surfaces via replace_global. Tests: spv_block_step unit tests rewritten around the arming gate — `unarmed_never_blocks` is the regression guard (ambient sync never blocks); `armed_terminal_state_disarms`; jargon-free-description test. The integration kittest is rewritten to `task9_spv_overlay_armed_scope_disarm_and_escape`: an un-armed Connecting does NOT block, an armed one does, Synced disarms, ambient sync afterward does NOT re-block, the escape lowers without re-raising, and only a fresh armed episode re-blocks. New `AppState::test_arm_spv_block` seam. is_synced() finding: `EventBridge::on_progress` (event_bridge.rs) does map `!is_synced()` → `SpvStatus::Syncing`, so overall_state CAN flip Synced→Syncing on per-block catch-up — the arming gate makes that harmless (disarmed after the initial episode). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(overlay): address review findings — deterministic elapsed test, SPV phase-count constant, input-claim hardening, doc drift - Replace the 2.1s wall-clock sleep in tc_ovl_013b with the deterministic `backdate` clock seam (gated behind `testing`), mirroring tc_ovl_047b — zero wall-clock waiting; asserts the elapsed readout counts up to a concrete 2s. - Add `SPV_SYNC_PHASE_COUNT` next to `spv_phase_step` as the single source of truth for the "Step N of 5" total; reference it at both app.rs call sites and guard the max step with a `debug_assert!` so it cannot silently drift. - Delete the misplaced orphan-sweeper paragraph from `claim_overlay_input`'s doc (it belongs to `drain_overlay_actions`, which already carries it). - Reconcile the `Order::Middle` → `Order::Foreground` doc drift: supersession callouts in the dev plan §4.2/§4.3 and the kittest module doc, citing SEC-002. - Drop the dead `CONNECTING_MSG`/`replace_global` swap in the StartSpv failure path (the "Connecting…" banner was removed in F-SPV-E) for a plain `set_global(...).with_details(e)`; fix the now-stale comment. - Extend `claim_input`'s per-frame strip to also drop Backspace, Delete, Home, End, PageUp, PageDown and the Copy/Cut/Paste clipboard events; add a kittest locking the new classes via event survival + the field-beneath contract. - Strengthen the SEC-001 lifecycle rustdoc on `show_global` / `show_global_spinner_only` (button-less blocks need a frame-driven reconcile owner or an escape; the watchdog only logs). - Nits: UX-001 "developer warning" → "developer error"; "while a armed" → "while an armed". Add deferred TODOs (SEC-002-pointer, SEC-001, RUST-006). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(overlay): close one-frame SPV block gap, fix slow-phase watchdog, align API to MessageBanner Three changes to the blocking progress overlay + SPV-sync hard-block: A — Close the one-frame interactive gap. `update_spv_overlay` now runs at the top of `AppState::update`, BEFORE `claim_overlay_input`, the visible screen `ui()`, and `render_global`. A freshly-armed episode therefore raises, claims input, AND paints on the same frame; previously the block was raised only after `render_global`, leaving the frame right after Connect/arming fully interactive (effective at frame N+2). The connection banner still reads the block state afterwards, so its Connecting/Syncing suppression is unchanged. B — Stop the 120s no-progress watchdog from falsely escalating on slow phases. A single SPV phase running >120s (e.g. Headers on a slow link) wrote a constant (description, step), so `log_overlay_state` never reset `last_progress_at` and the watchdog tripped — swapping to the STUCK copy and firing the one-shot dev-error, the exact false signal the SPV escape was meant to avoid. A hidden, monotonic `progress_token` (step in the high 32 bits, advancing height in the low 32) is threaded from `ConnectionStatus` into the overlay; an advancing token resets the watchdog even when the shown (description, step) is unchanged. The token is NEVER rendered — copy is byte-for-byte unchanged and the jargon-free test stays green. Distinct from TODO(SEC-001), which is left in place. C — Align the overlay public API toward MessageBanner so migrating from the banner is a name-for-name swap. One-way (overlay → banner), no capability loss: with_button(id, label) -> with_action(label, action_id) with_secondary_button(id, label) -> with_secondary_action(label, action_id) show_global(...) -> set_global(...) (return type kept) show_global_spinner_only(...) -> set_global_spinner_only(...) `OptionOverlayExt::raise` keeps its name: renaming to `replace` (the banner analogue) would be shadowed by the inherent `Option::replace`, so every `slot.replace(ctx, desc, config)` call would fail with E0061 (verified). A doc note records why. `render_global`, `claim_input`, the watchdog, `OverlayConfig`, and all handle progress methods are untouched. Rustdoc, the README catalog row, and the design-doc API references are updated to the new names; the banner's own `MessageBanner::show_global(ui)` render path is left alone. Tests: new real-AppState kittest for the one-frame gap (same-frame paint), new backdate kittest + unit tests for the token-driven watchdog reset, and a `spv_progress_token` monotonicity unit test. fmt + clippy clean; kittest 138 passed; lib 926 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(overlay): keyboard-reachable escape for the SPV hard block (QA-002 refinement) Resolves the TODO(RUST-006) marker: the SPV-sync hard block's "Continue in the background" escape was mouse-only, stranding keyboard-only / assistive-tech users behind the UNBOUNDED block. Hard blocks strip Enter/Space every frame (the deliberate QA-002 rule, guarded by TC-OVL-044), so the escape could not be activated by keyboard. Add a per-block opt-in — `OverlayConfig::with_keyboard_escape(action_id)` and `OverlayHandle::with_keyboard_escape(action_id)` — that designates ONE action as the single keyboard-reachable escape. The general rule is unchanged: a block with no designated escape stays fully keyboard-blocked. - claim_input: when the active block designates an escape AND that escape button is *confirmed* to hold focus (its egui id was recorded by last frame's render_buttons and still matches the focused widget), Enter/Space pass through; every other key, and the raise frame (focus not yet confirmed), stays stripped. So the passthrough can never reach a widget beneath. - render_buttons: for an opt-in block, pin focus to the designated escape (match by action id) — re-requested every frame and locked — and record its id for the claim_input gate. - SPV adopter (update_spv_overlay): mark "Continue in the background" as the keyboard escape; it remains unconditionally present whenever the block is up. Tests (egui_kittest — the reliable check for input/focus): - TC-OVL-051/052: Enter / Space activate the focus-pinned escape. - TC-OVL-053: a TextEdit beneath never receives Enter; Tab and a backdrop click cannot move focus off the escape. - task9_spv_escape_is_keyboard_activatable: the REAL SPV block lowers on Enter. - TC-OVL-044 and the keyboard-block tests stay green (general rule intact). - Unit tests for the opt-in API + the claim_input safety gate. Docs: QA-002 design note + NFR-3 accessibility ACs, test-spec, user story UX-002, and the public rustdoc updated to state the refined rule. cargo +nightly fmt: clean. clippy --all-features --all-targets -D warnings: 0. kittest --all-features: 142 passed. lib --all-features: 928 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(overlay): activate keyboard escape at frame start (SEC-001, SEC-002) The opt-in keyboard escape used to "keep" Enter/Space in `i.events` only while the escape button was confirmed-focused, and `render_buttons` re-requested that focus every frame. Two bugs fell out of it: - SEC-001: `render_global` runs before `render_secret_prompt`, so the per-frame focus re-request stole focus from a passphrase modal raised above the block — the field went un-typeable and Enter fired the escape instead of submitting. Realistic on a cold-start migration prompt over the startup SPV auto-sync block. - SEC-002: the kept Enter/Space reached the beneath screen's `ui()` (which runs before `render_global`), so a focus-independent global key handler beneath (info_popup / selection_dialog / address_input) observed the key — a single Enter/Space leaked through the "hard" block. Unified fix: move escape activation to frame start in `claim_input`. When a block designates a `keyboard_escape_action` and Enter/Space is pressed, enqueue that action directly (the same queue a click feeds) and strip the key with all the others. Activation no longer needs the button focused (SEC-001) and the key never survives to a widget beneath (SEC-002). Focus on the escape is now purely visual and is suppressed while a secret prompt is active — `render_global` takes a `secret_prompt_active` flag mirroring the `claim_overlay_input` gate. A non-opted block still strips Enter/Space and activates nothing; Esc still never dismisses. Drops the now-dead `escape_focus_id` field and confirmed-focus logic. Also in this rework: - SEC-003 residual: TODO documenting the narrow constant-height >120s watchdog false-alarm (benign log + accurate copy, no abort) pending a coarser SDK liveness signal. - RUST-001: `keyboard_escape_action.clone()` -> `as_deref()` in render_buttons (no per-frame String alloc). - RUST-002: corrected the stale `log_overlay_state` call comment to note the watchdog also resets on a hidden progress_token advance. - PROJ-001: render_global rustdoc now cross-references `MessageBanner::show_global` for the set_global/render_global vs set_global/show_global asymmetry. Tests (egui_kittest, the authority for input/focus): - sec001_* drives the real AppState loop with an escape block beneath an active secret prompt: the prompt keeps focus, Enter submits it, the escape action is never enqueued. - sec002_* a focus-independent `key_pressed(Enter)` sentinel beneath an escape block never fires; the Enter is stripped and routed to the escape. - Replaced the obsolete confirmed-focus unit test with one asserting the frame-start enqueue + strip. TC-OVL-044/048/051/052/053, rq1, and task9 escape tests stay green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(overlay): document hidden progress_token watchdog reset; pin cross-phase token invariant - RUST-003: strengthen `spv_progress_token_advances_with_height_and_is_monotonic` with a cross-phase assertion — a later phase (masternodes, step 2) at height 0 must out-rank an earlier phase (headers, step 1) near the u32 ceiling, pinning the high-bits-dominate invariant the test name claims. - DOC-001: design-addendum §1 now documents the hidden progress_token watchdog reset — `last_progress_at` resets on a shown (description, step) change OR a token advance; the token is never rendered and its reset is decoupled from the once-per-content-change log (NFR-5). Corrected the now-wrong "only when content changes" instructions and the test note, and the superseded confirmed-focus escape description. - DOC-002: dev-plan §3 superseded block — dropped `with_action` from the "there is no ..." list (it is the real shipped builder), resolving the self-contradiction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(overlay): keep escape button mouse-clickable after a backdrop press The blocking ProgressOverlay rendered its dim/pointer sink and its content card as peer Order::Foreground areas. egui auto-raises any interactable Area to the top of its Order on a pointer press (area.rs bring-to-front), so a single click on the dim backdrop floated the full-window sink above the card and permanently buried its buttons beneath the click-absorbing sink. For the unbounded SPV-sync block that meant the "Continue in the background" escape became unclickable with the mouse — force-quit was the only exit. Pin the card as a sublayer of the sink (ctx.set_sublayer): egui places a sublayer directly above its parent after the per-frame order sort, so the card-above-sink z-order now holds by construction, immune to the bring-to- front race. The sink still blocks every widget beneath, and the secret-prompt window still wins above the overlay. Add TC-OVL-054: press the backdrop, then click the escape at its own position and assert the action enqueues. It fails before this change (the sink eats the click) and passes after. Existing button-click tests never press the backdrop first, so they missed this path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(overlay): arm the SPV-sync block on the post-onboarding auto-start path The blocking SPV-sync overlay only shows for an *armed* episode (spv_block_step returns Idle when !armed). Two production paths armed it — boot auto-start (via the constructor: spv_block_armed = boot_auto_start_spv) and the Connect button (AppAction::StartSpv) — but the third did not: AppAction::OnboardingComplete calls try_auto_start_spv(), which spawned ensure_wallet_backend_and_start_spv WITHOUT setting spv_block_armed. So a fresh user who enabled auto-start and then finished onboarding (onboarding_completed was false at boot, so boot_auto_start_spv was false and the flag stayed false) would sync with no blocking overlay at all — exactly the journey the overlay exists to cover. Fix: arm the block inside try_auto_start_spv when the start actually fires (spv_block_armed = true; spv_overlay_dismissed = false), mirroring AppAction::StartSpv. This is the single correct arming point for that caller — the method takes &mut self now, and the active context is cloned up front so the mutation does not alias the borrow. Boot auto-start is untouched (it arms via the constructor and inlines its own start). Test: fspv_a_onboarding_auto_start_arms_spv_block drives the REAL try_auto_start_spv via a testing-only seam and asserts both the armed flag flips and that an armed Connecting sync then raises the overlay. Verified the test fails when the arm is removed and passes with it. Verified: cargo clippy --all-features --all-targets -D warnings (0 warnings), cargo test --test kittest (146 ok). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…barrier Flips the same-network disconnect/reconnect to restart-in-place (Option A) and removes the interim B-2 release barrier. Builds on the machinery landed in the previous commit (re-armable CoordinatorGate, re-runnable StartLatch, WalletBackend::stop_in_place). - stop_spv (src/context/wallet_lifecycle.rs) no longer takes + drops the WalletBackend. It keeps the backend (and its Arc<SqlitePersister>) wired in the AppContext slot and calls backend.stop_in_place().await — stop the SPV run loop + quiesce the 3 coordinators, re-arm the start latch + gate — then settles the indicator. The persister DB is never closed/reopened, so the reconnect cannot hit WalletStorageError::AlreadyOpen by construction. - Reconnect reuses the SAME backend: ensure_wallet_backend fast-paths on the populated slot (no WalletBackend::new, no SqlitePersister::open) and start() re-runs on the re-armed latch. - Deleted the B-2 await_persister_released barrier + its offline test: under keep-alive the persister is never released, so the barrier's open-probe would itself hit AlreadyOpen and spin to its 5s timeout every reconnect — it is mutually exclusive with restart-in-place. - Removed AppContext::take_wallet_backend (its only caller was the old stop_spv). - Network SWITCH is unaffected: it uses a per-network context with a different persister path and never calls stop_spv. Tests: - reconnect_restart_in_place_reuses_backend (now runs, not ignored): drives the real stop_spv -> ensure_wallet_backend_and_start_spv path; asserts the same backend pointer across disconnect->connect, latch/gate re-armed, no AlreadyOpen. The Q3 timing race is NOT asserted here (see below). - stop_spv_unwires... renamed to stop_spv_in_place_keeps_backend_and_disconnects_indicator and asserts the backend stays wired + latch re-armed. - Removed the obsolete rebuild test reconnect_after_stop_rebuilds_fresh_backend_and_restarts. TODO(dashpay/platform#3828): restart-in-place RUNTIME safety depends on the platform_address_sync background_generation guard being in the pinned rev. platform_address_sync (rev 925b109) clears its cancel slot unconditionally, so a rapid reconnect can leak an uncancellable platform-address sync loop (Q3). identity_sync and shielded_sync already carry the guard. The DET code compiles and the start/stop/quiesce/accessor APIs all exist on 925b109 — only the Q3 timing race is unsafe until the guard lands. Finalize once it merges on branch fix/wallet-core-derived-rehydration: cargo update -p platform-wallet -p platform-wallet-storage -p dash-sdk then re-run live reconnect validation. (Not done here — the user coordinates the upstream fix.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…esign' into docs/platform-wallet-migration-design
…r platform_address_sync guard The restart-in-place reconnect (stop_spv keeps the WalletBackend + Arc<SqlitePersister> alive and restarts SPV + the 3 coordinators on the SAME instance; B-2 barrier retired) was wired in e084b7a but its RUNTIME safety depended on the upstream platform_address_sync background_generation guard, which the pinned platform rev 925b109 lacked (Q3: a rapid reconnect could leak an uncancellable / duplicate platform-address sync loop). That guard has now landed on branch fix/wallet-core-derived-rehydration (dashpay/platform#3828, head b4506492): platform_address_sync now carries a background_generation: AtomicU64 field, bumps it in start(), and gates the exiting thread's `*background_cancel = None` clear on `background_generation.load(Acquire) == my_gen` — matching identity_sync / shielded_sync. Verified in the vendored source. This commit finalizes Option A: - Bump the pinned platform crates 925b109 -> b4506492 (cargo update -p dash-sdk -p platform-wallet -p platform-wallet-storage; Cargo.lock only). - Remove the now-resolved TODO(dashpay/platform#3828) at the stop_spv call site and the conditional "until the guard lands" caveats in stop_in_place's SAFETY doc and the reconnect_restart_in_place_reuses_backend test doc — restated as the guard now being present in the pinned rev. Merges origin/docs/platform-wallet-migration-design (PR #863, blocking progress overlay) were integrated in the preceding merge commit; #863 touched no restart-in-place file, so the integration was conflict-free. Validated in-process (offline): reconnect_restart_in_place_reuses_backend (same backend reused across disconnect->connect, no AlreadyOpen, SPV+coordinators restart, latch/gate re-armed), stop_spv_in_place_keeps_backend_and_disconnects_indicator, reset_re_arms_gate_for_restart_in_place, start_latch_reset_allows_restart. Still requires live network (left #[ignore]d): the backend-e2e B-reconnect connect->disconnect->connect against a synced testnet, which exercises the Q3 timing race the offline tests cannot force. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…864) * docs(mn-cli): requirements + UX spec for masternode CLI withdrawals Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(mn-cli): test-case specification for masternode CLI withdrawals Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(mn-cli): development plan for masternode CLI withdrawals Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(mcp): add masternode input validators (Task 1) Stateless parsing for the headless masternode/evonode tools, the single source of truth for their string params: - parse_node_type: trim + case-insensitive, maps to Masternode/Evonode, never User (pins coverage gap G-4) - parse_key_mode: owner/transfer, trim + case-insensitive - require_at_least_one_signing_key: rejects a key-less (watch-only) load, naming both keys and the two withdraw modes - decode_identity_id: Base58-then-Hex fallback, mirroring the backend Also pins the Platform-address pitfall guard (TC-MN-031): a dedicated is_platform_address_string test proving dash1…/tdash1… are flagged as Platform and Core y…/X… are not — the weaker first-char check must not be relied on. The module is gated behind the mcp/cli features since it depends on the tool-layer McpToolError, keeping the default no-feature build green. Covers TC-MN-001/002/003/004/005/006/007/008/009/030/031. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(mcp): add Tool A load params with redacting Debug (Task 2) IdentityMasternodeLoadParams / IdentityMasternodeLoadOutput for the identity_masternode_load tool. The params carry three private keys, so Debug is hand-written to render each key field as <redacted> — mirroring ImportWalletParams. A derived Debug would leak key material into logs and the MCP error `data` payload (McpToolError::TaskFailed serializes {task_err:?}). Unit tests: - TC-MN-010 (the single most important security test): Debug surfaces none of the three key sentinels, renders exactly three <redacted> markers, and keeps pro_tx_hash/node_type/alias/network readable. - TC-MN-011: the params accept any key string without a competing local length check — the backend verify_key_input is the single source of truth for the length policy. The invoke + tool_router registration follow in Task 3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(mcp): implement Tool A invoke + register identity_masternode_load (Task 3) The identity_masternode_load tool, a thin adapter over the existing IdentityTask::LoadIdentity. Invoke order puts cheap validation before the SPV wait: require_network -> parse_node_type -> at-least-one-signing-key -> ensure_spv_synced -> Secret::new(keys) -> IdentityInputToLoad{ derive_keys_from_wallets:false, keys_input:[] } -> dispatch LoadIdentity -> map output. The output is self-describing for the withdraw step: which keys loaded, available_withdrawal_keys ("owner"/"transfer") derived by purpose from available_withdrawal_keys(), and the registered payout_address. Registered one line in tool_router(). Tests: - TC-MN-015: built the router (no AppContext) and assert the tool is registered, annotations are read_only=false/destructive=false/ idempotent=false/open_world=true, and the schema exposes all seven params. TC-MN-012/013/014 (network mismatch / missing network / ordering before the SPV gate) reach invoke and need a live AppContext — covered by the det-cli smoke pass and the backend-e2e suite (Task 7), per the test spec layering. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(mcp): add Tool B masternode credit withdraw (Tasks 4+5) identity_masternode_credits_withdraw — a masternode-aware credit withdrawal over the existing IdentityTask::WithdrawFromIdentity, with explicit owner/transfer key modes. Params + Output + pure pre-flight helpers (Task 4): - reject_owner_address_contradiction: owner mode forbids a supplied to_address (TC-MN-033). - parse_transfer_core_address: transfer mode requires an address (TC-MN-034), rejects Platform bech32m via is_platform_address_string — the pitfall guard, NOT the weaker first-char check (TC-MN-031/046) — and rejects unparseable Core addresses (TC-MN-035). invoke + registration (Task 5), cheap validation before the SPV wait: require_network -> validate_credits -> parse_key_mode -> owner+to_address contradiction (before resolution, TC-MN-033/042) -> resolve identity (not-loaded names identity-masternode-load, TC-MN-040) -> KeyID from available_withdrawal_keys() by purpose (TC-MN-044/054) -> destination (owner -> payout/None-or-reject when absent; transfer -> parsed Core + require_network cross-network reject) -> ensure_spv_synced (NFR-P2 / OQ-4 option b: add the gate, diverging from the sibling identity_credits_withdraw that skips it) -> dispatch WithdrawFromIdentity(qi, dest, credits, Some(key_id)). The output echoes the address actually used (the payout address in owner mode). Registered one line in tool_router(). Tests (Task 4 units + Task 5 discoverability): - TC-MN-032 amount=0; TC-MN-033 owner+address; TC-MN-034 missing address; TC-MN-035 invalid address; TC-MN-031/046 Platform-address rejection; TC-MN-043 annotations (destructive=true) + schema. The helpers and their sole caller (invoke) are one compile unit, so Task 4 and Task 5 land together to keep the clippy gate green (no transient dead_code). Note: a real finding caught here — hardcoded placeholder Core addresses have invalid checksums and do NOT parse, so the "valid address accepted" test derives a checksum-valid address from a fixed key instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(mcp): cross-cutting discoverability + key-redaction guards (Task 6) - TC-MN-061: McpToolError::TaskFailed serializes {task_err:?} into the MCP error `data` payload. Assert neither the Display message nor the Debug data chain of a KeyInputValidationFailed leaks a key sentinel — the variant carries only a role name + a format detail, no key bytes. - Secret Debug renders "Secret(***)" and never the plaintext, anchoring redaction at the source. TC-MN-015/043/060 (discoverability, schema, CLI hyphenation) are covered by the router-level annotation/schema tests added with Tasks 3 and 5 plus the det-cli smoke pass; zero tool logic lives in src/bin/det_cli/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(e2e): masternode load + withdraw backend-e2e suite (Task 7) New #[ignore], network-gated suite mirroring identity_withdraw.rs, driving the same IdentityTask::LoadIdentity / WithdrawFromIdentity the new tools dispatch. Env-gated by E2E_MN_* (PRO_TX_HASH, OWNER_WIF, PAYOUT_WIF, VOTING_WIF, NODE_TYPE); each case skips with a log line when its inputs are unset (never fails on absence — they are #[ignore] anyway). Cases: - TC-MN-016 load with payout key (transfer key present, owner absent, payout address present) - TC-MN-017 load with owner key - TC-MN-018 load with both keys - TC-MN-020 wrong-but-valid-format key -> KeyInputValidationFailed, with a TC-MN-061 cross-check that no key bytes appear in Display/Debug - TC-MN-021 nonexistent ProTxHash -> IdentityNotFound - TC-MN-050 owner-mode withdraw: dispatch to_address=None, assert WithdrewFromIdentity with a positive actual_fee - TC-MN-051 transfer-mode withdraw to a fresh Core address, positive fee - TC-MN-054 owner-mode key-not-loaded precondition (no OWNER key present) Every fund-moving assertion checks the result variant AND a number (actual_fee > 0), per the QA test-depth rule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(mcp): document masternode load + withdraw tools (Task 8) - MCP.md: tool-table rows for identity_masternode_load and identity_masternode_credits_withdraw; a private-key handling note (Secret redaction in output/errors/Debug; NFR-S4 HTTP-transport caution / G-6); and a note that the masternode withdraw adds the SPV gate the sibling identity_credits_withdraw skips (NFR-P2). - CLI.md: a headless masternode load + owner/transfer withdraw walkthrough. - user-stories.md: MCP-003 (headless MN load) and MCP-004 (headless MN withdraw), tagged [Implemented]. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mcp): owner-mode withdrawal dispatches None, not Some(payout) (QA-001) Owner-key withdrawals must dispatch WithdrawFromIdentity(qi, None, ...) and let Platform consensus force the registered payout address — matching the locked decision, the GUI (withdraw_screen.rs sends None in owner mode), and TC-MN-050. The tool previously dispatched Some(payout_address), a fund-path divergence no test could catch. Extract a pure resolve_withdrawal_plan(qi, key_mode, to_address, network): - owner mode: dispatch_address = None; the resolved payout address is used only to validate one exists and to echo it back in the output. - transfer mode: dispatch_address = Some(parsed) after a format + active-network check. Truthful coverage that exercises the tool's actual resolution against a built masternode QualifiedIdentity fixture (random_masternode_owner_key / random_masternode_transfer_key): - owner_mode_dispatches_none_and_echoes_payout (the QA-001 regression guard) - transfer_mode_dispatches_and_echoes_the_caller_address - owner/transfer key-not-loaded naming the param (TC-MN-044, L-01/L-02) - owner no-payout-address (TC-MN-045, the G-2 hand-built fixture) - transfer cross-network address rejected (TC-MN-047) Also: key-not-loaded messages now name the param (owner_private_key / payout_private_key) and bridge transfer<->payout (L-01/L-02); FR-B2 in the requirements doc corrected to address = None; ephemeral TC-MN IDs stripped from the two pre-flight helpers' production rustdoc and the rustdoc trimmed (QA-010/QA-011). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mcp): decode_identity_id error states what to do (M-01) Append the two accepted formats and the producing tool to the error so a caller who passes a malformed identity_id has a concrete next step: "...Provide a 64-character hex ProTxHash or the Base58 identity ID from identity-masternode-load." Single i18n-ready sentence, per the project error rule (what happened + what to do). Also clarify the decode_identity_id test-section header: the function serves the withdraw tool's identity_id; the load tool delegates pro_tx_hash to the backend, which parses it identically (PROJ-005). Add a unit test asserting the message carries the format guidance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mcp): blank network yields a clear "required" message (QA-004) A require_nonblank_network guard runs at the top of both new tools' invoke, before resolve::require_network. An empty/whitespace network now returns "The network parameter is required." instead of the confusing NetworkMismatch { expected: "" } that require_network would produce for Some(""). The shared resolve::require_network is untouched. Unit test covers ""/" " rejected with the required message and a real network accepted. TC-MN-013's note in the test spec is corrected to distinguish the omitted (schema-required deserialization) path from the empty-string (guard) path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(e2e): fill missing masternode backend-e2e cases (PROJ-001/QA-005/PROJ-002) Add the Task-7 cases the suite was missing: - TC-MN-007 — malformed ProTxHash to the load tool surfaces as TaskError::IdentifierParsingError with the original input preserved (QA-005). - TC-MN-019 — load with a voting key fetches and binds the voter identity; it adds no withdrawal mode. - TC-MN-023 — re-load is idempotent at the DB layer: count the rows for the identity before and after a re-load; INSERT-OR-REPLACE keeps it at one. - TC-MN-052 — owner mode + supplied to_address is rejected before dispatch (the rejection itself is unit-tested); assert the identity balance is unchanged, so no ST moved funds. - TC-MN-053 — A→B composition THROUGH the local DB: load (persist), drop the in-memory qi, re-resolve via ctx.get_identity_by_id, then withdraw. This exercises the load→DB→withdraw seam the requirements call out as the only A→B coupling — previously untested. Also document that TC-MN-022 (load SPV-gate presence) is deferred per coverage gap G-3 (needs a forced-SPV-error harness), so a reader does not assume it was accidentally omitted (PROJ-002). Every fund-moving assertion checks the result variant AND a number (actual_fee > 0), per the QA test-depth rule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(mcp): doc accuracy + comment hygiene (DOC-001..005, QA-010) - DOC-001: CLI.md masternode examples use hyphenated param names (pro-tx-hash, owner-private-key, key-mode, ...) to match the --help canonical form and the rest of CLI.md. - DOC-002: MCP.md "page-locked" -> "best-effort page-locked" Secret, matching Secret::new's best-effort mlock. - DOC-003: drop the false "all identity tools are destructive" phrasing; list the fund-moving tools and note identity_masternode_load is non-destructive yet still requires network (keys are chain-scoped). - DOC-004: document that transfer mode rejects Platform bech32m addresses, in the MCP.md table row and the CLI.md preamble. - DOC-005/SEC-001: CLI.md warns that inline key=value args are visible via ps / /proc/<pid>/cmdline and saved to shell history. - QA-010: strip ephemeral design-doc/review IDs (FR-B5, TC-MN-NNN, QA-004) from production comments in identity.rs, keeping the prose reason. TC-MN IDs remain only in test comments (test-spec IDs, allowed there). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * style(mcp): trim the redacting-Debug rationale comment to 3 lines (QA-011) Keep the security "why" (keys must not leak; mirrors ImportWalletParams) within the internal-comment budget. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mcp): gracefully stop wallet backend before standalone det-cli exits Awaits WalletBackend::shutdown() before the Tokio runtime is torn down, stopping the platform-address-sync / identity-sync coordinators so their timer-based retries no longer panic during runtime shutdown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mcp): wait for cold-start storage migration before dispatching wallet tools Extends ensure_spv_synced to poll migration_status() until the cold-start storage migration finishes (60 s timeout) before waiting for SPV. Prevents WalletStorageNotReady fast-fails when a wallet tool is dispatched before the migration that runs on first wallet-backend wiring has completed. Adds McpToolError::StorageNotReady (code -32005) with an actionable message, and two new unit tests covering its Display and distinct error code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mcp): wire wallet backend before resolving identity in masternode withdraw ensure_spv_synced now runs before get_identity_by_id, so a cold standalone det-cli process builds the wallet backend before the identity-kv lookup. Fixes the WalletBackendNotYetWired ("wallet is still starting up", -32603) fast-fail on a fresh process. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(mcp): wait for coordinator threads to exit before runtime shutdown `quiesce()` (called by `backend.shutdown()`) is a persister-drain barrier, not a thread-join barrier. Each coordinator (`identity-sync`, `platform-address-sync`, `shielded-sync`) runs on a dedicated OS thread that calls `Handle::block_on`. After `sync_now()` completes, the thread is still alive and enters: tokio::select! { _ = tokio::time::sleep(interval) => {} // panics if runtime shutting down _ = cancel.cancelled() => break, } `tokio::select!` polls arms in random order. If `sleep(interval)` is polled before `cancel.cancelled()` (which IS immediately ready) and the Tokio runtime is already shutting down, `Sleep::poll` panics: "A Tokio 1.x context was found, but it is being shutdown." Fix: after `backend.shutdown()` (cancel tokens cancelled, no sync pass in flight), sleep 100 ms inside `block_on` — while the runtime is still alive — to give the OS scheduler time to run the coordinator threads through their `select! → cancel.cancelled() → break` exit path before the runtime tears down. The upstream fix (storing and joining the OS thread's `JoinHandle` in `quiesce()`) would be airtight; this is the correct DET-side mitigation until that lands in `rs-platform-wallet`. The sleep only executes when a wallet backend was actually started; the no-backend early-return path is unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(mcp): hard-exit CLI after flush to avoid coordinator thread panics The 100ms grace sleep from 98e9638 fails under live conditions: DAPI retries already in flight when `quiesce()` returns extend past any fixed window, causing all three coordinator threads (identity-sync, platform-address-sync, shielded-sync) to panic at exit. Root cause recap: each coordinator runs on a dedicated OS thread via `Handle::block_on`. `quiesce()` waits for `is_syncing==false` (persister drain) but does NOT join the thread. The thread is still alive and enters `tokio::select!{ sleep(interval) | cancel.cancelled() }` with random arm ordering. If `sleep` is polled first after the runtime starts tearing down, `Sleep::poll` panics: "A Tokio 1.x context was found, but it is being shutdown." No sleep duration can reliably beat an in-flight DAPI retry. Deterministic fix: `std::process::exit` after flushing stdout/stderr, applied at all three CLI exit points: - `run_stdio_server()` (`det-cli serve`): replaces `shutdown_timeout`; now returns `!` since it always exits. - `run_headless()` (`det-cli headless`): same; retains early-return error path for config failures before the server starts. - one-shot tool path (`main`): replaces `Ok(())` tail; exit code from the block_on result. `backend.shutdown()` (persister drain) is kept in `shutdown_wallet_backend` and `run_headless` for correctness — it ensures no half-written `TokenBalanceChangeSet` is abandoned — but it is no longer expected to stop the coordinator threads before the runtime tears down. Safety: the tool result is printed and flushed before `process::exit`; all SQLite writes issued before the tool returned are transaction-committed; the platform-wallet-storage single-open lock is process-global and is reclaimed by the OS on exit, so the next invocation is unaffected. The upstream fix (joining the OS thread's `JoinHandle` in `quiesce()`) would be the library-level solution; this is the correct DET-side mitigation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(mcp): address all PR #864 review findings (B1 S1-S7 H1 H2) H2: Extract masternode tools to src/mcp/tools/masternode.rs, rename to masternode_identity_load / masternode_credits_withdraw. B1: Fix is_platform_address_string panic on non-ASCII input — use as_bytes() throughout instead of str byte-slicing; add non-ASCII regression test. H1 + S1: Add Deserialize + JsonSchema to model::secret::Secret (feature- gated to mcp/cli). Change MasternodeIdentityLoadParams private-key fields from String to Secret so keys are mlock-ed from deserialization, before any SPV wait. S2: Call decode_identity_id before ensure_spv_synced in the LOAD tool so a malformed ProTxHash is rejected immediately rather than after a 10-min sync. S3: Add impl Display for KeyMode in masternode_input.rs; replace three literal "owner"/"transfer" sites with .to_string() so the canonical string is defined once. S4: Rewrite TC-MN-050 and TC-MN-051 to call MasternodeCreditsWithdraw::invoke() through a DashMcpService, exercising key-mode resolution, the payout-address echo, and the SPV gate. S5: Fix headless shutdown to also drain the initial context's wallet backend when the network was switched after startup (Arc::ptr_eq guard). S6: Restructure start_stdio to always call shutdown_wallet_backend even when serve() or waiting() returns an error. S7: Dispatch MigrationTask::FinishUnwire from ensure_spv_synced in standalone/headless mode where the GUI frame-loop never runs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(mcp): rename withdraw tool, fix S5 at swap callsite, update docs - Rename MasternodeCreditsWithdraw → MasternodeWithdraw and tool name masternode_credits_withdraw → masternode_withdraw (CLI: masternode-withdraw) per spec; all callers and tests updated. - S5 (network_switch backend drain): move the outgoing-backend drain from headless.rs to the swap_context callsite in NetworkSwitch::invoke, so every network switch immediately drains the replaced WalletBackend rather than waiting until process exit. headless.rs simplified back to draining only the active (current) context. - Docs: update MCP.md and CLI.md to reflect new tool names, corrected key handling description (params are now typed Secret, not plain String). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AmYyq2qZdWpxoC8BNgVxh * docs(mcp): update all docs to final tool names; revert wrong withdraw rename H2 doc-fix pass: - Rename MasternodeWithdraw → MasternodeCreditsWithdraw / masternode_withdraw → masternode_credits_withdraw throughout code (the previous commit had wrongly shortened the name; the user-confirmed name is masternode_credits_withdraw). - docs/MCP.md, docs/CLI.md: replace stale identity_masternode_* tool names with the real final names (masternode_identity_load, masternode_credits_withdraw) and matching CLI hyphenated forms. - docs/ai-design/2026-06-18-masternode-cli-withdraw/ (all 3 docs): replace every identity_masternode_load / identity_masternode_credits_withdraw / identity- masternode-* reference with the canonical names; update IdentityMasternode* struct placeholders to MasternodeIdentityLoad* / MasternodeCreditsWithdraw*. - src/model/masternode_input.rs: fix error message and test assertion that still referenced the old CLI command identity-masternode-load; update to masternode-identity-load so the self-resolution hint is accurate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AmYyq2qZdWpxoC8BNgVxh * feat(mcp): scaffold graceful-teardown plan + fix QA-008 doc overstatement Graceful teardown (TODO pseudocode): - server.rs: add a new "Graceful teardown — plan for when upstream delivers" section to the `shutdown_wallet_backend` doc comment, with step-by-step pseudocode showing how to replace process::exit once WalletBackend::quiesce() joins coordinator OS threads. Lists the three call-sites to update. - connect.rs, main.rs, headless.rs: add one-line `TODO(graceful-teardown): replace with normal return once ...` above each of the three process::exit hard-exits, so they're easy to find and remove together when the upstream fix lands. QA-008 (MCP.md:110): - Fix overstated "no plain-String copy ever exists" claim. The correct description: Secret::new() copies the content to a mlock-backed buffer and zeroes + frees the transient serde String before returning, so no plain copy persists beyond the deserialization call. The original text implied the transient String never existed, which misrepresents the actual flow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AmYyq2qZdWpxoC8BNgVxh * fix(mcp): secret-typed key presence check; dedup withdrawal modes; propagate typed SPV-wiring error Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AmYyq2qZdWpxoC8BNgVxh * fix(mcp): real key-redaction assertion in e2e test; correct Secret/start_stdio doc wording Fix C (LRitq): TC-MN-061 in tests/backend-e2e/identity_masternode_withdraw.rs The redaction assertion checked !display.contains("bogus") and !debug.contains("bogus"), where "bogus" is the variable name — it never appears in a WIF string, making both assertions vacuously true. Fix: capture the actual WIF value before it is moved into load_task() (renamed wrong_key_wif, cloned at the call site), then assert !display.contains(&wrong_key_wif) and !debug.contains(&wrong_key_wif). The test is #[ignore] (backend-e2e); confirmed it still compiles. Fix A (LRis4): src/model/secret.rs Deserialize impl doc Removed the inaccurate "without ever living as a plain String" claim. The impl does <String as Deserialize>::deserialize(), creating a transient String before Secret::new consumes it. New wording: "deserializes into a transient String, then moves it into the zeroizing/mlock'd buffer and drops the transient, so no long-lived plain String copy persists." Fix B (LRit_): src/mcp/mod.rs start_stdio doc Removed "preventing panics from timer registration during runtime shutdown" — shutdown_wallet_backend does NOT join coordinator OS threads so it does not prevent that panic. New wording: "This does not prevent the coordinator timer-wheel panic that occurs on Tokio runtime drop — shutdown_wallet_backend does not join coordinator OS threads. std::process::exit in the caller is the deterministic mitigation." Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AmYyq2qZdWpxoC8BNgVxh --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…(Bucket A) (#866) * docs(overlay): requirements + UX spec for blocking progress overlay Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(overlay): test case specification 49 TCs covering FR-1..FR-10, NFR-1..NFR-6, and R-7 kittest checklist. Items depending on the FR-10 concurrent-overlay architecture decision (stack vs. replace vs. reject) and the stuck-overlay threshold (R-4) are marked [depends on 1d] for Nagatha to resolve. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs(overlay): development plan and architecture decisions Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(overlay): generic button facility + Component trait conformance Folds in two user-mandated redesigns of the blocking progress overlay that the prior session did not land: Redirect 1 — generic button facility (no first-class Cancel). The overlay knows nothing about cancellation. `OVERLAY_CANCEL_ACTION_ID`, `with_cancel`, `CANCEL_LABEL`, and the Esc->Cancel routing are gone. A caller attaches a generic button via `OverlayConfig::with_button(id, label)` / `OverlayHandle::with_button(id, label)`, choosing its own opaque action id and label. A click enqueues the id; the owning screen drains it via `take_actions` and runs whatever logic it wants — including its own cancellation. Esc/Tab/Enter are swallowed so a hard block is never keyboard-dismissable. Redirect 2 — `Component` trait conformance (placement legitimacy for `src/ui/components/`). `ProgressOverlay` is now a struct holding `state: Option<OverlayState>`; `Component::show` renders that instance's card and returns `ProgressOverlayResponse` (`DomainType = String`, the clicked action id), with `current_value()` reporting the last clicked id. The global `render_global` path is preserved as the production entry point; the instance `show()` is additive, mirroring `MessageBanner`. Also: clamp the card to the window so it never runs off-screen in a narrow window (FR-6); settle the centered card in the kittest click/focus cases before interacting (anchored CENTER_CENTER needs a few frames to cache its size). Docs: dev-plan gains a post-outage note superseding D-5/FR-7; test-spec reframes the Cancel-specific cases to the generic-button model. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(overlay): align D-5 and risk notes to the generic-button redesign Rewrites the D-5 decision body and §8 risk #3 in place to drop the stale `with_cancel`/`OVERLAY_CANCEL_ACTION_ID` framing and describe the generic `with_button(id, label)` facility instead — consistent with the post-outage note added at the top of the plan. Documentation only. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(overlay): add ignored probe proving button-less keyboard-block gap (QA-001) TC-OVL-029 only exercises a with-button overlay, where the first button steals focus on raise, so typing is blocked incidentally rather than by the overlay's input handling. This probe raises a button-less hard block over an already-focused field (the J-2 broadcast / J-4 migration case) and asserts FR-8 AC-8.2: typed input must not reach the field beneath. The probe currently FAILS — render_global filters Tab/Enter/Esc only after the beneath widgets have consumed input that frame, and a button-less overlay has no first button to steal focus, so keystrokes leak into the focused field beneath. Marked #[ignore] so the suite stays green; un-ignore once the overlay claims keyboard focus / consumes text while active. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(overlay): frame-start input claim (QA-001) + clear action queue on switch (SEC-007) Implements two QA-wave findings from the design addendum (§1 A-2, §2 A-4): - QA-001 (HIGH) — button-less keyboard/text leak. `render_global`'s key filter runs at end-of-frame, one frame too late: a button-less hard block raised over an already-focused field let typed characters reach the field beneath (the J-2 broadcast / J-4 migration case). New `ProgressOverlay::claim_input(ctx)`, called near the top of `AppState::update` (before the panels) and gated on no active secret prompt, releases beneath text-edit focus and strips `Event::Text` plus the navigation/confirm keys (Tab, Enter, Escape, Space, arrows). The `#[ignore]`d probe `qa_buttonless_overlay_blocks_typing_into_focused_field_beneath` is un-ignored and now passes. - SEC-007 — `clear_all_global` (network switch) now also drains the action queue, so a click queued just before the switch cannot survive into the new context and be mis-dispatched. Adds inline unit tests: `claim_input` strips text + nav/confirm keys while a block is up and is a no-op when idle; `clear_all_global` clears the queue. Scope note: this is a partial pass on the QA list. The end-of-frame filter in `render_global` is kept as belt-and-suspenders and is NOT yet gated on a secret prompt (marked TODO at the call site — blocker #2's full fix removes it and routes the keyboard tests through `claim_input`). Still outstanding from the addendum / task: A-1 no-progress watchdog, A-3 keyed `OverlayHandle::take_actions` + `sweep_orphan_actions`, instance `Component::show` focus-trap separation, secondary-button styling, 30s clock seam, Foreground layering, and doc sync. Also adds Nagatha's `04-design-addendum.md` (the authoritative spec). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(overlay): QA-wave hardening — watchdog, keyed dispatch, secondary buttons, Foreground, focus separation Implements the design addendum (§1/§2) plus the rest of the QA fix list and the three cross-finding reconciliations. All on top of the earlier claim_input/SEC-007 pass. Addendum §1 (safety-valve / A-1): - 120 s no-progress watchdog: STUCK_OVERLAY_WATCHDOG_THRESHOLD, OverlayState { last_progress_at, watchdog_logged }, watchdog_tripped() clock seam, escalated STUCK_WATCHDOG_REASSURANCE (replaces the soft line, never stacks), one-shot tracing::error! (no flaky time-based panic). last_progress_at is bumped on a real content change, reusing log_overlay_state's change detection, so a progressing multi-step flow never trips it. Addendum §2 (action-dispatch / A-3, SEC-007/A-4): - Actions are keyed: OverlayAction { key, action_id }. OverlayHandle::take_actions() drains only its own ids (FIFO); clear() purges its key's pending ids; the static take_actions is demoted to sweep_orphan_actions() (dead-owner ids only). app's drain logs orphans. clear_all_global already clears the queue (SEC-007). Reconciliations (lead brief): 1. SEC-004/F-1 — claim_input is gated on no active secret prompt at the app site, and render_global no longer strips keyboard at all (the gated claim_input is the sole keyboard block); release-beneath-focus is button-less only (stop_text_input clears ANY focus, which would steal a button's focus otherwise). 2. QA-002 — claim_input strips Space (and render_global's removal means the kittest keyboard path runs through claim_input). TC-OVL-044 now also presses Space. 3. QA-003 — render_card/render_buttons take trap_focus; the instance Component::show passes false so it never seizes the host screen's focus or installs the lock. Rest of the list: - SEC-002: overlay dim/sink/card raised to Order::Foreground (above ComboBox / autocomplete / SelectionDialog popups); passphrase modal also raised to Foreground so it stays above the overlay (R-1, TC-OVL-048). - F-3/4/7: ButtonStyle { Primary, Secondary }, with_secondary_button on OverlayConfig/OverlayHandle/instance, ConfirmationDialog-style right_to_left layout (primary right, secondary left). - SEC-005: corrected the Send+Sync note to the real invariant (UI-thread-only ops). - F-6: Elapsed uses a named placeholder. SEC-006: log-content doc note on show_global. - QA-007: instance clear() makes the empty-response path reachable. - QA-008: TC-OVL-013b asserts elapsed >= 2s; TC-OVL-021 also bounds vertically. Tests: un-ignored qa_buttonless probe; new inline tests (watchdog threshold/clock-reset/ one-shot, keyed FIFO/isolation/orphan-sweep, QA-007); new kittest reconciliations (render_global keeps keyboard for the prompt; instance show leaves host focus navigable). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(overlay): sync requirements/dev-plan to the shipped design + add UX user story - 01-requirements-ux.md: add a supersession callout flagging the Cancel-era items now overtaken by the generic-button + watchdog + claim_input redesign (FR-7, AC-7.3/7.4, NFR-3 AC-3b, AC-8.4, AC-10.5, J-1/J-2/J-3, §6.3-6.5), pointing at the dev-plan post-outage note, the addendum, and the code as source of truth. - 03-dev-plan.md: drop OVERLAY_CANCEL_ACTION_ID from the §2 re-export row; mark the §3 API block superseded (real surface is with_button/with_secondary_button, keyed take_actions/sweep_orphan_actions, OptionOverlayExt::raise, the watchdog); fix the §4.1 drain comment; update the §9 D-4/D-5 rows. - user-stories.md: add UX-001 (blocking please-wait overlay; cannot fire a conflicting second action), tagged across personas, [Implemented]. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(overlay): close re-QA coverage residuals RQ-1/RQ-2/RQ-3 RQ-1 (security) — the app.rs secret-prompt gate had no test; deleting `if self.active_secret_prompt.is_none()` left every test green. Extracted the gate into `AppState::claim_overlay_input` (called from `update`) and added a `#[cfg(feature = "testing")]` seam (`AppState::test_set_secret_prompt_active`, `ActivePrompt::test_stub`). New AppState-level kittest `rq1_appstate_secret_prompt_gate_keeps_prompt_typeable_over_overlay` drives the REAL `update()` loop with a prompt active over a button-less overlay and asserts the prompt input keeps focus AND accepts typed text (types a passphrase + Enter, the prompt submits and closes). Deleting the gate makes `claim_input` (button-less → `stop_text_input`) steal focus and strip the keys, failing both assertions. Extended `tc_ovl_048` to assert prompt interactivity (submit button renders + input holds focus), not just visibility. RQ-2 — added a `#[cfg(feature = "testing")]` clock seam `OverlayHandle::backdate` (shifts `created_at` + `last_progress_at` into the past). New kittest `tc_ovl_047b_threshold_reveals_via_clock_seam` renders past 30 s and 120 s and asserts: the soft "This is taking longer than usual." line + Elapsed force-reveal, then `STUCK_WATCHDOG_REASSURANCE` REPLACING the soft line (never both) — the addendum §1 obligation that was previously only flag-checked. RQ-3 — reframed the `tc_ovl_047` doc comment (the escape-hatch button is a deliberate v1 non-feature per addendum §1, not a deferred T7 TODO); added a "(superseded)" note to 01-requirements-ux.md's "what to reuse" list where it still cited `with_cancel`/`with_action`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(overlay): close QA residuals — README catalog entry, requirements Cancel reconciliation, T7 TODO Post-gate cleanup on the blocking progress overlay (gate green): - README: add a ProgressOverlay row to the Feedback Components table, covering show_global/render_global, with_button(id, label), the 120s watchdog, and companions OverlayConfig/OverlayHandle/OptionOverlayExt/ ProgressOverlayResponse. - 01-requirements-ux.md: reconcile the remaining literal-Cancel acceptance criteria (intro line, AC-7.3, AC-8.4, the §6.5 "Visible, cancelable" row, R-3) to the shipped generic-button model, matching the top supersession callout — Esc/Tab/Enter/Space are swallowed and there is no built-in Cancel. - app.rs: mark drain_overlay_actions with a TODO(T7) recording that an overlay button can only stop waiting (not abort) until the BackendTask system gains cooperative cancellation; until then the 120s watchdog (see progress_overlay.rs) bounds every block. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(overlay): hard-block the UI during startup/Connect SPV sync Raises the blocking ProgressOverlay while a startup- or Connect-initiated SPV sync runs, and lowers it when the chain becomes usable (Synced) or fails (Error). Honors the overlay's C1/C2 caller contract. SPV sync is UNBOUNDED — it can wait indefinitely for peers — so a button-less block would trap the user. The block therefore carries a "Continue in the background" escape (`SYNC_CONTINUE_BACKGROUND_ACTION`); clicking it lowers the block while sync proceeds safely in the background (read-only — nothing is stranded). C1: the block also always lowers on its own at a terminal state. - `AppState`: `sync_overlay`/`sync_block_active`/`sync_overlay_dismissed` fields; armed on boot auto-start and on the manual `StartSpv` (Connect); reset on network switch so the handle never goes stale. - New per-frame `update_sync_overlay` driver (called beside `update_connection_banner`) applies a pure, unit-tested policy `sync_block_step` (Block / Release / Idle) and drains the escape click. - Pure decision + descriptions are i18n-clean single sentences. Tests: 6 inline unit tests of `sync_block_step` (inactive→Idle; active+not-usable →Block; terminal→Release for both dismissed states; dismissed→Idle; stable action id; sentence descriptions). New `#[cfg(feature = "testing")]` integration kittest `task9_sync_overlay_blocks_lowers_on_synced_and_on_escape` drives the real `update_sync_overlay` against a forced connection state: asserts the block raises while connecting, lowers on Synced (C1), and lowers on the escape click (C2 — user never trapped). Adds `ConnectionStatus::set_overall_state` + AppState `test_activate_sync_block`/`test_drive_sync_overlay` test seams. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(overlay): align SPV-sync block to the approved spec Reworks the SPV-sync overlay wiring (introduced in the previous commit) to the user-approved design. Net behaviour: while the active context is Connecting or Syncing the overlay hard-blocks the UI, lowering when the chain becomes usable (Synced), fails (Error), or drops (Disconnected). Changes vs the first cut: - Keyed purely to the live connection state + a per-episode dismissal flag — drops the separate "armed" flag, so any sync episode (startup, Connect, or reconnect) blocks. Pure policy renamed `sync_block_step` -> `spv_block_step` (Block/Release/Stand); Disconnected now Releases + re-arms. - Escape is now an always-visible SECONDARY button "Continue in the background" (id renamed `spv:sync:continue_background`); fields renamed to `spv_overlay`/`spv_overlay_dismissed`; method renamed `update_spv_overlay` and driven BEFORE `update_connection_banner`. - Live content: description = `spv_phase_summary(progress)` (else a generic connecting line), plus a "Step N of 5" counter via new `connection_status::spv_phase_step` (Headers=1 … Blocks=5). Raises once per episode, then updates in place. - Suppresses the redundant Connecting/Syncing connection-banner text while the overlay is up (don't double-shout); keeps Error/Disconnected banners. C1/C2 contract preserved: SPV sync is UNBOUNDED, so the escape (lower while sync continues safely in the background — read-only, nothing stranded) guarantees the user is never trapped; episode-ending states always release. Tests updated: 4 inline `spv_block_step` unit tests; the integration kittest `task9_spv_overlay_blocks_lowers_on_synced_and_on_escape` now also asserts the secondary escape button, re-raise for a fresh episode, no re-raise within a dismissed episode, and re-raise after the episode ends. Test seams renamed to `AppState::test_drive_spv_overlay` (+ `ConnectionStatus::set_overall_state`). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(overlay): reconcile SPV-sync block decision (F-SPV-1) + phase-step test (F-SPV-2) F-SPV-1 — the user-authorized SPV-sync hard-block + always-visible "Continue in the background" escape contradicted three docs written for the standalone overlay. Reconcile the docs to the decision (the feature is correct; the docs were stale) so a future dev does not "correctly" remove the button per old docs: - docs/user-stories.md: carve out the SPV-sync exception in UX-001's "no background/dismiss button" guarantee, and add UX-002 — the blocking SPV-sync overlay with the always-on "Continue in the background" escape (tagged across personas, [Implemented]). - 01-requirements-ux.md §5: supersession note — the user chose to block the startup/Connect get-connected sync; the power-user concern is mitigated by the escape (sync is read-only and safe to background); this is the overlay's first adopter. - 04-design-addendum.md A-1: record that A-1's "ship NO dismiss/background button in v1" was scoped to unsafe-to-interrupt ops whose safety rests on boundedness; for the unbounded-but-read-only SPV-sync adopter the C2 "never trap the user" guarantee is met by the always-on escape, which must NOT be removed. F-SPV-2 — the granular phase progress (spv_phase_summary description + "Step N of 5" via spv_phase_step) was already wired in the previous commit; adds a unit test locking the active-phase → step mapping and the summary text. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(overlay): scope SPV block to user-initiated sync + de-jargon copy (F-SPV-A/B/E) F-SPV-A (sev-2/1 regression, introduced by the prior refactor) — the SPV block fired on ANY Connecting/Syncing, so an ambient mid-session reconnect, or the SPV engine flipping Synced→Syncing as it processes each new block (event_bridge on_progress maps !is_synced() → Syncing), would hard-block a working user. Re-introduce a startup/Connect-SCOPED arming gate: - `spv_block_armed` flag, armed only on boot auto-start and the Connect button (AppAction::StartSpv); reset on network switch. - `spv_block_step(armed, dismissed, state)`: !armed → Idle (never block); armed + Synced/Error → Disarm (lower + clear armed); armed + Connecting/Syncing/ Disconnected → Block (or Stand if dismissed). Once disarmed, ambient sync never re-blocks until the next user-initiated episode. F-SPV-B (sev-2) — the block description showed blockchain jargon ("Headers: 12345 / 27000 (45%)") to the Everyday User. Replace with plain complete sentences ("Connecting to the Dash network." / "Syncing with the Dash network."); keep the jargon-free "Step N of 5" counter (via spv_phase_step) as the determinate granularity. spv_phase_summary stays (still used by wallets_screen); it is just no longer the overlay description. UX-002 acceptance criterion updated to stop enshrining the jargon. F-SPV-E (sev-4) — AppAction::StartSpv set an orphaned Info banner whose handle was dropped (could not be cleared by the overlay's banner suppression). Dropped it; the block conveys "connecting" and the error path still surfaces via replace_global. Tests: spv_block_step unit tests rewritten around the arming gate — `unarmed_never_blocks` is the regression guard (ambient sync never blocks); `armed_terminal_state_disarms`; jargon-free-description test. The integration kittest is rewritten to `task9_spv_overlay_armed_scope_disarm_and_escape`: an un-armed Connecting does NOT block, an armed one does, Synced disarms, ambient sync afterward does NOT re-block, the escape lowers without re-raising, and only a fresh armed episode re-blocks. New `AppState::test_arm_spv_block` seam. is_synced() finding: `EventBridge::on_progress` (event_bridge.rs) does map `!is_synced()` → `SpvStatus::Syncing`, so overall_state CAN flip Synced→Syncing on per-block catch-up — the arming gate makes that harmless (disarmed after the initial episode). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(overlay): address review findings — deterministic elapsed test, SPV phase-count constant, input-claim hardening, doc drift - Replace the 2.1s wall-clock sleep in tc_ovl_013b with the deterministic `backdate` clock seam (gated behind `testing`), mirroring tc_ovl_047b — zero wall-clock waiting; asserts the elapsed readout counts up to a concrete 2s. - Add `SPV_SYNC_PHASE_COUNT` next to `spv_phase_step` as the single source of truth for the "Step N of 5" total; reference it at both app.rs call sites and guard the max step with a `debug_assert!` so it cannot silently drift. - Delete the misplaced orphan-sweeper paragraph from `claim_overlay_input`'s doc (it belongs to `drain_overlay_actions`, which already carries it). - Reconcile the `Order::Middle` → `Order::Foreground` doc drift: supersession callouts in the dev plan §4.2/§4.3 and the kittest module doc, citing SEC-002. - Drop the dead `CONNECTING_MSG`/`replace_global` swap in the StartSpv failure path (the "Connecting…" banner was removed in F-SPV-E) for a plain `set_global(...).with_details(e)`; fix the now-stale comment. - Extend `claim_input`'s per-frame strip to also drop Backspace, Delete, Home, End, PageUp, PageDown and the Copy/Cut/Paste clipboard events; add a kittest locking the new classes via event survival + the field-beneath contract. - Strengthen the SEC-001 lifecycle rustdoc on `show_global` / `show_global_spinner_only` (button-less blocks need a frame-driven reconcile owner or an escape; the watchdog only logs). - Nits: UX-001 "developer warning" → "developer error"; "while a armed" → "while an armed". Add deferred TODOs (SEC-002-pointer, SEC-001, RUST-006). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(overlay): close one-frame SPV block gap, fix slow-phase watchdog, align API to MessageBanner Three changes to the blocking progress overlay + SPV-sync hard-block: A — Close the one-frame interactive gap. `update_spv_overlay` now runs at the top of `AppState::update`, BEFORE `claim_overlay_input`, the visible screen `ui()`, and `render_global`. A freshly-armed episode therefore raises, claims input, AND paints on the same frame; previously the block was raised only after `render_global`, leaving the frame right after Connect/arming fully interactive (effective at frame N+2). The connection banner still reads the block state afterwards, so its Connecting/Syncing suppression is unchanged. B — Stop the 120s no-progress watchdog from falsely escalating on slow phases. A single SPV phase running >120s (e.g. Headers on a slow link) wrote a constant (description, step), so `log_overlay_state` never reset `last_progress_at` and the watchdog tripped — swapping to the STUCK copy and firing the one-shot dev-error, the exact false signal the SPV escape was meant to avoid. A hidden, monotonic `progress_token` (step in the high 32 bits, advancing height in the low 32) is threaded from `ConnectionStatus` into the overlay; an advancing token resets the watchdog even when the shown (description, step) is unchanged. The token is NEVER rendered — copy is byte-for-byte unchanged and the jargon-free test stays green. Distinct from TODO(SEC-001), which is left in place. C — Align the overlay public API toward MessageBanner so migrating from the banner is a name-for-name swap. One-way (overlay → banner), no capability loss: with_button(id, label) -> with_action(label, action_id) with_secondary_button(id, label) -> with_secondary_action(label, action_id) show_global(...) -> set_global(...) (return type kept) show_global_spinner_only(...) -> set_global_spinner_only(...) `OptionOverlayExt::raise` keeps its name: renaming to `replace` (the banner analogue) would be shadowed by the inherent `Option::replace`, so every `slot.replace(ctx, desc, config)` call would fail with E0061 (verified). A doc note records why. `render_global`, `claim_input`, the watchdog, `OverlayConfig`, and all handle progress methods are untouched. Rustdoc, the README catalog row, and the design-doc API references are updated to the new names; the banner's own `MessageBanner::show_global(ui)` render path is left alone. Tests: new real-AppState kittest for the one-frame gap (same-frame paint), new backdate kittest + unit tests for the token-driven watchdog reset, and a `spv_progress_token` monotonicity unit test. fmt + clippy clean; kittest 138 passed; lib 926 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(overlay): keyboard-reachable escape for the SPV hard block (QA-002 refinement) Resolves the TODO(RUST-006) marker: the SPV-sync hard block's "Continue in the background" escape was mouse-only, stranding keyboard-only / assistive-tech users behind the UNBOUNDED block. Hard blocks strip Enter/Space every frame (the deliberate QA-002 rule, guarded by TC-OVL-044), so the escape could not be activated by keyboard. Add a per-block opt-in — `OverlayConfig::with_keyboard_escape(action_id)` and `OverlayHandle::with_keyboard_escape(action_id)` — that designates ONE action as the single keyboard-reachable escape. The general rule is unchanged: a block with no designated escape stays fully keyboard-blocked. - claim_input: when the active block designates an escape AND that escape button is *confirmed* to hold focus (its egui id was recorded by last frame's render_buttons and still matches the focused widget), Enter/Space pass through; every other key, and the raise frame (focus not yet confirmed), stays stripped. So the passthrough can never reach a widget beneath. - render_buttons: for an opt-in block, pin focus to the designated escape (match by action id) — re-requested every frame and locked — and record its id for the claim_input gate. - SPV adopter (update_spv_overlay): mark "Continue in the background" as the keyboard escape; it remains unconditionally present whenever the block is up. Tests (egui_kittest — the reliable check for input/focus): - TC-OVL-051/052: Enter / Space activate the focus-pinned escape. - TC-OVL-053: a TextEdit beneath never receives Enter; Tab and a backdrop click cannot move focus off the escape. - task9_spv_escape_is_keyboard_activatable: the REAL SPV block lowers on Enter. - TC-OVL-044 and the keyboard-block tests stay green (general rule intact). - Unit tests for the opt-in API + the claim_input safety gate. Docs: QA-002 design note + NFR-3 accessibility ACs, test-spec, user story UX-002, and the public rustdoc updated to state the refined rule. cargo +nightly fmt: clean. clippy --all-features --all-targets -D warnings: 0. kittest --all-features: 142 passed. lib --all-features: 928 passed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(overlay): adopt blocking overlay for DPNS registration (Bucket A exemplar) Establish the canonical Bucket A overlay-adoption pattern on the DPNS username registration screen, the template for the remaining transaction screens. The screen used a progress banner as its in-progress indicator and did not block re-entry while WaitingForResult, leaving a double-submit hole (duplicate name registration). Replace the banner with a button-less full-window ProgressOverlay raised at dispatch and torn down on every terminal result, which both signals progress and closes the double-submit hole. - Add `op_overlay: Option<OverlayHandle>`; raise it in `begin_registration` only when a real BackendTask is produced, so a no-op click never strands a block. - Tear the overlay down on both terminal paths (SEC-001): the success arm of `display_task_result` and the error/warning branch of `display_message`, mirroring the prior `refresh_banner` lifecycle. - Remove the now-redundant progress banner; the full-window block makes a WaitingForResult button-disable unnecessary. - Add a `raise_progress_overlay_for_test` seam and kittests proving the raise + guaranteed teardown on success and error. - Make `ui::identities` `pub` (the lone non-pub sibling) so the screen is reachable from the kittest crate, matching `wallets`/`dpns`/`tokens`. - Note the blocking overlay + double-submit prevention on DPN-001. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(overlay): restore SEC-003 watchdog TODO comment dropped by the base-merge Comment-only restore (matches the base #863 app.rs); no logic change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hydration HEAD (ea0082e6) Advances the 4 dashpay/platform branch deps (dash-sdk, platform-wallet, platform-wallet-storage, rs-sdk-trusted-context-provider) and their 23 transitive platform crates from b4506492 to branch HEAD ea0082e6 (PR #3828, 6 commits forward). What this picks up: - 8d8724e9 (breaking): retire the in-band pool snapshot; hardcode core_utxos.account_index = 0; drop the core_derived_addresses and account_address_pools tables (V001 edited in place). Removes the WalletStorageError::DerivedIndexInvariantViolated / UtxoAddressNotDerived variants. - e8308ed6: persist non-default-account core UTXOs under index 0 instead of skipping them — a genuine write-side fund-loss fix. - b4506492/1f3ea29c sync-manager restart-in-place generation guards. No DET API breakage: the removed WalletStorageError variants and the dropped storage tables are never referenced by DET, and the account_address_pools_blocking manager accessor (the only pool API DET calls) is retained. Verified clean: cargo check --all-features --all-targets cargo clippy --all-features --all-targets -- -D warnings cargo +nightly fmt --all -- --check det-cli smoke: network-info / tools (28) / tool-describe / core-wallets-list Note: V001 was edited in place to drop two tables, so existing wallet databases must be dropped and re-migrated (handled separately). This bump does NOT by itself resolve the restart-in-place Core balance undercount — that needs the upstream BIP44 address-pool depth fix (see docs investigation). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…esign' into docs/platform-wallet-migration-design
…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>
…intext 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>
… 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>
Goal
Dash Evo Tool carried its own large SPV/wallet stack that duplicated logic now maintained upstream. This PR rewrites DET's wallet backend on top of the upstream
platform-walletcrate (dashpay/platform), which owns SPV chain sync, address derivation, identity/asset-lock handling, and the shielded coordinator.DET is now a thin adapter over that crate: its bespoke SPV stack (
src/spv/) and the legacy RPC wallet mode are gone, chain sync and derivation come from upstream, and the shielded subsystem routes through the upstream coordinator. TheBackendTaskaction/channel contract is preserved, so the UI layer is largely unchanged. Existing wallets are migrated onto the new model on first launch (marker-gated, with a*.premigrationbackup). Net effect: far less wallet code to maintain in DET, a single source of truth shared with the rest of the platform, and upstream fixes inherited automatically.Bugs fixed
Funds-safety
Balance display
Shielded confirmation-safety
*ConfirmationUnknownerrors instead of a false success.Secret hygiene
Data-safety
Lifecycle / sync
Breaking changes
platform-walletpin includes a schema change that rewrites migrationV001in place — it drops thecore_derived_addressesandaccount_address_poolstables and hardcodes core UTXOaccount_index = 0. Existingspv/<net>/platform-wallet.sqlitedatabases are not forward-compatible and must be deleted and re-synced.Known gaps
platform-wallet/dash-sdkcrates are pinned to an unreleased branch (fix/wallet-core-derived-rehydration), not a released tag. Must be re-pinned to a released version before merge-to-ship.432.41vs7.34DASH). Watch-only rehydration rebuilds accounts from xpubs and derives only the default gap window (indices0..=29) without restoring the persisted derivation depth — a regression of bug: Imported wallet shows zero balance — historical UTXOs invisible even after restart #829 Bug 2. The fix belongs upstream inrs-platform-wallet(extend each account'sAddressPoolto the highest restored index on rehydration); tracked.SingleKeyWalletsUnsupported, pending upstream watch-only support. Import / data-loss guard / password-restore are done.Testing
cargo build/cargo build --features headlessclean;cargo clippy --all-features --all-targets -- -D warningsclean;cargo +nightly fmt --all -- --checkclean.network-info,tools,tool-describe,core-wallets-list) passes.docs/ai-design/.Attribution
🤖 Co-authored by Claudius the Magnificent AI Agent