Repository navigation
feat(validator): durable EIP-3076 slashing protection - #734
Conversation
🤖 Claude Code ReviewReview of PR 734The only change in the working tree is a 4-line edit to
The diff replaces the description of durable slashing protection with "still keeps no slashing-protection record". The code on this branch has that protection:
The text being removed was accurate. It said the client keeps EIP-3076 minimal-watermark protection in The new text says the opposite. Operators or agents who read Suggested fix: revert the Caveats
Automated review by Claude (Anthropic) · sonnet · custom prompt |
🤖 Kimi Code ReviewI'll review this PR which introduces durable slashing protection to replace in-memory guards. This is critical consensus-layer code affecting validator safety. Security & Correctness1.
|
| Severity | Count | Items |
|---|---|---|
| Critical | 0 | — |
| High | 1 | Item 4: macOS fsync vs F_FULLFSYNC durability concern |
| Medium | 2 | Item 1: spawn_blocking panic handling; Item 3: O(n) clone per write |
| Low | 4 | Items 5, 8, 9, 11, 12 |
The implementation is fundamentally correct and well-structured. The most significant concern is the macOS durability trade-off (Item 4), which should be documented more prominently or revisited. The O(n) clone (Item 3) should be benchmarked for expected validator counts.
Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt
🤖 Codex Code ReviewLooks good overall. The PR materially improves validator safety by moving attestation/proposal guards to durable pre-sign recording, using atomic replace + fsync semantics, and sharing the guard with key import. I did not spot a consensus-critical bug in fork choice, state transition, SSZ, or signature logic because this PR stays in the validator client path.
Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
MegaRedHand
left a comment
There was a problem hiding this comment.
The Kurtosis assertoor battery will fail on main after this merges.
The pinned lambdaclass/ethereum-package (67cb0e02, src/vc/ethlambda.star) starts ethlambda validator with --validators-dir=/data/ethlambda/validators, which is a fresh directory on every run, and without --init-slashing-protection. tooling/kurtosis-assertoor/network_params.yaml sets no vc_extra_params for the two ethlambda VC participants.
So SlashingProtection::open hits !missing.is_empty() && !init and the VC exits. The block-proposal check for participants 2 and 3 can then never pass. This PR's CI will not show it, because kurtosis_assertoor.yml runs on pull requests only for changes under its own directory or workflow; the failure appears on the first push to main.
Fix: add vc_extra_params: ["--init-slashing-protection"] to both ethlambda VC participants (safe there, since the genesis keys have never signed), or pass the flag in the fork's launcher.
| // record keeps it. The history the request may carry is not imported yet, | ||
| // so a key arriving from another client starts as never having signed: | ||
| // see the warning in `import`. | ||
| let registered = context |
There was a problem hiding this comment.
Keymanager import registers keys as never having signed, which skips the --init-slashing-protection gate and drops the history the caller sent.
register() writes Record::default() for every new key. When the request includes slashing_protection, import only logs a warn!. The CLI calls this exact action DANGEROUS and refuses it without a flag. The API does it on every import, with no flag and no two-epoch condition.
Scenario: a key is deleted from another VC (or from this one after a DELETE) and imported here with its interchange. It is registered with no watermarks and attests in the same epoch, which is a double vote.
Until PR 2, it would be safer to refuse the import when slashing_protection is present, or to seed the new record at the current epoch/slot rather than None.
There was a problem hiding this comment.
Done in 2808b51. Keys registered without history (--init-slashing-protection and keymanager imports) now start at the current slot and epoch. One difference from the suggestion: the attestation source stays at 0. The source a beacon node hands out is the justified epoch, which lags the current one, so seeding it at the current epoch would refuse every attestation until justification caught up, and on a non-finalizing chain, forever. The target watermark alone closes the double-vote window. The surround window against older history stays open, which is why the flag's docs still say "two epochs".
| reason, | ||
| }; | ||
|
|
||
| let existed = path.exists(); |
There was a problem hiding this comment.
path.exists() fails open. It returns false for a dangling symlink and for any metadata error (for example EACCES). In both cases the record is treated as absent.
Scenario: an operator symlinks slashing_protection.json to a separate volume, and that volume is not mounted at startup. With zero keys at startup (keymanager-only), or with --init-slashing-protection left in the unit file, open writes a fresh empty record. rename then replaces the symlink itself, so the real history is orphaned and every key signs from None.
Suggestion: use symlink_metadata / try_exists. Treat only NotFound as absent, and refuse anything that is not a regular file.
There was a problem hiding this comment.
Fixed in 2808b51. Only NotFound counts as absent; a symlink, directory or metadata error is refused instead of replaced (file::exists), with a test for a dangling symlink.
| let secret = keystore.decrypt(password).map_err(KeyStatus::error)?; | ||
| let pubkey = ValidatorStore::derive_pubkey(&secret).map_err(KeyStatus::error)?; | ||
|
|
||
| // Registered before the key is written to disk or reachable through the |
There was a problem hiding this comment.
A failed import leaves a "never signed" record behind, and that record later bypasses the startup gate.
register() commits before persist_import. When persist_import fails (disk, definitions save, symlink refusal), the status is error, but nothing removes the empty record that was just written.
Later the operator adds the same keystore by hand, for example copied from the client that ran it. On restart, open() finds a record, so --init-slashing-protection is never required and the key starts as never having signed.
Suggestion: register only after the key is persisted (keeping the order "record exists before the key is loaded"), or remove the record on failure if this call created it.
There was a problem hiding this comment.
Fixed in 2808b51. Keys are registered in one write, after they are persisted and before any is loaded, so a failed import leaves no record (test: a_failed_import_leaves_no_slashing_protection_record).
| // A poisoned lock is treated as a refusal rather than unwrapped: a | ||
| // crash in the duty path would be a worse outcome than a skipped | ||
| // proposal. | ||
| match self.protection.lock() { |
There was a problem hiding this comment.
The std::sync::Mutex is taken on the executor here (and in keystores::prepare_import), while another holder may be inside an fsync.
The SharedProtection doc says async callers take it inside spawn_blocking. This pre-check does not, and keymanager register does a full rewrite plus two fsyncs inline in an axum handler.
Concrete case: an attestation duty abandoned at its deadline (timeout in attest_and_aggregate) does not cancel the detached blocking task, so its write can still be running at the next slot boundary. propose then blocks the duty-loop worker on lock() for as long as the disk takes.
Suggestion: move the pre-check and register into spawn_blocking too.
| let first = encode_hex(&missing[0].0); | ||
| return Err(refused(format!( | ||
| "{} validator key(s) have no slashing-protection record (first: {first}). \ | ||
| Import their history from the client that last ran them, or, only if they \ |
There was a problem hiding this comment.
This startup error points operators to a path that does not exist. "Import their history from the client that last ran them" is not supported in this PR: there is no CLI import, and the keymanager ignores slashing_protection.
An operator migrating keys therefore has one real option, --init-slashing-protection. That is the dangerous one for a key that signed recently elsewhere.
Suggestion: until PR 2, say that import is not available yet, and spell out the wait condition (stop the old client and wait at least two epochs) as a requirement.
| @@ -268,30 +266,38 @@ impl<B: BeaconNodeApi> ProposalService<B> { | |||
| // guard's slot is recorded against and what the signature covers. | |||
There was a problem hiding this comment.
Stale "guard" wording remains after the guards were removed. Locations:
- here, and
proposal.rs:226 - the test docs at
proposal.rs:653, 800, 1072 error.rs:75: theProposalRefusedrustdoc still says "The proposal guard refused"error.rs:140: "A refusal is deterministic". That is no longer true, sinceProposalRefusednow also carries record-write failures.beacon_node/fallback.rs:609, 625andbeacon_node/mod.rs:163
There was a problem hiding this comment.
Fixed in 2808b51, including ProposalRefused's rustdoc and the is_retryable comment, which now covers record-write failures too.
| /// Created, or truncated if it exists, at mode `0600` on unix: the record says | ||
| /// which keys this client holds. | ||
| #[cfg(unix)] | ||
| fn open_private(path: &Path) -> std::io::Result<File> { |
There was a problem hiding this comment.
Reuse (cleanup). open_private re-implements secure_fs::write_private. secure_fs.rs:8-10 says every file that "decides what a validator signs" must go through that module. write_durably is also a second tmp+rename next to ValidatorDefinitions::save (keys/definitions.rs:54-75), which has no fsync.
Suggestion: add one secure_fs::write_private_durably (tmp at 0600, sync_all, rename, directory fsync) and use it for both files.
There was a problem hiding this comment.
Done in 2808b51: secure_fs::write_private_durably is shared by the record and validator_definitions.yml, which gains its missing fsyncs.
| let pubkeys: Vec<BlsPubkey> = candidates.iter().map(|(_, pubkey)| *pubkey).collect(); | ||
| let protection = self.protection.clone(); | ||
| let recorded_data = data; | ||
| let verdicts = tokio::task::spawn_blocking(move || { |
There was a problem hiding this comment.
Simplification (cleanup). This block (spawn_blocking, then lock, then map poison to a string, then flatten the JoinError) appears again almost verbatim in proposal.rs:274-285.
Suggestion: one helper in slashing_protection, for example async fn blocking<T>(p: &SharedProtection, f: impl FnOnce(&mut SlashingProtection) -> Result<T> + Send) -> Result<T, String>. It removes the duplicate, and the keymanager and proposal pre-check could use it too (see the executor-blocking comment).
There was a problem hiding this comment.
Done in 2808b51: one slashing_protection::blocking helper replaces the duplicated blocks.
| self.records.extend(changes); | ||
| return Ok(()); | ||
| }; | ||
| let mut next = self.records.clone(); |
There was a problem hiding this comment.
Efficiency (cleanup). Each commit clones the whole records map and rewrites and double-fsyncs the full pretty-printed file, while holding the mutex. So the cost of every slot's batch and every block grows with the total number of keys, not with the batch.
The keymanager makes it worse: register(&[pubkey]) runs once per key, so an import of N keystores does N full rewrites and 2N fsyncs, and attestation writes wait behind them.
Cheaper options:
- Serialize from
self.recordswithchangesoverlaid, without cloning. - Use compact JSON.
- Register all decrypted keys of one import in a single commit.
There was a problem hiding this comment.
Done in 2808b51: no clone per write (unchanged entries are chained with the changes), compact JSON, and one write per keymanager import.
| "Registering a validator with no slashing-protection history \ | ||
| (--init-slashing-protection)" | ||
| ); | ||
| records.insert(*key, Record::default()); |
There was a problem hiding this comment.
Altitude. init (and keymanager registration) seed None, so "safe only if nothing was signed for two epochs" is documentation, not enforcement. open runs after genesis()/spec(), so the current slot and epoch are known.
Seeding block_slot = current_slot and attestation = (current_epoch, current_epoch) would cost at most one epoch of duties. It would close the double-vote window for a key that signed recently elsewhere, both here and on the keymanager path. Worth considering, even if it lands in PR 2.
There was a problem hiding this comment.
Done in 2808b51. Keys registered without history (--init-slashing-protection and keymanager imports) now start at the current slot and epoch. One difference from the suggestion: the attestation source stays at 0. The source a beacon node hands out is the justified epoch, which lags the current one, so seeding it at the current epoch would refuse every attestation until justification caught up, and on a non-finalizing chain, forever. The target watermark alone closes the double-vote window. The surround window against older history stays open, which is why the flag's docs still say "two epochs".
|
Kurtosis battery (review summary): Added |
…and attestation is checked against a per-validator record of EIP-3076 minimal watermarks (highest block slot, highest attestation source and target) that is written to disk before the signature exists. The record lives in <validators-dir>/slashing_protection.json as an EIP-3076 v5 minimal interchange bound to the chain's genesis_validators_root, is written through a temporary file, fsync, rename and directory fsync, and is locked to one process. Reading fails closed, a failed write signs nothing, and a key with no record is refused at startup unless --init-slashing-protection is passed. Keymanager imports register a key before loading it. This replaces the in-memory AttestationGuard and ProposalGuard, whose rule and tests move into the new slashing_protection module, and removes the startup warning that the client kept no record.
…thout history (--init-slashing-protection, keymanager imports) now start at the current slot and epoch instead of as never having signed, closing the double-vote and double-proposal window for a key that signed elsewhere a moment ago; the source stays at zero so a lagging justified checkpoint does not stall attestations. The record's existence check fails closed: only a missing file is absence, and a symlink, directory or unreadable path is refused instead of replaced. Keymanager imports register keys in one write after they are persisted, so a failed import leaves no record behind. Every async access to the record, including the proposal pre-check and keymanager registration, goes through one spawn_blocking helper. The durable write moves to secure_fs and is shared with the validator definitions file, which gains its missing fsyncs; each write no longer clones the record and is compact JSON. A failed attestation write counts every refused attestation, the permission-based tests skip under root, stale guard wording is gone, the docs say the lock is per directory and that --validators-dir must be writable, and the Kurtosis battery passes --init-slashing-protection to its two ethlambda validator clients.
… write creates its temporary file fresh with create_new at mode 0600, removing whatever was at that path first, so a leftover of a crashed write cannot carry a wider mode into the renamed record and a symlink planted there is never written through; two tests pin both. The directory flush after the rename tries File::sync_all first, which is F_FULLFSYNC on macOS like the file's own flush, and falls back to a plain fsync only where a filesystem refuses that for a directory. The blocking helper returns the crate's own Error instead of a string, so a caller can tell a failed write from a record that cannot be reached at all: a lock poisoned by a panic, or the runtime shutting down, is the new SlashingProtectionUnusable, whose message says the client must be restarted. Attested gains a refused count so a slot where slashing protection withheld every signature, or could not record them, is distinguishable from a slot with nothing to publish.
696e2a8 to
7c3001b
Compare
| /// since the source a beacon node offers is the justified epoch, which lags | ||
| /// the current one, and seeding it higher would refuse every attestation | ||
| /// until justification caught up. | ||
| fn seeded(now: Option<Slot>) -> Record { |
There was a problem hiding this comment.
Drop the seeding and leave the migration window to doppelganger protection (to be added in another PR).
Record::seeded came out of my 10-29 review, but I'd remove it and go back to registering keys with no history as never having signed:
- It only guards against messages signed elsewhere up to this node's
now, and it costs the rest of the epoch's duties every time a key is registered. - It's inconsistent between entry points: feat(validator): EIP-3076 slashing-protection import and export #736's interchange import still registers an empty or partial entry as never signed, so the same key starts differently depending on how it arrived.
- Lighthouse doesn't seed in any of these cases (
--init-slashing-protection, keymanagerPOSTwithout history, empty interchange entries). It registers with no history and covers the migration window with its opt-in doppelganger protection, which checks the network for the key's activity before letting it sign.
What changes: Record::seeded and its callers (open's init path, register), the keymanager warnings in keystores.rs, the flag's help in bin/ethlambda/src/validator.rs, and the "as if it had signed a block and an attestation at the current slot and epoch" wording in docs/cli.md.
There was a problem hiding this comment.
Done in 4db7bb0. Record::seeded is gone and open and register no longer take a slot: a key with no history is registered as Record::default() again, at startup and from the keymanager, so slot 0 and epoch 0 are signable once. The keymanager's two import warnings, the flag's help in bin/ethlambda/src/validator.rs and the docs/cli.md row now say "as never having signed", and the module doc says the migration window is doppelganger protection's to close. The seeding test is replaced by one that pins the never-signed start for both entry points. PR description updated to match.
| let directory = std::fs::File::open(directory)?; | ||
| directory.sync_all().or_else(|full_sync| { | ||
| // SAFETY: the descriptor is owned by `directory`, which outlives the call. | ||
| if unsafe { libc::fsync(directory.as_raw_fd()) } == 0 { |
There was a problem hiding this comment.
This fallback also runs on Linux, where it can turn a failed flush into Ok.
On Linux, File::sync_all is already fsync, so after a failure this retries the same call on the same descriptor. Retrying fsync after an error is not reliable there: the kernel reports a writeback error once per open file, so the second call can return 0 even though nothing reached the disk (the PostgreSQL "fsyncgate" behavior). Whether that happens for a directory depends on the filesystem; I haven't reproduced it.
If it does, write_private_durably returns Ok, commit treats the record as durable, and the block or attestation is signed. After a power loss the rename can be lost, the record goes back to the older watermark, and the restarted client can sign the same slot or target again.
The fallback only exists for macOS (F_FULLFSYNC refused for a directory), so gating it with #[cfg(target_os = "macos")] fixes this without changing behavior there.
There was a problem hiding this comment.
Agreed, and done in 4db7bb0. The plain fsync retry is now under #[cfg(target_os = "macos")], so on Linux sync_directory is a single sync_all whose error is returned as is. The doc comment spells out why a second fsync after a failed one must not be trusted there. Behaviour on macOS is unchanged.
…ned again, dropping the seeding at the current slot and epoch: it only covered messages signed elsewhere up to this node's clock, cost the rest of the epoch's duties on every registration, and would have differed from the interchange import's empty entries; the migration window is doppelganger protection's to close. Gate the directory fsync fallback to macOS, where it exists for filesystems that refuse F_FULLFSYNC on a directory: on Linux sync_all already is fsync, and a retry after a failed one can report success after the kernel dropped the writeback error.
MegaRedHand
left a comment
There was a problem hiding this comment.
Reviewed at 4db7bb0. The signing path looks solid: every block and attestation is checked and durably recorded before it is signed, and a failed write signs nothing. Comments inline.
Follow-up for a separate PR (not blocking this one): validate_attestation_data checks the slot and target epoch but passes data.index through unchecked. Teku (validateCommitteeIndexValue) and Lodestar (validateAttestationDuty: 0 at electra, 0 or 1 at gloas) refuse an index that cannot be valid for the fork. It is a liveness check rather than a slashing one, since protection already refuses a second vote for the same target, and it is a few lines next to the existing checks.
| (Ok((_, secret)), Ok(_)) => finish_import(&mut store, &secret), | ||
| // Persisted but not registered: not loaded now, and refused at the | ||
| // next start until it has a record. | ||
| (Ok(_), Err(err)) => KeyStatus::error(format!( |
There was a problem hiding this comment.
If register fails here, persist_import has already written the key and added it to validator_definitions.yml as enabled. The response says "saved, but not loaded", but the next restart hits the no-record check in SlashingProtection::open and refuses to start the whole validator client, not just this key.
Failing closed is right for a crash between persist and register, but on this path we know registration failed and can still clean up. Could we undo persist_import for the persisted keys when registration is Err (remove the definitions entries and the keystore and password files), so a failed write costs only this request?
There was a problem hiding this comment.
Done in 0ef17bf. When the registration write fails, the keys this request added are taken off disk again: their definitions entries in one save first, since that file is what the next start loads from, then the keystore and password files. A key the file already listed is left as it was (its entry and keystore were rewritten, not added), and its status keeps the "saved, but not loaded" wording. a_failed_registration_rolls_the_import_back forces the write to fail with a directory in the record's place, so it runs the same under root.
| // persist failed leaves no record behind to let a hand-copied keystore | ||
| // past the startup check later. A crash in between leaves a key on disk | ||
| // with no record, which the next start refuses: closed, not open. Still | ||
| // under the definitions guard, so a concurrent delete cannot interleave. |
There was a problem hiding this comment.
Pre-existing on main, but this comment made me look: the guard is dropped at line 247, before context.store.write(). On the multi-threaded runtime, a DELETE of the same key can take the definitions lock in that gap and queue for the store lock first. It then finds the key on disk but not yet in memory, removes it, and answers deleted. finish_import then inserts it, and it signs from memory until the next restart, although the operator was told it is gone.
The window is tiny, but the fix is free: release the guard after store.write() (or after finish_import). delete already takes the two locks in that order, so there is no deadlock risk.
There was a problem hiding this comment.
Done in 0ef17bf: the definitions guard is now held across the store lock and released after it, in the order delete takes the two. The comment above the lock explains the race it closes.
| - **Keys with no record:** refused at startup. `--init-slashing-protection` | ||
| registers them as if each had signed a block and an attestation at the | ||
| current slot and epoch, and a key imported through the keymanager API with | ||
| no history is registered the same way before it is loaded. That closes the | ||
| double-vote window for a key that signed elsewhere a moment ago, at the cost | ||
| of the rest of the epoch's duties, but not a surround against older history, | ||
| so the flag is still only for keys that have signed nothing anywhere for at | ||
| least two epochs. |
There was a problem hiding this comment.
This still describes the seeding that 4db7bb0 removed. Keys registered by --init-slashing-protection or a keymanager import now start as never having signed (Record::default()), so this does not close the double-vote window for a key that signed elsewhere a moment ago. Line 97 makes the same claim for imports.
It should say the window is left to doppelganger protection, which docs/cli.md lists as not implemented yet, and adjust the guidance on when the flag is safe accordingly. (#736 is still based on the pre-4db7bb03 version and uses Record::seeded; whichever way that lands, these docs should match it.)
| /// The current slot, which dates the record of a key imported with no | ||
| /// history. See `SlashingProtection::register`. | ||
| pub clock: crate::slot_clock::SlotClock, |
There was a problem hiding this comment.
Removed in 0ef17bf, with its initializer in lib.rs and the two test ones. Nothing dates a registration any more.
| /// protects within one process and nothing more, which is exactly what the | ||
| /// in-memory guards this module replaced did. The client itself only ever | ||
| /// uses [`Self::open`]. | ||
| pub fn in_memory() -> SharedProtection { |
There was a problem hiding this comment.
Nit: this is pub, treats every unknown key as registered and persists nothing, so it is a footgun for any non-test caller. ethlambda-rpc's tests use it, so cfg(test) will not do, but a test-utils feature would.
Related: since most service tests use this, the Refusal::Unregistered path in attest/propose has no service-level test, only the record-level an_unregistered_key_is_refused_on_a_persisted_record.
There was a problem hiding this comment.
Done in 09d1cef: in_memory is behind a test-utils feature, which the rpc crate enables as a dev-dependency. The Refusal::Unregistered path now has a service-level test too, an_unregistered_key_is_refused_before_a_block_is_requested in proposal.rs, over a persisted record opened with no keys.
| /// The watermarks one entry stands for: the highest slot, source and target it | ||
| /// lists. More than one entry of a kind is reduced rather than refused, since | ||
| /// taking the maximum is never less strict than any one of them. | ||
| fn record_of(entry: &Entry) -> std::result::Result<Record, String> { |
There was a problem hiding this comment.
No test feeds record_of more than one entry of a kind, yet reducing them to their maxima is what keeps a file with several entries for one validator from lowering a watermark. A small test would pin it: attestations [(5, 9), (2, 4)] and blocks [7, 3] should read back as (5, 9) and 7. The deleted guards had recording_out_of_order_does_not_lower_the_bar tests, and this is where that property lives now.
There was a problem hiding this comment.
Added in 0ef17bf, with your values: blocks [7, 3] and attestations [(5, 9), (2, 4)] read back as 7 and (5, 9).
| protection.check_and_record_block(&pubkey, slot) | ||
| }) | ||
| .await; | ||
| let refusal = match recorded { |
There was a problem hiding this comment.
Nit: this maps the blocking result to a refusal, increments inc_blocks_refused and returns ProposalRefused, exactly like the block at line 173. The two copies already differ: the first logs a refusal at warn and this one does not. A small helper returning Result<()> would keep them together.
There was a problem hiding this comment.
Done in 0ef17bf. I took the middle option: off unix sync_directory returns Unsupported, so the durable write fails rather than answers Ok without a flush, and the doc on write_private_durably states the guarantee as unix-only. A compile_error! would have made the whole crate unix-only, which seemed more than the function warrants.
| } | ||
|
|
||
| #[cfg(not(unix))] | ||
| fn sync_directory(_directory: &Path) -> std::io::Result<()> { |
There was a problem hiding this comment.
Nit: on non-unix this reports success without flushing, while write_private_durably's doc promises that Ok means the data is on disk. Nothing builds for Windows today, so the simplest fix is to state the guarantee as unix-only, or compile_error! on non-unix, rather than return Ok.
There was a problem hiding this comment.
Done in 0ef17bf: one refuse_unless_safe helper, which also makes the two paths log the same way (a judged refusal at warn, a record that could not be consulted or written at error).
| protection: &SharedProtection, | ||
| operation: impl FnOnce(&mut SlashingProtection) -> Result<T> + Send + 'static, | ||
| ) -> Result<T> { | ||
| let poisoned = || Error::SlashingProtectionUnusable { |
There was a problem hiding this comment.
Not for this PR, just a possible follow-up. Once the mutex is poisoned, every duty is refused until restart, but the process keeps running, so a supervisor never restarts it. Exiting on poison would let systemd or k8s apply the remedy this message asks for.
Also minor: the call whose own panic poisoned the lock gets "an earlier access panicked", which points at the wrong event.
There was a problem hiding this comment.
Agreed on both, and left for a follow-up PR as you suggest: exiting on poison so a supervisor restarts the client, and attributing the first failure to its own panic rather than to "an earlier access".
…on fails: the keys this request added are taken out of the definitions file and their keystore and password files removed, so the failed write costs the request only instead of leaving a key on disk that the next start refuses the whole client for; a key the file already listed is left as it was. The definitions guard is now held across the store lock, in the order delete takes the two, so a delete of the same key can no longer slip in between, answer deleted and then watch the import load it. The keymanager context's clock, which only dated the dropped seeding, is gone, and the spec deviations no longer describe that seeding. The two blocks mapping slashing protection's answer to a refused proposal are one helper, a persisted record's refusal of an unregistered key gets a service-level test, record_of's reduction of several entries to their maxima gets its own test, and off unix the durable write fails instead of answering Ok without flushing the directory.
…ats every unknown key as registered and persists nothing, which no production caller should be able to reach. The rpc crate's tests, the only users outside this crate, enable the feature as a dev-dependency.
|
/review |
🤖 Codex Code Review
Tests could not run: Rustup attempted to write to its read-only toolchain directory. Automated review by OpenAI Codex · gpt-6.1-sol · custom prompt |
🤖 Claude Code ReviewReview: durable EIP-3076 minimal slashing protectionThis is a solid change. Several things are done right:
The items below are what I would address or consider. Correctness / security
Performance
Rust / build
Follow-ups (not blocking)
Overall the change looks good. I would address Item 1 before merge, since durability turns a transient beacon-node fault into a persistent outage. Item 2 also needs a decision before merge. Automated review by Claude · claude-opus-5-5 · custom prompt |
🤖 Gemini Code ReviewSolid implementation of durable EIP-3076 minimal-watermark slashing protection. The ordering guarantees (durable file + directory sync before any block or attestation signature is produced, and outside the One minor edge case worth noting:
Automated review by Gemini · gemini-4-argon · custom prompt |
🤖 DeepSeek Code ReviewOverall this is a well-structured change: checking, durably recording, then signing is the right order, and the fail-closed record reader/locking are good. Issues found:
Automated review by DeepSeek · deepseek-v4-pro · custom prompt |
🤖 Grok Code ReviewThe record-before-sign design is sound: watermarks fail closed, a failed write does not advance memory, and the restart and rollback tests cover the important cases. A few safety gaps remain.
The minimal rule itself (strictly increasing target, non-decreasing source, strictly increasing block slot, Automated review by Grok (xAI) · grok-4.7 · custom prompt |
🤖 Kimi Code Review
The central safety design otherwise looks sound: records are committed before slashable signatures exist, failed writes do not update in-memory watermarks on Unix, duplicate keys in one attestation batch are handled, and startup/import paths fail closed. Automated review by Kimi (Moonshot AI) · kimi-k3.1-pro · custom prompt |
…utside genesis, so a beacon node cannot talk the client into recording a source the chain has not justified, which the durable record would then hold against every honest attestation until justification caught up. Tell a durable write that failed before the rename from one that failed after it: before, nothing changed; after, the file is replaced and only its survival of a power loss is in doubt, so the record takes the new watermarks into memory while still signing nothing (Error::Unflushed), and the definitions file counts the write as done with a warning, since a lost entry only removes a key. Off unix the durable write is refused before anything is touched. A failed write removes its temporary again, and a single-component relative path flushes the current directory rather than "". Startup warns when --init-slashing-protection is set and every key already has a record. A re-imported key whose registration write failed says its keystore was rewritten and it stays as it was, rather than "not loaded". The proposal refusal log names the validator on both branches, the write-time histogram reaches 5 s, the module doc states the per-write cost, the docs say the directory should be a local disk and that two idle epochs rule out a double vote but not a surround, and [features] moves to the end of the manifest so the libc comment stays with its table.
…he record: flock follows the inode, so deleting slashing_protection.lock let a second process lock a fresh file and sign with the same keys, while the directory is the one inode nothing here replaces or deletes. The lock file is gone, and a test opens the same directory twice.
|
Answers to the six automated reviews of 09d1cef. Fixed in ae16928 and 81bdc45 unless noted. Fixed
Not changed, with the reason
|
|
/review |
🤖 Codex Code ReviewNo actionable findings. The watermark checks, durable writes before signing, process locking, and fail-closed error handling look sound. Validation limitation: I couldn’t run the validator tests because Rust toolchain setup attempted to write to the read-only Automated review by OpenAI Codex · gpt-6.1-sol · custom prompt |
🤖 Claude Code ReviewReview: PR 734, durable EIP-3076 minimal slashing protectionThis is a solid, carefully reasoned change. The ordering is check, then durable record, then sign. Reads fail closed, the lock is per directory, the chain is pinned by Correctness / security
Build / portability
Performance
Smaller notes
Overall the approach is sound. Item 5 should be verified before merge, and Items 1–3 are worth addressing or explicitly documenting. Automated review by Claude · claude-opus-5-5 · custom prompt |
|
/review |
🤖 Codex Code Review
Tests could not run: rustup attempted to write to a read-only toolchain directory. Automated review by OpenAI Codex · gpt-6.1-sol · custom prompt |
🤖 Claude Code ReviewReview: durable EIP-3076 minimal slashing protectionThis is a solid change. The ordering is right: the record is checked, then durably written, then the message is signed. Reads fail closed, the lock is on the directory inode rather than a deletable lock file, the chain root is checked, Correctness / safety
Rust / readability
Minor
Nothing blocking beyond items 1 and 2. Item 6 is a quick fix worth making before merge. Automated review by Claude · claude-opus-5-5 · custom prompt |
🤖 Gemini Code ReviewThis PR is very well-designed and thorough. The fail-closed semantics on startup and signing paths, durable atomic file replacement ( One minor suggestion:
Automated review by Gemini · gemini-4-argon · custom prompt |
…import's files back as they were, probe the directory flush at startup, and make a retried delete flush what an earlier one could not. record_of now refuses a source at or after its target outside genesis, as the record and the attestation-data check do, so a file carrying such an entry stops the client at startup instead of refusing every honest attestation after it. An undone import restores the keystore and password bytes it overwrote, as a pair, or removes the files where there was nothing, so a restored entry never points at a keystore encrypted under a password that is gone and nothing this request did not write is deleted. The record is written once at every start, whatever its state, so a filesystem that refuses to flush a directory is a startup error rather than a slot-by-slot refusal with the records advancing in memory. A delete whose removal could not be flushed sets a flag the keymanager keeps until a later save flushes the directory, and a retry that would otherwise answer not_found saves the file again first, so the removal is durable before anyone is told the key is gone. The attestation duty returns before touching the record when no held key has a duty, and the README and the startup error say that two idle epochs rule out a double vote, not a surround.
|
Fourth automated round, at 11f8814. Fixed in b53ac02 unless noted.
|
🤖 Grok Code ReviewThe core rule is sound: record-before-sign, fail-closed reads, directory
Minor: the sentences about The minimal watermark rule, slot 0 / epoch 0 handling, restart tests, and symlink/ Automated review by Grok (xAI) · grok-4.7 · custom prompt |
🤖 DeepSeek Code ReviewAutomated review by DeepSeek |
🤖 Kimi Code ReviewThe core implementation looks strong: watermarks follow EIP-3076’s minimal rule, reads fail closed, the directory lock survives record renames, and recording happens before signing. Restart, duplicate-duty, failed-write, and keymanager rollback coverage is particularly good.
Automated review by Kimi (Moonshot AI) · kimi-k3.1-pro · custom prompt |
… changed, except after a rename whose directory flush failed, where the slot or target is on disk and in memory and a retry is refused as already signed. Every Err still means do not sign; none means the old watermark is the one in force.
|
On Grok's points at b53ac02.
|
…tch the record's contract: nothing may be signed, and nothing was recorded except after a rename whose directory flush failed, where the target is held and a retry is refused.
|
On Kimi's points.
|
|
/review |
🤖 Codex Code ReviewNo actionable findings. The watermark rules, durable record-before-sign ordering, and handling of failed writes look sound. Tests could not run: the pinned Rust toolchain was unavailable, and the installed toolchain lacked the cached Automated review by OpenAI Codex · gpt-6.1-sol · custom prompt |
🤖 Claude Code ReviewReview: PR 734, durable EIP-3076 minimal slashing protectionThis is a solid, carefully reasoned PR. The ordering is right: check, then durable record, then sign. Reads fail closed, the lock sits on the directory inode, and the Bugs / correctness
Design / safety observations
Minor
TestsCoverage is good:
Missing: a regression test for Item 1, i.e. the same keystore twice in one POST with registration failing. Automated review by Claude · claude-opus-5-5 · custom prompt |
🤖 Gemini Code ReviewSolid implementation of durable EIP-3076 minimal-watermark slashing protection. The fail-closed design (strict file parsing, directory A couple of minor issues spotted:
Automated review by Gemini · gemini-4-argon · custom prompt |
…one request ends with what was there before the request rather than with what its first copy wrote; a test imports the same keystore twice with the registration failing. Put the record-wait constants above the doc comment they had split from its function, so blocking is documented again. One predicate, is_impossible_source, for the shape the attestation-data check, the record and the file reader all refuse. The refusal for a record held too long no longer asserts the write is stuck, since a busy keymanager can win every try as well. The file reader says it is strict for this client's own file and not for other clients' interchanges, and the CLI reference says a record that is a symlink is refused.
|
Sixth automated round, at 4a31fc4. Fixed in d2d495f unless noted.
|
🤖 Grok Code ReviewSlashing protection is carefully ordered (durable record, then sign) and fails closed on a bad file, a second process, and a source that would wedge the watermark. A few gaps are still worth fixing.
Automated review by Grok (xAI) · grok-4.7 · custom prompt |
🤖 DeepSeek Code ReviewThe PR is substantial and the overall design is sound, with good fail-closed behavior and extensive tests. A few issues worth addressing:
Automated review by DeepSeek · deepseek-v4-pro · custom prompt |
…g none of its keys: until history can be imported, a 200 with imported would tell the caller the history it handed over was taken, which is the standard way to move an active key and the surest way to double-sign. Put both of an import's files back when the second write, or the entry's save, fails, so a re-import that fails halfway does not leave the previous keystore replaced under the previous entry; tests cover both. Say in the attestation outcome that the slot's data is not a record of what was signed, in the blocking helper that a record held past its wait refuses the duty without a retry because the holder is a flush that a retry would wait on too, and in the spec deviations why the record carries no checksum and is rewritten in the minimal shape at every start.
|
On Grok's round at d2d495f. Fixed in b339ff7 unless noted.
|
🤖 Kimi Code Review
The core signing path otherwise looks sound: checks and durable recording happen before signatures exist, write failures fail closed, persisted records survive restarts, and the block/attestation watermark rules are well covered by tests. Automated review by Kimi (Moonshot AI) · kimi-k3.1-pro · custom prompt |
MegaRedHand
left a comment
There was a problem hiding this comment.
Re-reviewed at b339ff7. Everything from the previous round is in. One small thing inline.
| KeyStatus::error(format!( | ||
| "keystore rewritten, but the slashing-protection record could not be \ | ||
| written: {err}; {entry}" | ||
| )) |
There was a problem hiding this comment.
Since b339ff7, undo_import also puts this key's keystore and password files back, so "keystore rewritten" no longer describes what happened when the rollback succeeds. Something like "not imported: its slashing-protection record could not be written: {err}; its keystore and definitions entry are back as they were" would match, keeping the current wording for the rolled_back.is_err() case.
|
|
||
| [dev-dependencies] | ||
| ethlambda-validator.workspace = true | ||
| ethlambda-validator = { workspace = true, features = ["test-utils"] } |
There was a problem hiding this comment.
This using ethlambda-validator just for test is strange
🗒️ Description / Motivation
ethlambda validatorkept no slashing-protection record. Its two in-memory guards (AttestationGuard,ProposalGuard) applied EIP-3076's minimal rule within one run, but a restart emptied them and nothing stopped a second process from signing with the same keys. The client warned about this on every start, anddocs/spec_deviations.mdlisted it as a known gap. That gap is the blocker for anyone running ethlambda with real stake.This PR makes slashing protection durable. It is the first of two PRs; the second adds EIP-3076 interchange import/export (CLI and keymanager API).
Design. Before choosing, we surveyed Lighthouse, Prysm, Teku, Nimbus, Lodestar and Grandine.
What Changed
crates/validator/src/slashing_protection/:mod.rs,SlashingProtection:target > last targetandsource >= last source; a block needsslot > last slot;None, so slot 0 and epoch 0 are signable exactly once;openand the batch APIs;file.rs, the on-disk record:<validators-dir>/slashing_protection.jsonis an EIP-3076 v5 minimal interchange naming the chain'sgenesis_validators_root;fsync,rename, directoryfsync;File::try_lockonslashing_protection.lock.attestation.rs: one slot's batch is checked and recorded in a single write, insidespawn_blockingand before the store's read lock is taken. Only then are the allowed attestations signed.proposal.rs: the cheap pre-check stays beforeproduce_block. The recording check moves before signing and persists.lib.rs):genesis(), which supplies the root it must match;--init-slashing-protectionis passed;POST /eth/v1/keystoresregisters each imported key before writing it to disk or loading it.attestation_guard.rsandproposal_guard.rs. Their rule and tests moved intoslashing_protection.ethlambda_validator_slashing_protection_write_secondsandethlambda_validator_slashing_protection_write_failures_total.docs/spec_deviations.md: the section is rewritten to describe the minimal-watermark deviation.docs/cli.mdandREADME.md: the new flag and the slashing-protection callout.docs/metrics.mdandCLAUDE.md: updated to match.Correctness / Behavior Guarantees
Ordering: check, then a durable record, then the signature. A signature that exists is always one the record already accounts for, whether the process then crashes, hits its duty deadline, or is rejected by the beacon node.
Fail closed: startup stops on any of these, never reading them as "nothing signed":
genesis_validators_root;At signing time, an unregistered key or a failed write refuses the message.
One process: a second validator client on the same
--validators-dirrefuses to start while the first holds the lock.Behavior change for operators: the first start after upgrading refuses with a message naming
--init-slashing-protection. That flag registers each key as never having signed. It is meant only for keys that have signed nothing anywhere for at least two epochs, and it never lowers an existing record; the migration window is left to doppelganger protection, in a later PR.Behavior change for deployments:
--validators-dirmust now be writable, for the record and its lock. A read-only mount of the keys (a Kubernetes Secret, a:rovolume) fails at startup.Known cost: a byte-identical re-sign after a crash is refused, as in Teku. Documented in
spec_deviations.md.Not in this PR: carrying history in or out. Keymanager
DELETEstill returns the empty, all-zero-root interchange, andPOSTstill ignoresslashing_protection. Both are PR 2.Tests Added / Run
slashing_protectionunit tests:open;open;initand registered with it;initnever lowers a record;MockBeaconNode):a_restart_does_not_forget_what_was_attested;a_restart_does_not_forget_what_was_proposed(no block is even requested);a_record_that_cannot_be_written_signs_nothing.an_imported_key_is_registered_for_slashing_protection, where the next start opens without--init-slashing-protection.make fmt: cleanmake lint: cleanmake test: 2878 passed, 0 failed (after the review follow-ups); the validator crate alone is at 344 after the bot follow-upsReview follow-ups (2808b51)
mainvc_extra_params: ["--init-slashing-protection"]on both ethlambda validator clients. The Assertoor check on this PR fails for the same chicken-and-egg reason: PR runs test the publishedunstableimage, which does not know the flag yet. The post-merge run onmaintests the merge commit's image, which doespath.exists()fails openNotFoundcounts as absent. A symlink, directory or unreadable path is refused instead of replacedslashing_protection::blockinghelper--validators-dirdocs/cli.mdgeteuid() == 0ProposalRefusedandis_retryablesecure_fs::write_private_durablyand is shared withvalidator_definitions.yml, which gains its missing fsyncs. No clone of the record per write, compact JSON, one write per keymanager importBot review follow-ups (696e2a8)
fsyncis notF_FULLFSYNC(Kimi 4)File::sync_allfirst,F_FULLFSYNCon macOS like the file's own flush, and falls back to a plainfsynconly where a filesystem refuses that for a directory. The original error is kept if both fail.tmppath (Kimi 5, 11)0600), but a leftover.tmpfrom a crashed write kept its old mode, sincemode()applies only at creation. The temporary is now removed first and created withcreate_new, so the record never inherits a wider mode and a symlink planted at the.tmppath is never written through. Two tests pin bothslashing_protection::blockingreturns the crate'sErrorinstead of aString. A poisoned lock, or a panic inside the operation, is the newSlashingProtectionUnusable, whose message says the client must be restarted; a failed write staysIo. Never retryableattestanswersOkwith zero published on a record failure (Codex)Attestedgains arefusedcount, so a slot where protection withheld every signature, or could not record them, is distinguishable from a slot with nothing to publish. The refusal tests assert itRelated Issues / PRs
eth-clients/slashing-protection-interchange-testsv5.3.0 vectors.✅ Verification Checklist
make fmt— cleanmake lint(clippy with-D warnings) — cleanmake test(test-consensusplustest-node, atrelease-fast) — all passing