Skip to main content
netspecsEthereum networking

1.3.Fork identification

Stage
In progress
Version
Pulled
consensus-specsp2p-interface.mdconsensus-specsp2p-interface.mdconsensus-specsp2p-interface.mdconsensus-specsbeacon-chain.md
Related
Consensus discovery

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:

WhereWhat the digest does
ENR eth2 and nfd entriesLets discovery skip peers on another chain or fork before connecting
Gossip topic names, /eth2/<digest>/<name>/ssz_snappyKeeps each fork’s messages on their own topics, so a node never receives a message type it cannot decode
Req/Resp Status handshakeA peer with a different digest is on another fork and is dropped
Req/Resp response context bytesTells 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_VERSION

Blob-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.

EpochMax Blobs Per BlockDate
41267215December 9, 2025, 02:21:11pm UTC
41907221January 7, 2026, 01:01:11am UTC

Each entry is read as a BlobParameters value:

@dataclass
class BlobParameters:
    epoch: Epoch
    max_blobs_per_block: Uint64

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:

  1. Fork data root. Hash the fork version together with the genesis_validators_root, as hash_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.
  2. 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:

EpochUpgradeFork versionBlob parametersDigest
364032Electra0x05000000n/aad532ceb
411392Fulu0x06000000(364032, 9), Electra’s fallbackcc2c5cdb
412672BPO 10x06000000(412672, 15)cb0d1acc
419072BPO 20x06000000(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.