diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2df54e..13d385f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,19 +47,12 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Checkout leanSpec - uses: actions/checkout@v4 - with: - repository: leanEthereum/leanSpec - ref: bf0f606a75095cf1853529bc770516b1464d9716 - path: ./leanSpec - - - name: Install uv - uses: astral-sh/setup-uv@v5 - - - name: Generate test fixtures - working-directory: ./leanSpec - run: uv run fill --clean --fork=devnet + - name: Download test fixtures + env: + GH_TOKEN: ${{ github.token }} + run: | + mkdir -p leanSpec/fixtures + gh run download --repo leanEthereum/leanSpec --name fixtures-prod-scheme --dir leanSpec/fixtures - name: Setup Rust uses: dtolnay/rust-toolchain@master diff --git a/Cargo.lock b/Cargo.lock index 972696c..ccfeb54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1821,18 +1821,23 @@ name = "ethlambda-blockchain" version = "0.1.0" dependencies = [ "datatest-stable 0.3.3", + "ethereum_ssz", + "ethereum_ssz_derive", "ethlambda-fork-choice", "ethlambda-state-transition", "ethlambda-storage", "ethlambda-types", "hex", + "leansig", "prometheus", "serde", "serde_json", "spawned-concurrency", + "ssz_types", "thiserror 2.0.17", "tokio", "tracing", + "tree_hash", ] [[package]] diff --git a/Makefile b/Makefile index 7734da0..0802def 100644 --- a/Makefile +++ b/Makefile @@ -15,6 +15,9 @@ leanSpec: git clone https://github.com/leanEthereum/leanSpec.git --single-branch cd leanSpec && git checkout $(LEAN_SPEC_COMMIT_HASH) +leanSpec/fixtures: leanSpec + cd leanSpec && uv run fill --fork devnet --scheme=prod -o fixtures + # lean-quickstart: # git clone https://github.com/blockblaz/lean-quickstart.git --depth 1 --single-branch diff --git a/bin/ethlambda/src/main.rs b/bin/ethlambda/src/main.rs index 1043547..f07b574 100644 --- a/bin/ethlambda/src/main.rs +++ b/bin/ethlambda/src/main.rs @@ -187,22 +187,22 @@ fn read_validator_keys( for validator in validator_vec { let validator_index = validator.index; - // Resolve the private key file path relative to the validators config directory - let privkey_path = if validator.privkey_file.is_absolute() { + // Resolve the secret key file path relative to the validators config directory + let secret_key_path = if validator.privkey_file.is_absolute() { validator.privkey_file.clone() } else { validator_keys_dir.join(&validator.privkey_file) }; - info!(node_id=%node_id, index=validator_index, privkey_file=?privkey_path, "Loading validator private key"); + info!(node_id=%node_id, index=validator_index, secret_key_file=?secret_key_path, "Loading validator secret key"); - // Read the hex-encoded private key file - let privkey_bytes = - std::fs::read(&privkey_path).expect("Failed to read validator secret key file"); + // Read the hex-encoded secret key file + let secret_key_bytes = + std::fs::read(&secret_key_path).expect("Failed to read validator secret key file"); - // Parse the private key - let secret_key = ValidatorSecretKey::from_bytes(&privkey_bytes).unwrap_or_else(|err| { - error!(node_id=%node_id, index=validator_index, privkey_file=?privkey_path, ?err, "Failed to parse validator secret key"); + // Parse the secret key + let secret_key = ValidatorSecretKey::from_bytes(&secret_key_bytes).unwrap_or_else(|err| { + error!(node_id=%node_id, index=validator_index, secret_key_file=?secret_key_path, ?err, "Failed to parse validator secret key"); std::process::exit(1); }); @@ -212,7 +212,7 @@ fn read_validator_keys( info!( node_id = %node_id, count = validator_keys.len(), - "Loaded validator private keys" + "Loaded validator secret keys" ); validator_keys diff --git a/crates/blockchain/Cargo.toml b/crates/blockchain/Cargo.toml index 85c6c46..589abf9 100644 --- a/crates/blockchain/Cargo.toml +++ b/crates/blockchain/Cargo.toml @@ -27,15 +27,26 @@ thiserror.workspace = true tracing.workspace = true prometheus.workspace = true +hex.workspace = true [dev-dependencies] serde = { workspace = true } serde_json = { workspace = true } hex = { workspace = true } datatest-stable = "0.3.3" +leansig.workspace = true +tree_hash = "0.12.0" +ethereum_ssz = "0.10.0" +ethereum_ssz_derive = "0.10.0" +ssz_types = "0.14.0" [[test]] name = "forkchoice_spectests" path = "tests/forkchoice_spectests.rs" harness = false required-features = ["skip-signature-verification"] + +[[test]] +name = "signature_spectests" +path = "tests/signature_spectests.rs" +harness = false diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 273cb0c..b4b6438 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -303,7 +303,8 @@ impl Store { #[cfg(not(feature = "skip-signature-verification"))] { use ethlambda_types::signature::ValidatorSignature; - let epoch = target.slot.try_into().expect("slot exceeds u32"); + // Use attestation.data.slot as epoch (matching what Zeam and ethlambda use for signing) + let epoch: u32 = attestation.data.slot.try_into().expect("slot exceeds u32"); let signature = ValidatorSignature::from_bytes(&signed_attestation.signature) .map_err(|_| StoreError::SignatureDecodingFailed)?; if !validator_pubkey.is_valid(epoch, &message, &signature) { @@ -949,9 +950,11 @@ fn verify_signatures( } let proposer_attestation = &signed_block.message.proposer_attestation; + let proposer_signature = ValidatorSignature::from_bytes(&signed_block.signature.proposer_signature) .map_err(|_| StoreError::ProposerSignatureDecodingFailed)?; + let proposer = validators .get(block.proposer_index as usize) .ok_or(StoreError::InvalidValidatorIndex)?; @@ -959,12 +962,14 @@ fn verify_signatures( let proposer_pubkey = proposer .get_pubkey() .map_err(|_| StoreError::PubkeyDecodingFailed(proposer.index))?; + let epoch = proposer_attestation .data .slot .try_into() .expect("slot exceeds u32"); let message = proposer_attestation.data.tree_hash_root(); + if !proposer_pubkey.is_valid(epoch, &message, &proposer_signature) { return Err(StoreError::ProposerSignatureVerificationFailed); } diff --git a/crates/blockchain/tests/signature_spectests.rs b/crates/blockchain/tests/signature_spectests.rs new file mode 100644 index 0000000..0318d35 --- /dev/null +++ b/crates/blockchain/tests/signature_spectests.rs @@ -0,0 +1,88 @@ +use std::path::Path; + +use ethlambda_blockchain::{SECONDS_PER_SLOT, store::Store}; +use ethlambda_types::{ + block::{Block, SignedBlockWithAttestation}, + primitives::TreeHash, + state::State, +}; + +mod signature_types; +use signature_types::VerifySignaturesTestVector; + +const SUPPORTED_FIXTURE_FORMAT: &str = "verify_signatures_test"; + +fn run(path: &Path) -> datatest_stable::Result<()> { + let tests = VerifySignaturesTestVector::from_file(path)?; + + for (name, test) in tests.tests { + if test.info.fixture_format != SUPPORTED_FIXTURE_FORMAT { + return Err(format!( + "Unsupported fixture format: {} (expected {})", + test.info.fixture_format, SUPPORTED_FIXTURE_FORMAT + ) + .into()); + } + + println!("Running test: {}", name); + + // Step 1: Populate the pre-state with the test fixture + let anchor_state: State = test.anchor_state.into(); + + // Create anchor block from the state's latest block header + let anchor_block = Block { + slot: anchor_state.latest_block_header.slot, + proposer_index: anchor_state.latest_block_header.proposer_index, + parent_root: anchor_state.latest_block_header.parent_root, + state_root: anchor_state.tree_hash_root(), + body: Default::default(), + }; + + // Initialize the store with the anchor state and block + let genesis_time = anchor_state.config.genesis_time; + let mut store = Store::get_forkchoice_store(anchor_state, anchor_block); + + // Step 2: Run the state transition function with the block fixture + let signed_block: SignedBlockWithAttestation = test.signed_block_with_attestation.into(); + + // Advance time to the block's slot + let block_time = signed_block.message.block.slot * SECONDS_PER_SLOT + genesis_time; + store.on_tick(block_time, true); + + // Process the block (this includes signature verification) + let result = store.on_block(signed_block); + + // Step 3: Check that it succeeded or failed as expected + match (result.is_ok(), test.expect_exception.as_ref()) { + (true, None) => { + // Expected success, got success + } + (true, Some(expected_err)) => { + return Err(format!( + "Test '{}' failed: expected exception '{}' but got success", + name, expected_err + ) + .into()); + } + (false, None) => { + return Err(format!( + "Test '{}' failed: expected success but got failure: {:?}", + name, + result.err() + ) + .into()); + } + (false, Some(_)) => { + // Expected failure, got failure + } + } + } + + Ok(()) +} + +datatest_stable::harness!({ + test = run, + root = "../../../ethlambda/leanSpec/fixtures/consensus/verify_signatures", + pattern = r".*\.json" +}); diff --git a/crates/blockchain/tests/signature_types.rs b/crates/blockchain/tests/signature_types.rs new file mode 100644 index 0000000..5def57a --- /dev/null +++ b/crates/blockchain/tests/signature_types.rs @@ -0,0 +1,525 @@ +use ethlambda_types::attestation::{ + AggregatedAttestation as EthAggregatedAttestation, AggregationBits as EthAggregationBits, + Attestation as EthAttestation, AttestationData as EthAttestationData, XmssSignature, +}; +use ethlambda_types::block::{ + AggregatedAttestations, AttestationSignatures, Block as EthBlock, BlockBody as EthBlockBody, + BlockSignatures, BlockWithAttestation, NaiveAggregatedSignature, SignedBlockWithAttestation, +}; +use ethlambda_types::primitives::{BitList, Encode, H256, VariableList}; +use ethlambda_types::state::{Checkpoint as EthCheckpoint, State, ValidatorPubkeyBytes}; +use serde::Deserialize; +use ssz_derive::{Decode as SszDecode, Encode as SszEncode}; +use ssz_types::FixedVector; +use ssz_types::typenum::{U28, U32}; +use std::collections::HashMap; +use std::path::Path; + +// ============================================================================ +// SSZ Types matching leansig's GeneralizedXMSSSignature structure +// ============================================================================ + +/// A single hash digest (8 field elements = 32 bytes) +pub type HashDigest = FixedVector; + +/// Randomness (7 field elements = 28 bytes) +pub type Rho = FixedVector; + +/// SSZ-compatible HashTreeOpening matching leansig's structure +#[derive(Clone, SszEncode, SszDecode)] +pub struct SszHashTreeOpening { + pub co_path: Vec, +} + +/// SSZ-compatible XMSS Signature matching leansig's GeneralizedXMSSSignature +#[derive(Clone, SszEncode, SszDecode)] +pub struct SszXmssSignature { + pub path: SszHashTreeOpening, + pub rho: Rho, + pub hashes: Vec, +} + +/// Root struct for verify signatures test vectors +#[derive(Debug, Clone, Deserialize)] +pub struct VerifySignaturesTestVector { + #[serde(flatten)] + pub tests: HashMap, +} + +impl VerifySignaturesTestVector { + /// Load a verify signatures test vector from a JSON file + pub fn from_file>(path: P) -> Result> { + let content = std::fs::read_to_string(path)?; + let test_vector = serde_json::from_str(&content)?; + Ok(test_vector) + } +} + +/// A single verify signatures test case +#[derive(Debug, Clone, Deserialize)] +pub struct VerifySignaturesTest { + #[allow(dead_code)] + pub network: String, + #[serde(rename = "leanEnv")] + #[allow(dead_code)] + pub lean_env: String, + #[serde(rename = "anchorState")] + pub anchor_state: TestState, + #[serde(rename = "signedBlockWithAttestation")] + pub signed_block_with_attestation: TestSignedBlockWithAttestation, + #[serde(rename = "expectException")] + pub expect_exception: Option, + #[serde(rename = "_info")] + #[allow(dead_code)] + pub info: TestInfo, +} + +/// Pre-state of the beacon chain for signature tests +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct TestState { + pub config: Config, + pub slot: u64, + #[serde(rename = "latestBlockHeader")] + pub latest_block_header: BlockHeader, + #[serde(rename = "latestJustified")] + pub latest_justified: Checkpoint, + #[serde(rename = "latestFinalized")] + pub latest_finalized: Checkpoint, + #[serde(rename = "historicalBlockHashes")] + pub historical_block_hashes: Container, + #[serde(rename = "justifiedSlots")] + pub justified_slots: Container, + pub validators: Container, + #[serde(rename = "justificationsRoots")] + pub justifications_roots: Container, + #[serde(rename = "justificationsValidators")] + pub justifications_validators: Container, +} + +impl From for State { + fn from(value: TestState) -> Self { + let historical_block_hashes = + VariableList::new(value.historical_block_hashes.data).unwrap(); + let validators = + VariableList::new(value.validators.data.into_iter().map(Into::into).collect()).unwrap(); + let justifications_roots = VariableList::new(value.justifications_roots.data).unwrap(); + + State { + config: value.config.into(), + slot: value.slot, + latest_block_header: value.latest_block_header.into(), + latest_justified: value.latest_justified.into(), + latest_finalized: value.latest_finalized.into(), + historical_block_hashes, + justified_slots: BitList::with_capacity(0).unwrap(), + validators, + justifications_roots, + justifications_validators: BitList::with_capacity(0).unwrap(), + } + } +} + +/// Configuration for the beacon chain +#[derive(Debug, Clone, Deserialize)] +pub struct Config { + #[serde(rename = "genesisTime")] + pub genesis_time: u64, +} + +impl From for ethlambda_types::state::ChainConfig { + fn from(value: Config) -> Self { + ethlambda_types::state::ChainConfig { + genesis_time: value.genesis_time, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Checkpoint { + pub root: H256, + pub slot: u64, +} + +impl From for EthCheckpoint { + fn from(value: Checkpoint) -> Self { + Self { + root: value.root, + slot: value.slot, + } + } +} + +/// Block header representing the latest block +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct BlockHeader { + pub slot: u64, + #[serde(rename = "proposerIndex")] + pub proposer_index: u64, + #[serde(rename = "parentRoot")] + pub parent_root: H256, + #[serde(rename = "stateRoot")] + pub state_root: H256, + #[serde(rename = "bodyRoot")] + pub body_root: H256, +} + +impl From for ethlambda_types::block::BlockHeader { + fn from(value: BlockHeader) -> Self { + Self { + slot: value.slot, + proposer_index: value.proposer_index, + parent_root: value.parent_root, + state_root: value.state_root, + body_root: value.body_root, + } + } +} + +/// Validator information +#[derive(Debug, Clone, Deserialize)] +pub struct Validator { + pub index: u64, + #[serde(deserialize_with = "deser_pubkey_hex")] + pub pubkey: ValidatorPubkeyBytes, +} + +impl From for ethlambda_types::state::Validator { + fn from(value: Validator) -> Self { + Self { + index: value.index, + pubkey: value.pubkey, + } + } +} + +/// Generic container for arrays +#[derive(Debug, Clone, Deserialize)] +pub struct Container { + pub data: Vec, +} + +/// Signed block with attestation and signature +#[derive(Debug, Clone, Deserialize)] +pub struct TestSignedBlockWithAttestation { + pub message: TestBlockWithAttestation, + pub signature: TestSignatureBundle, +} + +impl From for SignedBlockWithAttestation { + fn from(value: TestSignedBlockWithAttestation) -> Self { + let message = BlockWithAttestation { + block: value.message.block.into(), + proposer_attestation: value.message.proposer_attestation.into(), + }; + + let proposer_signature = value.signature.proposer_signature.to_xmss_signature(); + + // For now, attestation signatures use placeholder proofData (for future SNARK aggregation). + // We create empty NaiveAggregatedSignature entries to match the attestation count. + // The actual signature verification for attestations is not yet implemented. + let attestation_signatures: AttestationSignatures = value + .signature + .attestation_signatures + .data + .into_iter() + .map(|_att_sig| { + // Create empty signature list for each attestation + // Real implementation would parse proofData or individual signatures + let empty: NaiveAggregatedSignature = Vec::new().try_into().unwrap(); + empty + }) + .collect::>() + .try_into() + .expect("too many attestation signatures"); + + SignedBlockWithAttestation { + message, + signature: BlockSignatures { + proposer_signature, + attestation_signatures, + }, + } + } +} + +/// Block with proposer attestation (the message that gets signed) +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct TestBlockWithAttestation { + pub block: Block, + #[serde(rename = "proposerAttestation")] + pub proposer_attestation: ProposerAttestation, +} + +/// A block to be processed +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct Block { + pub slot: u64, + #[serde(rename = "proposerIndex")] + pub proposer_index: u64, + #[serde(rename = "parentRoot")] + pub parent_root: H256, + #[serde(rename = "stateRoot")] + pub state_root: H256, + pub body: BlockBody, +} + +impl From for EthBlock { + fn from(value: Block) -> Self { + Self { + slot: value.slot, + proposer_index: value.proposer_index, + parent_root: value.parent_root, + state_root: value.state_root, + body: value.body.into(), + } + } +} + +/// Block body containing attestations +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct BlockBody { + pub attestations: Container, +} + +impl From for EthBlockBody { + fn from(value: BlockBody) -> Self { + let attestations: AggregatedAttestations = value + .attestations + .data + .into_iter() + .map(Into::into) + .collect::>() + .try_into() + .expect("too many attestations"); + Self { attestations } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct AggregatedAttestation { + #[serde(rename = "aggregationBits")] + pub aggregation_bits: AggregationBits, + pub data: AttestationData, +} + +impl From for EthAggregatedAttestation { + fn from(value: AggregatedAttestation) -> Self { + Self { + aggregation_bits: value.aggregation_bits.into(), + data: value.data.into(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct AggregationBits { + pub data: Vec, +} + +impl From for EthAggregationBits { + fn from(value: AggregationBits) -> Self { + let mut bits = EthAggregationBits::with_capacity(value.data.len()).unwrap(); + for (i, &b) in value.data.iter().enumerate() { + bits.set(i, b).unwrap(); + } + bits + } +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct AttestationData { + pub slot: u64, + pub head: Checkpoint, + pub target: Checkpoint, + pub source: Checkpoint, +} + +impl From for EthAttestationData { + fn from(value: AttestationData) -> Self { + Self { + slot: value.slot, + head: value.head.into(), + target: value.target.into(), + source: value.source.into(), + } + } +} + +/// Proposer attestation structure +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct ProposerAttestation { + #[serde(rename = "validatorId")] + pub validator_id: u64, + pub data: AttestationData, +} + +impl From for EthAttestation { + fn from(value: ProposerAttestation) -> Self { + Self { + validator_id: value.validator_id, + data: value.data.into(), + } + } +} + +/// Bundle of signatures for block and attestations +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct TestSignatureBundle { + #[serde(rename = "proposerSignature")] + pub proposer_signature: ProposerSignature, + #[serde(rename = "attestationSignatures")] + pub attestation_signatures: Container, +} + +/// XMSS signature structure as it appears in JSON +#[derive(Debug, Clone, Deserialize)] +pub struct ProposerSignature { + pub path: SignaturePath, + pub rho: RhoData, + pub hashes: HashesData, +} + +impl ProposerSignature { + /// Convert to XmssSignature (FixedVector of bytes). + /// + /// Constructs an SSZ-encoded signature matching leansig's GeneralizedXMSSSignature format. + pub fn to_xmss_signature(&self) -> XmssSignature { + // Build SSZ types from JSON data + let ssz_sig = self.to_ssz_signature(); + + // Encode to SSZ bytes + let bytes = ssz_sig.as_ssz_bytes(); + + // Pad to exactly SignatureSize bytes (3112) + let sig_size = 3112; + let mut padded = bytes.clone(); + padded.resize(sig_size, 0); + + XmssSignature::new(padded).expect("signature size mismatch") + } + + /// Convert to SSZ signature type + fn to_ssz_signature(&self) -> SszXmssSignature { + // Convert path siblings to HashDigest (Vec of 32 bytes each) + let co_path: Vec = self + .path + .siblings + .data + .iter() + .map(|sibling| { + let bytes: Vec = sibling + .data + .iter() + .flat_map(|&val| val.to_le_bytes()) + .collect(); + HashDigest::new(bytes).expect("Invalid sibling length") + }) + .collect(); + + // Convert rho (7 field elements = 28 bytes) + let rho_bytes: Vec = self + .rho + .data + .iter() + .flat_map(|&val| val.to_le_bytes()) + .collect(); + let rho = Rho::new(rho_bytes).expect("Invalid rho length"); + + // Convert hashes to HashDigest + let hashes: Vec = self + .hashes + .data + .iter() + .map(|hash| { + let bytes: Vec = hash + .data + .iter() + .flat_map(|&val| val.to_le_bytes()) + .collect(); + HashDigest::new(bytes).expect("Invalid hash length") + }) + .collect(); + + SszXmssSignature { + path: SszHashTreeOpening { co_path }, + rho, + hashes, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SignaturePath { + pub siblings: Container, +} + +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct HashElement { + pub data: [u32; 8], +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RhoData { + pub data: [u32; 7], +} + +#[derive(Debug, Clone, Deserialize)] +pub struct HashesData { + pub data: Vec, +} + +/// Attestation signature from a validator +/// Note: proofData is for future SNARK aggregation, currently just placeholder +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct AttestationSignature { + pub participants: AggregationBits, + #[serde(rename = "proofData")] + pub proof_data: ProofData, +} + +/// Placeholder for future SNARK proof data +#[derive(Debug, Clone, Deserialize)] +pub struct ProofData { + pub data: String, +} + +/// Test metadata and information +#[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] +pub struct TestInfo { + pub hash: String, + pub comment: String, + #[serde(rename = "testId")] + pub test_id: String, + pub description: String, + #[serde(rename = "fixtureFormat")] + pub fixture_format: String, +} + +// Helpers + +pub fn deser_pubkey_hex<'de, D>(d: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::de::Error; + + let value = String::deserialize(d)?; + let pubkey: ValidatorPubkeyBytes = hex::decode(value.strip_prefix("0x").unwrap_or(&value)) + .map_err(|_| D::Error::custom("ValidatorPubkey value is not valid hex"))? + .try_into() + .map_err(|_| D::Error::custom("ValidatorPubkey length != 52"))?; + Ok(pubkey) +}