Skip to content

BLS Signing Internals

Domain separation, fork data root, signing root computation, and per-operation signing logic in Containment Chamber

This page explains the cryptographic signing process Containment Chamber performs for Ethereum consensus-layer validator operations.

Ethereum consensus-layer validator operations use BLS12-381 signatures. BLS (Boneh–Lynn–Shacham) signatures provide:

  • Aggregation: multiple signatures can be combined into one
  • Small size: 96 bytes per signature
  • Deterministic: same key + signing root = 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 inputs are:

  1. Domain type (4 bytes) — what operation this is
  2. Fork version (4 bytes) — the fork context supplied by fork_info.fork.current_version
  3. Genesis validators root (32 bytes) — the chain identity supplied by fork_info.genesis_validators_root
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.

Before signing, the HTTP path:

  1. Verifies authorization for the validator public key and signing operation
  2. Checks the signer is unsealed and the state watcher is fresh for stateful deployments
  3. Rejects mismatched genesis_validators_root values before anti-slashing
  4. Looks up the requested validator key
  5. Records the operation in the anti-slashing backend

Only after those checks pass is the BLS signature computed:

let signature = keypair.sk.sign(signing_root);
// Returns 96-byte BLS signature
direction: down
req: "Signing Request\ntype: ATTESTATION\ndata: {...}\nfork_info: {...}"
extract: "1. Extract Fork Info\nfork_version = fork_info.fork.current_version\ngenesis_root = fork_info.genesis_validators_root"
domain: "2. Compute Domain\ncompute_domain(\n DOMAIN_BEACON_ATTESTER (0x01000000),\n fork_version,\n genesis_root\n)"
root: "3. Compute Signing Root\nSignedRoot::signing_root(domain)\nor compute_signing_root(&value, domain)"
safety: "4. Safety Checks\nnetwork GVR guard\nauth + key lookup\nanti-slashing check"
sign: "5. BLS Sign\nkeypair.sk.sign(signing_root)\n96-byte BLS12-381 signature"
resp: "6. Return Response\n{ signature: 0x... }\nhex-encoded 96 bytes"
req.style.fill: "#FFF6EF"
req.style.stroke: "#D35F0A"
req.style.font-color: "#170206"
extract.style.fill: "#FEEC8C"
extract.style.stroke: "#D35F0A"
extract.style.font-color: "#170206"
domain.style.fill: "#FEEC8C"
domain.style.stroke: "#D35F0A"
domain.style.font-color: "#170206"
root.style.fill: "#FEEC8C"
root.style.stroke: "#D35F0A"
root.style.font-color: "#170206"
safety.style.fill: "#FEE2E2"
safety.style.stroke: "#DC2626"
safety.style.font-color: "#170206"
sign.style.fill: "#CAF2E6"
sign.style.stroke: "#13A477"
sign.style.font-color: "#170206"
resp.style.fill: "#CAF2E6"
resp.style.stroke: "#13A477"
resp.style.font-color: "#170206"
req -> extract
extract -> domain
domain -> root
root -> safety
safety -> sign
sign -> resp
Constant Value Operation
DOMAIN_BEACON_PROPOSER 0x00000000 Block proposals
DOMAIN_BEACON_ATTESTER 0x01000000 Attestations
DOMAIN_RANDAO 0x02000000 RANDAO reveals
DOMAIN_DEPOSIT 0x03000000 Deposit data and credentials transfer, not an HTTP signing request
DOMAIN_VOLUNTARY_EXIT 0x04000000 Voluntary exits
DOMAIN_SELECTION_PROOF 0x05000000 Aggregator selection
DOMAIN_AGGREGATE_AND_PROOF 0x06000000 Aggregate attestations
DOMAIN_SYNC_COMMITTEE 0x07000000 Sync committee messages
DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF 0x08000000 Sync committee selection
DOMAIN_CONTRIBUTION_AND_PROOF 0x09000000 Sync committee contributions
DOMAIN_APPLICATION_BUILDER 0x00000001 Builder 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

Containment Chamber uses SignedRoot for attestation data, block headers, exits, sync aggregator selection data, validator registrations, aggregates, and sync contributions. It uses compute_signing_root() directly for scalar values such as RANDAO epochs, aggregation slots, and sync committee block roots.

The codebase and lighthouse_types both depend on the same tree_hash 0.12.x line (resolved through the Lighthouse v8.1.3 tag pinned in Cargo.toml; see Cargo.lock for the exact patch version). No version-bridging conversion is required — SignedRoot::signing_root() from lighthouse_types and the codebase’s own compute_signing_root interoperate directly.

SigningRequest::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);
anti_slashing.check_and_update(pubkey, generic("RANDAO")).await?;
keypair.sk.sign(signing_root)
}
SigningRequest::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));
anti_slashing.check_and_update(pubkey, attestation_watermark).await?;
keypair.sk.sign(signing_root)
}
SigningRequest::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));
anti_slashing.check_and_update(pubkey, block_watermark).await?;
keypair.sk.sign(signing_root)
}
SigningRequest::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));
anti_slashing.check_and_update(pubkey, generic("VALIDATOR_REGISTRATION")).await?;
keypair.sk.sign(signing_root)
}