Two consensus nodes can only work together if they follow the same chain under the same rules. A node on another network, or one that missed an upgrade, will build blocks and send messages its peers reject. So before a node spends bandwidth on a peer, it checks that they agree on two things:
- Which chain. This is fixed at genesis by the
genesis_validators_root. - Which rules. These change at every upgrade.
Both collapse into one 4-byte value, the fork digest. Every place where two nodes meet on the network carries it:
| Where | What the digest does |
|---|---|
ENR eth2 and nfd entries | Lets discovery skip peers on another chain or fork before connecting |
Gossip topic names, /eth2/<digest>/<name>/ssz_snappy | Keeps each fork’s messages on their own topics, so a node never receives a message type it cannot decode |
Req/Resp Status handshake | A peer with a different digest is on another fork and is dropped |
| Req/Resp response context bytes | Tells the receiver which fork’s SSZ type to decode each response chunk with |
A node computes the digest from its wall-clock epoch, not from the epoch it has synced to. That way every honest node switches digest at the same moment, whatever its sync state.
Fork version
A fork version is a 4-byte constant assigned by hand to each regular upgrade.
Mainnet uses 0x06000000 for Fulu, 0x07000000 for Gloas and 0x08000000 for
Heze. Testnets use their own versions, so the same upgrade never has the same
version on two networks.
compute_fork_version maps an epoch to the version in force at that epoch.
Each upgrade adds one branch at the top:
def compute_fork_version(epoch: Epoch) -> Version:
"""
Return the fork version at the given ``epoch``.
"""
if epoch >= HEZE_FORK_EPOCH:
return HEZE_FORK_VERSION
if epoch >= GLOAS_FORK_EPOCH:
return GLOAS_FORK_VERSION
if epoch >= FULU_FORK_EPOCH:
return FULU_FORK_VERSION
if epoch >= ELECTRA_FORK_EPOCH:
return ELECTRA_FORK_VERSION
if epoch >= DENEB_FORK_EPOCH:
return DENEB_FORK_VERSION
if epoch >= CAPELLA_FORK_EPOCH:
return CAPELLA_FORK_VERSION
if epoch >= BELLATRIX_FORK_EPOCH:
return BELLATRIX_FORK_VERSION
if epoch >= ALTAIR_FORK_EPOCH:
return ALTAIR_FORK_VERSION
return GENESIS_FORK_VERSIONBlob-parameter-only forks
Fusaka added a second kind of upgrade. A blob-parameter-only (BPO) fork changes only the maximum number of blobs per block. It gets no new fork version and ships no new code; clients read the new limit from a schedule in their configuration.
The network still has to treat it as a fork. A node that missed the new limit rejects blocks that use it, and its peers could not tell from the fork version alone. So the blob limit is folded into the digest (see Computing the digest), and peers on different limits see different digests.
The schedule lists the limit and the epoch it takes effect from:
[New in Fulu:EIP7892] This schedule defines the maximum blobs per block limit for a given epoch.
There MUST NOT exist multiple blob schedule entries with the same epoch value.
The epoch value in each entry MUST be greater than or equal to
FULU_FORK_EPOCH. The maximum blobs per block limit in each entry MUST be less
than or equal to MAX_BLOB_COMMITMENTS_PER_BLOCK. The blob schedule entries
SHOULD be sorted by epoch in ascending order. The blob schedule MAY be empty.
| Epoch | Max Blobs Per Block | Date |
|---|---|---|
| 412672 | 15 | December 9, 2025, 02:21:11pm UTC |
| 419072 | 21 | January 7, 2026, 01:01:11am UTC |
Each entry is read as a BlobParameters value:
get_blob_parameters returns the entry in force at an epoch: the latest one
whose epoch has passed. Before the first entry, it falls back to Electra’s
limit.
def get_blob_parameters(epoch: Epoch) -> BlobParameters:
"""
Return the blob parameters at a given epoch.
"""
for entry in sorted(BLOB_SCHEDULE, key=lambda e: e["EPOCH"], reverse=True):
if epoch >= entry["EPOCH"]:
return BlobParameters(entry["EPOCH"], entry["MAX_BLOBS_PER_BLOCK"])
return BlobParameters(ELECTRA_FORK_EPOCH, MAX_BLOBS_PER_BLOCK_ELECTRA)Computing the digest
The digest is built in two steps:
- Fork data root. Hash the fork version together with the
genesis_validators_root, ashash_tree_root(ForkData(version, root)). Mixing in the genesis root lets two chains reuse the same fork versions without colliding. Signature domains use the same root, so a signature made for one fork or chain is invalid on another. - Blob parameters. From Fulu onward, XOR that root with
sha256(epoch || max_blobs_per_block)of the blob parameters in force, then keep the first 4 bytes. Both values are 8-byte little-endian integers. Before Fulu, the digest was simply the first 4 bytes of the fork data root.
Note: The compute_fork_digest helper is updated to account for
Blob-Parameters-Only forks.
def compute_fork_digest(
genesis_validators_root: Root,
epoch: Epoch,
) -> ForkDigest:
"""
Return the 4-byte fork digest for the ``genesis_validators_root`` at a given ``epoch``.
This is a digest primarily used for domain separation on the p2p layer.
4-bytes suffices for practical separation of forks/chains.
"""
fork_version = compute_fork_version(epoch)
base_digest = compute_fork_data_root(fork_version, genesis_validators_root)
# [New in Fulu:EIP7892]
if epoch < FULU_FORK_EPOCH:
return ForkDigest(base_digest[:4])
# [Modified in Fulu:EIP7892]
# Bitmask digest with hash of blob parameters
blob_parameters = get_blob_parameters(epoch)
return ForkDigest(
xor(
base_digest,
sha256(
uint_to_bytes(Uint64(blob_parameters.epoch))
+ uint_to_bytes(Uint64(blob_parameters.max_blobs_per_block))
),
)[:4]
)Worked example: mainnet
Mainnet’s genesis_validators_root is
0x4b363db94e286120d76eb905340fdd4e54bfe9f06bf33ff6cf5ad27f511bfe95. Applying
the function above gives:
| Epoch | Upgrade | Fork version | Blob parameters | Digest |
|---|---|---|---|---|
| 364032 | Electra | 0x05000000 | n/a | ad532ceb |
| 411392 | Fulu | 0x06000000 | (364032, 9), Electra’s fallback | cc2c5cdb |
| 412672 | BPO 1 | 0x06000000 | (412672, 15) | cb0d1acc |
| 419072 | BPO 2 | 0x06000000 | (419072, 21) | 8c9f62fe |
The fork version stays 0x06000000 across Fulu and both BPO forks, but the
digest changes three times.
Crossing a fork boundary
A node learns about an upcoming fork, regular or BPO, from its peers’ ENRs. The
eth2 entry carries the current digest, next_fork_version and
next_fork_epoch. A BPO fork moves next_fork_epoch but leaves
next_fork_version unchanged, so from Fulu onward the ENR also carries nfd:
the digest the node will use after its next scheduled fork.
If a peer’s nfd differs from ours, it plans to follow a different fork from
the boundary on. The node must keep the connection until the boundary and may
drop it at or after it. The exact rules are on
Consensus discovery.
Gossip topics change name at every boundary, because the digest is part of the name. A node subscribes to the new topics ahead of the boundary. After it, the node still accepts messages on the old topics and unsubscribes from them two epochs later. The old and new topics share one duplicate cache, and a message is never forwarded from one to the other. See Gossip.