Skip to content
Open
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
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.

1 change: 1 addition & 0 deletions crates/cluster/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pluto-k1util.workspace = true
pluto-ssz.workspace = true
k256.workspace = true
tokio.workspace = true
tracing.workspace = true
reqwest = { workspace = true, features = ["json"] }
# Workaround to use test code from different crate.
# See: https://github.com/NethermindEth/pluto/pull/285
Expand Down
2 changes: 2 additions & 0 deletions crates/cluster/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ pub mod distvalidator;
pub mod eip712sigs;
/// General helper utilities.
pub mod helpers;
/// Loading and verification of a cluster [`Lock`](lock::Lock) from disk.
pub mod load;
/// `Lock` type representing the finalized cluster configuration, including
/// distributed validators and node signatures.
pub mod lock;
Expand Down
265 changes: 265 additions & 0 deletions crates/cluster/src/load.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
//! Loading and verification of a cluster [`Lock`] from disk.
//!
//! Mirrors Charon's `cluster.LoadClusterLock` (`cluster/load.go`), which
//! replaced the manifest-DAG loading path when the cluster manifest was
//! removed (Charon #4130). Charon v1.7.1 obtained the cluster by materialising
//! a manifest DAG; this function reads and verifies the cluster lock file
//! directly.

use std::path::Path;

use pluto_eth1wrap::{EthClient, EthClientError};
use tracing::warn;

use crate::lock::{Lock, LockError};

