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
638 changes: 182 additions & 456 deletions lean_client/Cargo.lock

Large diffs are not rendered by default.

16 changes: 10 additions & 6 deletions lean_client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -251,10 +251,11 @@ indexmap = "2"
http-body-util = "0.1"
http_api_utils = { git = "https://github.com/grandinetech/grandine", rev = "64afdee3c6be79fceffb66933dcb69a943f3f1ae" }
k256 = "0.13"
rec_aggregation = { git = "https://github.com/leanEthereum/leanMultisig.git", rev = "f66d4a974eced803574eb0ea43d812e523c8d7ad" }
leansig = { git = "https://github.com/leanEthereum/leanSig" }
leansig_wrapper = { git = "https://github.com/leanEthereum/leanMultisig.git", rev = "f66d4a974eced803574eb0ea43d812e523c8d7ad" }
libp2p = { version = "0.56.0", default-features = false, features = [
rec_aggregation = { git = "https://github.com/leanEthereum/leanVM.git", rev = "e2592df4e30fdddbbf8ae26a333116c68cec7026" }
backend = { git = "https://github.com/leanEthereum/leanVM.git", rev = "e2592df4e30fdddbbf8ae26a333116c68cec7026" }
leansig = { git = "https://github.com/leanEthereum/leanSig", branch = "devnet4" }
leansig_wrapper = { git = "https://github.com/leanEthereum/leanVM.git", rev = "e2592df4e30fdddbbf8ae26a333116c68cec7026" }
libp2p = { git = "https://github.com/libp2p/rust-libp2p.git", rev = "91e8931e275bcd1c72791d18b09fea8b77209baf", default-features = false, features = [
'dns',
'gossipsub',
'identify',
Expand All @@ -266,8 +267,7 @@ libp2p = { version = "0.56.0", default-features = false, features = [
'tokio',
'yamux'
] }
libp2p-identity = { version = "0.2", features = ["secp256k1"] }
libp2p-mplex = "0.39"
libp2p-identity = { version = "0.2.13", features = ["secp256k1"] }
dedicated_executor = { path = "dedicated_executor" }
num-bigint = "0.4"
num-traits = "0.2"
Expand Down Expand Up @@ -337,3 +337,7 @@ validator = { workspace = true }
xmss = { workspace = true }
reqwest = { workspace = true }
tikv-jemallocator = { workspace = true }

[profile.release]
lto = true
codegen-units = 1
Comment on lines +341 to +343

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.

we probably don't need that?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

So leanVM upstream itself pins target-cpu=native in its cargo/config.toml, and our x86-64-v3 + LTO + codegen-units=1 is the portable Hetzner-friendly version of the same pattern, so the prover gets the AVX2 vectorization and cross-crate inlining it was tuned for

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.

how the debug = true/split-debuginfo = 'packed' is related to this? codegen-units=1 is arguable too, but we can keep it for now. If performance matters, then probably lto should be set to true, not to "thin"?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Dropped debug = true + split-debuginfo = "packed" and flipped lto = "thin" → lto = true pushed in the latest commit

3 changes: 2 additions & 1 deletion lean_client/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ GIT_BRANCH := $(shell git rev-parse --abbrev-ref HEAD)
# Build date. Used for docker image metadata.
BUILD_DATE := $(shell date -u +'%Y-%m-%dT%H:%M:%SZ')
# Currently supported devnet. Used to publish devnet tag, along with `latest`.
CURRENT_DEVNET := devnet-4
CURRENT_DEVNET := devnet-5
# Repo root that cross bind-mounts into its build container (see
# `lean_client/Cross.toml`). Auto-derived locally; CI overrides via env.
LEAN_REPO_ROOT ?= $(shell git rev-parse --show-toplevel)
Expand Down Expand Up @@ -86,6 +86,7 @@ clean:
x86_64-unknown-linux-gnu: ./target/x86_64-unknown-linux-gnu/release/lean_client

./target/x86_64-unknown-linux-gnu/release/lean_client:
CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS="-C target-cpu=x86-64-v3" \
LEAN_REPO_ROOT=$(LEAN_REPO_ROOT) cross build --bin lean_client --target x86_64-unknown-linux-gnu --profile release

.PHONY: aarch64-unknown-linux-gnu
Expand Down
2 changes: 0 additions & 2 deletions lean_client/containers/src/attestation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ pub type Attestations = PersistentList<Attestation, ValidatorRegistryLimit>;

pub type AggregatedAttestations = PersistentList<AggregatedAttestation, ValidatorRegistryLimit>;

pub type AttestationSignatures = PersistentList<AggregatedSignatureProof, ValidatorRegistryLimit>;

/// Aggregated signature proof with participant tracking.
///
/// Combines the participant bitfield with the proof bytes.
Expand Down
160 changes: 65 additions & 95 deletions lean_client/containers/src/block.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
use std::collections::HashSet;

use crate::{Slot, State};
use anyhow::{Context, Result, ensure};
use metrics::METRICS;
use rayon::prelude::*;
use ssz::{H256, Ssz, SszHash};
use xmss::Signature;
use xmss::MultiMessageAggregate;

use crate::attestation::{AggregatedAttestations, AttestationSignatures};
use crate::attestation::AggregatedAttestations;
use crate::state::MAX_ATTESTATIONS_DATA;

/// The body of a block, containing payload data.
///
/// Attestations are stored WITHOUT signatures. Signatures are aggregated
/// separately in BlockSignatures to match the spec architecture.
// todo(containers): default implementation doesn't make sense here.
#[derive(Clone, Debug, Ssz, Default)]
pub struct BlockBody {
Expand All @@ -35,121 +33,93 @@ pub struct Block {
pub body: BlockBody,
}

// todo(containers): default implementation doesn't make sense here
#[derive(Debug, Clone, Ssz, Default)]
pub struct BlockSignatures {
pub attestation_signatures: AttestationSignatures,
pub proposer_signature: Signature,
}

/// Signed block for devnet4: block body + aggregated signatures.
/// Proposer attestation is no longer embedded in the block message.
#[derive(Clone, Debug, Ssz)]
pub struct SignedBlock {
/// The proposed block.
pub block: Block,
/// Aggregated signature payload (attestation proofs + proposer signature).
pub signature: BlockSignatures,
pub proof: MultiMessageAggregate,
}

impl SignedBlock {
/// Verify all XMSS signatures in this signed block.
///
/// Verifies each aggregated attestation proof against the participant
/// validator public keys from parent state.
///
/// Returns `Ok(())` if all signatures are valid, or an error describing the failure.
pub fn verify_signatures(&self, parent_state: State) -> Result<()> {
let block = &self.block;
let signature = &self.signature;
let aggregated_attestations = &block.body.attestations;
let attestation_signatures = &signature.attestation_signatures;

// Verify signature count matches aggregated attestation count
ensure!(
aggregated_attestations.len_u64() == attestation_signatures.len_u64(),
"attestation signature count mismatch: {} attestations vs {} signatures",
(aggregated_attestations.len_u64() as usize) <= MAX_ATTESTATIONS_DATA,
"block has {} attestations; max is {MAX_ATTESTATIONS_DATA}",
aggregated_attestations.len_u64(),
attestation_signatures.len_u64()
);

let validators = &parent_state.validators;
let num_validators = validators.len_u64();

// Phase 1: collect all verification inputs (serial - reads from State)
let verification_tasks = aggregated_attestations
.into_iter()
.zip(attestation_signatures.into_iter())
.map(|(aggregated_attestation, aggregated_signature)| {
let validator_ids = aggregated_attestation
.aggregation_bits
.to_validator_indices();

// Ensure all validators exist in the active set
for validator_id in &validator_ids {
ensure!(
*validator_id < num_validators,
"validator index {validator_id} out of range (max {num_validators})"
);
}

let attestation_data_root = aggregated_attestation.data.hash_tree_root();
let slot = aggregated_attestation.data.slot.0 as u32;

// Collect validators, returning error if any not found
let public_keys = validator_ids
.into_iter()
.map(|id| {
validators
.get(id)
.map(|validator| validator.attestation_pubkey.clone())
.map_err(Into::into)
})
.collect::<Result<Vec<_>>>()?;

Ok((
public_keys,
attestation_data_root,
slot,
aggregated_signature,
))
})
.collect::<Result<Vec<_>>>()?;

// Phase 2: verify all proofs in parallel (CPU-intensive XMSS verification)
verification_tasks.into_par_iter().try_for_each(
|(public_keys, attestation_data_root, slot, aggregated_signature)| {
aggregated_signature
.verify(public_keys, attestation_data_root, slot)
.context("attestation aggregated signature verification failed")
},
)?;

// Verify the proposer's XMSS signature over the block root
let _timer = METRICS.get().map(|metrics| {
metrics
.lean_pq_sig_attestation_verification_time_seconds
.start_timer()
});

let attestations_len = aggregated_attestations.len_u64() as usize;
let mut pubkeys_owned: Vec<Vec<xmss::PublicKey>> = Vec::with_capacity(attestations_len + 1);
let mut messages: Vec<(H256, u32)> = Vec::with_capacity(attestations_len + 1);
let mut seen_data_roots: HashSet<H256> = HashSet::with_capacity(attestations_len);

for aggregated_attestation in aggregated_attestations {
let validator_ids = aggregated_attestation
.aggregation_bits
.to_validator_indices();
ensure!(
!validator_ids.is_empty(),
"attestation has empty aggregation_bits"
);
for validator_id in &validator_ids {
ensure!(
*validator_id < num_validators,
"validator index {validator_id} out of range (max {num_validators})"
);
}
let pubkeys = validator_ids
.into_iter()
.map(|id| {
validators
.get(id)
.map(|validator| validator.attestation_pubkey.clone())
.map_err(Into::into)
})
.collect::<Result<Vec<_>>>()?;
pubkeys_owned.push(pubkeys);

let data_root = aggregated_attestation.data.hash_tree_root();
ensure!(
seen_data_roots.insert(data_root),
"duplicate AttestationData in block body"
);
messages.push((data_root, aggregated_attestation.data.slot.0 as u32));
}

let proposer_index = block.proposer_index;
ensure!(
proposer_index < num_validators,
"proposer index {proposer_index} out of range (max {num_validators})"
);

let proposer = validators
.get(proposer_index)
.context(format!("proposer {proposer_index} not found in state"))?;
pubkeys_owned.push(vec![proposer.proposal_pubkey.clone()]);

let _timer = METRICS.get().map(|metrics| {
metrics
.lean_pq_sig_attestation_verification_time_seconds
.start_timer()
});
let block_root = block.hash_tree_root();
ensure!(
!seen_data_roots.contains(&block_root),
"block root collides with attestation data root"
);
messages.push((block_root, block.slot.0 as u32));

let pubkeys_per_message: Vec<&[xmss::PublicKey]> =
pubkeys_owned.iter().map(|v| v.as_slice()).collect();

signature
.proposer_signature
.verify(
&proposer.proposal_pubkey,
block.slot.0 as u32,
block.hash_tree_root(),
)
.context("proposer signature verification failed")?;
self.proof
.verify(&pubkeys_per_message, &messages)
.context("block proof verification failed")?;

Ok(())
}
Expand Down
7 changes: 4 additions & 3 deletions lean_client/containers/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ mod validator;

pub use attestation::{
AggregatedAttestation, AggregatedSignatureProof, AggregatedSignatures, AggregationBits,
Attestation, AttestationData, AttestationSignatures, Attestations, SignatureKey,
SignedAggregatedAttestation, SignedAttestation,
Attestation, AttestationData, Attestations, SignatureKey, SignedAggregatedAttestation,
SignedAttestation,
};
pub use block::{Block, BlockBody, BlockHeader, BlockSignatures, SignedBlock};
pub use block::{Block, BlockBody, BlockHeader, SignedBlock};
pub use checkpoint::Checkpoint;
pub use config::{Config, GenesisConfig, GenesisValidatorEntry};
pub use slot::Slot;
Expand All @@ -21,3 +21,4 @@ pub use state::{
};
pub use status::Status;
pub use validator::{Validator, Validators};
pub use xmss::MultiMessageAggregate;
50 changes: 27 additions & 23 deletions lean_client/containers/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,19 @@ use crate::{
attestation::{
AggregatedAttestation, AggregatedAttestations, AggregationBits, AttestationData,
},
block::{Block, BlockBody, BlockHeader, SignedBlock},
block::{Block, BlockBody, BlockHeader},
validator::{Validator, ValidatorRegistryLimit, Validators},
};

/// Maximum number of distinct AttestationData entries per block (spec: chain/config.py:36).
const MAX_ATTESTATIONS_DATA: usize = 16;
/// Used by the import-side validation so we accept any spec-valid block from other clients.
pub(crate) const MAX_ATTESTATIONS_DATA: usize = 8;

/// Producer-side cap on distinct AttestationData entries when *we* build a block.
/// Lower than the spec ceiling so the per-block Type-2 MultiMessageAggregate prove
/// fits inside the slot. Spec-permissible because MAX_ATTESTATIONS_DATA is a ceiling,
/// not a floor (confirmed on 2026-06-10 spec call).
pub(crate) const PRODUCER_MAX_ATTESTATIONS_DATA: usize = 4;

type HistoricalRootsLimit = U262144; // 2^18

Expand Down Expand Up @@ -295,17 +302,10 @@ impl State {
self
}

pub fn state_transition(
&self,
signed_block: SignedBlock,
valid_signatures: bool,
) -> Result<Self> {
ensure!(valid_signatures, "invalid block signatures");

pub fn state_transition(&self, block: &Block) -> Result<Self> {
let _timer = METRICS
.get()
.map(|metrics| metrics.lean_state_transition_time_seconds.start_timer());
let block = &signed_block.block;
let mut state = self.process_slots(block.slot)?;
state = state.process_block(block)?;

Expand Down Expand Up @@ -500,6 +500,7 @@ impl State {

let source = attestation.data.source.clone();
let target = attestation.data.target.clone();
let head = attestation.data.head.clone();

if !justified_slots.is_slot_justified(finalized_slot, source.slot)? {
info!("skipping attestation, source slot is not justified");
Expand All @@ -511,17 +512,16 @@ impl State {
continue;
}

if source.root.is_zero() || target.root.is_zero() {
info!("skipping attestation, source or target slots are zero");
if source.root.is_zero() || target.root.is_zero() || head.root.is_zero() {
info!("skipping attestation, source/target/head root is zero");
continue;
}

if &source.root != self.historical_block_hashes.get(source.slot.0)?
|| &target.root != self.historical_block_hashes.get(target.slot.0)?
|| &head.root != self.historical_block_hashes.get(head.slot.0)?
{
info!(
"skipping attestation, source or target roots not found in historical block hashes"
);
info!("skipping attestation, source/target/head roots not on canonical chain");
continue;
}

Expand Down Expand Up @@ -698,7 +698,12 @@ impl State {
&H256,
&(AttestationData, Vec<AggregatedSignatureProof>),
)> = aggregated_payloads.iter().collect();
sorted_entries.sort_by_key(|(_, (data, _))| data.target.slot);
sorted_entries.sort_by_key(|(_, (data, _))| {
(
std::cmp::Reverse(data.target.slot),
std::cmp::Reverse(data.slot),
)
});

let mut processed_data_roots: HashSet<H256> = HashSet::new();

Expand All @@ -714,9 +719,12 @@ impl State {
if processed_data_roots.contains(data_root) {
continue;
}
if processed_data_roots.len() >= MAX_ATTESTATIONS_DATA {
if processed_data_roots.len() >= PRODUCER_MAX_ATTESTATIONS_DATA {
break;
}
if att_data.target.slot <= att_data.source.slot {
continue;
}
if !known_block_roots.contains(&att_data.head.root) {
continue;
}
Expand All @@ -732,12 +740,8 @@ impl State {
continue;
}

let is_genesis_self_vote =
att_data.source.slot == Slot(0) && att_data.target.slot == Slot(0);

if !is_genesis_self_vote
&& current_justified_slots
.is_slot_justified(current_finalized_slot, att_data.target.slot)?
if current_justified_slots
.is_slot_justified(current_finalized_slot, att_data.target.slot)?
{
continue;
}
Expand Down
Loading
Loading