Skip to content

fix(validator): slashing-protection review follow-ups: attestation index check, exit on a poisoned record - #789

Open
pablodeymo wants to merge 6 commits into
mainfrom
validator-slashing-protection-followups
Open

pablodeymo wants to merge 6 commits into
mainfrom
validator-slashing-protection-followups

Conversation

@pablodeymo

@pablodeymo pablodeymo commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

🗒️ Description / Motivation

#734 (durable slashing protection) and #736 (EIP-3076 import and export) were approved with a last round of review comments still open, and with two follow-ups set aside for a separate PR. This is that PR. It changes nothing about the slashing rule or the record's format.

Seven of the eight open points are addressed here. The eighth, ethlambda-rpc's dev-dependency on ethlambda-validator, is left alone and explained at the end.

The review of this PR is addressed too, and what follows describes the result.

What Changed

From the last round on #734

  • A re-import that was fully undone no longer says "keystore rewritten" (comment). Since b339ff7 the undo puts the key's keystore and password files back as well as its definitions entry, so the status now reads not imported: its slashing-protection record could not be written: …; its keystore and definitions entry are back as they were. (http_api/keystores.rs)

From the last round on #736

  • The vector runner skips a case only for the one refusal this importer makes (comment). It used to end a case whenever a step flagged contains_slashable_data was refused. Now the step's interchange must also hold an attestation whose source is at or past its target outside genesis, and the importer's own error must say that is why it refused; every other slashable step has to import. At v5.3.0, 21 steps are flagged slashable and expected to import, and 4 of them hold such an attestation (the single_validator_source_greater_than_target* cases). The predicate is restated in the test rather than borrowed from the crate, so the importer widening what it refuses cannot widen what the runner forgives. (tests/interchange_vectors.rs)
  • an_import_carrying_history_is_refused_whole is renamed to an_import_whose_history_does_not_parse_is_refused_whole, with its doc, and now asserts that the refusal is for the history's shape (review).
  • The Interchange doc says what the types are for (comment): shared by the record reader and the importer, strict in both, as Lighthouse's are. Behaviour unchanged. (slashing_protection/file.rs)
  • export no longer claims to need a writable directory (comment). The lock is an flock on the directory itself and an export writes nothing there. A test pins it on a mode 0500 directory. (docs/cli.md)

The two follow-ups #734 set aside

  • Attestation data whose index cannot be valid for its fork is refused (review). validate_attestation_data now takes the slot's fork and refuses any index but 0 before gloas, and any above 1 from gloas, where it is the payload signal. Those are gossip's own bounds (attestation.data.index == 0 from electra, < 2 from gloas). Lodestar's validator client refuses the same before signing (validateAttestationDuty); Teku enforces them on its beacon-node side (validateCommitteeIndexValue).
    • It is a liveness check, not a slashing one. Such a vote is rejected by every peer, after slashing protection has spent the validator's one vote for the epoch on it. Refused in the beacon-node implementation, the answer is an Err that failover moves past, and nothing is recorded.
    • Before electra the index is a committee, and this client only ever asks about committee 0, so 0 is the only answer to its question. Every fork is a named match arm, so a fork after gloas has to say what its index means.
    • At gloas only the range is checked: whether 0 or 1 is right depends on the node's view of the block.
    • Lean is not a Beacon Chain fork, so its arm is an unreachable! naming the function. An Err there would be blamed on the beacon node and retried on every other one.
    • fork must be the fork of slot and is taken on trust, since the implementations are handed it with the request and hold no schedule. The doc says so, and attest runs the check again under the fork it derives from its own Config.
  • A poisoned record stops the client, and the panicking access is told the panic was its own (comment).
    • SharedProtection is now a handle carrying the record and a signal, which the blocking task raises when it finds the lock poisoned or its own operation panics. run races the duty loop against it, so the client stops at once, exits with a failure, and systemd or Kubernetes restarts it. Before, it stayed up, answered /health and refused every attestation and proposal.
    • The keymanager goes last: run tells its server to stop accepting requests and waits, up to 60 s, for the ones in flight. An import caught between saving its keys and registering them meets the poisoned record and takes its keys off disk again, instead of leaving keys the next start refuses.
    • blocking tells the call whose own operation panicked that the panic was its own. Only later calls get an earlier access panicked.
    • tokio-util, already in the workspace, joins the validator crate for CancellationToken.

