Skip to content

feat: platform-wallet backend rewrite (spec + implementation)#860

Draft
lklimek wants to merge 358 commits into
v1.0-devfrom
docs/platform-wallet-migration-design
Draft

feat: platform-wallet backend rewrite (spec + implementation)#860
lklimek wants to merge 358 commits into
v1.0-devfrom
docs/platform-wallet-migration-design

Conversation

@lklimek

@lklimek lklimek commented May 18, 2026

Copy link
Copy Markdown
Contributor

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-wallet crate (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. The BackendTask action/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 *.premigration backup). 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

  • Funds sent to addresses beyond the SPV gap window — via Receive → New Address, the asset-lock deposit address, or platform top-up change — were never watched and never appeared in the balance. All address derivation now comes from the SPV-watched pool.
  • The asset-lock deposit QR flow could hang at "Waiting for funds…" forever (the event that advances it had no producer).
  • Incoming DashPay contact payments were never detected or credited (the detection chain had no callers).
  • Creating an identity or funding from wallet balance on a reloaded (watch-only) wallet no longer fails with a missing-private-key error.

Balance display

  • The Wallets selector's platform balance no longer diverges from the per-address total (it previously over-counted non-owned "orphan" addresses, reading up to ~227× too high). The selector total and the per-address tab now derive from the same coordinator-pushed data and are equal by construction.

Shielded confirmation-safety

  • Shield-from-asset-lock and the four sibling shielded spends reported success / marked notes spent even when the transaction might never have confirmed. They now surface typed *ConfirmationUnknown errors instead of a false success.

Secret hygiene

  • The passphrase was prompted at startup for wallets the user hadn't chosen to unlock; it is now requested just-in-time per operation and dropped immediately after use.
  • A single-key private key was kept in plaintext for the whole session — removed.
  • DashPay ECDH and HD encryption keys are now zeroized on drop.

Data-safety

  • Password-protected single-key wallets that silently vanished after upgrade are preserved.
  • Wallets left unloaded after the cold-start migration (which required a manual restart) are now rehydrated automatically.

Lifecycle / sync

  • User-set identity aliases are preserved during auto-discovery (previously silently overwritten).
  • Platform/identity sync no longer starts before chain quorum is ready (which previously caused a DAPI ban storm).
  • Disconnect → Connect no longer fails to reopen the wallet database (the SPV persister is now released on shutdown).

Breaking changes

  • Wallet database must be dropped and re-synced. The upstream platform-wallet pin includes a schema change that rewrites migration V001 in place — it drops the core_derived_addresses and account_address_pools tables and hardcodes core UTXO account_index = 0. Existing spv/<net>/platform-wallet.sqlite databases are not forward-compatible and must be deleted and re-synced.

