Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
eff6b8b
feat(types): carry proposer signature outside the block proof
MegaRedHand Jun 24, 2026
10f36fb
Merge branch 'main' into feat/proposer-signature-outside-block-proof
MegaRedHand Jun 25, 2026
1ed3e67
Merge remote-tracking branch 'origin/main' into feat/proposer-signatu…
MegaRedHand Jun 25, 2026
35ba220
Merge branch 'main' into feat/proposer-signature-outside-block-proof
MegaRedHand Jun 26, 2026
3a8f691
Merge branch 'main' into feat/proposer-signature-outside-block-proof
MegaRedHand Jun 26, 2026
461e348
Merge branch 'main' into feat/proposer-signature-outside-block-proof
MegaRedHand Jun 29, 2026
b57df4b
Merge branch 'main' into feat/proposer-signature-outside-block-proof
MegaRedHand Jun 29, 2026
c616b74
Merge remote-tracking branch 'origin/main' into pr467
MegaRedHand Jul 1, 2026
54e86de
Merge branch 'main' into feat/proposer-signature-outside-block-proof
MegaRedHand Jul 1, 2026
19801b1
Merge remote-tracking branch 'origin/main' into feat/proposer-signatu…
MegaRedHand Jul 14, 2026
506b961
Merge remote-tracking branch 'origin/main' into feat/proposer-signatu…
MegaRedHand Jul 22, 2026
0b62446
Merge remote-tracking branch 'origin/main' into feat/proposer-signatu…
MegaRedHand Jul 29, 2026
adbc459
fix(blockchain): count proposer signature verify in crypto_elapsed
MegaRedHand Jul 29, 2026
d598499
Merge remote-tracking branch 'origin/main' into feat/proposer-signatu…
MegaRedHand Aug 4, 2026
8fc0fe4
fix(blockchain): report a bad proposer signature as an invalid block …
MegaRedHand Aug 4, 2026
7c186ff
Merge remote-tracking branch 'origin/main' into feat/proposer-signatu…
MegaRedHand Aug 5, 2026
136111b
Merge remote-tracking branch 'origin/main' into feat/proposer-signatu…
MegaRedHand Aug 5, 2026
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
4 changes: 2 additions & 2 deletions crates/blockchain/src/aggregation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -773,7 +773,7 @@ mod tests {
use super::*;
use ethlambda_storage::backend::InMemoryBackend;
use ethlambda_types::{
block::{Block, BlockBody, BlockHeader, MultiMessageAggregate, SignedBlock},
block::{Block, BlockBody, BlockHeader, BlockProof, SignedBlock},
checkpoint::Checkpoint,
state::{ChainConfig, JustificationValidators, JustifiedSlots, State},
};
Expand Down Expand Up @@ -870,7 +870,7 @@ mod tests {
state_root: H256::ZERO,
body: BlockBody::default(),
},
proof: MultiMessageAggregate::default(),
proof: BlockProof::default(),
};
store
.insert_signed_block(root, signed_block)
Expand Down
21 changes: 15 additions & 6 deletions crates/blockchain/src/block_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -891,8 +891,12 @@ fn trace_skipped_attestation(reason: &'static str, att: &AttestationData, data_r
mod tests {
use super::*;
use ethlambda_types::{
attestation::{AggregatedAttestation, AggregationBits, AttestationData},
block::{ByteList512KiB, MultiMessageAggregate, SignedBlock, SingleMessageAggregate},
attestation::{
AggregatedAttestation, AggregationBits, AttestationData, blank_xmss_signature,
},
block::{
BlockProof, ByteList512KiB, MultiMessageAggregate, SignedBlock, SingleMessageAggregate,
},
checkpoint::Checkpoint,
state::State,
};
Expand Down Expand Up @@ -1101,11 +1105,16 @@ mod tests {
);

// Substitute a worst-case-size proof to model what `propose_block`
// would attach. The actual SNARK can't be built without lean-multisig,
// but the size cap (`ByteList512KiB`) bounds the worst case.
// would attach: a 512 KiB attestation aggregate plus the fixed-size
// proposer signature. The actual SNARK can't be built without
// lean-multisig, but the size cap bounds the worst case.
let _ = signatures;
let proof = MultiMessageAggregate::new(
ByteList512KiB::try_from(vec![0xAB; 512 * 1024]).expect("worst-case proof fits in cap"),
let proof = BlockProof::new(
blank_xmss_signature(),
MultiMessageAggregate::new(
ByteList512KiB::try_from(vec![0xAB; 512 * 1024])
.expect("worst-case proof fits in cap"),
),
);
let signed_block = SignedBlock {
message: block,
Expand Down
4 changes: 2 additions & 2 deletions crates/blockchain/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ mod tests {
use super::*;
use ethlambda_storage::{ForkCheckpoints, backend::InMemoryBackend};
use ethlambda_types::{
block::{Block, BlockBody, MultiMessageAggregate, SignedBlock},
block::{Block, BlockBody, BlockProof, SignedBlock},
state::State,
};
use std::sync::Arc;
Expand Down Expand Up @@ -455,7 +455,7 @@ mod tests {
state_root,
body: BlockBody::default(),
},
proof: MultiMessageAggregate::default(),
proof: BlockProof::default(),
};
store
.insert_signed_block(root, signed_block)
Expand Down
143 changes: 62 additions & 81 deletions crates/blockchain/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
use std::collections::{HashMap, HashSet, VecDeque};
use std::time::{Duration, Instant, SystemTime};

use ethlambda_crypto::signature::{ValidatorPublicKey, ValidatorSignature};
use ethlambda_crypto::signature::ValidatorPublicKey;
use ethlambda_network_api::{BlockChainToP2PRef, BlockSource, InitP2P};
use ethlambda_state_transition::is_proposer;
use ethlambda_storage::{ALL_TABLES, Store};
use ethlambda_types::{
ShortRoot,
aggregator::AggregatorController,
attestation::{SignedAggregatedAttestation, SignedAttestation},
block::{ByteList512KiB, MultiMessageAggregate, SignedBlock},
block::{BlockProof, ByteList512KiB, MultiMessageAggregate, SignedBlock},
primitives::{H256, HashTreeRoot as _},
};

Expand Down Expand Up @@ -777,105 +777,86 @@ impl BlockChainServer {
return;
};

// Wrap the proposer's raw XMSS signature into a singleton
// single-message aggregate SNARK, then merge it with every attestation
// single-message aggregate into the single multi-message aggregate.
// Assemble SignedBlock: carry the proposer's raw XMSS signature as a
// standalone field, and aggregate the attestation single-message
// aggregates (only) into the block's attestation multi-message
// aggregate. The proposer no longer enters the aggregate, so a block
// with no attestations needs no prover work and the attestation
// multi-message aggregate can be built independently of the block root.
let head_state = self.store.head_state();
let validators = &head_state.validators;
let Some(proposer_validator) = validators.get(validator_id as usize) else {
if validators.get(validator_id as usize).is_none() {
error!(%slot, %validator_id, "Proposer index out of range when assembling block");
metrics::inc_block_building_failures();
return;
};

// Decode the proposer's proposal pubkey once and reuse it both for the
// singleton single-message aggregate wrap and for the multi-message
// aggregate merge inputs.
let Ok(proposer_pubkey) = ValidatorPublicKey::from_bytes(
&proposer_validator.proposal_pubkey,
)
.inspect_err(
|err| error!(%slot, %validator_id, %err, "Failed to decode proposer proposal pubkey"),
) else {
metrics::inc_block_building_failures();
return;
};
}

let Ok(proposer_validator_signature) =
ValidatorSignature::from_bytes(&proposer_signature).inspect_err(|err| {
error!(%slot, %validator_id, %err, "Failed to decode proposer signature bytes")
})
else {
metrics::inc_block_building_failures();
return;
};
let Ok(proposer_proof_bytes) = ethlambda_crypto::aggregate_signatures(
vec![proposer_pubkey.clone()],
vec![proposer_validator_signature],
&block_root,
slot as u32,
)
.inspect_err(
|err| error!(%slot, %validator_id, %err, "Failed to wrap proposer signature as single-message aggregate"),
) else {
metrics::inc_block_building_failures();
return;
};
// `sign_block_root` already returns an `XmssSignature`, so the proposer
// signature is carried verbatim — no packing or prover work needed.

let mut merge_inputs: Vec<(Vec<ValidatorPublicKey>, ByteList512KiB)> =
Vec::with_capacity(single_message_aggregates.len() + 1);
let mut resolve_failed = false;
for sma in &single_message_aggregates {
let mut pubkeys = Vec::new();
for vid in sma.participant_indices() {
let Some(validator) = validators.get(vid as usize) else {
error!(%slot, %validator_id, vid, "Participant out of range while resolving pubkeys");
resolve_failed = true;
break;
};
match ValidatorPublicKey::from_bytes(&validator.attestation_pubkey) {
Ok(pk) => pubkeys.push(pk),
Err(err) => {
error!(%slot, %validator_id, vid, %err, "Failed to decode attestation pubkey");
// Aggregate the attestation single-message aggregates into a single
// multi-message aggregate. With no attestations the aggregate is empty:
// the proposer signature stands alone, mirroring `(prop-sig,
// empty-proof)`.
let attestation_proof = if single_message_aggregates.is_empty() {
MultiMessageAggregate::default()
} else {
let mut merge_inputs: Vec<(Vec<ValidatorPublicKey>, ByteList512KiB)> =
Vec::with_capacity(single_message_aggregates.len());
let mut resolve_failed = false;
for sma in &single_message_aggregates {
let mut pubkeys = Vec::new();
for vid in sma.participant_indices() {
let Some(validator) = validators.get(vid as usize) else {
error!(%slot, %validator_id, vid, "Participant out of range while resolving pubkeys");
resolve_failed = true;
break;
};
match ValidatorPublicKey::from_bytes(&validator.attestation_pubkey) {
Ok(pk) => pubkeys.push(pk),
Err(err) => {
error!(%slot, %validator_id, vid, %err, "Failed to decode attestation pubkey");
resolve_failed = true;
break;
}
}
}
if resolve_failed {
break;
}
merge_inputs.push((pubkeys, sma.proof.clone()));
}
if resolve_failed {
break;
}
merge_inputs.push((pubkeys, sma.proof.clone()));
}
if resolve_failed {
metrics::inc_block_building_failures();
return;
}
merge_inputs.push((vec![proposer_pubkey], proposer_proof_bytes));

// Merge yields raw lean-multisig type-2 bytes. Per-component
// participants are rederived at verify time from
// `block.body.attestations[i].aggregation_bits` plus
// `block.proposer_index`, so nothing else needs persisting.
let merged_bytes = match ethlambda_crypto::merge_type_1s_into_type_2(merge_inputs) {
Ok(bytes) => bytes,
Err(err) => {
error!(%slot, %validator_id, %err, "Failed to merge Type-1s into Type-2");
metrics::inc_block_building_failures();
return;
}
};
let proof = match MultiMessageAggregate::from_bytes(merged_bytes.iter().as_slice()) {
Ok(p) => p,
Err(err) => {
error!(%slot, %validator_id, %err, "Failed to build multi-message aggregate");
metrics::inc_block_building_failures();
return;

// Merge yields raw lean-multisig type-2 bytes. Per-component
// participants are rederived at verify time from
// `block.body.attestations[i].aggregation_bits`, so nothing else
// needs persisting.
let merged_bytes = match ethlambda_crypto::merge_type_1s_into_type_2(merge_inputs) {
Ok(bytes) => bytes,
Err(err) => {
error!(%slot, %validator_id, %err, "Failed to merge Type-1s into Type-2");
metrics::inc_block_building_failures();
return;
}
};
match MultiMessageAggregate::from_bytes(merged_bytes.iter().as_slice()) {
Ok(p) => p,
Err(err) => {
error!(%slot, %validator_id, %err, "Failed to build multi-message aggregate");
metrics::inc_block_building_failures();
return;
}
}
};
// `single_message_aggregates` is no longer needed past this point.
drop(single_message_aggregates);
let signed_block = SignedBlock {
message: block,
proof,
proof: BlockProof::new(proposer_signature, attestation_proof),
};

// Stop timing here: the build is done, and the alignment wait below must
Expand Down
22 changes: 7 additions & 15 deletions crates/blockchain/src/reaggregate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,12 @@ pub fn reaggregate_from_block(
let validators = &parent_state.validators;
let num_validators = validators.len() as u64;

// Per-component pubkeys: one entry per body attestation in order, then
// the proposer entry. Layout is invariant per block, so it's resolved
// once and reused for every split call below.
// Per-component pubkeys: one entry per body attestation in order. The
// attestation aggregate no longer carries a proposer component (the
// proposer signature lives outside it), so the layout is attestations
// only. Resolved once and reused for every split call below.
let mut pubkeys_per_component: Vec<Vec<ValidatorPublicKey>> =
Vec::with_capacity(attestations.len() + 1);
Vec::with_capacity(attestations.len());
for att in &attestations {
let mut pubkeys = Vec::new();
for vid in validator_indices(&att.aggregation_bits) {
Expand All @@ -93,15 +94,6 @@ pub fn reaggregate_from_block(
}
pubkeys_per_component.push(pubkeys);
}
if block.proposer_index >= num_validators {
return Vec::new();
}
let Ok(proposer_pubkey) =
ValidatorPublicKey::from_bytes(&validators[block.proposer_index as usize].proposal_pubkey)
else {
return Vec::new();
};
pubkeys_per_component.push(vec![proposer_pubkey]);

let candidates = select_candidates(store, &attestations);
if candidates.is_empty() {
Expand All @@ -123,8 +115,8 @@ pub fn reaggregate_from_block(
};

// Step 1: SNARK-split this attestation's component out of the block's
// merged multi-message aggregate proof.
let merged_bytes = signed_block.proof.proof_bytes();
// attestation multi-message aggregate proof.
let merged_bytes = signed_block.proof.attestation_proof.proof_bytes();
let split_bytes = match ethlambda_crypto::split_type_2_by_message(
merged_bytes,
pubkeys_per_component.clone(),
Expand Down
8 changes: 8 additions & 0 deletions crates/blockchain/src/spec_test_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ pub fn rejection_reason(err: &StoreError) -> Option<RejectionReason> {
StoreError::AttestationTooFarInFuture { .. } => RejectionReason::AttestationTooFarInFuture,
StoreError::AggregateVerificationFailed(_) => RejectionReason::InvalidSignature,
StoreError::BlockProofVerificationFailed(_) => RejectionReason::InvalidBlockProof,
// The proposer signature and the attestation aggregate are both
// components of the block proof, so a failure in either is the same
// spec rejection even though we carry them as separate wire fields. An
// attestation-free block carries no aggregate at all, so stray proof
// bytes are a malformed block proof rather than a distinct reason.
StoreError::ProposerSignatureDecodingFailed
| StoreError::ProposerSignatureVerificationFailed
| StoreError::UnexpectedAttestationProof => RejectionReason::InvalidBlockProof,
StoreError::EmptyAggregationBits => RejectionReason::EmptyAggregationBits,
StoreError::NotProposer { .. } => RejectionReason::WrongProposer,
StoreError::DuplicateAttestationData { .. } => RejectionReason::DuplicateAttestationData,
Expand Down
Loading
Loading