Skip to content

BLS Signing Internals

This page explains the cryptographic signing process Containment Chamber performs for Ethereum 2.0 validator operations.

Ethereum 2.0 uses BLS12-381 signatures for all validator operations. BLS (Boneh–Lynn–Shacham) signatures provide:

  • Aggregation: multiple signatures can be combined into one
  • Small size: 96 bytes per signature
  • Deterministic: same key + message = same signature

Every signature includes a domain to prevent replay attacks across different contexts:

// Domain type constants (first 4 bytes)
pub const DOMAIN_BEACON_PROPOSER: [u8; 4] = [0x00, 0x00, 0x00, 0x00];
pub const DOMAIN_BEACON_ATTESTER: [u8; 4] = [0x01, 0x00, 0x00, 0x00];
pub const DOMAIN_RANDAO: [u8; 4] = [0x02, 0x00, 0x00, 0x00];
pub const DOMAIN_VOLUNTARY_EXIT: [u8; 4] = [0x04, 0x00, 0x00, 0x00];
// ... and more

Domain computation includes:

  1. Domain type (4 bytes) — what operation this is
  2. Fork version (4 bytes) — which network/fork
  3. Genesis validators root (32 bytes) — which chain
pub fn compute_domain(
domain_type: [u8; 4],
fork_version: [u8; 4],
genesis_validators_root: Hash256,
) -> [u8; 32] {
let fork_data_root = compute_fork_data_root(fork_version, genesis_validators_root);
let mut domain = [0u8; 32];
domain[..4].copy_from_slice(&domain_type);
domain[4..].copy_from_slice(&fork_data_root.as_slice()[..28]);
domain
}

Why domains matter:

  • An attestation signature cannot be replayed as a block proposal
  • A mainnet signature cannot be replayed on testnet
  • A Phase 0 signature cannot be replayed after a fork

The fork data root combines fork version with chain identity:

#[derive(TreeHash)]
struct ForkData {
current_version: [u8; 4],
genesis_validators_root: Hash256,
}
pub fn compute_fork_data_root(
fork_version: [u8; 4],
genesis_validators_root: Hash256,
) -> Hash256 {
ForkData {
current_version: fork_version,
genesis_validators_root,
}.tree_hash_root()
}

The signing root combines the object being signed with the domain:

#[derive(TreeHash)]
pub struct SigningData {
pub object_root: Hash256,
pub domain: [u8; 32],
}
pub fn compute_signing_root<T: TreeHash>(object: &T, domain: [u8; 32]) -> Hash256 {
SigningData {
object_root: object.tree_hash_root(),
domain,
}.tree_hash_root()
}

The signing root is the actual 32-byte value that gets signed by BLS.

Finally, the BLS signature is computed:

let signature = keypair.sk.sign(signing_root);
// Returns 96-byte BLS signature
Diagram
ConstantValueOperation
DOMAIN_BEACON_PROPOSER0x00000000Block proposals
DOMAIN_BEACON_ATTESTER0x01000000Attestations
DOMAIN_RANDAO0x02000000RANDAO reveals
DOMAIN_DEPOSIT0x03000000Deposits
DOMAIN_VOLUNTARY_EXIT0x04000000Voluntary exits
DOMAIN_SELECTION_PROOF0x05000000Aggregator selection
DOMAIN_AGGREGATE_AND_PROOF0x06000000Aggregate attestations
DOMAIN_SYNC_COMMITTEE0x07000000Sync committee messages
DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF0x08000000Sync committee selection
DOMAIN_CONTRIBUTION_AND_PROOF0x09000000Sync committee contributions
DOMAIN_APPLICATION_BUILDER0x00000001Builder API (MEV)

Lighthouse types implement the SignedRoot trait, which provides a signing_root() method:

pub trait SignedRoot: TreeHash {
fn signing_root(&self, domain: Hash256) -> Hash256 {
SigningData {
object_root: self.tree_hash_root(),
domain,
}
.tree_hash_root()
}
}

Types implementing SignedRoot:

  • AttestationData
  • BeaconBlockHeader
  • VoluntaryExit
  • SyncAggregatorSelectionData
  • ValidatorRegistrationData
  • AggregateAndProof
  • ContributionAndProof
  • And more

The codebase and lighthouse_types both depend on tree_hash 0.12.1 (resolved through the Lighthouse v8.1.3 tag pinned in Cargo.toml). No version-bridging conversion is required — SignedRoot::signing_root() from lighthouse_types and the codebase’s own compute_signing_root interoperate directly.

SigningRequestOwned::RandaoReveal { randao_reveal, fork_info } => {
let domain = compute_domain(DOMAIN_RANDAO, fork_version, genesis_root);
let signing_root = compute_signing_root(&randao_reveal.epoch.as_u64(), domain);
keypair.sk.sign(signing_root)
}
SigningRequestOwned::Attestation { attestation, fork_info } => {
let domain = compute_domain(DOMAIN_BEACON_ATTESTER, fork_version, genesis_root);
let signing_root = attestation.data.signing_root(Hash256::from(domain));
keypair.sk.sign(signing_root)
}
SigningRequestOwned::BlockV2 { beacon_block, fork_info, .. } => {
let domain = compute_domain(DOMAIN_BEACON_PROPOSER, fork_version, genesis_root);
let signing_root = beacon_block.block_header.signing_root(Hash256::from(domain));
keypair.sk.sign(signing_root)
}
SigningRequestOwned::ValidatorRegistration { validator_registration, .. } => {
// Uses DOMAIN_APPLICATION_BUILDER with genesis fork version [0,0,0,0]
// and zero genesis_validators_root — fork-independent.
let domain = compute_domain(
DOMAIN_APPLICATION_BUILDER,
[0u8; 4], // Genesis fork version
Hash256::zero(), // Zero validators root
);
let signing_root = validator_registration
.registration
.signing_root(Hash256::from(domain));
keypair.sk.sign(signing_root)
}