Skip to content

feat(mcp): headless masternode/evonode credit withdrawals (det-cli)#864

Open
lklimek wants to merge 22 commits into
docs/platform-wallet-migration-designfrom
feat/masternode-cli-withdraw
Open

feat(mcp): headless masternode/evonode credit withdrawals (det-cli)#864
lklimek wants to merge 22 commits into
docs/platform-wallet-migration-designfrom
feat/masternode-cli-withdraw

Conversation

@lklimek

@lklimek lklimek commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Why this PR exists

  • Problem: There is no headless (det-cli / MCP) path to operate a masternode/evonode identity's Platform credits. Operators must use the desktop GUI to load a node identity and withdraw its credits.
  • What breaks without it: A node operator running headless — a server, CI, or scripted environment with no GUI — cannot (1) load a masternode/evonode identity from its ProTxHash + owner/voting/payout keys, nor (2) withdraw the node's Platform credits. The only existing withdrawal tooling assumes a User identity and the GUI.
  • Blocking relationship: Based on PR feat: platform-wallet backend rewrite (spec + implementation) #860 (docs/platform-wallet-migration-design); merged up to its tip 0d301d01 (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=owner forces the destination to the registered payout address (dispatches to_address = None so Platform consensus routes the payout — matches the GUI/locked design); key_mode=transfer sends to any Core address (Platform/bech32m rejected). Destructive; SPV-gated.

Thin tool layer over IdentityTask::LoadIdentity / WithdrawFromIdentity; stateless parsing in model/masternode_input.rs. Private keys wrapped in Secret with a hand-written redacting Debug — 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):

  • Backend-wiring ordering (acb06b14): withdraw resolved the identity (get_identity_by_idwallet_backend()) before ensure_spv_synced built the backend → cold-process WalletBackendNotYetWired ("wallet is still starting up", -32603). Moved the gate ahead of the lookup. Live-validated: owner-mode withdrawal completes to the payout address.
  • Cold-start migration race (7b5353f4): ensure_spv_synced waits for the storage migration (ensure_storage_ready, new StorageNotReady = -32005) before dispatch.
  • Graceful backend stop on exit (ec983601): standalone/headless exit awaits WalletBackend::shutdown() (stops SPV, drains the persister) before terminating.
  • Exit-time Tokio panic — deterministic hard-exit (98e96380 → superseded by 74cf43af): the sync coordinators (identity/platform-address/shielded) run on detached OS threads whose tokio::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 and std::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

  • Unit + tool registration/schema tests; masternode validators, key-redaction, owner/transfer pre-flight.
  • Backend-e2e (#[ignore], E2E_MN_*) compiles; network-gated cases run manually against testnet.
  • QA at the tip — PASS, zero regressions: cargo test --lib 971/971, --test kittest 146/146, doctests, e2e/backend-e2e compile, clippy -D warnings clean, fmt clean.
  • Live: owner-mode withdraw succeeds end-to-end (det-cli, testnet). The hard-exit's confirmation (clean exit, no coordinator panic) is a manual headless retest — can't be unit-reproduced.

Breaking changes

None.

Checklist

Follow-ups (tracked, not in this PR)

  • Security (codebase-wide audit): encrypt directly-loaded identity keys at rest (PrivateKeyData::ClearSecretStore/AES-GCM at the DetKv seam, HIGH); SecretStore vault opened with empty passphrase (MED); ClosedSingleKey derives Debug over raw key bytes (MED); det-cli keys via argv — implement stdin/env input (MED); .env plaintext creds (LOW); non-Zeroizing no-password key material (LOW).
  • Upstream: quiesce() should join the coordinator JoinHandle (then DET could keep the graceful teardown instead of hard-exit).
  • ensure_spv_synced swallows backend-wiring errors → misleading SpvSyncFailed timeout.
  • Credit-withdrawal fee estimate over-shoots actual (~2×).

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 16 commits June 18, 2026 14:23
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>
@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • master
  • v1.0-dev

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8ba3e6bb-c15e-4365-afed-113dd030265f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/masternode-cli-withdraw

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@lklimek lklimek marked this pull request as ready for review June 19, 2026 11:44
@thepastaclaw

thepastaclaw commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit 74cf43a)

