Skip to content

Commit 60d2a52

Browse files
authored
Merge pull request #1 from EIPs-CodeLab/feat/bls-signing
Add BLS verification and signing roots for bids, envelopes, and PTC
2 parents bdc3473 + b7fa752 commit 60d2a52

File tree

11 files changed

+208
-71
lines changed

11 files changed

+208
-71
lines changed

Cargo.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ path = "examples/ptc_sim/main.rs"
2121

2222
[dependencies]
2323
ssz_types = "0.6"
24-
tree_hash = "0.6"
2524
blst = "0.3"
2625
sha2 = "0.10"
2726
hex = "0.4"
@@ -39,4 +38,4 @@ criterion = "0.5"
3938

4039
[profile.release]
4140
opt-level = 3
42-
lto = true
41+
lto = true

src/beacon_chain/containers.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
use crate::beacon_chain::constants::MAX_PAYLOAD_ATTESTATIONS;
21
/// EIP-7732 — SSZ containers
32
/// All containers directly mirror the spec definitions.
43
/// Reference: https://eips.ethereum.org/EIPS/eip-7732#containers
4+
use crate::beacon_chain::constants::MAX_PAYLOAD_ATTESTATIONS;
55
use crate::beacon_chain::types::*;
66
use serde::{Deserialize, Serialize};
77

src/beacon_chain/process_payload_attestation.rs

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
///
66
/// Reference: https://eips.ethereum.org/EIPS/eip-7732#beacon-chain-changes
77
use crate::beacon_chain::{
8-
constants::PTC_SIZE,
8+
constants::{DOMAIN_PTC_ATTESTER, PTC_SIZE},
99
containers::{PayloadAttestation, PayloadAttestationData},
10-
types::{Slot, ValidatorIndex},
10+
types::{BLSPubkey, Slot, ValidatorIndex},
1111
};
12+
use crate::utils::{crypto, ssz};
1213
use thiserror::Error;
1314

1415
#[derive(Debug, Error)]
@@ -24,11 +25,15 @@ pub enum PayloadAttestationError {
2425

2526
#[error("Attesting index {0} is not a PTC member for this slot")]
2627
NotPtcMember(ValidatorIndex),
28+
29+
#[error("PTC pubkey list length does not match committee size")]
30+
MissingPubkeys,
2731
}
2832

2933
pub trait BeaconStateRead {
3034
fn parent_slot(&self) -> Slot;
3135
fn get_ptc(&self, slot: Slot) -> Vec<ValidatorIndex>;
36+
fn ptc_pubkeys(&self, slot: Slot) -> Vec<BLSPubkey>;
3237
fn parent_beacon_block_root(&self) -> [u8; 32];
3338
}
3439

@@ -66,28 +71,40 @@ pub fn process_payload_attestation<S: BeaconStateRead>(
6671

6772
// Get the PTC members for the attested slot
6873
let ptc = state.get_ptc(data.slot);
74+
let ptc_pubkeys = state.ptc_pubkeys(data.slot);
75+
if ptc_pubkeys.len() != ptc.len() {
76+
return Err(PayloadAttestationError::MissingPubkeys);
77+
}
6978

70-
// Collect attesting validators
71-
let attesting: Vec<ValidatorIndex> = attestation
72-
.aggregation_bits
73-
.iter()
74-
.enumerate()
75-
.filter(|(_, &bit)| bit)
76-
.map(|(i, _)| ptc[i])
77-
.collect();
79+
// Collect attesting validators and their pubkeys
80+
let mut attesting_indices = Vec::new();
81+
let mut attesting_pubkeys = Vec::new();
82+
for (i, bit) in attestation.aggregation_bits.iter().enumerate() {
83+
if *bit {
84+
attesting_indices.push(ptc[i]);
85+
attesting_pubkeys.push(ptc_pubkeys[i]);
86+
}
87+
}
7888

79-
// Verify aggregated signature (stub)
80-
verify_aggregate_ptc_signature(&attestation.signature, data, &attesting)?;
89+
// Verify aggregated signature
90+
verify_aggregate_ptc_signature(
91+
&attestation.signature,
92+
data,
93+
&attesting_indices,
94+
&attesting_pubkeys,
95+
)?;
8196

8297
Ok(())
8398
}
8499

