Compare commits

...

2 Commits

Author SHA1 Message Date
james-prysm
812129e44a e2e test 2025-12-05 18:44:10 -06:00
Potuz
32bc5e6a76 Track the dependent root of the latest finalized checkpoint
This PR adds the dependent root of the latest finalized checkpoint to
forkchoice since this node will be typically pruned upon finalization.
2025-12-05 16:01:21 -03:00
9 changed files with 174 additions and 5 deletions

View File

@@ -642,8 +642,25 @@ func (f *ForkChoice) DependentRootForEpoch(root [32]byte, epoch primitives.Epoch
if !ok || node == nil {
return [32]byte{}, ErrNilNode
}
if slots.ToEpoch(node.slot) >= epoch && node.parent != nil {
node = node.parent
if slots.ToEpoch(node.slot) >= epoch {
if node.parent != nil {
node = node.parent
} else {
return f.store.finalizedDependentRoot, nil
}
}
// E2E TEST: Log the dependent root result for verification
// The returned root should be from epoch-1, not from epoch or later
returnedEpoch := slots.ToEpoch(node.slot)
if returnedEpoch >= epoch {
// BUG DETECTED: returning a root from the wrong epoch
logrus.WithFields(logrus.Fields{
"requestedEpoch": epoch,
"returnedSlot": node.slot,
"returnedEpoch": returnedEpoch,
"returnedRoot": fmt.Sprintf("%#x", node.root),
}).Error("E2E_DEPENDENT_ROOT_BUG: dependent root is from wrong epoch")
}
return node.root, nil
}

View File

@@ -212,6 +212,9 @@ func (s *Store) prune(ctx context.Context) error {
return nil
}
// Save the new finalized dependent root because it will be pruned
s.finalizedDependentRoot = finalizedNode.parent.root
// Prune nodeByRoot starting from root
if err := s.pruneFinalizedNodeByRootMap(ctx, s.treeRootNode, finalizedNode); err != nil {
return err

View File

@@ -465,6 +465,7 @@ func TestStore_TargetRootForEpoch(t *testing.T) {
ctx := t.Context()
f := setup(1, 1)
// Insert a block in slot 32
state, blk, err := prepareForkchoiceState(ctx, params.BeaconConfig().SlotsPerEpoch, [32]byte{'a'}, params.BeaconConfig().ZeroHash, params.BeaconConfig().ZeroHash, 1, 1)
require.NoError(t, err)
require.NoError(t, f.InsertNode(ctx, state, blk))
@@ -475,6 +476,7 @@ func TestStore_TargetRootForEpoch(t *testing.T) {
require.NoError(t, err)
require.Equal(t, dependent, [32]byte{})
// Insert a block in slot 33
state, blk1, err := prepareForkchoiceState(ctx, params.BeaconConfig().SlotsPerEpoch+1, [32]byte{'b'}, blk.Root(), params.BeaconConfig().ZeroHash, 1, 1)
require.NoError(t, err)
require.NoError(t, f.InsertNode(ctx, state, blk1))
@@ -488,7 +490,7 @@ func TestStore_TargetRootForEpoch(t *testing.T) {
require.NoError(t, err)
require.Equal(t, dependent, [32]byte{})
// Insert a block for the next epoch (missed slot 0)
// Insert a block for the next epoch (missed slot 0), slot 65
state, blk2, err := prepareForkchoiceState(ctx, 2*params.BeaconConfig().SlotsPerEpoch+1, [32]byte{'c'}, blk1.Root(), params.BeaconConfig().ZeroHash, 1, 1)
require.NoError(t, err)
@@ -509,6 +511,7 @@ func TestStore_TargetRootForEpoch(t *testing.T) {
require.NoError(t, err)
require.Equal(t, dependent, blk1.Root())
// Insert a block at slot 66
state, blk3, err := prepareForkchoiceState(ctx, 2*params.BeaconConfig().SlotsPerEpoch+2, [32]byte{'d'}, blk2.Root(), params.BeaconConfig().ZeroHash, 1, 1)
require.NoError(t, err)
require.NoError(t, f.InsertNode(ctx, state, blk3))
@@ -533,8 +536,11 @@ func TestStore_TargetRootForEpoch(t *testing.T) {
dependent, err = f.DependentRoot(1)
require.NoError(t, err)
require.Equal(t, [32]byte{}, dependent)
dependent, err = f.DependentRoot(2)
require.NoError(t, err)
require.Equal(t, blk1.Root(), dependent)
// Insert a block for next epoch (slot 0 present)
// Insert a block for the next epoch, slot 96 (descends from finalized at slot 33)
state, blk4, err := prepareForkchoiceState(ctx, 3*params.BeaconConfig().SlotsPerEpoch, [32]byte{'e'}, blk1.Root(), params.BeaconConfig().ZeroHash, 1, 1)
require.NoError(t, err)
require.NoError(t, f.InsertNode(ctx, state, blk4))
@@ -551,6 +557,7 @@ func TestStore_TargetRootForEpoch(t *testing.T) {
require.NoError(t, err)
require.Equal(t, dependent, blk1.Root())
// Insert a block at slot 97
state, blk5, err := prepareForkchoiceState(ctx, 3*params.BeaconConfig().SlotsPerEpoch+1, [32]byte{'f'}, blk4.Root(), params.BeaconConfig().ZeroHash, 1, 1)
require.NoError(t, err)
require.NoError(t, f.InsertNode(ctx, state, blk5))
@@ -600,12 +607,16 @@ func TestStore_TargetRootForEpoch(t *testing.T) {
require.NoError(t, err)
require.Equal(t, target, blk1.Root())
// Prune finalization
// Prune finalization, finalize the block at slot 96
s.finalizedCheckpoint.Root = blk4.Root()
require.NoError(t, s.prune(ctx))
target, err = f.TargetRootForEpoch(blk4.Root(), 3)
require.NoError(t, err)
require.Equal(t, blk4.Root(), target)
// Dependent root for the finalized block should be the root of the pruned block at slot 33
dependent, err = f.DependentRootForEpoch(blk4.Root(), 3)
require.NoError(t, err)
require.Equal(t, blk1.Root(), dependent)
}
func TestStore_DependentRootForEpoch(t *testing.T) {

View File

@@ -31,6 +31,7 @@ type Store struct {
proposerBoostRoot [fieldparams.RootLength]byte // latest block root that was boosted after being received in a timely manner.
previousProposerBoostRoot [fieldparams.RootLength]byte // previous block root that was boosted after being received in a timely manner.
previousProposerBoostScore uint64 // previous proposer boosted root score.
finalizedDependentRoot [fieldparams.RootLength]byte // dependent root at finalized checkpoint.
committeeWeight uint64 // tracks the total active validator balance divided by the number of slots per Epoch.
treeRootNode *Node // the root node of the store tree.
headNode *Node // last head Node

View File

@@ -0,0 +1,3 @@
### Added
- Track the dependent root of the latest finalized checkpoint in forkchoice.

View File

@@ -94,6 +94,7 @@ go_test(
"component_handler_test.go",
"endtoend_setup_test.go",
"endtoend_test.go",
"minimal_dependent_root_e2e_test.go",
"minimal_e2e_test.go",
"minimal_slashing_e2e_test.go",
"slasher_simulator_e2e_test.go",

View File

@@ -6,6 +6,7 @@ go_library(
srcs = [
"builder.go",
"data.go",
"dependent_root.go",
"execution_engine.go",
"fee_recipient.go",
"finality.go",

View File

@@ -0,0 +1,95 @@
package evaluators
import (
"bufio"
"fmt"
"os"
"path"
"strings"
"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
e2e "github.com/OffchainLabs/prysm/v7/testing/endtoend/params"
"github.com/OffchainLabs/prysm/v7/testing/endtoend/policies"
"github.com/OffchainLabs/prysm/v7/testing/endtoend/types"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
)
// DependentRootEvaluators returns evaluators for testing dependent root handling.
// These evaluators verify that the dependent root bug does NOT occur:
// - When a block at the first slot of an epoch becomes finalized and its parent pruned
// - The dependent root query should still return correct data (from the previous epoch)
//
// The evaluator checks for "E2E_DEPENDENT_ROOT_BUG" error log which indicates
// a dependent root was returned from the wrong epoch.
//
// WITH the fix: No bug log appears → test PASSES
// WITHOUT the fix: Bug log appears (wrong epoch data) → test FAILS
func DependentRootEvaluators(afterEpoch primitives.Epoch) []types.Evaluator {
return []types.Evaluator{
{
Name: "no_dependent_root_bug_epoch_%d",
Policy: policies.AfterNthEpoch(afterEpoch + 2), // Allow time for finalization
Evaluation: checkNoDependentRootBug,
},
}
}
// checkNoDependentRootBug scans beacon node logs to verify that the dependent root
// bug did NOT occur. The bug would manifest as returning a dependent root from
// the wrong epoch (current epoch instead of previous epoch).
//
// The beacon node logs "E2E_DEPENDENT_ROOT_BUG" when it detects this condition.
//
// WITH the fix: No bug log appears → test PASSES
// WITHOUT the fix: Bug log appears → test FAILS
func checkNoDependentRootBug(_ *types.EvaluationContext, _ ...*grpc.ClientConn) error {
// This log message indicates the bug was triggered
bugLogMessage := "E2E_DEPENDENT_ROOT_BUG"
for i := 0; i < e2e.TestParams.BeaconNodeCount; i++ {
logFile := path.Join(e2e.TestParams.LogPath, fmt.Sprintf(e2e.BeaconNodeLogFileName, i))
found, err := searchLogForMessages(logFile, []string{bugLogMessage})
if err != nil {
return errors.Wrapf(err, "failed to search beacon node %d log file", i)
}
if found {
// Bug was detected - test FAILS
return errors.New("E2E_DEPENDENT_ROOT_BUG detected: dependent root returned from wrong epoch - the fix is not working or not present")
}
}
// No bug detected - test PASSES
log.Info("No dependent root bug detected - fix is working correctly")
return nil
}
// searchLogForMessages searches a log file for any of the given messages.
func searchLogForMessages(logPath string, messages []string) (bool, error) {
file, err := os.Open(logPath) // #nosec G304 -- test code only
if err != nil {
return false, errors.Wrap(err, "failed to open log file")
}
defer func() {
if err := file.Close(); err != nil {
log.WithError(err).Error("Failed to close log file")
}
}()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
for _, msg := range messages {
if strings.Contains(line, msg) {
return true, nil
}
}
}
if err := scanner.Err(); err != nil {
return false, errors.Wrap(err, "error scanning log file")
}
return false, nil
}

View File

@@ -0,0 +1,37 @@
package endtoend
import (
"testing"
"github.com/OffchainLabs/prysm/v7/config/params"
"github.com/OffchainLabs/prysm/v7/runtime/version"
ev "github.com/OffchainLabs/prysm/v7/testing/endtoend/evaluators"
"github.com/OffchainLabs/prysm/v7/testing/endtoend/types"
)
// TestEndToEnd_MinimalConfig_DependentRoot tests that the beacon node correctly
// handles the dependent root bug scenario where:
// 1. A block at the first slot of an epoch becomes finalized
// 2. The parent of that block is pruned during finalization
// 3. Dependent root queries for that epoch would return wrong data without the fix
//
// The test adds verification logging that detects when a dependent root is
// returned from the wrong epoch (E2E_DEPENDENT_ROOT_BUG).
//
// WITH the fix: No bug is detected → test PASSES
// WITHOUT the fix: Bug is detected → test FAILS
func TestEndToEnd_MinimalConfig_DependentRoot(t *testing.T) {
r := e2eMinimalDependentRoot(t, types.InitForkCfg(version.Electra, version.Electra, params.E2ETestConfig()))
r.run()
}
func e2eMinimalDependentRoot(t *testing.T, cfg *params.BeaconChainConfig) *testRunner {
// Use the standard e2eMinimal setup with the dependent root evaluators
r := e2eMinimal(t, cfg,
func(c *types.E2EConfig) {
// Add the dependent root evaluators to check for the bug scenario
c.Evaluators = append(c.Evaluators, ev.DependentRootEvaluators(2)...)
},
)
return r
}