feat(mcp): headless masternode/evonode credit withdrawals (det-cli)#864
Conversation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stateless parsing for the headless masternode/evonode tools, the single source of truth for their string params: - parse_node_type: trim + case-insensitive, maps to Masternode/Evonode, never User (pins coverage gap G-4) - parse_key_mode: owner/transfer, trim + case-insensitive - require_at_least_one_signing_key: rejects a key-less (watch-only) load, naming both keys and the two withdraw modes - decode_identity_id: Base58-then-Hex fallback, mirroring the backend Also pins the Platform-address pitfall guard (TC-MN-031): a dedicated is_platform_address_string test proving dash1…/tdash1… are flagged as Platform and Core y…/X… are not — the weaker first-char check must not be relied on. The module is gated behind the mcp/cli features since it depends on the tool-layer McpToolError, keeping the default no-feature build green. Covers TC-MN-001/002/003/004/005/006/007/008/009/030/031. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
IdentityMasternodeLoadParams / IdentityMasternodeLoadOutput for the
identity_masternode_load tool. The params carry three private keys, so
Debug is hand-written to render each key field as <redacted> — mirroring
ImportWalletParams. A derived Debug would leak key material into logs and
the MCP error `data` payload (McpToolError::TaskFailed serializes
{task_err:?}).
Unit tests:
- TC-MN-010 (the single most important security test): Debug surfaces
none of the three key sentinels, renders exactly three <redacted>
markers, and keeps pro_tx_hash/node_type/alias/network readable.
- TC-MN-011: the params accept any key string without a competing local
length check — the backend verify_key_input is the single source of
truth for the length policy.
The invoke + tool_router registration follow in Task 3.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d (Task 3)
The identity_masternode_load tool, a thin adapter over the existing
IdentityTask::LoadIdentity. Invoke order puts cheap validation before the
SPV wait:
require_network -> parse_node_type -> at-least-one-signing-key
-> ensure_spv_synced -> Secret::new(keys)
-> IdentityInputToLoad{ derive_keys_from_wallets:false, keys_input:[] }
-> dispatch LoadIdentity -> map output.
The output is self-describing for the withdraw step: which keys loaded,
available_withdrawal_keys ("owner"/"transfer") derived by purpose from
available_withdrawal_keys(), and the registered payout_address. Registered
one line in tool_router().
Tests:
- TC-MN-015: built the router (no AppContext) and assert the tool is
registered, annotations are read_only=false/destructive=false/
idempotent=false/open_world=true, and the schema exposes all seven params.
TC-MN-012/013/014 (network mismatch / missing network / ordering before
the SPV gate) reach invoke and need a live AppContext — covered by the
det-cli smoke pass and the backend-e2e suite (Task 7), per the test spec
layering.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
identity_masternode_credits_withdraw — a masternode-aware credit
withdrawal over the existing IdentityTask::WithdrawFromIdentity, with
explicit owner/transfer key modes.
Params + Output + pure pre-flight helpers (Task 4):
- reject_owner_address_contradiction: owner mode forbids a supplied
to_address (TC-MN-033).
- parse_transfer_core_address: transfer mode requires an address
(TC-MN-034), rejects Platform bech32m via is_platform_address_string —
the pitfall guard, NOT the weaker first-char check (TC-MN-031/046) —
and rejects unparseable Core addresses (TC-MN-035).
invoke + registration (Task 5), cheap validation before the SPV wait:
require_network -> validate_credits -> parse_key_mode
-> owner+to_address contradiction (before resolution, TC-MN-033/042)
-> resolve identity (not-loaded names identity-masternode-load, TC-MN-040)
-> KeyID from available_withdrawal_keys() by purpose (TC-MN-044/054)
-> destination (owner -> payout/None-or-reject when absent;
transfer -> parsed Core + require_network cross-network reject)
-> ensure_spv_synced (NFR-P2 / OQ-4 option b: add the gate, diverging
from the sibling identity_credits_withdraw that skips it)
-> dispatch WithdrawFromIdentity(qi, dest, credits, Some(key_id)).
The output echoes the address actually used (the payout address in owner
mode). Registered one line in tool_router().
Tests (Task 4 units + Task 5 discoverability):
- TC-MN-032 amount=0; TC-MN-033 owner+address; TC-MN-034 missing address;
TC-MN-035 invalid address; TC-MN-031/046 Platform-address rejection;
TC-MN-043 annotations (destructive=true) + schema.
The helpers and their sole caller (invoke) are one compile unit, so
Task 4 and Task 5 land together to keep the clippy gate green (no
transient dead_code).
Note: a real finding caught here — hardcoded placeholder Core addresses
have invalid checksums and do NOT parse, so the "valid address accepted"
test derives a checksum-valid address from a fixed key instead.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- TC-MN-061: McpToolError::TaskFailed serializes {task_err:?} into the MCP
error `data` payload. Assert neither the Display message nor the Debug
data chain of a KeyInputValidationFailed leaks a key sentinel — the
variant carries only a role name + a format detail, no key bytes.
- Secret Debug renders "Secret(***)" and never the plaintext, anchoring
redaction at the source.
TC-MN-015/043/060 (discoverability, schema, CLI hyphenation) are covered
by the router-level annotation/schema tests added with Tasks 3 and 5 plus
the det-cli smoke pass; zero tool logic lives in src/bin/det_cli/.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New #[ignore], network-gated suite mirroring identity_withdraw.rs, driving the same IdentityTask::LoadIdentity / WithdrawFromIdentity the new tools dispatch. Env-gated by E2E_MN_* (PRO_TX_HASH, OWNER_WIF, PAYOUT_WIF, VOTING_WIF, NODE_TYPE); each case skips with a log line when its inputs are unset (never fails on absence — they are #[ignore] anyway). Cases: - TC-MN-016 load with payout key (transfer key present, owner absent, payout address present) - TC-MN-017 load with owner key - TC-MN-018 load with both keys - TC-MN-020 wrong-but-valid-format key -> KeyInputValidationFailed, with a TC-MN-061 cross-check that no key bytes appear in Display/Debug - TC-MN-021 nonexistent ProTxHash -> IdentityNotFound - TC-MN-050 owner-mode withdraw: dispatch to_address=None, assert WithdrewFromIdentity with a positive actual_fee - TC-MN-051 transfer-mode withdraw to a fresh Core address, positive fee - TC-MN-054 owner-mode key-not-loaded precondition (no OWNER key present) Every fund-moving assertion checks the result variant AND a number (actual_fee > 0), per the QA test-depth rule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- MCP.md: tool-table rows for identity_masternode_load and identity_masternode_credits_withdraw; a private-key handling note (Secret redaction in output/errors/Debug; NFR-S4 HTTP-transport caution / G-6); and a note that the masternode withdraw adds the SPV gate the sibling identity_credits_withdraw skips (NFR-P2). - CLI.md: a headless masternode load + owner/transfer withdraw walkthrough. - user-stories.md: MCP-003 (headless MN load) and MCP-004 (headless MN withdraw), tagged [Implemented]. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-001) Owner-key withdrawals must dispatch WithdrawFromIdentity(qi, None, ...) and let Platform consensus force the registered payout address — matching the locked decision, the GUI (withdraw_screen.rs sends None in owner mode), and TC-MN-050. The tool previously dispatched Some(payout_address), a fund-path divergence no test could catch. Extract a pure resolve_withdrawal_plan(qi, key_mode, to_address, network): - owner mode: dispatch_address = None; the resolved payout address is used only to validate one exists and to echo it back in the output. - transfer mode: dispatch_address = Some(parsed) after a format + active-network check. Truthful coverage that exercises the tool's actual resolution against a built masternode QualifiedIdentity fixture (random_masternode_owner_key / random_masternode_transfer_key): - owner_mode_dispatches_none_and_echoes_payout (the QA-001 regression guard) - transfer_mode_dispatches_and_echoes_the_caller_address - owner/transfer key-not-loaded naming the param (TC-MN-044, L-01/L-02) - owner no-payout-address (TC-MN-045, the G-2 hand-built fixture) - transfer cross-network address rejected (TC-MN-047) Also: key-not-loaded messages now name the param (owner_private_key / payout_private_key) and bridge transfer<->payout (L-01/L-02); FR-B2 in the requirements doc corrected to address = None; ephemeral TC-MN IDs stripped from the two pre-flight helpers' production rustdoc and the rustdoc trimmed (QA-010/QA-011). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Append the two accepted formats and the producing tool to the error so a caller who passes a malformed identity_id has a concrete next step: "...Provide a 64-character hex ProTxHash or the Base58 identity ID from identity-masternode-load." Single i18n-ready sentence, per the project error rule (what happened + what to do). Also clarify the decode_identity_id test-section header: the function serves the withdraw tool's identity_id; the load tool delegates pro_tx_hash to the backend, which parses it identically (PROJ-005). Add a unit test asserting the message carries the format guidance. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A require_nonblank_network guard runs at the top of both new tools' invoke,
before resolve::require_network. An empty/whitespace network now returns
"The network parameter is required." instead of the confusing
NetworkMismatch { expected: "" } that require_network would produce for
Some(""). The shared resolve::require_network is untouched.
Unit test covers ""/" " rejected with the required message and a real
network accepted. TC-MN-013's note in the test spec is corrected to
distinguish the omitted (schema-required deserialization) path from the
empty-string (guard) path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…/PROJ-002) Add the Task-7 cases the suite was missing: - TC-MN-007 — malformed ProTxHash to the load tool surfaces as TaskError::IdentifierParsingError with the original input preserved (QA-005). - TC-MN-019 — load with a voting key fetches and binds the voter identity; it adds no withdrawal mode. - TC-MN-023 — re-load is idempotent at the DB layer: count the rows for the identity before and after a re-load; INSERT-OR-REPLACE keeps it at one. - TC-MN-052 — owner mode + supplied to_address is rejected before dispatch (the rejection itself is unit-tested); assert the identity balance is unchanged, so no ST moved funds. - TC-MN-053 — A→B composition THROUGH the local DB: load (persist), drop the in-memory qi, re-resolve via ctx.get_identity_by_id, then withdraw. This exercises the load→DB→withdraw seam the requirements call out as the only A→B coupling — previously untested. Also document that TC-MN-022 (load SPV-gate presence) is deferred per coverage gap G-3 (needs a forced-SPV-error harness), so a reader does not assume it was accidentally omitted (PROJ-002). Every fund-moving assertion checks the result variant AND a number (actual_fee > 0), per the QA test-depth rule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- DOC-001: CLI.md masternode examples use hyphenated param names (pro-tx-hash, owner-private-key, key-mode, ...) to match the --help canonical form and the rest of CLI.md. - DOC-002: MCP.md "page-locked" -> "best-effort page-locked" Secret, matching Secret::new's best-effort mlock. - DOC-003: drop the false "all identity tools are destructive" phrasing; list the fund-moving tools and note identity_masternode_load is non-destructive yet still requires network (keys are chain-scoped). - DOC-004: document that transfer mode rejects Platform bech32m addresses, in the MCP.md table row and the CLI.md preamble. - DOC-005/SEC-001: CLI.md warns that inline key=value args are visible via ps / /proc/<pid>/cmdline and saved to shell history. - QA-010: strip ephemeral design-doc/review IDs (FR-B5, TC-MN-NNN, QA-004) from production comments in identity.rs, keeping the prose reason. TC-MN IDs remain only in test comments (test-spec IDs, allowed there). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-011) Keep the security "why" (keys must not leak; mirrors ImportWalletParams) within the internal-comment budget. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review (2)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
✅ Review complete (commit a88a5b3) |
#863) into feat/masternode-cli-withdraw Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Owner-mode withdraw dispatches Some(payout_address) instead of None, diverging from the locked design and the e2e test's assumption — confirmed blocking. The new transfer-mode path also newly exposes a pre-existing non-ASCII panic in is_platform_address_string through the MCP surface. Remaining items are test-coverage and Rust-quality refinements.
🔴 2 blocking | 🟡 4 suggestion(s) | 💬 1 nitpick(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/mcp/tools/identity.rs`:
- [BLOCKING] src/mcp/tools/identity.rs:978-1013: Owner-mode withdraw dispatches Some(payout_address); spec and e2e expect None
The owner-mode arm at lines 979–1000 unconditionally wraps the resolved payout address in `Some(...)`, so `IdentityTask::WithdrawFromIdentity` is dispatched with `Some(payout_address)`. The locked design (`docs/ai-design/2026-06-18-masternode-cli-withdraw/03-dev-plan.md:21`) and the e2e test (`tests/backend-e2e/identity_masternode_withdraw.rs:296-302`, TC-MN-050) both require owner mode to dispatch `to_address = None` so Platform consensus authoritatively routes the payout to the registered address — the resolved payout address should only be echoed in the output. Because the test hand-constructs the task with `None`, the divergence is silently uncaught. The owner arm should resolve the payout address only for the output echo and pass `None` to the dispatched task; the transfer arm continues to pass `Some(checked)`. After this fix, also reconsider the trailing `unwrap_or_default()` on `dispatched_address` (line 1034) which becomes a real `None` branch in owner mode — replace it with a branch-specific value so the caller always learns where the funds went.
- [SUGGESTION] src/mcp/tools/identity.rs:739-749: Wrap private keys in Secret before the SPV await
Cheap validation runs at lines 732–737, then the tool awaits `ensure_spv_synced` before moving `owner_private_key`, `voting_private_key`, and `payout_private_key` into `Secret`. If the SPV wait is long or times out, those key strings sit in the async state machine as ordinary `String`s — not mlocked, not zeroized on drop. The rest of the crate moves sensitive values into `Secret`/`Zeroizing` at the boundary, before any await. Wrap the three key params into `Secret::new` immediately after key-presence validation; the validation ordering is preserved and the plaintext lifetime is bounded.
- [SUGGESTION] src/mcp/tools/identity.rs:732-741: Validate pro_tx_hash before the SPV wait
The load tool already validates network, node type, and key-presence before `ensure_spv_synced`, but skips `pro_tx_hash`. A malformed ProTxHash therefore waits on SPV and, if SPV is unavailable, returns an SPV error instead of an input error. `masternode_input::decode_identity_id` is already available and cheap. Calling it before `ensure_spv_synced` keeps the cheap-validation-before-SPV pattern consistent with the other parameters and lets the load tool reuse the decoded `Identifier` in `IdentityInputToLoad` (the backend currently re-parses the string).
- [SUGGESTION] src/mcp/tools/identity.rs:766-781: KeyMode vocabulary duplicated between Tool A output and Tool B input
The `available_withdrawal_keys` output writes literal `"owner"` / `"transfer"` strings, and `parse_key_mode` (`src/model/masternode_input.rs:55`) parses the same vocabulary — but each side hand-writes its own literals. A typo on either side becomes silent contract drift between Tool A's output and Tool B's input. Both pieces live in this PR, so adding a small `KeyMode::as_str()` (or `Display`) in `model/masternode_input.rs` and using it on both sides removes the duplication without adding any abstraction overhead.
In `src/model/address.rs`:
- [BLOCKING] src/model/address.rs:14-24: is_platform_address_string panics on non-ASCII input — now reachable from the new MCP tool
`is_platform_address_string` slices `s[..hrp.len()]` by byte offset after only a length check. A non-ASCII string whose first code point straddles the HRP boundary (4 for `dash`, 5 for `tdash`) panics on the string slice. The function pre-dates this PR, but the new `parse_transfer_core_address` in `src/mcp/tools/identity.rs:876` feeds it user-controlled `to_address` from a JSON MCP request after only `trim()`, which does not normalize non-ASCII. A single bad request to `identity_masternode_credits_withdraw` can panic the in-process MCP server. Comparing on `s.as_bytes()` (e.g. `s.as_bytes().get(..hrp.len()).is_some_and(|h| h.eq_ignore_ascii_case(hrp.as_bytes())) && s.as_bytes().get(hrp.len()) == Some(&b'1')`) makes the check non-panicking. Add a regression test for non-ASCII input at the same time.
In `tests/backend-e2e/identity_masternode_withdraw.rs`:
- [SUGGESTION] tests/backend-e2e/identity_masternode_withdraw.rs:264-314: TC-MN-050/051 bypass the tool — owner-mode dispatch is never exercised
`test_mn050_owner_withdraw_to_payout` and the companion TC-MN-051 build `IdentityTask::WithdrawFromIdentity` directly rather than calling `IdentityMasternodeCreditsWithdraw::invoke`. The tool's owner/transfer destination resolution, purpose→KeyID mapping, `payout_address` echo, owner+address contradiction guard, and SPV gate are therefore never exercised end-to-end on testnet — exactly how the blocking owner-mode mismatch went undetected. Drive these tests through the tool's `invoke()` so the production code path is what the suite verifies. The test comment on line 296 ("OWNER mode dispatches to_address = None") then becomes an actual assertion on the tool's behavior rather than a description of the hand-built task.
Note: this review is pinned to dispatcher-claimed commit 6d823786; the PR branch had already advanced to b9cb11bb before posting, so findings are posted body-only rather than inline-mapped against the newer live diff.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Verifier pass against b9cb11b. The prior blocking owner-mode dispatch bug (QA-001) is RESOLVED via the new pure resolve_withdrawal_plan (owner -> dispatch_address: None) wired in at line 1066, with new unit-test coverage. One prior blocking finding still applies: is_platform_address_string byte-slices a &str and panics on non-ASCII input, and this path is now reachable from the new identity_masternode_credits_withdraw tool BEFORE the SPV gate. Five lower-severity carried-forward quality items (Secret-wrap timing, pre-SPV ProTxHash validation, duplicate withdrawal-mode labels, KeyMode vocabulary duplication, and e2e tests bypassing invoke()) are unchanged at HEAD. No new latest-delta defects beyond these carried-forward items.
🔴 1 blocking | 🟡 4 suggestion(s) | 💬 1 nitpick(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/model/address.rs`:
- [BLOCKING] src/model/address.rs:14-24: is_platform_address_string panics on non-ASCII input; reachable from the new withdraw tool
STILL VALID at b9cb11bb. The helper byte-slices `s[..hrp.len()]` after only a `s.len() > hrp.len()` length check — if `hrp.len()` (4 for `dash`, 5 for `tdash`) falls inside a multi-byte UTF-8 code point, the slice panics. The new `identity_masternode_credits_withdraw` tool feeds user-controlled `to_address` straight through `parse_transfer_core_address` (src/mcp/tools/identity.rs:888), which is called by `resolve_withdrawal_plan` at line 1057 — BEFORE the SPV gate at line 1062. A single MCP request with `key_mode=transfer` and a non-ASCII `to_address` whose first character is multi-byte (e.g. `日本人ABC`, 9 bytes with char boundaries at 0/3/6/9) panics the in-process MCP server instead of returning `InvalidParam`. The fix compares on bytes (the function is ASCII-only by design). Add a regression test for non-ASCII input — the new withdraw tests cover only ASCII.
In `src/mcp/tools/identity.rs`:
- [SUGGESTION] src/mcp/tools/identity.rs:743-768: Wrap masternode private keys in Secret before the SPV await
STILL VALID. Cheap validation runs at lines 746-752, then `ensure_spv_synced(&ctx).await` runs at line 756 with `param.{owner,voting,payout}_private_key` still held as ordinary `String`s. Only at lines 762-764 are they moved into `Secret::new(...)`. If the SPV gate waits a long time or times out (up to the 10-minute cap), the plaintext key material sits in the async state machine without zeroize/mlock protection. The rest of the crate moves sensitive values into `Secret`/`Zeroizing` at the input boundary. Wrap the three keys into `Secret::new` immediately after `require_at_least_one_signing_key` so plaintext lifetime is bounded; validation ordering is preserved.
- [SUGGESTION] src/mcp/tools/identity.rs:743-759: Validate pro_tx_hash before the SPV wait
STILL VALID. The load tool validates network presence/match, node type, and the at-least-one-signing-key rule before `ensure_spv_synced`, but does not validate `pro_tx_hash` — line 759 forwards the raw `String` into `IdentityInputToLoad.identity_id_input` for the backend task to re-parse after SPV. `masternode_input::decode_identity_id` is a stateless cheap check; calling it before the SPV gate keeps the cheap-validation-before-network pattern consistent and lets a malformed ProTxHash fail as `InvalidParam` rather than as an SPV error when the chain is unavailable. The decoded `Identifier` could then be reused instead of having the backend re-parse the string.
- [SUGGESTION] src/mcp/tools/identity.rs:781-805: KeyMode vocabulary duplicated between Tool A output and Tool B input/output
STILL VALID. The load tool's `available_withdrawal_keys` output hand-writes literal `"owner"` / `"transfer"` strings at lines 788 and 792; the withdraw tool's `key_mode` echo writes the same literals again at 1083-1084; `parse_key_mode` in src/model/masternode_input.rs:55 parses the same vocabulary. A typo on either side becomes silent contract drift between Tool A's advertised modes and Tool B's accepted input. All three call sites live in this PR — adding a small `KeyMode::as_str()` (or `Display`) in `model/masternode_input.rs` and using it on both sides removes the duplication without adding abstraction overhead.
In `tests/backend-e2e/identity_masternode_withdraw.rs`:
- [SUGGESTION] tests/backend-e2e/identity_masternode_withdraw.rs:264-382: E2E withdrawal tests still hand-build IdentityTask::WithdrawFromIdentity instead of driving invoke()
PARTIALLY MITIGATED, STILL VALID. The latest delta adds unit tests against the new pure `resolve_withdrawal_plan` resolver, which is the regression guard for the QA-001 owner-mode dispatch bug. But `git grep -n 'IdentityMasternodeCreditsWithdraw::invoke\|IdentityMasternodeLoad::invoke' tests/` returns nothing: TC-MN-050 (lines 297-302) and TC-MN-051 (lines 365-370) still construct `IdentityTask::WithdrawFromIdentity` directly rather than calling `IdentityMasternodeCreditsWithdraw::invoke`. As a result the SPV gate ordering, network echo, owner+address contradiction check, identity DB lookup, and `IdentityMasternodeWithdrawOutput::to_address` echo are not exercised against a real chain — only the pure resolver is. Drive at least TC-MN-050/051 through `IdentityMasternodeCreditsWithdraw::invoke` so the production code path is what the e2e suite verifies on testnet.
Awaits WalletBackend::shutdown() before the Tokio runtime is torn down, stopping the platform-address-sync / identity-sync coordinators so their timer-based retries no longer panic during runtime shutdown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…llet tools Extends ensure_spv_synced to poll migration_status() until the cold-start storage migration finishes (60 s timeout) before waiting for SPV. Prevents WalletStorageNotReady fast-fails when a wallet tool is dispatched before the migration that runs on first wallet-backend wiring has completed. Adds McpToolError::StorageNotReady (code -32005) with an actionable message, and two new unit tests covering its Display and distinct error code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… withdraw
ensure_spv_synced now runs before get_identity_by_id, so a cold standalone
det-cli process builds the wallet backend before the identity-kv lookup.
Fixes the WalletBackendNotYetWired ("wallet is still starting up", -32603)
fast-fail on a fresh process.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Carried-forward + incremental review at acb06b1. The non-ASCII panic in is_platform_address_string remains the sole blocking issue — reproduced locally: slicing s[..hrp.len()] panics on UTF-8 boundary inputs and is still reachable through the new MCP withdraw tool's transfer-mode path. The new lifecycle delta (graceful wallet-backend shutdown, storage-migration wait, withdraw-tool reordering) is mostly sound, but the shutdown is incomplete on two paths (stdio ? error short-circuits, and headless after network_switch) and the storage-migration wait observes a migration state that is never set in standalone MCP. Five prior quality/test findings still apply unchanged; one (pre-SPV ID validation) was partially fixed for the withdraw tool but not for the load tool.
🔴 1 blocking | 🟡 4 suggestion(s)
1 additional finding(s) omitted (not in diff).
4 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
🤖 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/model/address.rs`:
- [BLOCKING] src/model/address.rs:14-24: is_platform_address_string panics on non-ASCII input; reachable from the new MCP withdraw tool
Line 17 still slices `s[..hrp.len()]` against a `&str`. Rust panics if the byte offset falls inside a multi-byte UTF-8 code point. With `hrp.len() == 4` (`dash`) or `5` (`tdash`), any input whose first character is a 3-byte sequence (e.g. `日本ABC`, char boundaries 0/3/6/9) trips the slice at byte 4 — verified locally: `&"日本ABC"[..4]` panics with `byte index 4 is not a char boundary`. The new `identity_masternode_credits_withdraw` tool feeds user-controlled `to_address` through `parse_transfer_core_address` (src/mcp/tools/identity.rs:1066, inside `resolve_withdrawal_plan`) — on a synced standalone process, one MCP request with `key_mode=transfer` and a non-ASCII `to_address` panics the in-process MCP server instead of returning `InvalidParam`. Switch to byte comparison (HRPs are ASCII by design) and add a non-ASCII regression test.
In `src/bin/det_cli/headless.rs`:
- [SUGGESTION] src/bin/det_cli/headless.rs:48-56: Headless shutdown only stops the current backend after a network_switch
The headless exit path shuts down only `swappable.load_full()`'s currently active context. `BackendTask::SwitchNetwork` (src/backend_task/mod.rs:572-628) builds a fresh `AppContext` for the new network and wires its backend, but never calls `stop_spv()` on the old context. After a session that switches networks, the previous network's `WalletBackend` (run loop + periodic coordinators) is still alive and is then dropped during runtime teardown — exactly the timer-registration panic class this delta is meant to prevent. The stdio path has the same shape via `DashMcpService::shutdown_wallet_backend`. Track every context wired during the session (or shut down the outgoing backend at the `swap_context` callsite in src/mcp/tools/network.rs:250) so all backends are quiesced before runtime drop.
In `src/mcp/mod.rs`:
- [SUGGESTION] src/mcp/mod.rs:29-44: start_stdio skips shutdown_wallet_backend() on the `?` error paths
Both `service.serve(...).await?` at :36 and `server.waiting().await?` at :37 short-circuit with `?` before the shutdown at :42. If `waiting()` errors after a tool has already wired the wallet backend (e.g. mid-session transport hiccup), the runtime drops the backend uncleanly — the same panic class the commit was meant to eliminate. The 5-second `runtime.shutdown_timeout(...)` backstop in `src/bin/det_cli/connect.rs:36` only bounds the wait, it doesn't quiesce the coordinators. Capture `let result = server.waiting().await;` (no `?`), call `service_for_shutdown.shutdown_wallet_backend().await` unconditionally, then return `result` — mirroring the layout already used in `src/bin/det_cli/headless.rs:30-58` where shutdown runs regardless of the server result.
In `src/mcp/resolve.rs`:
- [SUGGESTION] src/mcp/resolve.rs:130-161: ensure_storage_ready waits for a migration that standalone MCP never starts
`ensure_storage_ready` only blocks when `ctx.migration_status().state().is_running()`. In standalone/headless MCP, `init_app_context` (src/mcp/server.rs:232) does not dispatch any migration task, and `MigrationTask::FinishUnwire` is only ever dispatched from the GUI cold-start path (`AppState::dispatch_cold_start_migration` at src/app.rs:1121,1881) or the shielded-tab retry button. That means det-cli/headless always sees `MigrationState::Idle`, the new wait short-circuits, and the underlying `run_backend_task` gate (`is_wallet_touching` short-circuits only when migration is *Running*) also passes — so wallet-touching tools may run against not-yet-migrated sidecars on a cold standalone process. Either dispatch the migration from the headless/CLI context path before serving tools, or kick it off inside `ensure_spv_synced` before observing its state.
In `src/mcp/tools/identity.rs`:
- [SUGGESTION] src/mcp/tools/identity.rs:781-1088: KeyMode vocabulary duplicated across load output, withdraw echo, and parser
`available_withdrawal_keys` writes literal `"owner"` / `"transfer"` at :788 and :792; the withdraw tool repeats the same literals at :1087-1088; `parse_key_mode` (src/model/masternode_input.rs:55) parses the same vocabulary from raw strings. All three sites live in this PR, so a typo on any side becomes silent contract drift between Tool A's advertised modes and Tool B's accepted input. A small `KeyMode::as_str()` (or `Display`) in `model/masternode_input.rs` used at every site removes the duplication with no abstraction overhead.
`quiesce()` (called by `backend.shutdown()`) is a persister-drain
barrier, not a thread-join barrier. Each coordinator (`identity-sync`,
`platform-address-sync`, `shielded-sync`) runs on a dedicated OS thread
that calls `Handle::block_on`. After `sync_now()` completes, the thread
is still alive and enters:
tokio::select! {
_ = tokio::time::sleep(interval) => {} // panics if runtime shutting down
_ = cancel.cancelled() => break,
}
`tokio::select!` polls arms in random order. If `sleep(interval)` is
polled before `cancel.cancelled()` (which IS immediately ready) and the
Tokio runtime is already shutting down, `Sleep::poll` panics:
"A Tokio 1.x context was found, but it is being shutdown."
Fix: after `backend.shutdown()` (cancel tokens cancelled, no sync pass
in flight), sleep 100 ms inside `block_on` — while the runtime is still
alive — to give the OS scheduler time to run the coordinator threads
through their `select! → cancel.cancelled() → break` exit path before
the runtime tears down.
The upstream fix (storing and joining the OS thread's `JoinHandle` in
`quiesce()`) would be airtight; this is the correct DET-side mitigation
until that lands in `rs-platform-wallet`.
The sleep only executes when a wallet backend was actually started; the
no-backend early-return path is unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Incremental review at 98e9638. The latest delta (acb06b1..98e9638) adds a documented 100 ms post-shutdown grace sleep in DashMcpService::shutdown_wallet_backend (stdio path) plus block-comment documentation. All 9 prior findings from the acb06b1 review remain valid at HEAD: the non-ASCII slice panic in is_platform_address_string is reachable from the new MCP withdraw tool's parse_transfer_core_address before the SPV gate; Secret-wrap timing, pre-SPV ProTxHash validation in the load tool, KeyMode vocabulary duplication, available_withdrawal_keys duplicates, ensure_storage_ready/migration mismatch, start_stdio error-path skip, network-switch lifecycle leak, and the e2e bypass of invoke() are all unchanged. The new delta also exposes a related gap: the 100 ms coordinator-thread grace lives only in the stdio shutdown helper — the headless HTTP path in src/bin/det_cli/headless.rs:54-56 calls backend.shutdown().await directly without the same grace.
🔴 1 blocking | 🟡 1 suggestion(s)
1 additional finding(s) omitted (not in diff).
8 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
🤖 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/model/address.rs`:
- [BLOCKING] src/model/address.rs:14-24: is_platform_address_string panics on non-ASCII input; reachable from the new MCP withdraw tool
CARRIED FORWARD from prior reviews. Line 17 slices `s[..hrp.len()]` against a `&str` after only a `s.len() > hrp.len()` length check. Rust panics if the byte offset falls inside a multi-byte UTF-8 code point. With `PLATFORM_HRP_MAINNET="dash"` (4 bytes) or `PLATFORM_HRP_TESTNET="tdash"` (5 bytes), any input whose first character is a 3-byte UTF-8 sequence (e.g. `日本ABC` — char boundaries 0/3/6/9) trips the slice at byte 4 with `byte index 4 is not a char boundary`. The new `identity_masternode_credits_withdraw` tool feeds user-controlled `to_address` through `parse_transfer_core_address` (src/mcp/tools/identity.rs:888), called from `resolve_withdrawal_plan` at line 974 AFTER the SPV gate but before any address parsing — so a single MCP request with `key_mode="transfer"` and a non-ASCII `to_address` panics the in-process MCP server instead of returning `InvalidParam`. The HRPs are ASCII by design; switch to byte comparison and add a non-ASCII regression test.
In `src/bin/det_cli/headless.rs`:
- [SUGGESTION] src/bin/det_cli/headless.rs:53-56: Headless HTTP shutdown skips the new coordinator-grace window
NEW IN LATEST DELTA. Commit 98e96380 adds a 100 ms post-`backend.shutdown()` sleep in `DashMcpService::shutdown_wallet_backend` (src/mcp/server.rs:179-190), explicitly documenting that `backend.shutdown()` cancels coordinators but does NOT join their dedicated OS threads — they may still reach the outer `tokio::select!` and call `tokio::time::sleep(interval)` as the runtime begins shutdown, panicking with `A Tokio 1.x context was found, but it is being shutdown.` The headless HTTP exit path here does NOT use that helper: it calls `backend.shutdown().await` directly at line 55, then exits the `block_on` block and enters `runtime.shutdown_timeout(Duration::from_secs(5))` at line 63. This is the primary `det-cli --headless` path the PR adds for operators, so the exit-panic mitigation is incomplete in the path that needs it most. Either share the grace via a helper, or call the existing `service.shutdown_wallet_backend()` from here.
The 100ms grace sleep from 98e9638 fails under live conditions: DAPI retries already in flight when `quiesce()` returns extend past any fixed window, causing all three coordinator threads (identity-sync, platform-address-sync, shielded-sync) to panic at exit. Root cause recap: each coordinator runs on a dedicated OS thread via `Handle::block_on`. `quiesce()` waits for `is_syncing==false` (persister drain) but does NOT join the thread. The thread is still alive and enters `tokio::select!{ sleep(interval) | cancel.cancelled() }` with random arm ordering. If `sleep` is polled first after the runtime starts tearing down, `Sleep::poll` panics: "A Tokio 1.x context was found, but it is being shutdown." No sleep duration can reliably beat an in-flight DAPI retry. Deterministic fix: `std::process::exit` after flushing stdout/stderr, applied at all three CLI exit points: - `run_stdio_server()` (`det-cli serve`): replaces `shutdown_timeout`; now returns `!` since it always exits. - `run_headless()` (`det-cli headless`): same; retains early-return error path for config failures before the server starts. - one-shot tool path (`main`): replaces `Ok(())` tail; exit code from the block_on result. `backend.shutdown()` (persister drain) is kept in `shutdown_wallet_backend` and `run_headless` for correctness — it ensures no half-written `TokenBalanceChangeSet` is abandoned — but it is no longer expected to stop the coordinator threads before the runtime tears down. Safety: the tool result is printed and flushed before `process::exit`; all SQLite writes issued before the tool returned are transaction-committed; the platform-wallet-storage single-open lock is process-global and is reclaimed by the OS on exit, so the next invocation is unaffected. The upstream fix (joining the OS thread's `JoinHandle` in `quiesce()`) would be the library-level solution; this is the correct DET-side mitigation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Cumulative verification at 74cf43a. The latest delta replaces the 100ms post-shutdown grace with std::process::exit in run_stdio_server, run_headless, and the one-shot CLI path — this deterministically eliminates the coordinator-thread/timer-wheel panic class, fully resolving prior finding #2. Nine prior findings remain STILL VALID and are carried forward; one new low-confidence finding (one-shot CLI hard-exits without draining the in-process backend, codex-rust-quality only) is added. The lone blocking issue is the non-ASCII slice panic in is_platform_address_string, reachable from the new MCP withdraw tool's transfer-mode path with user-controlled input.
🔴 1 blocking | 🟡 1 suggestion(s)
1 additional finding(s) omitted (not in diff).
8 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
🤖 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/model/address.rs`:
- [BLOCKING] src/model/address.rs:14-24: is_platform_address_string panics on non-ASCII input; reachable from the new MCP withdraw tool
CARRIED FORWARD — STILL VALID at 74cf43af. Line 17 still does `s[..hrp.len()].eq_ignore_ascii_case(hrp)` against a `&str` after only a byte-length check `s.len() > hrp.len()`. Rust panics when the byte offset falls inside a multi-byte UTF-8 code point. With `PLATFORM_HRP_MAINNET = "dash"` (4 bytes) or `PLATFORM_HRP_TESTNET = "tdash"` (5 bytes), any input whose first character is a 3-byte UTF-8 sequence (e.g. `日本ABC`) trips the slice at byte 4 with `byte index 4 is not a char boundary`. The new `identity_masternode_credits_withdraw` tool feeds user-controlled `to_address` through `parse_transfer_core_address` (src/mcp/tools/identity.rs:888), invoked from `resolve_withdrawal_plan` at line 974 — so a single MCP request with `key_mode="transfer"` and a non-ASCII `to_address` panics the in-process MCP service instead of returning `InvalidParam`. The unit tests at lines 410-425 only cover ASCII inputs. HRPs are ASCII by design — compare on bytes and add a non-ASCII regression test.
In `src/bin/det_cli/main.rs`:
- [SUGGESTION] src/bin/det_cli/main.rs:129-146: One-shot standalone CLI hard-exits without draining the in-process backend
NEW IN LATEST DELTA. The one-shot CLI now calls `std::process::exit` immediately after `runtime.block_on(run(cli))`. In standalone mode `run()` uses `connect_in_process()`, which spawns a local `DashMcpService`; wallet-facing tools such as `identity_masternode_credits_withdraw` wire the wallet backend and start coordinator persisters via `ensure_spv_synced`. Unlike `start_stdio` and `run_headless`, this path has no handle to the service after the call and never awaits `DashMcpService::shutdown_wallet_backend()`, so process exit kills in-flight coordinator change-set writes. The inline comment correctly notes that SQLite transaction atomicity prevents corruption and that the tool's own writes are committed, but it does not address pending coordinator persister writes that were started in the background during the tool invocation. Capture the service from `connect_in_process` for one-shot calls and run `shutdown_wallet_backend().await` before computing `exit_code`.
H2: Extract masternode tools to src/mcp/tools/masternode.rs, rename to masternode_identity_load / masternode_credits_withdraw. B1: Fix is_platform_address_string panic on non-ASCII input — use as_bytes() throughout instead of str byte-slicing; add non-ASCII regression test. H1 + S1: Add Deserialize + JsonSchema to model::secret::Secret (feature- gated to mcp/cli). Change MasternodeIdentityLoadParams private-key fields from String to Secret so keys are mlock-ed from deserialization, before any SPV wait. S2: Call decode_identity_id before ensure_spv_synced in the LOAD tool so a malformed ProTxHash is rejected immediately rather than after a 10-min sync. S3: Add impl Display for KeyMode in masternode_input.rs; replace three literal "owner"/"transfer" sites with .to_string() so the canonical string is defined once. S4: Rewrite TC-MN-050 and TC-MN-051 to call MasternodeCreditsWithdraw::invoke() through a DashMcpService, exercising key-mode resolution, the payout-address echo, and the SPV gate. S5: Fix headless shutdown to also drain the initial context's wallet backend when the network was switched after startup (Arc::ptr_eq guard). S6: Restructure start_stdio to always call shutdown_wallet_backend even when serve() or waiting() returns an error. S7: Dispatch MigrationTask::FinishUnwire from ensure_spv_synced in standalone/headless mode where the GUI frame-loop never runs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Rename MasternodeCreditsWithdraw → MasternodeWithdraw and tool name masternode_credits_withdraw → masternode_withdraw (CLI: masternode-withdraw) per spec; all callers and tests updated. - S5 (network_switch backend drain): move the outgoing-backend drain from headless.rs to the swap_context callsite in NetworkSwitch::invoke, so every network switch immediately drains the replaced WalletBackend rather than waiting until process exit. headless.rs simplified back to draining only the active (current) context. - Docs: update MCP.md and CLI.md to reflect new tool names, corrected key handling description (params are now typed Secret, not plain String). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AmYyq2qZdWpxoC8BNgVxh
Review fix-pass —
|
| # | Finding | What changed | File(s) |
|---|---|---|---|
| B1 🔴 | non-ASCII panic in is_platform_address_string |
&str byte-slice → byte comparison; added non-ASCII regression test |
src/model/address.rs |
| H2 | split masternode tools into their own module + rename | new masternode.rs; tools renamed masternode_identity_load / masternode_credits_withdraw; structs Masternode*; registered in tool_router() |
src/mcp/tools/masternode.rs, identity.rs, mod.rs, server.rs |
| H1 + S1 | use a zeroizing secret type; bound plaintext lifetime | Secret gains Deserialize (+feature-gated JsonSchema); load-tool key params are Secret from deserialization — no plain String across the SPV wait |
src/model/secret.rs, masternode.rs |
| S2 | validate pro_tx_hash before SPV (load tool) |
decode_identity_id(...) now runs before ensure_spv_synced |
masternode.rs |
| S3 | KeyMode vocabulary duplicated ×3 |
impl Display for KeyMode → canonical "owner"/"transfer"; all sites use it |
src/model/masternode_input.rs, masternode.rs |
| S4 | e2e tests bypassed invoke() |
TC-MN-050/051 now drive MasternodeCreditsWithdraw::invoke(); owner-mode to_address=None is a real assertion |
tests/backend-e2e/identity_masternode_withdraw.rs |
| S5 | network-switch backend leak | headless exit now drains the initial context too (see caveat) | src/bin/det_cli/headless.rs |
| S6 | start_stdio skipped shutdown on ? error paths |
shutdown now runs unconditionally; result propagated after | src/mcp/mod.rs |
| S7 | ensure_storage_ready never waited in standalone |
ensure_spv_synced dispatches MigrationTask::FinishUnwire before the storage-ready wait |
src/mcp/resolve.rs |
S8 (one-shot hard-exit) was already correct by design — no change.
Tool name change (heads-up for reviewers)
identity_masternode_load → masternode_identity_load, identity_masternode_credits_withdraw → masternode_credits_withdraw (CLI: masternode-identity-load, masternode-credits-withdraw). Safe — these tools are new in this unmerged PR, no external consumers.
⚠️ Caveats still in flight
- Docs (
docs/MCP.md,docs/CLI.md) not yet updated for the rename — fix commit incoming. - S5 drains the initial context at exit, not at the swap point — a single switch is covered, but a multi-switch session (A→B→C) still orphans the intermediate backend. Under consideration; hard-exit prevents the teardown panic regardless.
- S7 migration-dispatch idempotency/safety is under QA review.
I'll follow up here once QA + the docs commit land.
🤖 Co-authored by Claudius the Magnificent AI Agent
… rename H2 doc-fix pass: - Rename MasternodeWithdraw → MasternodeCreditsWithdraw / masternode_withdraw → masternode_credits_withdraw throughout code (the previous commit had wrongly shortened the name; the user-confirmed name is masternode_credits_withdraw). - docs/MCP.md, docs/CLI.md: replace stale identity_masternode_* tool names with the real final names (masternode_identity_load, masternode_credits_withdraw) and matching CLI hyphenated forms. - docs/ai-design/2026-06-18-masternode-cli-withdraw/ (all 3 docs): replace every identity_masternode_load / identity_masternode_credits_withdraw / identity- masternode-* reference with the canonical names; update IdentityMasternode* struct placeholders to MasternodeIdentityLoad* / MasternodeCreditsWithdraw*. - src/model/masternode_input.rs: fix error message and test assertion that still referenced the old CLI command identity-masternode-load; update to masternode-identity-load so the self-resolution hint is accurate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AmYyq2qZdWpxoC8BNgVxh
| } | ||
| }; | ||
|
|
||
| // Hard-exit: bypass Tokio runtime teardown to prevent coordinator OS threads |
There was a problem hiding this comment.
I don't really like this approach. We should rather shutdown the OS threads first, retrieve their shutdown status, and then shutdown the tokio runtime.
Final fix-pass pushed —
|
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Cumulative review at 51a12f8. The latest delta is a heavy fix-pass: 7 of 10 prior findings are FIXED (B1 non-ASCII slice panic, S1 Secret-wrap timing, S2 pre-SPV ProTxHash validation, S3 KeyMode unification, S4 e2e MasternodeCreditsWithdraw::invoke coverage, S6 unconditional start_stdio shutdown, S7 standalone MigrationTask dispatch). Three prior findings remain — partial S5 (multi-switch headless drain), and unchanged S9/S10 (one-shot CLI hard-exit, duplicate withdrawal-mode labels). New in this delta: an H2 refactor renamed the masternode tools from identity_masternode_* to masternode_*, but docs/MCP.md, docs/CLI.md, and the ai-design specs still advertise the old names — every documented headless workflow is now broken. Requesting changes for the naming/doc mismatch only.
🔴 1 blocking | 🟡 1 suggestion(s) | 💬 1 nitpick(s)
1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
🤖 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 `docs/MCP.md`:
- [BLOCKING] docs/MCP.md:85-86: Documented MCP/CLI tool names don't match the registered names
The H2 refactor renamed the registered tools to `masternode_identity_load` (src/mcp/tools/masternode.rs:124) and `masternode_credits_withdraw` (src/mcp/tools/masternode.rs:425), but the public-facing docs still advertise the old `identity_masternode_*` names:
- docs/MCP.md:85-86 lists `identity_masternode_load` and `identity_masternode_credits_withdraw` with example CLI commands `det-cli identity-masternode-load` and `det-cli identity-masternode-credits-withdraw`.
- docs/CLI.md:158, 169, 176 walk users through `det-cli identity-masternode-load` / `det-cli identity-masternode-credits-withdraw`.
- The PR's ai-design spec (`docs/ai-design/2026-06-18-masternode-cli-withdraw/{01,02,03}-*.md`) uses the old names throughout.
The CLI discovers tools dynamically via `tools/list` and hyphen-translates them, so `det-cli identity-masternode-load` resolves to `identity_masternode_load`, which is not registered — every documented workflow fails with an unknown-tool error. Either (a) update all three doc surfaces to the new `masternode_*` names (preferred — the new layout follows the `{domain}_{object}_{action}` convention in CLAUDE.md and matches the new src/mcp/tools/masternode.rs module split), or (b) revert the registered names back to `identity_masternode_*` and keep masternode tools nested under the identity domain. The in-code error-message references (masternode.rs:376, 381, 485) already use the new `masternode-identity-load` spelling, so option (a) is the consistent path.
In `src/bin/det_cli/headless.rs`:
- [SUGGESTION] src/bin/det_cli/headless.rs:54-68: Headless drain still misses intermediate backends after multiple network switches
CARRIED FORWARD from prior S5 — PARTIALLY FIXED at 51a12f81. The new drain shuts down both the currently-active context and the initial context (covering the single-switch case), but a headless session that issues `network_switch` more than once (e.g. mainnet → testnet → devnet) still leaks every intermediate `AppContext`: `service.swap_context()` in src/mcp/server.rs replaces the active `ArcSwap` pointer without first calling `shutdown()` on the outgoing backend, and the exit-path drain only sees the first and last pointers. The cleanest fix is to shut down the outgoing backend inside `swap_context` (or at the `network_switch` tool callsite, src/mcp/tools/network.rs) before storing the new context, so the lifecycle is local to the swap and headless exit only needs to drain the active pointer. Hard-exit at line 85 still prevents the coordinator-panic class regardless, so this is a refinement rather than a regression.
Responses to review threads(Posted as a PR comment — inline replies are currently blocked by a stale pending review on the bot account. Addressing all three threads here.) Hard-exit teardown —
|
…ment Graceful teardown (TODO pseudocode): - server.rs: add a new "Graceful teardown — plan for when upstream delivers" section to the `shutdown_wallet_backend` doc comment, with step-by-step pseudocode showing how to replace process::exit once WalletBackend::quiesce() joins coordinator OS threads. Lists the three call-sites to update. - connect.rs, main.rs, headless.rs: add one-line `TODO(graceful-teardown): replace with normal return once ...` above each of the three process::exit hard-exits, so they're easy to find and remove together when the upstream fix lands. QA-008 (MCP.md:110): - Fix overstated "no plain-String copy ever exists" claim. The correct description: Secret::new() copies the content to a mlock-backed buffer and zeroes + frees the transient serde String before returning, so no plain copy persists beyond the deserialization call. The original text implied the transient String never existed, which misrepresents the actual flow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AmYyq2qZdWpxoC8BNgVxh
Final commit pushed —
|
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Cumulative review at 33ef8a1. The latest delta resolves the prior blocking docs/tool-naming mismatch (B1) and the multi-network-switch backend drain (S5). No new blocking defects. Two carried-forward findings remain: a nitpick that masternode_identity_load's available_withdrawal_keys may emit duplicate labels for identities with multiple keys per purpose, and a new error-propagation suggestion at the SPV gate. The prior one-shot CLI hard-exit suggestion is intentionally deferred (documented in 74cf43a and the inline comment) and is dropped.
🟡 1 suggestion(s)
1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
🤖 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/mcp/resolve.rs`:
- [SUGGESTION] src/mcp/resolve.rs:201-203: Backend wiring errors are logged-and-dropped before the SPV wait
`ensure_wallet_backend_and_start_spv` returns a typed `TaskError` describing the precise failure (storage, secret store, backend setup, etc.), but `ensure_spv_synced` only logs it and falls through to the watcher loop. The loop sees `SpvStatus::Error` (which `ensure_wallet_backend_and_start_spv` sets on failure, per the comment in `context/wallet_lifecycle.rs:296-302`) and returns the generic `McpToolError::SpvSyncFailed`. Net effect: the caller correctly fails, but the typed root cause is discarded — the rest of the backend task system goes to lengths to preserve it. Propagating the original `TaskError` here gives MCP clients an actionable error instead of a bare "SPV sync failed."
…opagate typed SPV-wiring error Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AmYyq2qZdWpxoC8BNgVxh
Round-2 review fixes pushed —
|
There was a problem hiding this comment.
Pull request overview
Adds a headless (det-cli / MCP) flow for masternode/evonode identity loading and Platform credit withdrawals, plus several headless lifecycle fixes (SPV/storage gating, backend draining, and deterministic exit behavior) to make the end-to-end headless path reliable.
Changes:
- Introduces two new MCP tools:
masternode_identity_loadandmasternode_credits_withdraw, with shared stateless parsing/validation helpers. - Hardens headless lifecycle behavior: waits for cold-start migration, drains wallet backend on network switch/exit, and uses hard-exit to avoid Tokio teardown panics.
- Adds tests (unit + ignored backend-e2e) and updates CLI/MCP/user-story documentation for the new headless masternode workflows.
Reviewed changes
Copilot reviewed 23 out of 23 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/backend-e2e/main.rs | Registers the new ignored backend-e2e module for masternode withdrawal scenarios. |
| tests/backend-e2e/identity_masternode_withdraw.rs | Adds ignored, env-gated backend-e2e tests covering masternode load + owner/transfer withdrawal flows. |
| src/model/secret.rs | Adds Secret::is_blank() plus Deserialize/JsonSchema support for MCP params carrying secrets. |
| src/model/mod.rs | Exposes masternode_input module behind mcp/cli features. |
| src/model/masternode_input.rs | Implements stateless parsing/validation for masternode tool inputs (node type, key mode, identity id, key presence). |
| src/model/address.rs | Fixes potential UTF-8 slicing panic in is_platform_address_string and adds regression tests. |
| src/mcp/tools/network.rs | Drains outgoing wallet backend during network switch before swapping contexts. |
| src/mcp/tools/mod.rs | Registers the new masternode MCP tool module. |
| src/mcp/tools/masternode.rs | Implements the two new MCP tools, including preflight validation, SPV gating, and destination/key-mode rules. |
| src/mcp/tests.rs | Adds StorageNotReady to error-code uniqueness tests and adds message/code checks. |
| src/mcp/server.rs | Registers masternode tools and adds wallet-backend shutdown helper with detailed rationale. |
| src/mcp/resolve.rs | Adds cold-start migration wait (ensure_storage_ready) and wires migration finishing into ensure_spv_synced. |
| src/mcp/mod.rs | Ensures stdio server path drains backend on shutdown and avoids short-circuiting shutdown on errors. |
| src/mcp/error.rs | Adds StorageNotReady MCP error variant + code mapping. |
| src/bin/det_cli/main.rs | Converts CLI flow to deterministic hard-exit after running commands to avoid runtime teardown races. |
| src/bin/det_cli/headless.rs | Drains backend on shutdown and hard-exits after HTTP server termination. |
| src/bin/det_cli/connect.rs | Makes stdio MCP server path hard-exit (never returns) and flushes output before exit. |
| docs/user-stories.md | Adds MCP-003/MCP-004 user stories for headless masternode identity load and credit withdrawal. |
| docs/MCP.md | Documents the two new tools, SPV gating notes, and private-key handling guidance. |
| docs/CLI.md | Adds CLI walkthrough for masternode identity load + credit withdraw in owner/transfer modes. |
| docs/ai-design/2026-06-18-masternode-cli-withdraw/01-requirements-ux.md | Adds requirements/UX spec for the feature. |
| docs/ai-design/2026-06-18-masternode-cli-withdraw/02-test-spec.md | Adds detailed test specification (unit/tool/e2e cases). |
| docs/ai-design/2026-06-18-masternode-cli-withdraw/03-dev-plan.md | Adds development plan outlining tasks, sequencing, and coverage gaps. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…art_stdio doc wording
Fix C (LRitq): TC-MN-061 in tests/backend-e2e/identity_masternode_withdraw.rs
The redaction assertion checked !display.contains("bogus") and
!debug.contains("bogus"), where "bogus" is the variable name — it never
appears in a WIF string, making both assertions vacuously true.
Fix: capture the actual WIF value before it is moved into load_task()
(renamed wrong_key_wif, cloned at the call site), then assert
!display.contains(&wrong_key_wif) and !debug.contains(&wrong_key_wif).
The test is #[ignore] (backend-e2e); confirmed it still compiles.
Fix A (LRis4): src/model/secret.rs Deserialize impl doc
Removed the inaccurate "without ever living as a plain String" claim.
The impl does <String as Deserialize>::deserialize(), creating a transient
String before Secret::new consumes it. New wording: "deserializes into a
transient String, then moves it into the zeroizing/mlock'd buffer and
drops the transient, so no long-lived plain String copy persists."
Fix B (LRit_): src/mcp/mod.rs start_stdio doc
Removed "preventing panics from timer registration during runtime shutdown"
— shutdown_wallet_backend does NOT join coordinator OS threads so it does
not prevent that panic. New wording: "This does not prevent the coordinator
timer-wheel panic that occurs on Tokio runtime drop — shutdown_wallet_backend
does not join coordinator OS threads. std::process::exit in the caller is
the deterministic mitigation."
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AmYyq2qZdWpxoC8BNgVxh
Copilot review round addressed —
|
e2fdec6
into
docs/platform-wallet-migration-design
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Verifier confirms the latest delta (33ef8a1..a88a5b3) is documentation/comment-only — TODO(graceful-teardown) markers at three CLI exit call-sites, a planning doc-block in DashMcpService::shutdown_wallet_backend, and a docs/MCP.md wording refinement on Secret-wrap timing. No new defects introduced in this delta. Both prior findings are carried forward unchanged: resolve.rs still logs-and-drops the typed backend-wiring TaskError before entering the SPV wait, and masternode_identity_load can still emit duplicate withdrawal-mode labels for identities holding multiple OWNER or TRANSFER keys.
🟡 1 suggestion(s)
1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.
🤖 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/mcp/resolve.rs`:
- [SUGGESTION] src/mcp/resolve.rs:201-203: Propagate the typed backend-wiring TaskError instead of dropping it before the SPV wait
Carried forward from prior reviews — still valid at a88a5b3b. `ctx.ensure_wallet_backend_and_start_spv(sender)` returns a typed `TaskError` describing the precise startup failure (storage init, secret store, backend wiring, etc.), and `context/wallet_lifecycle.rs` also marks `SpvStatus::Error` before returning. `ensure_spv_synced` currently only logs that error via `tracing::warn!` and falls through into `ensure_storage_ready` + the SPV watcher loop, which then collapses the failure into the generic `McpToolError::SpvSyncFailed`. Net effect: the caller still fails, but the actionable root cause is discarded and the user can be made to wait through the full 10-minute SPV timeout for what is really a wiring failure. Returning the original error here preserves the typed-error boundary the rest of the backend task system maintains and gives MCP clients an actionable diagnosis.
…secret at-rest encryption (#865) * docs(secret-seam): Phase-1 design artifacts (UX disclosure + test case spec) UX disclosure spec by Diziet; 30-case TDD test spec by Marvin. Design reference for the secret-storage raw-SecretBytes seam re-architecture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * feat(wallet-backend): add raw-SecretBytes secret seam + typed errors (T2,T4) Crikey, here's the one socket every wallet secret will squeeze through. T2 — new wallet_backend/secret_seam.rs: SecretSeam over raw SecretBytes with put_secret/get_secret/delete_secret, a no-encryption pass-through to the upstream vault TODAY. Every put/get body carries the greppable `TODO(per-secret-encryption):` tag so wiring real per-secret encryption later is a localized change. Prompt-free — the passphrase requirement lives only in the retained legacy readers, never here. No-serialization guard mechanism: compile_fail doctests (no new deps — static_assertions/trybuild stay out of Cargo.toml). One asserts a newtype cannot derive Serialize over a SecretBytes; one asserts serde_json::to_string on a SecretBytes is rejected. If upstream ever adds Serialize to SecretBytes these start compiling and the canary fires (TS-INV-01). TS-INV-02 round-trips a SecretBytes through the real signatures (compiler is the assertion). T4 — TaskError variants (no String fields, typed #[source]): SecretSeam, SecretSeamMissing (loud funds-safety miss), IdentityKeyVault, IdentityKeyMissing. Promote the private assert_no_leak (hex + decimal-array) into a shared wallet_backend/leak_test_support.rs so the seam/sidecar/QI/Debug leak cases reuse one impl instead of copy-pasting. TS-NOLEAK-01: the on-disk vault file holds no raw secret in either form. Tests: 6 seam unit + 2 compile-fail doctests, all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * fix(model): redacting Debug for ClosedSingleKey (T9, 6a2818cd) ClosedSingleKey derived Debug and its encrypted_private_key holds the raw 32 key bytes in the no-password / pre-migration shape — a derived Debug dumped them as a decimal byte array straight into logs. Hand-write a redacting Debug mirroring ClosedKeyItem / SingleKeyEntry: key_hash + lengths, never the bytes. Parents SingleKeyData / SingleKeyWallet are safe by delegation. TS-DBG-01 asserts via the shared assert_no_leak_bytes (hex AND decimal-array — the decimal form is the one the pre-fix Debug leaked) at all three levels. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * feat(model): PrivateKeyData::InVault placeholder + migration probes (T1) Identity private keys get a non-resident home. New PrivateKeyData::InVault appended at bincode index 4 — discriminants 0-3 (AlwaysClear/Clear/Encrypted/ AtWalletDerivationPath) are untouched, so blobs written before it still decode (TS-RESID-02 round-trips all four pre-existing variants + InVault). Redacting Debug/Display arms (carries no bytes — trivially clean). KeyStorage probes: - is_in_vault / public_key_for — a vault placeholder reports true yet still surfaces its public key for display + signing-key selection. - take_plaintext_for_vault — rewrites every Clear/AlwaysClear to InVault and returns the raw bytes (Zeroizing) the migration must store in the vault FIRST (vault-before-blob order). Wallet-derived + encrypted keys untouched — they were never plaintext-at-rest. get/get_resolve_local gain an InVault arm (resolve through the vault, not locally). key_info_screen gains degraded InVault arms (securely-stored notice; full JIT view/sign via dedicated identity-key WalletTasks is the T8 follow-up). Promote the private assert_no_leak + distinctive_secret to the shared leak_test_support helper (no fork). TS-RESID-01 / TS-NOLEAK-03: post-migration KeyStorage has only InVault, and the re-encoded blob leaks neither secret in hex nor decimal-array form. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * feat(model,wallet-backend): WalletMeta+ImportedKey sidecar fields, schema-gated (T5) Non-secret metadata moves out of the per-wallet seed envelope into the sidecar. WalletMeta gains uses_password + password_hint. Because WalletMeta is positional bincode behind the DetKv envelope, #[serde(default)] alone is NOT forward-compatible (R-SCHEMA) — so a real version gate: WALLET_META_VERSION (v2) framed as [version | bincode] at the WalletMetaView boundary, plus a retained decode-only WalletMetaV1. decode_versioned detects v2 / v1-framed / bare-legacy and migrates a v1 blob into v2 (defaults uses_password=false), never positionally misparsing it. The global DetKv SCHEMA_VERSION is deliberately untouched (it governs every payload, not just WalletMeta). TS-META-01 covers all three shapes. ImportedKey gains public_key_bytes (the compressed SEC1 PUBLIC key) so the locked-render cold-boot path can rebuild a protected key's display wallet without the secret — moved out of the SingleKeyEntry vault blob ahead of the raw-seam migration. NON-secret; #[serde(default)] for old entries. write_wallet_meta now carries uses_password/password_hint from the open Wallet; the legacy-table drain (finish_unwire) defaults them (the authoritative flag is read from the envelope at the migrating unlock). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * chore(wallet-backend): satisfy fmt + clippy for the secret-seam batch - leak_test_support: drop redundant inner #![cfg(test)] (mod.rs already gates it). - encrypted_key_storage: factor take_plaintext_for_vault's return into the VaultBoundKey type alias (clippy::type_complexity). - wallet_hydration bench: carry the new WalletMeta password fields. - nightly-fmt whitespace. Gate: cargo +nightly fmt --all clean; cargo clippy --all-features --all-targets -D warnings clean; cargo test --all-features --workspace = 944 lib + 146 + 10 + 3 + 2 pass, 0 fail; 2 compile_fail doctests pass; det-cli standalone smoke (network-info / tools / core-wallets-list) all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * feat(wallet-backend): SecretScope::IdentityKey + seam-first SecretAccess (T3) The chokepoint learns identity keys and goes seam-first for everyone. - SecretScope::IdentityKey { identity_id:[u8;32], target, key_id } (DET-opaque; KeyID is just u32, PrivateKeyTarget is a DET model enum). identity_key_label() builds identity_key_priv.<m|v|o>.<key_id> — a stable one-char target tag keeps the label inside the upstream allowlist. - SecretPlaintext::IdentityKey + expose_identity_key; Plaintext::IdentityKey. Borrowed-only, zeroizing, never resident — same hygiene as the other kinds. - decrypt_jit is now SEAM-FIRST for all three classes: the raw label wins; the retained legacy reader (decrypt_hd_seed / SingleKeyEntry::decrypt) is the migration fallback for HD seeds and single keys. IdentityKey reads raw via the seam → loud IdentityKeyMissing if absent (never silent). - scope_has_passphrase: a migrated raw secret reports false (the password no longer gates it); only a not-yet-migrated legacy entry can still be protected; IdentityKey is always false → prompt-free fast-path → headless/MCP signing works. - DetSigner treats an IdentityKey plaintext as a raw single key (same secp256k1 shape, no derivation tree). Tests: TS-FAST-01 (identity key resolves prompt-free, ask_count 0, can_resolve_without_prompt true), IdentityKeyMissing is loud, TS-LEGACY-01 (legacy envelope served when raw absent), raw-wins-over-legacy precedence. The pre-existing protected-HD/single-key tests now exercise the legacy fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * feat(wallet-backend): identity_key_store + seed/single-key seam-raw writes (T6) Secrets start landing raw. No DET envelope for the new write paths. - New wallet_backend/identity_key_store.rs: IdentityKeyView with store/get/delete + store_all/delete_all over raw 32 bytes via SecretSeam (scope = identity_id, label identity_key_priv.<m|v|o>.<key_id>). NO StoredIdentityKey envelope — the InVault marker in the QI blob is the only on-disk trace. store_all is the migration's vault-first writer (call before the blob rewrite); delete_all backs purge_identity_scope. - WalletSeedView gains set_raw/get_raw/delete_raw (raw 64-byte seed under seed.raw.v1 via the seam) + legacy_envelope_get (retained decode-only reader). - write_seed_envelope now branches: a no-password wallet writes the RAW seed (encrypted_seed_slice() is verbatim the seed); a password wallet keeps the legacy AES-GCM envelope at creation and migrates lazily at unlock (T7). - import_wif_with_passphrase: unprotected import writes RAW 32 bytes under the existing single_key_priv.<addr> label (no SingleKeyEntry framing); protected import keeps the legacy SingleKeyEntry (lazy-migrates at unlock). The locked-render pubkey rides in the ImportedKey sidecar (the T5 field). SingleKeyEntry::decode treats a bare 32-byte blob as unprotected, so a raw-written key still rebuilds + opens at cold boot. Tests: identity_key_store round-trip / scope+target isolation / store_all+ delete_all; seed raw round-trip independent of the legacy label; single-key unprotected import is exactly 32 raw bytes (no framing) and signs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * feat: crash-safe dual-format migration + InVault resolver + vault delete (T7) This is the part that actually moves secrets. Funds-safety ordering throughout. Resolver (mod.rs): resolve_private_key_bytes gains the InVault route — keyed by is_in_vault/public_key_for, it fetches the raw bytes per-use via with_secret(IdentityKey{...}) (prompt-free). No chokepoint wired ⇒ fail closed (WalletLocked); bytes never resident. EAGER migration on load (dialog-free): - Identity keys (identity_db::migrate_identity_keys_to_vault, run per identity in load_identities_filtered): take_plaintext_for_vault → IdentityKeyView store_all (vault FIRST) → rewrite the QI blob with InVault. Vault-write failure restores the resident plaintext for this session and defers; a blob-rewrite failure is re-detected and retried next load. Idempotent. - No-password HD seeds (hydration::reconstruct_wallet): raw seam wins (precedence raw > legacy); a no-password legacy envelope is re-stored raw (set_raw, vault FIRST) then deleted. reconstruct_from_envelope extracted so the raw and legacy paths share the xpub-decode + build tail. LAZY migration on unlock (one prompt, the unlock the user already does): promote_and_maybe_migrate_hd_seed re-stores the just-decrypted legacy seed raw (set_raw before delete) inside the borrowed Zeroizing scope and reports migrated=true; handle_wallet_unlocked then flips WalletMeta.uses_password=false and shows the one-time disclosure (T8 Copy A/D). Delete: forget_wallet_local_state now deletes BOTH the raw seed and the legacy envelope (a wallet may be in either form) — closes a wipe gap where a migrated no-password seed would survive removal. identity_db.clear_identity_vault_keys drains an identity's raw vault keys on single-delete + devnet sweep. Loud, never silent: a seed in neither form ⇒ TaskError::SecretSeamMissing (was WalletNotFound) on both scope_has_passphrase and decrypt_jit. Tests: TS-EAGER-01/04 (no-pw seed migrates + idempotent), TS-CRASH-01 read (raw wins, legacy cleaned), TS-MISS-01 (SecretSeamMissing loud). Updated 5 wallet_lifecycle removal/clear tests to assert the raw seed (the new at-rest form) in BOTH precondition and post-delete. wallet_lifecycle 38, hydration 10, identity_db 16, encrypted_key_storage 4 — all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * feat: key_info_screen JIT identity signing + single-key Copy B disclosure (T8) Real JIT for vault-backed identity keys, and the per-key migration notice. Two new WalletTasks + handlers, opening with_secret(IdentityKey{...}): - DeriveIdentityKeyForDisplay → derive_identity_key_for_display: fetches the raw key JIT, returns only the WIF (Secret). - SignMessageWithIdentityKey → sign_message_with_identity_key: signs in the backend, returns only the public Base64 envelope. New result variants IdentityKeyForDisplay / IdentityMessageSigned (identity- flavored — carry identity_id/target/key_id, not a meaningless seed_hash). key_info_screen: the InVault arms are now real — "View Private Key" queues DeriveIdentityKeyForDisplay and renders the returned WIF/hex via the existing render_decrypted_key_grid; "Sign" queues SignMessageWithIdentityKey. The degraded placeholders are gone. display_task_result handles both new results. Single-key protected lazy migration + Copy B: verify_passphrase now re-stores the just-decrypted protected entry raw under the same label (upsert replaces the AES-GCM framing) and clears the persistent has_passphrase flag, returning a migrated bool. verify_single_key_passphrase surfaces the one-time per-key disclosure (Copy B — text DISTINCT from the wallet Copy A so set_global's dedup keeps both) on migration. decrypt_jit's sign path also lazy-migrates (migrate_single_key_to_raw + in-memory flag flip) — idempotent defense-in-depth. SingleKeyView::clear_passphrase_flag persists the flip to the sidecar. Tests: TS-LAZY-03 — protected single key migrates via the chokepoint, the vault holds raw 32 bytes after, and a second resolve under a never-prompt host is prompt-free with the WIF-plaintext bytes. secret_access 24 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * chore: fmt + clippy for the T3-T8 integration batch - secret_access: drop explicit_auto_deref on set_raw(seed_hash, seed) — a &Zeroizing<[u8;64]> auto-derefs to &[u8;64]. - nightly-fmt whitespace across the touched files. Gate: cargo +nightly fmt --all clean; cargo clippy --all-features --all-targets -D warnings clean; cargo test --all-features --workspace = 957 lib + 146 + 10 + 3 + 2 pass, 0 fail, 1 ignored (funded-testnet TS-SIGN-E2E-01); 2 compile_fail doctests pass; det-cli standalone smoke (network-info / core-wallets-list / tools) all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * fix(wallet-backend): dual-format read for WalletMeta + ImportedKey sidecars The real defect QA caught (PROJ-001/002/003 + SEC-003): appending fields to a positional-bincode DetKv value is format-breaking, and my T5 framing made it WORSE — WalletMeta writes went through kv.put::<Vec<u8>>(versioned-frame) and reads through kv.get::<Vec<u8>>, which type-confuses an OLD kv.put::<WalletMeta> blob (decodes the alias's UTF-8 bytes AS the Vec) → alias/is_main silently lost. ImportedKey appended public_key_bytes with no legacy reader → old keys vanish from the picker. Fix (one policy for both sibling sidecars): drop the hand-rolled version byte (SEC-003: it could collide with a bincode length varint — a 1/2-char alias). Instead lean on the DetKv schema envelope + try-decode-both: - write the current shape directly (kv.put::<WalletMeta> / ::<ImportedKey>); - on read, try the current shape; on a bincode Decode error (an old blob runs out of bytes for the appended fields) fall back to the legacy shape (WalletMetaV1 / ImportedKeyV1, decode-only) and RE-STORE in the new shape. Order is load-bearing and tested: the 6-field struct CANNOT decode a 4-field blob (runs past end), so "new first, then V1" never mis-promotes. A DetKv schema-version mismatch stays a hard error; only Decode triggers the fallback. Removes the now-dead encode_versioned/decode_versioned/WALLET_META_VERSION (PROJ-002 — the unreachable legacy branch + its overclaiming test are gone; the legacy path is now live via the view and tested end-to-end). Tests: model leg (ts_meta_01) asserts the order-sensitivity + the SEC-003 1/2-char-alias collision case; view legs (old_wallet_meta_blob_*, old_imported_key_blob_*) write an OLD blob exactly as the base branch did, read it back through the view preserving every field, and confirm re-store in the new shape. wallet::meta 3, wallet_meta 13, single_key all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * test(identity-db): identity-key migration, deletion, write-fault no-loss (QA-002/003/005) Refactor the eager identity-key migration core out of AppContext into a free fn migrate_keystore_to_vault(secret_store, id, qi, persist) returning a KeystoreMigration outcome, so the funds-safety logic is unit-testable with a bare SecretStore + a controllable persist closure (no full AppContext). QA-002 — migration is vault-FIRST: the persist closure asserts the raw keys are already in the vault and the blob being persisted is InVault-only; the AtWalletDerivationPath key is untouched; zero plaintext remains; idempotent (second run = Nothing). QA-005 — write-fault no-loss (the write half CRASH-01's read half misses): with the vault parent dir chmod'd read-only so store_all fails, the migration restores the resident plaintext keystore byte-for-byte, does NOT call persist, and reports VaultWriteFailed — keys never lost on a mid-write fault. (#[cfg(unix)].) QA-003 — identity-key deletion is scoped + isolated: delete_all over the victim's (target,key_id) set removes its vault keys while a second identity's key under the same (target,key_id) is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * test(wallet-lifecycle): assert lazy-migration secret post-conditions (QA-004) The protected-wallet-unlock test asserted only upstream registration. Add the secret post-conditions the lazy migration is actually for: after handle_wallet_unlocked the raw seed is written and equals the true 64-byte seed, the legacy envelope.v1 is deleted, WalletMeta.uses_password flipped false, and a SECOND resolve through a never-prompt chokepoint over the now-raw vault returns the seed with zero prompts (the migrated wallet is permanently prompt-free). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * test(backend-e2e): TS-SIGN-E2E-01 InVault identity signs + broadcasts (QA-001) New #[ignore] backend-e2e test: migrate the shared identity's plaintext signing keys to the vault (PrivateKeyData::InVault, exactly as the eager load-path migration does), assert residency (zero Clear/AlwaysClear remain), wire the chokepoint, then build + sign + broadcast an IdentityUpdateTransition. Signing runs through the async QualifiedIdentity Signer → resolve_private_key_bytes → with_secret(IdentityKey{..}) — the JIT free-rider path. A successful broadcast + the new key appearing on Platform proves the InVault MASTER key signed live without ever being resident. Requires E2E_WALLET_MNEMONIC + live DAPI/SPV; run command + RUST_MIN_STACK in the header. Compiles + registered in main.rs; left #[ignore] for a manual/live run during QA. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * refactor(wallet-backend): zeroize migration source, flavor identity-key errors, lift signed-message helper PROJ-004 (security): take_plaintext_for_vault now zeroizes the resident Clear/AlwaysClear array BEFORE the InVault overwrite drops it — de-residenting the key is the function's whole purpose, so it must wipe the source, not just the moved-out copy. PROJ-005: IdentityKeyView::store/get/delete now map the generic seam error to the identity-flavored TaskError::IdentityKeyVault (previously a producerless variant), so an identity-key vault failure surfaces with identity-specific banner copy. Wrong-length stays SecretDecryptFailed. QA-DEDUP-01: lift dash_signed_message (the recoverable-envelope builder) from sign_message_with_key.rs to backend_task/wallet/mod.rs as pub(crate); both the wallet-key and identity-key signers now call it instead of two drifting copies. The recovery-header round-trip tests move alongside the shared helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * test(secret-seam): TS-INV-03 audit guard + TS-NOLEAK-02 sidecar no-leak (SEC-001/002) SEC-001 (TS-INV-03): source-text audit over the changed secret-path modules — no Serialize/Encode struct may name a plaintext-key field (SecretBytes, Zeroizing<[u8, [u8;32], [u8;64]). Catches the bare-Vec/array plaintext bypass the compile_fail doctests can't (they only catch an embedded SecretBytes). The module list mirrors the blast-radius table; ciphertext fields are deliberately not flagged. Passes — the invariant holds today and now has a regression guard. SEC-002 (TS-NOLEAK-02): assert the encoded WalletMeta + ImportedKey sidecar blobs contain neither secret (hex AND decimal-array via the shared assert_no_leak_bytes), and that the ImportedKey's PUBLIC key IS present (locked render needs it). Canary coverage — the sidecars structurally hold no secret. Plus a clarifying "// no secret to (de)crypt" note at delete_secret instead of an encryption TODO. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * test(kittest): disclosure-banner copy coverage (QA-007/Diziet) Extract the interim at-rest disclosure copy into pure pub fns (wallet_migration_notice / single_key_migration_notice) + pub INTERIM_AT_REST_DETAILS, re-exported from context, so the exact copy is testable without an AppState and i18n-extractable. Both callsites now use them. New tests/kittest/disclosure_banner.rs (QA-007): Copy A and Copy B each render as Warning banners naming the wallet/key, the ⚠ icon shows (not color-only), the two copies are DISTINCT (so set_global's text-dedup keeps both when a wallet and a key migrate in one session), and all copy (A/B/D) is jargon-free (no AES/vault/seam/encryption/0600). 4 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * docs: comment hygiene + CLAUDE.md seam pointer + user-story softening (QA-DOC/DOC) QA-DOC-01: strip ephemeral review IDs from comments I authored in the secret-seam surface — "Smythe must-fix #3/#4/#5", "Q-HEADLESS", "(F-2)", "6a2818cd" — keeping the rationale prose. (Pre-existing PROJ-010/TC-W-*/F43/F63 in code outside this PR's diff are left untouched to avoid scope creep.) QA-DOC-02: drop the "Promoted from…" history line in leak_test_support.rs (belongs in git, not the module header). QA-DOC-03: secret_access module-header resolution order now lists the unprotected fast-path as an explicit step 2 (cache → unprotected → prompt), matching the three-branch body. DOC-001: CLAUDE.md wallet_backend bullet now points at secret_seam.rs as the single secret chokepoint + the TODO(per-secret-encryption): grep convention + the design dir. DOC-002: user-stories WAL-006 gains the post-migration no-password-prompt note; WAL-025 "modern encrypted vault" → "on-device secret vault" (no longer asserts encryption that is presently absent — the accepted interim regression). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * chore: nightly fmt for the QA-findings batch Whitespace-only reformat (cargo +nightly fmt --all) of the files touched while closing the QA findings. No behavioral change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * test(backend-e2e): seed Clear key so TS-SIGN-E2E-01 exercises the InVault JIT path The shared_identity() fixture registers a wallet-derived identity, so its keys are PrivateKeyData::AtWalletDerivationPath and take_plaintext_for_vault() (which migrates only Clear/AlwaysClear) correctly found nothing — the test panicked in setup before reaching the path under test. Add materialize_master_key_as_clear(): derive the master key's raw bytes from the HD seed through the real with_secret(SecretScope::HdSeed) chokepoint (identity index 0, key 0) and insert_non_encrypted() them as Clear, so the migration carries a genuine plaintext key into the vault as InVault and the JIT signing path produces a signature whose bytes match the on-chain master key. The !taken.is_empty() assertion is unweakened; no signer stub, no mocked broadcast. Stays #[ignore]: the live broadcast additionally needs a funding wallet that derives within its rehydrated window (the e2e funding step hit the known core-wallet gap-window/rehydration limitation, unrelated to the InVault path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6 * chore(deps): repin platform deps to feat/platform-wallet-secret-protection (fb7953ea) Moves the 4 dashpay/platform branch deps (dash-sdk, rs-sdk-trusted-context-provider, platform-wallet, platform-wallet-storage) — and their 23 transitive platform crates, 27 total — from fix/wallet-core-derived-rehydration@ea0082e6 to feat/platform-wallet-secret-protection@fb7953ea (PR #3953), establishing the green baseline for the secret-handling-hardening work. Done on top of the merge of origin/docs/platform-wallet-migration-design (ac0c3d9), which brought in #864 (headless masternode/evonode withdrawals) and #866 (DPNS blocking overlay). The merged DET tree compiles cleanly against the secret-protection branch — no API breakage. Verified green: cargo build --all-features cargo clippy --all-features --all-targets -- -D warnings cargo +nightly fmt --all -- --check Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(secret): open the vault keyless (file_unprotected) for the Tier-1 baseline PR #3953 ("platform-wallet-secret-protection") hardened upstream `SecretStore::file(path, passphrase)` to reject a blank passphrase (`SecretStoreError::BlankPassphrase`). DET's `open_secret_store` opened the vault with `SecretString::new("")`, so after the repin every AppContext init failed at the secret-store open and 7 secret_seam/secret_access tests broke. Switch to the explicit keyless door `SecretStore::file_unprotected(path)`, which upstream documents for exactly this model: the vault file itself is keyless (at-rest floor = owner-only perms) and per-secret confidentiality comes from Tier-2 object passwords on the individual secrets. Behavior for the Tier-1 baseline is unchanged from the old empty-passphrase open. Restores the green baseline at the fb7953ea pin: build/clippy/fmt clean, the 8 secret_seam/secret_access vault tests pass again. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(secret): add Tier-2 seam capability (protected set/get + scheme probe) Adds the upstream Tier-2 object-password path to the secret seam, the single coherent encrypt/decrypt chokepoint: - `put_secret_protected` / `get_secret_protected` seal/unseal a secret under its OWN object password via upstream `SecretStore::set_secret/get_secret` (Argon2id + XChaCha20-Poly1305). Per-secret, never a shared/per-wallet pw. - `scheme()` reports the at-rest tier (Absent / Unprotected / Protected) of a stored secret WITHOUT the password, via a `get(None)` probe that reads the upstream `NeedsPassword` signal. - The plain `*_secret` methods stay Tier-1 (unprotected) and are documented as such; the 3 `TODO(per-secret-encryption)` markers are resolved — the per- secret encryption IS the upstream envelope selected by the password arg. Additive and behavior-preserving: existing Tier-1 callers are unchanged; the read/migration wiring in SecretAccess lands next. Build/check + the 8 secret_seam/secret_access tests stay green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(secret): adopt Tier-2 per-secret passwords for HD seeds Routes HD-seed at-rest crypto through the upstream Tier-2 object-password envelope instead of DET AES-GCM, KEEPING protection rather than downgrading a password-protected seed to a raw, password-free secret on first unlock. - `WalletSeedView` gains `scheme()` / `set_protected()` / `get_protected()`: a protected seed lives at the `seed.raw.v1` label as a Tier-2 envelope (Argon2id + XChaCha20-Poly1305) sealed under that seed's OWN object password; an unprotected seed stays Tier-1 raw. - `scope_has_passphrase` + `decrypt_jit` are now scheme-driven (via the seam `get(None)` `NeedsPassword` probe): Unprotected → raw, no prompt; Protected → unseal with the JIT-prompted per-seed password; Absent → decode the legacy AES-GCM envelope (decode-only reader) and LAZY re-wrap to Tier-2 (protected) or raw (unprotected), then drop the legacy envelope. Crash-safe: re-store upserts before the legacy delete; the scheme probe prefers the new label. - `promote_and_maybe_migrate_hd_seed` no longer downgrades; it reports "no downgrade" so the unlock callsite's `uses_password=false` finalizer never fires — protection is kept and the metadata stays accurate, with no change to `wallet_lifecycle.rs`. - `is_wrong_passphrase` now also catches the upstream `WrongPassword` so a Tier-2 unseal with a bad object password re-prompts instead of aborting. Per-SECRET model: the session cache is plaintext keyed by `SecretScope`, so remembering seed A never satisfies seed B — each prompts and decrypts only with its own password. Tests: lazy re-wrap keeps protection (legacy gone, raw read of a protected seed fails), Tier-2 wrong-password re-ask, and the A/B different-password isolation. 72 secret tests pass; clippy/fmt green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(secret): clean keep-protection replacement of the downgrade subsystem (HD seed) Supersedes the transitional "inert return" approach with a clean excision of #865's downgrade-to-raw machinery, now that wallet_lifecycle.rs is editable (user WIP stashed). Protected HD seeds STAY protected (Tier-2 object password); nothing downgrades them to a raw, password-free secret. - `wallet_lifecycle.rs`: remove `finish_lazy_seed_migration` (the `uses_password=false` downgrade flip + the "protection removed" notice) and collapse the two `promote_*` methods into one `promote_hd_seed_with_passphrase` (decrypt + cache) — the lazy re-wrap lives in `decrypt_jit`. The unlock callsite no longer finalizes a downgrade. - `finish_unwire::migrate_wallet_meta`: carry the legacy `wallet.uses_password` / `password_hint` into `WalletMeta` (it was defaulting `false`). The persisted flag is now accurate from cold-start (`true` for a protected wallet) and always agrees with the at-rest scheme — no stale/drift-prone metadata. - `protected_wallet_registers_..._on_unlock` acceptance test rewritten to the keep-protection end-state: after the migrating unlock the seed is Tier-2 (scheme=Protected), a raw read fails, `WalletMeta.uses_password` stays true, and a second resolve prompts for the object password. 1009 lib tests pass; clippy -D warnings + fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(secret): adopt Tier-2 keep-protection for imported single keys Extends the Tier-2 keep-protection model from HD seeds to imported single keys, replacing their downgrade-to-raw migration. A protected imported key STAYS protected under its own object password instead of being re-stored raw. - `decrypt_jit` / `scope_has_passphrase` (SingleKey) are scheme-driven (seam `get(None)` → `NeedsPassword` probe): Protected → unseal with the JIT-prompted per-key password; Unprotected → a migrated raw-32 key wins prompt-free, else the not-yet-migrated legacy `SingleKeyEntry` blob's `has_passphrase` decides; the in-band length-32 check disambiguates raw vs legacy-framed. - `migrate_single_key_to_raw` → `migrate_single_key_to_tier2`: lazy re-wrap the just-decrypted protected key to a Tier-2 envelope under the same password (upsert replaces the AES-GCM framing). `has_passphrase` is NOT flipped — protection is kept and the index/persisted flag stay accurate. - `single_key::verify_passphrase` (the unlock-gesture path): re-wraps to Tier-2 instead of downgrading to raw; returns `()` (no migration bool). The `clear_passphrase_flag` finalizer is removed. Downgrade-disclosure machinery retired (Tier-2 keeps protection, nothing to disclose): removed `show_single_key_migration_notice` + the `wallet_migration_notice` / `single_key_migration_notice` / `INTERIM_AT_REST_DETAILS` copy + their re-exports, and the obsolete `tests/kittest/disclosure_banner.rs`. Tests: `ts_lazy_03` rewritten to the keep-protection end-state (vault holds a Tier-2 envelope, password-free read fails, second resolve prompts). 1009 lib tests pass; clippy -D warnings + fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(secret): address Smythe Tier-2 review findings (SEC-001/002/004/005) Smythe verdict on the Tier-2 adoption: SOUND, 0 Critical/High (it closes a prior HIGH-grade protected-seed downgrade-to-obfuscation). Folds in the carry-forward findings (SEC-003 — excise the inert downgrade — already landed in 6dafbda): - SEC-001 (LOW): GC an orphaned legacy `envelope.v1`. The seed Protected read branch (`decrypt_jit`) now best-effort `view.delete(seed_hash)` so an `envelope.v1` left behind by a crash/delete-failure during the re-wrap (which still decrypts under the seed's OLD password) cannot survive forever — the Absent branch, the only other deleter, is never re-entered once Protected. The single-key path migrates in-band (same-label upsert) and has no such orphan. - SEC-004 (LOW): assert the NEGATIVE crypto property. `ts_t2_03` (seed) and the new `ts_t2_sk_iso` (single key) now prove A's object password is REJECTED by B's envelope (`WrongPassword`) — the upstream per-object-salt + AAD binding — not merely that the DET cache is scope-keyed. - SEC-002 (MEDIUM, doc): record loudly that the keyless `file_unprotected` vault is "obfuscation, not confidentiality" for Tier-1 secrets (no-password seeds, raw single keys, identity keys rest on file perms ALONE; only Tier-2 object passwords give real at-rest confidentiality). Documented at `open_secret_store`, reworded `ts_noleak_01` (proves non-literal-plaintext, NOT confidentiality), and in the design note's threat-model residual. - SEC-005 (info): one-line note in `seed_envelope.rs` — the legacy reader is decode-only / local owner-only vault, uses bincode 2.x; the RUSTSEC-2025-0141 bincode 1.3.3 is a transitive dep. No code change. 1010 lib tests pass; clippy -D warnings + fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(migration): note the wallet.uses_password/password_hint schema invariant Smythe's schema-robustness query on `migrate_wallet_meta`'s new SELECT (it reads `uses_password`/`password_hint` unprobed, unlike the probed optional `core_wallet_name`). Verified + documented the invariant rather than adding a needless probe: the wallet-seed migration (`migrate_wallet_seeds_rows_from_conn`) already SELECTs both columns unconditionally and runs FIRST over the same `wallet` table at the same cold-start, so any schema lacking them fails there before the meta pass. The unprobed read here is therefore exactly as robust as the shipped seed migration; `core_wallet_name` stays probed because it is the one droppable column. Comment-only — 1010 lib tests pass, clippy -D + fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(test): eliminate register_wallet_from_seed race in cold-boot test The `ensure_identity_funding_accounts_succeeds_on_cold_booted_watch_only_wallet` test failed in CI (1000+ parallel tests) with: WalletBackend { source: WalletNotFound("70dba4c1d8c5c3854aa02c8f15e0fcd66df6661841d7ae822891fa21aaef48d2") } Root cause: the test wired the backend BEFORE calling register_wallet, which caused register_wallet_upstream to spawn a background subtask that called create_wallet_from_seed_bytes concurrently with the test's own explicit register_wallet_from_seed call. The upstream register_wallet (inside create_wallet_from_seed_bytes) inserts into wallet_manager (step A) and into self.wallets (step B) with async work in between (persister.store + load_persisted + initialize). A concurrent caller that lands between A and B sees WalletAlreadyExists from step A, then get_wallet returns None (step B not yet complete) → resolve_registered_wallet returns WalletNotFound. Under CI load this window is reliably hit. Fix: register the wallet BEFORE wiring the backend. register_wallet_upstream finds no backend and returns early without spawning the subtask. The backend is then wired, and the explicit register_wallet_from_seed call runs race-free (no concurrent subtask competing for the same wallet slot). <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * fix(wallet-backend): keep Tier-2 protected wallets visible at cold boot and stop plaintext key writes Addresses PR #865 review findings on the secret-storage seam. A (BLOCKER): identity write paths no longer serialize plaintext keys. insert/update_local_qualified_identity (and the alias re-encode) now route through encode_identity_blob_vault_first — the write-path twin of the load migration: plaintext keys go into the vault FIRST, the persisted blob carries only InVault placeholders, and a vault-write failure aborts the write (never lands Clear/AlwaysClear bytes in det-app.sqlite). B (HIGH) / C (BLOCKER): cold-boot hydration no longer drops Tier-2-protected wallets. reconstruct_wallet (HD seed) and rebuild_wallet (imported single key) branch on the at-rest SecretScheme before reading the secret. A Protected secret rehydrates CLOSED from the public sidecar (xpub / public_key_bytes) instead of propagating NeedsPassword as fatal, so a keep-protection-migrated wallet stays in the picker across launches. D: the HD Absent-branch legacy-envelope delete is now best-effort (log, don't propagate), matching the Protected branch — a transient delete failure no longer fails an otherwise-successful unlock. E: the eager no-password seed migration wraps the extracted 64-byte seed in Zeroizing so the stack copy wipes on drop. F: resolve_registered_wallet tolerates the registration TOCTOU window with a bounded re-poll before declaring a wallet missing; the fund-routing xpub gate is unchanged. G: present-but-malformed identity-key bytes map to SecretDecryptFailed (with a warn) in both the display and sign tasks, distinct from genuinely-absent IdentityKeyMissing. I/J: refreshed stale doc-comments (single-key has_passphrase, WalletMeta uses_password, wallet_seed_store header) to describe the Tier-2 keep-protection shape, and stripped ephemeral review-finding IDs from secret-path comments. Regression tests cover A, B, and C. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * fix(wallet-backend): seal fresh protected single-key imports Tier-2, typed malformed-identity-key error, skip needless keystore clone Follow-up to PR #865 review on the secret-storage seam. Fresh protected single-key imports now seal Tier-2 at import time instead of writing the legacy DET AES-GCM SingleKeyEntry envelope and migrating lazily on first unlock. import_wif_with_passphrase routes the protected branch through the seam's put_secret_protected, so the storage chokepoint is a single shape from import onward. raw_key_bytes and verify_passphrase branch on the at-rest SecretScheme: a Tier-2 key surfaces SingleKeyPassphraseRequired on a direct read and is verified by unsealing (wrong password -> SingleKeyPassphraseIncorrect, no oracle), while the legacy decode + lazy re-wrap path is retained for pre-existing installs. The legacy AES-GCM SingleKeyEntry remains a decode-only reader. sec_002_import_with_passphrase_encrypts_payload tightens to assert SecretScheme::Protected at import; ts_lazy_03 now starts from a directly-written legacy entry so the legacy->Tier-2 migration stays covered. Present-but-malformed identity-key bytes map to a new typed TaskError::IdentityKeyMalformed (jargon-free "stored but unreadable / re-import to refresh") in both the display and sign tasks, replacing the off-domain SecretDecryptFailed ("recovery phrase") message and staying distinct from the genuinely-absent IdentityKeyMissing. migrate_keystore_to_vault and encode_identity_blob_vault_first skip the KeyStorage clone in the steady-state (already-InVault) case via a new KeyStorage::has_plaintext_for_vault probe, so cold-boot load and identity re-saves no longer clone per identity for no benefit. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * docs(secret-seam): correct drifted docs to Tier-2 keep-protection reality - 01-ux-disclosure.md: full rewrite — the previous doc described the retired drop-protection design (password downgraded to file-permission only, one-time disclosure notices). Replaced with the Tier-2 keep-protection reality: protected secrets re-wrap under the same password, uses_password/has_passphrase stay true, migration is silent, no disclosure notices. Removed candy tally and agent byline. - 02-test-spec.md: update TS-LAZY-01/02/03 expected outcomes to Tier-2: scheme stays Protected, uses_password/has_passphrase stay true, second unlock still prompts (ask_count == 1). Added source-test names (ts_t2_01_*, ts_lazy_03_*). Removed machine-local plan paths, Marvin's note, and future-tense TDD framing. Added section-5 note that raw seam applies only to unprotected secrets. - user-stories.md WAL-006: replace false bullet ("no longer prompts, one-time notice") with the truth: Tier-2 re-seal, wallet keeps prompting, migration is silent. - CLAUDE.md wallet_backend/ bullet: remove dead TODO(per-secret-encryption) grep pointer (zero hits); describe present state — put_secret_protected/ get_secret_protected implemented; keyless-vault residual is deferred tier. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * feat(wallet-backend): optional per-identity at-rest encryption for identity keys (SEC-001) Identity keys default to keyless (Tier-1 raw, prompt-free) so headless/MCP signing of a non-opted-in identity is unchanged byte-for-byte. A user may opt in per identity to seal that identity's keys Tier-2 over the existing seam (Argon2id + XChaCha20-Poly1305) — no new crypto. The at-rest vault scheme is the single source of truth: scope_has_passphrase probes SecretSeam::scheme for the identity-key label (Protected -> prompt, Unprotected -> prompt-free, Absent -> IdentityKeyMissing), and decrypt_jit gains a symmetric Tier-2 arm. A protection-aware IdentityKeyView::store refuses a keyless write over a Protected label (IdentityKeyProtectionDowngrade), with store_unprotected as the deliberate opt-out downgrade. New crash-safe, idempotent migrations IdentityTask::Protect/UnprotectIdentityKeys re-seal an identity's keys keyless<->Tier-2 under one per-identity password. A display-only IdentityMeta sidecar carries the password hint + prompt copy (never the gate), seeded into the chokepoint's identity prompt index at identity load. UI: a collapsible 'Key Protection' section on the Key Info screen (default closed) with danger-gated opt-in (new password + confirm + strength + hint) and opt-out (verify) flows; PassphraseModalConfig gains remember_label so the sign-time prompt says 'key', not 'wallet'. Opted-in signing prompts just-in-time; headless yields SecretPromptUnavailable. Per-identity password isolation (TS-T2-IK-ISO twins TS-T2-SK-ISO). <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * fix(wallet-backend): seal new keys on a protected identity Tier-2, never keyless (SEC-001) Smythe MUST-FIX: a key added to a password-protected identity slipped through the per-label downgrade guard (a new key_id is scheme Absent), so AddKeyToIdentity -> insert_non_encrypted(Clear) -> encode_identity_blob_vault_first -> store_all wrote it Tier-1 keyless — a fully-capable signing key in plaintext on an identity the user believed protected. Two layers close it: (1) an identity-level fail-closed guard in encode_identity_blob_vault_first / migrate_keystore_to_vault refuses to move resident plaintext into the vault when the identity already has any Tier-2 key (IdentityKeyProtectionDowngrade / new KeystoreMigration::ProtectedSkipped), so a keyless write is impossible. (2) add_key_to_identity now seals the new key Tier-2 via SecretAccess::seal_new_identity_key, which prompts once, verifies the password against an existing protected key (so the identity stays under one password, with the standard wrong-pass re-ask), seals the new key, and marks it InVault before the save — headless yields SecretPromptUnavailable (fail closed; signing also fails closed earlier). KeyStorage::mark_in_vault performs the post-seal transition. SEC-002 (SHOULD-FIX): protect_identity_keys now re-enforces the password policy in the backend (validate_protection_password) so a non-UI caller cannot seal under a too-short password. SEC-003/SEC-004 tracked as code comments (store-guard TOCTOU bounded by the single-writer lock + UI in-flight gate; pre-opt-in plaintext may persist in freed filesystem blocks until reused). Tests: secret_access seal-new-key (seals Tier-2 under verified password / headless fails closed with no write / wrong-pass re-asks); identity_db encode+migrate refuse keyless on a protected identity; protect_identity_keys rejects a weak password. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * fix(identity): fail closed before broadcast when adding a key to a protected identity (SEC-001 O-2) Adding a key to a password-protected identity used to seal the new key Tier-2 (or fail closed) only during LOCAL persist, which runs AFTER the on-chain AddKeys broadcast. A headless add therefore broadcast the state transition on-chain and only then failed closed locally (no password) — leaving the key on-chain but never persisted by DET: an on-chain/local divergence. Move the protected-identity precondition BEFORE any on-chain side effect. `add_key_to_identity` now determines up front whether the identity is protected (`protected_identity_verify_scope`) and, if so, prompts for and VERIFIES its object password before building or broadcasting the state transition. Headless (`NullSecretPrompt` → `SecretPromptUnavailable`) or a wrong password returns the typed error before the broadcast, so no state transition is ever sent. The seal then runs after the broadcast with the already-verified password — a single prompt, split across the broadcast. `SecretAccess::seal_new_identity_key` is split into `verify_identity_object_password` (prompt + verify, returns an opaque `VerifiedIdentityPassword` that zeroizes on drop) and `seal_new_identity_key_with_password` (no prompt); the original composes the two and keeps its tests. The d965ca5 encode fail-closed guard (`IdentityKeyProtectionDowngrade`) stays as the defense-in-depth backstop. Also: O-1 — `mark_in_vault`'s bool return is now checked and warns on an unexpected miss (the encode guard still backstops it). O-3 — document that a Mixed identity fails closed on a plain re-save until "Finish protecting" reseals the remaining keys (intended secure behavior). <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * fix(identity): harden SEC-001 identity-key paths (r2 review) Address four thepastaclaw findings on the SEC-001 identity-key code at fcf6da1: - BLOCKING: `seal_identity_keys` now verifies the supplied password opens every already-`Protected` key BEFORE sealing any keyless one. A Mixed-state "Finish protecting" re-run with a different password is rejected up front with `IdentityKeyPassphraseIncorrect` and zero state changes, so an identity can never be split across two passwords. - `get_identity_by_id` now mirrors the bulk-load vault migration, so the single-get read path (and the SEC-001 protect/unprotect tasks that use it) migrates legacy resident `Clear`/`AlwaysClear` keys to the vault on read instead of returning and re-persisting plaintext. - A post-broadcast seal failure in `add_key_to_identity` now surfaces the typed, actionable `IdentityKeyAddedButNotSaved` (key is on-chain; retry after freeing disk space), preserving the upstream cause in the source chain — never a silent loss and never a keyless-write fallback. - The three prompt-meta setters recover a poisoned lock (`unwrap_or_else(|p| p.into_inner())`), matching `forget`/`forget_all`, so prompt-copy metadata can self-heal after a panicked reader instead of silently freezing. Adds regression tests for each (the blocker's split-prevention, read-path migration via an offline AppContext, and the typed orphan-error mapping). <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * docs(single-key): correct has_passphrase on-disk-shape doc to Tier-2-direct The has_passphrase field doc claimed fresh protected imports use a legacy AES-GCM envelope migrated on first unlock; imports seal Tier-2 directly at import time. Align the field doc with the function docstring. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Why this PR exists
docs/platform-wallet-migration-design); merged up to its tip0d301d01(incl. overlay feat(overlay): blocking progress overlay + SPV-sync hard-block #863), so the diff here is masternode-only.What was done
Two new MCP tools — no backend change; both dispatch existing
IdentityTasks:identity_masternode_load— load a masternode/evonode identity by ProTxHash + owner/voting/payout private keys (WIF or hex), persisted locally. Non-destructive. Reports keys loaded, available withdrawal modes, and the registered payout address.identity_masternode_credits_withdraw— withdraw the node identity's Platform credits.key_mode=ownerforces the destination to the registered payout address (dispatchesto_address = Noneso Platform consensus routes the payout — matches the GUI/locked design);key_mode=transfersends to any Core address (Platform/bech32m rejected). Destructive; SPV-gated.Thin tool layer over
IdentityTask::LoadIdentity/WithdrawFromIdentity; stateless parsing inmodel/masternode_input.rs. Private keys wrapped inSecretwith a hand-written redactingDebug— never reach logs or the MCP error payload.Headless lifecycle fixes (found + validated during live testing)
Live headless runs surfaced several standalone-det-cli lifecycle bugs (general to the headless path, fixed here so the feature is usable end-to-end):
acb06b14): withdraw resolved the identity (get_identity_by_id→wallet_backend()) beforeensure_spv_syncedbuilt the backend → cold-processWalletBackendNotYetWired("wallet is still starting up", -32603). Moved the gate ahead of the lookup. Live-validated: owner-mode withdrawal completes to the payout address.7b5353f4):ensure_spv_syncedwaits for the storage migration (ensure_storage_ready, newStorageNotReady= -32005) before dispatch.ec983601): standalone/headless exit awaitsWalletBackend::shutdown()(stops SPV, drains the persister) before terminating.98e96380→ superseded by74cf43af): the sync coordinators (identity/platform-address/shielded) run on detached OS threads whosetokio::select!{ sleep | cancel }loop can poll the timer wheel against a shutting-down runtime and panic. A 100 ms grace (98e96380) was tried and failed live (it races the teardown). The deterministic fix (74cf43af): the one-shot CLI flushes stdout/stderr andstd::process::exit()after the result — the runtime is never gracefully dropped while coordinator threads are alive, so there's no teardown for them to race. Safe because the withdrawal's state transition is already on-chain, SQLite is transaction-atomic, and the storage-registry lock is process-global (reclaimed on exit).Testing
#[ignore],E2E_MN_*) compiles; network-gated cases run manually against testnet.cargo test --lib971/971,--test kittest146/146, doctests,e2e/backend-e2ecompile, clippy-D warningsclean, fmt clean.Breaking changes
None.
Checklist
TaskErrorchange for the tools (lifecycle fixes touch the MCP/CLI seam + a newStorageNotReadyMCP error)Follow-ups (tracked, not in this PR)
PrivateKeyData::Clear→SecretStore/AES-GCM at theDetKvseam, HIGH);SecretStorevault opened with empty passphrase (MED);ClosedSingleKeyderivesDebugover raw key bytes (MED); det-cli keys via argv — implement stdin/env input (MED);.envplaintext creds (LOW); non-Zeroizingno-password key material (LOW).quiesce()should join the coordinatorJoinHandle(then DET could keep the graceful teardown instead of hard-exit).ensure_spv_syncedswallows backend-wiring errors → misleadingSpvSyncFailedtimeout.🤖 Co-authored by Claudius the Magnificent AI Agent