Skip to content

feat(validator): voluntary exits, as an ethlambda voluntary-exit command and the keymanager /voluntary_exit route - #779

Merged
MegaRedHand merged 5 commits into
mainfrom
validator-voluntary-exit
Nov 13, 2026
Merged

MegaRedHand merged 5 commits into
mainfrom
validator-voluntary-exit

Conversation

@pablodeymo

@pablodeymo pablodeymo commented Nov 5, 2026 •

Copy link
Copy Markdown
Collaborator

🗒️ Description / Motivation

  • A comparison with the other consensus clients listed voluntary exits as missing: all five ship an exit command and the keymanager API's voluntary_exit route, and this client had neither. The beacon node already accepts, validates, pools and gossips a SignedVoluntaryExit (POST /eth/v1/beacon/pool/voluntary_exits), but nothing on the validator side could produce one.
  • This adds both: ethlambda voluntary-exit, which signs one keystore's exit and hands it to a beacon node, and POST /eth/v1/validator/{pubkey}/voluntary_exit on the keymanager API, which signs without publishing.

What Changed

  • crates/validator/src/signing.rs: voluntary_exit_signing_root and sign_voluntary_exit. The domain follows the fork of the state that processes the exit, the one at max(current_epoch, exit.epoch), which both functions take the current epoch for. EIP-7044: from deneb on that is CAPELLA_FORK_VERSION whatever epoch the exit names, an epoch from before deneb included; before deneb it mirrors get_domain on that state (its previous fork version for an epoch before its fork). Lighthouse keys on the exit's epoch alone, which signs --epoch 0 under the genesis version on a chain past deneb, an exit no node accepts. Local keystores only: it goes through the private sign_root, which feat(validator): Web3Signer remote signing and the keymanager /eth/v1/remotekeys route #776 replaces (see Related).
  • crates/validator/src/beacon_node/: two new BeaconNodeApi methods. validator(pubkey) is GET /eth/v1/beacon/states/head/validators/{pubkey} with the activation, exit and withdrawable epochs (404 is None; an answer naming another key is InconsistentResponse). submit_voluntary_exit is POST /eth/v1/beacon/pool/voluntary_exits; a 400 surfaces as BeaconNodeStatus with the node's reason. Implemented for the HTTP client, FallbackBeaconNode and the test mock. FallbackBeaconNode::submit_voluntary_exit fails over from a node that is down but ends the walk on a 400, so a refused exit is not resent to every node and reported as all of them failing; FallbackBeaconNode::validator takes the first node's answer as final, a 404 included. config_from_spec_response (dto.rs) now reads SHARD_COMMITTEE_PERIOD, which it had left at mainnet's 256 whatever the node reported.
  • crates/validator/src/exit.rs (new): the command. Refuses a syncing or optimistic node, then a future --epoch without --presign, then a validator the chain would refuse (not active_ongoing, or not yet SHARD_COMMITTEE_PERIOD past activation, naming the eligible epoch; the period is the node's own, from /eth/v1/config/spec, so a devnet that shortens it is followed, and it is measured against max(current_epoch, exit.epoch), as the chain measures it), signs, and unless --no-confirmation asks for the phrase Exit my validator on stdin before publishing. --presign stops after signing and returns the SignedVoluntaryExit. Unless --no-wait, it then polls the validator once a slot, for up to two epochs, until the chain has given it an exit epoch: active_exiting (printing the current, exit and withdrawable epochs and the time to the exit epoch), any later status, or active_slashed. A node error during the wait is retried at the next slot, and a wait that runs out returns successfully saying so, since the exit was published. run_with is the testable core over any BeaconNodeApi and any BufRead. The keystore and its password file are read by ValidatorStore::insert_keystore_file, the one helper ValidatorStore::load now uses too, so the command and the client cannot come to read a password differently.
  • crates/validator/src/http_api/voluntary_exit.rs (new): the keymanager route. epoch query parameter (the spec's Uint64 string) or the current epoch by the client's clock; 404 for a key not held and for a key the beacon node has no index for yet, through the same validator() lookup the command makes; 400 for a malformed key or epoch; the read lock is not held across the beacon node round trip, so a keystore delete landing in between is the same 404. KeymanagerContext gains beacon_node, signing and clock; parse_uint64 in proposer.rs now names the field it rejects.
  • bin/ethlambda/src/voluntary_exit.rs (new), command.rs, main.rs: ethlambda voluntary-exit --keystore <path> --password-file <path> [--beacon-node URL] [--epoch N] [--presign] [--no-confirmation] [--no-wait], a top-level command of its own like slashing-protection. Logs go to stderr so a pre-signed exit on stdout pipes cleanly. ethlambda validator and bin/ethlambda/src/validator.rs are untouched.
  • crates/validator/src/error.rs: ExitRefused, deterministic like ProposalRefused. slot_clock.rs: slot_duration().
  • docs/cli.md: a voluntary-exit section (flags, the phrase, what the wait prints, the EIP-7044 note, where the shard committee period comes from) and the keymanager route. The validator section no longer lists voluntary exits as not implemented.

Correctness / Behavior Guarantees

  • No existing signature changes: the new domain logic is confined to voluntary_exit_signing_root, and one_object_root_signs_differently_under_each_domain still covers the rest.
  • The keymanager route never publishes; the caller submits the returned message, as the specification has it.
  • The command refuses before signing everything the chain's process_voluntary_exit would refuse, so an operator sees the reason rather than a node's 400; the node's gossip rules remain the authority and a refusal from them is reported verbatim.
  • ethlambda validator is exactly as on main: its options, its required flags and its tests are not touched by this PR.
  • --presign with a future --epoch produces an exit a node would refuse today and accept once that epoch comes, which is the point of it. Without --presign a future epoch is refused before the prompt.
  • A run aborted at the confirmation prompt, an empty stdin included, exits with a failing status, so a script cannot take an exit that never went out for one that did. (Lighthouse exits 0 there.)
  • One rule cannot be checked up front: from electra on the chain refuses a validator with a partial withdrawal still pending. That comes back as the node's 400.

Tests Added / Run

  • signing.rs: an exit verifies under its own root; at and after deneb it signs under the capella version and not the schedule's; an exit naming a pre-deneb epoch signs under capella on a chain past deneb; before deneb under what the processing state verifies, an epoch two forks back included.
  • exit.rs: a syncing node, an unknown validator, an inactive one and one inside its shard committee period are refused with nothing published; an exit naming a past epoch publishes for an eligible validator; a future epoch without presign is refused; presign verifies and submits nothing; a wrong phrase submits nothing; the phrase publishes; --no-confirmation skips the prompt; the epoch defaults to the current one; the wait loop ends on every status that carries an exit epoch, gives up after its bound, and rides out a node outage after the publish (under start_paused).
  • beacon_node/dto.rs: SHARD_COMMITTEE_PERIOD is read from the spec response, keeps mainnet's value when absent, and is rejected when malformed.
  • beacon_node/fallback.rs: a refused exit ends the walk with the node's 400 and reason and reaches no second node; an exit fails over from a node that is down; every node down is still AllBeaconNodesFailed.
  • http_api/voluntary_exit.rs: the happy path returns a verifying signature with quoted integers; ?epoch= is honoured; ?epoch=0 on a chain past deneb verifies under the capella domain; malformed epoch and pubkey are 400; a key not held and a key with no index are 404; no bearer token is 401.
  • crates/net/rpc/src/beacon/validator_client_tests.rs: the whole command (minus the prompt) run against this node's Beacon API over a real socket: the lookup, the signature, the pool endpoint, the gossip publish, a 404 for an unknown key, a forged exit answered with the node's 400, and an exit naming epoch 0 accepted by the node. A second test runs the command against this node serving a two-epoch SHARD_COMMITTEE_PERIOD at epoch 2, which mainnet's 256 would refuse.
  • bin/ethlambda/src/command.rs: voluntary-exit parses as its own sub-command with its own flags, and is not given the node default.
  • cargo test -p ethlambda-validator --profile release-fast: 405 passed. cargo test -p ethlambda --profile release-fast: 266 passed. cargo test -p ethlambda-rpc --profile release-fast: 337 passed, the two integration tests among them. make lint: clean. cargo fmt --check: clean.

Related Issues / PRs

  • feat(validator): Web3Signer remote signing and the keymanager /eth/v1/remotekeys route #776 (Web3Signer) and this PR collide, and whichever lands second needs a rebase that is more than textual: feat(validator): Web3Signer remote signing and the keymanager /eth/v1/remotekeys route #776 makes every sign_* async over &RwLock<ValidatorStore>, replaces sign_root with sign(store, pubkey, root, epoch, SigningBody) and gives SigningContext a private field behind SigningContext::new. sign_voluntary_exit, the SigningContext { .. } literals here and the keymanager route's read guard all have to follow. Remote keys cannot sign exits until SigningBody gains a voluntary-exit variant whose fork_info yields the EIP-7044 capella domain rather than the exit epoch's scheduled fork; what Web3Signer does with it after deneb is still to be confirmed.
  • Out of scope, as follow-ups: --presign for a validator that is not active yet (the keymanager route already signs for any held key with an index), a password prompt when --password-file is absent (needs an rpassword-style dependency), exiting several validators in one call, and exiting through a running client's keymanager from the CLI (Lighthouse's validator-manager exit).
  • Not yet run against a live network; the integration test covers this node, and the signing rule is pinned against the STF's own process_voluntary_exit domain.

✅ Verification Checklist

  • Ran make fmt — clean
  • Ran make lint (clippy with -D warnings) — clean
  • Ran make test — the validator crate, the binary and the rpc integration test were run at release-fast; CI runs the rest

…exit`

sub-command that signs one keystore's exit, asks for a confirmation phrase,
publishes it to a beacon node and waits for the chain to accept it, plus the
keymanager API's POST /eth/v1/validator/{pubkey}/voluntary_exit, which signs
without publishing. Exits sign under the capella fork version from deneb on
(EIP-7044), keyed on the fork at the exit's epoch as Lighthouse does, so a
pre-signed exit stays valid across later forks.

The beacon node API gains `validator` (one registry entry with its activation,
exit and withdrawable epochs) and `submit_voluntary_exit`, implemented for the
HTTP client, failover and the test mock. The keymanager context carries the
beacon node, signing context and clock the new route needs.

The client's three required flags become `Option`s clap still marks required,
because `subcommand_negates_reqs` only relaxes the parser: the derived
`from_arg_matches` refused `validator exit` on a plain `PathBuf` field.
@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

  1. [P1] Old-epoch exits use the wrong signing domain — signing.rs:214. EIP-7044 depends on the processing state’s fork, regardless of the exit’s epoch. On mainnet today, requesting ?epoch=0 produces a genesis-domain signature that nodes reject; they require the Capella domain. Select the rule using the current processing fork and add a regression test for an epoch-0 exit on a post-Deneb chain.

  2. [P2] Eligibility incorrectly depends on the exit message’s epoch — exit.rs:258. Consensus compares the current epoch against activation_epoch + SHARD_COMMITTEE_PERIOD. This check instead rejects valid historical-epoch exits, such as --epoch 0 for a mature validator. Pass the current epoch separately for eligibility checks; treat the message epoch as the earliest processing epoch.

Tests could not run because rustup attempted to write to a read-only directory.


Automated review by OpenAI Codex · gpt-6.1-sol · custom prompt

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Review: ethlambda validator exit and the keymanager voluntary_exit route

The overall structure is solid:

  • Key check before the node round-trip.
  • Pubkey cross-check on the /validators/{id} answer.
  • subcommand_negates_reqs wiring, with tests for both directions.
  • Good test coverage, including a real end-to-end run against the node.

Most items below are edge cases. Item 1 is the one I'd fix before merge.

Correctness

1. EIP-7044 domain is keyed on the exit's epoch, not the processing fork (signing.rs, voluntary_exit_signing_root)

On any deneb-or-later state, process_voluntary_exit verifies under CAPELLA_FORK_VERSION regardless of exit.epoch. An exit whose epoch is before deneb is still processable today, because the spec only requires current_epoch >= exit.epoch.

Today, --epoch <pre-deneb epoch> on mainnet, or ?epoch= on the keymanager route, signs under the altair, bellatrix or capella version. The resulting exit is invalid everywhere. The doc comment's claim ("same answer … for every exit a live network can still process") doesn't hold for past epochs.

Suggested fix: key the switch on the current fork, either by wall clock (fork_at_epoch(clock_epoch)) or max(current_epoch, exit.epoch). Then use capella if that is ≥ Deneb. Matching Lighthouse is a fine tiebreaker, but it shares the same edge. Please add a test for a pre-deneb exit.epoch signed while the chain is past deneb.

2. Shard-committee check uses the requested epoch, not the current one (exit.rs, eligible_for_exit)

The spec gate is get_current_epoch(state) >= activation_epoch + SHARD_COMMITTEE_PERIOD.

  • When publishing, the check should use current_epoch. As written, --epoch set to an old epoch is refused even though the chain would accept it.
  • For --presign, checking the target epoch is the right intent, so keep it there.

3. Presign refuses validators that aren't active_ongoing (exit.rs, eligible_for_exit)

The main EIP-7044 use case is a staking service pre-signing for a deposit that is pending_queued. That validator already has an index and an activation_epoch, but this code refuses it. Consider relaxing the status gate under presign. Keep requiring an index, and keep refusing exited_*, withdrawal_*, and *_slashed.

4. No early refusal of a future --epoch without --presign (exit.rs, run_with)

The operator walks through the confirmation prompt, only to get the node's 400. A one-line check after computing epoch would refuse up front.

5. wait_for_acceptance can loop forever (exit.rs)

There is no bound, and no terminal arm for active_slashed. Other statuses also just print "Waiting…" each slot indefinitely, for example if the exit is dropped from the pool or the validator is slashed meanwhile. Suggestions:

  • Add a timeout, e.g. N epochs, after which you report "published, not yet included" and return.
  • Treat active_slashed as terminal.

Also, a transient node.validator error here returns Err after a successful publish. The operator sees a failure for an exit that went out. Consider logging the error and continuing to poll, or wrapping it so the message says the exit was published.

6. Truncating cast (exit.rs, in the wait loop)

epochs_to_go.saturating_mul(SLOTS_PER_EPOCH) as u32 silently wraps. Use u32::try_from(..).unwrap_or(u32::MAX), or compute in u64 seconds directly: slot_duration.as_secs() * slots.

UX / API

7. Aborted prompt exits 0 (validator.rs, exit)

Outcome::NotConfirmed exits with status 0, including on EOF or empty stdin. A script without --no-confirmation would treat "nothing published" as success. A non-zero exit, or at least detecting EOF as an error, is safer.

8. Misleading lock comment (voluntary_exit.rs, "Signed under the store's read lock…")

require_held takes and releases the read lock, the node is queried, and then the lock is re-taken. A delete can land in between. The behavior is still correct, because UnknownValidator maps to 404, but the comment should say that rather than claim atomicity.

9. Wrong error variant (exit.rs, print_presigned)

A serialization failure is mapped to Error::Decode. That's a misnomer; Io or a dedicated variant would be clearer.

10. Undocumented Electra refusal

Electra adds get_pending_balance_to_withdraw(state, index) == 0 to exit validity. The API can't check this, so the node's 400 is the right surface. A short mention in the docs or the module comment would save operators confusion.

Minor

  • load_keystore: the password String and decrypted secret aren't zeroized. Consider Zeroizing<String>, if the rest of the keystore path does this.
  • FallbackBeaconNode::submit_voluntary_exit: confirm try_each does not fall through to the next node on a 400. Otherwise a refused exit is resubmitted N times, which is harmless but produces noisy logs.
  • the_wait_ends_once_the_chain_reports_the_validator_exiting depends on the whole pre-sleep path completing in run's first poll before flip runs. This works with the mock, but it's fragile. Flipping the details after the first submit_voluntary_exit would be deterministic.

Automated review by Claude · claude-opus-5-5 · custom prompt

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

🤖 Gemini Code Review

This PR is well-structured and thoroughly tested, including EIP-7044 (CAPELLA_FORK_VERSION pinning from Deneb onward), CLI flag conflict handling, and the Keymanager voluntary exit endpoint.

A few minor edge cases in crates/validator/src/exit.rs worth addressing:

  1. Future --epoch without --presign is not refused before prompting (crates/validator/src/exit.rs, lines 145–147, 229–268)
    eligible_for_exit checks epoch < eligible_epoch, where epoch is options.epoch.unwrap_or(current_epoch). If an operator passes --epoch <future_epoch> without --presign (for instance, passing --epoch <eligible_epoch> while current_epoch < eligible_epoch), eligible_for_exit succeeds, prompts for the confirmation phrase, and submits the exit to the beacon node, which will reject it with a 400 (get_current_epoch(state) >= voluntary_exit.epoch and get_current_epoch(state) >= activation_epoch + SHARD_COMMITTEE_PERIOD).

    Checking if !options.presign && epoch > current_epoch and returning Error::ExitRefused (mentioning --presign) before prompting ensures both current_epoch >= epoch and current_epoch >= eligible_epoch hold whenever publishing.

  2. Terminal validator statuses and as u32 cast in wait_for_acceptance (crates/validator/src/exit.rs, lines 286–315)

    • The match details.status.as_str() only exits the loop on "active_exiting" and "exited_unslashed" | "exited_slashed". If a validator is in "active_slashed", "withdrawal_possible", or "withdrawal_done", it will hit the wildcard arm and keep polling. Including "active_slashed" alongside "active_exiting" and "withdrawal_possible" | "withdrawal_done" alongside "exited_*" avoids looping unnecessarily.
    • On line 291, epochs_to_go.saturating_mul(SLOTS_PER_EPOCH) as u32 will silently truncate if the u64 product exceeds u32::MAX. Using u32::try_from(...).unwrap_or(u32::MAX) prevents truncation.

Automated review by Gemini · gemini-4-argon · custom prompt

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

🤖 Grok Code Review

The voluntary-exit flow is careful in the right places (syncing refusal, pubkey/index binding, confirmation before publish, EIP-7044 called out, e2e coverage). A few consensus and CLI correctness gaps should be fixed before this is relied on for real exits.

  1. EIP-7044 domain is keyed on the exit epoch, not the state that will verify it. In voluntary_exit_signing_root (crates/validator/src/signing.rs, the fork_at_epoch(exit.epoch) branch), a pre-deneb exit.epoch is signed under that epoch's fork version. Post-deneb process_voluntary_exit always verifies with CAPELLA_FORK_VERSION, regardless of voluntary_exit.epoch. On any live post-deneb network, ?epoch=0 / --epoch below the deneb fork (a normal pre-sign) produces a signature every honest node will reject. The tests lock this in rather than catching it. Use capella whenever the exit can only be processed at or after deneb, i.e. when max(current_epoch, exit.epoch) is deneb-or-later; keep the historical domain only if both are still pre-deneb. Callers in exit.rs and http_api/voluntary_exit.rs already have the current epoch.

  2. SHARD_COMMITTEE_PERIOD is checked against the wrong epoch. eligible_for_exit in crates/validator/src/exit.rs refuses when exit.epoch < activation_epoch + SHARD_COMMITTEE_PERIOD and the comment says this is the same gate as process_voluntary_exit. The spec checks current_epoch for that inequality, and current_epoch >= voluntary_exit.epoch separately. A valid exit whose message epoch is earlier than eligibility (including a pre-signed epoch 0 once the validator is eligible) is rejected locally; the chain would accept it. This is fail-safe, but it is not spec-equivalent, and it blocks the usual "sign a low epoch so it can be broadcast later" pattern. Check current_epoch for the committee period, and reject a not-yet-valid epoch only on the publish path, not on --presign.

  3. Aborting the confirmation prompt exits 0. In bin/ethlambda/src/validator.rs (exit), Outcome::NotConfirmed is logged and then Ok(()). A script that misses the phrase, or hits EOF, looks successful even though nothing was published. Return a non-zero error for that outcome.

  4. wait_for_acceptance can hang after a successful inclusion. The status match only treats active_exiting, exited_unslashed, and exited_slashed as done. withdrawal_possible / withdrawal_done (easy to hit on a devnet with a short withdrawability delay, or if the first poll is late) fall through to "Waiting..." forever. There is also no timeout if the pool never includes the exit. Treat the withdrawal statuses as success and cap the wait.

The pubkey check in HttpBeaconNode::validator (refuse to sign an index if the node answers about a different key) is the right safeguard, and the clap split so validator exit does not inherit the client's required flags looks correct. The keymanager route correctly does not publish; once Item 1 is fixed it will sign the domain the beacon API actually verifies.


Automated review by Grok (xAI) · grok-4.7 · custom prompt

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

🤖 DeepSeek Code Review

Overall the PR is well structured and the EIP-7044 signing-domain logic looks correct. The CLI split, the response-pubkey check in http.rs, and the test coverage are good. Main concerns:

  • Consensus eligibility check uses the wrong epoch in crates/validator/src/exit.rs. run_with computes current_epoch but calls eligible_for_exit(node, &pubkey, epoch, &signing), and eligible_for_exit then compares epoch against activation_epoch + SHARD_COMMITTEE_PERIOD. process_voluntary_exit requires:

    • get_current_epoch(state) >= activation_epoch + SHARD_COMMITTEE_PERIOD
    • get_current_epoch(state) >= voluntary_exit.epoch

    It does not require voluntary_exit.epoch >= activation_epoch + SHARD_COMMITTEE_PERIOD.

    This causes both false negatives and false positives: an already-eligible validator with an explicit older --epoch is refused, while a validator that is not yet eligible can sign/publish a future-epoch exit and only receive a node 400. eligible_for_exit should take current_epoch separately and check eligibility against that; for a non-presign run it should also check current_epoch >= epoch.

  • Eligibility depends on the Beacon API status string in eligible_for_exit (details.status != "active_ongoing"). The spec’s gate is structural: is_active_validator(...) plus validator.exit_epoch == FAR_FUTURE_EPOCH. This can refuse validators such as active_slashed that are still active and may still have exit_epoch == FAR_FUTURE_EPOCH. The DTO already has activation/exit/withdrawable epochs; use those instead of the status string.

  • wait_for_acceptance can hang forever and may misreport success in crates/validator/src/exit.rs. It has no timeout or iteration limit, and it treats any active_exiting/exited_* transition as the result of this specific exit. If the gossip path drops the exit, or the validator exits for another reason, the command can hang indefinitely or report false success. Consider adding a bounded wait and, if possible, checking that the state transition corresponds to the submitted exit.

  • Decrypted key material is not zeroized in load_keystore. The secret is inserted into ValidatorStore but the local secret buffer is discarded without clearing. For a one-shot CLI handling voting keys, clear the secret with zeroize after insertion.

  • FallbackBeaconNode::validator likely stops on the first Ok(None) in crates/validator/src/beacon_node/fallback.rs. If a lagging first node returns 404 while another node has the validator, failover may not happen. Confirm try_each semantics and whether a 404 should be treated as a terminal answer.

  • Error::is_deterministic now includes ExitRefused in crates/validator/src/error.rs. Exit eligibility changes as the chain advances, so classifying it as deterministic may suppress retries that should happen after a few epochs, unlike the slot-specific guard refusals described in the comment.


Automated review by DeepSeek · deepseek-v4-pro · custom prompt

@github-actions

github-actions Bot commented Nov 5, 2026

Copy link
Copy Markdown

🤖 Kimi Code Review

  1. crates/validator/src/signing.rs:213-219 — historical exit epochs can produce an invalid signing domain.
    voluntary_exit_signing_root selects the domain solely from fork_at_epoch(exit.epoch). However, once Deneb is active, process_voluntary_exit verifies every exit using CAPELLA_FORK_VERSION, regardless of the exit’s epoch. Consequently, --epoch or the keymanager’s ?epoch= can name a pre-Deneb epoch after Deneb has activated, causing this code to sign under an older fork version and the beacon node to reject the exit. A similar mismatch exists pre-Deneb for epochs older than the processing state’s previous fork. Select the domain using the processing/current fork as well as the exit epoch, or explicitly reject unsupported historical epochs. A regression test covering “current fork is Deneb, exit epoch is pre-Deneb” would help.

The normal current/future-epoch path correctly implements EIP-7044. The confirmation flow, presign/no-publication separation, node-response pubkey check, and keymanager authentication coverage look good.


Automated review by Kimi (Moonshot AI) · kimi-k3.1-pro · custom prompt

Conflicts were all two sides adding to the same place, and both are kept:

- bin/ethlambda/src/main.rs: the `validator exit` dispatch beside the new
  `slashing-protection` command.
- bin/ethlambda/src/validator.rs: `--init-slashing-protection` is read
  before `ValidatorOptions::required` consumes the options.
- crates/validator: `KeymanagerContext` carries the exit route's
  `beacon_node`, `signing` and `clock` beside main's `definitions_unflushed`
  and `protection`, at its definition and at every site that builds one,
  the exit route's own test harness included.
…e rest of the review

The signing domain was keyed on the fork at the exit's own epoch. A node
verifies against the state that processes the exit: any deneb-or-later
state uses the capella version whatever epoch the exit names (EIP-7044),
so `--epoch 0`, or `?epoch=0` on the keymanager route, produced an exit
every node on a chain past deneb rejects. The domain now follows the fork
at max(current_epoch, exit.epoch), the earliest epoch a state can process
the exit in, and before deneb mirrors `get_domain` (the processing state's
previous version for an epoch before its fork). `sign_voluntary_exit` and
`voluntary_exit_signing_root` take the current epoch for that.

`ethlambda validator exit`:
- The shard committee period is measured against max(current_epoch,
  exit.epoch), as the chain measures it, so an exit naming an old epoch is
  no longer refused for a validator that is eligible now. A pre-signed
  exit is still checked against the epoch it is signed for.
- A future `--epoch` without `--presign` is refused before the prompt
  rather than by the node's 400 after it.
- The wait for the chain is bounded at two epochs, ends on any status
  that carries an exit epoch (`active_slashed` and `withdrawal_*`
  included), and no longer turns a node error after the publish into a
  failed run: the exit went out, so the node is asked again next slot.
- An aborted confirmation prompt, an empty stdin included, exits with a
  failing status.
- The slots-to-seconds product no longer truncates through `as u32`, a
  JSON encoding failure is no longer reported as a malformed response,
  and the password is zeroized on drop like the secret it decrypts.

The keymanager route's comment no longer claims the read lock is held
across the beacon node round trip; a delete landing in between is the
same 404. docs/cli.md follows all of the above and notes electra's
pending-withdrawal refusal, which only the node can check.

Tests: the domain for a pre-deneb epoch on a chain past deneb and for an
epoch two forks back before it; the old-epoch, future-epoch and
pre-signed eligibility cases; every terminal status of the wait, its
bound, and an outage after the publish, with the mock answering as a
chain that processed the exit instead of a racing flip; the keymanager
route at `?epoch=0`; and an exit naming epoch 0 accepted by this node's
Beacon API end to end.
@pablodeymo

Copy link
Copy Markdown
Collaborator Author

/review

@MegaRedHand MegaRedHand 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.

No correctness issues found in the exit logic or the EIP-7044 domain handling. The tests against the STF's process_voluntary_exit domain and the end-to-end run against this node's Beacon API are good to have.

One change I want before this merges: the validator client's required flags must not become Options that are still marked required (see the comment on bin/ethlambda/src/validator.rs). Please keep them as plain required fields and give exit its own command instead.

The rest are nits, plus a heads-up on how this collides with #776.

Comment thread bin/ethlambda/src/validator.rs Resolved
Comment thread bin/ethlambda/src/validator.rs Resolved
Comment thread bin/ethlambda/src/command.rs Resolved
Comment thread crates/validator/src/signing.rs Resolved
Comment thread crates/validator/src/exit.rs Resolved
…nd validator lookups

Review follow-ups on the voluntary exit:

- `ethlambda validator exit` becomes `ethlambda voluntary-exit`, a command
  of its own like `slashing-protection`. Living inside `validator` had
  turned the client's three required flags into `Option`s clap still
  marked required, re-checked at runtime by `ValidatorOptions::required`,
  with `subcommand_negates_reqs` and `args_conflicts_with_subcommands` on
  the variant to let the exit omit them. All three workarounds are gone:
  `bin/ethlambda/src/validator.rs` is as on main, and a client run without
  its flags is clap's usage error again rather than a runtime one. The
  command's options and entry point move to `voluntary_exit.rs`.
- `ValidatorStore::insert_keystore_file` is the one place a keystore and
  its password file are read from disk, used by `ValidatorStore::load` and
  by the exit command, which had its own copy of the same steps. The
  password is zeroized on drop in both now.
- The keymanager route resolves the validator through `validator()`, the
  lookup the command makes, instead of `validator_indices`, so the two ways
  to sign an exit cannot disagree about a validator's index.
- `HttpBeaconNode::get_optional` takes the status that means "nothing", a
  204 or a 404, so `validator()` no longer hand-rolls its GET and error
  mapping.

docs/cli.md gives `voluntary-exit` its own section beside
`slashing-protection`.
@pablodeymo pablodeymo changed the title feat(validator): voluntary exits, as an ethlambda validator exit sub-command and the keymanager /voluntary_exit route feat(validator): voluntary exits, as an ethlambda voluntary-exit command and the keymanager /voluntary_exit route Nov 12, 2026

@MegaRedHand MegaRedHand 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.

Re-reviewed at 92a9d6b. All the earlier threads are addressed. The EIP-7044 domain now keys on max(current_epoch, exit.epoch), which matches what the processing state verifies (capella from deneb on, the previous fork's version for an older epoch before deneb), and the node's own gossip check verifies under the same domain.

One thing I'd fix before merging (the SHARD_COMMITTEE_PERIOD comment on exit.rs); the other two are nits.

Comment thread crates/validator/src/exit.rs Resolved
… on a refused exit

Review follow-ups on the voluntary exit:

- `config_from_spec_response` starts from `Config::mainnet()` and never
  read `SHARD_COMMITTEE_PERIOD`, so the exit command measured every
  validator against mainnet's 256 epochs whatever the node reported. On a
  mainnet-preset devnet that shortens the period, the usual way to try an
  exit without waiting about 27 hours, it refused an eligible validator
  and no flag got past it. The key is read now, and the function's doc
  names the keys it reads and says every other `Config` field stays
  mainnet's, so the next field this crate comes to read is added there
  first.
- `FallbackBeaconNode::submit_voluntary_exit` went through `try_each`,
  which walks past every `Err`: a 400 from the first node was resent to
  each node behind it and came back as `AllBeaconNodesFailed`, logged as
  a missed duty, where the trait promises `BeaconNodeStatus` with the
  node's reason. A 400 now ends the walk and is returned as it is. Any
  other failure still fails over, since it is about the node and not the
  exit.
- `FallbackBeaconNode::validator` is documented as it behaves: the first
  node's answer is final, a 404 included, and with every node down
  `try_each` logs a missed duty for what is only the keymanager's
  request. The keymanager route's call site points at it.

docs/cli.md says where the period comes from, and no longer lists
voluntary exits among what the validator client does not implement.

Tests: the period read, defaulted and rejected when malformed; the whole
command run against this node serving a two-epoch period at epoch 2, which
mainnet's 256 would refuse; a refused exit ending the walk with the node's
reason and reaching no second node; an exit failing over from a node that
is down; every node down still being `AllBeaconNodesFailed`.
@MegaRedHand
MegaRedHand added this pull request to the merge queue Nov 13, 2026
Merged via the queue into main with commit 7ad885a Nov 13, 2026
13 checks passed
@MegaRedHand
MegaRedHand deleted the validator-voluntary-exit branch November 13, 2026 15:58
pablodeymo added a commit that referenced this pull request Nov 13, 2026
…through the async signing path.

The two collide in `http_api/mod.rs`, `http_api/proposer.rs`, `lib.rs`, `signing.rs` and `docs/cli.md`, and #779 signs through the synchronous `sign_root` this branch replaces. Both sides are kept: `KeymanagerContext` carries the remote signer default beside the exit route's node, signing context and clock.

`sign_voluntary_exit` is async over `&RwLock<ValidatorStore>` like every other signer, so the keymanager route passes `&context.store` and holds no guard across what is a round trip for a remote key, and the `voluntary-exit` command puts its one decrypted keystore behind the lock. The `SigningContext { .. }` literals become `SigningContext::new`.

A remote key's exit is a `VOLUNTARY_EXIT` request. Web3Signer derives the root itself from the fork it is told and refuses a `signingRoot` that disagrees (`Eth2SignForIdentifierHandler`, via Teku's `signingRootForSignVoluntaryExit`), and it has picked the rule by the exit's epoch in older releases and by the told fork's epoch in current ones. So the signer is told the one version the exit is signed under as both `previous_version` and `current_version`, with the exit's epoch, which lands on this client's root under either reading; `sign` gains `sign_under` to take that fork in place of the schedule's.
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