#863) into feat/masternode-cli-withdraw

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

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

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 thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

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.

Comment thread src/mcp/tools/identity.rs
Comment on lines +743 to +768
// Cheap validation first, before the SPV wait: network presence/match,
// node type, and the at-least-one-signing-key rule all reject without
// touching the network.
require_nonblank_network(&param.network)?;
resolve::require_network(&ctx, Some(&param.network))?;
let identity_type = masternode_input::parse_node_type(&param.node_type)?;
masternode_input::require_at_least_one_signing_key(
&param.owner_private_key,
&param.payout_private_key,
)?;

// Load fetches the identity (and, with a voting key, the voter identity)
// over DAPI; proof verification needs a synced chain.
resolve::ensure_spv_synced(&ctx).await?;

let input = IdentityInputToLoad {
identity_id_input: param.pro_tx_hash,
identity_type,
alias_input: param.alias.trim().to_owned(),
voting_private_key_input: Secret::new(param.voting_private_key),
owner_private_key_input: Secret::new(param.owner_private_key),
payout_address_private_key_input: Secret::new(param.payout_private_key),
keys_input: vec![],
derive_keys_from_wallets: false,
selected_wallet_seed_hash: None,
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: 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 Strings. 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.

Suggested change
// Cheap validation first, before the SPV wait: network presence/match,
// node type, and the at-least-one-signing-key rule all reject without
// touching the network.
require_nonblank_network(&param.network)?;
resolve::require_network(&ctx, Some(&param.network))?;
let identity_type = masternode_input::parse_node_type(&param.node_type)?;
masternode_input::require_at_least_one_signing_key(
&param.owner_private_key,
&param.payout_private_key,
)?;
// Load fetches the identity (and, with a voting key, the voter identity)
// over DAPI; proof verification needs a synced chain.
resolve::ensure_spv_synced(&ctx).await?;
let input = IdentityInputToLoad {
identity_id_input: param.pro_tx_hash,
identity_type,
alias_input: param.alias.trim().to_owned(),
voting_private_key_input: Secret::new(param.voting_private_key),
owner_private_key_input: Secret::new(param.owner_private_key),
payout_address_private_key_input: Secret::new(param.payout_private_key),
keys_input: vec![],
derive_keys_from_wallets: false,
selected_wallet_seed_hash: None,
};
let voting_private_key = Secret::new(param.voting_private_key);
let owner_private_key = Secret::new(param.owner_private_key);
let payout_private_key = Secret::new(param.payout_private_key);
// Load fetches the identity (and, with a voting key, the voter identity)
// over DAPI; proof verification needs a synced chain.
resolve::ensure_spv_synced(&ctx).await?;
let input = IdentityInputToLoad {
identity_id_input: param.pro_tx_hash,
identity_type,
alias_input: param.alias.trim().to_owned(),
voting_private_key_input: voting_private_key,
owner_private_key_input: owner_private_key,
payout_address_private_key_input: payout_private_key,

source: ['claude-general', 'claude-rust-quality', 'codex-general', 'codex-rust-quality']

Comment on lines +264 to +382
#[ignore]
#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)]
async fn test_mn050_owner_withdraw_to_payout() {
let (Some(pro_tx_hash), Some(owner_wif)) =
(opt_env("E2E_MN_PRO_TX_HASH"), opt_env("E2E_MN_OWNER_WIF"))
else {
return;
};
let ctx = ctx().await;

let load = load_task(
pro_tx_hash,
node_type_from_env(),
Some(owner_wif),
None,
None,
);
let BackendTaskSuccessResult::LoadedIdentity(qi) = run_task(&ctx.app_context, load)
.await
.expect("load should succeed")
else {
panic!("Expected LoadedIdentity");
};

let owner_key_id = withdrawal_key_id(&qi, Purpose::OWNER).expect("owner key loaded");
let payout_address = qi
.masternode_payout_address(ctx.app_context.network())
.expect("payout address present");
let balance = qi.identity.balance();
assert!(balance > 0, "identity must have withdrawable credits");
let amount = (balance / 10).max(1);

// OWNER mode dispatches to_address = None; Platform forces the payout addr.
let task = BackendTask::IdentityTask(IdentityTask::WithdrawFromIdentity(
qi,
None,
amount,
Some(owner_key_id),
));
let result = run_task(&ctx.app_context, task)
.await
.expect("owner-mode withdrawal should succeed");

match result {
BackendTaskSuccessResult::WithdrewFromIdentity(fee) => {
tracing::info!("Owner withdraw to {payout_address}, fee: {fee:?}");
assert!(fee.actual_fee > 0, "actual fee should be positive");
}
other => panic!("Expected WithdrewFromIdentity, got: {other:?}"),
}
}

// ── TC-MN-051 — TRANSFER mode happy path: any Core address ───────────────────

#[ignore]
#[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)]
async fn test_mn051_transfer_withdraw_to_address() {
let (Some(pro_tx_hash), Some(payout_wif)) =
(opt_env("E2E_MN_PRO_TX_HASH"), opt_env("E2E_MN_PAYOUT_WIF"))
else {
return;
};
let ctx = ctx().await;

let load = load_task(
pro_tx_hash,
node_type_from_env(),
None,
Some(payout_wif),
None,
);
let BackendTaskSuccessResult::LoadedIdentity(qi) = run_task(&ctx.app_context, load)
.await
.expect("load should succeed")
else {
panic!("Expected LoadedIdentity");
};

let transfer_key_id = withdrawal_key_id(&qi, Purpose::TRANSFER).expect("transfer key loaded");
let balance = qi.identity.balance();
assert!(balance > 0, "identity must have withdrawable credits");
let amount = (balance / 10).max(1);

// A fresh testnet Core address from the framework wallet (no extra funding
// broadcast — we only need a watched destination address).
let framework_wallet = {
let wallets = ctx.app_context.wallets().read().expect("wallets lock");
wallets
.get(&ctx.framework_wallet_hash)
.expect("framework wallet must exist")
.clone()
};
let addr_str = crate::framework::identity_helpers::get_receive_address(
&ctx.app_context,
&framework_wallet,
)
.await;
let to_address = dash_sdk::dpp::dashcore::Address::from_str(&addr_str)
.expect("valid address")
.assume_checked();

let task = BackendTask::IdentityTask(IdentityTask::WithdrawFromIdentity(
qi,
Some(to_address),
amount,
Some(transfer_key_id),
));
let result = run_task(&ctx.app_context, task)
.await
.expect("transfer-mode withdrawal should succeed");

match result {
BackendTaskSuccessResult::WithdrewFromIdentity(fee) => {
tracing::info!("Transfer withdraw to {addr_str}, fee: {fee:?}");
assert!(fee.actual_fee > 0, "actual fee should be positive");
}
other => panic!("Expected WithdrewFromIdentity, got: {other:?}"),
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: 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.

source: ['claude-general', 'claude-rust-quality', 'codex-general', 'codex-rust-quality']

Comment thread src/mcp/tools/identity.rs
Comment on lines +743 to +759
// Cheap validation first, before the SPV wait: network presence/match,
// node type, and the at-least-one-signing-key rule all reject without
// touching the network.
require_nonblank_network(&param.network)?;
resolve::require_network(&ctx, Some(&param.network))?;
let identity_type = masternode_input::parse_node_type(&param.node_type)?;
masternode_input::require_at_least_one_signing_key(
&param.owner_private_key,
&param.payout_private_key,
)?;

// Load fetches the identity (and, with a voting key, the voter identity)
// over DAPI; proof verification needs a synced chain.
resolve::ensure_spv_synced(&ctx).await?;

let input = IdentityInputToLoad {
identity_id_input: param.pro_tx_hash,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: 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.

source: ['claude-general', 'claude-rust-quality', 'codex-general', 'codex-rust-quality']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Resolved in acb06b1Validate pro_tx_hash before the SPV wait no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment thread src/mcp/tools/identity.rs
Comment on lines +781 to +805
let mut owner_key_loaded = false;
let mut payout_key_loaded = false;
let mut available_withdrawal_keys = Vec::new();
for key in qi.available_withdrawal_keys() {
match key.identity_public_key.purpose() {
Purpose::OWNER => {
owner_key_loaded = true;
available_withdrawal_keys.push("owner".to_owned());
}
Purpose::TRANSFER => {
payout_key_loaded = true;
available_withdrawal_keys.push("transfer".to_owned());
}
_ => {}
}
}

Ok(IdentityMasternodeLoadOutput {
identity_id: qi.identity.id().to_string(Encoding::Base58),
node_type: identity_type.to_string().to_ascii_lowercase(),
alias: qi.alias.clone(),
owner_key_loaded,
voting_key_loaded: qi.associated_voter_identity.is_some(),
payout_key_loaded,
available_withdrawal_keys,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: 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.

source: ['claude-general', 'claude-rust-quality', 'codex-general', 'codex-rust-quality']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Resolved in acb06b1KeyMode vocabulary duplicated between Tool A output and Tool B input/output no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Comment thread src/mcp/tools/identity.rs
Comment on lines +781 to +796
let mut owner_key_loaded = false;
let mut payout_key_loaded = false;
let mut available_withdrawal_keys = Vec::new();
for key in qi.available_withdrawal_keys() {
match key.identity_public_key.purpose() {
Purpose::OWNER => {
owner_key_loaded = true;
available_withdrawal_keys.push("owner".to_owned());
}
Purpose::TRANSFER => {
payout_key_loaded = true;
available_withdrawal_keys.push("transfer".to_owned());
}
_ => {}
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 Nitpick: available_withdrawal_keys may contain duplicates

STILL VALID. The loop at lines 783-796 unconditionally pushes "owner" / "transfer" once per matching key. QualifiedIdentity::available_withdrawal_keys() returns one entry per loaded key per purpose, so an identity carrying multiple OWNER or TRANSFER keys would emit ["owner","owner","transfer"]. Cosmetic for typical masternode identities (one of each), but agents that read this as the set of supported modes would be surprised. Use a BTreeSet<&str> or guard with contains before pushing to make the set semantics explicit.

Suggested change
let mut owner_key_loaded = false;
let mut payout_key_loaded = false;
let mut available_withdrawal_keys = Vec::new();
for key in qi.available_withdrawal_keys() {
match key.identity_public_key.purpose() {
Purpose::OWNER => {
owner_key_loaded = true;
available_withdrawal_keys.push("owner".to_owned());
}
Purpose::TRANSFER => {
payout_key_loaded = true;
available_withdrawal_keys.push("transfer".to_owned());
}
_ => {}
}
}
let mut owner_key_loaded = false;
let mut payout_key_loaded = false;
let mut available_withdrawal_keys = std::collections::BTreeSet::new();
for key in qi.available_withdrawal_keys() {
match key.identity_public_key.purpose() {
Purpose::OWNER => {
owner_key_loaded = true;
available_withdrawal_keys.insert("owner");
}
Purpose::TRANSFER => {
payout_key_loaded = true;
available_withdrawal_keys.insert("transfer");
}
_ => {}
}
}
Ok(IdentityMasternodeLoadOutput {
identity_id: qi.identity.id().to_string(Encoding::Base58),
node_type: identity_type.to_string().to_ascii_lowercase(),
alias: qi.alias.clone(),
owner_key_loaded,
voting_key_loaded: qi.associated_voter_identity.is_some(),
payout_key_loaded,
available_withdrawal_keys: available_withdrawal_keys.into_iter().map(str::to_owned).collect(),

source: ['claude-general', 'claude-rust-quality', 'codex-general', 'codex-rust-quality']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Resolved in acb06b1available_withdrawal_keys may contain duplicates no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

lklimek and others added 3 commits June 19, 2026 14:49
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 thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

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.

Comment thread src/bin/det_cli/headless.rs Outdated
Comment on lines +48 to +56
// Quiesce the wallet backend (coordinator threads) before the runtime
// is torn down — same concern as the stdio path. `swappable.load_full()`
// yields the context that is CURRENTLY active (may differ from the
// initial `ctx` if a `network_switch` tool call was made during the
// session).
let current_ctx = swappable.load_full();
if let Ok(backend) = current_ctx.wallet_backend() {
backend.shutdown().await;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: 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.

source: ['codex']

Comment thread src/mcp/mod.rs
Comment on lines 29 to 44
pub async fn start_stdio() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use rmcp::ServiceExt;

let service = server::DashMcpService::new_lazy();
// Keep a clone so we can access the context after `serve()` moves `service`.
// `DashMcpService` is cheaply cloneable (all fields are `Arc`-wrapped).
let service_for_shutdown = service.clone();
let server = service.serve(rmcp::transport::stdio()).await?;
server.waiting().await?;

// Quiesce the wallet backend (coordinator threads) before returning.
// The caller's runtime is still alive at this point; the shutdown must
// complete here, inside `block_on`, NOT during runtime drop.
service_for_shutdown.shutdown_wallet_backend().await;
Ok(())
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: 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.

source: ['claude']

Comment thread src/mcp/resolve.rs
Comment on lines +130 to +161
async fn ensure_storage_ready(ctx: &Arc<AppContext>) -> Result<(), McpToolError> {
let migration = ctx.migration_status();
// Fast path — not running; nothing to wait for.
if !migration.state().is_running() {
return Ok(());
}

tracing::info!("Waiting for cold-start storage migration to complete…");

let poll = async {
loop {
if !migration.state().is_running() {
return Ok(());
}
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
}
};

match tokio::time::timeout(STORAGE_MIGRATION_WAIT_TIMEOUT, poll).await {
Ok(result) => {
tracing::info!("Cold-start storage migration complete.");
result
}
Err(_elapsed) => {
tracing::warn!(
timeout_secs = STORAGE_MIGRATION_WAIT_TIMEOUT.as_secs(),
"Timed out waiting for cold-start storage migration"
);
Err(McpToolError::StorageNotReady)
}
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: 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.

source: ['codex']

Comment thread src/mcp/tools/identity.rs
Comment on lines +781 to +1088
let mut owner_key_loaded = false;
let mut payout_key_loaded = false;
let mut available_withdrawal_keys = Vec::new();
for key in qi.available_withdrawal_keys() {
match key.identity_public_key.purpose() {
Purpose::OWNER => {
owner_key_loaded = true;
available_withdrawal_keys.push("owner".to_owned());
}
Purpose::TRANSFER => {
payout_key_loaded = true;
available_withdrawal_keys.push("transfer".to_owned());
}
_ => {}
}
}

Ok(IdentityMasternodeLoadOutput {
identity_id: qi.identity.id().to_string(Encoding::Base58),
node_type: identity_type.to_string().to_ascii_lowercase(),
alias: qi.alias.clone(),
owner_key_loaded,
voting_key_loaded: qi.associated_voter_identity.is_some(),
payout_key_loaded,
available_withdrawal_keys,
payout_address: qi
.masternode_payout_address(ctx.network())
.map(|addr| addr.to_string()),
dpns_names: qi.dpns_names.iter().map(|d| d.name.clone()).collect(),
})
}
}

// ---------------------------------------------------------------------------
// IdentityMasternodeCreditsWithdraw (masternode-aware Identity -> Core)
// ---------------------------------------------------------------------------

/// Withdraw a masternode/evonode identity's Platform credits, honoring the
/// two key modes (owner -> payout-forced, transfer -> any Core address).
pub struct IdentityMasternodeCreditsWithdraw;

#[derive(Debug, Deserialize, schemars::JsonSchema, Default)]
pub struct IdentityMasternodeWithdrawParams {
/// Base58 identity ID of the loaded masternode/evonode identity.
pub identity_id: String,
/// Key mode: "owner" (destination forced to the payout address) or
/// "transfer" (withdraw to any Core address).
pub key_mode: String,
/// Core address to receive the withdrawal. Required for "transfer" mode;
/// forbidden for "owner" mode (the destination is the registered payout
/// address).
#[serde(default)]
pub to_address: String,
/// Amount in credits to withdraw (must be greater than zero).
pub amount_credits: u64,
/// Expected network (required for destructive operations).
pub network: String,
}

#[derive(Serialize, schemars::JsonSchema)]
pub struct IdentityMasternodeWithdrawOutput {
identity_id: String,
key_mode: String,
/// The Core address the funds were actually sent to (the payout address in
/// owner mode, the caller's address in transfer mode).
to_address: String,
amount_credits: u64,
estimated_fee: u64,
actual_fee: u64,
}

/// Reject a caller-supplied address in owner mode.
///
/// An owner-key withdrawal always goes to the registered payout address, so a
/// supplied `to_address` is a contradiction surfaced as an error, not ignored.
fn reject_owner_address_contradiction(to_address: &str) -> Result<(), McpToolError> {
if !to_address.trim().is_empty() {
return Err(McpToolError::InvalidParam {
message: "An owner-key withdrawal always goes to the registered payout address. \
Remove 'to_address', or use key_mode=transfer to choose an address."
.to_owned(),
});
}
Ok(())
}

/// Parse a transfer-mode destination as a Core address (format only).
///
/// Rejects empties, Platform bech32m addresses (via `is_platform_address_string`,
/// not the weaker first-char check), and non-Core strings. The caller enforces
/// the network match on the returned `NetworkUnchecked` address.
fn parse_transfer_core_address(
to_address: &str,
) -> Result<
dash_sdk::dashcore_rpc::dashcore::Address<
dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked,
>,
McpToolError,
> {
let trimmed = to_address.trim();
if trimmed.is_empty() {
return Err(McpToolError::InvalidParam {
message: "A transfer withdrawal needs a Core address. \
Provide 'to_address' with a Core address."
.to_owned(),
});
}
if crate::model::address::is_platform_address_string(trimmed) {
return Err(McpToolError::InvalidParam {
message: "Enter a valid Core address — Platform addresses cannot receive withdrawals."
.to_owned(),
});
}
trimmed
.parse::<dash_sdk::dashcore_rpc::dashcore::Address<
dash_sdk::dashcore_rpc::dashcore::address::NetworkUnchecked,
>>()
.map_err(|_| McpToolError::InvalidParam {
message: "Enter a valid Core address — that address could not be read.".to_owned(),
})
}

/// The resolved key + destination for a masternode credit withdrawal.
#[derive(Debug)]
struct WithdrawalPlan {
/// Signing key resolved from the identity's available withdrawal keys.
key_id: dash_sdk::dpp::identity::KeyID,
/// Destination passed to the state transition. `None` in owner mode —
/// Platform consensus forces the registered payout address; `Some(addr)` in
/// transfer mode.
dispatch_address: Option<dash_sdk::dpp::dashcore::Address>,
/// Destination the funds actually reach, echoed to the caller: the registered
/// payout address in owner mode, the caller's address in transfer mode.
echo_address: dash_sdk::dpp::dashcore::Address,
}

/// Resolve the signing key and destination for a withdrawal (pure, no network).
///
/// Owner mode dispatches `None` (Platform consensus forces the registered payout
/// address — matching the GUI), validating only that a payout address exists and
/// echoing it back. Transfer mode dispatches and echoes the caller's Core
/// address after a format + active-network check.
///
/// # Errors
///
/// [`McpToolError::InvalidParam`] when the mode's key is not loaded, owner mode
/// has no registered payout address, or the transfer address is missing,
/// malformed, a Platform address, or for the wrong network.
fn resolve_withdrawal_plan(
qi: &QualifiedIdentity,
key_mode: KeyMode,
to_address: &str,
network: dash_sdk::dpp::dashcore::Network,
) -> Result<WithdrawalPlan, McpToolError> {
let purpose = match key_mode {
KeyMode::Owner => Purpose::OWNER,
KeyMode::Transfer => Purpose::TRANSFER,
};
let key_id = qi
.available_withdrawal_keys()
.into_iter()
.find(|k| k.identity_public_key.purpose() == purpose)
.map(|k| k.identity_public_key.id())
.ok_or_else(|| McpToolError::InvalidParam {
message: match key_mode {
KeyMode::Owner => "The owner key (owner_private_key) needed for owner-mode \
withdrawals is not loaded. Re-run identity-masternode-load \
with owner_private_key included."
.to_owned(),
KeyMode::Transfer => "The payout key (payout_private_key) needed for \
transfer-mode withdrawals is not loaded. Re-run \
identity-masternode-load with payout_private_key included."
.to_owned(),
},
})?;

match key_mode {
KeyMode::Owner => {
let payout = qi.masternode_payout_address(network).ok_or_else(|| {
McpToolError::InvalidParam {
message: "This identity has no registered payout address, so an owner-key \
withdrawal has no destination. Use key_mode=transfer with a Core \
address."
.to_owned(),
}
})?;
Ok(WithdrawalPlan {
key_id,
dispatch_address: None,
echo_address: payout,
})
}
KeyMode::Transfer => {
let checked = parse_transfer_core_address(to_address)?
.require_network(network)
.map_err(|_| McpToolError::InvalidParam {
message: "The Core address does not match the active network. \
Use an address for the active network."
.to_owned(),
})?;
Ok(WithdrawalPlan {
key_id,
dispatch_address: Some(checked.clone()),
echo_address: checked,
})
}
}
}

impl ToolBase for IdentityMasternodeCreditsWithdraw {
type Parameter = IdentityMasternodeWithdrawParams;
type Output = IdentityMasternodeWithdrawOutput;
type Error = McpToolError;

fn name() -> Cow<'static, str> {
"identity_masternode_credits_withdraw".into()
}

fn description() -> Option<Cow<'static, str>> {
Some(
"Withdraw a loaded masternode/evonode identity's Platform credits to \
Core. With key_mode=owner the destination is forced to the \
registered payout address; with key_mode=transfer you choose any \
Core address. The 'network' parameter is required."
.into(),
)
}

fn annotations() -> Option<ToolAnnotations> {
Some(
ToolAnnotations::default()
.read_only(false)
.destructive(true)
.idempotent(false)
.open_world(true),
)
}
}

impl AsyncTool<DashMcpService> for IdentityMasternodeCreditsWithdraw {
async fn invoke(
service: &DashMcpService,
param: IdentityMasternodeWithdrawParams,
) -> Result<IdentityMasternodeWithdrawOutput, McpToolError> {
let ctx = service
.ctx()
.await
.map_err(|e| McpToolError::Internal(e.to_string()))?;

// Cheap validation first, before the SPV wait.
require_nonblank_network(&param.network)?;
resolve::require_network(&ctx, Some(&param.network))?;
resolve::validate_credits(param.amount_credits)?;
let key_mode = masternode_input::parse_key_mode(&param.key_mode)?;

// Surface the owner+address contradiction before resolving the identity
// so it fires even for a not-yet-loaded identity.
if key_mode == KeyMode::Owner {
reject_owner_address_contradiction(&param.to_address)?;
}

// Parse identity ID (pure — no backend access).
let identity_id = masternode_input::decode_identity_id(&param.identity_id)?;

// Wire the wallet backend and wait for a synced chain BEFORE any
// backend-touching call. `get_identity_by_id` reads through `identity_kv`
// → `wallet_backend()?.kv()`, so on a cold standalone process it returns
// `WalletBackendNotYetWired` unless the backend is built first. This gate
// is the single chokepoint that ensures that — matching the LOAD tool's
// ordering at :756.
resolve::ensure_spv_synced(&ctx).await?;

// Resolve the loaded identity. A not-found points at the load tool.
let qi = ctx
.get_identity_by_id(&identity_id)
.map_err(|e| McpToolError::Internal(e.to_string()))?
.ok_or_else(|| McpToolError::InvalidParam {
message: format!(
"This identity is not loaded yet: {}. \
Run identity-masternode-load with the ProTxHash and keys first.",
param.identity_id
),
})?;

// Resolve the signing key and destination per mode (pure, no network).
let plan = resolve_withdrawal_plan(&qi, key_mode, &param.to_address, ctx.network())?;

let task = BackendTask::IdentityTask(IdentityTask::WithdrawFromIdentity(
qi,
plan.dispatch_address,
param.amount_credits,
Some(plan.key_id),
));
let result = dispatch_task(&ctx, task)
.await
.map_err(McpToolError::TaskFailed)?;

let BackendTaskSuccessResult::WithdrewFromIdentity(fee_result) = result else {
return Err(McpToolError::Internal(format!(
"Unexpected task result: {result:?}"
)));
};

Ok(IdentityMasternodeWithdrawOutput {
identity_id: param.identity_id,
key_mode: match key_mode {
KeyMode::Owner => "owner".to_owned(),
KeyMode::Transfer => "transfer".to_owned(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: 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.

source: ['claude', 'codex']

`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 thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

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

Comment on lines +53 to +56
let current_ctx = swappable.load_full();
if let Ok(backend) = current_ctx.wallet_backend() {
backend.shutdown().await;
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Headless HTTP shutdown skips the new coordinator-grace window

NEW IN LATEST DELTA. Commit 98e9638 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.

Suggested change
let current_ctx = swappable.load_full();
if let Ok(backend) = current_ctx.wallet_backend() {
backend.shutdown().await;
}
if let Ok(backend) = current_ctx.wallet_backend() {
backend.shutdown().await;
tokio::time::sleep(Duration::from_millis(100)).await;
}

source: ['claude', 'codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Resolved in 74cf43aHeadless HTTP shutdown skips the new coordinator-grace window no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

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 thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

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

Comment thread src/bin/det_cli/main.rs
Comment on lines +129 to +146
let exit_code: i32 = match runtime.block_on(run(cli)) {
Ok(()) => 0,
Err(e) => {
eprintln!("Error: {e}");
1
}
};

// Hard-exit: bypass Tokio runtime teardown to prevent coordinator OS threads
// (identity-sync, platform-address-sync, shielded-sync) from panicking when
// they poll `tokio::time::sleep` against a shutting-down timer wheel.
// The tool result has already been printed by this point; any SQLite writes
// issued before the tool returned are transaction-committed.
// See `DashMcpService::shutdown_wallet_backend` for the full race analysis.
use std::io::Write as _;
let _ = std::io::stdout().lock().flush();
let _ = std::io::stderr().lock().flush();
std::process::exit(exit_code);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: 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.

source: ['codex']

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants