FCR Implementation Divergence

Networks:
Ethereum Mainnet
Time range:
Start 2025-12-06T00:00:00Z
End 2026-01-02T23:59:59Z
consensus fast-confirmation fork-choice lighthouse teku
Published May 14, 2026

Question

Two independent implementations of consensus-specs PR #4747 (Fast Confirmation Rule) disagree on 1.2% of mainnet slots over the same epoch range. Where does the divergence come from, and which one matches the spec?

Background

The Fast Confirmation Rule (FCR) is a proposed addition to the consensus-specs (PR #4747) that lets a node locally confirm a block within seconds of the attestation deadline, well ahead of FFG finality.

Two implementations of the rule now exist: Lighthouse (sigp/lighthouse#8951, tracking PR #4747 from spec) and a Teku branch. We ran our Lighthouse-based simulator (ethpandaops/fcr-simulator) across epochs 412000–418139 (195,190 slots) and compared its output against the Teku branch's replay logs over the same range. Teku reports a 96.96% confirmation rate on that range; Lighthouse reports 95.81%. This page traces the 1.15 pp gap.

Investigation

When counting agreements and disagreements

The gap is one-sided. Almost all of it comes from slots Teku confirms that Lighthouse does not.

Loading...
Category Slots Share
Both confirmed 186,874 95.74%
Both unconfirmed 5,785 2.96%
Teku confirmed, Lighthouse did not 2,388 1.22%
Lighthouse confirmed, Teku did not 143 0.07%
Total 195,190 100%

The substantive gap is the 2,388 slots Teku confirms and Lighthouse does not. That bucket is what we are going to investigate here. The 143 reverse-direction slots (Lighthouse confirms, Teku does not) are a separate, smaller asymmetry that we are not investigating in this page.

When comparing the two attestation data sources

The two replays feed FCR from different sources. Our Lighthouse simulator pulls attestations from the next future canonical block after each slot, on the assumption that the proposer included the aggregates that were observable at proposal time. That subset is capped per Electra at MAX_ATTESTATIONS_ELECTRA = 8 aggregates per block. The Teku branch reads aggregates from xatu's libp2p_gossipsub_aggregate_and_proof table filtered to 5 sentry clients, which is the gossip-pool view: every aggregate that reached at least one of those sentries, whether or not any block ever included it.

The 1.15 pp could come from either implementation logic, data source, or both. We sampled 299 random slots from the disagreement window (epochs 412000-418139) and counted distinct validators voting for the canonical head from each source. Block side uses uniqExact(arrayJoin(validators)) over canonical_beacon_elaborated_attestation. Gossip side takes the per-committee max popcount of aggregation_bits, summed across committees and minus one terminator per committee, restricted to the 5 sentries.

Loading...
Source Mean Median P25 P75
Block-included 30,490 30,779 30,608 30,904
Gossip-pool (5 sentries) 30,414 30,709 30,543 30,834
block − gossip +76 +65 +64 +67

The two views are essentially the same. Mean per-slot delta is +76 validators (~0.25% of the ~30,000-validator slot committee), and block-included sees more voters than gossip in all 299 sampled slots (max +767, min +64). The 0.25% shift is well inside the noise of the safety threshold, and it tilts toward more support on the Lighthouse side, not less. The 1.15 pp does not live in the data source. It lives in the implementation logic.

Loading...

When isolating a single disagreement

We picked slot 13,184,078 as a representative case. The block at slot 78 (0xe9c236...) arrived late: at slot 78's attestation deadline, only 14,729 of ~31,000 validators in slot 78's committee had seen it. The other 16,194 voted for the parent (0xd58f96..., slot 77's canonical block).

The disagreement actually appears one slot later, at slot 13,184,079 (Teku confirmed, Lighthouse did not). The block at slot 79 (0xbadf6ba0) had 30,823 distinct validators voting for it, about 99% by count. Plenty of weight.

Why didn't Lighthouse confirm? FCR's find_latest_confirmed_descendant walks ancestors and breaks on the first failure. To confirm slot 79, the chain check has to independently pass slot 78. The suffix-sum scoring (get_attestation_score) means slot 79's voters count toward slot 78's score as well: 14,729 + 30,823 = 45,552 distinct validators with current_root in slot 78's subtree.

We computed exact effective balances from the BN at slot 13,184,080's state for all 45,546 of those distinct voters. Total support for slot 78 in the 2-slot evaluation window: 1,656,717 ETH. The Teku log for the same slot reports support of 1,654,605 ETH. We have more support than Teku does, yet Lighthouse still fails the threshold and Teku still passes it. The gap can't be on the support side. It has to be in the threshold itself.

When inspecting the threshold

We ran the Lighthouse engine with RUST_LOG=beacon_chain::fast_confirmation=debug against a targeted 1-epoch slice and captured the exact is_one_confirmed_with_score numbers, then put them next to the Teku logs for the same slot:

Field Lighthouse Teku (log)
support 1,660,301 ETH 1,654,605 ETH
max_support 2,227,064 ETH 2,227,064 ETH
proposer_score 445,412 ETH 445,412 ETH
adversarial_weight (2-slot) 556,766 ETH 556,766 ETH
support_discount 0 ETH 544,432 ETH
safety_threshold 1,893,004 ETH 1,620,788 ETH
Verdict FAIL by 233k ETH PASS by 34k ETH

Every component agrees except support_discount. Teku subtracts 544,432 ETH from its threshold; Lighthouse subtracts zero. The size of that delta is not a coincidence: it equals the weight of the 16,194 validators that voted for the parent at slot 78's deadline.

Loading...

Both implementations agree on the components going into the threshold (proposer_score + adversarial_weight). They disagree on the discount coming out, and that disagreement moves the threshold by 272k ETH. The support values are within 5,696 ETH of each other; what changes the verdict is which side of the threshold support lands on.

When reading the spec

The spec defines compute_empty_slot_support_discount (fast-confirmation.md on mkalinin:fast-conf-rule):

if parent_block.slot + 1 == block.slot:
    return Gwei(0)

Lighthouse matches verbatim (lighthouse/beacon_node/beacon_chain/src/fast_confirmation.rs:1064):

if parent_slot.saturating_add(1u64) == block_slot {
    return Ok(0);
}

For slot 78 with parent at slot 77, 77 + 1 == 78, so the spec function returns 0. There is no empty-slot discount to apply, because there is no empty slot. Lighthouse is doing exactly what the spec says.

The Teku branch (ConfirmationRuleUtil.java, getSupportDiscount) does something different:

UInt64 emptySlotSupport = computeEmptySlotSupportDiscount(...);   // spec-compliant, = 0
UInt64 parentBlockSupport =                                       // non-spec addition
    getBlockSupportInSlots(store, balanceSource, parentRoot, blockSlot, blockSlot);
return emptySlotSupport.plus(parentBlockSupport);

Teku adds a second term: parentBlockSupport, evaluated at the block's own slot (blockSlot, blockSlot). The spec's get_support_discount has no such term. For our example slot, this term returns the weight of the 16,194 parent-voters, which matches the 544,432 ETH delta we observed exactly. The same shape holds on the rest of the 2,388 disagreement slots: every time Teku confirms and Lighthouse does not, it is because Teku's larger discount pulls the safety threshold below support.

Takeaways

  • The 1.15 pp gap between Teku (96.96%) and Lighthouse (95.81%) on the same 195,190-slot range is driven by the implementation logic, not the data source. Direct comparison on the disagreement window shows the two sources agree to within 0.25% of committee, and the small remaining bias is toward more block-included voters, not fewer.
  • The implementation gap is Teku's non-spec support_discount term. Lighthouse matches PR #4747 verbatim; the Teku branch adds a same-slot parent-vote term that the spec does not define. Across the 2,388 disagreement slots, every "Teku-yes, Lighthouse-no" is explained by Teku's larger discount pulling the safety threshold below support.