Skip to content

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

Open
pablodeymo wants to merge 3 commits into
mainfrom
validator-voluntary-exit
Open

pablodeymo wants to merge 3 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 validator 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. Goes through the private sign_root, so the Web3Signer variant in feat(validator): Web3Signer remote signing and the keymanager /eth/v1/remotekeys route #776 picks it up unchanged.
  • 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.
  • 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 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.
  • 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; 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/validator.rs, command.rs, main.rs: ethlambda validator exit --keystore <path> --password-file <path> [--beacon-node URL] [--epoch N] [--presign] [--no-confirmation] [--no-wait]. Logs go to stderr so a pre-signed exit on stdout pipes cleanly. subcommand_negates_reqs and args_conflicts_with_subcommands sit on the Command::Validator variant; the client's three required flags became Options clap still marks required, because the derived from_arg_matches refused the sub-command on a plain PathBuf field even though the parser allowed it. ValidatorOptions::required resolves them with an error rather than an unwrap.
  • crates/validator/src/error.rs: ExitRefused, deterministic like ProposalRefused. slot_clock.rs: slot_duration().
  • docs/cli.md: a validator exit section (flags, the phrase, what the wait prints, the EIP-7044 note) and the keymanager route.

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 --beacon-nodes ... parses exactly as before (the_validator_subcommand_is_not_given_a_default is unchanged), and a client run without its required flags is still refused.
  • --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).
  • 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.
  • bin/ethlambda/src/command.rs: validator exit parses without the client's flags; the client's flags are refused beside it; a client run still requires its flags.
  • cargo test -p ethlambda-validator --profile release-fast: 401 passed. cargo test -p ethlambda --profile release-fast: 268 passed. cargo test -p ethlambda-rpc --profile release-fast: 336 passed, the integration test among them. make lint: clean. cargo fmt --check: clean.

Related Issues / PRs

  • Related to feat(validator): Web3Signer remote signing and the keymanager /eth/v1/remotekeys route #776 (Web3Signer): a remote key will sign exits through the same path once it lands.
  • 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 on lines 36 to +48
#[arg(long, value_delimiter = ',', required = true)]
pub(crate) beacon_nodes: Vec<String>,
pub(crate) beacon_nodes: Option<Vec<String>>,

/// Directory holding the EIP-2335 keystores and `validator_definitions.yml`.
/// Without that file, keystores in the Lighthouse layout
/// (`<0xpubkey>/voting-keystore.json`, password in
/// `--secrets-dir/<0xpubkey>`) are discovered and the file is written.
#[arg(long)]
pub(crate) validators_dir: PathBuf,
#[arg(long, required = true)]
pub(crate) validators_dir: Option<PathBuf>,

/// Directory holding one password file per keystore.
#[arg(long)]
pub(crate) secrets_dir: PathBuf,
#[arg(long, required = true)]
pub(crate) secrets_dir: Option<PathBuf>,

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.

Please don't make these Options while keeping them required = true. I don't want flags that are optional in the type but required by clap.

Changing the type, then required() re-checking it at runtime, then subcommand_negates_reqs/args_conflicts_with_subcommands on the variant: all three are workarounds for fitting exit inside ValidatorOptions. Every future required client flag would have to repeat the pattern, and one that misses it fails with a runtime eyre error instead of a clap usage error.

Keep beacon_nodes, validators_dir and secrets_dir as plain Vec<String> / PathBuf, and make the exit its own top-level command, the way slashing-protection already is (e.g. ethlambda voluntary-exit ...). That keeps ethlambda validator --beacon-nodes ... exactly as it is today and removes all three workarounds.

/// The flags clap requires of a client run, which only `validator exit`
/// may omit (see the struct's comment). A `None` here is a bug in the
/// command-line wiring, reported as such rather than unwrapped.
fn required(self) -> eyre::Result<(Vec<String>, PathBuf, PathBuf)> {

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.

Goes away with the comment above: once the fields are plain required ones, clap guarantees them and there's nothing left to re-check at runtime.

// `args_conflicts_with_subcommands`: and none of the client's flags may be
// given alongside it, since none applies. On the variant rather than on
// `ValidatorOptions`, where clap's derive does not pick them up.
#[command(subcommand_negates_reqs = true, args_conflicts_with_subcommands = true)]

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.

Same here: with the exit as its own top-level command, neither setting is needed and Validator goes back to a plain variant.

///
/// `current_epoch` is the chain's, by the caller's clock; see
/// [`Self::voluntary_exit_signing_root`] for why the domain needs it.
pub fn sign_voluntary_exit(

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.

Heads-up on #776. This won't build against it, and the description's "the Web3Signer variant in #776 picks it up unchanged" doesn't hold:

/// The password file is read the way `ValidatorStore::load` reads one: whole,
/// with a trailing newline trimmed, since an editor leaves one and a password
/// ending in a newline is not something anyone chose.
pub fn load_keystore(

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.

Nit: this repeats the read → Keystore::from_json → read password → trim → decrypt → insert_secret steps from ValidatorStore::load. A shared helper that decrypts one keystore/password pair would keep the two from drifting (the trim and the reasoning behind it, or any later change to how passwords are read).


let resolved = context
.beacon_node
.validator_indices(&[pubkey])

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.

Nit: this resolves the index through validator_indices, while the CLI path uses the new validator() (GET /eth/v1/beacon/states/head/validators/{pubkey}), so the two exit paths look the validator up through different endpoints. One of them can fail where the other succeeds. Using validator() here too would keep them on one path. Relatedly, HttpBeaconNode::validator hand-rolls its GET and error mapping; letting get_optional take the status that means "not found" would avoid a third copy.

This branch has not been deployed

No deployments
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