Skip to content
Draft
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
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -879,8 +879,10 @@ transitions are in `ethlambda-types`, per the section above. Nothing above
aggregate when `verified_sync_aggregate` fails (see `docs/spec_deviations.md`).
`SlotClock` picks the
offsets by each slot's own fork, and `/eth/v1/config/spec` supplies them. It
still keeps no slashing-protection record; PTC votes have an in-memory
`(validator, slot)` dedup only, and envelopes are unguarded.
keeps EIP-3076 minimal-watermark slashing protection in
`--validators-dir/slashing_protection.json` (`crate::slashing_protection`),
durable before every block and attestation signature; PTC votes have an
in-memory `(validator, slot)` dedup only, and envelopes are unguarded.
`get_head_node` is one bottom-up walk for every fork, with
the current slot's fork selecting the payload rules and the boost gate, so a
follower on any network that schedules gloas (Sepolia does) pays work
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ If you discover a security vulnerability, **do not open a public issue**. Instea

## Communication

- **Telegram**: [ethlambda group](https://t.me/lambdaconsensus), where we post daily updates; drop by to ask questions or chat about anything Lean-related.
- **Telegram**: [ethlambda group](https://t.me/ethlambda), where we post daily updates; drop by to ask questions or chat about anything Lean-related.
- **X (Twitter)**: [@ethlambda_lean](https://twitter.com/ethlambda_lean) for occasional updates.
- **Weekly community call**: every Friday, streamed live on [@class_lambda](https://x.com/class_lambda); the call link is posted on Telegram beforehand.
- **GitHub Issues**: bugs, feature requests, and technical discussion.
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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
22 changes: 13 additions & 9 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 @@ -341,13 +341,17 @@ payload envelope and the payload timeliness vote. It talks to a beacon node over
the standard Beacon API, so it works against `ethlambda beacon` (run with the
`--execution-*` flags) or any other conformant beacon node.

> **Warning:** the validator client keeps no slashing-protection record yet.
> Slashing protection is a work in progress and will be added soon. Until then,
> do not run the same keys in another client while this one runs, and treat a
> restart with the same care as a manual key move. Read
> [Spec deviations](./docs/spec_deviations.md#the-validator-client-keeps-no-slashing-protection-record)
> before using keys that hold real stake. The client repeats this warning at
> startup.
> **Slashing protection:** every block and attestation is checked against a
> durable per-validator record in `--validators-dir` before it is signed, and a
> second validator client pointed at the same directory refuses to start. A key
> with no record is refused at startup: pass `--init-slashing-protection` once,
> 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.
> 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
./target/release/ethlambda validator \
Expand Down Expand Up @@ -496,7 +500,7 @@ We have a proof-of-concept formalization of a part of the state transition funct

## Community

- **Telegram**: [ethlambda group](https://t.me/lambdaconsensus), where we post daily updates; drop by to ask questions or chat about anything Lean-related.
- **Telegram**: [ethlambda group](https://t.me/ethlambda), where we post daily updates; drop by to ask questions or chat about anything Lean-related.
- **X (Twitter)**: [@ethlambda_lean](https://twitter.com/ethlambda_lean) for occasional updates.
- **Weekly community call**: every Friday, streamed live on [@class_lambda](https://x.com/class_lambda); the call link is posted on Telegram beforehand.
- **Ecosystem coordination**: the [PQ Interop calls](https://github.com/ethereum/pm/issues?q=is%3Aissue+%22PQ+Interop%22+in%3Atitle) on `ethereum/pm` cover cross-client Lean Ethereum work and related updates; the meeting links are posted on each issue.
Expand Down
8 changes: 8 additions & 0 deletions bin/ethlambda/src/benchmark/import/replay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,14 @@ pub(crate) async fn replay_corpus(dir: &Path, options: &ReplayOptions) -> eyre::
// The same refusal startup makes, so a gloas corpus fails with its real
// reason rather than partway through replay.
crate::refuse_unfollowable_fork(anchor_state.fork_name())?;
// Mirrors what node startup does on its head state, so the first imports
// do not pay for validating the registry's keys.
let warm_started = Instant::now();
let warmed = ethlambda_blockchain::warm_registry_pubkeys(&anchor_state);
eprintln!(
"registry warm-up: {warmed} keys validated in {:?}",
warm_started.elapsed()
);
let store = fork_choice::get_forkchoice_store(backend, anchor_state, anchor_block, &config)?;
// A clone, not a borrow: `Store::set_time_ms` writes to the shared
// backend's metadata, so this clone's write is the write every other
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);
})
}
Loading
Loading