From the review of this PR, beyond what is folded in above

  • A failed import's undo says what it left on disk (comment). undo_import reports which step failed: the definitions save, where nothing was undone, or the files, naming the keys whose keystore and password could not be put back. Each key's status and the log line are built from that. A key whose entry was removed and whose files stayed is reported as left behind and unused, where it used to be reported as one the next start refuses. (http_api/keystores.rs)
    • The undo no longer stops at the first key whose files cannot be put back, so the others' files still go back.
    • An Unflushed definitions save inside the undo warns and goes on to the files. It used to be returned as a failure before the files were touched, although the definitions file had already been replaced.

Docs: docs/cli.md gets the export wording, the index check in the attestation walkthrough, and a note to run the client under a supervisor.

Correctness / Behavior Guarantees

  • No change to what is signed or recorded for a well-behaved beacon node. The slashing rule, the record's format and the import's merge are untouched.
  • Behavior change, attestations: a beacon node answering with an index outside what the slot's fork allows is now failed over from, where its answer used to be signed. If every configured node answers that way, the slot's attestations are not made and nothing is recorded for them.
  • Behavior change, process lifetime: a validator client whose slashing-protection lock is poisoned now exits non-zero as soon as the poisoning is seen, after giving keymanager requests in flight up to 60 s to finish. That only follows a panic while the record was held, which is a bug; a slow or failing disk does not take this path. A client with no supervisor used to idle and will now stop.
  • Keymanager: the same requests are refused as before. What changes is the path where registering an imported key fails: the statuses say what is on disk for each key, and when the undo cannot put one key's files back it still puts back the others'.

Tests Added / Run

  • Index check:
    • a_nonzero_index_is_refused_before_gloas (every fork before gloas), a_gloas_index_is_zero_or_one, the_lean_fork_is_a_bug_in_the_caller;
    • a_node_answering_with_an_index_invalid_at_the_fork_is_failed_over_from;
    • an_index_invalid_at_the_slots_fork_is_refused_and_records_nothing: the service still signs a correct answer for the same slot afterwards. It and the two older service tests beside it (wrong slot, wrong target epoch) now run against a mock that hands the data back unchecked (MockBeaconNode::without_attestation_data_check), so what refuses is attest's own re-check. Deleting the re-check fails all three.
    • ethlambda-rpc's wire tests, which drive this client against the node's own Beacon API at fulu and gloas, pass unchanged.
  • Poisoned record:
    • the_access_that_panics_is_told_so_and_raises_the_signal, the_access_that_finds_the_record_poisoned_raises_the_signal, the_signal_is_raised_even_when_the_caller_is_gone;
    • four tests of run_until_stopped, the part of run that needs no beacon node: an_unusable_record_stops_the_client_once_the_keymanager_has_finished, duties_that_stop_on_their_own_also_wait_for_the_keymanager, a_keymanager_that_never_finishes_does_not_hold_the_client_up, stopping_without_a_keymanager_waits_for_nothing;
    • an_import_in_flight_when_the_record_is_poisoned_is_undone_before_the_client_stops, end to end over a real socket: the import has saved its key and is waiting to register it when the record is poisoned, and it has undone itself and answered before the client is reported stopped. Without the wait for the keymanager the test fails.
  • Export: an_export_needs_no_writable_directory, skipped under root, where a mode 0500 directory refuses nothing.
  • Keymanager: a_failed_registration_restores_a_replaced_definitions_entry now asserts the new wording. New: an_undo_names_the_keys_whose_files_it_could_not_put_back, an_undo_that_cannot_save_the_definitions_undoes_nothing, the_status_of_an_unregistered_key_says_what_is_on_disk.
  • Vectors: all 38 still pass, and the runner now reads epochs written as bare integers as well as strings. Two mutation checks:
    • With the importer changed to refuse a pubkey listed twice, the old skip reports 1 failing case and skips the other two duplicate_pubkey_* cases without a word; the new skip reports all 3.
    • With the importer changed to refuse the four single_validator_source_greater_than_target* interchanges for another reason, the four cases fail. Before the error was matched, they passed.
  • Commands run:
    • make fmt: clean
    • make lint: clean
    • make test: 2935 passed, 0 failed (1324 in test-consensus, 1611 in test-node)

Related Issues / PRs

Not changed: ethlambda-rpc's dev-dependency on ethlambda-validator (comment). The dependency predates #734 (it came with #661); #734 only added the test-utils feature to it. It exists for rpc/src/beacon/validator_client_tests.rs, about 1,600 lines that drive the validator client's own HTTP client against this node's Beacon API over a socket, to check the two ends agree on the wire. Removing the dependency means moving that file to a crate that depends on both, and it reaches into ethlambda-rpc internals in about ten places (build_beacon_api_router, the test_utils module, FuluSignedBlockContents; proposal.rs's tests also borrow a helper back from it), so those would have to become a public test surface. That is a PR of its own, and it is worth agreeing where those tests should live before moving them.

✅ Verification Checklist

  • Ran make fmt — clean
  • Ran make lint (clippy with -D warnings) — clean
  • Ran make test (test-consensus plus test-node, at release-fast) — all passing

)

- A re-import whose registration failed and was fully undone now answers
  "not imported: ...; its keystore and definitions entry are back as they
  were". "keystore rewritten" stopped being true once the undo put the
  files back too, and is kept for the case where the undo itself failed.
- The interchange vector runner ends a case only when the refused step
  holds an attestation whose source is at or past its target outside
  genesis, the one shape this importer refuses, rather than on any step
  flagged as slashable. Every other slashable step must now import.
- an_import_carrying_history_is_refused_whole is renamed for what it has
  tested since the importer landed, history that does not parse, and
  asserts that this is the reason.
- The Interchange types' doc says they are shared by the record reader and
  the importer, both strict.
- docs/cli.md no longer says an export needs a writable directory: the
  lock is on the directory itself and an export writes nothing there. A
  test pins it on a mode 0500 directory.
validate_attestation_data checked the slot, the target epoch and the
source, and passed data.index through unchecked. It now takes the slot's
fork and refuses any index but 0 before gloas and any above 1 from gloas,
where the index is the payload signal. Those are gossip's own bounds
(attestation.data.index == 0 from electra, < 2 from gloas), and Lodestar's
validator client refuses the same before signing.

It is a liveness check rather than a slashing one. A vote whose index
cannot be valid is rejected by every peer, after slashing protection has
recorded its target, so the validator has no second vote to cast in that
epoch. Refused in the beacon-node implementation, the answer is an Err
that failover moves past, and nothing is recorded for it.

Before electra the index is a committee, and this client only ever asks
about committee 0. At gloas only the range is checked, since which of 0
and 1 is right depends on the node's view of the block. Every fork is a
named match arm, so a fork after gloas has to say what its index means.
Once a panic poisoned the record's lock, every duty was refused until a
restart, but the process stayed up and answered /health, so nothing that
supervises it had a reason to restart it. The duty loop now asks
slashing_protection::ensure_usable at each slot boundary and returns the
error from run on a poisoned lock: the process exits with a failure, and
systemd or Kubernetes applies the remedy the refusals ask for. The restart
rereads the record from disk, which every write either replaced whole or
left alone.

The access whose own operation panicked was told "an earlier access
panicked", which points at an event that never happened. It is now told it
was its own panic, with the panic's message; only later accesses get the
old wording.

docs/cli.md says to run the client under a supervisor.
@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

No actionable findings in the diff. The fork-specific attestation index validation runs before recording or signing, preserves beacon-node failover, and correctly allows Gloas indices 0 and 1. The poisoned-lock check propagates a fatal error for supervisor restart.

Could not run validator tests: Rustup attempted to write to the read-only /home/runner/.rustup/tmp directory.


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

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Review of PR 789

Overall this is a solid PR. The index check closes a real liveness gap: a bad vote gets recorded by slashing protection and then rejected by every peer. The poisoned-lock exit gives supervisors a signal they can act on. The interchange-vector runner is now stricter in a way that catches regressions it used to hide. A few points are worth addressing.

Correctness

Item 1: Pre-Electra index is the duty's committee, not 0 (beacon_node/mod.rs, highest_attestation_index)

  • Phase0 through Deneb return Ok(0), justified by "this client only ever asks about committee 0".
  • Before EIP-7549, data.index must equal the validator's own committee_index. A validator in committee 3 that signs data with index = 0 produces an attestation that fails verification.
  • The new check therefore codifies a pre-existing limitation: pre-Electra attesting is only correct for committee-0 validators.
  • If pre-Electra signing is unsupported, say so explicitly and consider refusing those forks outright, rather than "succeeding" with 0.
  • Otherwise the expected value should be the duty's committee index, which means comparing for equality rather than checking a range.
  • The new test in attestation.rs builds a duty whose committee appears to be 3 (duty(..., 1337, 96, 3)) at slot 96. That is Phase0 on mainnet, and it expects a successful publish with index = 0. That is exactly the invalid case described above, so the test passes for the wrong reason.

Item 2: ForkName::Lean is reported as InconsistentResponse (mod.rs, highest_attestation_index)

  • This is a caller bug, not a node misbehaving.
  • Because InconsistentResponse is failover-able, FallbackBeaconNode will query every node, discard every answer, and surface AllBeaconNodesFailed. That points the operator at the beacon nodes.
  • In http.rs and mock.rs the check also runs only after the request has been made.
  • Suggestions:
    • Use a non-retryable internal/config error variant.
    • Optionally, validate the fork before issuing the request.

Item 3: ensure_usable only detects poisoning, not a lock stuck past the wait (lib.rs, around line 268; error.rs, around line 117)

  • The updated SlashingProtectionUnusable doc now lists "it stayed held past the wait" alongside poisoning, under "names a process to restart … Never retryable".
  • A permanently held lock (for example a hung fsync or a deadlock) produces that error on every duty. ensure_usable passes, so the client still idles indefinitely, which is the same failure mode this PR fixes for poisoning.
  • Either:
    • narrow the doc so the timeout case is described as transient, or
    • track consecutive timeouts and stop after N slots.

Item 4: Keystore import message claims more than the old one did (http_api/keystores.rs, around line 414)

  • The (true, true) arm now says "its keystore and definitions entry are back as they were". The previous text said only the definitions entry was restored.
  • Please confirm that rolled_back restores the keystore and password files, not just the definitions entry.
  • If it doesn't, this message is now actively misleading, and the test only asserts the string, not the on-disk keystore bytes.
    • A cheap guard would be to assert in that test that the keystore file content equals the original.

Minor

  • mod.rs, error text: "a {} attestation's is at most {highest_index}" reads awkwardly. Consider "index for a {fork} attestation must be at most {highest_index}".
  • Fallback test (fallback.rs, around line 517): it uses ForkName::Electra with slot 96, which is Phase0 on mainnet. This is harmless with the mock, but it is inconsistent with the sibling test's "slot 96 is long before gloas" reasoning. Consider using a slot whose fork matches the fork argument.
  • an_export_needs_no_writable_directory (slashing_protection/mod.rs): it passes vacuously when the tests run as root, which is common in CI containers. Consider skipping when geteuid() == 0. The restore-before-expect ordering is good.
  • panic_message / try_into_panic: looks correct. The comment's claim that only the operation can panic while holding the guard holds, since the try_lock/sleep loop runs before the guard exists.

Looks good

  • holds_impossible_source: restating the rule independently of the crate is the right call. Unparseable epochs fall through to "not forgiven", which is the safe default. source != 0 correctly excludes only the (0, 0) genesis shape.
  • Exhaustive match on ForkName: naming every fork means a post-Gloas fork has to make an explicit decision about its index rather than inheriting Gloas's.
  • Placement of ensure_usable: checking at the slot boundary, before any duty, is the right spot. Returning Err from run gives a non-zero exit for systemd or Kubernetes to restart on.

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

@github-actions

Copy link
Copy Markdown

🤖 Gemini Code Review

The changes look solid and well-tested:

  • Fork-aware attestation index validation (crates/validator/src/beacon_node/mod.rs): Exhaustively matching on ForkName in highest_attestation_index (enforcing 0 pre-Gloas, <= 1 in Gloas, and rejecting Lean) and running it inside BeaconNodeApi::attestation_data ensures invalid indices trigger node failover before slashing protection records anything.
  • Poisoned slashing protection handling (crates/validator/src/slashing_protection/mod.rs, crates/validator/src/lib.rs): Checking ensure_usable at the slot boundary so the process exits for supervisor restart—and distinguishing the panicking access (including the extracted panic message) from subsequent accesses—is clean and accurate.
  • Keystore rollback reporting & EIP-3076 vector runner (crates/validator/src/http_api/keystores.rs, crates/validator/tests/interchange_vectors.rs): The 2x2 match on (before.contains_key(&pubkey), rolled_back.is_ok()) fixes the misleading "keystore rewritten" message on successful rollback, and scoping the allowed import failure in interchange_vectors.rs specifically to holds_impossible_source prevents masking regressions on other slashable inputs.