Known gaps

  • Dependency pin — merge-blocker (PROJ-005). The upstream platform-wallet / dash-sdk crates 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.
  • Core balance under-counts after restart-in-place. The header / Balance-breakdown "Core" shows the full persisted balance while the "Dash Core" tab and per-address view under-count to the gap window (e.g. 432.41 vs 7.34 DASH). Watch-only rehydration rebuilds accounts from xpubs and derives only the default gap window (indices 0..=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 in rs-platform-wallet (extend each account's AddressPool to the highest restored index on rehydration); tracked.
  • Non-wallet data not migrated (PROJ-034). App settings (network/theme/paths), top-up history, and scheduled DPNS votes reset on upgrade; scheduled votes carry a vote-window deadline risk. Deferred to a follow-up.
  • Single-key wallets partial (PROJ-007). Balance refresh and SPV send for single-key wallets still return SingleKeyWalletsUnsupported, pending upstream watch-only support. Import / data-loss guard / password-restore are done.
  • Minor / deferred: token "stop tracking" is undone by a refresh (PROJ-041); no in-app notice for the removed QR-direct-fund path (DOC-003); external user-docs update pending (PROJ-018). Merge-time action: fill the ADR floor SHA placeholder (PROJ-019).

Testing

  • cargo build / cargo build --features headless clean; cargo clippy --all-features --all-targets -- -D warnings clean; cargo +nightly fmt --all -- --check clean.
  • Library + UI (kittest) + e2e suites green.
  • det-cli standalone smoke (network-info, tools, tool-describe, core-wallets-list) passes.
  • Funds-safety / security review on the migration, shielded retirement, and address-derivation changes; per-workstream design + QA reports under docs/ai-design/.

Attribution

🤖 Co-authored by Claudius the Magnificent AI Agent

@coderabbitai

coderabbitai Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 725be8b0-ad55-4827-bbc4-d419fe928907

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/platform-wallet-migration-design

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@lklimek lklimek changed the title docs: platform-wallet migration design docs: platform-wallet backend — clean-slate rewrite spec May 18, 2026
@lklimek lklimek changed the title docs: platform-wallet backend — clean-slate rewrite spec feat: platform-wallet backend rewrite (spec + implementation) May 19, 2026
lklimek added a commit that referenced this pull request May 29, 2026
refactor: complete data.db unwire — shielded + DashPay (stacked on #860)
@lklimek

lklimek commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Doc sync — 686430a4 (docs/kv-keys.md)

Trillian audited the k/v key reference against current source and corrected three stale entries. All align the doc with code changes already described in this PR:

  • Migration sentinel — was documented as a single global det:migration:finish_unwire:v1 with constant SENTINEL_KEY. Now correctly det:migration:finish_unwire:<network>:v1 (per-network, via sentinel_key_for(network)), consts SENTINEL_KEY_PREFIX + SENTINEL_KEY_VERSION. Tracks SEC-001.
  • Single-key metadata sidecarImportedKey field list extended from 3 → 5 (has_passphrase, passphrase_hint). Tracks SEC-002 Option C.
  • HD seed envelope encoding — value is now [ version byte | bincode::serde(payload) ], not bare bincode::serde. Tracks SEC-005.

All other sections (17 platform-wallet keys, DashPay sidecar, settings, wallet selection, summary counts) verified accurate — no drift. Doc-only; no source touched.

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 2 commits June 1, 2026 13:08
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>
@lklimek

lklimek commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

SPV-start fix (PROJ-001, CRITICAL) + consolidated gap report — 8d35933e

Bug: the Settings "Connect" button did nothing and SPV stayed idle. Root cause: AppContext::start_spv() was an inert Ok(()) compile-floor stub and WalletBackend::start() (the only spawner of the SPV run loop) had zero callers — so SPV / platform-address / identity sync never started in any path (Connect, auto-start, MCP, network switch).

Fix (3 commits):

  • 42388c4b — wire start_spv()WalletBackend::start(); add a per-instance StartLatch so SPV can't be double-driven.
  • 3165f98c — route all callers through one idempotent async chokepoint AppContext::ensure_wallet_backend_and_start_spv() (wire-then-start); fixes the boot auto-start race (sync constructor fired before the backend was wired), MCP ensure_spv_synced, and network-switch restart; hoist SpvStatus::Error above the DAPI gate so chain-sync failures aren't masked as "Disconnected".
  • 36f5a982 — surface start/wiring failures to the user (indicator → Error in the chokepoint; actionable banner at the Connect handler); replaces the dead AppAction::Custom no-op.

QA: two full Marvin rounds (caller-integration focus). All findings resolved; 2 LOW residuals (user-facing wiring-failure feedback + a forward-compat dead branch) closed by 36f5a982. 571 lib tests pass (+8), clippy/fmt clean. 8 new offline tests cover the start-path gating. The one kittest failure (tc_sk_004) is confirmed pre-existing.


Consolidated gap audit — 2313089a (+ status update 8d35933e)

docs/ai-design/2026-06-01-pr860-gap-audit/gaps.md — a whole-PR audit by project-reviewer Adams. 21 confirmed gaps (1 CRITICAL, 4 HIGH, 6 MEDIUM, 7 LOW, 3 INFO), each with file:line. Highlights:

  • PROJ-001 (CRITICAL) — resolved on-branch (above).
  • PROJ-005 (HIGH) — now the sole remaining open merge-blocker: Cargo.toml pins platform to the unreleased #3625 rev (release gate G1).
  • 5 net-new findings, notably PROJ-004 (HIGH) — DashPay outgoing contact-request xpub derivation uses placeholder seed material (let wallet_seed = sender_private_key;), the substrate behind the TC-037/043 association symptom.
  • 4 seeded "gaps" verified already-fixed (eager-init hard-failure → graceful warn, TC-019 precedence, core_backend_mode removal, the 5-manager readiness gate).

Remaining open gaps are catalogued in the report (not filed as separate issues by request).

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 7 commits June 1, 2026 13:31
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>
@lklimek

lklimek commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

DetKv per-object refactor + platform bump to 35e4a2f129d54d0

Reseats DET's KV layer onto upstream's per-object KvStore and retires two of three duplicate caches. 7 commits:

  • 549ddfa1 — honest error when wallet data is from a newer app version (WalletDataTooNew, replaces the misleading "check disk space" for schema-version skew).
  • dbd94356 — reseat DetKv on the 08b0ed9 per-object KvStore via a DET-side DetScope { Global, Wallet, Identity, Token } (maps to upstream ObjectId only inside kv.rs — no type leak); isolate the platform-address and token-balance caches behind PlatformAddressView/TokenBalanceView seams.
  • 845ff685 — honest error for an incompatible on-disk schema (WalletDataIncompatible, divergent-migration case); finish the token-balance seam.
  • fb50c044 — bump platform to 35e4a2f (meta_* FK relaxed to soft-cascade triggers; public per-(identity,token) balance reader).
  • f6119de7delete the det:token_balance cache; balances now read live from upstream IdentitySyncManager via a lock-free snapshot bridge (pre-sync shows "syncing", not 0).
  • d7ac9a06promote identities + DashPay overlays to Identity scope (identities/top-ups/scheduled-votes → Identity(id); dashpay private/address_indexIdentity(owner)); Global enumeration indices; explicit cleanup (the upstream soft-cascade trigger doesn't fire for DET-only KV removals); hardened private-key Debug redaction.
  • 129d54d0 — docs synced to the per-object model.

Principle applied: DET's KV stores only DET-specific metadata; canonical object state upstream owns (token balances) is read, not duplicated.

QA (Marvin, combined tree): PASS — cargo build --all-features ✓, 598 tests (597 pass, 1 ignored) ✓, clippy -D warnings ✓, fmt ✓. Zero code defects; M-DONT-LEAK-TYPES intact.

Remaining duplicate: the platform-address cache (det:platform_addr/det:platform_sync) stays, staged behind PlatformAddressView — one-line swap once upstream exposes a public per-address nonce reader (the only open platform-side dependency; the FK relaxation and token-balance reader both landed in #3625 @ 35e4a2f).

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 8 commits June 1, 2026 17:41
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>
@lklimek

lklimek commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

Mock/stub findings + SPV progress + DashPay fund-routing — 450214e5 (8 commits)

Post-35e4a2f cleanup from a mock-audit, plus a fund-routing fix the audit surfaced. All on top of the DetKv per-object refactor.

SPV / UX

  • bd0ed0e4SPV sync progress bar restored: two UI sites read an inert SpvStatusSnapshot::default(); EventBridge::on_progress already received full upstream SyncProgress (per-phase heights) but discarded it. Routed it into ConnectionStatus (single source of truth) → determinate per-phase % bar. Also fixed two dead gates (network selector now disables mid-sync; "Clear SPV Data" gated).
  • 7e2553e3 — real per-network platform activation height (was hardcoded 1).

Tokens / MCP

  • 5a047357"Stop Tracking Balance" truly un-watches the (identity, token) pair upstream (update_watched_tokens), not just a local order prune.
  • 5ba4554eplatform_withdrawals_get MCP tool: structured fields + pagination.

DashPay

  • a7327e7c — contact-request expires_at derived from created_at + 7d.
  • 3ac9b3b0update_payment_status now persists transitions (was a logging no-op). check_address_usage documented as blocked-by-design (upstream usage reader is keyed by (wallet, account-type), not arbitrary address; contact-send addresses aren't pool-managed).
  • 6c520a33PROJ-004 (fund-adjacent): contact-request xpub now derived from the real HD seed via upstream derive_contact_xpub (was a placeholder using the ECDH private key → mis-routed contact payments). Seed handling: Zeroizing, confined to the wallet_backend seam, never in the document. Smythe-audited: SAFE.
  • 450214e5SEC-001 (HIGH, fund-routing): receive-side DashPay derivation hardcoded coin-type 5' on all networks while the send side (now correct) uses 1' on testnet → testnet contact payments landed on un-scanned addresses (recoverable; mainnet unaffected). Fixed via a canonical coin_type_for_network helper threaded through all receive paths (incl. the DIP-15 root-encryption + auto-accept keys, same bug). Send==receive xpub now pinned by a cross-implementation equality test on both networks. Smythe-re-audited: SEC-001 RESOLVED.

QA/Security: Marvin (integration) + Smythe (two fund-safety passes) — cargo build --all-features ✓, 611 lib tests ✓, clippy -D warnings ✓, fmt ✓.

Standing gates / follow-ups (non-blocking):

  • Live-network test required before trusting contact payments with funds — mainnet round-trip (real funds) and testnet (now expected to pass post-SEC-001).
  • SEC-003 (G1): fund routing depends on the unreleased platform-wallet @ 35e4a2f — no mainnet release until a published rev (existing G1 gate).
  • SEC-004 (LOW): auto-accept key rooted on a 32-byte private key rather than the HD seed (self-consistent, doesn't route funds) — tracked follow-up.

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 2 commits June 2, 2026 11:02
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>
@lklimek

lklimek commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

Platform dep bump → ffdc28b8 + gap-audit refresh

Pushed 2 commits (450214e5..419ef952).

fd209783chore(deps): bump platform deps 35e4a2fffdc28b8 (current head of dashpay/platform#3625, a clean 15-ahead/0-behind fast-forward)

  • All four platform crates re-pinned: dash-sdk, rs-sdk-trusted-context-provider, platform-wallet, platform-wallet-storage. grovestark untouched.
  • Cargo.lock: 27 platform crates → v3.1.0-dev.8; 13 transitive grovedb crates moved (upstream-pinned, pulled in by the v3.1-dev merge 269e5783). Nothing stray.
  • Zero source fallout — no DET files needed editing. Picks up two fixes that sit under our seam:
    • 1053caa2 platform-wallet-storage single-read KvStore::get (TOCTOU, CMT-001) — public get/put/delete/list_keys signatures unchanged; our DetKv envelope intact, all 9 adapter tests pass.
    • 73eb0ae0 platform-wallet SPV client deadlock-on-stop — behavioral, no API change; WalletBackend::shutdown/stop_spv compile untouched.
  • Build + clippy (-D warnings) + nightly fmt: all green.
  • Caveat: verified API-clean, but the runtime behavior of the TOCTOU/deadlock fixes is upstream's to validate (needs the #[ignore] backend-e2e suite on a funded testnet wallet).

419ef952docs(gap-audit): refresh gaps.md to as-of-450214e5 state

  • 5 PROJ gaps flipped RESOLVED (re-verified against source, not commit messages): PROJ-001 (SPV start wired), PROJ-003 (update_payment_status persists), PROJ-004 (HD-seed contact xpub + SEC-001 per-network coin-type), PROJ-006 (per-network activation heights), PROJ-014 (offline start-path tests) — plus 4 appendix/seed items.
  • 3 new findings: PROJ-022 (LOW — reserved UpstreamPlatformAddresses stub, deferred to upstream nonce reader), PROJ-023 (LOW — pre-existing error-string parsing in add_contact_screen.rs, tracked under refactor: migrate from Result<T, String> to typed errors with TaskError #660), PROJ-024 (BLOCKED-BY-DESIGN — check_address_usage).
  • Merge-blocker verdict: no CRITICAL open. Sole remaining blocker is PROJ-005 (HIGH) — platform pin is an unreleased dev rev (this bump keeps us on ffdc28b8, so G1 release gate stands until #3625 merges and a tag cuts).

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 4 commits June 17, 2026 19:35
…-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>
lklimek and others added 5 commits June 18, 2026 10:56
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>
@lklimek

lklimek commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

Session update — 5 commits pushed (d2f4fd87..1c4f4dcd)

Three fixes landed against the platform-wallet backend, each root-caused from a live testnet symptom:

Reconnect crash (09540cf8)

  • Connect → disconnect → connect failed with WalletStorage(AlreadyOpen { … platform-wallet.sqlite }).
  • Cause: WalletBackend::shutdown() never stopped the SPV run loop, so the persister path stayed registered in the process-global open-set.
  • Fix: shutdown() now calls spv().stop().await before tearing down the wallet manager.

Watch-only asset-lock failure (98bc4913, tests b8517905 + acdd1336)

  • Both "create identity" and "send funds core→identity" failed with AssetLockTransaction("Watch-only wallet has no private key").
  • Cause: wallets reload seedless (watch-only); identity funding-account provisioning called add_account(type, None), which has no key material on a cold-booted watch-only wallet.
  • Fix: provisioning now derives the account xpub from the seed (derive_extended_public_key) inside the with_secret_session scope and calls add_account(type, Some(xpub)). Regression guard uses a two-boot cold-start scenario (write persister from seed → copy data dir → cold-load seedless → assert provisioning succeeds + is idempotent).

Legacy MN-list-diff inspector + Core P2P handler removed (1c4f4dcd, −5,559 LOC)

  • The Masternode-List-Diff inspector was the only RPC-only tool and is dead in SPV-only mode. Removed the screen (~4.5k LOC), core_p2p_handler.rs, MnListTask, TaskError::P2P, all wiring, and the backend-e2e mnlist tests.
  • Verified independently: git grep clean of all removed symbols; surviving chainlock paths use only CoreTask::GetBestChainLock (no dependency on the removed task/handler).

Verification: cargo build (default + --features headless) ✅ · clippy --all-features --all-targets -D warnings ✅ zero · test --all-features --workspace998 passed, 0 failed · +nightly fmt --check ✅ clean.

🤖 Generated with Claude Code

lklimek and others added 3 commits June 18, 2026 12:52
…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>
@lklimek

lklimek commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

Pushed 1c4f4dcd..976ad0d4 (3 commits): backend-e2e coverage (B reconnect + C/D cold-boot), event-driven ensure_spv_synced (②), and the interim persister-release barrier for the reconnect AlreadyOpen regression (B-2).

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 (StructuredShutdown) covering both DET-owned wallet subtasks and the upstream coordinator threads, with the architecturally clean variant being keep-the-WalletBackend-alive + SPV/coordinator restart-in-place — is designed and will retire the barrier. An upstream rs-platform-wallet issue (make coordinator quiesce() join, not cancel-and-drain) will be filed. See the "Additional fixes folded in (2026-06-18)" section in the description.

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 5 commits June 18, 2026 15:03
… 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>
lklimek and others added 7 commits June 22, 2026 16:16
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants