Skip to content

fix(backend-e2e): stabilize PR #860 platform-wallet e2e suite + SEC-001 hardening - #867

Merged
lklimek merged 73 commits into
docs/platform-wallet-migration-designfrom
security/secret-handling-hardening
Jun 26, 2026
Merged

fix(backend-e2e): stabilize PR #860 platform-wallet e2e suite + SEC-001 hardening#867
lklimek merged 73 commits into
docs/platform-wallet-migration-designfrom
security/secret-handling-hardening

Conversation

@lklimek

@lklimek lklimek commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Why this PR exists

  • Problem: PR feat: rewrite Dash Evo Tool onto the new platform-wallet #860 (the platform-wallet backend rewrite) shipped a large new wallet backend, but its backend-e2e suite was red against live testnet — a mix of real regressions, stale test expectations, and test-infra gaps that made it impossible to tell genuine bugs from noise. We could not gate the rewrite on a suite we couldn't trust.
  • What breaks without it: feat: rewrite Dash Evo Tool onto the new platform-wallet #860 ships with a real funds bug — Core sends were gated on the confirmed balance while the coin selector and the "Max" button use spendable, so users get "insufficient confirmed balance" rejections on funds they can actually spend; plus wrong error semantics (unknown wallet → WalletNotLoaded instead of WalletNotFound), a top-up fee that misreports after a backend reload, and an e2e suite too flaky/red to validate the migration.
  • Blocking relationship: Stacked atop PR feat: rewrite Dash Evo Tool onto the new platform-wallet #860 (docs/platform-wallet-migration-design). Stabilizes its e2e suite and folds in the SEC-001 at-rest identity-key encryption hardening.

What was done

