Skip to content

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

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

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

Conversation

@lklimek
Copy link
Copy Markdown
Contributor

@lklimek lklimek commented May 18, 2026

Summary

From-scratch wallet backend rewrite on the upstream platform-wallet crate, which owns SPV chain sync internally. DET is now a thin adapter: src/spv/ is deleted, the RPC wallet mode is removed, reconcile_spv_wallets is gone, and the BackendTask action/channel contract is preserved so the UI layer is largely unchanged. A one-time, two-stage, marker-gated data migration moves existing wallets onto the upstream model with a *.db.premigration backup. This branch supersedes — and removes — the prior incremental-migration spec; the spec now reflects the implemented design.

NEW since last push — a90603a6 finish-unwire QA cycle + retry + re-pass (workflow-feature, Phase 3 → Phase 2 retry → Phase 3 re-pass)

Phase 3 QA quad (Marvin / Smythe / Trillian / Adams) found 2 HIGH + 6 MEDIUM + ~13 LOW. Auto-retry per workflow-feature severity gate. Three parallel Phase 2 retry waves landed 14 commits; a tight Phase 3 re-pass (Marvin + Smythe focused on the deltas) found 2 additional LOWs (QA-R-001 + SEC-009), both closed by the final commit (a90603a6). 15 commits total in this wave.

HIGH findings — both addressed.

  • SEC-001 (cc887fec) — migration sentinel was global (det:migration:finish_unwire:v1) but migration bodies filter WHERE network = ?1. Mainnet upgrade → sentinel set → testnet switch → testnet wallets invisible forever. Fix: per-network sentinel (det:migration:finish_unwire:<network>:v1) + BTreeSet<Network> dispatch guard + banner reconciler reset on network switch. Two regression tests including the mainnet→testnet sequence.
  • SEC-002 (48cdb8ad6052dc724d235a5e) — SecretStore::file(path, SecretString::new("")) opened the single-key vault with an empty passphrase, deterministic Argon2id KDF, file-read = key compromise despite .pwsvault extension implying real encryption. User-chosen UX = Option C: per-key passphrase at import time (mirrors the HD per-wallet password model). New SingleKeyEntry { ciphertext, salt, nonce, has_passphrase, passphrase_hint }, leading version byte (folds in SEC-005), import_with_passphrase + unlock_with_passphrase API, typed TaskError::SingleKeyPassphrase{Required,Incorrect,TooShort,Mismatch} (no String message fields). Import dialog extended with optional checkbox + passphrase + confirm + hint. Backward compat: legacy 32-byte entries decode as unprotected without panic. Sign-time prompt modal explicitly deferred — it would touch every signing path (register_identity, top_up_identity, send_funds, asset_lock_signer, core_zmq_listener); TODO marker at wallet_backend/mod.rs::single_key(), follow-up tracked separately.

MEDIUM findings — all addressed.

  • PROJ-001 + PROJ-002 (24ea553b) — TaskError::WalletStorageNotReady and legacy_shielded_present_but_sidecar_empty() were both dead surface; dev plan promised enforcement that never landed. Fix: is_wallet_touching() helper + short-circuit gate in run_backend_task covering Wallet / Core / Identity / DashPay / Shielded task families; shielded family additionally consults the NFR-4 pre-flight predicate. Matrix unit test pins the families.
  • QA-002 / DOC-002 (23be4330) — dead MigrationStep::label() (only called from an internal unit test) diverged from the live migration_running_text() in app.rs on 4 of 6 steps. Fix: delete dead label fn; canonical source is app.rs::migration_running_text(). Internal test redirected.
  • PROJ-003 (ce676fa7) — 8 sites of msg.contains("no such table") violated the "never parse error strings" rule. Fix: typed legacy_table_exists_named pre-check before every legacy SELECT; rusqlite error arms now unreachable for missing-table case.
  • PROJ-004 (23be4330) — MigrationState::Failed { reason: String } violated "no String fields for user-facing messages" and broke Display/Debug separation. Fix: Failed { error: Arc<MigrationError> }; UI calls Display::fmt at render time. TaskError::MigrationFailed mirrors the Arc so publisher hands the same refcount to both Err and Failed state. Manual PartialEq via Arc::ptr_eq.
  • DOC-001 (b182f016) — no CHANGELOG.md for downstream consumers. Fix: bootstrap Keep-a-Changelog [Unreleased] with the finish-unwire migration entry.
  • Marvin coverage (19 missing TCs) (0afcf539 + /tmp/marvin-finish-unwire-exceptions.md) — 5 trivial TCs implemented (TC-DEV-001/002/003 + TC-SH-007 + TC-MIG-003); 14 deferred with categorized justification (backend-e2e network-gated, perf benches, a11y framework limits, feature-flag-only). New tests/legacy_table_surface.rs covers the dev-table no-write invariants.

LOWs — all addressed.

  • PROJ-005 (f13b9cc4) — UI dialog rendered the TaskError::InvalidWif literal verbatim instead of via the typed variant; sentence existed twice. Fix: render via TaskError::InvalidWif { source }.to_string().
  • PROJ-006 (f13b9cc4) — format!("Network: {}", network_label(...)) violated i18n placement (concat fragments, Polish noun-case breakage). Fix: format!("This is a {network} address.", network = ...) — complete sentence, named placeholder.
  • PROJ-008 (5ae36bc6) — T-PERF-01 had promoted wallet_backend::{hydration, single_key, wallet_meta, wallet_seed_store} modules from internal → pub so benches could reach helpers. Fix: new bench cargo feature; pub mod gated by #[cfg(any(test, feature = "bench"))]. Bench target carries required-features = ["bench"].
  • SEC-005 (48cdb8ad) — envelope label was versioned (envelope.v1) but the bincode payload had no leading version byte. Fix: leading version: u8; readers default-fill v=1 for legacy entries; round-trip test asserts both paths.
  • SEC-007 (48cdb8ad) — migration trusted legacy salt/nonce lengths without validation; corrupted DB rows could panic crypto primitives. Fix: length guards skip-and-log corrupted rows via typed TaskError::MigrationCorruptedEntry; no whole-migration abort.
  • SEC-008 (48cdb8ad) — non-64-byte non-password seed silently degraded to Closed wallet with no user feedback. Fix: typed TaskError::SeedLengthInvalid { wallet_label, got, expected }; surfaces a banner explaining which wallet was skipped and why.
  • QA-007 (13706030) — TC-MIG rustdoc citations swapped vs spec. Fix: rustdoc IDs corrected; one citation in finish_unwire.rs:1299 left for Bilby-A territory (now part of her commits via cc887fec).

Phase 3 re-pass LOWs (closed by a90603a6):

  • QA-R-001 (Marvin re-QA) — wallet_touching_matrix_is_stable test docstring claimed every wallet-touching family was pinned; only 3 of 5 were asserted. Fix: added IdentityTask + DashPayTask assertions so a future drop from the matches! arm is caught at test time.
  • SEC-009 (Smythe re-QA) — SingleKeyEntry and StoredSeedEnvelope derived Debug over a ciphertext / encrypted_seed field that, for unprotected entries, held raw private key bytes. Latent: no current call site exploited it, but a future tracing::debug!(entry = ?entry, ...) would have leaked the key. Fix: replaced the derive with manual impl Debug emitting "[redacted]" for the secret-bearing field; new unit tests per struct assert a known plaintext does not appear in the Debug output.

Trivial cleanup landed in the wave:

  • DOC-003 (7ad1a426) — "this PR" stale rustdoc reference on open_secret_store → stable ADR cross-link.
  • d30f9371 — paragraph break in SEC-001's rationale doc comment to satisfy doc_lazy_continuation clippy lint.

Test coverage delta: 548 → 563 passing lib tests (+15 net, +10 from SEC-002 per-key passphrase suite + 2 from SEC-001 + 1 from PROJ-001/002 + 2 from SEC-009 redaction). 80 kittests pass + 1 pre-existing failure on tc_sk_004_valid_wif_renders_address_preview (confirmed pre-existing — not regressed by this wave). New dedicated tests/legacy_table_surface.rs (3 passing) pins the dev-table no-write invariants.

Phase 3 spec correction (Adams nit PROJ-007): previous PR body section claimed single_key_wallet.rs shrank 330 → 77; actual is 330 → 83. Acknowledged.

Explicitly deferred to follow-up (not blocking):

  • SEC-002 sign-time unlock prompt modal — touches every signing path; storage + cache + API + typed errors are in place; follow-up task tracked.
  • PROJ-014 — ADR floor SHA in docs/ai-design/2026-05-29-finish-unwire/notes.md:92 is a deliberate post-merge edit.
  • DOC-004 — external user-docs update lives at dashpay/docs (separate repo); follow-up issue to be filed.
  • 14 missing TCs — Marvin's exceptions log at /tmp/marvin-finish-unwire-exceptions.md categorizes each (backend-e2e / perf benches / a11y / feature-flag-only).
  • T-DEV-02b — initialization.rs CREATE TABLE tombstones for now-deleted tables remain pending a separate ADR pass.

Quality gates on merged tip (a90603a6):

  • cargo clippy --all-features --all-targets -- -D warnings — green
  • cargo test --lib --all-features563 passed, 0 failed, 1 ignored
  • cargo +nightly fmt --all -- --check — clean
  • cargo check --no-default-features — green
  • cargo check --features bench — green

Phase 3 re-pass verdict: PASS (no MEDIUM+ findings remaining). Workflow-feature Phase 4 (Lessons Learned) is the next coordinator step.

NEW since last push — a4926f5c finish-unwire campaign (workflow-feature, P1–P4)

Closes out the data.db unwire by retiring the legacy DET wallet store entirely. WalletBackend now owns the full read/write path; data.db lingers for the deferred migration tool only. 8 commits, eight tasks, three parallel-agent waves at the tail.

Landed (in order):

  • 26ac4c43 T-W-00 — DET wallet-metadata sidecar (<network>:wallet_meta:<seed_hash> in det-app.sqlite) carrying alias / is_main / core_wallet_name / xpub_encoded. Upstream WalletMetadataEntry doesn't carry alias; a sidecar was needed once Stage-B mirror was dropped.
  • a6ba77b1 → superseded by 380b09b7 T-W-00.5-v2 — per-wallet password support: StoredSeedEnvelope { encrypted_seed, salt, nonce, password_hint, uses_password, xpub_encoded } at SecretStore label envelope.v1, scoped by WalletId. (v1 went plaintext-only; user reversed the decision after seeing the UX implications. v2 is what shipped.)
  • 06ce4c19 T-W-01 — HD wallet cutover. db.get_wallets() cut at context/mod.rs:262; cold start now goes through wallet_backend::hydration::hydrate_wallets_for_network(network) reading sidecar + envelope.
  • b67d45f4 T-W-01b — single-key wallet cutover. Cut db.get_single_key_wallets(); new SingleKeyView owns <network>:single_key_meta:<addr> sidecar + SecretStore label single_key_priv.<addr> (scoped to fixed SINGLE_KEY_NAMESPACE_ID, not per-WalletId). Per-key passwords rejected at import time — vault scope is the single gate; per-key UX deferred (see follow-up T-MIG-03).
  • 42b88a15 T-DEV-02 — dead-surface delete in src/database/. 13 pub fn deleted across wallet.rs / single_key_wallet.rs / utxo.rs (net -554 LOC) plus 2 stale tests. single_key_wallet.rs shrank 330 → 77 lines. Kept-with-tether log in commit message documents every survivor (UI rename/remove flows, migration helpers, WalletError enum with 21 active callsites).
  • 2e6a34fd T-PERF-01 — criterion 0.5.1 benches at benches/wallet_hydration.rs for cold-start hydration (HD and single-key, N=1/10/100). Smoke-tested via cargo bench -- --test; full measurement run deferred (not gating).
  • a4926f5c T-DOC-01 — kv documentation refresh. New canonical keyspace reference table in docs/ai-design/2026-05-29-finish-unwire/notes.md covering all 3 stores (det-app.sqlite, platform-wallet.sqlite, SecretStore) and 23 key patterns. Corrected 4 stale claims, including one risky misstatement: single_key_priv was documented as per-WalletId scope when it's actually a fixed namespace constant — a future migration-tool author following the old text would have shipped broken scoping.

Migration-tool input (recorded for the future companion tool):

  • HD wallets: wallet.encrypted_seed / salt / nonce / master_ecdsa_bip44_account_0_epk / alias / is_main / uses_password rows → SecretStore envelope.v1 + <network>:wallet_meta:<seed_hash> sidecar.
  • Single-key wallets: single_key_wallet rows → SecretStore single_key_priv.<addr> (fixed SINGLE_KEY_NAMESPACE_ID) + <network>:single_key_meta:<addr> sidecar.
  • Migration sentinel: det:migration:finish_unwire:v1 in det-app.sqlite (MigrationTask::FinishUnwire).

Accepted trade-off (disclosed):

  • T-PERF-01 widened pub surface to make benches reachable: wallet_backend::{hydration, single_key} modules promoted from internal → pub; WalletSeedView::new, WalletMetaView::new, open_secret_store, SingleKeyView::rehydrate_index exposed; new pub constructors SingleKeyView::from_views and hydration::hydrate_hd_wallets_from_views. All first-party types — M-PLATFORM-WALLET-FIRST-PARTY untouched — but external bench code is now coupled to internal hydration mechanics. A follow-up could tighten this behind a bench cargo feature.

Deferred to follow-up PRs (non-blocking):

  • T-MIG-03 vault passphrase setup UX.
  • T-DEV-02b initialization.rs CREATE TABLE tombstones for deleted tables (proof_log, top_up, contested_name, contestant, contract, identity, dashpay_*) — separate ADR pass before any of them can go, per Nagatha §5.

