Skip to content

feat(key-wallet): reserve receive addresses on hand-out#818

Open
xdustinface wants to merge 2 commits into
devfrom
feat/address-reservation
Open

feat(key-wallet): reserve receive addresses on hand-out#818
xdustinface wants to merge 2 commits into
devfrom
feat/address-reservation

Conversation

@xdustinface

@xdustinface xdustinface commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Adds a reservation lifecycle to addresses so a receive address handed out to a caller is not re-issued before it is funded or explicitly released. This closes the hand-out race where two sequential requests returned the same address.

  • Model address lifecycle as an AddressState enum (Available, Reserved { at }, Used { at }) on AddressInfo, replacing the separate used/used_at fields. The states are mutually exclusive by construction, so the invariant "a used address is never reserved" holds structurally instead of being maintained by hand.
  • Add next_unused_and_reserve, release_reservation, and sweep_expired_reservations (TTL backstop) on AddressPool; reserved addresses are excluded from hand-out, count against the gap limit, and are never pruned or aged out when clockless.
  • Wire next_receive_address_and_reserve, release_receive_reservation, and sweep_expired_receive_reservations through ManagedCoreFundsAccount, bumping the monitor revision on change.
  • Add reserved_count to PoolStats.
  • Cover reserve/release/sweep, serde and bincode round-trips, gap-limit and prune interaction, and end-to-end promotion on funding.

Summary by CodeRabbit

Release Notes

New Features

  • Added reservation-based receive-address flow for Standard accounts, allowing temporary holds on receive addresses with TTL-based expiry.
  • You can now release a receive address reservation back to the available pool.
  • Address lifecycle tracking is now modeled with distinct states: available, reserved, and used.

Error Handling

  • Introduced an explicit “Invalid state” error for internal invariant violations and exposed it through the FFI error mapping.

Tests

  • Expanded coverage for reservation hand-off, release idempotency, sweeping behavior, and pool selection behavior.

Adds a reservation lifecycle to addresses so a receive address handed out to a caller is not re-issued before it is funded or explicitly released. This closes the hand-out race where two sequential requests returned the same address.

- Model address lifecycle as an `AddressState` enum (`Available`, `Reserved { at }`, `Used { at }`) on `AddressInfo`, replacing the separate `used`/`used_at` fields. The states are mutually exclusive by construction, so the invariant "a used address is never reserved" holds structurally instead of being maintained by hand.
- Add `next_unused_and_reserve`, `release_reservation`, and `sweep_expired_reservations` (TTL backstop) on `AddressPool`; reserved addresses are excluded from hand-out, count against the gap limit, and are never pruned or aged out when clockless.
- Wire `next_receive_address_and_reserve`, `release_receive_reservation`, and `sweep_expired_receive_reservations` through `ManagedCoreFundsAccount`, bumping the monitor revision on change.
- Add `reserved_count` to `PoolStats`.
- Cover reserve/release/sweep, serde and bincode round-trips, gap-limit and prune interaction, and end-to-end promotion on funding.
@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8b19100d-b3b7-4a49-ba98-591b7e775bbd

📥 Commits

Reviewing files that changed from the base of the PR and between b4a2439 and fc0668f.

📒 Files selected for processing (3)
  • key-wallet-ffi/src/error.rs
  • key-wallet/src/error.rs
  • key-wallet/src/managed_account/address_pool.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • key-wallet/src/managed_account/address_pool.rs

📝 Walkthrough

Walkthrough

Introduces AddressState (Available, Reserved, Used) to replace the flat used/used_at fields on AddressInfo. AddressPool gains next_unused_and_reserve, release_reservation, and sweep_expired_reservations. ManagedCoreFundsAccount exposes matching Standard-only wrappers. FFI adapters and downstream test fixtures are updated throughout.

Changes

Address Reservation Lifecycle

Layer / File(s) Summary
AddressState enum and AddressInfo contract
key-wallet/src/managed_account/address_pool.rs
Defines AddressState (Available, Reserved { at }, Used { at }) with serde/bincode support. Refactors AddressInfo to carry state instead of used/used_at. Updates both constructors and rewrites all lifecycle predicates (is_available, is_reserved, is_used, used_at, reserved_at, mark_used).
AddressPool selection, marking, and stats
key-wallet/src/managed_account/address_pool.rs
Updates next_unused*, next_unused_multiple*, unused_addresses_count, unused_addresses, mark_used, mark_index_used, scan_for_usage, needs_more_addresses, stats (adds reserved_count), reset_usage, prune_unused, PoolStats, and Display to use is_available()/is_reserved()/is_used().
InvalidState error and maintain_gap_limit safety
key-wallet/src/error.rs, key-wallet/src/managed_account/address_pool.rs
Adds Error::InvalidState(String) variant with Display formatting. Replaces panic in maintain_gap_limit with returning Error::InvalidState.
New reservation methods on AddressPool
key-wallet/src/managed_account/address_pool.rs
Implements next_unused_and_reserve, release_reservation, and sweep_expired_reservations with defined edge-case semantics for now==0, ttl==0, and at==0.
ManagedCoreFundsAccount reservation wrappers
key-wallet/src/managed_account/managed_core_funds_account.rs
Adds next_receive_address_and_reserve, release_receive_reservation, and sweep_expired_receive_reservations as Standard-only methods, each bumping the monitor revision on observable state changes.
FFI adapter and downstream fixture updates
key-wallet-ffi/src/address_pool.rs, key-wallet-ffi/src/error.rs, key-wallet-manager/src/events.rs
Updates address_info_to_ffi to call is_used()/used_at(). Adds Error::InvalidState to FFI error mapping. Fixes AddressInfo construction in FFI and manager test fixtures to use state: AddressState::Available.
Tests: pool units, batch selection, and end-to-end reservation
key-wallet/src/managed_account/address_pool.rs, key-wallet/src/tests/address_pool_tests.rs, key-wallet/src/tests/address_reservation_tests.rs, key-wallet/src/tests/mod.rs
Adds/reworks pool unit tests for reservation handout, release, idempotency, serde round-trip, sweep, reset, prune, and headroom. Updates batch-selection tests to assert reserved addresses are skipped. Adds address_reservation_tests module with async end-to-end, failure-mode, and TTL sweep boundary tests.

Sequence Diagram(s)

sequenceDiagram
    participant Caller
    participant ManagedCoreFundsAccount
    participant AddressPool
    participant AddressInfo

    Caller->>ManagedCoreFundsAccount: next_receive_address_and_reserve(xpub, now)
    ManagedCoreFundsAccount->>AddressPool: next_unused_and_reserve(key_source, now)
    AddressPool->>AddressInfo: find Available entry or derive new
    AddressInfo-->>AddressPool: state = Reserved { at: now }
    AddressPool-->>ManagedCoreFundsAccount: Address
    ManagedCoreFundsAccount-->>Caller: Ok(Address) + bump monitor_revision

    Caller->>ManagedCoreFundsAccount: release_receive_reservation(&address)
    ManagedCoreFundsAccount->>AddressPool: release_reservation(index)
    AddressPool->>AddressInfo: if Reserved → state = Available
    AddressPool-->>ManagedCoreFundsAccount: bool (was_reserved)
    ManagedCoreFundsAccount-->>Caller: bool + bump monitor_revision if true

    Caller->>ManagedCoreFundsAccount: sweep_expired_receive_reservations(now, ttl)
    ManagedCoreFundsAccount->>AddressPool: sweep_expired_reservations(now, ttl)
    AddressPool->>AddressInfo: Reserved { at } where (now - at) > ttl → Available
    AddressPool-->>ManagedCoreFundsAccount: usize (reclaimed)
    ManagedCoreFundsAccount-->>Caller: usize + bump monitor_revision if > 0
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Suggested labels

ready-for-review

Suggested reviewers

  • ZocoLini

Poem

🐇 Hop, hop — no more address race!
Three states now keep each coin in place:
Available, Reserved, or Used with care,
The bunny stamps a timestamp there.
Sweep the stale ones, free the rest —
A tidy pool is always best! 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(key-wallet): reserve receive addresses on hand-out' accurately and concisely summarizes the main change: introducing address reservation functionality for receive address hand-out to prevent race conditions.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/address-reservation

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.

@codecov

codecov Bot commented Jun 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.73256% with 25 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.20%. Comparing base (0b056c2) to head (fc0668f).
⚠️ Report is 6 commits behind head on dev.

Files with missing lines Patch % Lines
key-wallet/src/managed_account/address_pool.rs 92.56% 22 Missing ⚠️
.../src/managed_account/managed_core_funds_account.rs 95.23% 2 Missing ⚠️
key-wallet/src/error.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #818      +/-   ##
==========================================
- Coverage   73.38%   73.20%   -0.18%     
==========================================
  Files         323      323              
  Lines       72288    72333      +45     
==========================================
- Hits        53048    52953      -95     
- Misses      19240    19380     +140     
Flag Coverage Δ
core 76.74% <ø> (ø)
ffi 47.35% <100.00%> (-1.53%) ⬇️
rpc 20.00% <ø> (-13.05%) ⬇️
spv 90.30% <ø> (+0.04%) ⬆️
wallet 72.45% <92.64%> (+0.80%) ⬆️
Files with missing lines Coverage Δ
key-wallet-ffi/src/address_pool.rs 36.72% <100.00%> (-3.11%) ⬇️
key-wallet-ffi/src/error.rs 67.02% <100.00%> (ø)
key-wallet-manager/src/events.rs 67.98% <100.00%> (-0.16%) ⬇️
key-wallet/src/error.rs 10.52% <0.00%> (-0.29%) ⬇️
.../src/managed_account/managed_core_funds_account.rs 78.52% <95.23%> (+1.79%) ⬆️
key-wallet/src/managed_account/address_pool.rs 78.42% <92.56%> (+12.57%) ⬆️

