Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ jobs:
# lag leanVM, and `--all-targets` leaves such targets out. Name them so
# they keep compiling until they run again.
- name: Clippy (skipped spectests)
run: cargo clippy --locked --workspace --test forkchoice_spectests --test signature_spectests --test stf_spectests --test ssz_spectests -- -D warnings
run: cargo clippy --locked --workspace --test forkchoice_spectests --test signature_spectests --test stf_spectests --test ssz_spectests --test interchange_vectors -- -D warnings

# Two features gate code that no step in `lint` reaches, so both would rot
# silently between merges.
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ target
# BLS and KZG test vectors, downloaded by `make cryptography-specs`
/cryptography-specs

# EIP-3076 interchange test vectors, downloaded by
# `make slashing-protection-interchange-tests`
/slashing-protection-interchange-tests

# Personal Claude Code overrides (not shared with team)
CLAUDE.local.md

Expand Down
34 changes: 31 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: help fmt lint bench update cooldown-check docker-build shadow-build shadow-docker-build run-devnet test test-consensus test-node test-beacon test-beacon-mainnet test-beacon-minimal consensus-spec-tests cryptography-specs docs docs-deps docs-serve
.PHONY: help fmt lint bench update cooldown-check docker-build shadow-build shadow-docker-build run-devnet test test-consensus test-node test-beacon test-beacon-mainnet test-beacon-minimal consensus-spec-tests cryptography-specs docs docs-deps docs-serve slashing-protection-interchange-tests

help: ## 📚 Show help for each of the Makefile recipes
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
Expand All @@ -10,7 +10,7 @@ lint: ## 🔍 Run clippy on all workspace crates
cargo clippy --locked --workspace --all-targets -- -D warnings
# The spectests are `test = false` (stale leanSpec fixtures), which
# `--all-targets` skips: lint them by name so they keep compiling
cargo clippy --locked --workspace --test forkchoice_spectests --test signature_spectests --test stf_spectests --test ssz_spectests -- -D warnings
cargo clippy --locked --workspace --test forkchoice_spectests --test signature_spectests --test stf_spectests --test ssz_spectests --test interchange_vectors -- -D warnings

# release-fast: release-grade opt-level to avoid stack overflows during
# signature verification/aggregation, without paying for LTO on every rebuild
Expand Down Expand Up @@ -38,8 +38,9 @@ test: test-consensus test-node ## 🧪 Run all tests
test-consensus: leanSpec/fixtures ## 🧪 Run the consensus half of the workspace suite
$(TEST) $(addprefix -p ,$(CONSENSUS_CRATES))

test-node: leanSpec/fixtures ## 🧪 Run the node half of the workspace suite
test-node: leanSpec/fixtures slashing-protection-interchange-tests ## 🧪 Run the node half of the workspace suite
$(TEST) --workspace $(addprefix --exclude ,$(CONSENSUS_CRATES))
$(TEST) -p ethlambda-validator --test interchange_vectors

# --lib as well as the spec target: the BLS and KZG modules keep their fixture
# vectors as unit tests, which are `ignore`d unless this feature is on.
Expand Down Expand Up @@ -211,6 +212,33 @@ $(CRYPTOGRAPHY_SPECS_STAMP):
unzip -q "$$tmpdir/tests.zip" -d cryptography-specs || exit 1
@touch $@

# EIP-3076 slashing-protection interchange test vectors, for the validator
# client's `slashing_protection` module (crates/validator/tests/
# interchange_vectors.rs). Pinned, and stamped by version like the fixture trees
# above, so a bump re-downloads rather than passing against the old cases. The
# release ships no asset for this tag, so the tag's source archive is fetched
# and checked against a pinned SHA-256 instead.
SLASHING_PROTECTION_TESTS_VERSION ?= v5.3.0
SLASHING_PROTECTION_TESTS_SHA256 ?= 516d551cfb3e50e4ac2f42db0992f4ceb573a7cb1616d727a725c8161485329f
SLASHING_PROTECTION_TESTS_URL ?= https://github.com/eth-clients/slashing-protection-interchange-tests/archive/refs/tags/$(SLASHING_PROTECTION_TESTS_VERSION).tar.gz
SLASHING_PROTECTION_TESTS_STAMP=slashing-protection-interchange-tests/.version-$(SLASHING_PROTECTION_TESTS_VERSION)

slashing-protection-interchange-tests: $(SLASHING_PROTECTION_TESTS_STAMP) ## ⬇️ Download the EIP-3076 interchange test vectors