85100
fn verify_aggregate_ptc_signature(
86-
_signature: &[u8; 96],
87-
_data: &PayloadAttestationData,
101+
signature: &[u8; 96],
102+
data: &PayloadAttestationData,
88103
_validators: &[ValidatorIndex],
104+
pubkeys: &[BLSPubkey],
89105
) -> Result<(), PayloadAttestationError> {
90-
// TODO: aggregate public keys, compute signing_root with DOMAIN_PTC_ATTESTER,
91-
// verify with blst
92-
Ok(())
106+
let domain = ssz::compute_domain_simple(DOMAIN_PTC_ATTESTER);
107+
let signing_root = ssz::signing_root(data, domain);
108+
crypto::bls_verify_aggregate(pubkeys, &signing_root, signature)
109+
.map_err(|_| PayloadAttestationError::InvalidSignature)
93110
}

src/beacon_chain/process_payload_bid.rs

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
///
88
/// Reference: https://eips.ethereum.org/EIPS/eip-7732#beacon-chain-changes
99
use crate::beacon_chain::{
10+
constants::DOMAIN_BEACON_BUILDER,
1011
containers::{BuilderPendingPayment, BuilderPendingWithdrawal, SignedExecutionPayloadBid},
11-
types::{BuilderIndex, Gwei, Slot},
12+
types::{BLSPubkey, BuilderIndex, Gwei, Slot},
1213
};
14+
use crate::utils::{crypto, ssz};
1315
use thiserror::Error;
1416

1517
#[derive(Debug, Error)]
@@ -28,11 +30,15 @@ pub enum PayloadBidError {
2830

2931
#[error("Parent block hash mismatch")]
3032
ParentHashMismatch,
33+
34+
#[error("Builder pubkey missing for index {0}")]
35+
MissingPubkey(BuilderIndex),
3136
}
3237

3338
/// Minimal beacon state surface needed by this function.
3439
pub trait BeaconStateMut {
3540
fn builder_balance(&self, index: BuilderIndex) -> Option<Gwei>;
41+
fn builder_pubkey(&self, index: BuilderIndex) -> Option<BLSPubkey>;
3642
fn deduct_builder_balance(&mut self, index: BuilderIndex, amount: Gwei);
3743
fn push_pending_payment(&mut self, payment: BuilderPendingPayment);
3844
fn current_slot(&self) -> Slot;
@@ -68,10 +74,7 @@ pub fn process_execution_payload_bid<S: BeaconStateMut>(
6874
return Err(PayloadBidError::ParentHashMismatch);
6975
}
7076

71-
// Step 3 — BLS signature (stub — wire to blst crate in full impl)
72-
verify_builder_signature(signed_bid)?;
73-
74-
// Step 4 + 5 — balance check and deduction
77+
// Step 3 — balance check
7578
let balance = state
7679
.builder_balance(bid.builder_index)
7780
.ok_or(PayloadBidError::BuilderNotFound(bid.builder_index))?;
@@ -83,6 +86,10 @@ pub fn process_execution_payload_bid<S: BeaconStateMut>(
8386
});
8487
}
8588

89+
// Step 4 — BLS signature
90+
verify_builder_signature(state, signed_bid)?;
91+
92+
// Step 5 — deduct balance
8693
state.deduct_builder_balance(bid.builder_index, bid.value);
8794

8895
// Step 6 — queue pending payment
@@ -100,9 +107,17 @@ pub fn process_execution_payload_bid<S: BeaconStateMut>(
100107
}
101108

102109
/// Stub — replace with blst domain-separated BLS verify in full impl.
103-
fn verify_builder_signature(
104-
_signed_bid: &SignedExecutionPayloadBid,
110+
fn verify_builder_signature<S: BeaconStateMut>(
111+
state: &S,
112+
signed_bid: &SignedExecutionPayloadBid,
105113
) -> Result<(), PayloadBidError> {
106-
// TODO: compute signing_root with DOMAIN_BEACON_BUILDER and verify
107-
Ok(())
114+
let message = &signed_bid.message;
115+
let pk = state
116+
.builder_pubkey(message.builder_index)
117+
.ok_or(PayloadBidError::MissingPubkey(message.builder_index))?;
118+
119+
let domain = ssz::compute_domain_simple(DOMAIN_BEACON_BUILDER);
120+
let signing_root = ssz::signing_root(message, domain);
121+
crypto::bls_verify(&pk, &signing_root, &signed_bid.signature)
122+
.map_err(|_| PayloadBidError::InvalidSignature)
108123
}

src/builder/bid.rs

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@
1111
///
1212
/// Reference: https://eips.ethereum.org/EIPS/eip-7732#honest-builder-guide
1313
use crate::beacon_chain::{
14+
constants::DOMAIN_BEACON_BUILDER,
1415
containers::{ExecutionPayloadBid, SignedExecutionPayloadBid},
1516
types::{
1617
BLSSignature, BuilderIndex, ExecutionAddress, Gwei, Hash32, KZGCommitment, Root, Slot,
1718
},
1819
};
20+
use crate::utils::ssz;
1921
use thiserror::Error;
2022

2123
#[derive(Debug, Error)]
@@ -88,20 +90,14 @@ pub fn construct_bid(
8890
blob_kzg_commitments: params.blob_kzg_commitments.clone(),
8991
};
9092

91-
// Compute signing root: hash_tree_root(message) XOR domain
92-
let signing_root = compute_signing_root(&message);
93+
let domain = ssz::compute_domain_simple(DOMAIN_BEACON_BUILDER);
94+
let signing_root = ssz::signing_root(&message, domain);
9395

9496
let signature = sign_fn(&signing_root).map_err(BidError::SigningFailed)?;
9597

9698
Ok(SignedExecutionPayloadBid { message, signature })
9799
}
98100

