fix(backend-e2e): stabilize PR #860 platform-wallet e2e suite + SEC-001 hardening - #867
Conversation
…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>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Latest delta (d63ad9f → b98515d) 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.
thepastaclaw
left a comment
There was a problem hiding this comment.
Clearing my stale CHANGES_REQUESTED state. The prior SEC-001 blocker is fixed at b98515d; only the nonblocking suggestions from my latest review remain.
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
|
Re: the review note (review #4578554863) that Split into a dedicated Verified the action is real: 🤖 Co-authored by Claudius the Magnificent AI Agent |
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (2)
tests/backend-e2e/core_tasks.rs (1)
233-259: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winFunding send + fixed 5s sleep is now dead weight and a flakiness source.
Since
RefreshSingleKeyWalletInforeturnsSingleKeyWalletsUnsupportedunconditionally (independent of funding), the real send to the single-key address and the fixedsleep(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 winAdd 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_errorretriesWalletNotLoaded-style startup failures but treatsWalletNotFoundas 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
📒 Files selected for processing (22)
src/backend_task/error.rssrc/backend_task/identity/protect_identity_keys.rssrc/backend_task/identity/top_up_identity.rssrc/backend_task/wallet/generate_receive_address.rssrc/context/identity_db.rssrc/context/wallet_lifecycle.rssrc/model/fee_estimation.rssrc/model/qualified_identity/encrypted_key_storage.rssrc/ui/dashpay/send_payment.rssrc/ui/wallets/send_screen.rssrc/ui/wallets/wallets_screen/dialogs.rssrc/wallet_backend/mod.rstests/backend-e2e/core_tasks.rstests/backend-e2e/dashpay_tasks.rstests/backend-e2e/framework/harness.rstests/backend-e2e/framework/wait.rstests/backend-e2e/identity_cold_boot.rstests/backend-e2e/identity_in_vault_sign.rstests/backend-e2e/main.rstests/backend-e2e/spv_reconnect.rstests/backend-e2e/wallet_tasks.rstests/backend-e2e/z_broadcast_st_tasks.rs
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Latest delta (b98515d → d78b930) 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.
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
|
All 5 CodeRabbit items from review #4579124870 are addressed (pushed in
Gate: 🤖 Co-authored by Claudius the Magnificent AI Agent |
|
@coderabbitai review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
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 insrc/backend_task/identity/top_up_identity.rscorrectly routes the top-up estimate throughself.fee_estimator()so the reportedFeeResult.estimatedtracks the cachedfee_multiplier_permille. The sibling entry pointtop_up_identity_from_platform_addressescalls the sameestimate_identity_topup()method on a hardcodedPlatformFeeEstimator::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.
…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
|
Addressed the two remaining items from the latest review in
Gate: 🤖 Co-authored by Claudius the Magnificent AI Agent |
43bed20
into
docs/platform-wallet-migration-design
thepastaclaw
left a comment
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
🟡 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.
| let estimated_fee = self.fee_estimator().estimate_identity_topup(); | |
| let estimated_fee = self | |
| .fee_estimator() | |
| .estimate_identity_topup_from_addresses(inputs.len()); |
source: ['codex']
Why this PR exists
backend-e2esuite 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.confirmedbalance 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 →WalletNotLoadedinstead ofWalletNotFound), a top-up fee that misreports after a backend reload, and an e2e suite too flaky/red to validate the migration.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
WalletNotFound(wasWalletNotLoaded).model/fee_estimation.rs(+ a stale-HIGHbalance_beforeguard so it can't report the full minted amount as the "fee").send_screen, dashpaysend_payment, and the send dialog. Fixes false "insufficient funds" rejections on IS-locked/spendable funds.Encryptedkeys; zeroize the migration rollback clone.Test / harness fixes
stop_spvrustdoc + inline comment; QA-016 cold-boot funding buffer; QA-017 funded-wallet SPV-pickup budget 30s→120s.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.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.tc_014: pre-existing environmental failure (DAPI exhaustion at the withdrawal step).Known gaps (deferred with upstream references)
dashpay/rust-dashcore#818(next_unused_and_reserve); tc_012 advance assertion PENDING.dashpay/platform#3841("complete dashpay"); module gated out, re-enable on land.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
Bug Fixes