LGTM!


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

@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

No blocking issues found in the provided diff.

  • crates/validator/src/beacon_node/mod.rs: the fork-specific index bounds match the stated gossip rules, and the exhaustive ForkName match avoids silently inheriting Gloas behavior for future forks. Validation still occurs before slashing-protection recording or signing, and InconsistentResponse preserves failover behavior.
  • crates/validator/src/slashing_protection/mod.rs: JoinError::try_into_panic correctly distinguishes operation panics from cancellation, and panic_message handles the standard panic payload types safely. The once-per-slot poison check addresses the liveness problem without adding meaningful overhead.
  • crates/validator/tests/interchange_vectors.rs: narrowing the allowed import failure to interchanges actually containing an impossible source/target relationship is a useful test-hardening change; the genesis (0, 0) exception is handled correctly.
  • crates/validator/src/http_api/keystores.rs: the rollback message restructuring preserves the existing four cases and makes the successful-rollback message more accurate.

Optional coverage improvement: the tests exercise ensure_usable directly, but not its wiring in run (crates/validator/src/lib.rs, around line 268). A small duty-loop regression test could verify that poisoning actually terminates the client rather than merely returning the expected helper error.


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

@github-actions

Copy link
Copy Markdown

🤖 Grok Code Review

The attestation-index gate and the poisoned-record exit are the right safety direction. A few spots are weaker than the comments claim.

  1. Gloas accepts index 1 with no payload check in highest_attestation_index (crates/validator/src/beacon_node/mod.rs, the new match). That is only safe if gloas gossip really allows index < 2 on this attestation. If beacon attestations are still index == 0 and 1 is a different message, a bad node can return 1, this client signs it, every peer drops it, and slashing protection has already spent the epoch. Confirm against the gloas gossip condition before shipping. Pre-Gloas Ok(0) is a client invariant ("we only query committee 0"), not the protocol max. Any attestation_data caller that starts passing a non-zero committee will reject a valid answer and burn the vote the same way.

  2. The service regression test does not reach the service check. MockBeaconNode::attestation_data now calls validate_attestation_data and returns Err before the service sees the data (mock.rs). an_index_invalid_at_the_slots_fork_is_refused_and_records_nothing still passes if the service-side call is deleted. The trait comment says a public BeaconNodeApi can be wrong and the service must refuse before recording. Use a stub that returns index 1 without validating, and assert nothing was signed or recorded.

  3. Keystore status text now overclaims rollback (http_api/keystores.rs, (true, true) arm). The previous message said the keystore was rewritten even when rolled_back succeeded; this diff only changes the string to "keystore and definitions entry are back as they were" and the test only checks that string. If rolled_back restores the definitions entry and not the keystore bytes and password, operators will not look for a rewritten key. Assert the on-disk keystore and password match the pre-import files in that test.

  4. SlashingProtectionUnusable docs are broader than ensure_usable. The variant text (error.rs) says a lock held past the wait also makes every duty fail until the duty loop exits. ensure_usable only checks is_poisoned(), so a timeout is still per-duty and the next slot can succeed. Callers that treat the whole variant as fatal will restart on a slow lock. Keep the permanent-exit wording on the poison path only.

  5. holds_impossible_source can forgive the wrong failure (tests/interchange_vectors.rs). return Ok(()) ends the whole case when the file merely contains an impossible source, even if import failed earlier for another reason. Epochs are read only as strings, so a numeric epoch will not match. Safer than the old flag-only skip, but a step that fails for a parse error and also contains one bad attestation will go green. Skip only when the import error is the impossible-source refusal.

ensure_usable once a slot, the "this access" vs "earlier access" panic wording, and failing over an out-of-range index before signing are sound. panic_message covers &str and String. No change needed there.


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

@github-actions

Copy link
Copy Markdown

🤖 DeepSeek Code Review

Overall this looks solid. The attestation index validation is correctly layered in the node implementations, so FallbackBeaconNode can fail over before slashing protection records a target, and the slashing-protection poison handling is a real safety improvement.

