FCR Implementation Divergence
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.
| 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.
| 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.
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.
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_discountterm. 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.