Repository navigation
feat(validator): EIP-3076 slashing-protection import and export - #736
pablodeymo wants to merge 12 commits into
Conversation
🤖 Kimi Code ReviewI'll review this PR implementing EIP-3076 slashing protection interchange import/export functionality. Let me analyze the code systematically. Overall AssessmentThis is a well-structured PR adding slashing protection interchange support. The design is sound with proper "history first, keys second" semantics, offline locking, and good test coverage. I found several issues ranging from minor to moderate severity. Security & Correctness Issues1. TOCTOU in export file creation (
|
| Item | Severity | File:Line | Issue |
|---|---|---|---|
| 1 | Moderate | slashing_protection.rs:88 |
World-readable export file |
| 2 | Moderate | slashing_protection.rs:92 |
Missing directory fsync |
| 3 | Low | keystores.rs:170,440 |
Lock poisoning not recovered |
| 4 | Incorrect analysis | — | not_active logic is actually correct |
| 5 | Informational | keystores.rs |
Empty history semantic gap |
| 6 | Low | file.rs:110 |
Vec allocation for large imports |
| 7 | Low | mod.rs:483 |
pubkeys() allocates |
| 8 | Nit | mod.rs:507 |
Extra string allocation |
| 9 | Nit | mod.rs:519 |
Implicit default |
| 10 | Nit | mod.rs:967 |
Raw JSON in test |
| 11 | Low | slashing_protection.rs:75 |
Empty string in pubkeys |
| 12 | Low | mod.rs:536 |
Silent exclusion in export |
| 13 | Low | slashing_protection.rs:80 |
No position in parse error |
| 14 | Nit | mod.rs:316 |
Comment clarity |
| 15 | Nit | README.md:340 |
Ambiguous phrasing |
| 16 | Informational | interchange_vectors.rs:160 |
Could verify absorption |
| 17 | Low | mod.rs (tests) |
Missing idempotency test |
| 18 | Low | interchange_vectors.rs |
Missing empty interchange test |
| 19 | Nit | slashing_protection.rs:9 |
Style |
| 20 | Nit | keystores.rs:185 |
Logging style |
Recommended Fixes (Priority Order)
- File permissions on export (Item 1): Add
0o600mode on Unix - Directory fsync (Item 2): Use
write_durablyor addsync_dir - Add idempotency test (Item 17): Verify double-import is safe
- Warn on missing pubkeys in export (Item 12): Don't silently skip
The core logic—import ordering, watermark merging, chain validation, and test vector compliance—is correct and well-tested.
Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt
🤖 Claude Code ReviewReview: EIP-3076 import/export for the validator client (PR 736)I read the diff and did not run it. I only saw the first ~1100 diff lines: the CLI, keymanager, Done well
Points to consider
Security/consensus Overall this looks good to merge. Points 2 and 3 are the ones I would fix first. Automated review by Claude (Anthropic) · sonnet · custom prompt |
🤖 Codex Code ReviewI found two review points; the rest looks solid.
If you want, I can turn Item 1 into a concrete fix sketch. Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
3393a66 to
60ed721
Compare
5c30913 to
b72bc33
Compare
b72bc33 to
87fb007
Compare
87fb007 to
721c300
Compare
| //! - `POST` judges the request's `slashing_protection` before touching | ||
| //! anything: one that does not parse, or is for another chain, fails every | ||
| //! key in the request and imports none of them. It is written once the keys |
There was a problem hiding this comment.
The keymanager router sets no DefaultBodyLimit, so POST /eth/v1/keystores gets axum 0.8's 2 MB default for Json. Now that the request carries slashing_protection, a complete-format interchange for a batch of validators can exceed that, and the whole import fails with 413.
For comparison: Nimbus allows 16 MiB and Lodestar 20 MB (both configurable), and Lighthouse and Prysm set no limit. Could we set DefaultBodyLimit::max(64 * 1024 * 1024) on the keymanager router, or at least on this route?
There was a problem hiding this comment.
Done in cd5e3df: a DefaultBodyLimit::max(64 MiB) layer on the keymanager router, with the other clients' numbers in the comment.
| } else { | ||
| crate::slashing_protection::blocking(&context.protection, move |protection| { | ||
| if let Some(history) = &history { | ||
| let validators = protection.import(history)?; |
There was a problem hiding this comment.
Could the history and the registration go in one write? Today import commits the history, then register commits again. If the second write fails, every persisted key reports "saved, but not loaded … refused at the next start until it has a record". But the keys the history covers already have a record, so they load and sign after a restart. Staging both and committing once makes that message true. Combined with the rollback I suggested on #734 (undo persist_import when registration fails), a failure then leaves nothing behind.
It also lets the interchange be parsed once. Today check_import (slashing_protection/mod.rs:587) parses it and repeats import's chain check, then import parses it again, both under the mutex. Parsing once outside the lock into staged records, then committing staged history plus new registrations under it, removes both.
Related: the module doc (line 12) says "a key that failed to import leaves none behind", but the whole interchange is imported, including entries for request keys that failed. Either leave those entries out of the staged write or adjust the doc.
There was a problem hiding this comment.
Done in cd5e3df. The interchange is parsed once, before any lock, into a StagedHistory, judged against the record's chain, and then merged and the keys registered in one durable write through a new SlashingProtection::absorb; import and register are now wrappers over it and check_import is gone. A failed write holds neither, so the "refused at the next start" message is true again, and combined with the rollback on #734 nothing is left behind. a_failed_write_holds_neither_history_nor_registration pins it.
On the module doc: I went with your second option. The history is written whole, entries for request keys that failed included, since real history is never the dangerous direction and a later import of such a key merges with it; the doc says so now. A request that saved no key still writes nothing, so history_whose_key_failed_to_import_is_not_written stands.
| // in the record, or can no longer happen. | ||
| let requested: Vec<Option<BlsPubkey>> = results.iter().map(|(pubkey, _)| *pubkey).collect(); | ||
| let exported = crate::slashing_protection::blocking(&context.protection, move |protection| { | ||
| let pubkeys: Vec<BlsPubkey> = requested.iter().flatten().copied().collect(); |
There was a problem hiding this comment.
The keymanager spec says "Slashing protection data must only be returned for keys from request.pubkeys for which a deleted or not_active status is returned" (apis/local_keystores.yaml). This exports every parsed pubkey, including ones whose delete_one returned error. For example, when persist_delete failed, the key is still in the definitions file and comes back on restart, yet its history is handed to the caller.
Teku and Prysm filter on the status. Exporting only the keys whose delete_one status is deleted or not_found would match them: not_found keys become not_active when they have a record, and export already skips keys without one.
There was a problem hiding this comment.
Done in cd5e3df: the export is built only from keys whose status is deleted or not_found (the latter becoming not_active when recorded), so a key whose delete failed keeps its history here, matching Teku and Prysm.
| let data = results | ||
| .into_iter() | ||
| .map(|(_, status)| match status.status.as_str() { | ||
| "deleted" => KeyStatus::error(&message), |
There was a problem hiding this comment.
When the export fails, recorded is never computed, so nothing here can tell a key with no record from one whose record could not be read. Mapping only deleted to error means a key that is gone but has a record answers not_found. That tells a migrator there is no history to carry, and the comment above says such a key "becomes an error".
Prysm marks every key error in this case. Doing the same here, for every parsed pubkey, would be the safe fallback.
There was a problem hiding this comment.
Done in cd5e3df: when the export fails, every key that parsed is an error, as Prysm answers; only a malformed pubkey keeps its own error.
| //! reported `not_active`, and its record is kept, so importing it back later | ||
| //! stays protected. |
There was a problem hiding this comment.
Nit: "stays protected" is a bit strong. The kept record covers what this client signed, not what the key signed elsewhere in between. That is the same caveat spec_deviations.md makes about doppelganger protection.
There was a problem hiding this comment.
Reworded in cd5e3df: importing it back later starts from what this client signed with it, with a pointer to the doppelganger caveat in spec_deviations.md.
| let mut exported = 0; | ||
| for pubkey in &pubkeys { | ||
| if protection.contains(pubkey) { | ||
| exported += 1; | ||
| } else { | ||
| warn!( | ||
| pubkey = %encode_hex(&pubkey.0), | ||
| "No slashing-protection record for this validator; it is not in the export" | ||
| ); | ||
| } |
There was a problem hiding this comment.
When a requested key has no record, this only warns and still exits 0, possibly writing an interchange with no entries. So export --pubkeys K out.json && import elsewhere succeeds with nothing to import, for example after a typo or the wrong --validators-dir.
Lighthouse fails the export instead when a --pubkeys key has no record (error, exit 1, no file). Could we do the same?
There was a problem hiding this comment.
Done in cd5e3df: a requested validator with no record fails the export (exit 1, no file, naming the count and the first key), as Lighthouse does. docs/cli.md says so.
| fn eip_3076_interchange_vectors() { | ||
| let dir = vectors_dir(); | ||
| assert!( | ||
| dir.is_dir(), |
There was a problem hiding this comment.
This hard-fails when the vectors have not been downloaded, so a plain cargo test --workspace (CLAUDE.md's "all workspace tests") now fails on a fresh checkout. The other fixture suites are test = false in their Cargo.toml and run explicitly (--test <name>), or sit behind required-features. Could this one follow the same pattern, with test-node running it explicitly?
There was a problem hiding this comment.
Done in cd5e3df: interchange_vectors is a [[test]] with test = false, and make test-node and make lint run it by name, like the other fixture suites. A plain cargo test --workspace no longer needs the download.
| text.parse().map_err(|err| format!("{text:?}: {err}")) | ||
| } | ||
|
|
||
| fn parse_root(text: &str) -> Result<Root, String> { |
There was a problem hiding this comment.
Nit: ethlambda_validator::beacon_node::dto::parse_root is public and does this. parse_root(text).map_err(|err| err.to_string()) would do, like parse_pubkey above.
| /// imported into a directory before the client first runs there; with | ||
| /// neither, this is an error. The file itself is only written by the | ||
| /// first change, so an import that fails, or imports nothing, leaves the | ||
| /// directory as it found it. A record created for the wrong chain is not |
There was a problem hiding this comment.
Nit: file::lock creates slashing_protection.lock before anything else, so a failed import does not quite leave the directory as it found it (docs/cli.md also says "a failed import creates nothing"), and export needs a writable directory. Keeping the lock is fine; it is only the wording of these docs.
There was a problem hiding this comment.
Docs fixed in cd5e3df: a failed import creates nothing but the lock file, and export takes the lock, so the directory must be writable. The lock stays.
721c300 to
cd5e3df
Compare
|
/review |
🤖 Codex Code Review
Tests could not run because rustup attempted to write to a read-only directory. Automated review by OpenAI Codex · gpt-6.1-sol · custom prompt |
🤖 Claude Code ReviewReview: EIP-3076 interchange import/export (PR 736)The design holds up. Merge-by-maximum is the right semantics for minimal watermarks. Re-checking the chain inside Correctness
Security
Build / tooling
Minor / idiomatic
TestsGood coverage overall: round-trip, idempotence, never-lower, wrong chain, failed persist, and failed record write. Two cases are missing:
Automated review by Claude · claude-opus-5-5 · custom prompt |
🤖 Gemini Code ReviewThe implementation is well-structured, with thorough test coverage (including the EIP-3076 v5.3.0 test vectors) and careful ordering of lock acquisition and durable writes in the keymanager API. A couple of suggestions below:
Automated review by Gemini · gemini-4-argon · custom prompt |
🤖 Grok Code ReviewThe import/export design is sound in the ways that matter most: chain mismatch fails closed, watermarks only move by maximum, the validator lock is held for offline import, and
Minor: Automated review by Grok (xAI) · grok-4.7 · custom prompt |
🤖 Kimi Code Review
The maximum-based merge, chain validation, and single-write history/registration approach otherwise look appropriately conservative for slashing protection. Automated review by Kimi (Moonshot AI) · kimi-k3.1-pro · custom prompt |
🤖 DeepSeek Code ReviewReviewed the slashing-protection import/export, keymanager API changes, and vector harness. The core design is fail-closed and the EIP-3076 test wiring is a good addition. A few issues worth addressing:
Automated review by DeepSeek · deepseek-v4-pro · custom prompt |
cd5e3df to
869c9fa
Compare
|
Addressed the bot round in Fixed
Not changed, on purpose
|
01a4da8 to
e8473a0
Compare
…slashing protection, so a staker can move keys in and out with their history. SlashingProtection gains import (version 5, complete or minimal, checked against the record's genesis_validators_root, each validator reduced to its highest slot, source and target and merged by maximum in one durable write, so slashable data is absorbed and nothing is lowered) and export (the record as a minimal interchange). A new offline ethlambda slashing-protection import|export command works against --validators-dir under the client's lock. The keymanager API now imports POST /eth/v1/keystores' slashing_protection before any key, refusing every key if it is invalid or for another chain, and DELETE returns the deleted keys' real records, answering not_active for a key that is gone but has one. The shared eth-clients/slashing-protection-interchange-tests v5.3.0 vectors are downloaded by a pinned, checksummed make target that test-node depends on, and all 38 pass under the minimal expectations.
…'s POST /eth/v1/keystores now only judges the request's slashing_protection up front (check_import: it parses and is for this record's chain), and writes it after the keys are saved, in the same critical section as their registration and under the definitions lock, so a key that fails to import leaves no history behind and a concurrent delete cannot interleave. When DELETE cannot export the history, only the keys it actually deleted become errors; not_found and malformed pubkeys keep their statuses. The export runs off the executor like every other access to the record. The interchange vectors and the delete test follow the rebase onto the seeded registration.
…ort command writes its file readable by its owner only and removes it if the write fails partway, so a retry is not refused for a file this run never finished; it warns for every requested validator that has no record and logs how many it actually wrote, and a malformed --pubkeys value is reported with the value. open_offline no longer writes an empty record at open: the first change writes it, so an import that fails, or imports nothing, leaves the directory as it found it. Two tests pin that an import is idempotent and that an empty interchange is refused. The docs say that a POST's history may name validators without a keystore in the request, and the README and CLI reference say the keymanager carries history in the same request as the key.
… in the CLI reference's chain-less sub-command paragraph and the README's pointer to it, and say on the keymanager's slashing_protection field that the history is written once the keys are saved and before any is loaded, which is what the module documentation and the handler already say.
…having signed: the keymanager import no longer dates its registrations, and the module doc, the import warning and the spec deviations say so instead of describing the dropped seeding at the current slot and epoch. The interchange vector test and the keymanager tests open the record without a slot, matching the base branch's signature.
… record_of: an entry whose source is after its target is absorbed here rather than refused, so the test reads the record directly instead of unwrapping a result.
… keymanager import merges the request's history and registers its keys in one durable write (SlashingProtection::absorb, over an interchange parsed once off every lock as a StagedHistory), so a failed write holds neither and a key the history covers can no longer load after a restart that the response said would refuse it; a request that saved no key still writes nothing. The keymanager router allows 64 MiB bodies, since a complete-format interchange for a batch of validators runs past axum's 2 MB default. DELETE hands history over only for keys that are deleted or not_active, as the specification requires and Teku and Prysm do, and when the export fails every key that parsed is an error, as Prysm answers, rather than a not_found that reads as nothing to carry. The offline export fails on a requested validator with no record instead of writing a file without it. The interchange vectors are a test = false target that make test-node and make lint run by name, so a plain cargo test needs no download; the vector test reuses the public parse_root. Docs: the kept record covers what this client signed, a failed import leaves the lock file, and export needs a writable directory.
… round. An export lists a key once however many times it is requested: a DELETE naming one key twice answered deleted then not_active and handed over an interchange naming it twice, which this client's own record reader refuses, and a repeated --pubkeys did the same. The CLI import parses its file once, staging it before the record is opened for the chain it names, so interchange_chain is gone and StagedHistory exposes its root. The keymanager import parses the interchange on the blocking pool, since a complete-format file runs to tens of megabytes and the handler's thread also drives the duty services. absorb warns when a registered key already has a record and brought no history, since that record is what this client last signed with the key and says nothing about what it signed elsewhere since. The DELETE comments say why the export may run after the keys leave the store (the duty services record before taking the store's read lock) and that a failed export leaves the deletions in place with their records kept for a retry. The interchange_vectors target drops an unused import that failed make lint, and the workflow's by-name clippy step now names the target as the Makefile does, which is why CI had not caught it. Four tests: an import raises an existing watermark and the raise survives a restart, an export lists a key requested twice once, a DELETE listing a key twice exports it once, and history naming a key outside the request gives it a record that a later DELETE answers not_active with. The CLI reference no longer says a failed import leaves a lock file, since the lock is now on the directory.
…han in the same write as the keys' registration. A key deleted here earlier keeps its record, so with the keys saved first a crash before the history landed would load such a key at its next start on the watermarks it had before it left, with whatever it signed elsewhere since unknown: the migration case the history exists for, and the one the startup check cannot see because the key has a record. The history only ever raises watermarks, so writing it for keys that then fail to save leaves nothing dangerous behind; the keys are still registered after they are saved, in one write, with the existing rollback. The post-persist absorb takes the history along again so that a key it covers is not warned about as having brought none, and absorb now drops entries whose watermarks would not change before committing, so that second call writes nothing unless a key is new and re-importing the same history costs one write. check_staged is private, with no caller outside absorb. history_is_written_before_its_key_is_saved pins the ordering on a key with an older record whose save fails, and a_failed_history_write_saves_no_key replaces the one-write test, asserting that no keystore reaches disk when the history cannot be written. The module doc, the field doc and the CLI reference say the history is written first and why.
…n watermarks of a validator this client is running. The history may name validators outside the request, and one of them may be loaded here; its watermarks are raised like any other, since a history saying it signed elsewhere is exactly what the record must know, but the operator should learn that the key ran in two places. absorb now returns an Absorbed report (how many validators the history named, how many keys were newly registered, and which existing records it raised) in place of the pair, every caller reads the field it needs, and the import warns for each raised key the store holds. absorb_reports_the_records_it_raised pins that a raised record is reported while an unchanged one and a newly created one are not.
…terchange. open_offline takes the directory lock through SlashingProtection::lock rather than file::lock, like open does. Two comments settle what Error::Unflushed means on the interchange paths, where the base now accepts it at the keys' registration: the keymanager import's history write still refuses it, since the history is on disk and in memory but were it lost to a power loss a key saved on the strength of it would load at the next start on the record it had before, the case writing it first closes; and a delete whose removal reached the disk but not a flush answers error, so its history stays here like that of any other key not confirmed gone.
…et outside genesis, following the base branch's record_of into the import rather than absorbing it: such an entry is never valid, and read into the record it would refuse every honest attestation until the chain justified past that source, so the whole import fails and names the validator. The vector runner applies the README's rule that an importer may refuse a step holding slashable data and skip the rest of the case, as Lighthouse does, which is where the four source_greater_than_target cases now end; the two genesis cases still import (0, 0). an_import_with_a_source_past_its_target_is_refused_whole pins both sides. The import doc, the CLI reference and the spec deviations state the one exception to absorbing slashable data, and the record fixture test unwraps the now fallible record_of.
e8473a0 to
135c9e2
Compare
MegaRedHand
left a comment
There was a problem hiding this comment.
Re-reviewed at 135c9e2. Everything from the previous round is addressed. Writing the history before any key is saved is a better fix than the single write I suggested: a deleted key keeps its old record, so saving the keys first and crashing before the history landed would restart that key on its old record.
Three comments inline, plus one on a test outside this diff:
an_import_carrying_history_is_refused_whole(keystores.rs, from #734) still passes here, but only because"{}"is not a valid interchange. Its name and doc describe #734's behaviour of refusing any history. Renaming it to something likean_import_whose_history_does_not_parse_is_refused_whole, with the doc to match, would keep it meaningful.
| // The README lets an importer refuse an interchange holding | ||
| // slashable data, in which case the rest of the case is skipped. | ||
| // This one absorbs all of it but an attestation whose source is at | ||
| // or past its target, so a refusal on such a step ends the case, | ||
| // as it does in Lighthouse; on any other step it is a failure. | ||
| (false, true) if step.contains_slashable_data => return Ok(()), |
There was a problem hiding this comment.
This skips the rest of the case whenever a step with contains_slashable_data is refused. The README allows that, but it is wider than what this importer refuses. At v5.3.0, 21 steps qualify, and only 4 of them (the single_validator_source_greater_than_target* cases) hold an attestation whose source is at or past its target. If the importer ever started refusing one of the other 17, for example a duplicate-pubkey or surround step, the suite would still pass, with up to 49 block and attestation checks skipped.
Could the skip apply only when the step's interchange holds such an attestation (or when the error is the "at or after its target" refusal), so that every other slashable step must import?
| /// A pubkey listed more than once is returned more than once: an import merges | ||
| /// them, while the record this client writes must never contain one twice. | ||
| /// The error is a reason, for the caller to attach to whatever it was reading. | ||
| pub(crate) fn parse(text: &str) -> std::result::Result<(Root, Vec<(BlsPubkey, Record)>), String> { |
There was a problem hiding this comment.
This parses other clients' files with Interchange, whose doc (lines 45-48) says it is for this client's own file only and that an importer needs its own, lenient, types. The strictness itself seems fine: Lighthouse's EIP-3076 types are deny_unknown_fields too. I'd keep the behaviour and update that doc to say the types are shared by the record reader and the importer.
| | Sub-command | Does | | ||
| |---|---| | ||
| | `import <file>` | Merges a version 5 interchange, complete or minimal, into the record. Each validator is reduced to its highest slot, source and target and merged by maximum, so nothing already recorded is lowered and slashable data in the file is absorbed rather than refused, except an attestation whose source is at or past its target outside genesis, which is refused. All or nothing. With no record yet, the import creates one for the interchange's chain, and a failed import creates nothing; the client refuses the record at its next start if that is not its beacon node's chain | | ||
| | `export [--pubkeys ...] <file>` | Writes the record, or the named validators' part of it, as a minimal version 5 interchange, readable by its owner only. A named validator with no record fails the export, so a typo or the wrong directory cannot produce a file that reads as complete. Takes the record's lock, so the directory must be writable. Refuses to overwrite an existing file | |
There was a problem hiding this comment.
Stacked on #734, rebased onto its review follow-ups.
🗒️ Description / Motivation
#734 made slashing protection durable, but history could not move in or out. A staker migrating keys to or from ethlambda had no safe path: a key arriving from another client started as "never signed", and a key leaving got only an empty interchange from
DELETE /eth/v1/keystores. EIP-3076's interchange format exists for exactly this, and every other client supports it.This PR adds EIP-3076 version 5 import and export, offline through a new
ethlambda slashing-protectioncommand and online through the keymanager API. It also runs the sharedeth-clients/slashing-protection-interchange-testsvectors.What Changed
slashing_protection(validator crate):import: parses a complete or minimal interchange and checks the version andgenesis_validators_root. It then reduces each validator to its highest slot, source and target, and merges the result with the existing record by maximum, in one durable write.export: writes the record, or selected keys, as a minimal interchange. Keys with no record are omitted.open_offline: opens the record without a beacon node, creating one for a given chain if there is none.interchange_chain,contains,pubkeys,genesis_validators_root.file.rs: parsing is split from reading. An import must accept a pubkey listed twice, and an attestation whose source is after its target; the vectors require both. The client's own record still refuses a duplicate pubkey.ethlambda slashing-protection import <file>/export [--pubkeys ...] <file>(bin/ethlambda/src/slashing_protection.rs):--validators-dir;POST /eth/v1/keystoresjudgesslashing_protectionbefore touching anything: if it does not parse or is for another chain, every key in the request fails and none is loaded. It is written before any key is saved, so a crash between saving a key and registering it cannot load a returning key on the record it had before it left; the history only ever raises watermarks, so a key that then fails to save leaves nothing dangerous behind. The keys are registered after they are saved, under the definitions lock, so a concurrentDELETEcannot interleave.DELETE /eth/v1/keystoresreturns the deleted keys' real records, replacing the deliberately empty, all-zero-root interchange.not_active.make slashing-protection-interchange-testsdownloads v5.3.0. It is pinned, its SHA-256 is checked, and the download is stamped by version.make test-nodedepends on it.crates/validator/tests/interchange_vectors.rsruns all 38 cases against theshould_succeed(minimal) expectations, as Teku, Nimbus and Lodestar do.docs/cli.mdgets a newslashing-protectionsection and the keymanager notes;docs/spec_deviations.md,README.mdand the crate docs are updated.Correctness / Behavior Guarantees
POST, the history is judged and written before any key is saved, so no key ever signs, or loads after a crash, ahead of its history. OnDELETE, the keys leave the store before the export. If the export fails, only the keys actually deleted become errors;not_foundand malformed pubkeys keep their statuses.DELETE /eth/v1/keystoresnow returns real history with the realgenesis_validators_root, where it used to return an empty one with an all-zero root.Tests Added / Run
<=to<), 10 of the 38 fail.open_offlineneeds a record or a chain.not_active.slashing-protection export. Smoke-tested by hand: import, export, a refused overwrite, and a missing record.make fmt: cleanmake lint: cleanmake test: 2888 passed, 0 failed (after the review follow-ups)Related Issues / PRs
✅ Verification Checklist
make fmt— cleanmake lint(clippy with-D warnings) — cleanmake test(test-consensusplustest-node, atrelease-fast) — all passing