A few minor points:

  • crates/validator/src/beacon_node/mod.rs, in validate_attestation_data’s new error message:

    "attestation data for slot {slot} carries index ..., and a {} attestation's is at most ..."

    is missing the word “index”: it should be something like

    "... and a {fork} attestation's index is at most {highest_index}"

    This is purely cosmetic but affects operator logs.

  • crates/validator/src/beacon_node/mod.rs, test a_nonzero_index_is_refused_before_gloas uses:

    ForkName::ALL
        .iter()
        .copied()
        .filter(|fork| *fork < ForkName::Gloas)

    This relies on enum ordering and the contents of ALL. If Lean is included and ordered before Gloas, the test will incorrectly assert that a Lean index is valid. Prefer an explicit list of Beacon Chain forks or a helper that matches the same forks as highest_attestation_index rather than relying on Ord.

  • crates/validator/src/beacon_node/mod.rs, highest_attestation_index returns Error::InconsistentResponse for ForkName::Lean. That makes a local caller/programming error look like an inconsistent node response and will cause failover to try every node. Consider a dedicated “unsupported fork” error or debug assertion; failover cannot fix this case anyway.

  • crates/validator/src/lib.rs, ensure_usable is checked once per slot at the boundary. That is fine for the intended supervisor-restart behavior, but a panic occurring mid-slot will not stop the client until the next slot. This is documented, so likely intentional.

No consensus-layer correctness issues found in this diff; the changes are validator-client validation and slashing-protection lifecycle improvements.


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

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

Nothing blocking. The exit path in lib.rs is the one I'd fix before merging; the rest are smaller.

