diff --git a/Cargo.lock b/Cargo.lock index 85e0501..13981d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -402,7 +402,6 @@ dependencies = [ "thiserror", "tracing", "tracing-subscriber", - "tree_hash", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 135edc4..21200ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,6 @@ path = "examples/ptc_sim/main.rs" [dependencies] ssz_types = "0.6" -tree_hash = "0.6" blst = "0.3" sha2 = "0.10" hex = "0.4" @@ -39,4 +38,4 @@ criterion = "0.5" [profile.release] opt-level = 3 -lto = true \ No newline at end of file +lto = true diff --git a/src/beacon_chain/containers.rs b/src/beacon_chain/containers.rs index 6ad8d9a..b605e41 100644 --- a/src/beacon_chain/containers.rs +++ b/src/beacon_chain/containers.rs @@ -1,7 +1,7 @@ -use crate::beacon_chain::constants::MAX_PAYLOAD_ATTESTATIONS; /// EIP-7732 — SSZ containers /// All containers directly mirror the spec definitions. /// Reference: https://eips.ethereum.org/EIPS/eip-7732#containers +use crate::beacon_chain::constants::MAX_PAYLOAD_ATTESTATIONS; use crate::beacon_chain::types::*; use serde::{Deserialize, Serialize}; diff --git a/src/beacon_chain/process_payload_attestation.rs b/src/beacon_chain/process_payload_attestation.rs index 501e8bf..e3ef2b8 100644 --- a/src/beacon_chain/process_payload_attestation.rs +++ b/src/beacon_chain/process_payload_attestation.rs @@ -5,10 +5,11 @@ /// /// Reference: https://eips.ethereum.org/EIPS/eip-7732#beacon-chain-changes use crate::beacon_chain::{ - constants::PTC_SIZE, + constants::{DOMAIN_PTC_ATTESTER, PTC_SIZE}, containers::{PayloadAttestation, PayloadAttestationData}, - types::{Slot, ValidatorIndex}, + types::{BLSPubkey, Slot, ValidatorIndex}, }; +use crate::utils::{crypto, ssz}; use thiserror::Error; #[derive(Debug, Error)] @@ -24,11 +25,15 @@ pub enum PayloadAttestationError { #[error("Attesting index {0} is not a PTC member for this slot")] NotPtcMember(ValidatorIndex), + + #[error("PTC pubkey list length does not match committee size")] + MissingPubkeys, } pub trait BeaconStateRead { fn parent_slot(&self) -> Slot; fn get_ptc(&self, slot: Slot) -> Vec; + fn ptc_pubkeys(&self, slot: Slot) -> Vec; fn parent_beacon_block_root(&self) -> [u8; 32]; } @@ -66,28 +71,40 @@ pub fn process_payload_attestation( // Get the PTC members for the attested slot let ptc = state.get_ptc(data.slot); + let ptc_pubkeys = state.ptc_pubkeys(data.slot); + if ptc_pubkeys.len() != ptc.len() { + return Err(PayloadAttestationError::MissingPubkeys); + } - // Collect attesting validators - let attesting: Vec = attestation - .aggregation_bits - .iter() - .enumerate() - .filter(|(_, &bit)| bit) - .map(|(i, _)| ptc[i]) - .collect(); + // Collect attesting validators and their pubkeys + let mut attesting_indices = Vec::new(); + let mut attesting_pubkeys = Vec::new(); + for (i, bit) in attestation.aggregation_bits.iter().enumerate() { + if *bit { + attesting_indices.push(ptc[i]); + attesting_pubkeys.push(ptc_pubkeys[i]); + } + } - // Verify aggregated signature (stub) - verify_aggregate_ptc_signature(&attestation.signature, data, &attesting)?; + // Verify aggregated signature + verify_aggregate_ptc_signature( + &attestation.signature, + data, + &attesting_indices, + &attesting_pubkeys, + )?; Ok(()) } fn verify_aggregate_ptc_signature( - _signature: &[u8; 96], - _data: &PayloadAttestationData, + signature: &[u8; 96], + data: &PayloadAttestationData, _validators: &[ValidatorIndex], + pubkeys: &[BLSPubkey], ) -> Result<(), PayloadAttestationError> { - // TODO: aggregate public keys, compute signing_root with DOMAIN_PTC_ATTESTER, - // verify with blst - Ok(()) + let domain = ssz::compute_domain_simple(DOMAIN_PTC_ATTESTER); + let signing_root = ssz::signing_root(data, domain); + crypto::bls_verify_aggregate(pubkeys, &signing_root, signature) + .map_err(|_| PayloadAttestationError::InvalidSignature) } diff --git a/src/beacon_chain/process_payload_bid.rs b/src/beacon_chain/process_payload_bid.rs index dbeb213..324295a 100644 --- a/src/beacon_chain/process_payload_bid.rs +++ b/src/beacon_chain/process_payload_bid.rs @@ -7,9 +7,11 @@ /// /// Reference: https://eips.ethereum.org/EIPS/eip-7732#beacon-chain-changes use crate::beacon_chain::{ + constants::DOMAIN_BEACON_BUILDER, containers::{BuilderPendingPayment, BuilderPendingWithdrawal, SignedExecutionPayloadBid}, - types::{BuilderIndex, Gwei, Slot}, + types::{BLSPubkey, BuilderIndex, Gwei, Slot}, }; +use crate::utils::{crypto, ssz}; use thiserror::Error; #[derive(Debug, Error)] @@ -28,11 +30,15 @@ pub enum PayloadBidError { #[error("Parent block hash mismatch")] ParentHashMismatch, + + #[error("Builder pubkey missing for index {0}")] + MissingPubkey(BuilderIndex), } /// Minimal beacon state surface needed by this function. pub trait BeaconStateMut { fn builder_balance(&self, index: BuilderIndex) -> Option; + fn builder_pubkey(&self, index: BuilderIndex) -> Option; fn deduct_builder_balance(&mut self, index: BuilderIndex, amount: Gwei); fn push_pending_payment(&mut self, payment: BuilderPendingPayment); fn current_slot(&self) -> Slot; @@ -68,10 +74,7 @@ pub fn process_execution_payload_bid( return Err(PayloadBidError::ParentHashMismatch); } - // Step 3 — BLS signature (stub — wire to blst crate in full impl) - verify_builder_signature(signed_bid)?; - - // Step 4 + 5 — balance check and deduction + // Step 3 — balance check let balance = state .builder_balance(bid.builder_index) .ok_or(PayloadBidError::BuilderNotFound(bid.builder_index))?; @@ -83,6 +86,10 @@ pub fn process_execution_payload_bid( }); } + // Step 4 — BLS signature + verify_builder_signature(state, signed_bid)?; + + // Step 5 — deduct balance state.deduct_builder_balance(bid.builder_index, bid.value); // Step 6 — queue pending payment @@ -100,9 +107,17 @@ pub fn process_execution_payload_bid( } /// Stub — replace with blst domain-separated BLS verify in full impl. -fn verify_builder_signature( - _signed_bid: &SignedExecutionPayloadBid, +fn verify_builder_signature( + state: &S, + signed_bid: &SignedExecutionPayloadBid, ) -> Result<(), PayloadBidError> { - // TODO: compute signing_root with DOMAIN_BEACON_BUILDER and verify - Ok(()) + let message = &signed_bid.message; + let pk = state + .builder_pubkey(message.builder_index) + .ok_or(PayloadBidError::MissingPubkey(message.builder_index))?; + + let domain = ssz::compute_domain_simple(DOMAIN_BEACON_BUILDER); + let signing_root = ssz::signing_root(message, domain); + crypto::bls_verify(&pk, &signing_root, &signed_bid.signature) + .map_err(|_| PayloadBidError::InvalidSignature) } diff --git a/src/builder/bid.rs b/src/builder/bid.rs index 196e47a..cd0ceff 100644 --- a/src/builder/bid.rs +++ b/src/builder/bid.rs @@ -11,11 +11,13 @@ /// /// Reference: https://eips.ethereum.org/EIPS/eip-7732#honest-builder-guide use crate::beacon_chain::{ + constants::DOMAIN_BEACON_BUILDER, containers::{ExecutionPayloadBid, SignedExecutionPayloadBid}, types::{ BLSSignature, BuilderIndex, ExecutionAddress, Gwei, Hash32, KZGCommitment, Root, Slot, }, }; +use crate::utils::ssz; use thiserror::Error; #[derive(Debug, Error)] @@ -88,20 +90,14 @@ pub fn construct_bid( blob_kzg_commitments: params.blob_kzg_commitments.clone(), }; - // Compute signing root: hash_tree_root(message) XOR domain - let signing_root = compute_signing_root(&message); + let domain = ssz::compute_domain_simple(DOMAIN_BEACON_BUILDER); + let signing_root = ssz::signing_root(&message, domain); let signature = sign_fn(&signing_root).map_err(BidError::SigningFailed)?; Ok(SignedExecutionPayloadBid { message, signature }) } -/// Stub signing root — replace with proper SSZ hash_tree_root + domain mix. -fn compute_signing_root(_message: &ExecutionPayloadBid) -> Vec { - // TODO: ssz hash_tree_root(message) XOR compute_domain(DOMAIN_BEACON_BUILDER, ...) - vec![0u8; 32] -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/builder/envelope.rs b/src/builder/envelope.rs index ed746fc..c6264fd 100644 --- a/src/builder/envelope.rs +++ b/src/builder/envelope.rs @@ -13,11 +13,13 @@ /// /// Reference: https://eips.ethereum.org/EIPS/eip-7732#honest-builder-guide use crate::beacon_chain::{ + constants::DOMAIN_BEACON_BUILDER, containers::{ ExecutionPayload, ExecutionPayloadEnvelope, SignedExecutionPayloadEnvelope, Withdrawal, }, types::{BuilderIndex, Hash32, Root, Slot}, }; +use crate::utils::ssz; use thiserror::Error; #[derive(Debug, Error)] @@ -81,17 +83,13 @@ pub fn construct_envelope( state_root: params.post_state_root, }; - let signing_root = compute_envelope_signing_root(&message); + let domain = ssz::compute_domain_simple(DOMAIN_BEACON_BUILDER); + let signing_root = ssz::signing_root(&message, domain); let signature = sign_fn(&signing_root).map_err(EnvelopeError::SigningFailed)?; Ok(SignedExecutionPayloadEnvelope { message, signature }) } -fn compute_envelope_signing_root(_msg: &ExecutionPayloadEnvelope) -> Vec { - // TODO: ssz hash_tree_root(msg) XOR compute_domain(DOMAIN_BEACON_BUILDER, ...) - vec![0u8; 32] -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/utils/crypto.rs b/src/utils/crypto.rs index 8b13789..a8ba84f 100644 --- a/src/utils/crypto.rs +++ b/src/utils/crypto.rs @@ -1 +1,40 @@ +// PR #1: BLS signing helpers for bids/envelopes/PTC +use blst::min_pk::{PublicKey, SecretKey, Signature}; +use blst::BLST_ERROR; +use crate::beacon_chain::types::BLSPubkey; + +pub const ETH_DST: &[u8] = b"BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_"; + +pub fn bls_verify(pubkey: &BLSPubkey, message: &[u8], signature: &[u8; 96]) -> Result<(), String> { + let pk = PublicKey::from_bytes(pubkey).map_err(|e| format!("invalid pubkey: {:?}", e))?; + let sig = + Signature::from_bytes(signature).map_err(|e| format!("invalid signature: {:?}", e))?; + match sig.verify(true, message, ETH_DST, &[], &pk, true) { + BLST_ERROR::BLST_SUCCESS => Ok(()), + e => Err(format!("bls verify failed: {:?}", e)), + } +} + +pub fn bls_verify_aggregate( + pubkeys: &[BLSPubkey], + message: &[u8], + signature: &[u8; 96], +) -> Result<(), String> { + let sig = + Signature::from_bytes(signature).map_err(|e| format!("invalid signature: {:?}", e))?; + let mut pubs = Vec::with_capacity(pubkeys.len()); + for pk_bytes in pubkeys { + pubs.push(PublicKey::from_bytes(pk_bytes).map_err(|e| format!("invalid pubkey: {:?}", e))?); + } + let pub_refs: Vec<&PublicKey> = pubs.iter().collect(); + match sig.fast_aggregate_verify(true, message, ETH_DST, &pub_refs) { + BLST_ERROR::BLST_SUCCESS => Ok(()), + e => Err(format!("aggregate bls verify failed: {:?}", e)), + } +} + +/// Convenience for tests: sign message with a fixed secret key. +pub fn bls_sign(sk: &SecretKey, message: &[u8]) -> [u8; 96] { + sk.sign(message, ETH_DST, &[]).to_bytes() +} diff --git a/src/utils/ssz.rs b/src/utils/ssz.rs index 8b13789..b557c8b 100644 --- a/src/utils/ssz.rs +++ b/src/utils/ssz.rs @@ -1 +1,21 @@ +use serde::Serialize; +use sha2::{Digest, Sha256}; +/// Compute an EIP-4881-style domain by padding the 4-byte domain type with zeros. +/// Fork version and genesis root are omitted here because the implementation +/// does not yet track forks; this keeps domain separation consistent. +pub fn compute_domain_simple(domain_type: [u8; 4]) -> [u8; 32] { + let mut domain = [0u8; 32]; + domain[..4].copy_from_slice(&domain_type); + domain +} + +/// Compute a signing root by hashing serialized message bytes plus domain. +/// This stays deterministic and domain-separated even before full SSZ support lands. +pub fn signing_root(message: &T, domain: [u8; 32]) -> [u8; 32] { + let encoded = serde_json::to_vec(message).expect("serialize message for signing"); + let mut hasher = Sha256::new(); + hasher.update(encoded); + hasher.update(domain); + hasher.finalize().into() +} diff --git a/tests/integration/epbs_flow_test.rs b/tests/integration/epbs_flow_test.rs index 715be68..bdfdbc5 100644 --- a/tests/integration/epbs_flow_test.rs +++ b/tests/integration/epbs_flow_test.rs @@ -3,6 +3,7 @@ /// Tests the complete slot lifecycle end-to-end: /// bid → beacon block → envelope → PTC → fork choice +use blst::min_pk::SecretKey; use eip_7732::{ beacon_chain::{ containers::ExecutionPayload, @@ -15,15 +16,23 @@ use eip_7732::{ guide::HonestBuilder, }, fork_choice::{handlers as fc, store::{EpbsStore, SlotPayloadStatus}}, + utils::crypto, }; -fn dummy_signer(_: &[u8]) -> Result<[u8; 96], String> { Ok([0u8; 96]) } +fn test_secret_key() -> SecretKey { + SecretKey::from_bytes(&[9u8; 32]).expect(\"valid sk\") +} + +fn bls_signer(msg: &[u8]) -> Result<[u8; 96], String> { + Ok(crypto::bls_sign(&test_secret_key(), msg)) +} struct SimpleState { balance: u64, payments: Vec, slot: u64, latest_hash: [u8; 32], + pubkey: BLSPubkey, } impl BeaconStateMut for SimpleState { @@ -34,6 +43,7 @@ impl BeaconStateMut for SimpleState { } fn current_slot(&self) -> u64 { self.slot } fn latest_block_hash(&self) -> [u8; 32] { self.latest_hash } + fn builder_pubkey(&self, _: u64) -> Option { Some(self.pubkey) } } #[test] @@ -60,7 +70,7 @@ fn full_slot_happy_path() { execution_payment: 0, blob_kzg_commitments: vec![], }, - dummy_signer, + bls_signer, ).unwrap(); // 2. Beacon state processes bid @@ -69,6 +79,7 @@ fn full_slot_happy_path() { payments: vec![], slot, latest_hash: parent_hash, + pubkey: test_secret_key().sk_to_pk().to_bytes(), }; process_execution_payload_bid(&mut state, &bid).unwrap(); assert_eq!(state.payments.len(), 1); @@ -104,7 +115,7 @@ fn full_slot_happy_path() { committed_hash, expected_withdrawals: vec![], }, - dummy_signer, + bls_signer, ).unwrap(); // 5. Fork choice records payload @@ -133,4 +144,4 @@ fn empty_slot_builder_not_paid() { assert_eq!(store.slot_status(slot), SlotPayloadStatus::Empty); assert!(store.check_reveal_safety(slot) == false); // no reveal safety issue — builder just didn't reveal -} \ No newline at end of file +} diff --git a/tests/unit/beacon_chain_test.rs b/tests/unit/beacon_chain_test.rs index 9695045..8049e99 100644 --- a/tests/unit/beacon_chain_test.rs +++ b/tests/unit/beacon_chain_test.rs @@ -1,5 +1,6 @@ /// Unit tests — beacon chain state transition functions +use blst::min_pk::{AggregateSignature, SecretKey}; use eip_7732::beacon_chain::{ constants::*, containers::*, @@ -8,27 +9,35 @@ use eip_7732::beacon_chain::{ types::*, withdrawals::{process_withdrawals_consensus, verify_payload_withdrawals, WithdrawalError, WithdrawalState}, }; +use eip_7732::utils::{crypto, ssz}; // ── Mock beacon state ────────────────────────────────────────────────────────── struct MockState { builders: std::collections::HashMap, + builder_pubkeys: std::collections::HashMap, pending_payments: Vec, slot: Slot, latest_block_hash: Hash32, expected_withdrawals: Vec, + ptc_pubkeys: Vec, } impl MockState { fn new(slot: Slot) -> Self { let mut builders = std::collections::HashMap::new(); builders.insert(1u64, 32_000_000_000u64); + let mut builder_pubkeys = std::collections::HashMap::new(); + builder_pubkeys.insert(1u64, test_pubkey()); + let ptc_pubkeys = vec![test_pubkey(); PTC_SIZE as usize]; Self { builders, + builder_pubkeys, pending_payments: vec![], slot, latest_block_hash: [0x01u8; 32], expected_withdrawals: vec![], + ptc_pubkeys, } } } @@ -37,6 +46,9 @@ impl BeaconStateMut for MockState { fn builder_balance(&self, index: BuilderIndex) -> Option { self.builders.get(&index).copied() } + fn builder_pubkey(&self, index: BuilderIndex) -> Option { + self.builder_pubkeys.get(&index).copied() + } fn deduct_builder_balance(&mut self, index: BuilderIndex, amount: Gwei) { if let Some(bal) = self.builders.get_mut(&index) { *bal -= amount; @@ -54,6 +66,9 @@ impl BeaconStateRead for MockState { fn get_ptc(&self, _slot: Slot) -> Vec { (0..PTC_SIZE as u64).collect() } + fn ptc_pubkeys(&self, _slot: Slot) -> Vec { + self.ptc_pubkeys.clone() + } fn parent_beacon_block_root(&self) -> [u8; 32] { [0xBBu8; 32] } } @@ -70,21 +85,24 @@ impl WithdrawalState for MockState { } fn make_valid_bid(slot: Slot) -> SignedExecutionPayloadBid { + let message = ExecutionPayloadBid { + parent_block_hash: [0x01u8; 32], + parent_block_root: [0x02u8; 32], + block_hash: [0xAAu8; 32], + prev_randao: [0u8; 32], + fee_recipient: [0xFEu8; 20], + gas_limit: 30_000_000, + builder_index: 1, + slot, + value: 1_000_000_000, + execution_payment: 0, + blob_kzg_commitments: vec![], + }; + let domain = ssz::compute_domain_simple(DOMAIN_BEACON_BUILDER); + let signing_root = ssz::signing_root(&message, domain); SignedExecutionPayloadBid { - message: ExecutionPayloadBid { - parent_block_hash: [0x01u8; 32], - parent_block_root: [0x02u8; 32], - block_hash: [0xAAu8; 32], - prev_randao: [0u8; 32], - fee_recipient: [0xFEu8; 20], - gas_limit: 30_000_000, - builder_index: 1, - slot, - value: 1_000_000_000, - execution_payment: 0, - blob_kzg_commitments: vec![], - }, - signature: [0u8; 96], + message, + signature: crypto::bls_sign(&test_secret_key(), &signing_root), } } @@ -188,15 +206,20 @@ fn withdrawal_cleared_after_valid_payload() { #[test] fn valid_ptc_attestation_accepted() { let state = MockState::new(100); + let aggregation_bits = vec![true; PTC_SIZE as usize]; + let data = PayloadAttestationData { + beacon_block_root: [0xBBu8; 32], + slot: 99, // parent slot + payload_present: true, + blob_data_available: true, + }; + let domain = ssz::compute_domain_simple(DOMAIN_PTC_ATTESTER); + let signing_root = ssz::signing_root(&data, domain); + let signature = aggregate_signature(&aggregation_bits, &signing_root); let att = PayloadAttestation { - aggregation_bits: vec![true; PTC_SIZE as usize], - data: PayloadAttestationData { - beacon_block_root: [0xBBu8; 32], - slot: 99, // parent slot - payload_present: true, - blob_data_available: true, - }, - signature: [0u8; 96], + aggregation_bits, + data, + signature, }; assert!(process_payload_attestation(&state, &att).is_ok()); } @@ -233,4 +256,24 @@ fn ptc_wrong_slot_rejected() { }; let err = process_payload_attestation(&state, &att).unwrap_err(); assert!(matches!(err, PayloadAttestationError::WrongSlot { .. })); -} \ No newline at end of file +} + +fn test_secret_key() -> SecretKey { + SecretKey::from_bytes(&[7u8; 32]).expect("valid secret key") +} + +fn test_pubkey() -> BLSPubkey { + test_secret_key().sk_to_pk().to_bytes() +} + +fn aggregate_signature(bits: &[bool], signing_root: &[u8]) -> BLSSignature { + let sk = test_secret_key(); + let mut agg = AggregateSignature::new(); + for bit in bits { + if *bit { + agg.add(&sk.sign(signing_root, crypto::ETH_DST, &[])) + .expect("aggregate add"); + } + } + agg.to_signature().to_bytes() +}