$(SLASHING_PROTECTION_TESTS_STAMP):
@rm -rf slashing-protection-interchange-tests
@mkdir -p slashing-protection-interchange-tests
@tmpdir=$$(mktemp -d); trap 'rm -rf "$$tmpdir"' EXIT; \
echo "Downloading EIP-3076 interchange test vectors ($(SLASHING_PROTECTION_TESTS_VERSION))"; \
curl -L -f -o "$$tmpdir/tests.tar.gz" "$(SLASHING_PROTECTION_TESTS_URL)" || exit 1; \
actual=$$(sha256sum "$$tmpdir/tests.tar.gz" | awk '{print $$1}'); \
if [ "$$actual" != "$(SLASHING_PROTECTION_TESTS_SHA256)" ]; then \
echo "SHA256 mismatch: expected $(SLASHING_PROTECTION_TESTS_SHA256), got $$actual" >&2; \
exit 1; \
fi; \
tar -xzf "$$tmpdir/tests.tar.gz" -C slashing-protection-interchange-tests --strip-components=1 || exit 1
@touch $@

# lambdaclass fork of lean-quickstart: genesis keys come from `ethlambda keygen`, and the
# partner clients run their devnet-5 images. An existing lean-quickstart/ is never
# re-cloned, so delete it to pick up a new pin.
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ cargo build --release
```

[`docs/cli.md`](./docs/cli.md) is the full flag reference for every sub-command,
`benchmark` and `replay-era` included. The
`benchmark`, `replay-era` and `slashing-protection` included. The
[quickstart](#beacon-chain-follower) runs a release binary with the same arguments.

#### `ethlambda node` — the Lean consensus chain
Expand Down Expand Up @@ -348,8 +348,9 @@ the standard Beacon API, so it works against `ethlambda beacon` (run with the
> and only for keys that have signed nothing anywhere for at least two epochs;
> even then it rules out a double vote, not a surround of an older vote, which
> only the key's own history can.
> Importing history from another client is not supported yet, so never run
> these keys in another client at the same time. See
> A key moving from another client brings its history with
> `ethlambda slashing-protection import`, or in the same keymanager API request
> that imports the key, and never runs in two clients at once. See
> [Spec deviations](./docs/spec_deviations.md#the-validator-client-keeps-eip-3076s-minimal-watermarks-not-full-history).

```sh
Expand Down
38 changes: 37 additions & 1 deletion bin/ethlambda/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const EXPLICIT: &[&str] = &[
BENCHMARK,
REPLAY_ERA,
VALIDATOR,
SLASHING_PROTECTION,
KEYGEN,
"help",
"-h",
Expand All @@ -43,6 +44,7 @@ const BEACON: &str = "beacon";
const BENCHMARK: &str = "benchmark";
const REPLAY_ERA: &str = "replay-era";
const VALIDATOR: &str = "validator";
const SLASHING_PROTECTION: &str = "slashing-protection";
const KEYGEN: &str = "keygen";

#[derive(Debug, clap::Parser)]
Expand Down Expand Up @@ -84,6 +86,9 @@ pub(crate) enum Command {
ReplayEra(ReplayEraOptions),
/// Run the beacon-chain validator client against a beacon node's HTTP API.
Validator(crate::validator::ValidatorOptions),
/// Import or export the validator client's EIP-3076 slashing-protection
/// history, offline.
SlashingProtection(crate::slashing_protection::SlashingProtectionOptions),
/// Generate validator XMSS keys for a genesis.
Keygen(KeygenOptions),
}
Expand Down Expand Up @@ -415,7 +420,14 @@ mod tests {
/// `ethlambda node keygen ...`, which fails on a stray positional.
#[test]
fn sub_command_tokens_do_not_get_the_default_inserted() {
for token in [NODE, BEACON, BENCHMARK, VALIDATOR, KEYGEN] {
for token in [
NODE,
BEACON,
BENCHMARK,
VALIDATOR,
SLASHING_PROTECTION,
KEYGEN,
] {
let args = [OsString::from("ethlambda"), OsString::from(token)];
assert_eq!(
default_subcommand(&args),
Expand All @@ -425,6 +437,30 @@ mod tests {
}
}

#[test]
fn slashing_protection_parses_as_its_own_sub_command() {
let args = [
"ethlambda",
SLASHING_PROTECTION,
"export",
"--validators-dir",
"/tmp/validators",
"--pubkeys",
"0xaa,0xbb",
"out.json",
];
match try_parse_from(args.iter().map(OsString::from)).expect("parses") {
Command::SlashingProtection(options) => match options.action {
crate::slashing_protection::Action::Export { pubkeys, file, .. } => {
assert_eq!(pubkeys, vec!["0xaa", "0xbb"]);
assert_eq!(file, PathBuf::from("out.json"));
}
other => panic!("expected an export, got {other:?}"),
},
other => panic!("expected a slashing-protection invocation, got {other:?}"),
}
}

#[test]
fn keygen_parses_as_its_own_sub_command() {
let args = [
Expand Down
6 changes: 6 additions & 0 deletions bin/ethlambda/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ mod era;
mod fd_limit;
mod keygen;
mod network;
mod slashing_protection;
mod validator;
mod version;

Expand Down Expand Up @@ -109,6 +110,11 @@ fn main() -> eyre::Result<()> {
init_node_logging()?;
validator::run(options)
}
// Offline file work: no runtime, and stdout left alone.
Command::SlashingProtection(options) => {
init_info_stderr_logging()?;
slashing_protection::run(options)
}
// Key generation is synchronous, CPU-bound work whose product is files,
// so it runs on this thread too and leaves stdout alone.
Command::Keygen(options) => {
Expand Down
137 changes: 137 additions & 0 deletions bin/ethlambda/src/slashing_protection.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
//! `ethlambda slashing-protection`: move a validator client's EIP-3076 history
//! in and out of its `--validators-dir`.
//!
//! Offline on purpose. It takes the same lock the validator client holds while
//! it runs, so it refuses while one is running there: history imported under a
//! running client could arrive after the client had already signed past it.
//! No beacon node is needed either: the record names its own chain, and an
//! import into a directory with no record yet creates one for the chain the
//! interchange names, which the client checks against its beacon node at its
//! next start.

use std::io::Write as _;
use std::path::PathBuf;

use ethlambda_validator::beacon_node::dto::{encode_hex, parse_pubkey};
use ethlambda_validator::slashing_protection::SlashingProtection;
use tracing::info;

#[derive(Debug, clap::Args)]
pub(crate) struct SlashingProtectionOptions {
#[command(subcommand)]
pub(crate) action: Action,
}

#[derive(Debug, clap::Subcommand)]
pub(crate) enum Action {
/// Merge an EIP-3076 interchange file (complete or minimal, version 5) into
/// the record, never lowering what is already there.
Import {
/// The validator client's `--validators-dir`.
#[arg(long)]
validators_dir: PathBuf,
/// The interchange file, as exported by this or another client.
file: PathBuf,
},
/// Write the record as a minimal EIP-3076 interchange file (version 5).
Export {
/// The validator client's `--validators-dir`.
#[arg(long)]
validators_dir: PathBuf,
/// Only these validators, `0x`-prefixed, comma-separated or repeated.
/// Every validator with a record when omitted.
#[arg(long, value_delimiter = ',')]
pubkeys: Vec<String>,
/// Where to write it. Refused if it exists, rather than overwritten.
file: PathBuf,
},
}

pub(crate) fn run(options: SlashingProtectionOptions) -> eyre::Result<()> {
match options.action {
Action::Import {
validators_dir,
file,
} => {
let interchange = std::fs::read_to_string(&file)
.map_err(|err| eyre::eyre!("{}: {err}", file.display()))?;
// Parsed once: the file can be tens of megabytes in the complete
// format, and its chain is needed before the record is opened.
let staged = SlashingProtection::stage(&interchange)?;
let mut protection =
SlashingProtection::open_offline(&validators_dir, Some(staged.root()))?;
let validators = protection.absorb(Some(&staged), &[])?.named;
info!(
validators,
file = %file.display(),
"Imported slashing-protection history"
);
}
Action::Export {
validators_dir,
pubkeys,
file,
} => {
let protection = SlashingProtection::open_offline(&validators_dir, None)?;
let pubkeys = if pubkeys.is_empty() {
protection.pubkeys()
} else {
pubkeys
.iter()
.map(|text| {
parse_pubkey(text).map_err(|err| eyre::eyre!("--pubkeys {text}: {err}"))
})
.collect::<Result<Vec<_>, _>>()?
};
// A requested key with no record fails the export, as Lighthouse
// does, rather than being left out of a file that then reads as
// complete: a typo, or the wrong directory, would otherwise be
// found out at the other end, after the keys moved.
let missing: Vec<String> = pubkeys
.iter()
.filter(|pubkey| !protection.contains(pubkey))
.map(|pubkey| encode_hex(&pubkey.0))
.collect();
if !missing.is_empty() {
eyre::bail!(
"no slashing-protection record for {} requested validator(s) (first: {}); \
nothing was written",
missing.len(),
missing[0]
);
}
let exported = pubkeys.len();
let interchange = protection.export(&pubkeys)?;
write_export(&file, &interchange)
.map_err(|err| eyre::eyre!("{}: {err}", file.display()))?;
info!(
validators = exported,
file = %file.display(),
"Exported slashing-protection history"
);
}
}
Ok(())
}

/// Write `interchange` to a new file at `path`, refusing an existing one.
///
/// Private to the owner, like the record it was taken from. A write that
/// fails partway removes what it left, so the retry is not refused for a file
/// this run created and never finished.
fn write_export(path: &std::path::Path, interchange: &str) -> std::io::Result<()> {
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let mut output = options.open(path)?;
output
.write_all(interchange.as_bytes())
.and_then(|()| output.sync_all())
.inspect_err(|_| {
let _ = std::fs::remove_file(path);
})
}
8 changes: 8 additions & 0 deletions crates/validator/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,11 @@ tempfile = "3"
[features]
# Exposes `SlashingProtection::in_memory` to other crates' tests.
test-utils = []

# Skipped by a plain `cargo test`: it reads the EIP-3076 interchange vectors,
# which `make slashing-protection-interchange-tests` downloads, and fails
# without them. `make test-node` runs it by name.
[[test]]
name = "interchange_vectors"
path = "tests/interchange_vectors.rs"
test = false
Loading
Loading