Comment thread crates/validator/src/lib.rs Outdated
// Kubernetes the restart the refusals ask for. Asked once a slot, at
// the boundary, which is soon enough: in between, the record refuses
// every duty on its own.
if let Err(err) = slashing_protection::ensure_usable(&protection) {

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.

Returning here drops the runtime, and with it any keymanager request in flight. An import that has already written its keystore files and enabled definitions entry is then parked on slashing_protection::blocking(...).await (always a spawn_blocking, so a real yield) and is dropped there, so undo_import never runs. The next start finds a loaded key with no record and refuses the whole client, and a supervisor restarts it into the same refusal every time.

The window is narrow (a poisoned record plus an import in flight at the boundary), and a crash in the same spot already leaves this state by design. But this exit is deliberate, so it can be graceful: signal shutdown from where the poisoning is observed (e.g. a CancellationToken cancelled in blocking on the panic) and let the keymanager finish or undo before run returns. That would also stop the client at once rather than up to a slot later.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 11bec9b, along the lines you suggest. SharedProtection is now a handle carrying the record and a CancellationToken. The blocking task cancels it itself: when it finds the lock poisoned, and, through a drop guard, when its own operation panics, so it fires even if the caller was dropped at its deadline. run races the duty loop against that signal, so the client stops at once and the once-a-slot poll is gone.

Whatever ends the loop, run then cancels a shutdown token and waits for the keymanager server, which stops accepting and finishes its requests in flight (http_api::serve). The wait is bounded at 60 s, so a request that never ends cannot hold the restart up.

an_import_in_flight_when_the_record_is_poisoned_is_undone_before_the_client_stops runs your scenario over a real socket: the import has saved its key and is waiting to register it when the record is poisoned. It meets the poisoned record, undoes itself and answers before the client is reported stopped. With the wait removed, the test fails.

)),
// The undo failed as well, so what this request wrote
// over the key's files and entry may still be there.
(true, false) => KeyStatus::error(format!(

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.

undo_import saves the definitions first and only then restores the keystore and password files, so rolled_back being Err doesn't mean the entry stayed. If a put_back failed, the entry is back, and it's the keystore and password that are still the new ones, which this message doesn't say. Same for the (false, false) arm: if the entry was removed but the files weren't, they're orphans and nothing is refused at the next start. Having undo_import report which step failed would let both messages say what's actually on disk.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 346b523. undo_import now says which step failed: UndoFailure::Definitions, where nothing was undone, or UndoFailure::Files, naming the keys whose files could not be put back. It no longer stops at the first such key, so the others' files still go back.

The status is built per key from that (unregistered_status), six messages in all. A re-import whose put_back failed now reads "its definitions entry is back as it was, but its keystore and password files could not be put back (…) and may be the ones this request wrote". The (false, false) case you describe reads "its definitions entry is removed, but its keystore and password files could not be (…) and are left behind, unused", and the log line no longer says the next start refuses anything in that case.

One thing changed on the way: an Unflushed definitions save inside the undo was returned as a failure before the files were touched, although the file had already been replaced. It now warns and goes on to the files.

// say, a duplicated pubkey or a surround, with that case's blocks
// and attestations never attempted.
(false, true)
if step.contains_slashable_data && holds_impossible_source(&step.interchange) =>

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.

This forgives any refusal of an interchange that happens to contain an impossible source, not a refusal for it. If the importer started refusing something else (a duplicated pubkey, a surround) in one of the single_validator_source_greater_than_target* cases, the case would still return Ok with its blocks and attestations never tried, which is the masking this change sets out to remove. Matching the error as well (it says "is at or after its target epoch", slashing_protection/file.rs:178) would close it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 36277cb: the skip now also requires the importer's error to say "is at or after its target epoch", on top of the flag and the interchange holding such an attestation. With the importer changed to refuse those four interchanges for another reason, the four cases fail; before, they passed.

/// still signed. `context()` is mainnet's schedule, where slot 96 is long
/// before gloas, so 1 is not an index a vote there can carry.
#[tokio::test]
async fn an_index_invalid_at_the_slots_fork_is_refused_and_records_nothing() {

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.

This error comes from MockBeaconNode::attestation_data, which runs validate_attestation_data itself (mock.rs:602), not from the re-check in attest at line 138. Deleting that line, or passing it the wrong fork, keeps every test green. Covering it needs a BeaconNodeApi impl that hands the data back unchecked.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Right, and the same was true of the two older tests beside it. Done in 36277cb: MockBeaconNode::without_attestation_data_check hands the data back unchecked, and the three service-level tests (wrong slot, wrong target epoch, invalid index) use it, so what refuses is attest's own check. Deleting that line now fails all three, and running it under the wrong fork fails the index one.

Comment thread crates/validator/src/beacon_node/mod.rs Outdated
ForkName::Gloas => Ok(1),
// Not a Beacon Chain fork: `Config::fork_at_epoch` never names it, so
// a caller that does is wrong, and nothing is signed for it.
ForkName::Lean => Err(crate::error::Error::InconsistentResponse(

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.

Can't happen today, but if it did: this fires after the HTTP request, as InconsistentResponse, which is retryable and blamed on the node, so failover tries every node and reports them all as failed. A local bug would read as a beacon-node outage every slot. Checking the fork before the request, or unreachable! here, would report it as what it is.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 36277cb: unreachable!, naming the function, as the lean arms elsewhere do. highest_attestation_index returns a plain u64 again, and the comment says why it is not an Err.

propose(&mut protection, 1, 97).expect("propose");
drop(protection);

let read_only = std::fs::Permissions::from_mode(0o500);

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.

Missing the running_as_root() skip that a_failed_write_refuses_and_changes_nothing uses (line 1211). Under root the 0o500 mode restricts nothing, so in a root container this passes even if export starts writing to the directory.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 36277cb: skipped under root, like a_failed_write_refuses_and_changes_nothing.

Comment thread crates/validator/src/error.rs Outdated
/// restart. A poisoned lock is a bug, never a disk, and it stays poisoned
/// for the life of the process, so every duty is refused until it is
/// restarted. Never retryable.
/// for the life of the process, so every duty is refused from then on

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.

"Every duty is refused" is broader than what happens: only attestations and proposals consult the record. Sync committee messages and contributions, aggregates and PTC votes keep signing until the next boundary, then stop with the process. Same wording in ensure_usable and docs/cli.md.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 11bec9b. error.rs and docs/cli.md now say that only attestations and proposals consult the record, so those are what a poisoned one refuses, and ensure_usable is gone with the poll. With the stop now immediate, the other duties no longer run on until the next boundary either.

/// it, and the `Err` is one failover moves past to a node that may answer
/// properly. Lodestar's validator client refuses the same before signing
/// (`validateAttestationDuty`).
pub fn validate_attestation_data(slot: Slot, fork: ForkName, data: &AttestationData) -> Result<()> {

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: fork comes from the caller and is never checked against slot, so the index bound is only as right as the caller's fork. Every call site derives it correctly today, but the new failover test already passes ForkName::Electra for slot 96, which is phase0 on mainnet. A debug_assert against the schedule, or deriving the fork here, would remove the footgun.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Partly, in 36277cb. The failover tests now derive the fork from mainnet's schedule for the slot they ask about (fork_of), instead of naming one.

I did not add the assert or derive the fork inside. The two implementations that call this are handed the fork with the request and hold no Config to check it against. In attest, the one caller with a schedule, the fork is derived from it for that same slot just before the call, so an assert there would compare a value with itself. The doc now states the precondition, and that attest re-runs the check under the fork it derives. Deriving it here would mean handing HttpBeaconNode the spec, which seems more than the nit warrants; say so if you'd rather have it.

/// forgives along with it.
fn holds_impossible_source(interchange: &serde_json::Value) -> bool {
let epoch =
|value: &serde_json::Value| value.as_str().and_then(|text| text.parse::<u64>().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.

Nit: only quoted epochs are read here, while the importer also takes bare integers (quoted_or_bare). The vectors quote them per EIP-3076, so this is theoretical, but the restated rule covers less than the one it mirrors.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Done in 36277cb: bare integers are read too.

/// A panic's message, when it carried one: `panic!` with a literal gives a
/// `&str` and with arguments a `String`. Anything else was raised with
/// `panic_any` and has no text to show.
fn panic_message(panic: &(dyn std::any::Any + Send)) -> &str {

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: the same downcast as guarded in crates/net/p2p/src/beacon/verdict.rs (&str, then String, then a fallback), with a different fallback string.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Removed in 11bec9b rather than shared. The validator crate does not depend on p2p, so sharing it meant finding a common home for one helper, and the message was redundant anyway: the panic hook prints it, with its location, just before. The error now says the panic was this access's own and points there.

The duty loop asked ensure_usable once a slot, so a poisoned record was
seen up to a slot late, and run returned with the keymanager server still
serving. An import caught between saving its keys and registering them was
cut off there, leaving keys on disk with no record, which every later
start refuses.

SharedProtection is now a handle carrying the record and a signal. The
blocking task raises the signal itself: when it finds the lock poisoned,
and, through a drop guard, when its own operation panics, so it is raised
even if the caller gave up at its deadline. run races the duty loop
against it and the once-a-slot poll is gone.

Whatever ends the loop, run then tells the keymanager server to stop
accepting requests and waits for the ones in flight (http_api::serve,
axum's graceful shutdown). The import above meets the poisoned record,
takes its keys off disk again and answers. The wait is bounded at 60 s, so
a request that never ends cannot hold the restart up.

The access whose operation panicked is still told the panic was its own,
but no longer repeats the panic's message: the panic hook prints it, with
its location, just before. That removes a second copy of the payload
downcast.

Only attestations and proposals consult the record, so the docs no longer
say a poisoned one refuses every duty.

tokio-util joins the validator crate for CancellationToken.
When registering an imported key failed and the undo failed too, the
answer and the log said the next start would refuse the key, whichever
step of the undo had failed. That is true only when the definitions entry
is still there. When the entry was removed and only the files stayed, the
next start is fine and the files are merely left behind, unused.

undo_import now says which step failed: UndoFailure::Definitions, where
nothing was undone, or UndoFailure::Files, naming the keys whose files
could not be put back. Each key's status is built from that
(unregistered_status) and says what is on disk for that key, and the log
line warns of a refused start only where one follows.

Two changes on the way. The undo no longer stops at the first key whose
files cannot be put back, so the others' files still go back. And an
Unflushed definitions save inside the undo is no longer returned as a
failure before the files are touched: the file was already replaced, so
it warns and goes on.
- The interchange vector runner skips a refused case only when the
  importer's own error says the refusal is for an attestation whose source
  is at or after its target, on top of the step being flagged slashable
  and the interchange holding one. A refusal of such an interchange for
  any other reason now fails the case.
- MockBeaconNode::without_attestation_data_check hands attestation data
  back unchecked, and the three service tests (wrong slot, wrong target
  epoch, invalid index) use it, so what refuses is attest's own re-check
  rather than the mock's. Deleting the re-check now fails all three.
- validate_attestation_data's lean arm is an unreachable! naming the
  function, as the lean arms elsewhere are, instead of an error blamed on
  the beacon node. highest_attestation_index returns a plain u64 again.
- validate_attestation_data's doc states that fork must be the fork of
  slot, and the failover tests derive it from mainnet's schedule instead
  of naming one.
- an_export_needs_no_writable_directory is skipped under root, where a
  mode 0500 directory refuses nothing.
- The vector runner reads epochs written as bare integers as well as
  strings.

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