/// Errors returned by [`load_cluster_lock`].
#[derive(Debug, thiserror::Error)]
pub enum LoadError {
/// The cluster-lock file could not be read from disk.
#[error("read cluster-lock.json {path}: {source}")]
Read {
/// Path that failed to read.
path: String,
/// Underlying I/O error.
#[source]
source: std::io::Error,
},

/// The cluster-lock file could not be JSON-decoded.
#[error("unmarshal cluster-lock.json: {0}")]
Parse(#[source] serde_json::Error),

/// Cluster-lock hash verification failed. Re-run with `no_verify` to bypass
/// this check at your own risk.
#[error(
"verify cluster lock hashes (run with no_verify to bypass verification at your own risk): {0}"
)]
VerifyHashes(#[source] LockError),

/// Cluster-lock signature verification failed. Re-run with `no_verify` to
/// bypass this check at your own risk.
#[error(
"verify cluster lock signatures (run with no_verify to bypass verification at your own risk): {0}"
)]
VerifySignatures(#[source] LockError),

/// The execution-layer client could not be constructed.
#[error("build execution-layer client: {0}")]
Eth1(#[source] EthClientError),
}

/// Reads the cluster lock file at `lock_file_path`, JSON-decodes it into a
/// [`Lock`], and verifies its hashes and signatures.
///
/// When `no_verify` is set, verification failures are logged as warnings
/// instead of being returned as errors (mirrors Charon's `--no-verify`): both
/// [`Lock::verify_hashes`] and [`Lock::verify_signatures`] still run.
///
/// `eth1` backs EIP-1271 smart-contract operator-signature verification. Pass a
/// no-op client (from `EthClient::new("")`) to skip only the contract-based
/// checks; BLS-aggregate and node signatures are still verified.
///
/// Mirrors Charon's `cluster.LoadClusterLock`.
pub async fn load_cluster_lock(
lock_file_path: impl AsRef<Path>,
no_verify: bool,
eth1: &EthClient,
) -> Result<Lock, LoadError> {
let path = lock_file_path.as_ref();

let contents = tokio::fs::read_to_string(path)
.await
.map_err(|source| LoadError::Read {
path: path.display().to_string(),
source,
})?;

let lock: Lock = serde_json::from_str(&contents).map_err(LoadError::Parse)?;

match lock.verify_hashes() {
Ok(()) => {}
Err(err) if no_verify => {
warn!(%err, "Ignoring failed cluster lock hashes verification due to no_verify flag");
}
Err(err) => return Err(LoadError::VerifyHashes(err)),
}

match lock.verify_signatures(eth1).await {
Ok(()) => {}
Err(err) if no_verify => {
warn!(%err, "Ignoring failed cluster lock signatures verification due to no_verify flag");
}
Err(err) => return Err(LoadError::VerifySignatures(err)),
}

Ok(lock)
}

/// Reads and verifies the cluster lock at `lock_file_path` with a default no-op
/// execution-layer client (no configured endpoint) and verification enabled.
///
/// Convenient for standalone tools that only need a verified lock and have no
/// execution-layer endpoint to inject. EIP-1271 smart-contract operator
/// signatures are skipped; BLS-aggregate and node signatures are still
/// verified.
///
/// Mirrors Charon's `cluster.LoadClusterLockAndVerify`.
pub async fn load_cluster_lock_and_verify(
lock_file_path: impl AsRef<Path>,
) -> Result<Lock, LoadError> {
let eth1 = EthClient::new("").await.map_err(LoadError::Eth1)?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

new("") is a no-op eth client, so it can not verify EIP-1271 smart-contract operator

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This is intentional, it follows what Charon does:

https://github.com/ObolNetwork/charon/pull/4130/changes#diff-518182b12c285fd619114196f2e858632a192d492f042c8222504db5c34907d4R17-R18

In practice, this does not matter at the moment since this function is only used by commands that are out of scope in our roadmap.


load_cluster_lock(lock_file_path, false, &eth1).await
}

#[cfg(test)]
mod tests {
use std::io::Write;

use tempfile::NamedTempFile;

use super::*;

const LOCK_V1_10_0: &str = include_str!("testdata/cluster_lock_v1_10_0.json");

/// A no-op execution-layer client: BLS-aggregate and node signatures are
/// still verified, only EIP-1271 contract-based operator signatures are
/// skipped.
async fn noop_eth1() -> EthClient {
EthClient::new("").await.expect("noop eth1 client")
}

/// Writes `contents` to a temporary file that `load_cluster_lock` can read
/// by path.
fn write_lock(contents: &str) -> NamedTempFile {
let mut file = NamedTempFile::new().expect("create temp lock file");
file.write_all(contents.as_bytes())
.expect("write temp lock file");
file.flush().expect("flush temp lock file");
file
}

/// Ports Charon's `TestLoadClusterLock`: the lock is read and parsed and
/// its fields are populated (verification skipped via `no_verify`).
#[tokio::test]
async fn load_cluster_lock_reads_and_parses() {
let file = write_lock(LOCK_V1_10_0);
let eth1 = noop_eth1().await;

let lock = load_cluster_lock(file.path(), true, &eth1)
.await
.expect("load lock");

assert_eq!(lock.definition.name, "test definition");
assert_eq!(lock.definition.version, "v1.10.0");
assert_eq!(lock.threshold, 3);
assert_eq!(lock.distributed_validators.len(), 2);
assert_eq!(lock.node_signatures.len(), 2);
assert_eq!(
lock.lock_hash,
hex::decode("015036f659bd05894dfb531bf0ab3fdb32a05584ec037fc8262843d14e1aae60")
.unwrap()
);
// Verify first operator
assert_eq!(
lock.operators[0].address.to_uppercase(),
"0x094279db1944ebd7a19d0f7bbacbe0255aa5b7d4".to_uppercase()
);
assert_eq!(
lock.operators[0].enr.to_uppercase(),
"enr://b0223beea5f4f74391f445d15afd4294040374f6924b98cbf8713f8d962d7c8d".to_uppercase()
);
// Verify first distributed validator
assert_eq!(
lock.distributed_validators[0].public_key_hex().unwrap().to_uppercase(),
"0x1814be823350eab13935f31d84484517e924aef78ae151c00755925836b7075885650c30ec29a3703934bf50a28da102".to_uppercase()
);
assert_eq!(lock.distributed_validators[0].pub_shares.len(), 2);
}

/// With verification enabled, a corrupted lock hash is rejected.
#[tokio::test]
async fn load_cluster_lock_rejects_tampered_hash() {
let mut lock: Lock = serde_json::from_str(LOCK_V1_10_0).unwrap();
lock.lock_hash[0] ^= 0xff;
let file = write_lock(&serde_json::to_string(&lock).unwrap());
let eth1 = noop_eth1().await;

let err = load_cluster_lock(file.path(), false, &eth1)
.await
.expect_err("tampered hash must fail verification");

assert!(matches!(err, LoadError::VerifyHashes(_)), "got {err:?}");
}

/// With `no_verify`, the same corrupted lock is loaded regardless (the
/// verification failure is downgraded to a warning).
#[tokio::test]
async fn load_cluster_lock_no_verify_ignores_tampered_hash() {
let mut lock: Lock = serde_json::from_str(LOCK_V1_10_0).unwrap();
lock.lock_hash[0] ^= 0xff;
let file = write_lock(&serde_json::to_string(&lock).unwrap());
let eth1 = noop_eth1().await;

let loaded = load_cluster_lock(file.path(), true, &eth1)
.await
.expect("no_verify should ignore verification failures");

assert_eq!(loaded.definition.version, "v1.10.0");
}

/// A missing file surfaces a read error rather than a parse/verify error.
#[tokio::test]
async fn load_cluster_lock_missing_file() {
let eth1 = noop_eth1().await;

let err = load_cluster_lock("/nonexistent/cluster-lock.json", false, &eth1)
.await
.expect_err("missing file must fail");

assert!(matches!(err, LoadError::Read { .. }), "got {err:?}");
}

/// Malformed JSON surfaces a parse error.
#[tokio::test]
async fn load_cluster_lock_malformed_json() {
let file = write_lock("{ not valid json");
let eth1 = noop_eth1().await;

let err = load_cluster_lock(file.path(), false, &eth1)
.await
.expect_err("malformed json must fail");

assert!(matches!(err, LoadError::Parse(_)), "got {err:?}");
}

/// A freshly generated, self-consistent lock verifies end-to-end with
/// `no_verify=false` against a no-op execution-layer client.
#[tokio::test]
async fn load_cluster_lock_verifies_generated_lock() {
let (lock, ..) = crate::test_cluster::new_for_test(1, 2, 3, 1);
let file = write_lock(&serde_json::to_string(&lock).expect("serialize generated lock"));
let eth1 = noop_eth1().await;

let loaded = load_cluster_lock(file.path(), false, &eth1)
.await
.expect("generated lock should verify");

assert_eq!(loaded.lock_hash, lock.lock_hash);
}

/// The convenience wrapper reads and verifies a self-consistent lock using
/// its built-in no-op execution-layer client.
#[tokio::test]
async fn load_cluster_lock_and_verify_generated_lock() {
let (lock, ..) = crate::test_cluster::new_for_test(1, 2, 3, 1);
let file = write_lock(&serde_json::to_string(&lock).expect("serialize generated lock"));

let loaded = load_cluster_lock_and_verify(file.path())
.await
.expect("generated lock should verify");

assert_eq!(loaded.lock_hash, lock.lock_hash);
}
}
Loading