Skip to content
Closed
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,577 changes: 1,147 additions & 430 deletions lean_client/Cargo.lock

Large diffs are not rendered by default.

4 changes: 1 addition & 3 deletions lean_client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,8 @@ version = "0.1.0"
edition = "2021"

[features]
default = ["devnet2", "xmss-signing"]
default = ["xmss-signing"]
xmss-signing = ["validator/xmss-signing"]
devnet1 = ["containers/devnet1", "fork-choice/devnet1", "networking/devnet1", "validator/devnet1"]
devnet2 = ["containers/devnet2", "fork-choice/devnet2", "networking/devnet2", "validator/devnet2"]

[dependencies]
chain = { path = "./chain" }
Expand Down
3 changes: 1 addition & 2 deletions lean_client/containers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ edition = "2021"
[features]
xmss-verify = ["leansig"]
default = []
devnet1 = ["env-config/devnet1"]
devnet2 = ["env-config/devnet2"]

[lib]
name = "containers"
Expand All @@ -24,6 +22,7 @@ serde_yaml = "0.9"
hex = "0.4.3"
sha2 = "0.10"
leansig = { git = "https://github.com/leanEthereum/leanSig", branch = "main", optional = true }
lean-multisig = { git = "https://github.com/leanEthereum/leanMultisig", branch = "main" }

[dev-dependencies]
rstest = "0.18"
Expand Down
230 changes: 209 additions & 21 deletions lean_client/containers/src/attestation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ use serde::{Deserialize, Serialize};
use ssz::BitList;
use ssz::ByteVector;
use ssz_derive::Ssz;
use typenum::{Prod, Sum, U100, U12, U31};
use typenum::{Prod, Sum, U100, U1024, U12, U31};

// Type-level number for 1 MiB (1048576 = 1024 * 1024)
pub type U1048576 = Prod<U1024, U1024>;

pub type U3100 = Prod<U31, U100>;

Expand All @@ -22,15 +25,174 @@ pub type Attestations = ssz::PersistentList<Attestation, U4096>;

pub type AggregatedAttestations = ssz::PersistentList<AggregatedAttestation, U4096>;

#[cfg(feature = "devnet1")]
pub type AttestationSignatures = ssz::PersistentList<SignedAttestation, U4096>;

#[cfg(feature = "devnet2")]
pub type AttestationSignatures = ssz::PersistentList<NaiveAggregatedSignature, U4096>;
pub type AttestationSignatures = ssz::PersistentList<MultisigAggregatedSignature, U4096>;

#[cfg(feature = "devnet2")]
/// Legacy naive aggregated signature type (list of individual XMSS signatures).
/// Kept for backwards compatibility but no longer used in wire format.
pub type NaiveAggregatedSignature = ssz::PersistentList<Signature, U4096>;

/// Aggregated signature proof from lean-multisig zkVM.
///
/// This is a variable-length byte list (up to 1 MiB) containing the serialized
/// proof bytes from `xmss_aggregate_signatures()`. The `#[ssz(transparent)]`
/// attribute makes this type serialize directly as a ByteList for SSZ wire format.
#[derive(Clone, Debug, PartialEq, Eq, Default, Ssz, Serialize, Deserialize)]
#[ssz(transparent)]
pub struct MultisigAggregatedSignature(
/// The serialized zkVM proof bytes from lean-multisig aggregation.
#[serde(with = "crate::serde_helpers::byte_list")]
pub ssz::ByteList<U1048576>,
);

impl MultisigAggregatedSignature {
/// Create a new MultisigAggregatedSignature from proof bytes.
pub fn new(proof: Vec<u8>) -> Self {
Self(ssz::ByteList::try_from(proof).expect("proof exceeds 1 MiB limit"))
}

/// Get the proof bytes.
pub fn as_bytes(&self) -> &[u8] {
self.0.as_bytes()
}

/// Check if the signature is empty (no proof).
pub fn is_empty(&self) -> bool {
self.0.as_bytes().is_empty()
}

/// Aggregate individual XMSS signatures into a single proof.
///
/// Uses lean-multisig zkVM to combine multiple signatures into a compact proof.
///
/// # Arguments
/// * `public_keys` - Public keys of the signers
/// * `signatures` - Individual XMSS signatures to aggregate
/// * `message` - The 32-byte message that was signed (as 8 field elements)
/// * `epoch` - The epoch/slot in which signatures were created
///
/// # Returns
/// Aggregated signature proof, or error if aggregation fails.
pub fn aggregate(
public_keys: &[lean_multisig::XmssPublicKey],
signatures: &[lean_multisig::XmssSignature],
message: [lean_multisig::F; 8],
epoch: u64,
) -> Result<Self, AggregationError> {
if public_keys.is_empty() {
return Err(AggregationError::EmptyInput);
}
if public_keys.len() != signatures.len() {
return Err(AggregationError::MismatchedLengths);
}

let proof_bytes =
lean_multisig::xmss_aggregate_signatures(public_keys, signatures, message, epoch)
.map_err(|_| AggregationError::AggregationFailed)?;

Ok(Self::new(proof_bytes))
}

/// Verify the aggregated signature proof against the given public keys and message.
///
/// Uses lean-multisig zkVM to verify that the aggregated proof is valid
/// for all the given public keys signing the same message at the given epoch.
///
/// # Returns
/// `Ok(())` if the proof is valid, `Err` with the proof error otherwise.
pub fn verify(
&self,
public_keys: &[lean_multisig::XmssPublicKey],
message: [lean_multisig::F; 8],
epoch: u64,
) -> Result<(), AggregationError> {
lean_multisig::xmss_verify_aggregated_signatures(
public_keys,
message,
self.0.as_bytes(),
epoch,
)
.map_err(|_| AggregationError::VerificationFailed)
}

/// Verify the aggregated payload against validators and message.
///
/// This is a convenience method that extracts public keys from validators
/// and converts the message bytes to the field element format expected by lean-multisig.
///
/// # Arguments
/// * `validators` - Slice of validator references to extract public keys from
/// * `message` - 32-byte message (typically attestation data root)
/// * `epoch` - Epoch/slot for proof verification
///
/// # Returns
/// `Ok(())` if verification succeeds, `Err` otherwise.
pub fn verify_aggregated_payload(
&self,
validators: &[&crate::validator::Validator],
message: &[u8; 32],
epoch: u64,
) -> Result<(), AggregationError> {
// NOTE: This stub matches Python leanSpec behavior (test_mode=True).
// Python also uses test_mode=True with TODO: "Remove test_mode once leanVM
// supports correct signature encoding."
// Once leanVM/lean-multisig supports proper signature encoding:
// 1. Extract public keys from validators
// 2. Convert message bytes to field element format
// 3. Call lean_multisig::xmss_verify_aggregated_signatures
let _ = (validators, message, epoch);

Ok(())
}
}

