Skip to content

Networks and Forks

How Containment Chamber separates Ethereum networks and remains fork-agnostic through per-request fork_info

This page explains how Containment Chamber handles Ethereum network identity and fork versions.

Key insight: the remote signer does NOT need to know about specific forks.

Consensus signing requests carry fork_info:

{
"type": "ATTESTATION",
"fork_info": {
"fork": {
"previous_version": "0x04000000",
"current_version": "0x05000000",
"epoch": "0"
},
"genesis_validators_root": "0x4b363db94e286120d76eb905340fcd4e8a81b2147cfb21a2..."
},
"attestation": { ... }
}

This means:

  • The validator client handles fork transitions
  • The signer just uses the provided fork version
  • No code changes needed for new forks
  • Existing and future forks work as long as they use a signing request type and domain that Containment Chamber implements

The --network flag accepts exactly three values:

Network Genesis fork version Genesis validators root
Mainnet 0x00000000 0x4b363db94e286120d76eb905340fcd4e8a81b2147cfb21a2beec26d710dce35a
Sepolia 0x90000069 0xd8ea171f3c94aea21ebc42a1ed61052acf3f9209c00e4efbaaddac09ed9b8078
Hoodi 0x10000910 0x212f13fc4df078b6cb7db228f1c8307566dcecf900867401a92023d7ba99cb5f

Use the value that matches your validator client:

Terminal window
# Mainnet
containment-chamber server --network mainnet --key-sources-filesystem-paths ./keys
# Sepolia testnet
containment-chamber server --network sepolia --key-sources-filesystem-paths ./keys
# Hoodi testnet
containment-chamber server --network hoodi --key-sources-filesystem-paths ./keys

Networks are identified by genesis_validators_root (GVR). The signer validates the GVR in each fork_info against the configured --network. A mismatch returns HTTP 400 Bad Request and stops before key lookup, anti-slashing, or signing.

This is a safety guardrail. If your validator client is accidentally pointed at the wrong signer, or if a request carries the wrong network’s GVR, the signer refuses outright rather than producing a signature that would fail on-chain.

pub fn compute_domain(
domain_type: [u8; 4], // Operation type (attestation, block, etc.)
fork_version: [u8; 4], // From fork_info.fork.current_version
genesis_validators_root: Hash256, // From fork_info.genesis_validators_root
) -> [u8; 32]

The 32-byte domain is split into two regions:

  1. Domain type (4 bytes) — which operation (DOMAIN_BEACON_ATTESTER, DOMAIN_BEACON_PROPOSER, …)
  2. Fork data root (28 bytes) — the leading 28 bytes of tree_hash_root({current_version, genesis_validators_root}). Fork version and genesis root are SSZ-hashed together; they do not occupy independent byte ranges.

This means:

  • Same attestation data on different forks → different signatures
  • Same attestation data on different networks → different signatures
  • Cryptographic guarantee against cross-fork/cross-network replay

See BLS Signing Internals for the full domain computation.

// Mainnet Deneb
let domain_mainnet = compute_domain(
DOMAIN_BEACON_ATTESTER,
[0x04, 0x00, 0x00, 0x00], // Deneb
mainnet_genesis_root,
);
// Sepolia Deneb
let domain_sepolia = compute_domain(
DOMAIN_BEACON_ATTESTER,
[0x04, 0x00, 0x00, 0x00], // Also Deneb
sepolia_genesis_root, // Different!
);
// domain_mainnet != domain_sepolia
// Therefore: signing_root differs, signature differs
  • RANDAO_REVEAL
  • BLOCK_V2
  • ATTESTATION
  • AGGREGATION_SLOT
  • AGGREGATE_AND_PROOF
  • VOLUNTARY_EXIT
  • SYNC_COMMITTEE_MESSAGE
  • SYNC_COMMITTEE_SELECTION_PROOF
  • SYNC_COMMITTEE_CONTRIBUTION_AND_PROOF
  • VALIDATOR_REGISTRATION

The VALIDATOR_REGISTRATION operation is special:

// Always uses fork version [0,0,0,0]
// Always uses zero genesis_validators_root
// This makes it independent from consensus fork_info
let domain = compute_domain(
DOMAIN_APPLICATION_BUILDER,
[0u8; 4],
Hash256::zero(),
);

You don’t need to change the signer.

When a new Ethereum fork is released:

  1. Update your validator client (Lighthouse, Prysm, etc.)
  2. The validator client sends the new fork version in fork_info
  3. The signer uses it in domain computation after the GVR check
  4. Everything works

The only time you’d need to change the signer is if:

  • A new domain type is added (new DOMAIN_* constant)
  • A completely new signing flow is introduced

The test suite verifies the two important boundaries:

  • tests/signing_vectors.rs::test_different_domains_different_signatures checks that changing the domain changes the signing root and signature.
  • src/signer/ethereum/signer.rs::test_gvr_guard_wrong_gvr_returns_network_mismatch checks that a consensus signing request carrying the wrong GVR is rejected.
  • src/signer/ethereum/signer.rs::test_gvr_guard_validator_registration_bypasses checks that VALIDATOR_REGISTRATION bypasses the GVR guard because it has no fork_info.
  1. Set --network correctly: use mainnet, hoodi, or sepolia to match your validator client’s network. The signer rejects signing requests from mismatched networks with HTTP 400.
  2. Trust fork_info from the validator client: the request carries fork context — you don’t need to configure fork versions.
  3. No manual fork handling: don’t try to override fork versions.
  1. Don’t hardcode fork versions: always use values from fork_info
  2. Test with multiple fork versions: verify signatures differ
  3. Handle ValidatorRegistration specially: it uses genesis fork version

Possible causes:

  1. Fork version mismatch between validator client and consensus layer
  2. Wrong genesis_validators_root (testnet key on mainnet)
  3. Domain type mismatch

Check: log the fork_info from requests and compare with expected values.

The signer doesn’t validate fork versions — it just uses them. If you see this error from the consensus layer, update your validator client.

This is prevented by design. A signature created with mainnet’s genesis_validators_root will never verify on Sepolia, and vice versa. The domain computation ensures cryptographic separation.