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 —
|
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.
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