/// Error types for signature aggregation operations.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AggregationError {
/// No signatures provided for aggregation.
EmptyInput,
/// Public keys and signatures arrays have different lengths.
MismatchedLengths,
/// Aggregation failed in lean-multisig.
AggregationFailed,
/// Verification of aggregated proof failed.
VerificationFailed,
}

/// Aggregated signature proof with participant tracking.
///
/// This type combines the participant bitfield with the proof bytes,
/// matches Python's `AggregatedSignatureProof` container structure.
/// Used in `aggregated_payloads` to track which validators are covered by each proof.
#[derive(Clone, Debug, PartialEq, Eq, Default, Ssz, Serialize, Deserialize)]
pub struct AggregatedSignatureProof {
/// Bitfield indicating which validators' signatures are included.
pub participants: AggregationBits,
/// The raw aggregated proof bytes from lean-multisig.
pub proof_data: MultisigAggregatedSignature,
}

impl AggregatedSignatureProof {
/// Create a new AggregatedSignatureProof.
pub fn new(participants: AggregationBits, proof_data: MultisigAggregatedSignature) -> Self {
Self {
participants,
proof_data,
}
}

pub fn from_aggregation(participant_ids: &[u64], proof: MultisigAggregatedSignature) -> Self {
Self {
participants: AggregationBits::from_validator_indices(participant_ids),
proof_data: proof,
}
}

/// Get the validator indices covered by this proof.
pub fn get_participant_indices(&self) -> Vec<u64> {
self.participants.to_validator_indices()
}
}

/// Bitlist representing validator participation in an attestation.
/// Limit is VALIDATOR_REGISTRY_LIMIT (4096).
#[derive(Clone, Debug, PartialEq, Eq, Default, Ssz, Serialize, Deserialize)]
Expand Down Expand Up @@ -98,6 +260,34 @@ pub struct AttestationData {
pub source: Checkpoint,
}

impl AttestationData {
/// Compute the data root bytes for signature lookup.
/// This is the hash tree root of the attestation data.
pub fn data_root_bytes(&self) -> crate::Bytes32 {
crate::Bytes32(ssz::SszHash::hash_tree_root(self))
}
}

/// Key for looking up individual validator signatures.
/// Used to index signature caches by (validator, attestation_data_root) pairs.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct SignatureKey {
/// The validator who produced the signature.
pub validator_id: u64,
/// The hash of the signed attestation data.
pub data_root: crate::Bytes32,
}

impl SignatureKey {
/// Create a new signature key.
pub fn new(validator_id: u64, data_root: crate::Bytes32) -> Self {
Self {
validator_id,
data_root,
}
}
}

/// Validator specific attestation wrapping shared attestation data.
#[derive(Clone, Debug, PartialEq, Eq, Ssz, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
Expand All @@ -111,12 +301,8 @@ pub struct Attestation {
/// Validator attestation bundled with its signature.
#[derive(Clone, Debug, PartialEq, Eq, Ssz, Default, Serialize, Deserialize)]
pub struct SignedAttestation {
#[cfg(feature = "devnet2")]
pub validator_id: u64,
#[cfg(feature = "devnet2")]
pub message: AttestationData,
#[cfg(feature = "devnet1")]
pub message: Attestation,
pub signature: Signature,
}

Expand Down Expand Up @@ -158,16 +344,18 @@ impl AggregatedAttestation {
.collect()
}

pub fn to_plain(&self) -> Vec<Attestation> {
let validator_indices = self.aggregation_bits.to_validator_indices();

validator_indices
.into_iter()
.map(|validator_id| Attestation {
validator_id: Uint64(validator_id),
data: self.data.clone(),
})
.collect()
/// Returns true if the provided list contains duplicate AttestationData.
pub fn has_duplicate_data(attestations: &AggregatedAttestations) -> bool {
use ssz::SszHash;
use std::collections::HashSet;
let mut seen: HashSet<ssz::H256> = HashSet::new();
for attestation in attestations {
let root = attestation.data.hash_tree_root();
if !seen.insert(root) {
return true;
}
}
false
}
}

Expand Down
Loading