Quality gates on merged tip (a4926f5c):

  • cargo clippy --all-features --all-targets -- -D warnings — green
  • cargo test --lib --all-features548 passed, 0 failed, 1 ignored
  • cargo +nightly fmt --all -- --check — clean (each agent ran it pre-commit)

Phase 3 QA (Marvin / Smythe / Trillian / Adams) is the next wave.

NEW since last push — a5538dc8 identity top-up fix (contained exception)

IdentityRegistration / IdentityTopUp HD funding accounts were never derived at registration, and the upstream rs-platform-wallet persister load() does not reconstruct them — so every top-up (and every relaunch) failed with "Identity top-up account for index N not found". Root cause: upstream rs-platform-wallet has no host-facing identity-funding-account registrar (no sibling to register_contact_account). The host is expected to provision the account itself before the asset-lock funding call — confirmed by dashpay/platform PR #3549, which ships an inline add_identity_topup_account precondition helper in its own test framework rather than adding a production registrar.

Fix shape (contained exception, faithful port of #3549's helper):

  • ONE private WalletBackend method does the keys-bearing dual insert (Wallet::add_account(AccountType::Identity*, …) + ManagedCoreKeysAccount::from_account(…)info.core_wallet.accounts.insert_keys_bearing_account(…)), idempotent via direct membership probes (no error-string parsing). peek_next_funding_address reads both collections, so a single insert is insufficient.
  • Public surface uses only DET types (a u32-only IdentityFundingAccount enum) — zero key_wallet::* past the WalletBackend seam (M-DONT-LEAK-TYPES).
  • Provisioning happens at the create_asset_lock_proof chokepoint so every asset-lock caller (IdentityTask, CoreTask, future) is covered with one idempotent pre-step — TDD caught a third caller (CoreTask::CreateTopUpAssetLock) the per-call-site plan missed; the chokepoint refactor is the minimal correct solution.
  • Reload re-provision in register_persisted_wallets for every persisted identity index — the recurrence trap #3549 never tests (fresh wallet per test there); without it, top-up would fail again on every relaunch.
  • In-code containment marker + tracked upstream-contribution TODO 9cdcfb25 to add register_identity_funding_account to rs-platform-wallet and remove the exception.
  • I1–I6 (fund-safety) untouched: this only ADDS a missing funding-account derivation pre-step; spend/coin-selection/broadcast paths are byte-identical.

Proven (live testnet, RUST_MIN_STACK=16777216):

  • core_tasks::test_tc005_create_top_up_asset_lockFAIL → PASS (asset lock broadcast, real TXID).
  • identity_tasks::tc_021_identity_funding_account_survives_reload (new) — PASS (restart-survival via SeedReregistrationLoader reload chokepoint).
  • "Identity top-up account for index ... not found" occurrences post-fix: 0.
  • Honest caveat: tc_020/tc_014 were environment-blocked during the verification window (live testnet/Platform-DAPI outage — NoAvailableAddresses, funds not confirming) — NOT the fix; bug symptom count = 0; same create_asset_lock_proof chokepoint proven by TC-005/TC-021. Re-confirm pending on a healthy testnet to close them out.

Compile-fix 99f0e92d (default-features build): bare cargo check (no flags) was broken at 3 sites using AppContext::sdk()/wallets() in method form — those methods only exist under cfg(any(test, feature = "testing")), while the underlying pub(crate) sdk: ArcSwap<Sdk> / pub(crate) wallets: RwLock<…> fields are always in-crate accessible. Switched to field access (self.sdk.load().as_ref().clone() / ctx.wallets.read()?) and added an explicit collect::<Vec<u32>>() annotation. 2 sites from a5538dc8's reload re-provision + 1 pre-existing from baba200b (P4a) that --all-features had been masking. Pure compile-fix, zero logic change, I1–I6 untouched. Verified cargo check (defaults), --all-features, --no-default-features all EXIT=0.

Eager WalletBackend init c1ea1c25 (b0972b77): kills the dash_sdk::sync 10ms retry-loop spam against live DAPI in the pre-unlock window. AppContext::ensure_wallet_backend(sender) + WalletBackend::start() hoisted from lazy-on-first-backend-task to eager-at-AppState::new (and at finalize_network_switch for runtime network switches). Case B confirmed via dashpay/platform PR #3549: PlatformWalletManager::new is wallet-independent at construction; SeedReregistrationLoader skips locked wallets pre-unlock so the manager runs SPV with zero registered wallets, and wallets register lazily on unlock. Defense in depth: tightened the SpvProvider::get_quorum_public_key gate-error map from ContextProviderError::Generic(<user-facing Display>) to ContextProviderError::Config("chain backend not initialized (pre-unlock)") so the SDK retry classifier no longer broadcasts the user-facing "temporarily unavailable" copy into logs. Zero key_wallet::* / SpvRuntime / PlatformWalletManager references introduced in DET — M-DONT-LEAK-TYPES preserved. I1–I6 untouched. All 6 quality gates green (cargo check defaults / --all-features / --no-default-features, build, clippy -D warnings, +nightly fmt --check); 446 lib tests pass. Live-environment empirical confirmation (30s pre-unlock log capture, expecting no request failed, retrying lines) is pending user dev-env verification — the sandbox lacks DAPI config + live network access for that window.

Pre-push gate note (tracked TODO): the pre-push gate (and CI) currently runs only --all-features — that is why this slipped through. Going forward it should also include bare cargo check and cargo check --no-default-features; cfg(any(test, feature = "testing"))-gated accessors are a project pattern, and an --all-features-only check will keep missing this class of regression.

CRITICAL fix landed previously — SPV now actually syncs (71c8baa0)

WalletBackend::start() never spawned the upstream SpvRuntime run loop, so the wallet never synced in any context — GUI and MCP included (peers stayed at 0 forever; balances never resolved). 71c8baa0 spawns the run loop in start(). Live-validated against testnet: SPV connects and reaches full sync (~60s, "SPV fully synced — mempool bloom filter active"), framework wallet spendable balance resolves, and real end-to-end flows (asset-lock broadcast, identity registration, token queries, DPNS) pass.

Test harness

  • backend-e2e is now wired to the real WalletBackend (no mock seam) so the suite exercises production sync/spend paths.
  • Transient wallet-registration retry in the funded-wallet fixture: wait_for_wallet_in_spv matches the typed TaskError::WalletBackend (the upstream runtime's documented "retry in a moment" signal — matched by variant, never by string) and retries within its existing timeout budget. Test-infra only — zero src/ product change.

E2E health (post-last-baseline-run; the new a5538dc8 fix has not yet been re-tallied against the full 59-test suite)

Last full baseline run (pre-a5538dc8-fix, post-6e85c462 retry-fix): 48 / 59 pass. With a5538dc8 landed, TC-005, TC-020 and TC-014 are expected to move from FAIL → PASS once testnet conditions allow a clean re-tally (TC-005 proven directly; TC-020/TC-014 share the same chokepoint, environment-blocked this session). Standing non-passes: 3 tracked DashPay/typed-error/address-reuse product bugs + 1 environment + 1 flake-candidate.

backend-e2e REQUIRES RUST_MIN_STACK=16777216 (documented in tests/backend-e2e/main.rs) — the Dash Platform SDK proof-verification path is deep and overflows the default test-thread stack without it.

What shipped (P0 -> P5)

  • P0.5 — compile floor: bumped dash-sdk to the dashpay/platform #3625 head; deleted/stubbed the old wallet stack.
  • P1: WalletBackend skeleton + EventBridge + PersistedWalletLoader seam.
  • P2: BackendTask rewired onto WalletBackend (action/channel contract preserved); ListCoreWallets/RecoverAssetLocks hard-removed (Decision feat: choose mn to vote with #8).
  • P3: one-time two-stage marker-gated migration — Stage-A SQL v35 + markers + *.db.premigration backup; Stage-B simplified platform-wallet migration engine with invariants and legacy-table DROP; mandatory one-time post-migration user notice.
  • P4a: WalletSnapshot read model + full wallets/screen data-path rewire onto the snapshot.
  • P4a.5: fund-safety spend-path completion — upstream-only asset-lock funding, FundWithUtxo removed (disclosed), ZMQ finality slimmed.
  • P4b: mechanical prune of the dead legacy spend / asset-lock-build cluster, dead HD balance/UTXO/tx fields + writers, v36 idempotent dead-settings-column migration; utxos table retained for the single-key carve-out (Decision feat: hide document button #7).
  • P5: mandatory single-key carve-out regression lane + DashPay derivation upstream-only.

Accepted trade-offs (disclosed)

  • Dropped DIP-14 back-compat: legacy DashPay contact addresses on non-mainnet / non-account-0 are not reproduced. Surfaced via a one-time in-app notice; the *.db.premigration backup preserves pre-migration state.
  • FundWithUtxo removed: disclosed in the same one-time notice (the §2(d)+FundWithUtxo notice text is spec-verbatim).
  • Single-key wallets mocked: Decision feat: hide document button #7 — read-only, underlying data retained (utxos table kept), swap-in deferred.

Quality gates

  • Release-blocking Smythe fund-safety audit (I1–I6): PASS. The a5538dc8 fix touches funding-account derivation only; spend/coin-selection/broadcast unchanged.
  • Marvin QA: PASS. Full QA matrix green.
  • The §2(d) + FundWithUtxo migration notice is spec-verbatim.

Standing release gates / follow-ups

  • G1 — BLOCKS MERGE of this PR: depends on dashpay/platform PR #3625 (the persister), currently an unmerged draft. This PR must NOT be merged until #3625 merges and DET's platform pin is bumped to a released revision containing it.
  • Backlog (tracked, non-blocking for review):
    • 9cdcfb25 — upstream-contribution TODO: add register_identity_funding_account to rs-platform-wallet (sibling to register_contact_account) so DET's contained a5538dc8 exception can be removed.
    • bfbc240f — DashPay incoming contact-request not associated with the sending identity after broadcast (TC-037/TC-043, likely one root cause).
    • 25f30d05 — TC-019 inverted error precedence: RefreshSingleKeyWalletInfo for an unknown seed returns WalletBackendNotYetWired instead of WalletNotFound.
    • e8212930 — TC-012 receive-address reuse: two consecutive next_receive_address calls return the same address (index not advanced/persisted).
    • 6e85c462 — test-infra: enforce RUST_MIN_STACK=16777216 (re-exec from the harness or document/CI-pin it) so contributors can't omit the documented prerequisite.
    • 7deebf4f — TC-066 key-not-visible-after-broadcast (flake-vs-bug, pending one confirmatory re-run).
    • QA-004 residue — inert core_backend_mode plumbing/column and the cosmetic Core-wallet picker in send_screen remain; clearing them needs a C3-style schema migration + spend-path-aware review, out of safe scope for the gate-fix pass.

Test Plan

  • RUST_MIN_STACK=16777216 cargo test --test backend-e2e --all-features -- --ignored --test-threads=1 — full serial backend-e2e against testnet (48/59 last-baseline; TC-005/020/014 expected → PASS post-a5538dc8 once testnet/DAPI healthy).
  • cargo test --all-features --workspace — lib tests + suites green.
  • cargo clippy --all-features --all-targets -- -D warnings — green.
  • cargo +nightly fmt --all -- --check — green.
  • Regression lanes: migration crash/restore, single-key carve-out, crash-retry no-double-broadcast, Path-3 asset-lock finality without Wallet mutation, identity funding-account restart-survival (TC-021).
  • Spec docs: docs/ai-design/2026-05-18-platform-wallet-migration/.

🤖 Co-authored by Claudius the Magnificent AI Agent

Investigation and phased plan for migrating dash-evo-tool key
storage and identity management onto the upstream platform-wallet
crate. Planning and verification only — no implementation in this
change. Blocked on dashpay/platform PR #3625.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 18, 2026

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: 6c2d473f-c5e3-4476-b92e-994bb994d33a

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 and usage tips.

The project pivoted from an incremental newtype-based migration to a
from-scratch backend rewrite where platform-wallet owns chain sync,
HD wallet, identity, DashPay, asset locks, and persistence entirely.

Superseded files removed: architecture.md, migration-plan.md,
spv-rpc-correctness.md, verification.md.

New spec files added:
- upstream-reality.md — verified upstream facts, src/spv/ deletion
  answer, G2 caveat (Wallet::from_persisted gap)
- backend-architecture.md — src/wallet_backend/ module, EventBridge
  replacing reconcile_spv_wallets, error model
- backendtask-contract.md — full BackendTask kept/modified/removed table
- data-model-and-migration.md — one-time migration procedure with
  backup/fail-safe, dead fields
- removal-inventory.md — DELETE/RETAIN lists, RPC mode fate, thin
  Core-RPC mining utility
- single-key-mock.md — SingleKeyBackend trait boundary and stub design
- phasing.md — P0–P5 phase table, QA matrix, highest-risk verdict
- open-questions.md — 8 decisions needed from user (rewritten from 5)

feature-coverage.md updated with supporting-analysis header and
corrected cross-links to new files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@lklimek lklimek changed the title docs: platform-wallet migration design docs: platform-wallet backend — clean-slate rewrite spec May 18, 2026
lklimek and others added 26 commits May 18, 2026 16:44
Folds the 8 maintainer decisions into the clean-slate spec: pin to
dashpay/platform #3625 head now (implementation no longer upstream-
blocked); G2 mocked via a PersistedWalletLoader seam (downgraded to a
deferred swap); DIP-14/15 = migrate-or-hard-stop+escalate (soft dual-
derivation fallback withdrawn); ZMQ audit-then-drop; Devnet discovery
stays DET-permanent; hybrid DashPay split; single-key mock-now-swap-
later; obsolete tasks hard-removed in the same release. Adds
g2-mock-boundary.md and dip14-migration-hardstop.md. Spec only —
implementation gated on user approval; the sole release-blocker is the
decision-#6 DIP-14/15 migration QA lane.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…-14/15 scope

- phasing.md: insert P0.5 "Compile Floor" phase between P0 and P1 with
  full 7-cluster delete/stub/fixup task list; re-sequence phase chain
  (P0→P0.5→P1→P2→P3→P4 cleanup→P5); add co-land constraint, harness-shape
  note, data-recoverability confirmation; update crew assignments and QA matrix
- removal-inventory.md: add "Retained code requiring SDK-rev signature
  updates at P0.5" subsection (Clusters E/F/G are RETAIN-with-fixup, not
  deletion targets)
- backendtask-contract.md: add transitional state section describing
  WalletBackendNotYetWired and SingleKeyWalletsUnsupported error variants
  introduced at P0.5; document P0.5→P2 inert-dispatch window
- dip14-migration-hardstop.md §6.2: replace CKDpriv256 divergence hypothesis
  with confirmed bounded structural finding from P0 probe
- open-questions.md #6: record confirmed P0 DIP-14/15 structural finding as fact
- single-key-mock.md: note stub boundary exists from P0.5 onward
- README.md: update STATUS callout (P0 done, P0.5 next); update ToC entry

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…e/stub old wallet stack

Atomically bumps dash-sdk + rs-sdk-trusted-context-provider from rev
54048b9352… to 738091f734…, adds platform-wallet (feature serde) +
platform-wallet-storage git deps at the same rev, and deletes/stubs/
fixes exactly enough of the old SPV/RPC wallet stack to reach a green
workspace build and clippy --all-features --all-targets -D warnings.

Right, this was a proper bin-fire — 42 SDK-drift errors that cascaded
to ~142 once the old SPV module came out. Cluster work per
docs/ai-design/2026-05-18-platform-wallet-migration/phasing.md §P0.5:

- A DELETE: src/spv/** removed; SpvStatus/SpvStatusSnapshot relocated to
  src/model/spv_status.rs as DET-owned display value types (no chain
  sync). CoreBackendMode deleted; system is SPV-only.
- B DELETE: reconcile_spv_wallets + the 4 SPV listener/sync fns +
  spv_manager() accessor + field wiring; start_spv/stop_spv/
  clear_spv_data are inert stubs (P2 wires PlatformWalletManager).
- C DELETE/STUB: SPV/RPC payment+derivation internals, CoreBackendMode
  branch sites, core_client_for_wallet/list_core_wallets removed;
  retained wallet/identity/DashPay dispatch arms return new typed
  TaskError::WalletBackendNotYetWired; single-key arms return
  TaskError::SingleKeyWalletsUnsupported; MineBlocks kept on thin
  Core-RPC client (Regtest/Devnet).
- D DELETE/RETAIN-MINIMAL: dead UTXO RPC reload body removed
  (reload_utxos → SPV no-op); seed-bearing wallet skeleton +
  select/remove UTXO surface RETAINED (P3 migration + DashPay/identity
  retained code depend on it). utxos.rs kept (over-deletion avoided).
- E/F/G SDK-DRIFT-FIXUP (RETAINED, no stubs): dpp Signer<K> is now
  #[async_trait] — qualified_identity + Wallet Signer impls made async;
  try_from_identity_with_signer / sign_external_with_options / shielded
  build_shield_transition are async (.await added; bridged via
  futures::executor::block_on in sync shielded ctx). AddressProvider
  reworked for new assoc-type API (Tag/Address: AddressToBytes,
  iterator returns, AddressKey→PlatformAddress, highest_found_index
  removed).

Two new typed fieldless TaskError variants (WalletBackendNotYetWired,
SingleKeyWalletsUnsupported) with calm/actionable #[error] messages per
CLAUDE.md error rules — no String fields.

Tests need not pass at P0.5: network-dependent backend-e2e #[ignore]
helpers patched to compile with // TODO(P0.5) markers (re-wired in P2).
No DB schema change (P3 owns that). RPC-mode toggle / Core-wallet
picker UI removed per removal-inventory; remaining dead RPC provider
kept behind #[allow(dead_code)] + TODO (removed in P4).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…etLoader

Builds the new leak-free wallet seam on the P0.5 green floor.
Non-destructive: adds src/wallet_backend/* + typed errors alongside
the P0.5 stubs; NOT yet wired into AppContext and BackendTask dispatch
still runs the P0.5 stubs (parallel/skeleton per phasing.md P1 row).
P2 points the task arms at WalletBackend.

- src/wallet_backend/mod.rs — WalletBackend wraps
  PlatformWalletManager<SqlitePersister>. Single boundary: no upstream
  type (PlatformWalletManager/PlatformWallet/WalletId/SqlitePersister/
  WalletManager) escapes; public API is DET-shaped (new/start/shutdown/
  wallet_count/is_syncing). Clone via Arc<Inner>, Send+Sync. Consumes
  upstream platform-wallet-storage SqlitePersister (no DET-authored
  persister). start() orchestrates SpvRuntime::start(ClientConfig) +
  platform-address/identity sync coordinators (see upstream-reality
  note below).
- src/wallet_backend/event_bridge.rs — EventBridge implements
  platform_wallet PlatformEventHandler + dash_spv EventHandler. Sync,
  non-blocking callbacks map upstream sync/network/wallet/error events
  to ConnectionStatus atomics (set_spv_status/_connected_peers/
  _last_error — relocated src/model/spv_status.rs types) AND a
  non-blocking TaskResult::Refresh on the existing MPSC, exactly the
  P0.5 EventBridge recommendation.
- src/wallet_backend/loader.rs — PersistedWalletLoader object-safe
  trait + SeedReregistrationLoader (reads DET retained encrypted-seed
  store, yields one DET-opaque WalletRegistration per open wallet;
  seed zeroized-on-drop, never persisted — secret boundary intact).
  UpstreamFromPersisted slot reserved (no upstream API yet), mirroring
  the single-key reserved-impl pattern. Unit tests: seed-redaction,
  network mapping, G2.4 zero-blast swap-boundary compile.
- backend_task/error.rs — 4 new typed variants (WalletBackend,
  WalletStorage, WalletSyncStartFailed, WalletSeedDecryptFailed,
  FileSystem) with #[source], no String fields, calm #[error] text.

Spec/upstream mismatches (non-blocking, adapted; feed to architect):
1. backend-architecture.md/g2-mock-boundary.md assume a unified
   PlatformWalletManager::start(). Upstream reality: no such method —
   WalletBackend::start() orchestrates spv().start(ClientConfig) +
   *_sync_arc().start() coordinators.
2. g2-mock-boundary G2.1 says call create_wallet_from_seed_bytes THEN
   load_persisted() THEN start(). Upstream reality:
   create_wallet_from_seed_bytes already calls load_persisted +
   initialize_from_persisted + identity discovery internally — loader
   plug-point is just create_wallet_from_seed_bytes per registration.
3. platform-wallet shielded feature is NOT enabled for DET (serde
   only), so on_shielded_sync_completed / shielded_sync_arc do not
   exist — left at upstream no-op default / omitted.

get_quorum_public_key stays the typed-error stub: WalletBackend is not
yet in AppContext (P1 parallel/skeleton), so there is nothing to
delegate to — comment points at P2 (AppContext wiring).

build + clippy --all-features --all-targets -D warnings + fmt --check
GREEN; 3 wallet_backend unit tests pass; P0.5 floor not regressed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replaces the P0.5 WalletBackendNotYetWired stubs with real WalletBackend
calls and wires the load-bearing get_quorum_public_key path. The
BackendTask action/channel/TaskResult contract and result variants are
preserved (UI unchanged); signatures change only where the wallet model
type changed, per backendtask-contract.md.

Construction wiring: AppContext gains a lazily-built
ArcSwapOption<WalletBackend>. WalletBackend::new is async and needs the
TaskResult sender, which lives on AppState not AppContext — so
AppContext::ensure_wallet_backend(sender) builds it idempotently on the
first wallet/core/identity/DashPay task (run_backend_task threads the
AppState-owned sender there). wallet_backend() returns it or
WalletBackendNotYetWired while unbuilt. AppContext::new is unchanged
(all 5 call sites untouched).

Stub → real:
- CoreTask::RefreshWalletInfo — Core state is upstream-continuous +
  EventBridge-pushed; arm ensures backend + optionally refreshes the
  retained DAPI platform-address balances. Returns near-instantly.
- WalletTask::GenerateReceiveAddress —
  WalletBackend::next_receive_address (upstream CoreWallet).
- CoreTask::SendWalletPayment — WalletBackend::send_payment (upstream
  send_to_addresses: build/sign + SpvRuntime broadcast). Result
  WalletPayment{txid,..} unchanged.
- CoreTask::CreateRegistration/TopUpAssetLock,
  identity register/top-up FundWithWallet,
  WalletTask::FundPlatformAddressFromWalletUtxos —
  WalletBackend::create_asset_lock_proof (upstream AssetLockManager
  builds/broadcasts/tracks-to-proof; returns proof+key+txid). The
  retained TopUpAddress SDK transition + DET credit bookkeeping stay.
- transaction_processing::broadcast_raw_transaction —
  WalletBackend::broadcast_transaction (upstream SpvBroadcaster).
- SpvProvider::get_quorum_public_key — bridged (block_in_place) to
  WalletBackend → SpvRuntime::get_quorum_public_key. Load-bearing
  proof-verification path is wired.

WalletBackend additions (all DET-shaped; no upstream type leaks):
seed_hash→WalletId map (upstream WalletId = SHA256(root_xpub‖chaincode)
≠ DET seed_hash = SHA256(seed) — bridged at registration), AssetLockKind
enum (hides upstream AssetLockFundingType), next_receive_address /
send_payment / create_asset_lock_proof / broadcast_transaction /
get_quorum_public_key.

Single-key arms stay permanently on SingleKeyWalletsUnsupported
(Decision #7). New typed TaskError variants from P1 reused;
DashPayContactDerivationIrreconcilable{contact: Identifier} added so
the DashPay arm shape supports P3 quarantine enforcement (no String
field; Base58 contact id is a copyable handle per CLAUDE.md rule 6).

EventBridge live-validation: deterministic mapping unit tests added
(synthetic SyncEvent/SyncProgress/NetworkEvent → ConnectionStatus +
TaskResult::Refresh assertions). Full live network path added as an
#[ignore] backend-e2e test (event_bridge_live.rs) — requires harness
WalletBackend wiring (test-infra, tracked for P3 QA lane).

Superseded DET helpers deleted (M-NO-TOMBSTONES, 0 callers): the
register_spv_address/wallet_network_key/classify_derivation_metadata
address-registration hooks (upstream now owns address derivation, so a
no-caller hook would be dead code — deviation-with-rationale from the
"un-allow and wire" instruction), plus the orphaned create_asset_lock.rs
and refresh_wallet_info.rs core submodules.

Accepts the 3 P1-noted spec/upstream mismatches (no unified start();
create_wallet_from_seed_bytes folds load_persisted; shielded feature
off) — built to upstream reality.

build + clippy --all-features --all-targets -D warnings + fmt --check
GREEN; EventBridge unit tests pass; P0.5/P1 floor not regressed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…sion #8)

Decision #8: named Core wallets and explicit asset-lock recovery are
RPC-only / obsolete under the upstream backend (AssetLockManager tracks
locks continuously). Hard-removed with no no-op grace — the tasks AND
their UI entry points go in the same change.

Backend:
- CoreTask::{RecoverAssetLocks, ListCoreWallets} enum variants + their
  PartialEq arms + dispatch arms removed.
- src/backend_task/core/recover_asset_locks.rs deleted.
- BackendTaskSuccessResult::{RecoveredAssetLocks, CoreWalletsList}
  result variants removed.

UI entry points:
- Core-wallet picker removed from add_new_wallet_screen and
  import_mnemonic_screen (fields, init, reset_core_wallets_cache,
  display_task_result/error overrides, ComboBox picker block, the
  wallet.core_wallet_name UI assignment; the core_wallet_name DB
  column/model field stays — P3 schema migration owns that).
- wallets_screen: "Search for Unused" asset-lock button + the
  SearchAssetLocks custom action + pending_asset_lock_search /
  asset_lock_search_banner state machine; the named-Core-wallet
  selection flow (core_wallet_dialog SelectionDialog,
  apply_core_wallet_selection, the CoreWalletNotConfigured
  display_task_error interception + pending_list_* state +
  RecoveredAssetLocks/CoreWalletsList result handlers).
- ui/mod.rs change_context calls to the removed
  reset_core_wallets_cache / reset_pending_list_state dropped.
- backend-e2e TC-006 (RecoverAssetLocks) removed, mirroring the
  existing TC-007/008/010 REMOVED markers.

Orphaned Core-wallet TaskError variants (CoreWalletNotConfigured/
InvalidCoreWalletName/NoCoreWalletsLoaded/WalletDatabasePersistError)
are now unconstructed but left for P4 cleanup — they are harmless dead
`pub enum` variants (no clippy/correctness impact) and CoreWalletNot-
Configured still has an RPC-error-mapping helper + tests; excising
them now would over-reach this change.

build + clippy --all-features --all-targets -D warnings + fmt --check
GREEN; 8 wallet_backend unit tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Apply Nagatha-ratified edits across the platform-wallet migration spec set:

- data-model-and-migration.md: replace implicit Step 3 prose with
  normative "Migration execution model — two stage, marker-gated
  (RATIFIED)" subsection. Defines Stage A (SQL v35, sync, pre-unlock,
  marker-only) and Stage B (async, post-unlock, ensure_wallet_backend
  seam, tokio::sync::Mutex-gated). Normative marker lifecycle:
  platform_wallet_migration_pending clears iff all wallets+identities+
  contacts classified; dashpay_dip14_quarantine_active independent.
  Quarantine is a successful terminal classification, not a failure.
  Restore-from-premigration is an exception path only.

- phasing.md: expand P3 phase-table entry; add P3a–P3e sub-steps
  (Stage-A SQL+markers, predicate TDD, Stage-B wiring, quarantine/
  restore invariants, backend-e2e harness). Each sub-step has its own
  commit target, test requirements, and release-blocking designation.

- backend-architecture.md: annotate ensure_wallet_backend
  (src/context/mod.rs:634) as the Stage-B post-unlock one-time-
  migration seam. Document AppContext-owned tokio::sync::Mutex,
  ArcSwapOption ordering guarantee. Add two marker settings fields to
  AppContext/settings inventory.

- dip14-migration-hardstop.md: add normative forward-references for
  index range (receive ∪ send union from get_contact_address_indices
  database/dashpay.rs:649, no sampled prefix) and quarantine-as-
  successful-terminal-classification clearing pending but setting
  quarantine flag.

- README.md: update STATUS callout — P0/P0.5/P1/P2 GREEN; P3 ratified
  two-stage design, in progress (P3a–P3e); run mid-execution on branch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…backup

Stage A of the RATIFIED two-stage marker-gated platform-wallet
migration (data-model-and-migration.md). Sync, pre-unlock, in-tx,
non-destructive. Stage B (post-unlock async engine) lands in P3c.

- DEFAULT_DB_VERSION 34 → 35.
- New settings columns `platform_wallet_migration_pending` and
  `dashpay_dip14_quarantine_active` (DEFAULT 0): added to the fresh
  CREATE TABLE settings, to ensure_settings_columns_exist (idempotent,
  column-guarded), and via the v35 tx itself.
- v35 apply_version_changes arm: arms platform_wallet_migration_pending
  inside the v35 transaction. NO destructive step, NO backup inside the
  live write-tx. Fresh installs start at v35 with the marker clear
  (nothing to migrate).
- C1/C2: retained `<db>.premigration` recovery floor created POST-commit
  in initialize() via the SQLite online-backup API (rusqlite `backup`
  feature added), gated by the marker, idempotent (skipped if present,
  never overwrites the floor), atomic (temp+rename), best-effort
  (logged, never blocks app start, (re)created on first post-marker
  launch even if the user never unlocks). The MARKER — not the backup
  file — is the authoritative pending signal.
- settings.rs: typed getters/setters for both markers.

Tests (5, all pass): v35 arms marker + non-destructive (legacy wallet
row preserved), v35 idempotent re-run is no-op, fresh v35 install has
no pending marker, premigration backup created post-migration & is a
consistent SQLite file with legacy data, premigration backup not
recreated once present (recovery-floor invariant).

build + clippy --all-features --all-targets -D warnings + fmt --check
GREEN.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…d-safety)

Pure, synchronous, no-DB/no-SDK predicate (`classify_contact` in
src/database/migration_pw.rs). Re-derives a contact's payment addresses
with BOTH the legacy DET engine (derive_dashpay_incoming_xpub /
derive_payment_address — historical ground truth) and the upstream
engine (derive_contact_xpub / derive_contact_payment_address), asserting
byte-equality over the contact's ENTIRE historical used-index range.

- Index range = UNION of receive [0, highest_receive_index] and send
  [0, next_send_index-1] (saturating) = [0, max(...)]. No sampled
  prefix anywhere (the silent fund-loss hazard). `historical_max_index`
  has a dedicated union-not-receive-only test.
- Contact-xpub equality compares the canonical public material
  (compressed pubkey + chain code); version/parent-fingerprint bytes
  are path-structural and excluded (they don't affect derived
  addresses / fund reachability).
- Quarantine is a SUCCESSFUL TERMINAL classification (never an error,
  never triggers restore); it is NEVER inferred from identifier class —
  always computed over the real index range.

Tests written FIRST (7, all pass). They empirically encode the
fund-safe truth and surfaced a MATERIAL finding:

  dip14-migration-hardstop.md §6.2's expectation that low-index
  contacts (first 28 bytes of identity id zero) are "expected
  migratable" is DISPROVEN. DET ckd_priv_256 has a `< 2^32`
  compatibility branch — for low-index it HMACs only index[28..32]
  (ser_32, 4 bytes); upstream key-wallet ckd_priv for
  ChildNumber::Normal256 ALWAYS HMACs the full 32-byte index (ser_256),
  no compatibility shortcut. Different HMAC input ⇒ different child key
  ⇒ low-index contact funds unreachable via upstream ⇒ MUST quarantine.
  Convergence holds ONLY for the full-256-bit class on mainnet/account-0
  (both engines ser_256; base path m/9'/5'/15'/0' identical).

  This is the §6.5 release-blocking scenario realised. The predicate
  correctly quarantines (does NOT silently assume equivalence); the
  quarantine machinery is the sole safety net until an upstream
  ser_32-compatibility fix or explicit acceptance. It does NOT block
  P3 — quarantine is the designed safety net; expected quarantine
  residue is WIDER than §6.2 predicted (testnet + non-account-0 + ALL
  low-index contacts). Reported to the architect via memory.

`#![allow(dead_code)]` (TODO(P3c)) until the Stage-B engine wires the
predicate in next sub-step.

build + clippy --all-features --all-targets -D warnings + fmt --check
GREEN; 7 predicate tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…d accepted trade-off

User decision 2026-05-18: drop backwards compatibility, support only what
platform-wallet supports. The migrate-or-quarantine / hard-stop apparatus
is WITHDRAWN and NOT implemented.

Changes applied:
- dip14-migration-hardstop.md: SUPERSEDED banner at top; body retained as
  historical record only.
- open-questions.md #6: re-resolved to upstream-only derivation; quarantine
  apparatus withdrawn.
- data-model-and-migration.md: Stage B simplified to single-branch model
  (no quarantine fork); dashpay_dip14_quarantine_active now INERT/RESERVED
  (P4 cleanup); new "Accepted fund-accessibility trade-off" subsection with
  mandatory one-time informational notice spec.
- backendtask-contract.md: DashPay row — quarantine error path removed;
  TaskError::DashPayContactDerivationIrreconcilable noted as P4 candidate.
- phasing.md: P3 re-scoped (drop back-compat, upstream-only); P3a/P3b
  marked DONE; P3c–P3e rewritten; P4/P5 updated; QA matrix updated.
- feature-coverage.md, removal-inventory.md, backend-architecture.md,
  README.md: live quarantine references replaced with pointer to
  data-model-and-migration.md "Accepted fund-accessibility trade-off".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the P3b classify_contact predicate (+ its 7 tests) with a
single-branch Stage-B engine. Back-compat for legacy DashPay DIP-14
derivation is dropped (Decision #6): contacts are re-established on
upstream derivation unconditionally — no DET re-derivation, no
comparison, no quarantine. The accepted fund-accessibility trade-off
is covered by the mandatory one-time notice landing in P3e.

Stage B (run_stage_b) is async, post-unlock, marker-gated, and runs
behind the AppContext-owned tokio::sync::Mutex (invariant C6, acquired
before the pending-marker check in ensure_wallet_backend). Each step is
idempotent for crash-and-relaunch:

  1. backup precondition — refuse to migrate without the .premigration
     recovery floor
  2. ensure_wallets_registered — re-register wallets from seed
     (tolerates upstream WalletAlreadyExists; chain identity sync
     covers step 3)
  3. identities — intentional no-op (DET blob + tables retained;
     repopulated by step-2 chain sync)
  4. re-establish every accepted DashPay contact via upstream
     register_contact_account on account 0, keyed (owner, contact)
  5. finalize — single success fork; legacy-table DROP gated off
     (LEGACY_DROP_ENABLED = false) until P3d invariants land

Supporting changes:
- wallet_backend: register_dashpay_contact, ensure_wallets_registered,
  flush_persister; register_persisted_wallets made idempotent
- database: load_all_accepted_dashpay_contacts,
  drop_legacy_migrated_tables, Database.path + db_file_path
- error: dedicated TaskError::WalletPersistenceFlushFailed (typed
  #[source], no String field, calm actionable message)
- context: stage_b_migration_lock field + C6-correct wiring

Build, clippy --all-features --all-targets -D warnings, and
+nightly fmt all green. P3a migration tests (5) pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add the launch-time corruption-recovery path and the P3d invariant
suite, then lift the gate on the destructive legacy-table DROP.

Database::recover_from_premigration_if_corrupt(db_file_path) runs on
file paths in app.rs BEFORE the live Database is opened. It restores
the live DB from the retained `<db>.premigration` floor ONLY when the
floor exists AND the live DB is missing/unopenable/fails PRAGMA
integrity_check — the exceptional path. A healthy-but-pending DB is
NEVER restored; it is left for the idempotent Stage-B retry. Restore
is atomic (copy→temp→rename) and re-runnable (the floor is never
consumed). Kept out of initialize() so it stays a pure launch concern.

New `p3d` test module (7 tests) pins the deterministic, network-free
invariants now that the DROP is live:
- backup_exists_before_any_destructive_step (backup-before-destroy)
- drop_removes_all_legacy_tables_and_retains_floor (DROP strictly last;
  retained surfaces survive; floor still holds pre-migration data)
- destructive_sequence_is_idempotent (crash-relaunch finalize tail)
- no_restore_when_live_db_healthy_but_pending (restore is exceptional)
- restore_on_injected_corruption (restore + marker still pending →
  Stage-B retries)
- no_restore_without_floor
- restore_is_rerunnable (floor survives repeated corruption cycles)

With the invariants green, the LEGACY_DROP_ENABLED gate is removed and
Stage-B step 5 now unconditionally runs: durable flush_persister →
drop_legacy_migrated_tables (strictly last) → clear marker. Crash
between any of these re-runs cleanly (idempotent registration/contacts,
DROP TABLE IF EXISTS, idempotent marker clear).

All 12 P3a+P3d tests pass. clippy --all-features --all-targets
-D warnings and +nightly fmt green.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…otice

Add the standalone migration QA harness and the release-blocking
post-migration notice — the SOLE compensating control for the accepted
DashPay fund-visibility trade-off (Testnet/Devnet or secondary-account
contact payments may not appear in this version).

Notice (release-blocking, unconditional, never downgraded):
- New pure module `migration_notice`: verbatim MIGRATION_NOTICE_TEXT
  (jargon-free, calming, gives a concrete recourse) + pure
  `should_show(migration_completed, notice_shown)` decision so the
  guarantee is unit-provable and cannot silently regress.
- New durable settings bits: `platform_wallet_migration_completed`
  (set by Stage-B finalise, written BEFORE clearing the pending marker
  so a crash between the two re-runs cleanly) and one-shot
  `platform_wallet_migration_notice_shown`. `completed` (not merely
  "not pending") is the discriminator so a fresh install — which also
  has pending == false — NEVER sees the notice.
- AppState::maybe_show_migration_notice: once-per-launch, shows a
  dismissible info MessageBanner exactly once to every migrated user,
  persists the one-shot guard immediately (fail-open: never fewer than
  once). The in-launch guard is intentionally not set while still
  uncompleted so a migration finishing later this session still fires.

QA harness (`tests/migration_pw/`, standalone integration crate, run
`cargo test --test migration_pw`): network-free lanes through the
public API — release-blocking notice contract (shows once, durable
one-shot flag, unconditional, fresh-install-excluded, verbatim &
jargon-free), launch-time restore-only-on-injected-corruption,
user-never-unlocks, no-restore-without-floor, restore re-runnable,
Stage-B cross-launch marker idempotency & reentrant single-source.

`tests/kittest/button_sizing.rs` deliberately left untracked
(out of scope, must not be touched).

13 harness tests + 14 lib tests (P3a/P3d/migration_notice) green.
clippy --all-features --all-targets -D warnings and +nightly fmt green.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ovider

P4 dead-code prune — the verified-safe, fully-traced removals:

- TaskError::DashPayContactDerivationIrreconcilable: zero callers (the
  quarantine apparatus was withdrawn, Decision #6). Removed.
- dashpay_dip14_quarantine_active settings column + its get/set
  accessors: never populated by any live path; the only reader was a
  P3a test assertion (also removed). Dropped from the migration-column
  helper and the fresh CREATE TABLE settings. (No DB downgrade is
  supported, so dropping the never-used column is safe; existing DBs
  simply keep an unused column until a future schema rebuild.)
- context_provider::Provider (the RPC SDK ContextProvider): orphaned
  since chain sync became SPV-only. Zero external references. Removed
  the struct + its ContextProvider/Clone/Debug impls and now-unused
  imports. The shared resolve_data_contract / resolve_token_configuration
  helpers (still used by SpvProvider) are retained unchanged.
- Refreshed a stale Stage-A doc comment that referenced
  "migrate-or-quarantine".

ZMQ-listener audit (Decision #3): CONCLUSION = RETAIN. The listener
feeds AppContext::received_transaction_finality (asset-lock proofs for
Platform identity top-up/registration) and chain-locked-block UI — a
non-wallet consumer exists, so it is not dropped.

Remaining P4 items deferred (see final report): the "dead UTXO /
WalletTransaction model+tables" and the RPC-mode-toggle / Core-wallet
-picker / local-node settings UI prune are NOT dead — WalletTransaction
and utxos are live fields of the Wallet model rendered by the wallets
screen. Safely separating the genuinely-dead subset from the live
wallet-display model is an unprovable irreversible step under the
current budget (STOP rule); broken out for a dedicated follow-up.

clippy --all-features --all-targets -D warnings + +nightly fmt green.
13 migration_pw + 65 database/migration_notice tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…); add WalletSnapshot read model

- backend-architecture.md: add WalletBackend read-accessor surface (DetWalletBalance,
  DetUtxo, WalletTransaction, address_balances), WalletSnapshot ArcSwap push model via
  EventBridge, TransactionRecord→WalletTransaction mapping placement, and prominently
  called-out A04 fund-safety mandate (display-only snapshot; spend path remains
  WalletBackend::send_payment/create_asset_lock_proof)
- backendtask-contract.md: note that the UI data-path rewire introduces no BackendTask
  variant changes — display fed by WalletSnapshot via existing EventBridge Refresh
- phasing.md: split P4 into P4a (UI data-path rewire, blocks P4b) and P4b (mechanical
  prune, only after P4a); add P4 sub-steps section with exit criteria and reviewer gates;
  add post-migration UI data-path test release-blocking lane to QA matrix; update
  sequencing note and crew assignments
- README.md: update STATUS block — P3c-P3e GREEN, P4-partial done, P4 split noted,
  display-only gap and WalletSnapshot model described

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds the display-only WalletSnapshot push model and rewires the
wallets-screen HD reads off the legacy Wallet balance/tx/utxo fields
onto the WalletBackend snapshot.

What landed:
- src/wallet_backend/snapshot.rs: DetWalletBalance, DetUtxo,
  WalletSnapshot view models + SnapshotStore. Transaction history is
  event-sourced (upstream drops chain-locked records from memory with
  keep-finalized-transactions OFF), accumulated in the store; balance
  is lock-free atomics (pw.balance()); UTXOs via non-blocking
  try_state(). Relocates the TransactionRecord -> WalletTransaction
  mapping (the surviving piece of the deleted reconcile_spv_wallets).
- WalletBackend: four DET-typed read accessors (wallet_balance,
  transaction_history, utxos, address_balances) + has_snapshot;
  registers each wallet's PlatformWallet handle in the store;
  idempotent WalletAlreadyExists path resolves the id by deterministic
  seed re-derivation (no error-string parsing).
- EventBridge: holds the SnapshotStore; on_wallet_event accumulates
  the event's TransactionRecords and recomputes/publishes the affected
  wallet's snapshot, then emits the existing TaskResult::Refresh.
- UI rewire (HD only; single-key untouched): wallets_screen
  balance/tx-table/account-summary/receive-address, address_table
  utxo-count/balance, account_summary.collect_account_summaries now
  takes the snapshot address_balances.

FUND-SAFETY (A04): the snapshot is DISPLAY-ONLY. Coin selection /
tx construction still go through WalletBackend::send_payment /
create_asset_lock_proof (upstream live UTXO set, P2). No code path
selects spendable inputs from WalletSnapshot.

Scope note: the QR funding-arrival detector
(capture_qr_funding_utxo_if_available) was intentionally NOT moved to
the snapshot. Its output flows into RegisterIdentity/TopUp
FundWithUtxo -> *_asset_lock_transaction_for_utxo, a spend path.
Snapshot-sourcing it would route coin selection through the snapshot
(A04 violation). That path is a pre-existing P2 gap (a display-cache
UTXO -> FundWithUtxo spend); fixing it (route via
create_asset_lock_proof) is P2-class backend work, recorded as a
blocking finding for P4b/P2 follow-up. Leaving this detector reading
the legacy wallet.utxos preserves exact prior behavior — P4a
introduces no new snapshot->spend coupling.

Pre-first-sync snapshot is empty -> UI renders the "syncing" state,
not a zero-balance bug.

Quality gates GREEN: cargo build --all-features, clippy
--all-features --all-targets -D warnings, +nightly fmt --check,
474 lib tests (incl. 5 new snapshot tests + updated event_bridge).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ock funding, FundWithUtxo removed w/ disclosure

- phasing.md: insert P4a.5 between P4a and P4b (3 paths: AssetLockKind::Shielded + shielded bundle rewire, FundWithUtxo removal, received_transaction_finality slim); gate P4b on P4a.5 exit; add P5 Smythe release-blocking audit with I1–I6 invariants and 3 test lanes; update crew assignments and QA matrix
- backend-architecture.md: document AssetLockKind::Shielded; state no upstream funding-outpoint API at #3625 head; document FundWithUtxo removal; document received_transaction_finality as asset-lock-finality-only with ZMQ retained
- backendtask-contract.md: split IdentityTask register/top-up row — FundWithUtxo variants removed, document accepted behavior change and disclosure; add Net Frontend Impact item 5
- data-model-and-migration.md: extend mandatory one-time post-migration notice with FundWithUtxo-removal sentence
- docs/user-stories.md: add IDN-014 [Removed — upstream-only funding] for FundWithUtxo / QR-direct-fund identity capability

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…lock, FundWithUtxo removed, ZMQ finality slimmed

Right, the fund-safety leg lands. Three paths, zero double-spend exposure:

Path 1 — Shielded asset-lock now goes through the one true engine.
Added AssetLockKind::Shielded → upstream AssetLockShieldedAddressTopUp.
Rewired shield_from_asset_lock from the legacy
generic_asset_lock_transaction + select_unspent_utxos_for-over-Wallet.utxos
to WalletBackend::create_asset_lock_proof. Upstream selects inputs from
its authoritative live UTXO set at construction, stores-before-broadcast,
and tracks to finality itself. DET does no coin selection here. (I1, I4)

Path 2 — FundWithUtxo gone, compile-enforced, no dead arm.
Deleted RegisterIdentityFundingMethod::FundWithUtxo and
TopUpIdentityFundingMethod::FundWithUtxo, their backend arms, the entire
QR-direct-fund UI in both identity screens, and
registration_asset_lock_transaction_for_utxo /
top_up_asset_lock_transaction_for_utxo /
asset_lock_transaction_for_utxo_from_private_key. Disclosed in the
shipped one-time notice — added the mandated FundWithUtxo sentence to
MIGRATION_NOTICE_TEXT and its verbatim pins (unit + migration_pw). (I3)

Path 3 — received_transaction_finality slimmed to asset-lock-only.
Deleted the Wallet.utxos / address_balances / legacy utxos-table write
loop and the DashPay-payment side-effect. Retained the asset-lock
detection/registration branch. The orphaned DET-side broadcast/finality
helpers (broadcast_and_commit_asset_lock, wait_for_asset_lock_proof,
broadcast_raw_transaction) — left with zero callers once Path 1+2 land —
are deleted, not orphaned: upstream owns the whole asset-lock lifecycle.
ZMQ call sites stay; the always-empty Vec return preserves them. (I2, I5)

Also removed the last live Wallet.utxos reader in the asset-lock UI
(capture_qr_funding_utxo_if_available) — create_asset_lock_screen already
detects arrival via the upstream-fed CoreItem event; funding_utxo was
write-only dead state.

Exit: zero live Wallet.{utxos,address_balances,transactions} readers in
the HD spend path; zero callers of the deleted *_for_utxo* fns; the
legacy select/build cluster is now fully dead (P4b deletes it).

Build + clippy -D warnings + nightly fmt + full workspace tests GREEN;
backend-e2e/e2e/kittest compile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d cluster (I2)

The whole DET-side parallel spend engine is now unreachable after P4a.5 —
identity register/top-up, shield, and asset-lock funding all route through
the upstream WalletBackend. This deletes the closed, zero-live-caller
cluster outright (not orphaned — I2):

- src/model/wallet/asset_lock_transaction.rs deleted entirely:
  select_utxos_with_fee_retry, generic_asset_lock_transaction,
  registration_asset_lock_transaction, top_up_asset_lock_transaction,
  asset_lock_transaction_from_private_key, plus the now-private-only
  calculate_asset_lock_fee helper they exclusively used (zero external
  consumers — confirmed workspace-wide).
- Wallet::{build_standard_payment_transaction,
  build_multi_recipient_payment_transaction, set_transactions,
  select_unspent_utxos_for, remove_selected_utxos, reload_utxos} deleted.
- Their dead unit tests + test-only helpers (test_wallet_with_utxo,
  register_test_address) deleted with them; surviving helpers kept.
- Stale imports / doc references cleaned to present-state.

Workspace-wide refs to every parallel-spend-engine fn: zero. I2 holds.

Remaining P4b (legacy Wallet balance fields/methods, src/database/utxo.rs
+ wallet.rs legacy queries, schema cleanup, RPC-mode settings UI) is
BLOCKED: those fields still have many LIVE readers in HD screens P4a never
rewired to WalletSnapshot (top_up/add_new identity, shield, send,
add_new_wallet, wallets_screen balance column, mcp wallet tool). Deleting
them is gated on completing the P4a snapshot rewire of those screens —
P4a scope, not a mechanical prune. Reported, not silently diverged.

Build + clippy -D warnings + nightly fmt + full workspace tests GREEN;
backend-e2e/e2e/kittest compile.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrate every remaining live legacy `Wallet` balance/address-balance
reader (HD identity/wallet screens + MCP wallet-balance tool) onto the
display-only `WalletBackend` `WalletSnapshot` accessors, finishing the
P4a data-path rewire that previously covered only `wallets_screen`.

- Add centralised `AppContext::snapshot_balance` /
  `snapshot_has_balance` / `snapshot_address_balances` helpers so call
  sites are a one-line mechanical swap and the legacy `has_balance`
  predicate semantics live in one place.
- Rewire top_up / add_new_identity / add_new_wallet / shield / send /
  dashpay-send / wallets-screen dialogs / MCP `core_balances_get`.
- `AddressInput` now takes per-wallet snapshot balances explicitly
  (`WalletWithBalances`) instead of reaching into `Wallet.address_balances`
  — keeps the component free of `AppContext` and snapshot display-only.
- Drop the now-meaningless `Wallet.address_balances` removal sub-block
  in the network-chooser maintenance action (snapshot is EventBridge-
  owned and read-only; field is deleted in P4b).

Fund-safety unchanged: the snapshot is DISPLAY-ONLY. No spendable-input
selection from it — spending stays on `WalletBackend::send_payment` /
`create_asset_lock_proof` (A04/I1 gate). Single-key paths (Decision #7)
untouched: wallets_screen/mod.rs:469,527 are SingleKeyWallet, not the
legacy HD `Wallet` — verified by source, left as-is.

Exit: `grep` proves zero live readers of legacy `Wallet.{utxos,
address_balances,transactions,confirmed/unconfirmed/total_balance,...}`
+ balance methods outside single-key / wallet_backend / db / model /
tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Removes the 8 dead HD Wallet fields (utxos, address_balances,
address_total_received, transactions, confirmed_balance,
unconfirmed_balance, total_balance, spv_balance_known) and the orphaned
balance/total-received method cluster (has_balance, max_balance,
confirmed_balance_duffs, spv_confirmed_balance, unconfirmed_balance_duffs,
total_balance_duffs, update_spv_balances, update_address_balance,
recalculate_affected_address_balances[_with_db], recalculate_address_balance,
update_address_total_received). Deletes src/model/wallet/utxos.rs entirely.

Grep-proven zero live readers: balances/tx/UTXO reads were relocated to
the WalletSnapshot in P4a. Conditional spend functions
(select_unspent_utxos_for et al.) were already removed in 1089b56.

P4a-gap completion (forced by GREEN-per-commit): two live legacy
Wallet.utxos readers P4a missed are retargeted to the established
snapshot accessor — send_screen::get_core_addresses and
Wallet::unused_bip_44_public_key (display/skip-logic only; spend path
stays upstream-authoritative via CoreTask, no money-path change).
backend-e2e harness balance/tx reads retargeted to snapshot_balance /
WalletBackend::transaction_history (tx_is_ours retargeted, not ignored).

Carve-out intact: src/database/utxo.rs, single_key_wallet.rs, and the
utxos SQLite table untouched. wallet_addresses-driven known/watched
address reconstruction preserved byte-for-byte.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Deletes the four DB writer methods left with zero production callers
after C1: update_address_balance, add_to_address_balance,
update_wallet_balances, update_address_total_received — plus their three
unit tests.

The historical v16/v17 schema-migration functions
(add_wallet_balance_columns, add_address_total_received_column,
ensure_wallet_columns_exist) are RETAINED: they are existence-guarded
idempotent steps on the irreversible migration ladder; removing them
would break old-DB upgrades. The dead balance columns are superseded by
the forward-only C3 settings/wallet migration, which is the fail-safe
path. The column-independent wallet_addresses-driven known/watched
reconstruction and platform_address_info path are untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bumps DEFAULT_DB_VERSION 35 -> 36 and adds a v36 migration arm that
drops the orphaned `dashpay_dip14_quarantine_active` settings column via
the inverted pragma_table_info existence guard (drop_dead_settings_columns
in settings.rs). The column was introduced by an early P3a build and
withdrawn with the quarantine apparatus (commit 4499c82); it is no
longer created by any code path but lingers in upgraded DBs.

Object-disjoint from the post-unlock Stage-B engine: v36 mutates ONLY
the settings table, never wallet/utxos/wallet_transactions — no double
drop, no ordering hazard. Existence-guarded and idempotent.

Spec-vs-source note: the spec's "RPC-era dead settings columns" clause
has no satisfiable referent — every other settings column
(core_backend_mode, use_local_spv_node, custom_dash_qt_path, etc.) still
has live readers. Dropping a live column would be irreversible data
loss, so only the provably-orphaned quarantine column is dropped.

Adds release-blocking-adjacent test v36_drops_orphaned_quarantine_column_idempotently
(present->dropped, idempotent re-run, clean-DB no-op, wallet data
untouched). Version-bump fallout: fresh_install test asserts against
DEFAULT_DB_VERSION instead of a hardcoded literal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the release-blocking Decision-#7 regression lane proving the P4b
carve-out is intact, not merely asserted:

- single_key_wallet_loads_utxos_via_retained_get_utxos_by_address:
  seeds the RETAINED utxos table via the #[cfg(test)] insert_utxo
  fixture + a single_key_wallet row, loads via get_single_key_wallets,
  and asserts SingleKeyWallet.utxos is hydrated through the retained
  get_utxos_by_address path (count, summed value, script round-trip).
- decision_7_stub_still_surfaces_single_key_unsupported: pins
  TaskError::SingleKeyWalletsUnsupported structurally and asserts the
  user-facing disclosure (unsupported / preserved / future update / HD
  alternative) verbatim so a regression that silently re-enables
  single-key spends or weakens the message fails CI.

Flips SND-002 [Implemented] -> [Gap]: single-key send is gated by
Decision #7 (data + UTXOs retained and load correctly; only the spend
action is unavailable). The prior "works the same as HD wallets" text
was factually stale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…scan reference (SEC-004)

data-model-and-migration.md: Stage-B SUCCESS fork now explicitly drops only
`wallet` and `wallet_transactions`; `utxos` table called out as RETAINED with
rationale (Decision #7 single-key load path, fund-data-loss risk). Dead-fields
list corrected: `src/model/wallet/utxos.rs` (HD-only model) is deleted; but
`src/database/utxo.rs` and the `utxos` table are explicitly NOT in scope for
deletion.

phasing.md: settings-migration disjointness note corrected — P3c drops
`wallet`/`wallet_transactions` only, not `utxos`. SEC-001 test-ordering
requirement added to P5 single-key regression lane: the test must run the
Stage-B DROP first, then load the single-key wallet, to prove the `utxos` table
survives the migration (prior isolated seed+load approach was tautological).

removal-inventory.md: `src/database/utxo.rs` moved from DELETE to RETAINED with
Decision #7 rationale. `wallet`/`wallet_transactions` table drop enumeration
corrected to exclude `utxos`.

backend-architecture.md: new "Seed / Secret Boundary" section added with the
accurate SEC-004 statement — `SeedReregistrationLoader` uses in-memory
`Zeroizing` material and never persists seeds/keys; no automated `secrets_scan`
test exists (future hardening). Replaces the stale `tests/secrets_scan.rs`
reference that only appears in the superseded dip14-migration-hardstop.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Re-wire DET's persisted-wallet load onto the upstream seedless
watch-only rehydration API (PR #3692). Closes the reserved G2 swap
point; removes SeedReregistrationLoader and the seed-bearing
WalletRegistration entirely.

Loader seam reshaped: PersistedWalletLoader::wallets_to_register (sync,
seed-bearing Vec<WalletRegistration>) becomes async load(...) ->
LoadedWallets { loaded, skipped } keyed by DET WalletSeedHash. New
DET-opaque types LoadedWallets / PersistedLoadSkip carry no upstream
type across the seam.

UpstreamFromPersisted delegates to WalletBackend::load_from_persistor_
seedless: one load_from_persistor() call rebuilds every wallet
watch-only (no seed in memory), then each loaded wallet is resolved to
its DET WalletSeedHash via the account-xpub bridge.

Fund-safety gates:
- Gate 1 (fund routing): a loaded wallet is accepted only when its
  BIP44 account xpub matches a persisted DET sidecar xpub_encoded.
  This IS the published-xpub == scanned-xpub invariant by construction;
  an unmatched wallet is rejected, never displayed.
- Gate 2 (signing): the load path stays seedless; the seed enters
  memory only at the unlock chokepoint (handle_wallet_unlocked ->
  WalletBackend::provide_seed), preserving asset-lock / payment /
  DashPay signing.

signer_for / derive_private_key now return WalletLocked (not the
inverted WalletBackendNotYetWired) when the seed is absent.

Deviations from the design doc, forced by upstream reality:
1. The seedless bridge keys on the persisted BIP44 account xpub, not a
   root-WalletId derivation: upstream WalletId = SHA256(root_xpub) at
   depth 0, but DET persists the depth-3 account xpub, so the parent
   cannot be derived from it. The account-xpub match is the literal
   routing invariant and needs no migration. A unit test pins that
   DET's and upstream's account xpub agree for the same seed.
2. The a5538dc8 identity-funding re-provision moved off the watch-only
   load path: add_account derives from the root private key, which a
   watch-only wallet lacks. It now runs only when the seed is already
   cached; the post-unlock asset-lock chokepoint remains the
   authoritative provisioner (TC-021 still covered).

EventBridge surfaces WalletSkippedOnLoad as a structured warn (UI
banner deferred, TODO PROJ-010-T6).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@lklimek
Copy link
Copy Markdown
Contributor Author

lklimek commented Jun 2, 2026

Seedless wallet rehydration (PROJ-010) + platform → PR #3692

Pushed 5 commits (b2febb71..e6c6c017). Headline is the wallet-load re-wire.

f35ea7b9chore(deps): platform → PR #3692 head ddfa66ed

e6c6c017feat(wallet): seedless wallet load via UpstreamFromPersisted (PROJ-010)

  • Closes the reserved G2 swap-point. PersistedWalletLoader reshaped to a seedless async load() -> LoadedWallets; UpstreamFromPersisted drives upstream PlatformWalletManager::load_from_persistor() (rebuilds wallets watch-only, no seed in memory at load). SeedReregistrationLoader deleted.
  • Two deviations from the design, both forced by upstream crypto reality:
    1. Bridge matches on the BIP44 account xpub, not WalletId — upstream WalletId = SHA256(root_xpub‖chaincode) is depth-0 master, but DET persists the depth-3 account xpub, and you can't derive a parent from a child. The account xpub is the literal scanned==published routing key, so this is a stronger, migration-free guard.
    2. Identity-funding re-provision deferred to the post-unlock asset-lock chokepoint — a watch-only loaded wallet has no private key, so add_account can't run at load.
  • Fund-safety gates: (1) account-xpub-match gate rejects any loaded wallet not matching a DET sidecar (preserves fund routing); (2) seed supplied only at unlock via provide_seed (signing keeps working; load stays seedless).
  • 612 lib tests pass, clippy -D warnings clean. Fund-safety review by Smythe is in flight.
  • Deferred: skipped-wallet MessageBanner (PROJ-010-T6) — skips are logged + returned in LoadedWallets.skipped, no UI surfacing yet (load path has no egui ctx).

Planning docs (rode along): rehydration design (df38b316), and Phase 1 of the sign-time unlock prompt feature — requirements+UX (d6811732) and a 45-case test spec (bf939c69).

Note: PR #3692 is an open, unreleased dev branch — this keeps the G1 release gate (PROJ-005) open by design until it merges and a tag cuts.

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 2 commits June 2, 2026 16:01
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…n sign (Smythe SEC-001/002)

After the seedless rehydration re-wire, a wallet's seed reaches the
backend (WalletBackend.inner.seeds) only through provide_seed, which is
called from the single unlock chokepoint AppContext::handle_wallet_unlocked.
The no-password unlock paths opened the seed into the in-memory Wallet but
never ran that chokepoint, and bootstrap_loaded_wallets ran only at
AppContext::new — before the backend was wired and against an empty wallet
map, so it was a no-op. Result: a hydrated no-password HD wallet showed as
unlocked yet every signing op (send, asset lock, identity register/top-up,
platform-address top-up) failed WalletLocked, while DashPay/shielded (which
read the seed straight off the Wallet) kept working — a confusing,
fund-blocking dead end.

Make seed injection uniform across every unlock transition:

- ensure_wallet_backend now runs bootstrap_loaded_wallets at its tail,
  after hydration populates ctx.wallets, so every cold-booted Open wallet
  gets its seed pushed to the backend (SEC-002 root cause). The dead call
  at AppContext::new is removed.
- try_open_wallet_no_password and ScreenWithWalletUnlock::should_ask_for_password
  route through handle_wallet_unlocked after open_no_password, so the
  per-operation no-password path injects the seed via the same chokepoint
  (SEC-001). All ~30 call sites pass &self.app_context.
- The backend-e2e harness no longer needs its manual post-wire
  bootstrap_loaded_wallets call — ensure_wallet_backend covers it.

Regression test no_password_wallet_resignable_via_unlock_chokepoint
reproduces the post-hydration state (Open wallet, empty seed cache),
asserts the WalletLocked symptom, then proves the chokepoint restores a
usable signer. Fails before the fix, passes after.

The account-xpub fund-routing gate and the seedless load path are
untouched (Smythe cleared them); the empty-passphrase single-key vault
(SEC-003) is out of scope.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@lklimek
Copy link
Copy Markdown
Contributor Author

lklimek commented Jun 2, 2026

Fix: no-password hydrated wallets couldn't sign (Smythe SEC-001/002)

2272bae0 — fixes a HIGH fund-signing bug Smythe found in the seedless re-wire.

Symptom: after the seedless rehydration, a no-password HD wallet hydrated as Open and the UI showed "unlocked," but inner.seeds was never populated — so signer_for returned WalletLocked for send / asset-lock / identity register+top-up. Inconsistent: DashPay/shielded (which read the seed off the Wallet struct) kept working, masking it.

Root cause: seeds reach inner.seeds only via the handle_wallet_unlockedprovide_seed chokepoint, which the no-password unlock paths bypassed; and bootstrap_loaded_wallets ran at AppContext::new (pre-wiring, empty wallet map) — a no-op.

Fix (one chokepoint, every path funnels through it):

  • Re-timed bootstrap_loaded_wallets to the tail of ensure_wallet_backend (after hydration, populated map).
  • Routed both no-password UI unlock paths (try_open_wallet_no_password, should_ask_for_password) through handle_wallet_unlocked.
  • 33 call sites threaded &self.app_context (mechanical).
  • Regression test (no_password_wallet_resignable_via_unlock_chokepoint): reproduces the seedless cold-boot state, asserts WalletLocked before, signing after.

The account-xpub fund-routing gate and seedless load path are untouched. 613 lib tests pass, clippy clean. Smythe re-verify in flight.

Separate pre-existing item (not from this change): the kittest suite has 15 Migration DivergentVersion failures from a stale on-disk migration artifact — verified identical on the clean base. Needs its own test-isolation cleanup.

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 24 commits June 2, 2026 17:09
…ate-on-error unlock)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…gner (foundation)

Wave 1 of the just-in-time secret-access refactor: the additive,
unit-tested foundation. No removals, no consumer rewiring — the eager
residencies (R1 inner.seeds/provide_seed, R2 single_key_unlocked, R3
Wallet::Open) and all call-site swaps are Waves 2-4 and untouched here.

New modules (src/wallet_backend/):
- secret_prompt.rs — the UI<->async seam. SecretPrompt trait,
  SecretScope{HdSeed,SingleKey}, SecretPromptRequest/Reply, the settled
  RememberPolicy{None,UntilAppClose,For(Duration)} (default None;
  For(Duration) is data-model-only for now), SecretPromptCancelled, and a
  #[cfg(test)] scripted TestPrompt double. SecretString crosses the seam;
  no derived key, no upstream key_wallet types.
- secret_access.rs — the chokepoint. with_secret (FnOnce) and
  with_secret_session (AsyncFnOnce) hand plaintext to a closure by borrow;
  resolution is session-cache (TTL-honored) -> unprotected fast-path ->
  prompt + decrypt-jit + re-ask-on-wrong. Opt-in per-scope session cache,
  boxed zeroizing values, poison-safe forget/forget_all.
- det_signer.rs — DetSigner borrows the held secret (no snapshot copy),
  implements the upstream key_wallet Signer for the HD path and a path-free
  sign_single_key_ecdsa for single keys. Compiles + unit-tested via the
  mock prompt; #![allow(dead_code)] until the Wave 2 call-site swap.

Smythe must-fixes baked in: (1) closure form, no storable guard; (2)
borrow-only, exploiting SecretBytes/SecretPlaintext having no Clone; (3)
boxed session secrets so a HashMap rehash leaves no un-wiped copy; (4)
unprotected scopes never prompt; (5) sentinel-confinement tests assert no
secret leaks into any error Display/Debug.

New typed TaskError variants: SecretPromptCancelled, SecretDecryptFailed,
HdPassphraseIncorrect — no user-facing String fields, no error-string
parsing.

24 new unit tests (15 secret_access, 6 secret_prompt, 3 det_signer); full
lib suite green, clippy --all-features --all-targets -D warnings clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Wave B of the JIT secret-access refactor: make the prompt infrastructure
live without migrating consumers or retiring the eager model (Wave C).

- EguiSecretPromptHost: the GUI SecretPrompt. request() enqueues onto an
  mpsc channel, repaints, and awaits a one-shot reply. AppState drains one
  request per frame into an ActivePrompt and answers on submit/cancel.
- passphrase_modal: shared modal chrome extracted from WalletUnlockPopup
  (overlay, centered window, focus-once, Cancel/Esc/X/click-outside).
  WalletUnlockPopup now renders through it; the JIT prompt adds the
  "Keep this wallet unlocked until I close the app." checkbox
  (RememberPolicy::UntilAppClose; unchecked = None). No UI for For(Duration).
- NullSecretPrompt: headless (MCP/CLI) host; the chokepoint surfaces a typed
  TaskError::SecretPromptUnavailable for protected scopes (Q-HEADLESS: no
  env/flag passphrase). Unprotected scopes still resolve headless.
- SecretAccess is constructed in WalletBackend::new from the host-chosen
  prompt (GUI injects the egui host via AppContext::install_secret_prompt;
  headless keeps NullSecretPrompt). Prompt-copy meta seeded at hydration.
- forget_all wired into the network switch (outgoing context) and exposed
  for teardown.

Additive only: DetSigner is not swapped into call sites, the eager
seeds/single_key_unlocked/Wallet::Open residencies stay, no consumer
signing behavior changes. One Wave-C-pending #[allow(dead_code)] on
WalletBackend::secret_access().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… route R3 backend readers through chokepoint

Wave C: migrate every secret consumer onto the SecretAccess chokepoint and
retire the eager seed-residency model. Signing/derivation now decrypt the
seed just-in-time from the encrypted vault and confine it to a closure that
zeroizes on return — no long-lived plaintext seed or single-key cache.

R1 — HD seed session map (RETIRED): delete Inner.seeds + provide_seed +
signer_for + the WalletAssetLockSigner module. send_payment,
register_identity, top_up_identity, and create_asset_lock_proof now wrap their
signer-driven upstream call in one with_secret_session(HdSeed) scope (one
prompt per operation for a passphrase wallet, none for a no-password wallet),
building DetSigner::from_held over the borrowed seed; derive_private_key reads
the same held seed for the credit-output key. handle_wallet_unlocked stops
distributing the seed — it now only promotes a verified-open seed into the
session cache (UntilAppClose). register_persisted_wallets no longer reprovisions
at load time (the asset-lock chokepoint reprovisions with the JIT seed).

R2 — single-key cache (RETIRED): delete Inner.single_key_unlocked, the
unlock_with_passphrase / forget_unlocked cache methods, and the import_wif
cache-prime. raw_key_bytes/sign_with on the view now serve unprotected keys
only; protected single-key signing flows through the new
WalletBackend::sign_single_key, which decrypts via with_secret(SingleKey) and
signs through DetSigner::sign_single_key_ecdsa. The typed
SingleKeyPassphraseRequired/Incorrect errors now surface from the chokepoint.

R3 — Wallet::Open whole-session seed (backend readers rerouted): DashPay
derive_contact_xpub_material and shielded initialize_shielded_wallet now pull
the seed JIT via with_secret(HdSeed) instead of reading Wallet::Open;
first_open_wallet_seed is removed. Eager shielded init at unlock is removed —
keys derive on first shielded op. RESIDUAL (noted for Smythe): the in-model
seed_bytes() derivation readers (address bootstrap, identity-key derivation,
the Wallet Signer impl) still read Wallet::Open; fully reshaping WalletSeed::open
to verify-not-park is the large gated follow (Q-R3 / T8) and would require
rerouting ~20 synchronous model call sites — out of scope for this wave.

Fund-routing untouched: the published==scanned account-xpub gate and
per-network coin-type derivation are unchanged; only WHERE/WHEN the seed is
obtained changed, never WHICH keys are derived. Secrets stay Zeroizing,
borrow-only, closure-confined, never logged. Allows removed: secret_access()
dead_code and det_signer.rs module-level dead_code.

Tests: adapted no_password_wallet_resignable_via_unlock_chokepoint to the JIT
model (no-password wallet signs after cold-boot via the unprotected fast-path,
no cache); added sec_002_protected_sign_via_chokepoint (protected single-key
signs through the chokepoint with wrong-then-right passphrase). 641 lib tests
green; clippy --all-features --all-targets clean; kittest DivergentVersion
failures are pre-existing (no new failures).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reroute the four async Wallet::Open seed readers off the parked seed
(seed_bytes()) onto the JIT chokepoint (SecretAccess::with_secret), so
each fetches the HD seed just-in-time and derives inside the closure:

- dashpay/contacts.rs       — derive_contact_info_keys (contactInfo decrypt)
- dashpay/contact_info.rs   — derive_contact_info_keys (contactInfo encrypt)
- dashpay/incoming_payments — register_dashpay_addresses_for_identity
- qualified_identity::sign  — ECDSA_HASH160 path-scan recovery fallback

The two contactInfo derivations were identical; their body is lifted into
one wallet_backend helper, derive_contact_info_encryption_keys, so the
BIP-32 derivation lives in a single place and the raw seed never leaves
the wallet_backend seam. QualifiedIdentity gains a non-serialized
secret_access carrier (attached alongside associated_wallets at hydration)
so the model-layer signer can reach the chokepoint; the field is skipped by
Encode/Decode and excluded from PartialEq, exactly like associated_wallets.

Additive/parallel: the ~23 sync in-model readers still use Wallet::Open
until D2-D4, so the build stays green. Wallet::Open, seed_bytes(), the
platform Signer<PlatformAddress> impl, and the bootstrap/private_key family
are deliberately untouched.

Smythe fixes:
- SEC-W-001 (MEDIUM): centralise the DashPay wallet selection on
  QualifiedIdentity::dashpay_wallet{,_seed_hash} (lowest seed hash) so the
  send side and receive side provably pick the same wallet; add a
  multi-wallet regression test proving published == scanned for the selected
  wallet and divergent for the other.
- SEC-W-003 (LOW): wrap SingleKeyView::raw_key_bytes in Zeroizing<[u8;32]>
  so the bare unprotected single-key array wipes on drop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Stop the synchronous in-model HD-seed readers from reaching back into a
wallet's parked session seed (self.seed_bytes()). They now take the seed
as a borrowed parameter (&[u8;64]) supplied by their async caller's
with_secret_session chokepoint scope — one session per bootstrap run.
Additive/parallel: Wallet::Open + seed_bytes() stay defined (D4 removes
them), so the build stays green; only the SEED SOURCE changes, never the
derivation math (same paths, same per-network coin-type, same keys).

Parameterized (seed source: self -> borrowed param):
- bootstrap_known_addresses + the whole bootstrap_* family (bip32,
  coinjoin, identity registration/invitation/topup/not-bound, provider,
  platform-payment) in model/wallet/mod.rs. The bip44 child is unchanged
  (it derives from the master xpub, never the seed).
- Added *_with_seed variants for private_key_at_derivation_path,
  private_key_for_address, derive_private_key_in_arc_rw_lock_slice, and
  KeyStorage::get_resolve; the legacy parked-seed variants stay until
  their remaining callers are drained in D3/D4.

Async callers wired through the chokepoint:
- context/wallet_lifecycle.rs: bootstrap_wallet_addresses is the single
  sync seed bridge (reads self once, R3 #17 -> D4); new async sibling
  bootstrap_wallet_addresses_jit opens with_secret_session per run.
  bootstrap_loaded_wallets is now async (awaited from ensure_wallet_
  backend) and JIT-resolves the seed. It is gated on is_open() so cold
  boot never forces a surprise passphrase prompt — open wallets resolve
  via the no-passphrase fast-path or the session cache promoted on unlock.
- QualifiedIdentity::sign probes the pure, secret-free wallet_seed_hash_for
  and opens with_secret only for genuinely wallet-derived keys; Clear /
  AlwaysClear keys resolve with no seed access and no prompt.
- fund_platform_address_from_asset_lock derives the asset-lock key via
  with_secret + private_key_for_address_with_seed. (The &wallet platform
  Signer at top_up is R3 #15 — untouched, D3.)

Borrow-only: the seed is passed by &Zeroizing/&[u8;64] and derived in
place; no owned bare-array copy outlives the scope, no 'static/Box/Arc of
the seed. Errors stay typed (no seed/passphrase in TaskError/Debug/logs).

Tests (model/wallet/mod.rs):
- seed_param_derivation_matches_parked_seed_derivation: byte-identical
  private keys param-vs-self across one representative path from every
  bootstrap family, on Testnet and Mainnet — proves zero derivation drift.
- private_key_for_address_seed_param_matches, slice_derive_seed_param_
  matches: the *_with_seed variants match their legacy counterparts.
- seed_param_derivation_error_does_not_leak_seed: confinement — no seed
  bytes in the seed-param derivation result.

D3/D4 surfaces deliberately untouched: Signer<PlatformAddress> for Wallet,
the 4 platform-fund SDK sites, get_platform_address_private_key,
WalletAddressProvider owned seed, wallet_seed_snapshot, WalletSeed::open
reshape, and the sync UI key viewers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…y WalletAddressProvider (R3 D3)

Replace the still-live `impl Signer<PlatformAddress> for Wallet` — which read
the wallet's parked session-long plaintext seed — with a just-in-time
`DetPlatformSigner<'a>` at every fund-moving SDK call site. The new signer
borrows the HD seed held open by a `with_secret_session` scope, maps the
platform address to its DIP-17 path through a pure prebuilt `PlatformPathIndex`,
derives locally, and signs with the EXACT same primitive
(`dashcore::signer::sign`) the legacy impl used. Only the seed source changes;
network is known up front instead of brute-forced across all four (safer, R-4).

Fund sites swapped `&wallet` -> `&signer` inside the secret scope:
- transfer_platform_credits (transfer_address_funds)
- withdraw_from_platform_address (withdraw_address_funds)
- fund_platform_address_from_asset_lock (top_up; asset-lock key + signer in one scope)
- fund_platform_address_from_wallet_utxos (top_up)

FUND-SAFETY PARITY (mandatory): unit tests prove `DetPlatformSigner` produces
byte-identical `sign` / `sign_create_witness` output AND byte-identical derived
keys to the legacy `Wallet` signer for the same address/data on Testnet AND
Mainnet. A divergence here = wrong signatures = lost/failed funds.

Rebuild `WalletAddressProvider` to drop its owned `seed: [u8;64]` field
(a residency the borrow was meant to prevent, R-1). It now derives the DIP-17
account-level xpub once from a borrowed seed (resolved through the chokepoint by
`fetch_platform_address_balances`) and derives every gap-limit child publicly —
the final `index` is non-hardened, so the addresses are byte-identical to the
seed path (parity test `provider_xpub_matches_seed_derivation`).

SEC-D2-001 (LOW): `TaskError::WalletKeyLookupFailed` is now fieldless — drop the
user-facing `String` field (project rule #7); callers log the cause via tracing.

Borrow discipline: `DetPlatformSigner<'a>` is lifetime-bound to the held seed
and the index, never `Box`/`Arc`/`'static`; the SDK takes `&S` (Nagatha R-2).
`can_sign_with` is a pure index lookup — no secret, no prompt (R-6).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ey viewers off the parked seed (R3 D3)

Add a `WalletTask::DeriveKeyForDisplay { seed_hash, derivation_path }` backend
task that fetches the HD seed just-in-time through the chokepoint, derives the
key in the backend, and returns only the WIF wrapped in `Secret` end-to-end via
`BackendTaskSuccessResult::WalletKeyForDisplay`. The seed never crosses into the
UI layer (Smythe-approved: same trust boundary as the on-screen display).

Convert the wallets-screen private-key viewers (the "View Key" button in the
address table and the post-unlock display in mod.rs) from the synchronous
`derive_private_key_wif` (which read the wallet's parked seed) to queueing the
request; the `ui()` loop drains it into the backend task and
`display_task_result` renders the returned `Secret` WIF.

The key_info_screen viewers are intentionally NOT converted here — their derived
`RPCPrivateKey` also feeds the on-screen `sign_message` feature, so they need a
dedicated signing chokepoint rather than a display-only WIF task. Deferred to D4.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…r (Smythe SEC-001)

Migrate the last two Signer<PlatformAddress> consumer sites that the Wave D3
swap missed — identity registration and top-up funded by Platform addresses.

Both sites built a session-long plaintext wallet snapshot and passed the legacy
`impl Signer<PlatformAddress> for Wallet` (`&wallet_clone`) into the SDK fund
call. They now build a pure, secret-free `PlatformPathIndex` before the secret
scope and sign through a JIT `DetPlatformSigner` that borrows the HD seed only
for the duration of the SDK call (seed zeroizes on return, never enters this
layer by value) — identical to the four D3-migrated fund sites.

- register_identity_from_platform_addresses: `put_with_address_funding` now
  takes `&signer`; the SDK result flows untouched into the existing
  `log_drive_proof_error` match arm.
- top_up_identity_from_platform_addresses: `top_up_from_addresses` now takes
  `&signer`; the redundant `is_open()` gate is dropped — the chokepoint resolves
  an unprotected/session-cached wallet without a prompt and returns WalletLocked
  only when the seed is truly unavailable.

Derivation, coin-type, and signing primitive are unchanged (DetPlatformSigner
has proven byte-parity with the legacy Wallet signer); only the seed source
moves from the parked seed to the borrowed JIT seed. The legacy
`impl Signer<PlatformAddress> for Wallet` and `get_platform_address_private_key`
now have zero production callers — their only remaining users are the
det_platform_signer parity tests, leaving them clean for D4 removal.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… (R3 D4a)

The four platform-fund sites surfaced TaskError::ContactWalletSeedUnavailable
when expose_hd_seed() returned None inside the HdSeed chokepoint scope. That
variant's Display is contact-request-specific and misleading for a generic
missing/locked HD seed. Swap to TaskError::WalletLocked — operation-neutral
and accurate for the 'no usable seed' case.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… (R3 D4a)

Convert the seed_bytes() readers that need no persistence change onto the
SecretAccess chokepoint, leaving Wallet::Open/seed_bytes() in place for D4c.

- Delete dead identity_top_up_ecdsa_private_key / identity_registration_ecdsa_private_key
  (no callers): removes two parked-seed reads outright.
- platform_receive_address: add generate_platform_receive_address_with_seed
  seed-param variant + WalletTask::GeneratePlatformReceiveAddress backend task;
  the receive dialog dispatches it instead of generating on the UI thread.
- key_info_screen: route the two WIF/hex display reads through
  WalletTask::DeriveKeyForDisplay and add WalletTask::SignMessageWithKey so the
  on-screen sign-message feature signs wallet-derived keys in the backend —
  seed JIT, only the public signature returns. Clear/AlwaysClear keys still
  sign locally (no seed involved).
- handle_wallet_unlocked: skip the parked-seed snapshot for no-password wallets
  (the chokepoint's unprotected fast-path covers them prompt-free); the
  protected-wallet snapshot and the register_wallet fresh-open bootstrap stay
  for D4c, coupled to the WalletSeed::open reshape.

Exact derivation preserved (same paths/keys/coin-type); only the seed SOURCE
changes. New no-drift test for the platform-address variant; secrets/WIF/keys
never enter logs, TaskError, or task results (signatures are public).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…e seed reads (D4b)

The identity-authentication derivation path is hardened to the leaf, so its
public keys cannot be derived from a stored xpub. Instead memoize the derived
33-byte compressed public keys in the DET KV sidecar, keyed per
(network, identity_index, key_index) under DetScope::Wallet(seed_hash). The two
PUBLIC readers (mod.rs:1045/1069) split into a pure seed-free *_cached variant
and a seed-param *_from_seed variant; the three async callers (load_identity,
load_identity_from_wallet, discover_identities) do cache-hit-else-one-JIT-cold-
fill, partitioning all misses in a request into a single with_secret scope and
writing them back. A cold or absent cache self-heals; correctness never depends
on the cache being present.

Lazy warm hangs off the existing bootstrap_wallet_addresses_jit seed scope for
known identity indices; later-discovered identities warm via the read-path cold
-fill. The private-key sibling (mod.rs:1112) stays pure-JIT and is untouched.

Public material only — never caches private keys. Network-keyed coordinates
exclude cross-network reuse. No SQL migration / no refinery touch / no KV
schema bump (additive new key, asserted by test).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…D4c-1)

Reshape the identity-key chooser to read PUBLIC keys, not private ones:

- Rename IdentityKeys -> IdentityKeySpecs (public-only): IdentityKeyEntry
  carries the cached public key + derivation path, never a PrivateKey. A
  single constructor (from_cached_public_key / from_seed) derives both from
  one (network, identity_index, key_index) coordinate (RK-1), so the key
  submitted to Platform and the key the JIT chokepoint signs with at
  registration are byte-for-byte identical. to_public_keys_map /
  to_key_storage read entry.public_key.
- Chooser sources keys from the D4b AuthPubkeyCache. A cold cache queues
  WalletTask::WarmIdentityAuthPubkeys (warm-before-show), shows a
  "Preparing identity keys…" hint, and leaves the key set empty so
  registration stays disabled until warm (fail-closed, RK-2).
- Advanced-mode WIF column becomes per-row "Show WIF" routed through
  WalletTask::DeriveKeyForDisplay — the seed never enters ui().
- Production build_identity_registration becomes a public seed-param
  builder (build_identity_registration_with_seed) plus an async chokepoint
  wrapper. e2e helper now async, delegates to it, and drops the dead
  master_key_bytes return across all fixtures/tests.
- Delete the now-dead identity_authentication_ecdsa_private_key (mod.rs:1228)
  and the test-only legacy private_key_at_derivation_path /
  private_key_for_address; re-anchor their parity tests against a direct
  BIP-32 reference derivation.
- LOW: stale ContactWalletSeedUnavailable -> WalletLocked in
  derive_key_for_display and the JIT bootstrap.

No-drift tests prove public==private derivation across networks/indices and
that the reshaped maps/storage round-trip; build + clippy (-D warnings) +
lib tests green.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…acy Wallet signer (R3 D4c-2 A)

The shielded shield-transition was the 7th fund site still signing through
the LEGACY parked-seed `impl Signer<PlatformAddress> for Wallet` — D3 swapped
the six address-funding sites but missed this one.

- `build_shield_credit` (sync, spawn_blocking) and `shield_credits` (async)
  now build a `DetPlatformSigner` inside a `with_secret_session(HdSeed{..})`
  scope, mirroring the 6 D3 fund sites. The pure `PlatformPathIndex` is built
  before the secret scope; the borrowed seed zeroizes when the scope returns.
  The sync builder bridges the async chokepoint + async builder with the same
  `block_on` it already used for `build_shield_transition`.
- With no production caller left, DELETE `impl Signer<PlatformAddress> for
  Wallet` and `get_platform_address_private_key` (confirmed zero non-test
  callers). DetPlatformSigner has proven byte-parity, so signing behaviour is
  unchanged — only the seed source moves. Parity stays anchored by the
  det_platform_signer reference tests (direct derive, no deleted impl).
- Also retire the now-dead in-model seed readers this wave drains:
  `derive_private_key_in_arc_rw_lock_slice` (legacy parked-seed slice-derive)
  and `platform_receive_address` (parked-seed generate); keep the `_with_seed`
  variants. Re-anchor their parity tests to direct BIP-32 references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…okepoint (R3 D4c-2 B)

Retire the last INDIRECT parked-seed readers: every legacy
`KeyStorage::get_resolve` caller now resolves keys without reading a wallet's
parked seed.

- `KeyStorage`: replace `get_resolve` (which read the parked seed via the
  slice-derive) with `get_resolve_local` (seed-free; resolves Clear/AlwaysClear,
  fails closed on Encrypted/wallet-derived). `get_resolve_with_seed` now
  delegates its non-wallet arms to `get_resolve_local` (DRY) and derives
  wallet-derived keys from the borrowed JIT seed.
- New `QualifiedIdentity::resolve_private_key_bytes(target, key_id)` is the one
  async resolver: `wallet_seed_hash_for` decides whether to open a
  `with_secret(HdSeed{..})` scope (`get_resolve_with_seed`) or resolve seed-free
  (`get_resolve_local`). `sign` now calls it instead of its inline match.
- DashPay async fund-adjacent sites (`payments`, `contact_requests`,
  `auto_accept_proof::verify`) and `auto_accept_proof::generate` route through
  the resolver. The two auto-accept fns become `async` (their callers were
  async or move to a task).
- Sync UI seed reads become backend tasks: a new
  `DashPayTask::GenerateAutoAcceptQrCode` (QR generator) and the existing async
  `GroveSTARKTask::GenerateProof`, which now carries the `QualifiedIdentity`
  (boxed) and resolves the signing key + ed25519 public key via the chokepoint
  in the backend — the seed never enters `ui()`.
- Re-point the identity-key-specs roundtrip test from the deleted `get_resolve`
  to `get_resolve_with_seed`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…-seed path (R3 D4c-2 C)

The production `Wallet::platform_receive_address` (parked-seed) was deleted in
part A; its only remaining callers were the backend-e2e suite and the parity
mirror (re-anchored in A).

Add a test-only `framework::funding::derive_platform_receive_address` that
reproduces the old reuse-or-generate behaviour without touching a seed in the
test: reuse returns an existing watched platform address (seed-free); generate
dispatches `WalletTask::GeneratePlatformReceiveAddress`, which derives the next
address through the JIT chokepoint in the backend. Migrate all nine e2e call
sites to the async helper and drop the now-unused write-lock/seed plumbing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…et residency (R3 capstone)

The last two production parked-seed readers are converted and the
`Wallet::seed_bytes()` accessor is deleted, so an open wallet now parks
NO plaintext seed. The compile-gate proves 100% elimination:
`rg "\.seed_bytes\(\)" src/` returns zero — the only plaintext seed left
in the process lives inside a `with_secret*` frame of the chokepoint.

Model reshape (verify-not-park):
- `WalletSeed::open(password)` decrypts only to PROVE the passphrase, then
  discards the plaintext (Zeroizing, dropped in-scope) and flips to `Open`.
- `OpenWalletSeed` drops its `seed` field — now a plain struct holding only
  `wallet_info: ClosedKeyItem`. The two-variant `Open`/`Closed` enum is kept;
  `Open` means "unlocked/verified", carrying no seed. The generic
  `OpenKeyItem<const N>` collapses (it was only ever `OpenKeyItem<64>`).
- `Wallet::seed_bytes()` and the `Drop for WalletSeed` zeroize-impl are removed.

Reader conversions:
- `register_wallet(wallet, seed: &[u8;64])` takes the freshly-created seed by
  borrow; the fresh-register bootstrap and password-wallet promotion run off
  that borrow via the seed-param `bootstrap_wallet_addresses` and a new
  `promote_seed_to_session`. No parked-seed read.
- `handle_wallet_unlocked(wallet, passphrase: Option<&str>)` promotes a
  password wallet's seed into the JIT session cache by decrypting the stored
  envelope through the chokepoint (`SecretAccess::promote_hd_seed_with_passphrase`,
  the same `decrypt_hd_seed` path signing uses) — never a parked seed.
  `wallet_seed_snapshot` is deleted.

Behavior preserved: no-password signing (unprotected fast-path), cold-boot
prompt-free startup (password wallets with no passphrase in hand are skipped),
and the promote-before-bootstrap ordering invariant.

Tests: new `open_wallet_retains_no_plaintext_seed` proves the Open state holds
no plaintext; all `#[cfg(test)]` seed_bytes() callers now take the known test
seed via a `test_seed()` helper; no-password/unlock/bootstrap behavior tests
stay green (`no_password_wallet_resignable_via_unlock_chokepoint` included).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…R3 QA)

Final-QA copy/docs cleanup for the JIT secret-access refactor — no fund-logic
changes.

- QA-001: fix import_single_key kittest tc_sk_004 to assert the rendered
  "Testnet address" copy instead of the never-rendered "Network: Testnet".
- PROJ-001: CHANGELOG Unreleased Changed bullet for sign-time JIT unlock.
- PROJ-002: WAL-006 acceptance criteria updated to the JIT model.
- PROJ-003: HdPassphraseIncorrect now says "passphrase" (matches the variant
  name, the JIT retry modal, and the single-key variants) — one concept, one
  term for i18n.
- PROJ-004: det_signer module header now reflects that sign_single_key_ecdsa
  is wired into WalletBackend::sign_single_key (no stale expect(dead_code)).
- PROJ-005: WalletSeed/OpenWalletSeed/open doc comments rephrased to
  present-state (M-NO-TOMBSTONES).
- PROJ-006: tightened the session-cache deadlock-avoidance comment to 4 lines.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…eroizing

Fund-critical and security coverage for the JIT secret-access refactor (R3 QA).
No signing/derivation logic changed.

- QA-002: HD DetSigner parity test mirroring the platform-signer one — an
  independent reference (path -> derive_priv_ecdsa_for_master_seed ->
  secp.sign_ecdsa) must yield byte-identical signature AND public key, looped
  over [Testnet, Mainnet]. A pinned (seed, path) -> pubkey vector guards the
  derivation itself against an internally-consistent regression.
- QA-003: cross-scope re-entrancy test — a with_secret_session closure for
  scope A that .awaits with_secret for scope B resolves both, guarding the
  documented lock-release-before-await property against a future deadlock.
- QA-006: open_no_password now pinned to REJECT a password-protected envelope
  (length guard) and ACCEPT an unprotected one.
- QA-004: assert_can_sign now drives a real sign_ecdsa, so the no-password
  cold-boot resignable test exercises derive+sign, not just signer construction.
- QA-007: context-level test seeds the session cache and runs the exact call
  finalize_network_switch funnels through (forget_all_secrets), asserting the
  outgoing cache is emptied.
- SEC-103: SingleKeyEntry::decrypt now returns Zeroizing<[u8;32]> so JIT-derived
  key bytes wipe on drop across the function boundary; callers updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The 14 AppState-constructing kittests resolved their data dir via the real
~/.config/Dash-Evo-Tool path, crashing on a Migration(DivergentVersion) from a
stale det-app.sqlite and polluting the user's wallet data. They now run through
a with_isolated_data_dir helper that redirects DASH_EVO_DATA_DIR (an existing
production env hook) to a throwaway tempdir, serialized by a process-global
mutex and restored via an RAII guard. No production change needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@lklimek
Copy link
Copy Markdown
Contributor Author

lklimek commented Jun 3, 2026

JIT secret-access refactor — HD seed no longer parked in memory (R3 complete)

Pushed 24 commits (2272bae0..43f412cf). This replaces the upfront session-long wallet-unlock model with just-in-time secret access: the HD seed is fetched, used, and zeroized within an operation, never held for the session.

Core (ed06c01906a4b92e)

  • SecretAccess chokepoint — async with_secret / with_secret_session, keyed by SecretScope{HdSeed, SingleKey}, behind a UI-agnostic SecretPrompt seam (egui host via mpsc→oneshot drained on the frame loop; NullSecretPrompt → typed error for MCP/CLI).
  • Residency: operation-scoped by default, opt-in "keep unlocked until I close the app" (RememberPolicy, boxed + Zeroizing, cleared on network switch / teardown). Timed-unlock variant modelled for the future (no GUI yet).
  • All signing routed through DetSigner (HD/single-key) and DetPlatformSigner (all 6+ platform-signer SDK sites incl. shielded), each borrowing the seed inside the secret scope — byte-parity proven against an independent reference on Testnet + Mainnet.
  • Identity-auth public keys memoized in AuthPubkeyCache (DET KV sidecar, public keys only — no DB migration / no DivergentVersion risk; the path is hardened to the leaf so xpub-only derivation is impossible).
  • Capstone: WalletSeed::open reshaped to verify-not-park; OpenWalletSeed.seed field dropped; Wallet::seed_bytes() deleted. rg "\.seed_bytes()" src/ → ZERO (compile-gate proof).

QA (three independent passes)

  • Security (Smythe): SHIP — 10/10 end-state gates; the only remaining plaintext-seed residencies are the three deliberate, sound ones (opt-in session cache, JIT single-key borrow, user-initiated DIP-15 QR). block_on on the shielded path confirmed deadlock-safe (off-UI-thread).
  • Tests (Marvin): 676 lib + 83 kittest + 3 doc — green. Added the HD signer parity test (independent reference + pinned vector), reentrancy + open_no_password guards.
  • Consistency (Adams): typed-error conventions, naming, MCP/CLI all clean.

Also (43f412cf) — QA-005: AppState kittests isolated onto a temp data dir (with_isolated_data_dir), eliminating the 14 pre-existing DivergentVersion failures and stopping tests from touching the real user data dir. kittest now 83/0.

CHANGELOG + user-story WAL-006 updated for the new unlock model.

Deferred/non-blocking: single-key send remains an upstream stub (the JIT machinery is wired and waiting). The G1 release gate stands — PR #3692 is still an unreleased dev rev.

🤖 Co-authored by Claudius the Magnificent AI Agent

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.

1 participant