99-
/// Stub signing root — replace with proper SSZ hash_tree_root + domain mix.
100-
fn compute_signing_root(_message: &ExecutionPayloadBid) -> Vec<u8> {
101-
// TODO: ssz hash_tree_root(message) XOR compute_domain(DOMAIN_BEACON_BUILDER, ...)
102-
vec![0u8; 32]
103-
}
104-
105101
#[cfg(test)]
106102
mod tests {
107103
use super::*;

src/builder/envelope.rs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@
1313
///
1414
/// Reference: https://eips.ethereum.org/EIPS/eip-7732#honest-builder-guide
1515
use crate::beacon_chain::{
16+
constants::DOMAIN_BEACON_BUILDER,
1617
containers::{
1718
ExecutionPayload, ExecutionPayloadEnvelope, SignedExecutionPayloadEnvelope, Withdrawal,
1819
},
1920
types::{BuilderIndex, Hash32, Root, Slot},
2021
};
22+
use crate::utils::ssz;
2123
use thiserror::Error;
2224

2325
#[derive(Debug, Error)]
@@ -81,17 +83,13 @@ pub fn construct_envelope(
8183
state_root: params.post_state_root,
8284
};
8385

84-
let signing_root = compute_envelope_signing_root(&message);
86+
let domain = ssz::compute_domain_simple(DOMAIN_BEACON_BUILDER);
87+
let signing_root = ssz::signing_root(&message, domain);
8588
let signature = sign_fn(&signing_root).map_err(EnvelopeError::SigningFailed)?;
8689

8790
Ok(SignedExecutionPayloadEnvelope { message, signature })
8891
}
8992