... and 19 files with indirect coverage changes

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@key-wallet/src/managed_account/address_pool.rs`:
- Around line 1053-1058: The needs_more_addresses() method now evaluates
available addresses using the is_available() predicate to determine if more
addresses are needed, but the maintain_gap_limit() function only checks against
highest_used when deciding whether to generate new addresses. This creates a
mismatch where needs_more_addresses() can return true for reserved-but-unused
pools while maintain_gap_limit() generates no new addresses. Update the
maintain_gap_limit() function to use the same availability predicate that counts
is_available() addresses when deciding whether to replenish the pool, ensuring
both methods use consistent logic for determining when the gap limit has been
breached.
- Around line 676-679: Replace the expect() call on the get_mut(&next_index)
operation with proper error handling using ok_or() to convert the Option into a
Result, then propagate this error through the return type of the containing
function instead of panicking. This ensures that missing pool entries result in
a returned error rather than a process crash, which is appropriate for library
code.
- Around line 250-251: The state field on AddressState lacks backward
compatibility support for deserialization of existing wallet payloads. Since the
legacy used and used_at fields have been removed, older serialized wallets will
fail to deserialize. Add the #[serde(default)] attribute to the state field
declaration to allow missing values during deserialization, or implement a
custom Deserialize handler or deserialize_with function that maps the old used
and used_at fields to the appropriate AddressState enum variant. Verify the fix
works by adding a test that attempts to deserialize a wallet payload with the
legacy field structure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 53d08b96-0bb9-4ded-afe8-c70aa70862c6

📥 Commits

Reviewing files that changed from the base of the PR and between 95a3c8f and b4a2439.

📒 Files selected for processing (7)
  • key-wallet-ffi/src/address_pool.rs
  • key-wallet-manager/src/events.rs
  • key-wallet/src/managed_account/address_pool.rs
  • key-wallet/src/managed_account/managed_core_funds_account.rs
  • key-wallet/src/tests/address_pool_tests.rs
  • key-wallet/src/tests/address_reservation_tests.rs
  • key-wallet/src/tests/mod.rs

Comment thread key-wallet/src/managed_account/address_pool.rs
Comment thread key-wallet/src/managed_account/address_pool.rs Outdated
Comment thread key-wallet/src/managed_account/address_pool.rs
…nvariant miss

`next_unused_and_reserve` and `maintain_gap_limit` guarded the "entry must exist after `generate_address_at_index(add_to_state=true)`" invariant with `expect()` and `panic!()`. This crate's error-handling philosophy is to never panic in library code, so both sites now propagate a new `Error::InvalidState` variant through their existing `Result` return type. The FFI maps it to `FFIErrorCode::InvalidState`.

Addresses CodeRabbit review comment on PR #818
#818 (comment)
@github-actions github-actions Bot added the ready-for-review CodeRabbit has approved this PR label Jun 23, 2026
@xdustinface xdustinface requested a review from ZocoLini June 23, 2026 13:16
@github-actions

Copy link
Copy Markdown
Contributor

This PR has merge conflicts with the base branch. Please rebase or merge the base branch into your branch to resolve them.

@github-actions github-actions Bot added the merge-conflict The PR conflicts with the target branch. label Jun 25, 2026
/// Funds have been seen at this address. `at` is when it was first used.
Used {
/// First-used timestamp.
at: u64,

@ZocoLini ZocoLini Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't remember any usage for the used_at field, why do we need it??

/// declines to judge staleness and reclaims nothing. A `ttl` of 0 means no
/// expiry window, so nothing is reclaimed either. A reservation stamped with
/// `at == 0` (its caller had no clock) is likewise never aged out.
pub fn sweep_expired_reservations(&mut self, now: u64, ttl: u64) -> usize {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

start it by default so that the developer doesn't need to care about that, with opt out option.

/// reclaimed only by [`AddressPool::release_reservation`] and
/// [`AddressPool::sweep_expired_reservations`], so callers are assumed
/// trusted. Gate or cap this before exposing it to untrusted input.
pub fn next_unused_and_reserve(&mut self, key_source: &KeySource, now: u64) -> Result<Address> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this is "good enough", but the idea was to return a "guard" with release-on-drop.

///
/// Derivation of fresh addresses on this path is unbounded, so callers are
/// assumed trusted. See [`address_pool::AddressPool::next_unused_and_reserve`].
pub fn next_receive_address_and_reserve(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this is "good enough", but the idea was to return a "guard" with release-on-drop.

/// no clock. Returns the number of reservations reclaimed and bumps the
/// monitor revision when that is non-zero. See
/// [`address_pool::AddressPool::sweep_expired_reservations`].
pub fn sweep_expired_receive_reservations(&mut self, now: u64, ttl: u64) -> usize {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would start a worker or create event handler to automatically run this method, with optional opt-out.

lklimek added a commit to dashpay/dash-evo-tool that referenced this pull request Jun 26, 2026
…01 hardening (#867)

* docs(secret-seam): Phase-1 design artifacts (UX disclosure + test case spec)

UX disclosure spec by Diziet; 30-case TDD test spec by Marvin. Design reference for the secret-storage raw-SecretBytes seam re-architecture.

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

Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* feat(wallet-backend): add raw-SecretBytes secret seam + typed errors (T2,T4)

Crikey, here's the one socket every wallet secret will squeeze through.

T2 — new wallet_backend/secret_seam.rs: SecretSeam over raw SecretBytes with
put_secret/get_secret/delete_secret, a no-encryption pass-through to the
upstream vault TODAY. Every put/get body carries the greppable
`TODO(per-secret-encryption):` tag so wiring real per-secret encryption later
is a localized change. Prompt-free — the passphrase requirement lives only in
the retained legacy readers, never here.

No-serialization guard mechanism: compile_fail doctests (no new deps —
static_assertions/trybuild stay out of Cargo.toml). One asserts a newtype
cannot derive Serialize over a SecretBytes; one asserts serde_json::to_string
on a SecretBytes is rejected. If upstream ever adds Serialize to SecretBytes
these start compiling and the canary fires (TS-INV-01). TS-INV-02 round-trips
a SecretBytes through the real signatures (compiler is the assertion).

T4 — TaskError variants (no String fields, typed #[source]): SecretSeam,
SecretSeamMissing (loud funds-safety miss), IdentityKeyVault, IdentityKeyMissing.

Promote the private assert_no_leak (hex + decimal-array) into a shared
wallet_backend/leak_test_support.rs so the seam/sidecar/QI/Debug leak cases
reuse one impl instead of copy-pasting. TS-NOLEAK-01: the on-disk vault file
holds no raw secret in either form.

Tests: 6 seam unit + 2 compile-fail doctests, all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* fix(model): redacting Debug for ClosedSingleKey (T9, 6a2818cd)

ClosedSingleKey derived Debug and its encrypted_private_key holds the raw 32
key bytes in the no-password / pre-migration shape — a derived Debug dumped
them as a decimal byte array straight into logs. Hand-write a redacting Debug
mirroring ClosedKeyItem / SingleKeyEntry: key_hash + lengths, never the bytes.
Parents SingleKeyData / SingleKeyWallet are safe by delegation.

TS-DBG-01 asserts via the shared assert_no_leak_bytes (hex AND decimal-array —
the decimal form is the one the pre-fix Debug leaked) at all three levels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* feat(model): PrivateKeyData::InVault placeholder + migration probes (T1)

Identity private keys get a non-resident home. New PrivateKeyData::InVault
appended at bincode index 4 — discriminants 0-3 (AlwaysClear/Clear/Encrypted/
AtWalletDerivationPath) are untouched, so blobs written before it still decode
(TS-RESID-02 round-trips all four pre-existing variants + InVault). Redacting
Debug/Display arms (carries no bytes — trivially clean).

KeyStorage probes:
- is_in_vault / public_key_for — a vault placeholder reports true yet still
  surfaces its public key for display + signing-key selection.
- take_plaintext_for_vault — rewrites every Clear/AlwaysClear to InVault and
  returns the raw bytes (Zeroizing) the migration must store in the vault FIRST
  (vault-before-blob order). Wallet-derived + encrypted keys untouched — they
  were never plaintext-at-rest.

get/get_resolve_local gain an InVault arm (resolve through the vault, not
locally). key_info_screen gains degraded InVault arms (securely-stored notice;
full JIT view/sign via dedicated identity-key WalletTasks is the T8 follow-up).

Promote the private assert_no_leak + distinctive_secret to the shared
leak_test_support helper (no fork). TS-RESID-01 / TS-NOLEAK-03: post-migration
KeyStorage has only InVault, and the re-encoded blob leaks neither secret in
hex nor decimal-array form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* feat(model,wallet-backend): WalletMeta+ImportedKey sidecar fields, schema-gated (T5)

Non-secret metadata moves out of the per-wallet seed envelope into the sidecar.

WalletMeta gains uses_password + password_hint. Because WalletMeta is positional
bincode behind the DetKv envelope, #[serde(default)] alone is NOT
forward-compatible (R-SCHEMA) — so a real version gate: WALLET_META_VERSION (v2)
framed as [version | bincode] at the WalletMetaView boundary, plus a retained
decode-only WalletMetaV1. decode_versioned detects v2 / v1-framed / bare-legacy
and migrates a v1 blob into v2 (defaults uses_password=false), never positionally
misparsing it. The global DetKv SCHEMA_VERSION is deliberately untouched (it
governs every payload, not just WalletMeta). TS-META-01 covers all three shapes.

ImportedKey gains public_key_bytes (the compressed SEC1 PUBLIC key) so the
locked-render cold-boot path can rebuild a protected key's display wallet
without the secret — moved out of the SingleKeyEntry vault blob ahead of the
raw-seam migration. NON-secret; #[serde(default)] for old entries.

write_wallet_meta now carries uses_password/password_hint from the open Wallet;
the legacy-table drain (finish_unwire) defaults them (the authoritative flag is
read from the envelope at the migrating unlock).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* chore(wallet-backend): satisfy fmt + clippy for the secret-seam batch

- leak_test_support: drop redundant inner #![cfg(test)] (mod.rs already gates it).
- encrypted_key_storage: factor take_plaintext_for_vault's return into the
  VaultBoundKey type alias (clippy::type_complexity).
- wallet_hydration bench: carry the new WalletMeta password fields.
- nightly-fmt whitespace.

Gate: cargo +nightly fmt --all clean; cargo clippy --all-features --all-targets
-D warnings clean; cargo test --all-features --workspace = 944 lib + 146 + 10 +
3 + 2 pass, 0 fail; 2 compile_fail doctests pass; det-cli standalone smoke
(network-info / tools / core-wallets-list) all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* feat(wallet-backend): SecretScope::IdentityKey + seam-first SecretAccess (T3)

The chokepoint learns identity keys and goes seam-first for everyone.

- SecretScope::IdentityKey { identity_id:[u8;32], target, key_id } (DET-opaque;
  KeyID is just u32, PrivateKeyTarget is a DET model enum). identity_key_label()
  builds identity_key_priv.<m|v|o>.<key_id> — a stable one-char target tag keeps
  the label inside the upstream allowlist.
- SecretPlaintext::IdentityKey + expose_identity_key; Plaintext::IdentityKey.
  Borrowed-only, zeroizing, never resident — same hygiene as the other kinds.
- decrypt_jit is now SEAM-FIRST for all three classes: the raw label wins; the
  retained legacy reader (decrypt_hd_seed / SingleKeyEntry::decrypt) is the
  migration fallback for HD seeds and single keys. IdentityKey reads raw via the
  seam → loud IdentityKeyMissing if absent (never silent).
- scope_has_passphrase: a migrated raw secret reports false (the password no
  longer gates it); only a not-yet-migrated legacy entry can still be protected;
  IdentityKey is always false → prompt-free fast-path → headless/MCP signing works.
- DetSigner treats an IdentityKey plaintext as a raw single key (same secp256k1
  shape, no derivation tree).

Tests: TS-FAST-01 (identity key resolves prompt-free, ask_count 0,
can_resolve_without_prompt true), IdentityKeyMissing is loud, TS-LEGACY-01
(legacy envelope served when raw absent), raw-wins-over-legacy precedence. The
pre-existing protected-HD/single-key tests now exercise the legacy fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* feat(wallet-backend): identity_key_store + seed/single-key seam-raw writes (T6)

Secrets start landing raw. No DET envelope for the new write paths.

- New wallet_backend/identity_key_store.rs: IdentityKeyView with
  store/get/delete + store_all/delete_all over raw 32 bytes via SecretSeam
  (scope = identity_id, label identity_key_priv.<m|v|o>.<key_id>). NO
  StoredIdentityKey envelope — the InVault marker in the QI blob is the only
  on-disk trace. store_all is the migration's vault-first writer (call before
  the blob rewrite); delete_all backs purge_identity_scope.
- WalletSeedView gains set_raw/get_raw/delete_raw (raw 64-byte seed under
  seed.raw.v1 via the seam) + legacy_envelope_get (retained decode-only reader).
- write_seed_envelope now branches: a no-password wallet writes the RAW seed
  (encrypted_seed_slice() is verbatim the seed); a password wallet keeps the
  legacy AES-GCM envelope at creation and migrates lazily at unlock (T7).
- import_wif_with_passphrase: unprotected import writes RAW 32 bytes under the
  existing single_key_priv.<addr> label (no SingleKeyEntry framing); protected
  import keeps the legacy SingleKeyEntry (lazy-migrates at unlock). The
  locked-render pubkey rides in the ImportedKey sidecar (the T5 field).
  SingleKeyEntry::decode treats a bare 32-byte blob as unprotected, so a
  raw-written key still rebuilds + opens at cold boot.

Tests: identity_key_store round-trip / scope+target isolation / store_all+
delete_all; seed raw round-trip independent of the legacy label; single-key
unprotected import is exactly 32 raw bytes (no framing) and signs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* feat: crash-safe dual-format migration + InVault resolver + vault delete (T7)

This is the part that actually moves secrets. Funds-safety ordering throughout.

Resolver (mod.rs): resolve_private_key_bytes gains the InVault route — keyed by
is_in_vault/public_key_for, it fetches the raw bytes per-use via
with_secret(IdentityKey{...}) (prompt-free). No chokepoint wired ⇒ fail closed
(WalletLocked); bytes never resident.

EAGER migration on load (dialog-free):
- Identity keys (identity_db::migrate_identity_keys_to_vault, run per identity
  in load_identities_filtered): take_plaintext_for_vault → IdentityKeyView
  store_all (vault FIRST) → rewrite the QI blob with InVault. Vault-write
  failure restores the resident plaintext for this session and defers; a
  blob-rewrite failure is re-detected and retried next load. Idempotent.
- No-password HD seeds (hydration::reconstruct_wallet): raw seam wins
  (precedence raw > legacy); a no-password legacy envelope is re-stored raw
  (set_raw, vault FIRST) then deleted. reconstruct_from_envelope extracted so
  the raw and legacy paths share the xpub-decode + build tail.

LAZY migration on unlock (one prompt, the unlock the user already does):
promote_and_maybe_migrate_hd_seed re-stores the just-decrypted legacy seed raw
(set_raw before delete) inside the borrowed Zeroizing scope and reports
migrated=true; handle_wallet_unlocked then flips WalletMeta.uses_password=false
and shows the one-time disclosure (T8 Copy A/D).

Delete: forget_wallet_local_state now deletes BOTH the raw seed and the legacy
envelope (a wallet may be in either form) — closes a wipe gap where a migrated
no-password seed would survive removal. identity_db.clear_identity_vault_keys
drains an identity's raw vault keys on single-delete + devnet sweep.

Loud, never silent: a seed in neither form ⇒ TaskError::SecretSeamMissing
(was WalletNotFound) on both scope_has_passphrase and decrypt_jit.

Tests: TS-EAGER-01/04 (no-pw seed migrates + idempotent), TS-CRASH-01 read
(raw wins, legacy cleaned), TS-MISS-01 (SecretSeamMissing loud). Updated 5
wallet_lifecycle removal/clear tests to assert the raw seed (the new at-rest
form) in BOTH precondition and post-delete. wallet_lifecycle 38, hydration 10,
identity_db 16, encrypted_key_storage 4 — all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* feat: key_info_screen JIT identity signing + single-key Copy B disclosure (T8)

Real JIT for vault-backed identity keys, and the per-key migration notice.

Two new WalletTasks + handlers, opening with_secret(IdentityKey{...}):
- DeriveIdentityKeyForDisplay → derive_identity_key_for_display: fetches the raw
  key JIT, returns only the WIF (Secret).
- SignMessageWithIdentityKey → sign_message_with_identity_key: signs in the
  backend, returns only the public Base64 envelope.
New result variants IdentityKeyForDisplay / IdentityMessageSigned (identity-
flavored — carry identity_id/target/key_id, not a meaningless seed_hash).

key_info_screen: the InVault arms are now real — "View Private Key" queues
DeriveIdentityKeyForDisplay and renders the returned WIF/hex via the existing
render_decrypted_key_grid; "Sign" queues SignMessageWithIdentityKey. The
degraded placeholders are gone. display_task_result handles both new results.

Single-key protected lazy migration + Copy B: verify_passphrase now re-stores
the just-decrypted protected entry raw under the same label (upsert replaces the
AES-GCM framing) and clears the persistent has_passphrase flag, returning a
migrated bool. verify_single_key_passphrase surfaces the one-time per-key
disclosure (Copy B — text DISTINCT from the wallet Copy A so set_global's dedup
keeps both) on migration. decrypt_jit's sign path also lazy-migrates
(migrate_single_key_to_raw + in-memory flag flip) — idempotent defense-in-depth.
SingleKeyView::clear_passphrase_flag persists the flip to the sidecar.

Tests: TS-LAZY-03 — protected single key migrates via the chokepoint, the vault
holds raw 32 bytes after, and a second resolve under a never-prompt host is
prompt-free with the WIF-plaintext bytes. secret_access 24 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* chore: fmt + clippy for the T3-T8 integration batch

- secret_access: drop explicit_auto_deref on set_raw(seed_hash, seed) — a
  &Zeroizing<[u8;64]> auto-derefs to &[u8;64].
- nightly-fmt whitespace across the touched files.

Gate: cargo +nightly fmt --all clean; cargo clippy --all-features --all-targets
-D warnings clean; cargo test --all-features --workspace = 957 lib + 146 + 10 +
3 + 2 pass, 0 fail, 1 ignored (funded-testnet TS-SIGN-E2E-01); 2 compile_fail
doctests pass; det-cli standalone smoke (network-info / core-wallets-list /
tools) all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* fix(wallet-backend): dual-format read for WalletMeta + ImportedKey sidecars

The real defect QA caught (PROJ-001/002/003 + SEC-003): appending fields to a
positional-bincode DetKv value is format-breaking, and my T5 framing made it
WORSE — WalletMeta writes went through kv.put::<Vec<u8>>(versioned-frame) and
reads through kv.get::<Vec<u8>>, which type-confuses an OLD kv.put::<WalletMeta>
blob (decodes the alias's UTF-8 bytes AS the Vec) → alias/is_main silently lost.
ImportedKey appended public_key_bytes with no legacy reader → old keys vanish
from the picker.

Fix (one policy for both sibling sidecars): drop the hand-rolled version byte
(SEC-003: it could collide with a bincode length varint — a 1/2-char alias).
Instead lean on the DetKv schema envelope + try-decode-both:
- write the current shape directly (kv.put::<WalletMeta> / ::<ImportedKey>);
- on read, try the current shape; on a bincode Decode error (an old blob runs
  out of bytes for the appended fields) fall back to the legacy shape
  (WalletMetaV1 / ImportedKeyV1, decode-only) and RE-STORE in the new shape.
Order is load-bearing and tested: the 6-field struct CANNOT decode a 4-field
blob (runs past end), so "new first, then V1" never mis-promotes. A DetKv
schema-version mismatch stays a hard error; only Decode triggers the fallback.

Removes the now-dead encode_versioned/decode_versioned/WALLET_META_VERSION
(PROJ-002 — the unreachable legacy branch + its overclaiming test are gone;
the legacy path is now live via the view and tested end-to-end).

Tests: model leg (ts_meta_01) asserts the order-sensitivity + the SEC-003
1/2-char-alias collision case; view legs (old_wallet_meta_blob_*,
old_imported_key_blob_*) write an OLD blob exactly as the base branch did, read
it back through the view preserving every field, and confirm re-store in the new
shape. wallet::meta 3, wallet_meta 13, single_key all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* test(identity-db): identity-key migration, deletion, write-fault no-loss (QA-002/003/005)

Refactor the eager identity-key migration core out of AppContext into a free
fn migrate_keystore_to_vault(secret_store, id, qi, persist) returning a
KeystoreMigration outcome, so the funds-safety logic is unit-testable with a
bare SecretStore + a controllable persist closure (no full AppContext).

QA-002 — migration is vault-FIRST: the persist closure asserts the raw keys are
already in the vault and the blob being persisted is InVault-only; the
AtWalletDerivationPath key is untouched; zero plaintext remains; idempotent
(second run = Nothing).

QA-005 — write-fault no-loss (the write half CRASH-01's read half misses): with
the vault parent dir chmod'd read-only so store_all fails, the migration
restores the resident plaintext keystore byte-for-byte, does NOT call persist,
and reports VaultWriteFailed — keys never lost on a mid-write fault. (#[cfg(unix)].)

QA-003 — identity-key deletion is scoped + isolated: delete_all over the
victim's (target,key_id) set removes its vault keys while a second identity's
key under the same (target,key_id) is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* test(wallet-lifecycle): assert lazy-migration secret post-conditions (QA-004)

The protected-wallet-unlock test asserted only upstream registration. Add the
secret post-conditions the lazy migration is actually for: after
handle_wallet_unlocked the raw seed is written and equals the true 64-byte seed,
the legacy envelope.v1 is deleted, WalletMeta.uses_password flipped false, and a
SECOND resolve through a never-prompt chokepoint over the now-raw vault returns
the seed with zero prompts (the migrated wallet is permanently prompt-free).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* test(backend-e2e): TS-SIGN-E2E-01 InVault identity signs + broadcasts (QA-001)

New #[ignore] backend-e2e test: migrate the shared identity's plaintext signing
keys to the vault (PrivateKeyData::InVault, exactly as the eager load-path
migration does), assert residency (zero Clear/AlwaysClear remain), wire the
chokepoint, then build + sign + broadcast an IdentityUpdateTransition. Signing
runs through the async QualifiedIdentity Signer → resolve_private_key_bytes →
with_secret(IdentityKey{..}) — the JIT free-rider path. A successful broadcast
+ the new key appearing on Platform proves the InVault MASTER key signed live
without ever being resident.

Requires E2E_WALLET_MNEMONIC + live DAPI/SPV; run command + RUST_MIN_STACK in
the header. Compiles + registered in main.rs; left #[ignore] for a manual/live
run during QA.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* refactor(wallet-backend): zeroize migration source, flavor identity-key errors, lift signed-message helper

PROJ-004 (security): take_plaintext_for_vault now zeroizes the resident
Clear/AlwaysClear array BEFORE the InVault overwrite drops it — de-residenting
the key is the function's whole purpose, so it must wipe the source, not just
the moved-out copy.

PROJ-005: IdentityKeyView::store/get/delete now map the generic seam error to
the identity-flavored TaskError::IdentityKeyVault (previously a producerless
variant), so an identity-key vault failure surfaces with identity-specific
banner copy. Wrong-length stays SecretDecryptFailed.

QA-DEDUP-01: lift dash_signed_message (the recoverable-envelope builder) from
sign_message_with_key.rs to backend_task/wallet/mod.rs as pub(crate); both the
wallet-key and identity-key signers now call it instead of two drifting copies.
The recovery-header round-trip tests move alongside the shared helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* test(secret-seam): TS-INV-03 audit guard + TS-NOLEAK-02 sidecar no-leak (SEC-001/002)

SEC-001 (TS-INV-03): source-text audit over the changed secret-path modules —
no Serialize/Encode struct may name a plaintext-key field (SecretBytes,
Zeroizing<[u8, [u8;32], [u8;64]). Catches the bare-Vec/array plaintext bypass
the compile_fail doctests can't (they only catch an embedded SecretBytes). The
module list mirrors the blast-radius table; ciphertext fields are deliberately
not flagged. Passes — the invariant holds today and now has a regression guard.

SEC-002 (TS-NOLEAK-02): assert the encoded WalletMeta + ImportedKey sidecar
blobs contain neither secret (hex AND decimal-array via the shared
assert_no_leak_bytes), and that the ImportedKey's PUBLIC key IS present (locked
render needs it). Canary coverage — the sidecars structurally hold no secret.
Plus a clarifying "// no secret to (de)crypt" note at delete_secret instead of
an encryption TODO.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* test(kittest): disclosure-banner copy coverage (QA-007/Diziet)

Extract the interim at-rest disclosure copy into pure pub fns
(wallet_migration_notice / single_key_migration_notice) + pub
INTERIM_AT_REST_DETAILS, re-exported from context, so the exact copy is
testable without an AppState and i18n-extractable. Both callsites now use them.

New tests/kittest/disclosure_banner.rs (QA-007): Copy A and Copy B each render
as Warning banners naming the wallet/key, the ⚠ icon shows (not color-only),
the two copies are DISTINCT (so set_global's text-dedup keeps both when a wallet
and a key migrate in one session), and all copy (A/B/D) is jargon-free
(no AES/vault/seam/encryption/0600). 4 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* docs: comment hygiene + CLAUDE.md seam pointer + user-story softening (QA-DOC/DOC)

QA-DOC-01: strip ephemeral review IDs from comments I authored in the
secret-seam surface — "Smythe must-fix #3/#4/#5", "Q-HEADLESS", "(F-2)",
"6a2818cd" — keeping the rationale prose. (Pre-existing PROJ-010/TC-W-*/F43/F63
in code outside this PR's diff are left untouched to avoid scope creep.)

QA-DOC-02: drop the "Promoted from…" history line in leak_test_support.rs
(belongs in git, not the module header).

QA-DOC-03: secret_access module-header resolution order now lists the
unprotected fast-path as an explicit step 2 (cache → unprotected → prompt),
matching the three-branch body.

DOC-001: CLAUDE.md wallet_backend bullet now points at secret_seam.rs as the
single secret chokepoint + the TODO(per-secret-encryption): grep convention +
the design dir.

DOC-002: user-stories WAL-006 gains the post-migration no-password-prompt note;
WAL-025 "modern encrypted vault" → "on-device secret vault" (no longer asserts
encryption that is presently absent — the accepted interim regression).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* chore: nightly fmt for the QA-findings batch

Whitespace-only reformat (cargo +nightly fmt --all) of the files touched while
closing the QA findings. No behavioral change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* test(backend-e2e): seed Clear key so TS-SIGN-E2E-01 exercises the InVault JIT path

The shared_identity() fixture registers a wallet-derived identity, so its keys
are PrivateKeyData::AtWalletDerivationPath and take_plaintext_for_vault() (which
migrates only Clear/AlwaysClear) correctly found nothing — the test panicked in
setup before reaching the path under test.

Add materialize_master_key_as_clear(): derive the master key's raw bytes from the
HD seed through the real with_secret(SecretScope::HdSeed) chokepoint (identity
index 0, key 0) and insert_non_encrypted() them as Clear, so the migration carries
a genuine plaintext key into the vault as InVault and the JIT signing path produces
a signature whose bytes match the on-chain master key. The !taken.is_empty()
assertion is unweakened; no signer stub, no mocked broadcast.

Stays #[ignore]: the live broadcast additionally needs a funding wallet that
derives within its rehydrated window (the e2e funding step hit the known
core-wallet gap-window/rehydration limitation, unrelated to the InVault path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6

* chore(deps): repin platform deps to feat/platform-wallet-secret-protection (fb7953ea)

Moves the 4 dashpay/platform branch deps (dash-sdk,
rs-sdk-trusted-context-provider, platform-wallet, platform-wallet-storage)
— and their 23 transitive platform crates, 27 total — from
fix/wallet-core-derived-rehydration@ea0082e6 to
feat/platform-wallet-secret-protection@fb7953ea (PR #3953), establishing
the green baseline for the secret-handling-hardening work.

Done on top of the merge of origin/docs/platform-wallet-migration-design
(ac0c3d98), which brought in #864 (headless masternode/evonode
withdrawals) and #866 (DPNS blocking overlay). The merged DET tree
compiles cleanly against the secret-protection branch — no API breakage.

Verified green:
  cargo build --all-features
  cargo clippy --all-features --all-targets -- -D warnings
  cargo +nightly fmt --all -- --check

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

* fix(secret): open the vault keyless (file_unprotected) for the Tier-1 baseline

PR #3953 ("platform-wallet-secret-protection") hardened upstream
`SecretStore::file(path, passphrase)` to reject a blank passphrase
(`SecretStoreError::BlankPassphrase`). DET's `open_secret_store` opened the
vault with `SecretString::new("")`, so after the repin every AppContext init
failed at the secret-store open and 7 secret_seam/secret_access tests broke.

Switch to the explicit keyless door `SecretStore::file_unprotected(path)`,
which upstream documents for exactly this model: the vault file itself is
keyless (at-rest floor = owner-only perms) and per-secret confidentiality
comes from Tier-2 object passwords on the individual secrets. Behavior for
the Tier-1 baseline is unchanged from the old empty-passphrase open.

Restores the green baseline at the fb7953ea pin: build/clippy/fmt clean,
the 8 secret_seam/secret_access vault tests pass again.

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

* feat(secret): add Tier-2 seam capability (protected set/get + scheme probe)

Adds the upstream Tier-2 object-password path to the secret seam, the single
coherent encrypt/decrypt chokepoint:

- `put_secret_protected` / `get_secret_protected` seal/unseal a secret under
  its OWN object password via upstream `SecretStore::set_secret/get_secret`
  (Argon2id + XChaCha20-Poly1305). Per-secret, never a shared/per-wallet pw.
- `scheme()` reports the at-rest tier (Absent / Unprotected / Protected) of a
  stored secret WITHOUT the password, via a `get(None)` probe that reads the
  upstream `NeedsPassword` signal.
- The plain `*_secret` methods stay Tier-1 (unprotected) and are documented as
  such; the 3 `TODO(per-secret-encryption)` markers are resolved — the per-
  secret encryption IS the upstream envelope selected by the password arg.

Additive and behavior-preserving: existing Tier-1 callers are unchanged; the
read/migration wiring in SecretAccess lands next. Build/check + the 8
secret_seam/secret_access tests stay green.

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

* feat(secret): adopt Tier-2 per-secret passwords for HD seeds

Routes HD-seed at-rest crypto through the upstream Tier-2 object-password
envelope instead of DET AES-GCM, KEEPING protection rather than downgrading
a password-protected seed to a raw, password-free secret on first unlock.

- `WalletSeedView` gains `scheme()` / `set_protected()` / `get_protected()`:
  a protected seed lives at the `seed.raw.v1` label as a Tier-2 envelope
  (Argon2id + XChaCha20-Poly1305) sealed under that seed's OWN object
  password; an unprotected seed stays Tier-1 raw.
- `scope_has_passphrase` + `decrypt_jit` are now scheme-driven (via the seam
  `get(None)` `NeedsPassword` probe): Unprotected → raw, no prompt; Protected
  → unseal with the JIT-prompted per-seed password; Absent → decode the legacy
  AES-GCM envelope (decode-only reader) and LAZY re-wrap to Tier-2 (protected)
  or raw (unprotected), then drop the legacy envelope. Crash-safe: re-store
  upserts before the legacy delete; the scheme probe prefers the new label.
- `promote_and_maybe_migrate_hd_seed` no longer downgrades; it reports "no
  downgrade" so the unlock callsite's `uses_password=false` finalizer never
  fires — protection is kept and the metadata stays accurate, with no change
  to `wallet_lifecycle.rs`.
- `is_wrong_passphrase` now also catches the upstream `WrongPassword` so a
  Tier-2 unseal with a bad object password re-prompts instead of aborting.

Per-SECRET model: the session cache is plaintext keyed by `SecretScope`, so
remembering seed A never satisfies seed B — each prompts and decrypts only
with its own password. Tests: lazy re-wrap keeps protection (legacy gone,
raw read of a protected seed fails), Tier-2 wrong-password re-ask, and the
A/B different-password isolation. 72 secret tests pass; clippy/fmt green.

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

* refactor(secret): clean keep-protection replacement of the downgrade subsystem (HD seed)

Supersedes the transitional "inert return" approach with a clean excision of
#865's downgrade-to-raw machinery, now that wallet_lifecycle.rs is editable
(user WIP stashed). Protected HD seeds STAY protected (Tier-2 object password);
nothing downgrades them to a raw, password-free secret.

- `wallet_lifecycle.rs`: remove `finish_lazy_seed_migration` (the
  `uses_password=false` downgrade flip + the "protection removed" notice) and
  collapse the two `promote_*` methods into one `promote_hd_seed_with_passphrase`
  (decrypt + cache) — the lazy re-wrap lives in `decrypt_jit`. The unlock
  callsite no longer finalizes a downgrade.
- `finish_unwire::migrate_wallet_meta`: carry the legacy `wallet.uses_password` /
  `password_hint` into `WalletMeta` (it was defaulting `false`). The persisted
  flag is now accurate from cold-start (`true` for a protected wallet) and always
  agrees with the at-rest scheme — no stale/drift-prone metadata.
- `protected_wallet_registers_..._on_unlock` acceptance test rewritten to the
  keep-protection end-state: after the migrating unlock the seed is Tier-2
  (scheme=Protected), a raw read fails, `WalletMeta.uses_password` stays true,
  and a second resolve prompts for the object password.

1009 lib tests pass; clippy -D warnings + fmt clean.

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

* feat(secret): adopt Tier-2 keep-protection for imported single keys

Extends the Tier-2 keep-protection model from HD seeds to imported single keys,
replacing their downgrade-to-raw migration. A protected imported key STAYS
protected under its own object password instead of being re-stored raw.

- `decrypt_jit` / `scope_has_passphrase` (SingleKey) are scheme-driven (seam
  `get(None)` → `NeedsPassword` probe): Protected → unseal with the JIT-prompted
  per-key password; Unprotected → a migrated raw-32 key wins prompt-free, else
  the not-yet-migrated legacy `SingleKeyEntry` blob's `has_passphrase` decides;
  the in-band length-32 check disambiguates raw vs legacy-framed.
- `migrate_single_key_to_raw` → `migrate_single_key_to_tier2`: lazy re-wrap the
  just-decrypted protected key to a Tier-2 envelope under the same password
  (upsert replaces the AES-GCM framing). `has_passphrase` is NOT flipped —
  protection is kept and the index/persisted flag stay accurate.
- `single_key::verify_passphrase` (the unlock-gesture path): re-wraps to Tier-2
  instead of downgrading to raw; returns `()` (no migration bool). The
  `clear_passphrase_flag` finalizer is removed.

Downgrade-disclosure machinery retired (Tier-2 keeps protection, nothing to
disclose): removed `show_single_key_migration_notice` + the
`wallet_migration_notice` / `single_key_migration_notice` / `INTERIM_AT_REST_DETAILS`
copy + their re-exports, and the obsolete `tests/kittest/disclosure_banner.rs`.

Tests: `ts_lazy_03` rewritten to the keep-protection end-state (vault holds a
Tier-2 envelope, password-free read fails, second resolve prompts). 1009 lib
tests pass; clippy -D warnings + fmt clean.

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

* fix(secret): address Smythe Tier-2 review findings (SEC-001/002/004/005)

Smythe verdict on the Tier-2 adoption: SOUND, 0 Critical/High (it closes a prior
HIGH-grade protected-seed downgrade-to-obfuscation). Folds in the carry-forward
findings (SEC-003 — excise the inert downgrade — already landed in 6dafbdab):

- SEC-001 (LOW): GC an orphaned legacy `envelope.v1`. The seed Protected read
  branch (`decrypt_jit`) now best-effort `view.delete(seed_hash)` so an
  `envelope.v1` left behind by a crash/delete-failure during the re-wrap (which
  still decrypts under the seed's OLD password) cannot survive forever — the
  Absent branch, the only other deleter, is never re-entered once Protected. The
  single-key path migrates in-band (same-label upsert) and has no such orphan.
- SEC-004 (LOW): assert the NEGATIVE crypto property. `ts_t2_03` (seed) and the
  new `ts_t2_sk_iso` (single key) now prove A's object password is REJECTED by
  B's envelope (`WrongPassword`) — the upstream per-object-salt + AAD binding —
  not merely that the DET cache is scope-keyed.
- SEC-002 (MEDIUM, doc): record loudly that the keyless `file_unprotected` vault
  is "obfuscation, not confidentiality" for Tier-1 secrets (no-password seeds,
  raw single keys, identity keys rest on file perms ALONE; only Tier-2 object
  passwords give real at-rest confidentiality). Documented at `open_secret_store`,
  reworded `ts_noleak_01` (proves non-literal-plaintext, NOT confidentiality), and
  in the design note's threat-model residual.
- SEC-005 (info): one-line note in `seed_envelope.rs` — the legacy reader is
  decode-only / local owner-only vault, uses bincode 2.x; the RUSTSEC-2025-0141
  bincode 1.3.3 is a transitive dep. No code change.

1010 lib tests pass; clippy -D warnings + fmt clean.

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

* docs(migration): note the wallet.uses_password/password_hint schema invariant

Smythe's schema-robustness query on `migrate_wallet_meta`'s new SELECT (it reads
`uses_password`/`password_hint` unprobed, unlike the probed optional
`core_wallet_name`). Verified + documented the invariant rather than adding a
needless probe: the wallet-seed migration (`migrate_wallet_seeds_rows_from_conn`)
already SELECTs both columns unconditionally and runs FIRST over the same `wallet`
table at the same cold-start, so any schema lacking them fails there before the
meta pass. The unprobed read here is therefore exactly as robust as the shipped
seed migration; `core_wallet_name` stays probed because it is the one droppable
column. Comment-only — 1010 lib tests pass, clippy -D + fmt clean.

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

* fix(test): eliminate register_wallet_from_seed race in cold-boot test

The `ensure_identity_funding_accounts_succeeds_on_cold_booted_watch_only_wallet`
test failed in CI (1000+ parallel tests) with:

  WalletBackend { source: WalletNotFound("70dba4c1d8c5c3854aa02c8f15e0fcd66df6661841d7ae822891fa21aaef48d2") }

Root cause: the test wired the backend BEFORE calling register_wallet, which
caused register_wallet_upstream to spawn a background subtask that called
create_wallet_from_seed_bytes concurrently with the test's own explicit
register_wallet_from_seed call.

The upstream register_wallet (inside create_wallet_from_seed_bytes) inserts
into wallet_manager (step A) and into self.wallets (step B) with async work
in between (persister.store + load_persisted + initialize). A concurrent
caller that lands between A and B sees WalletAlreadyExists from step A,
then get_wallet returns None (step B not yet complete) →
resolve_registered_wallet returns WalletNotFound. Under CI load this window
is reliably hit.

Fix: register the wallet BEFORE wiring the backend. register_wallet_upstream
finds no backend and returns early without spawning the subtask. The backend
is then wired, and the explicit register_wallet_from_seed call runs
race-free (no concurrent subtask competing for the same wallet slot).

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

* fix(wallet-backend): keep Tier-2 protected wallets visible at cold boot and stop plaintext key writes

Addresses PR #865 review findings on the secret-storage seam.

A (BLOCKER): identity write paths no longer serialize plaintext keys.
insert/update_local_qualified_identity (and the alias re-encode) now route
through encode_identity_blob_vault_first — the write-path twin of the load
migration: plaintext keys go into the vault FIRST, the persisted blob carries
only InVault placeholders, and a vault-write failure aborts the write (never
lands Clear/AlwaysClear bytes in det-app.sqlite).

B (HIGH) / C (BLOCKER): cold-boot hydration no longer drops Tier-2-protected
wallets. reconstruct_wallet (HD seed) and rebuild_wallet (imported single key)
branch on the at-rest SecretScheme before reading the secret. A Protected
secret rehydrates CLOSED from the public sidecar (xpub / public_key_bytes)
instead of propagating NeedsPassword as fatal, so a keep-protection-migrated
wallet stays in the picker across launches.

D: the HD Absent-branch legacy-envelope delete is now best-effort (log, don't
propagate), matching the Protected branch — a transient delete failure no
longer fails an otherwise-successful unlock.

E: the eager no-password seed migration wraps the extracted 64-byte seed in
Zeroizing so the stack copy wipes on drop.

F: resolve_registered_wallet tolerates the registration TOCTOU window with a
bounded re-poll before declaring a wallet missing; the fund-routing xpub gate
is unchanged.

G: present-but-malformed identity-key bytes map to SecretDecryptFailed (with a
warn) in both the display and sign tasks, distinct from genuinely-absent
IdentityKeyMissing.

I/J: refreshed stale doc-comments (single-key has_passphrase, WalletMeta
uses_password, wallet_seed_store header) to describe the Tier-2 keep-protection
shape, and stripped ephemeral review-finding IDs from secret-path comments.

Regression tests cover A, B, and C.

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

* fix(wallet-backend): seal fresh protected single-key imports Tier-2, typed malformed-identity-key error, skip needless keystore clone

Follow-up to PR #865 review on the secret-storage seam.

Fresh protected single-key imports now seal Tier-2 at import time instead of
writing the legacy DET AES-GCM SingleKeyEntry envelope and migrating lazily on
first unlock. import_wif_with_passphrase routes the protected branch through the
seam's put_secret_protected, so the storage chokepoint is a single shape from
import onward. raw_key_bytes and verify_passphrase branch on the at-rest
SecretScheme: a Tier-2 key surfaces SingleKeyPassphraseRequired on a direct
read and is verified by unsealing (wrong password -> SingleKeyPassphraseIncorrect,
no oracle), while the legacy decode + lazy re-wrap path is retained for
pre-existing installs. The legacy AES-GCM SingleKeyEntry remains a decode-only
reader. sec_002_import_with_passphrase_encrypts_payload tightens to assert
SecretScheme::Protected at import; ts_lazy_03 now starts from a directly-written
legacy entry so the legacy->Tier-2 migration stays covered.

Present-but-malformed identity-key bytes map to a new typed
TaskError::IdentityKeyMalformed (jargon-free "stored but unreadable / re-import
to refresh") in both the display and sign tasks, replacing the off-domain
SecretDecryptFailed ("recovery phrase") message and staying distinct from the
genuinely-absent IdentityKeyMissing.

migrate_keystore_to_vault and encode_identity_blob_vault_first skip the
KeyStorage clone in the steady-state (already-InVault) case via a new
KeyStorage::has_plaintext_for_vault probe, so cold-boot load and identity
re-saves no longer clone per identity for no benefit.

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

* docs(secret-seam): correct drifted docs to Tier-2 keep-protection reality

- 01-ux-disclosure.md: full rewrite — the previous doc described the
  retired drop-protection design (password downgraded to file-permission
  only, one-time disclosure notices). Replaced with the Tier-2
  keep-protection reality: protected secrets re-wrap under the same
  password, uses_password/has_passphrase stay true, migration is silent,
  no disclosure notices. Removed candy tally and agent byline.

- 02-test-spec.md: update TS-LAZY-01/02/03 expected outcomes to
  Tier-2: scheme stays Protected, uses_password/has_passphrase stay true,
  second unlock still prompts (ask_count == 1). Added source-test names
  (ts_t2_01_*, ts_lazy_03_*). Removed machine-local plan paths, Marvin's
  note, and future-tense TDD framing. Added section-5 note that raw seam
  applies only to unprotected secrets.

- user-stories.md WAL-006: replace false bullet ("no longer prompts,
  one-time notice") with the truth: Tier-2 re-seal, wallet keeps
  prompting, migration is silent.

- CLAUDE.md wallet_backend/ bullet: remove dead TODO(per-secret-encryption)
  grep pointer (zero hits); describe present state — put_secret_protected/
  get_secret_protected implemented; keyless-vault residual is deferred tier.

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

* feat(wallet-backend): optional per-identity at-rest encryption for identity keys (SEC-001)

Identity keys default to keyless (Tier-1 raw, prompt-free) so headless/MCP signing of a non-opted-in identity is unchanged byte-for-byte. A user may opt in per identity to seal that identity's keys Tier-2 over the existing seam (Argon2id + XChaCha20-Poly1305) — no new crypto.

The at-rest vault scheme is the single source of truth: scope_has_passphrase probes SecretSeam::scheme for the identity-key label (Protected -> prompt, Unprotected -> prompt-free, Absent -> IdentityKeyMissing), and decrypt_jit gains a symmetric Tier-2 arm. A protection-aware IdentityKeyView::store refuses a keyless write over a Protected label (IdentityKeyProtectionDowngrade), with store_unprotected as the deliberate opt-out downgrade. New crash-safe, idempotent migrations IdentityTask::Protect/UnprotectIdentityKeys re-seal an identity's keys keyless<->Tier-2 under one per-identity password. A display-only IdentityMeta sidecar carries the password hint + prompt copy (never the gate), seeded into the chokepoint's identity prompt index at identity load.

UI: a collapsible 'Key Protection' section on the Key Info screen (default closed) with danger-gated opt-in (new password + confirm + strength + hint) and opt-out (verify) flows; PassphraseModalConfig gains remember_label so the sign-time prompt says 'key', not 'wallet'. Opted-in signing prompts just-in-time; headless yields SecretPromptUnavailable. Per-identity password isolation (TS-T2-IK-ISO twins TS-T2-SK-ISO).

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

* fix(wallet-backend): seal new keys on a protected identity Tier-2, never keyless (SEC-001)

Smythe MUST-FIX: a key added to a password-protected identity slipped through the per-label downgrade guard (a new key_id is scheme Absent), so AddKeyToIdentity -> insert_non_encrypted(Clear) -> encode_identity_blob_vault_first -> store_all wrote it Tier-1 keyless — a fully-capable signing key in plaintext on an identity the user believed protected.

Two layers close it: (1) an identity-level fail-closed guard in encode_identity_blob_vault_first / migrate_keystore_to_vault refuses to move resident plaintext into the vault when the identity already has any Tier-2 key (IdentityKeyProtectionDowngrade / new KeystoreMigration::ProtectedSkipped), so a keyless write is impossible. (2) add_key_to_identity now seals the new key Tier-2 via SecretAccess::seal_new_identity_key, which prompts once, verifies the password against an existing protected key (so the identity stays under one password, with the standard wrong-pass re-ask), seals the new key, and marks it InVault before the save — headless yields SecretPromptUnavailable (fail closed; signing also fails closed earlier). KeyStorage::mark_in_vault performs the post-seal transition.

SEC-002 (SHOULD-FIX): protect_identity_keys now re-enforces the password policy in the backend (validate_protection_password) so a non-UI caller cannot seal under a too-short password. SEC-003/SEC-004 tracked as code comments (store-guard TOCTOU bounded by the single-writer lock + UI in-flight gate; pre-opt-in plaintext may persist in freed filesystem blocks until reused).

Tests: secret_access seal-new-key (seals Tier-2 under verified password / headless fails closed with no write / wrong-pass re-asks); identity_db encode+migrate refuse keyless on a protected identity; protect_identity_keys rejects a weak password.

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

* fix(identity): fail closed before broadcast when adding a key to a protected identity (SEC-001 O-2)

Adding a key to a password-protected identity used to seal the new key
Tier-2 (or fail closed) only during LOCAL persist, which runs AFTER the
on-chain AddKeys broadcast. A headless add therefore broadcast the state
transition on-chain and only then failed closed locally (no password) —
leaving the key on-chain but never persisted by DET: an on-chain/local
divergence.

Move the protected-identity precondition BEFORE any on-chain side effect.
`add_key_to_identity` now determines up front whether the identity is
protected (`protected_identity_verify_scope`) and, if so, prompts for and
VERIFIES its object password before building or broadcasting the state
transition. Headless (`NullSecretPrompt` → `SecretPromptUnavailable`) or a
wrong password returns the typed error before the broadcast, so no state
transition is ever sent. The seal then runs after the broadcast with the
already-verified password — a single prompt, split across the broadcast.

`SecretAccess::seal_new_identity_key` is split into
`verify_identity_object_password` (prompt + verify, returns an opaque
`VerifiedIdentityPassword` that zeroizes on drop) and
`seal_new_identity_key_with_password` (no prompt); the original composes
the two and keeps its tests. The d965ca50 encode fail-closed guard
(`IdentityKeyProtectionDowngrade`) stays as the defense-in-depth backstop.

Also: O-1 — `mark_in_vault`'s bool return is now checked and warns on an
unexpected miss (the encode guard still backstops it). O-3 — document that
a Mixed identity fails closed on a plain re-save until "Finish protecting"
reseals the remaining keys (intended secure behavior).

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

* fix(identity): harden SEC-001 identity-key paths (r2 review)

Address four thepastaclaw findings on the SEC-001 identity-key code at
fcf6da15:

- BLOCKING: `seal_identity_keys` now verifies the supplied password opens
  every already-`Protected` key BEFORE sealing any keyless one. A
  Mixed-state "Finish protecting" re-run with a different password is
  rejected up front with `IdentityKeyPassphraseIncorrect` and zero state
  changes, so an identity can never be split across two passwords.
- `get_identity_by_id` now mirrors the bulk-load vault migration, so the
  single-get read path (and the SEC-001 protect/unprotect tasks that use
  it) migrates legacy resident `Clear`/`AlwaysClear` keys to the vault on
  read instead of returning and re-persisting plaintext.
- A post-broadcast seal failure in `add_key_to_identity` now surfaces the
  typed, actionable `IdentityKeyAddedButNotSaved` (key is on-chain; retry
  after freeing disk space), preserving the upstream cause in the source
  chain — never a silent loss and never a keyless-write fallback.
- The three prompt-meta setters recover a poisoned lock
  (`unwrap_or_else(|p| p.into_inner())`), matching `forget`/`forget_all`,
  so prompt-copy metadata can self-heal after a panicked reader instead of
  silently freezing.

Adds regression tests for each (the blocker's split-prevention, read-path
migration via an offline AppContext, and the typed orphan-error mapping).

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

* docs(single-key): correct has_passphrase on-disk-shape doc to Tier-2-direct

The has_passphrase field doc claimed fresh protected imports use a legacy
AES-GCM envelope migrated on first unlock; imports seal Tier-2 directly at
import time. Align the field doc with the function docstring.

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

* test(dashpay-e2e): use real curve points in tc_045 fixture (QA-008)

The bumped secp256k1 now validates curve membership on
`PublicKey::from_slice`, and `[0x02; 33]` / `[0x03; 33]` are not points on
the curve, so tc_045 paniced with `Secp256k1(InvalidPublicKey)` before it
could test anything. Swap the hand-written bytes for two deterministic
pubkeys derived from fixed secret keys — stable across runs, valid on the
curve, and matching the file's existing secret-key→pubkey idiom. Pure
fixture fix; no product behavior involved.

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

* fix(wallet-backend): return WalletNotFound for an unknown seed hash (QA-002)

`GenerateReceiveAddress` for a seed hash that matches no wallet returned the
transient `WalletNotLoaded` ("still loading, wait and retry") instead of
`WalletNotFound`. The two mean very different things to a user: one is a
permanent "this wallet does not exist", the other a momentary boot state.

`resolve_wallet` cannot tell them apart on its own — a missing `id_map`
entry covers both — and ~24 callers rely on its `WalletNotLoaded` for the
genuine cold-boot case, so it must stay. Instead, resolve the existence
question one layer up in `generate_receive_address`, where the DET-side
wallet store (`self.wallets`) is the source of truth: unknown wallet ->
`WalletNotFound`; known-but-not-yet-loaded -> `WalletNotLoaded`. This mirrors
the sibling `generate_platform_receive_address`, which already does exactly
this. Confirmed against design spec TC-019.

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

* test(core-e2e): expect SingleKeyWalletsUnsupported in tc_009 (QA-001)

test_tc009 asserted `RefreshSingleKeyWalletInfo` returns
`OperationRequiresDashCore` in SPV mode — but single-key wallets are
intentionally unsupported this release (PROJ-007 / single-key-mock.md
Decision #7: "Every operation returns `Err(TaskError::SingleKeyWalletsUnsupported)`",
and refresh is one of those operations). The product correctly returns
`SingleKeyWalletsUnsupported`, and the sibling TC-003 already asserts that —
so test_tc009 was simply stale and contradicted both. Align its expectation
(and its comments) with the by-design behavior. Also corrected TC-003's own
header comment, which still described the superseded
`OperationRequiresDashCore` outcome while its assertion already checked the
right variant.

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

* fix(identity): compute a meaningful top-up fee after a backend reload (QA-006)

A wallet-funded identity top-up reported `actual_fee == 0` after a backend
reload. The fee was derived inline as
`amount*1000 - (new_balance - balance_before)`, where `balance_before` came
from the passed-in (post-reload, stale) `QualifiedIdentity`. When that cached
balance lags the real platform balance, the apparent increase exceeds the
minted credits and `saturating_sub` collapses the fee to zero — physically
impossible, since a top-up can never grow the balance by more than the asset
lock mints.

Move the computation into `model/fee_estimation.rs` (DET policy: no inline fee
math) as `resolve_identity_topup_actual_fee`, and have it fall back to the
deterministic estimate whenever the balance delta yields a zero fee — the
reliable signal that `balance_before` was stale. The happy path is unchanged
(a consistent delta still reports the real processing fee). Adds unit tests
for both the consistent-delta and stale-balance branches.

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

* test(spv-e2e): assert restart-in-place reconnect contract (QA-003)

The B-reconnect test asserted `wallet_backend().is_err()` after `stop_spv()`,
a leftover from the superseded drop-and-reopen design. The current lifecycle
is restart-in-place by intent: `stop_spv` calls `stop_in_place()` and KEEPS
the backend (and its `Arc<SqlitePersister>`) wired, so the next Connect
fast-paths on the populated slot and restarts the SAME instance — the
persister DB is never closed/reopened, making `AlreadyOpen` impossible by
construction. This is exactly what the offline unit tests
`stop_spv_in_place_keeps_backend_and_disconnects_indicator` and
`reconnect_restart_in_place_reuses_backend` lock in, and the latter even
names this e2e test as its live-network counterpart.

Update the test to assert the real contract over a live network: backend
stays wired and unstarted after `stop_spv`, and the reconnect reuses the same
instance (`Arc::as_ptr` equality) with sync restarted. Header comment and the
reconnect failure message rewritten to describe restart-in-place. Product
code is correct as-is; the assertion was stale.

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

* fix(wallet): gate sends on spendable balance, not confirmed (QA-010)

Upstream classifies a UTXO as `confirmed` only once it is in a block, chain-
locked, or flagged instant-locked locally; until then — including the window
after an IS-lock but before the local flag is applied — it sits in
`unconfirmed`. Coin selection draws from `spendable()` (confirmed +
unconfirmed), and the "Max" button already reserves against `spendable()`, but
several send paths still gated/validated on `confirmed`. The result: "Max"
could exceed the validation, and sends coin selection would happily fund were
rejected as "Insufficient confirmed balance" while funds showed as pending.

Align the UI with the coin selector:
- `send_screen::get_core_balance` -> `spendable()` (4 amount validations + the
  source-selector display).
- wallets-screen send dialog validation -> `spendable()` (and drop the
  now-misleading "confirmed" from the message).
- dashpay send_payment balance display + Max -> `spendable()`.

No change to actually-correct sites: `snapshot_has_balance` already counts
confirmed||unconfirmed, the MCP balances tool exposes all three buckets
distinctly, and `.total` displays are intentional.

Harness: `wait_for_spendable_balance` polled `.confirmed`, contradicting its
own "spendable" contract, so it timed out whenever funding landed as IS-locked
/ unconfirmed. Poll `.spendable()` (the coin-selector set) and report it in the
timeout diagnostic.

Audit note: at the pinned platform-wallet rev (fb7953e / key-wallet 981e97f)
IS-locked-FLAGGED UTXOs are classified `confirmed`, not `unconfirmed` — the
balance has no separate IS-locked bucket. So `spendable()` (= confirmed +
unconfirmed) is the correct, safe gate, not an over-count.

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

* test(identity-e2e): poll for key visibility after broadcast (QA-004)

`identity_in_vault_sign` and `z_broadcast_st_tasks::tc_066` slept a fixed ~1s
after broadcasting an IdentityUpdate, then re-fetched once and asserted the new
key was visible. That single delay races DAPI propagation — the node serving
the re-fetch may not have processed the block yet — so the tests failed
spuriously even though the broadcast (and SEC-001 signing) succeeded.

Replace the fixed sleep with a bounded poll: re-fetch the identity until the
new key appears or a ~10s deadline passes, then assert. Test robustness only;
no product change.

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

* test(harness): retry transient wallet registration with backoff (QA-013)

The framework-wallet register and `create_funded_test_wallet` both called
`register_wallet` exactly once and panicked on any error. Under the shared-
runtime backend-e2e harness the fail-closed sidecar writes (`WalletSeedStorage`
/ `WalletMetaStorage`) can briefly lose a SQLite race, and registration can
surface the typed transient `WalletBackend` ("retry in a moment") signal — a
single attempt then aborts init and masks the test under exercise
(identity_create / identity_cold_boot).

Add `register_wallet_with_retry`: bounded ~30s retry with backoff on the
transient variants only (`WalletBackend`, `WalletBackendNotYetWired`,
`WalletSeedStorage`, `WalletMetaStorage`); permanent errors surface
immediately, and `WalletAlreadyImported` is returned as-is so the framework
path keeps its idempotent-reuse branch. Wired into both registration sites.

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

* test(wallet-e2e): mark tc_012 address-advance assertion PENDING (QA-005)

QA-005 disposition is DEFER: "same address on consecutive GenerateReceiveAddress
calls" is correct, funds-safe BIP-44 keypool behavior (upstream `next_unused`
returns the lowest UNUSED address until it is used on-chain). The fresh-each-call
UX needs a reserve-on-hand-out API that does not exist in the pinned upstream.

- Annotate tc_012's `assert_ne!(address1, address2)` as PENDING (commented out
  with a soft observation log) so the test passes on the current funds-safe
  behavior. tc_012b's gap-window funds-safety assertion stays active.
- Enhance the existing `TODO(PROJ-015)` in `wallet_backend/mod.rs` to cite the
  fix's 3-layer propagation: dashpay/rust-dashcore#818 (`next_unused_and_reserve`,
  ready-for-review) → platform surface (`CoreWallet::next_receive_address_and_reserve_for_account`)
  → DET dep bump + switch `next_receive_address` to the reserving variant.
  Re-enable the `assert_ne!` once that lands.

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

* docs(wallet-lifecycle): correct stop_spv rustdoc to restart-in-place (QA-015)

The `stop_spv` rustdoc still described the superseded drop-and-reopen design
("drop the wired wallet backend", "WalletBackend::shutdown", "Unwire the
backend"), none of which the implementation does. It calls `stop_in_place()`
and KEEPS the backend (and its `Arc<SqlitePersister>`) wired, re-arming the
start latch and coordinator gate so the next same-network Connect restarts the
SAME instance — which is exactly why a reconnect cannot hit
`WalletStorageError::AlreadyOpen` (the persister is never closed/reopened).

Rewrite the doc to describe the actual restart-in-place semantics and note that
full teardown (`WalletBackend::shutdown`, dropping the backend + releasing the
persister) happens only on the network-switch and app-close paths, never here.
Companion to the QA-003 test/e2e-header fixes. Doc-only; no behavior change.

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

* test(identity-e2e): widen cold-boot funding to clear top-up minimum (QA-016)

`cd_cold_boot_identity_register_and_topup` funded 30M duffs, which after
scenario C's asset lock + registration fees left 4,999,703 duffs — 297 below
the 5M scenario-D top-up minimum, so scenario D failed on a buffer shortfall
(the watch-only-no-private-key bug is already fixed; scenario C passes). Bump
the funding to 35M so both transactions clear their network fees. Test-only.

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

* test(dashpay-e2e): defer dashpay backend-e2e module pending upstream (platform#3841)

The dashpay backend-e2e tests fail because upstream `platform-wallet` dashpay
support is incomplete. The completion lands in dashpay/platform#3841
("fix(platform-wallet)!: complete dashpay", shumkov, branch
feat/dashpay-m1-sync-correctness); we retest once it merges and the DET
platform-wallet dep is bumped.

- Comment out `mod dashpay_tasks;` in main.rs with a TODO(dashpay-e2e) citing
  #3841 and the affected tests (tc_032/033/036/037/041/043/044/045/046).
- Add a matching deferral note to the dashpay_tasks.rs module doc.

This removes 9 dashpay tests AND their SharedDashPayPair registration burst from
the run. The QA-008 tc_045 fixture fix stays in the file, dormant until
re-enabled.

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

* test(harness): widen funded-wallet SPV-pickup budget to 120s (QA-017)

QA-013 was verified INNOCENT against the re-run log: the
"retrying after backoff" warning logged 0 times, so `register_wallet_with_retry`
never fired — all 17 timeouts were in `wait_for_wallet_in_spv` (the 30s SPV-pickup
wait), downstream of the retry wrapper.

Root cause is throughput saturation: the other fixes (and, before deferral, the
dashpay tests) unmasked more funded-wallet registrations, and the suite runs
serially (`--test-threads=1`), so as wallets accumulate in the upstream manager
each later pickup round (bloom-filter rebuild + re-sync) exceeds the tight 30s
budget. Give `create_funded_test_wallet`'s `wait_for_wallet_in_spv` the same 120s
headroom the framework wallet already uses, via a named
`FUNDED_WALLET_REGISTRATION_TIMEOUT`. Concurrency throttling is unnecessary —
the run is already serial. Test-only.

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

* fix(identity): fail closed when opt-in protection leaves resident plaintext keys

protect_identity_keys could emit IdentityKeysProtected{count:0} when the
silent get_identity_by_id vault migration failed (VaultWriteFailed), leaving
Clear keys with Absent vault labels that seal_identity_keys skips. Guard the
protect boundary with a typed error so the user retries instead of believing
the identity is sealed.

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

* test(identity): prove the protect fail-closed guard is wired into the task (QA-001)

The guard's wiring was unverified: deleting the call passed every test because
the only fail-closed test invoked the helper directly and the end-to-end test
was the happy path. Extract the post-load protect logic into
protect_loaded_identity_keys (called by protect_identity_keys after
get_identity_by_id) and add a test that drives it on a qi carrying resident
plaintext, asserting IdentityKeyProtectionIncomplete. Deleting the guard line
now turns that test red (it returns IdentityKeysProtected{count:0}).

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

* fix(fee-estimation): fall back to estimate when balance_before is stale-HIGH (RUST-001)

The real-fee branch was gated only on `delta_fee == 0` (stale-LOW). When
`balance_before` is stale-HIGH (`balance_after <= balance_before`),
`balance_increase` saturates to 0 and `delta_fee` equals the full minted
amount, producing a wildly wrong "fee" (e.g. 5 M duffs → ~5 B-credit fee).

Gate the real-fee branch on `0 < delta_fee < expected_credits` so both
extremes fall back to the deterministic estimate. Add a unit test for the
stale-HIGH case.

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

* fix(identity-db): zeroize rollback clone after successful vault migration (SEC-002)

`before = qi.private_keys.clone()` holds raw identity private-key bytes
(Clear/AlwaysClear) as a rollback guard. On the success path it was dropped
UN-zeroized, leaving plaintext on the freed heap.

Call `before.take_plaintext_for_vault()` immediately after the vault write
succeeds — the method already zeroizes each `[u8; 32]` in-…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge-conflict The PR conflicts with the target branch. ready-for-review CodeRabbit has approved this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants