Implement fast confirmation rule - #17122
Conversation
3e840d2 to
4e64fd7
Compare
syjn99
left a comment
There was a problem hiding this comment.
LGTM from multiple rounds of review - I believe the implementation is correct as per spec. We might optimize this more after merging this because the feature is gated by a flag and other changes in our production path apart from FCR looks safe.
There was a problem hiding this comment.
Pull request overview
Implements the Ethereum consensus “fast confirmation rule” (FCR) behind --enable-fast-confirmation, integrates it into beacon-node slot processing, exposes new forkchoice read APIs needed by the algorithm, and wires up minimal-preset spec tests.
Changes:
- Add
beacon-chain/confirmationpackage implementing FCR (safety thresholding + FFG gates + committee/vote aggregation) with unit tests. - Extend forkchoice with new read accessors (vote snapshots, ancestry queries, unrealized justification, slashed indices, confirmed payload hash) and track unrealized justified roots per node.
- Add/enable FCR spec test runner + minimal spectests for Altair→Gloas, and wire FCR confirmed root into Engine API SafeBlockHash when enabled.
Reviewed changes
Copilot reviewed 54 out of 55 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| testing/spectest/shared/common/forkchoice/type.go | Extend forkchoice spectest check schema with FCR fields + meta bls_setting. |
| testing/spectest/shared/common/forkchoice/runner.go | Add fast-confirmation test runner path; refactor step handling helpers; honor bls_setting: 2. |
| testing/spectest/shared/common/forkchoice/builder.go | Add FCR-enabled builder variant; run FCR per-slot in spectest harness; adjust attestation disparity for FCR vectors. |
| testing/spectest/shared/common/forkchoice/BUILD.bazel | Add features dependency for FCR-enabled spectest builder. |
| testing/spectest/minimal/altair__fast_confirmation__fast_confirmation_test.go | Add minimal-preset Altair FCR spec test entrypoint. |
| testing/spectest/minimal/bellatrix__fast_confirmation__fast_confirmation_test.go | Add minimal-preset Bellatrix FCR spec test entrypoint. |
| testing/spectest/minimal/capella__fast_confirmation__fast_confirmation_test.go | Add minimal-preset Capella FCR spec test entrypoint. |
| testing/spectest/minimal/deneb__fast_confirmation__fast_confirmation_test.go | Add minimal-preset Deneb FCR spec test entrypoint. |
| testing/spectest/minimal/electra__fast_confirmation__fast_confirmation_test.go | Add minimal-preset Electra FCR spec test entrypoint. |
| testing/spectest/minimal/fulu__fast_confirmation__fast_confirmation_test.go | Add minimal-preset Fulu FCR spec test entrypoint. |
| testing/spectest/minimal/gloas__fast_confirmation__fast_confirmation_test.go | Add minimal-preset Gloas FCR spec test entrypoint. |
| testing/spectest/minimal/BUILD.bazel | Mark minimal spectest target large; include FCR spec test sources. |
| config/params/mainnet_config.go | Add ConfirmationByzantineThreshold forkchoice/FCR constant to mainnet config. |
| config/params/config.go | Add config field + YAML key for CONFIRMATION_BYZANTINE_THRESHOLD. |
| config/params/loader_test.go | Update placeholder spec field list for new config param. |
| config/features/flags.go | Add --enable-fast-confirmation feature flag. |
| config/features/config.go | Wire CLI flag into feature config. |
| changelog/terence_fcr.md | Add changelog entry for FCR feature. |
| beacon-chain/rpc/eth/config/handlers_test.go | Update spec endpoint test expectations for new config param exposure. |
| beacon-chain/forkchoice/types/types.go | Add VoteData type for exposing public vote snapshot fields to FCR. |
| beacon-chain/forkchoice/interfaces.go | Extend forkchoice getter interfaces with ancestry/vote snapshot/justification/slashed/confirmed-hash APIs. |
| beacon-chain/forkchoice/ro.go | Add locked RO delegations for newly-exposed forkchoice methods. |
| beacon-chain/forkchoice/ro_test.go | Extend RO locking tests/mocks for new RO forkchoice API surface. |
| beacon-chain/forkchoice/doubly-linked-tree/types.go | Track unrealizedJustifiedRoot per node for FCR justification queries. |
| beacon-chain/forkchoice/doubly-linked-tree/store.go | Store unrealized justified root on insert. |
| beacon-chain/forkchoice/doubly-linked-tree/store_test.go | Update tests for new store insert signature. |
| beacon-chain/forkchoice/doubly-linked-tree/unrealized_justification.go | Persist unrealized justified root during unrealized checkpoint updates. |
| beacon-chain/forkchoice/doubly-linked-tree/unrealized_justification_test.go | Update tests for new unrealized justified checkpoint setter. |
| beacon-chain/forkchoice/doubly-linked-tree/gloas.go | Fix vote node resolution when full-node entry is absent (pre-Gloas paths). |
| beacon-chain/forkchoice/doubly-linked-tree/forkchoice.go | Add new forkchoice read APIs required by FCR; adjust latest-message semantics; expose slashed/vote snapshot/confirmed payload hash. |
| beacon-chain/forkchoice/doubly-linked-tree/forkchoice_test.go | Add tests for new forkchoice helper APIs and updated vote semantics. |
| beacon-chain/core/transition/trailing_slot_state_cache.go | Add ClearNextSlotCache helper for isolating spectest runs. |
| beacon-chain/confirmation/types.go | Add shared types for FCR (equivocation scorer + balance info container). |
| beacon-chain/confirmation/interfaces.go | Define forkchoice/committee/balance accessor interfaces for FCR integration. |
| beacon-chain/confirmation/helpers.go | Implement committee weight estimation + adversarial/proposer scoring helpers. |
| beacon-chain/confirmation/helpers_test.go | Unit tests for committee weight/adversarial/proposer helper logic. |
| beacon-chain/confirmation/support.go | Implement vote support aggregation and equivocation scoring helpers for FCR queries. |
| beacon-chain/confirmation/support_test.go | Unit tests for support aggregation, equivocation, and table rebuild behavior. |
| beacon-chain/confirmation/safety.go | Implement LMD-GHOST safety thresholding + is_one_confirmed. |
| beacon-chain/confirmation/safety_test.go | Unit tests covering safety threshold, epoch crossing, optimistic blocks, and slot-0 behavior. |
| beacon-chain/confirmation/ffg.go | Implement FFG target support estimation + justification gate helpers. |
| beacon-chain/confirmation/ffg_test.go | Unit tests for FFG target scoring + honest-support computations. |
| beacon-chain/confirmation/confirmation.go | Implement full FCR store + per-slot update/advance/revert algorithm. |
| beacon-chain/confirmation/confirmation_test.go | Unit tests for key FCR store rotation + phase behaviors. |
| beacon-chain/confirmation/BUILD.bazel | Add Bazel targets for FCR library and tests. |
| beacon-chain/blockchain/service.go | Initialize FCR when flag enabled; compute engine SafeBlockHash from confirmed root when available. |
| beacon-chain/blockchain/receive_attestation.go | Run FCR at slot start after applying attestations. |
| beacon-chain/blockchain/receive_execution_payload_envelope.go | Use new safe hash selection (FCR confirmed root when enabled). |
| beacon-chain/blockchain/execution_engine.go | Use new safe hash selection (FCR confirmed root when enabled). |
| beacon-chain/blockchain/receive_block.go | Allow disabling block signature verification for spectests with unsigned blocks; add test helper method. |
| beacon-chain/blockchain/chain_info_forkchoice.go | Expose Service.FCR() accessor for spectest builder/harness. |
| beacon-chain/blockchain/fcr_accessors.go | Implement committee/balance accessors bridging Service state to FCR interfaces. |
| beacon-chain/blockchain/fcr_accessors_test.go | Add test ensuring checkpoint balance source handles skipped epoch boundary correctly. |
| beacon-chain/blockchain/execution_engine_test.go | Minor formatting fix in test setup call. |
| beacon-chain/blockchain/BUILD.bazel | Wire new FCR accessors + confirmation dep into blockchain build and tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if uint64(v) >= uint64(len(offs)) { | ||
| grown := make([]uint8, v+1) | ||
| copy(grown, offs) | ||
| for i := len(offs); i < len(grown); i++ { | ||
| grown[i] = noAssignment | ||
| } | ||
| offs = grown | ||
| } |
| currentSlot := uint64(tick) / params.BeaconConfig().SecondsPerSlot | ||
| for lastSlot < currentSlot { | ||
| lastSlot++ | ||
| bb.service.SetForkChoiceGenesisTime(time.Now().Add(-1 * time.Duration(params.BeaconConfig().SecondsPerSlot*lastSlot) * time.Second)) |
| } | ||
| } | ||
| if tick > int64(params.BeaconConfig().SecondsPerSlot*lastSlot) { | ||
| bb.service.SetForkChoiceGenesisTime(time.Now().Add(-1 * time.Duration(tick) * time.Second)) |
| confirmedRoot := f.confirmedRoot | ||
| currentEpoch := slots.ToEpoch(currentSlot) | ||
| // The slot start snapshot keeps every phase on one head even if forkchoice moves mid run. | ||
| head := f.currentSlotHead | ||
|
|
| if slots.IsEpochStart(currentSlot + 1) { | ||
| ujc := f.fc.UnrealizedJustifiedCheckpoint() | ||
| f.previousEpochGreatestUnrealizedCheckpoint = *ujc | ||
| } |
| // DisableBlockSignatureVerificationForTesting supports spectest vectors generated with bls_setting 2, their blocks are unsigned. | ||
| func (s *Service) DisableBlockSignatureVerificationForTesting() { | ||
| s.skipBlockSignaturesForTesting = true | ||
| } |
There was a problem hiding this comment.
Ohhh came here to write essentially the same :)
There was a problem hiding this comment.
@potuz what do you think of this way:
so we don't have to add skipBlockSignaturesForTesting field like this PR, and skip only for testing purpose via build flag.
# Conflicts: # beacon-chain/forkchoice/ro_test.go # beacon-chain/rpc/eth/config/handlers_test.go # testing/spectest/minimal/BUILD.bazel
potuz
left a comment
There was a problem hiding this comment.
Another quick pass, everything seems fine so far, a couple more of error handling that may result in a mistake, mostly nits this time.
| } | ||
| balances := make([]uint64, st.NumValidators()) | ||
| epoch := coreTime.CurrentEpoch(st) | ||
| for idx, val := range st.ValidatorsReadOnlySeq() { |
There was a problem hiding this comment.
Here we do a whole validator sweep and it seems to me that the balances are not cached on PulledUpHeadState. BalanceInfoByCheckpoint does cache, should we cache here at least on the pair (head, epoch)?
| return nil, 0, err | ||
| } | ||
| if a.byCheckpoint == nil { | ||
| a.byCheckpoint = make(map[forkchoicetypes.Checkpoint]*confirmation.FFGStateInfo) |
There was a problem hiding this comment.
This map is written under no lock. I think this is fine as FCR is single threaded, but then my comment on multiple calls to getFFG in the same slot can actually be a race if we every hit that path.
| if s.fcr != nil { | ||
| root := s.fcr.ConfirmedRoot() | ||
| if root != ([32]byte{}) { | ||
| return s.cfg.ForkChoiceStore.ConfirmedPayloadBlockHash(root) |
There was a problem hiding this comment.
Can't the payloadBlockHash here return zero as well? is this a problem? shouldn't in this case return the uj one?
potuz
left a comment
There was a problem hiding this comment.
This time I went over locks, there is no race now because there's a single thread that reads/writes I believe, but still there are some read locks being used to write and some fields read without any lock.
Those perhaps are not that important, but the O(n) under a forkchoice lock seems like a bad idea and can be avoided I think
| f.fc.RLock() | ||
| defer f.fc.RUnlock() |
There was a problem hiding this comment.
This lock is taking over forkchoice and can stall block import. It does a full validator's sweep and it seems to me that the expensive part can be taken out of the lock, will mark in the relevant places.
| equivocating := f.fc.SlashedIndices() | ||
| f.votesBuf = f.fc.VoteSnapshot(f.votesBuf[:0]) | ||
| votes := f.votesBuf |
There was a problem hiding this comment.
Here we snapshot the votes (still taking about the fc.Lock)
| equivocating := f.fc.SlashedIndices() | ||
| f.votesBuf = f.fc.VoteSnapshot(f.votesBuf[:0]) | ||
| votes := f.votesBuf | ||
| f.support.Build(votes, balances, f.tables, equivocating, f.fc) |
There was a problem hiding this comment.
Then we call this function that makes an O(n) loop on validators, but all the arguments are already copied from forkchoice, so this loop can be taken out of the lock itself, is there a race by doing so?
There was a problem hiding this comment.
Yes there is a race, the vote loop itself reads nothing from forkchoice but Build also walks the ancestry with ParentRoot to push support up, that part races with inserts. Split in b02664e, the O(n) aggregation runs off the lock and only the ancestor walk stays under it.
| } | ||
|
|
||
| // Checkpoint state loads can replay from disk, keep them off the forkchoice lock. | ||
| balances, totalActiveBalance, err := f.balances.BalanceInfoByCheckpoint(ctx, f.currentEpochObservedJustifiedCheckpoint) |
There was a problem hiding this comment.
This is a read without a lock 1/3
| var prevBalances []uint64 | ||
| prevTotalActive := uint64(0) | ||
| if slots.IsEpochStart(currentSlot) { | ||
| prevBalances, prevTotalActive, err = f.balances.BalanceInfoByCheckpoint(ctx, f.previousEpochObservedJustifiedCheckpoint) |
There was a problem hiding this comment.
This is a read without a lock 2/3
|
|
||
| // honest() needs the pulled up head state only on boundary, catch-up, or revert runs. | ||
| getFFG := sync.OnceValue(func() *FFGStateInfo { | ||
| ffg, err := f.balances.PulledUpHeadState(ctx, f.currentSlotHead) |
There was a problem hiding this comment.
This is a read without a lock 3/3
| fastConfirmationDuration.WithLabelValues(path).Observe(float64(time.Since(start).Milliseconds())) | ||
| }(time.Now()) | ||
|
|
||
| f.fc.RLock() |
There was a problem hiding this comment.
This takes a read lock, but writes some
previousSlotHead [fieldparams.RootLength]byte
currentSlotHead [fieldparams.RootLength]byte
currentEpochObservedJustifiedCheckpoint forkchoicetypes.Checkpoint
previousEpochObservedJustifiedCheckpoint forkchoicetypes.Checkpoint
previousEpochGreatestUnrealizedCheckpoint forkchoicetypes.Checkpoint
That are read unguarded in a few places marked below.
potuz
left a comment
There was a problem hiding this comment.
Pointing that we seem to be not even reading the confirmed head from the forkchoice tests vectors.
| CurrentSlotHead *string `json:"current_slot_head"` | ||
| PreviousEpochObservedJustifiedCheckpoint *EpochRoot `json:"previous_epoch_observed_justified_checkpoint"` | ||
| CurrentEpochObservedJustifiedCheckpoint *EpochRoot `json:"current_epoch_observed_justified_checkpoint"` | ||
| PreviousEpochGreatestUnrealizedCheckpoint *EpochRoot `json:"previous_epoch_greatest_unrealized_checkpoint"` |
There was a problem hiding this comment.
it seems to me we are not checking the fast confirmed head in tests
There was a problem hiding this comment.
confirmed_root was already checked in the builder, but you're right there was a gap, we were silently dropping safe_execution_block_hash from the checks. Added in 206559f
potuz
left a comment
There was a problem hiding this comment.
Last round, still have the dominant lock of O(n) and the in-state computation of unrealized justification to cover, but other than that it seems good to go.
| } | ||
| for _, t := range s.tables { | ||
| if sl, ok := t.assignedSlot(idx); ok && sl >= startSlot && sl <= endSlot { | ||
| total += s.balances[idx] |
There was a problem hiding this comment.
I think here we are counting equivocators that are onchain with balance zero and the spec would count this balance 1/2
| balances := make([]uint64, st.NumValidators()) | ||
| epoch := coreTime.CurrentEpoch(st) | ||
| for idx, val := range st.ValidatorsReadOnlySeq() { | ||
| if helpers.IsActiveValidatorUsingTrie(val, epoch) && !val.Slashed() { |
There was a problem hiding this comment.
Here we do not count slashed validators in the balance 2/2.
I believe in the previous message (marked 1/2) we needed to count the slashed balance. This is the whole mechanism to recover fast confirmation even in the presence of slashing.
There was a problem hiding this comment.
Fixed in 059783f. Checked all the other consumers, attestation score, block support between slots, and current target score all want unslashed per spec
potuz
left a comment
There was a problem hiding this comment.
I did my best on reviewing this one thoroughly, it's such a massive change that probably more eyes would be good anyway. I did find a series of bugs, and one is remaining which is the issue of the unrealized justification not being computed before slot 21, I think it's safe anyway and fine to ship right now, but we should open an issue to fix that
…s#17281) **What type of PR is this?** > Other: Spectest harness **What does this PR do? Why is it needed?** - OffchainLabs#17122 The PR above says "Honors `bls_setting: 2` in forkchoice spec test vectors, which ship unsigned blocks". `bls_setting` is not yet widely used, but in the FCR context, ALL FCR tests are based on `bls_setting: 2` for faster test duration. OffchainLabs#17122 solves this problem ad-hoc like adding `skipBlockSignaturesForTesting` field and skips the signature verification like: ```go if s.skipBlockSignaturesForTesting { _, postState, err = transition.ExecuteStateTransitionNoVerifyAnySig(ctx, preState, signed) } else { postState, err = transition.ExecuteStateTransition(ctx, preState, signed) } ``` I think this can be much improved by enforcing so-called "fake" BLS backend that returns "yes" for every signature verification request. This compile-time approach has several benefits: - We don't have to touch the production path like above. - Some tests like `test_is_one_confirmed_fails_recently_activated_validator_voting_in_empty_slot` assumes a deposit process. We should also add some field like `skipDepositSignatures` in order to include the new deposit request (with stub signature). **Which issue(s) does this PR fix?** N/A **Other notes for review** Verified with the latest spectest fixtures + merge this work with `fcr` branch and test. Here's a matrix for every cases regarding `meta.yaml` and `bls_seting`: | Case's `meta.yaml` | Meaning in the spec | `bls=real` (default) | `bls=fake` | | --- | --- | --- | --- | | no `meta.yaml` | no metadata at all | ✅ run | ✅ run | | no `bls_setting` key (only `blocks_count`, etc.) | unspecified = same as `0` | ✅ run | ✅ run | | `bls_setting: 0` | BLS optional — valid either way | ✅ run | ✅ run | | `bls_setting: 1` | BLS required — only valid with BLS on | ✅ run | ⏭️ skip | | `bls_setting: 2` | BLS ignored — only valid with BLS off | ⏭️ skip | ✅ run | | any other value (e.g. `3`) | unknown | ❌ hard fail | ❌ hard fail | | `realOnlyCases`, 3 entries (`utils.go:78`) | no metadata, but needs real verification | ✅ run | ⏭️ skip | **Acknowledgements** - [x] I have read [CONTRIBUTING.md](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md). - [x] I have included a uniquely named [changelog fragment file](https://github.com/prysmaticlabs/prysm/blob/develop/CONTRIBUTING.md#maintaining-changelogmd). - [x] I have added a description with sufficient context for reviewers to understand this PR. - [x] I have tested that my changes work as expected and I added a testing plan to the PR description (if applicable).
Implements the fast confirmation rule behind the
--enable-fast-confirmationfeature flag.beacon-chain/confirmationpackage implementing the FCR algorithm: LMD-GHOST safety checks, FFG justification gates, epoch-boundary reconfirmation, and vote support aggregationfast_confirmationspec tests for altair through gloas (minimal preset), all 1190 vectors passbls_setting: 2in forkchoice spec test vectors, which ship unsigned blocks