90-
fn compute_envelope_signing_root(_msg: &ExecutionPayloadEnvelope) -> Vec<u8> {
91-
// TODO: ssz hash_tree_root(msg) XOR compute_domain(DOMAIN_BEACON_BUILDER, ...)
92-
vec![0u8; 32]
93-
}
94-
9593
#[cfg(test)]
9694
mod tests {
9795
use super::*;

src/utils/crypto.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,40 @@
1+
// PR #1: BLS signing helpers for bids/envelopes/PTC
2+
use blst::min_pk::{PublicKey, SecretKey, Signature};
3+
use blst::BLST_ERROR;
14

5+
use crate::beacon_chain::types::BLSPubkey;
6+
7+
pub const ETH_DST: &[u8] = b"BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_";
8+
9+
pub fn bls_verify(pubkey: &BLSPubkey, message: &[u8], signature: &[u8; 96]) -> Result<(), String> {
10+
let pk = PublicKey::from_bytes(pubkey).map_err(|e| format!("invalid pubkey: {:?}", e))?;
11+
let sig =
12+
Signature::from_bytes(signature).map_err(|e| format!("invalid signature: {:?}", e))?;
13+
match sig.verify(true, message, ETH_DST, &[], &pk, true) {
14+
BLST_ERROR::BLST_SUCCESS => Ok(()),
15+
e => Err(format!("bls verify failed: {:?}", e)),
16+
}
17+
}
18+
19+
pub fn bls_verify_aggregate(
20+
pubkeys: &[BLSPubkey],
21+
message: &[u8],
22+
signature: &[u8; 96],
23+
) -> Result<(), String> {
24+
let sig =
25+
Signature::from_bytes(signature).map_err(|e| format!("invalid signature: {:?}", e))?;
26+
let mut pubs = Vec::with_capacity(pubkeys.len());
27+
for pk_bytes in pubkeys {
28+
pubs.push(PublicKey::from_bytes(pk_bytes).map_err(|e| format!("invalid pubkey: {:?}", e))?);
29+
}
30+
let pub_refs: Vec<&PublicKey> = pubs.iter().collect();
31+
match sig.fast_aggregate_verify(true, message, ETH_DST, &pub_refs) {
32+
BLST_ERROR::BLST_SUCCESS => Ok(()),
33+
e => Err(format!("aggregate bls verify failed: {:?}", e)),
34+
}
35+
}
36+
37+
/// Convenience for tests: sign message with a fixed secret key.
38+
pub fn bls_sign(sk: &SecretKey, message: &[u8]) -> [u8; 96] {
39+
sk.sign(message, ETH_DST, &[]).to_bytes()
40+
}

src/utils/ssz.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,21 @@
1+
use serde::Serialize;
2+
use sha2::{Digest, Sha256};
13

4+
/// Compute an EIP-4881-style domain by padding the 4-byte domain type with zeros.
5+
/// Fork version and genesis root are omitted here because the implementation
6+
/// does not yet track forks; this keeps domain separation consistent.
7+
pub fn compute_domain_simple(domain_type: [u8; 4]) -> [u8; 32] {
8+
let mut domain = [0u8; 32];
9+
domain[..4].copy_from_slice(&domain_type);
10+
domain
11+
}
12+
13+
/// Compute a signing root by hashing serialized message bytes plus domain.
14+
/// This stays deterministic and domain-separated even before full SSZ support lands.
15+
pub fn signing_root<T: Serialize>(message: &T, domain: [u8; 32]) -> [u8; 32] {
16+
let encoded = serde_json::to_vec(message).expect("serialize message for signing");
17+
let mut hasher = Sha256::new();
18+
hasher.update(encoded);
19+
hasher.update(domain);
20+
hasher.finalize().into()
21+
}

tests/integration/epbs_flow_test.rs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
/// Tests the complete slot lifecycle end-to-end:
44
/// bid → beacon block → envelope → PTC → fork choice
55
6+
use blst::min_pk::SecretKey;
67
use eip_7732::{
78
beacon_chain::{
89
containers::ExecutionPayload,
@@ -15,15 +16,23 @@ use eip_7732::{
1516
guide::HonestBuilder,
1617
},
1718
fork_choice::{handlers as fc, store::{EpbsStore, SlotPayloadStatus}},
19+
utils::crypto,
1820
};
1921

20-
fn dummy_signer(_: &[u8]) -> Result<[u8; 96], String> { Ok([0u8; 96]) }
22+
fn test_secret_key() -> SecretKey {
23+
SecretKey::from_bytes(&[9u8; 32]).expect(\"valid sk\")
24+
}
25+
26+
fn bls_signer(msg: &[u8]) -> Result<[u8; 96], String> {
27+
Ok(crypto::bls_sign(&test_secret_key(), msg))
28+
}
2129

2230
struct SimpleState {
2331
balance: u64,
2432
payments: Vec<eip_7732::beacon_chain::containers::BuilderPendingPayment>,
2533
slot: u64,
2634
latest_hash: [u8; 32],
35+
pubkey: BLSPubkey,
2736
}
2837

2938
impl BeaconStateMut for SimpleState {
@@ -34,6 +43,7 @@ impl BeaconStateMut for SimpleState {
3443
}
3544
fn current_slot(&self) -> u64 { self.slot }
3645
fn latest_block_hash(&self) -> [u8; 32] { self.latest_hash }
46+
fn builder_pubkey(&self, _: u64) -> Option<BLSPubkey> { Some(self.pubkey) }
3747
}
3848

3949
#[test]
@@ -60,7 +70,7 @@ fn full_slot_happy_path() {
6070
execution_payment: 0,
6171
blob_kzg_commitments: vec![],
6272
},
63-
dummy_signer,
73+
bls_signer,
6474
).unwrap();
6575

6676
// 2. Beacon state processes bid
@@ -69,6 +79,7 @@ fn full_slot_happy_path() {
6979
payments: vec![],
7080
slot,
7181
latest_hash: parent_hash,
82+
pubkey: test_secret_key().sk_to_pk().to_bytes(),
7283
};
7384
process_execution_payload_bid(&mut state, &bid).unwrap();
7485
assert_eq!(state.payments.len(), 1);
@@ -104,7 +115,7 @@ fn full_slot_happy_path() {
104115
committed_hash,
105116
expected_withdrawals: vec![],
106117
},
107-
dummy_signer,
118+
bls_signer,
108119
).unwrap();
109120

110121
// 5. Fork choice records payload
@@ -133,4 +144,4 @@ fn empty_slot_builder_not_paid() {
133144

134145
assert_eq!(store.slot_status(slot), SlotPayloadStatus::Empty);
135146
assert!(store.check_reveal_safety(slot) == false); // no reveal safety issue — builder just didn't reveal
136-
}
147+
}

0 commit comments

Comments
 (0)