Product fixes

  • QA-002 — unknown seed hash now returns typed WalletNotFound (was WalletNotLoaded).
  • QA-006 — meaningful top-up actual-fee after a backend reload, centralized in model/fee_estimation.rs (+ a stale-HIGH balance_before guard so it can't report the full minted amount as the "fee").
  • QA-010 — gate Core sends on spendable balance (= confirmed + unconfirmed), matching the coin selector + Max button, across send_screen, dashpay send_payment, and the send dialog. Fixes false "insufficient funds" rejections on IS-locked/spendable funds.
  • SEC-001 hardening (reconciled from the feat: rewrite Dash Evo Tool onto the new platform-wallet #860 branch and extended): fail-closed when opt-in identity-key protection would leave resident plaintext; defensive guard for legacy Encrypted keys; zeroize the migration rollback clone.

Test / harness fixes

  • QA-003 restart-in-place reconnect contract; QA-004 poll for key visibility (vs fixed sleep); QA-005 tc_012 address-advance marked PENDING; QA-008 real curve-point fixture; QA-013 bounded registration retry; QA-015 stop_spv rustdoc + inline comment; QA-016 cold-boot funding buffer; QA-017 funded-wallet SPV-pickup budget 30s→120s.
  • Dashpay backend-e2e module deferred pending upstream dashpay/platform#3841.

Quality — a parallel grumpy-review (security / Rust / consistency) returned 9 findings, all LOW; 8 were applied (fee fallback, key zeroize, error copy, stale comments/docs, test asserts).

Testing

  • cargo build --all-features, cargo clippy --all-features --all-targets -- -D warnings, cargo +nightly fmt — all clean.
  • Unit tests pass (incl. 18/18 fee, 12/12 identity-protect).
  • Backend-e2e final run vs live testnet (3 healthy DAPI nodes): 54 passed / 12 failed. All 12 reds are environmental/upstream, not code regressions:
    • QA-009 asset-lock IS finality (tc_004, tc_018) + fixture cascade (2): upstream IS-proof not arriving within the hardcoded 300s wait (unchanged from old DET) — testnet Core-P2P/IS degradation.
    • 7× SPV wallet-registration timeout at 120s: testnet Core-P2P degradation (budget bump cut the cascade ~18→7).
    • tc_014: pre-existing environmental failure (DAPI exhaustion at the withdrawal step).
  • SEC-001 reconciled commits independently security-audited: fail-closed verified, no bypass, no secret leak.

Known gaps (deferred with upstream references)

  • QA-005 receive-address advancement → dashpay/rust-dashcore#818 (next_unused_and_reserve); tc_012 advance assertion PENDING.
  • dashpay backend-e2edashpay/platform#3841 ("complete dashpay"); module gated out, re-enable on land.
  • QA-009 asset-lock IS finality → upstream platform-wallet SPV IS-detection / testnet IS health; not DET-tunable (300s hardcoded upstream).
  • Two LOW pre-existing at-rest-residual edges (SEC-001-B/C) tracked for follow-up.

Reviewing this PR

Because #865 was squash-merged into the base, the commit list looks long (~60 commits) — but the Files changed diff is clean: only the e2e-suite stabilization + the grumpy-review fixes, since the SEC-001 content already matches the base. Please review via Files changed, not the commit list.

Attribution

🤖 Generated with Claude Code

🤖 Co-authored by Claudius the Magnificent AI Agent

Summary by CodeRabbit

  • New Features

    • Wallet send screens now show and use spendable balance, so “Max” and send validation better match what can actually be spent.
    • Identity protection now gives clearer guidance when protection can’t be completed, including a new message for identities stored in an older format.
  • Bug Fixes

    • Improved identity protection safety to avoid partial changes on incorrect passwords or legacy key formats.
    • Receive address generation now checks that the wallet exists before continuing.
    • SPV reconnect now preserves the same wallet backend across restarts for smoother recovery.

lklimek and others added 30 commits June 19, 2026 17:29
…e 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
…(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
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
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
…hema-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
- 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
…ess (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
…rites (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
…ete (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
…sure (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
- 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
…decars

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
…oss (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
…(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
… (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
…ey 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
…ak (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
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
… (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
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
…ault 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
…esign' into security/secret-handling-hardening
…ction (fb7953ea)

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… 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>
…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>
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>
…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>
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>
Smythe verdict on the Tier-2 adoption: SOUND, 0 Critical/High (it closes a prior
HIGH-grade protected-seed downgrade-to-obfuscation). Folds in the carry-forward
findings (SEC-003 — excise the inert downgrade — already landed in 6dafbda):

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

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

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

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>
…grade (SEC-001)

The opt-out unseal looped per-key (get_protected then store_unprotected),
so a password opening only a prefix of the protected keys would downgrade
that prefix to keyless before aborting on the first key it couldn't open --
a silent partial protection downgrade. It relied on an external invariant
(one password per identity) plus BTreeSet ordering rather than guarding
itself.

Add the SAME all-keys preflight the opt-in seal already runs
(verify_existing_protection_password) at the top of unseal_identity_keys,
before any store_unprotected write. Opt-out is now atomic by construction:
a mismatch returns IdentityKeyPassphraseIncorrect up front with zero
mutation, mirroring opt-in. The secret-seam ordering (vault write before
sidecar delete) is untouched -- this only adds a read-only preflight.

Resolves thepastaclaw's PR #867 finding. New test
unseal_mixed_password_aborts_without_partial_downgrade seals two keys under
different passwords and proves an opt-out that can open only the first key
leaves both protected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@lklimek
lklimek requested a review from thepastaclaw June 26, 2026 09:48

@thepastaclaw thepastaclaw left a comment

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.

Code Review

Latest delta (d63ad9fb98515d) is a focused commit that resolves the prior blocking SEC-001 finding: unseal_identity_keys now calls verify_existing_protection_password as a preflight before any per-key downgrade, mirroring seal_identity_keys, and a Mixed-password regression test (unseal_mixed_password_aborts_without_partial_downgrade) proves the CWE-460 partial-downgrade window is closed for opt-out. No carried-forward prior blockers remain (only one prior finding was in flight; both general agents and both rust-quality agents agree it is FIXED). Two new in-scope suggestions hold up under verification: (1) the QA-006 top-up fee helper only falls back at the exact stale-LOW / stale-HIGH boundaries, so partial-stale offsets still produce a wrong displayed actual fee; (2) the SEC-001 plaintext guard now triggers for legacy Encrypted keys too, but reuses an error whose recovery copy (close/reopen) cannot resolve that branch, leaving the user in a retry loop. Neither is a blocker — the first is a UX-accuracy gap in a display path, the second a copy mismatch for a no-current-producer code path — so review_action is COMMENT.

🟡 2 suggestion(s)

1 additional finding(s) omitted (not in diff).

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/backend_task/identity/protect_identity_keys.rs`:
- [SUGGESTION] src/backend_task/identity/protect_identity_keys.rs:174-179: Legacy `Encrypted` keys hit a guard whose recovery action cannot help them
  `reject_resident_identity_plaintext` now returns `TaskError::IdentityKeyProtectionIncomplete` for BOTH residual plaintext (`has_plaintext_for_vault`) AND legacy `Encrypted` keys (`has_encrypted_legacy_keys`). The error's user-facing copy in `src/backend_task/error.rs:280-283` is `"Some of this identity's keys are not fully protected yet. Close and reopen the application, then try protecting this identity again."` That action is correct for the residual-plaintext branch (the eager load-path vault migration retries on next start), but the doc comments on `has_encrypted_legacy_keys` describe these as decode-only with no current producer and no current vault migration path — so closing and reopening will keep hitting the same guard, leaving the user in a retry loop with no self-service exit. Per the project's error-message rules every message must include a concrete action the user can take themselves. Either split the legacy-encrypted case into its own `TaskError` variant with copy that points to a path the user can actually take (e.g. recreate the identity from the wallet, or that this identity needs a developer-assisted migration), or drop the `has_encrypted_legacy_keys` branch from this guard if the eager load-path will already keep these keys from reaching protect.

In `src/model/fee_estimation.rs`:
- [SUGGESTION] src/model/fee_estimation.rs:436-452: Top-up actual-fee helper only catches exact-boundary stale balances
  `resolve_identity_topup_actual_fee` falls back to the estimate only when `delta_fee == 0` (stale-LOW saturates the increase to the full mint) or `delta_fee == expected_credits` (stale-HIGH saturates the increase to zero). Any partial stale offset — for example a cached `balance_before` that is stale-LOW by less than the real fee, or stale-HIGH by less than the mint amount — passes the `0 < delta_fee && delta_fee < expected_credits` guard and is returned as the "actual" fee even though the subtraction is invalid unless `balance_before` is the true pre-top-up value. Concretely, for a 500 000-duff top-up that really paid a 3 000 000-credit fee but whose cached `balance_before` is stale-LOW by 1 000 000 credits, the helper reports 2 000 000 credits as the actual fee. The doc comment frames the staleness mode as "collapses the computed fee to zero", which matches the implementation but understates the gap: non-boundary stale offsets are silently trusted. Impact is bounded (display only, no funds at risk) but the helper does not fully deliver QA-006's stale-safe guarantee. Consider either refreshing `balance_before` from the live source before the call, sanity-checking the delta against the deterministic estimate's plausible range, or documenting the residual so the UI knows the value is only trustworthy when caches are coherent.

Comment thread src/model/fee_estimation.rs Outdated

@thepastaclaw thepastaclaw left a comment

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.

Clearing my stale CHANGES_REQUESTED state. The prior SEC-001 blocker is fixed at b98515d; only the nonblocking suggestions from my latest review remain.

lklimek and others added 2 commits June 26, 2026 11:00
The actual-fee guard only rejected the two exact boundaries (delta 0 and
delta == minted), so a partial-stale balance_before could yield a delta
that is positive and below the mint yet grossly wrong — and it was shown
to the user as the real fee. Add an upper plausibility cap against the
deterministic estimate (2x headroom) so a grossly inflated partial-stale
delta falls back to the trustworthy estimate. The low side stays at
delta > 0 because the estimate over-predicts and a legitimately small
real fee must not be rejected.

Display-only path; no funds at risk. Honest doc comment plus a regression
test for the partial-stale case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kLXkaXo6xfcBSdn8rHg5Q
The protect guard returned one shared error for two different states:
resident plaintext (fixed by close-and-reopen, which the load-path
migration retries) and legacy Encrypted keys (no migration path, so
close-and-reopen loops forever with no exit). The shared message told
both to close and reopen, which is a dead end for the legacy case.

Split into a dedicated IdentityKeyProtectionLegacyFormat variant whose
message tells the user to load the identity again from its recovery
phrase or private key — the action the code actually supports, since
re-loading overwrites the stored blob and replaces the legacy key
entries with ones this version can protect. Route the guard per branch,
checking legacy first because re-loading also clears any resident
plaintext. Adds a regression test for the distinct error.

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

lklimek commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

Re: the review note (review #4578554863) that IdentityKeyProtectionIncomplete's "close and reopen" instruction is a no-exit retry loop for legacy Encrypted keys — addressed in d78b9306.

Split into a dedicated TaskError::IdentityKeyProtectionLegacyFormat variant for the legacy-Encrypted branch, with an honest, code-verified recovery action: "Some of this identity's keys are saved in an older format that cannot be protected. Load this identity again using its recovery phrase or private key, then try protecting it." reject_resident_identity_plaintext now routes per branch (legacy checked first, since re-loading also clears resident plaintext); the resident-plaintext branch keeps the original "close and reopen" message, which is correct there.

Verified the action is real: Encrypted is decode-only with no migration, but re-loading goes through insert/update_local_qualified_identity, which overwrites the stored blob, and load paths never emit Encrypted keys — so the legacy entries get replaced with protect-compatible ones via the existing "Load Identity" flow. Regression test protect_rejects_legacy_encrypted_key_with_distinct_error added.

🤖 Co-authored by Claudius the Magnificent AI Agent

@lklimek
lklimek requested a review from thepastaclaw June 26, 2026 11:25
@lklimek

lklimek commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@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

🧹 Nitpick comments (2)
tests/backend-e2e/core_tasks.rs (1)

233-259: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Funding send + fixed 5s sleep is now dead weight and a flakiness source.

Since RefreshSingleKeyWalletInfo returns SingleKeyWalletsUnsupported unconditionally (independent of funding), the real send to the single-key address and the fixed sleep(5s) no longer gate the assertion that follows. They add a live-testnet send that can fail or flake on a path whose only verified outcome is the typed error — which runs counter to this PR's stabilization goal. Consider dropping the funding/sleep until the single-key send flow is reinstated, or keep them only if the intent is explicitly to smoke-test the framework HD send here.

🤖 Prompt for 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.

In `@tests/backend-e2e/core_tasks.rs` around lines 233 - 259, The funding send and
fixed 5-second sleep in the single-key wallet test are unnecessary and introduce
flakiness because the assertion only verifies the unsupported error from
RefreshSingleKeyWalletInfo. Remove the live transfer to the single-key address
and the subsequent sleep from core_tasks.rs unless this test is meant to
explicitly smoke-test the framework wallet send path; if so, keep the funding
step only with clear intent and separate the unsupported-refresh assertion from
any propagation timing.
src/backend_task/wallet/generate_receive_address.rs (1)

12-21: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a regression test for the new error split.

Lines 19-20 change retry semantics, not just wording. tests/backend-e2e/framework/harness.rs:is_transient_registration_error retries WalletNotLoaded-style startup failures but treats WalletNotFound as terminal, so this distinction should be pinned with a task-level test for “unknown seed hash → WalletNotFound” (and ideally “known wallet still loading → WalletNotLoaded”).

🤖 Prompt for 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.

In `@src/backend_task/wallet/generate_receive_address.rs` around lines 12 - 21,
Add a regression test covering the new error split in generate_receive_address:
verify that an unknown seed hash returns TaskError::WalletNotFound, and that a
known wallet still loading continues to surface WalletNotLoaded. Use the
generate_receive_address flow and the wallet store lookup in BackendTask to pin
the behavior change, and make sure the test reflects the retry semantics
expected by is_transient_registration_error in the backend-e2e harness.
🤖 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 `@src/backend_task/identity/top_up_identity.rs`:
- Around line 20-21: The identity top-up flow is creating a fresh
PlatformFeeEstimator instead of using the AppContext fee model, so fee estimates
can drift from the active network multiplier. Update the logic in
top_up_identity to call self.fee_estimator() and use that estimator for
estimate_identity_topup, and make the related resolver fallback/plausibility
band code at the referenced match area use the same estimator path so all
reported fees stay multiplier-aware.

In `@src/context/identity_db.rs`:
- Around line 324-328: After store_all succeeds in the migration flow, the taken
value still holds zeroizing plaintext while persist(qi) runs; capture
taken.len() into migrated, then explicitly drop taken before calling persist(qi)
so the plaintext is released earlier. Keep the existing zeroization cleanup with
before.take_plaintext_for_vault() after the vault write path, and use the taken
and persist(qi) symbols in identity_db.rs to update the ownership order.

In `@tests/backend-e2e/framework/wait.rs`:
- Around line 102-108: The diagnostic comment in wait_balance_timeout is stale
because the code now logs spendable and total rather than confirmed and total.
Update the nearby comment in wait_balance_timeout to match the current
snapshot_balance usage and the spendable/total values, keeping the wording
aligned with the format! message and the spendable, total binding so future
readers are not misled.

---

Nitpick comments:
In `@src/backend_task/wallet/generate_receive_address.rs`:
- Around line 12-21: Add a regression test covering the new error split in
generate_receive_address: verify that an unknown seed hash returns
TaskError::WalletNotFound, and that a known wallet still loading continues to
surface WalletNotLoaded. Use the generate_receive_address flow and the wallet
store lookup in BackendTask to pin the behavior change, and make sure the test
reflects the retry semantics expected by is_transient_registration_error in the
backend-e2e harness.

In `@tests/backend-e2e/core_tasks.rs`:
- Around line 233-259: The funding send and fixed 5-second sleep in the
single-key wallet test are unnecessary and introduce flakiness because the
assertion only verifies the unsupported error from RefreshSingleKeyWalletInfo.
Remove the live transfer to the single-key address and the subsequent sleep from
core_tasks.rs unless this test is meant to explicitly smoke-test the framework
wallet send path; if so, keep the funding step only with clear intent and
separate the unsupported-refresh assertion from any propagation timing.
🪄 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: 98636966-3cbc-4c36-8662-cbf9a13c1f13

📥 Commits

Reviewing files that changed from the base of the PR and between 2242bc7 and d78b930.

📒 Files selected for processing (22)
  • src/backend_task/error.rs
  • src/backend_task/identity/protect_identity_keys.rs
  • src/backend_task/identity/top_up_identity.rs
  • src/backend_task/wallet/generate_receive_address.rs
  • src/context/identity_db.rs
  • src/context/wallet_lifecycle.rs
  • src/model/fee_estimation.rs
  • src/model/qualified_identity/encrypted_key_storage.rs
  • src/ui/dashpay/send_payment.rs
  • src/ui/wallets/send_screen.rs
  • src/ui/wallets/wallets_screen/dialogs.rs
  • src/wallet_backend/mod.rs
  • tests/backend-e2e/core_tasks.rs
  • tests/backend-e2e/dashpay_tasks.rs
  • tests/backend-e2e/framework/harness.rs
  • tests/backend-e2e/framework/wait.rs
  • tests/backend-e2e/identity_cold_boot.rs
  • tests/backend-e2e/identity_in_vault_sign.rs
  • tests/backend-e2e/main.rs
  • tests/backend-e2e/spv_reconnect.rs
  • tests/backend-e2e/wallet_tasks.rs
  • tests/backend-e2e/z_broadcast_st_tasks.rs

Comment thread src/backend_task/identity/top_up_identity.rs Outdated
Comment thread src/context/identity_db.rs
Comment thread tests/backend-e2e/framework/wait.rs Outdated

@thepastaclaw thepastaclaw left a comment

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.

Code Review

Latest delta (b98515dd78b930) cleanly resolves both prior findings and introduces no in-scope defects. prior-b985-1 is FIXED: a dedicated TaskError::IdentityKeyProtectionLegacyFormat variant with a concrete self-service recovery instruction is returned for legacy Encrypted keys, guarded ahead of the resident-plaintext branch, with a new protect_rejects_legacy_encrypted_key_with_distinct_error regression test. prior-b985-2 is FIXED: resolve_identity_topup_actual_fee now adds an estimate × 2 plausibility band that catches partial-stale balance_before values inflating the measured delta, with a dedicated test_identity_topup_actual_fee_rejects_partial_stale_inflated_delta test; the residual on the stale-LOW deflation side is documented in code with a justification. No new in-scope findings: codex-rust-quality's lone suggestion to swap PlatformFeeEstimator::new() for self.fee_estimator() in top_up_identity.rs is a pre-existing project-wide pattern (30+ call sites) that this PR did not introduce or change, and the same agent already records it as an out-of-scope follow-up; it is dropped here. No CodeRabbit comments.

lklimek and others added 4 commits June 26, 2026 12:11
After a successful vault migration the `taken` plaintext copy is no longer
needed, but it previously lived across the subsequent blob persist. Drop it
explicitly right after recording the count so its key bytes (which zeroize
on drop) leave memory before the DB write rather than after — trimming the
residency window to the minimum. Behaviour is otherwise unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kLXkaXo6xfcBSdn8rHg5Q
The top-up estimate was built from PlatformFeeEstimator::new(), which hard-
codes the default fee multiplier. This estimate is shown to the user and
also feeds the actual-fee plausibility band, so it must reflect the active
network fee multiplier. Switch this call site to the context estimator
(self.fee_estimator()) so both the displayed figure and the band track the
live multiplier. Scoped to this user-facing site only; the deferred
::new() pattern elsewhere is untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kLXkaXo6xfcBSdn8rHg5Q
Guards the #860 behaviour: a receive-address request for a seed hash that
matches no locally-stored wallet must return the genuine WalletNotFound,
not the transient WalletNotLoaded. The existence check runs before the
wallet backend is consulted, so the test needs only an offline AppContext
with no wallets loaded.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kLXkaXo6xfcBSdn8rHg5Q
TC-009 funded the single-key wallet and slept 5s before refreshing, but the
RefreshSingleKeyWalletInfo arm returns SingleKeyWalletsUnsupported
unconditionally (it ignores the wallet), and the test asserts only that
error before stopping — the send flow that would use the funds is fully
commented out. The funding-send and sleep therefore had no effect on the
assertion while burning real testnet funds and 5s per run, so remove them
(and the now-unused address/wallet bindings).

Also correct the wait-helper diagnostics comment: it said "confirmed and
total" but the code reports spendable and total.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kLXkaXo6xfcBSdn8rHg5Q
@lklimek
lklimek requested a review from thepastaclaw June 26, 2026 12:14
@lklimek

lklimek commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

All 5 CodeRabbit items from review #4579124870 are addressed (pushed in 829c1d30):

  • top_up_identity.rs:20 — now uses self.fee_estimator() instead of PlatformFeeEstimator::new(), so the displayed estimate and the estimate × 2 fee-plausibility band track the active fee multiplier (5957c1a2). Scoped to this site only — it's user-facing and feeds the band; the project-wide ::new() pattern stays deferred.
  • identity_db.rs:324drop(taken); before persist() to minimize the in-memory residency of the migrated plaintext key bytes (e97592e0).
  • tests/backend-e2e/framework/wait.rs:102 — corrected the stale comment ("confirmed and total" → "spendable and total") to match the code (829c1d30).
  • tests/backend-e2e/core_tasks.rs (TC-009) — removed the dead single-key funding-send + 5s sleep; forward-traced and verified it had zero effect on the assertion (RefreshSingleKeyWalletInfo returns SingleKeyWalletsUnsupported unconditionally) and was wasting real testnet funds (829c1d30).
  • generate_receive_address.rs — added regression test asserting an unknown seed hash returns WalletNotFound (the feat: rewrite Dash Evo Tool onto the new platform-wallet #860 WalletNotFound/WalletNotLoaded split) (ad8738a3).

Gate: build + clippy --all-features --all-targets -D warnings + +nightly fmt clean; fee_estimation 19/19, identity_db 23/23, new receive-address test 1/1, backend-e2e compiles. The three inline threads (fee estimator, key residency, stale comment) are resolved.

🤖 Co-authored by Claudius the Magnificent AI Agent

@lklimek

lklimek commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@thepastaclaw thepastaclaw left a comment

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.

Code Review

Incremental delta from d78b930 to 829c1d3 is small and focused, with no carried-forward prior findings from that review. Verification confirms two nonblocking in-scope items: a QA-006 sibling in top_up_identity_from_platform_addresses still uses a hardcoded PlatformFeeEstimator::new() despite the same-PR fix to its sibling, and the TC-009 header comment still describes the funding step that this PR removed.

🟡 1 suggestion(s) | 💬 1 nitpick(s)

Findings not posted inline (1)

These findings could not be anchored to the current diff, but they are still part of this review.

  • [SUGGESTION] src/backend_task/identity/mod.rs:896: QA-006 sibling: top_up_identity_from_platform_addresses still ignores active fee multiplier — This PR's fix in src/backend_task/identity/top_up_identity.rs correctly routes the top-up estimate through self.fee_estimator() so the reported FeeResult.estimated tracks the cached fee_multiplier_permille. The sibling entry point top_up_identity_from_platform_addresses calls the same estimate_identity_topup() method on a hardcoded PlatformFeeEstimator::new(), so under any non-default fee multiplier the platform-addresses top-up will misreport its estimate in exactly the way QA-006 describes. Same domain, same function, same one-line fix; AppContext::fee_estimator() is already in scope here.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/backend_task/identity/mod.rs`:
- [SUGGESTION] src/backend_task/identity/mod.rs:896: QA-006 sibling: top_up_identity_from_platform_addresses still ignores active fee multiplier
  This PR's fix in `src/backend_task/identity/top_up_identity.rs` correctly routes the top-up estimate through `self.fee_estimator()` so the reported `FeeResult.estimated` tracks the cached `fee_multiplier_permille`. The sibling entry point `top_up_identity_from_platform_addresses` calls the same `estimate_identity_topup()` method on a hardcoded `PlatformFeeEstimator::new()`, so under any non-default fee multiplier the platform-addresses top-up will misreport its estimate in exactly the way QA-006 describes. Same domain, same function, same one-line fix; `AppContext::fee_estimator()` is already in scope here.

In `tests/backend-e2e/core_tasks.rs`:
- [NITPICK] tests/backend-e2e/core_tasks.rs:200-207: TC-009 header comment still describes the removed funding step
  The latest delta removed the framework-wallet funding send from TC-009, and the test body now goes straight from constructing the `SingleKeyWallet` to asserting that `RefreshSingleKeyWalletInfo` returns `SingleKeyWalletsUnsupported`. The header comment still claims "The funding step still exercises a real send from the framework HD wallet to the single-key address", which contradicts the test body and will mislead anyone re-enabling this flow into thinking funding coverage is still present.

Comment thread tests/backend-e2e/core_tasks.rs Outdated
lklimek and others added 2 commits June 26, 2026 13:18
…sses estimate

Completes the active-fee-estimator wiring for the identity TOP-UP paths.
top_up_identity_from_platform_addresses built PlatformFeeEstimator::new(),
which hardcodes the default fee multiplier and so misreports the displayed
top-up estimate under a non-default network multiplier. Switch it to
self.fee_estimator() (with_fee_multiplier(fee_multiplier_permille)) so the
figure shown to the user tracks the live multiplier, matching the sibling
top_up_identity path. Removed the now-dead local PlatformFeeEstimator import.

Scoped to identity top-up estimates only (they feed the actual-fee band);
transfer and other estimate sites remain in the deferred fee-multiplier
cleanup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kLXkaXo6xfcBSdn8rHg5Q
The TC-009 header still described a funding send that the test no longer
performs. Rewrite it to describe what the test does now: verify that
RefreshSingleKeyWalletInfo returns the typed SingleKeyWalletsUnsupported and
stop, with the single-key send flow unreachable until single-key wallets are
reinstated.

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

lklimek commented Jun 26, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the two remaining items from the latest review in 9218eb51:

  • Fee estimator (suggestion) — completed the top-up estimator wiring: top_up_identity_from_platform_addresses (mod.rs:896) now uses self.fee_estimator(), matching top_up_identity.rs from the prior round. Scope is deliberately top-up estimates only — they feed the resolve_identity_topup_actual_fee plausibility band, so they must track the active multiplier. The transfer/other identity estimate sites (transfer_to_addresses, estimate_credit_transfer, register_identity, add_key, register_dpns, withdraw) stay on the default multiplier as part of the project-wide fee-multiplier cleanup, which is deferred and tracked separately — drawing the line at top-ups avoids a partial, inconsistent transfer-path fix in this PR.
  • TC-009 comment (nitpick) — rewrote the header to present-state, dropping the stale "still exercises a real send…" line (the funding step was removed earlier in this PR); thread resolved.

Gate: build + clippy --all-features --all-targets -D warnings + +nightly fmt clean; fee_estimation 19/19, --lib identity 132/132, backend-e2e compiles.

🤖 Co-authored by Claudius the Magnificent AI Agent

@lklimek
lklimek merged commit 43bed20 into docs/platform-wallet-migration-design Jun 26, 2026
4 of 5 checks passed
@lklimek
lklimek deleted the security/secret-handling-hardening branch June 26, 2026 13:40

@thepastaclaw thepastaclaw left a comment

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.

Code Review

Both prior findings are FIXED in the latest delta: top_up_identity_from_platform_addresses now routes through self.fee_estimator() (prior-829-1), and the TC-009 header comment now matches the present-state test body (prior-829-2). One new in-scope suggestion from codex-rust-quality survives verification: the same line at src/backend_task/identity/mod.rs:896 still uses the wallet-funded estimate_identity_topup() even though the function is explicitly the platform-address top-up, and a dedicated estimate_identity_topup_from_addresses(input_count) exists in model/fee_estimation.rs and is already used by the matching UI screen. No CodeRabbit inline comment IDs were supplied, so coderabbit_reactions is empty.

🟡 1 suggestion(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/backend_task/identity/mod.rs`:
- [SUGGESTION] src/backend_task/identity/mod.rs:896: Use the platform-address top-up estimator on the address-funded path
  `top_up_identity_from_platform_addresses` calls `estimate_identity_topup()`, which is the wallet-funded variant and ignores per-input costs. The model already provides `estimate_identity_topup_from_addresses(input_count)` (model/fee_estimation.rs:376), which factors in `address_funding_asset_lock_cost`, `address_funds_transfer_input_cost * inputs`, plus a 20% safety buffer — and the matching UI screen already calls it (`src/ui/identities/top_up_identity_screen/by_platform_address.rs:103`). Because the backend returns `FeeResult::new(estimated_fee, estimated_fee)` from the same `u64`, using the wallet-funded estimator can under-report fees for this flow and silently diverges the UI estimate from the backend estimate, especially with >1 input.

let estimated_fee = PlatformFeeEstimator::new().estimate_identity_topup();
// Estimate the top-up fee with the active network fee multiplier
// (context estimator) so the figure shown to the user is accurate.
let estimated_fee = self.fee_estimator().estimate_identity_topup();

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.

🟡 Suggestion: Use the platform-address top-up estimator on the address-funded path

top_up_identity_from_platform_addresses calls estimate_identity_topup(), which is the wallet-funded variant and ignores per-input costs. The model already provides estimate_identity_topup_from_addresses(input_count) (model/fee_estimation.rs:376), which factors in address_funding_asset_lock_cost, address_funds_transfer_input_cost * inputs, plus a 20% safety buffer — and the matching UI screen already calls it (src/ui/identities/top_up_identity_screen/by_platform_address.rs:103). Because the backend returns FeeResult::new(estimated_fee, estimated_fee) from the same u64, using the wallet-funded estimator can under-report fees for this flow and silently diverges the UI estimate from the backend estimate, especially with >1 input.

Suggested change
let estimated_fee = self.fee_estimator().estimate_identity_topup();
let estimated_fee = self
.fee_estimator()
.estimate_identity_topup_from_addresses(inputs.len());

source: ['codex']

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants