From 8bccadae128cfbf595691994b5fff2a8d0e7ca8d Mon Sep 17 00:00:00 2001 From: terence Date: Wed, 22 Jul 2026 09:00:31 -0700 Subject: [PATCH 01/10] Only increment PTC vote count metric for newly set votes (#17214) - `forkchoice_ptc_vote_count` incremented on every `SetPTCVote` call, so the same vote applied from gossip and again from a block aggregate counted twice (~883/slot observed on devnet-7 vs PTC size 512) - Only increment when the attester bit was previously unset --- beacon-chain/forkchoice/doubly-linked-tree/gloas.go | 4 +++- changelog/terence_fix_ptc_vote_count.md | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 changelog/terence_fix_ptc_vote_count.md diff --git a/beacon-chain/forkchoice/doubly-linked-tree/gloas.go b/beacon-chain/forkchoice/doubly-linked-tree/gloas.go index 16d677a7db97..5e826b5e64db 100644 --- a/beacon-chain/forkchoice/doubly-linked-tree/gloas.go +++ b/beacon-chain/forkchoice/doubly-linked-tree/gloas.go @@ -541,7 +541,9 @@ func (f *ForkChoice) SetPTCVote(root [32]byte, ptcIdx uint64, payloadPresent, bl if n == nil { return } - ptcVoteCount.Inc() + if !n.node.payloadAttesters.BitAt(ptcIdx) { + ptcVoteCount.Inc() + } n.node.payloadAttesters.SetBitAt(ptcIdx, true) n.node.payloadAvailabilityVote.SetBitAt(ptcIdx, payloadPresent) n.node.payloadDataAvailabilityVote.SetBitAt(ptcIdx, blobDataAvailable) diff --git a/changelog/terence_fix_ptc_vote_count.md b/changelog/terence_fix_ptc_vote_count.md new file mode 100644 index 000000000000..6c775ad04ac7 --- /dev/null +++ b/changelog/terence_fix_ptc_vote_count.md @@ -0,0 +1,3 @@ +### Fixed + +- Count each PTC vote once in `forkchoice_ptc_vote_count` instead of on every re-application. From 8a13e561bb8ad8683c86206dfc0d624706bf4e64 Mon Sep 17 00:00:00 2001 From: ethermachine <75843061+ethermachine@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:07:11 +0100 Subject: [PATCH 02/10] Add native proposer timing games support Introduce opt-in, experimental support for proposer timing games: delaying the block proposal request within the slot so builders have more time to accrue MEV, while staying safe from missed slots. Validator client: - --enable-proposer-timing-games: experimental gate, off by default. - --proposer-timing-game-delay: target time into the slot at which the block proposal request is released (default 1500ms). - The delay is applied at the top of ProposeBlock and clamped so the request plus the builder getHeader round-trip and block propagation still complete before the attestation deadline, never missing the slot. Warns when configured beyond the honest reorg-safe threshold, and clamps (with a warning) when it exceeds the safe maximum. Beacon node: - --builder-getheader-timeout makes the previously hardcoded 1s builder getHeader timeout configurable (falls back to 1s when unset). Adds unit tests for the delay clamp, the slot-offset wait, the feature gate wiring, and the builder getHeader timeout configuration. --- beacon-chain/node/config.go | 7 ++ beacon-chain/node/config_test.go | 15 ++++ .../v1alpha1/validator/proposer_bellatrix.go | 8 +- cmd/beacon-chain/flags/base.go | 7 ++ cmd/beacon-chain/main.go | 1 + cmd/beacon-chain/usage.go | 1 + config/features/config.go | 13 ++++ config/features/config_test.go | 26 +++++++ config/features/flags.go | 20 +++++ config/params/config.go | 1 + config/params/mainnet_config.go | 2 + validator/client/propose.go | 11 +++ validator/client/wait_helpers.go | 69 +++++++++++++++++ validator/client/wait_helpers_test.go | 77 +++++++++++++++++++ 14 files changed, 257 insertions(+), 1 deletion(-) diff --git a/beacon-chain/node/config.go b/beacon-chain/node/config.go index e50668751374..8e2734150df5 100644 --- a/beacon-chain/node/config.go +++ b/beacon-chain/node/config.go @@ -88,6 +88,13 @@ func configureBuilderCircuitBreaker(cliCtx *cli.Context) error { return err } } + if cliCtx.IsSet(flags.BuilderGetHeaderTimeout.Name) { + c := params.BeaconConfig().Copy() + c.BuilderGetHeaderTimeout = cliCtx.Duration(flags.BuilderGetHeaderTimeout.Name) + if err := params.SetActive(c); err != nil { + return err + } + } return nil } diff --git a/beacon-chain/node/config_test.go b/beacon-chain/node/config_test.go index 0dc9d39c86a0..13d5bb9fbf93 100644 --- a/beacon-chain/node/config_test.go +++ b/beacon-chain/node/config_test.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" "testing" + "time" "github.com/OffchainLabs/prysm/v7/cmd" "github.com/OffchainLabs/prysm/v7/cmd/beacon-chain/flags" @@ -39,6 +40,20 @@ func TestConfigureHistoricalSlasher(t *testing.T) { ) } +func TestConfigureBuilderGetHeaderTimeout(t *testing.T) { + params.SetupTestConfigCleanup(t) + + app := cli.App{} + set := flag.NewFlagSet("test", 0) + set.Duration(flags.BuilderGetHeaderTimeout.Name, 0, "") + require.NoError(t, set.Set(flags.BuilderGetHeaderTimeout.Name, "950ms")) + cliCtx := cli.NewContext(&app, set, nil) + + require.NoError(t, configureBuilderCircuitBreaker(cliCtx)) + + assert.Equal(t, 950*time.Millisecond, params.BeaconConfig().BuilderGetHeaderTimeout) +} + func TestConfigureSlotsPerArchivedPoint(t *testing.T) { params.SetupTestConfigCleanup(t) diff --git a/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bellatrix.go b/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bellatrix.go index 47c2a76e314a..5713d3bb430f 100644 --- a/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bellatrix.go +++ b/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bellatrix.go @@ -208,7 +208,13 @@ func (vs *Server) getPayloadHeaderFromBuilder( return nil, err } - ctx, cancel := context.WithTimeout(ctx, blockBuilderTimeout) + // Bound the getHeader call by the configurable builder timeout, falling back + // to the default BUILDER_PROPOSAL_DELAY_TOLERANCE when unset. + timeout := blockBuilderTimeout + if t := params.BeaconConfig().BuilderGetHeaderTimeout; t > 0 { + timeout = t + } + ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() signedBid, err := vs.BlockBuilder.GetHeader(ctx, slot, bytesutil.ToBytes32(h.BlockHash()), pk) diff --git a/cmd/beacon-chain/flags/base.go b/cmd/beacon-chain/flags/base.go index e26e30f262a4..546682a97b7c 100644 --- a/cmd/beacon-chain/flags/base.go +++ b/cmd/beacon-chain/flags/base.go @@ -78,6 +78,13 @@ var ( " and the beacon will revert to local building.", Value: 0, } + // BuilderGetHeaderTimeout bounds how long the beacon node waits for a builder relay getHeader response. + BuilderGetHeaderTimeout = &cli.DurationFlag{ + Name: "builder-getheader-timeout", + Usage: "Maximum time to wait for a builder relay getHeader response before falling back to local block building (e.g. 1s, 950ms). " + + "Lower values reduce missed-slot risk when using proposer timing games; higher values tolerate slower relays.", + DefaultText: params.BeaconConfig().BuilderGetHeaderTimeout.String(), + } // ExecutionEngineEndpoint provides an HTTP access endpoint to connect to an execution client on the execution layer ExecutionEngineEndpoint = &cli.StringFlag{ Name: "execution-endpoint", diff --git a/cmd/beacon-chain/main.go b/cmd/beacon-chain/main.go index a11258451d36..105353c0bb7e 100644 --- a/cmd/beacon-chain/main.go +++ b/cmd/beacon-chain/main.go @@ -87,6 +87,7 @@ var appFlags = []cli.Flag{ flags.LocalBlockValueBoost, flags.MinBuilderBid, flags.MinBuilderDiff, + flags.BuilderGetHeaderTimeout, flags.BeaconDBPruning, flags.PrunerRetentionEpochs, flags.DisableBuilderSSZ, diff --git a/cmd/beacon-chain/usage.go b/cmd/beacon-chain/usage.go index 66b60f05fe79..ecb31bdc56ff 100644 --- a/cmd/beacon-chain/usage.go +++ b/cmd/beacon-chain/usage.go @@ -144,6 +144,7 @@ var appHelpFlagGroups = []flagGroup{ flags.MevRelayEndpoint, flags.MinBuilderBid, flags.MinBuilderDiff, + flags.BuilderGetHeaderTimeout, flags.SuggestedFeeRecipient, flags.DisableBuilderSSZ, }, diff --git a/config/features/config.go b/config/features/config.go index db4ec9ee06d3..bdd780514e53 100644 --- a/config/features/config.go +++ b/config/features/config.go @@ -92,6 +92,14 @@ type Flags struct { // AggregateIntervals specifies the time durations at which we aggregate attestations preparing for forkchoice. AggregateIntervals [3]time.Duration + // EnableProposerTimingGames delays the block proposal request within the slot so + // builders have more time to accrue MEV (proposer timing games). Off by default. + EnableProposerTimingGames bool + // ProposerTimingGameDelay is the target time into the slot at which the block + // proposal request is released when EnableProposerTimingGames is set. Only read + // when EnableProposerTimingGames is true; the effective value is clamped at use. + ProposerTimingGameDelay time.Duration + // Feature related flags (alignment forced in the end) ForceHead string // ForceHead forces the head block to be a specific block root, the last head block, or the last finalized block. BlacklistedRoots map[[32]byte]struct{} // BlacklistedRoots is a list of roots that are blacklisted from processing. @@ -371,6 +379,11 @@ func ConfigureValidator(ctx *cli.Context) error { } cfg.KeystoreImportDebounceInterval = ctx.Duration(dynamicKeyReloadDebounceInterval.Name) + if ctx.Bool(enableProposerTimingGames.Name) { + logEnabled(enableProposerTimingGames) + cfg.EnableProposerTimingGames = true + cfg.ProposerTimingGameDelay = ctx.Duration(proposerTimingGameDelay.Name) + } Init(cfg) return nil } diff --git a/config/features/config_test.go b/config/features/config_test.go index 0f07670fb46c..ef450100d3f7 100644 --- a/config/features/config_test.go +++ b/config/features/config_test.go @@ -3,6 +3,7 @@ package features import ( "flag" "testing" + "time" validatorflags "github.com/OffchainLabs/prysm/v7/cmd/validator/flags" "github.com/OffchainLabs/prysm/v7/testing/assert" @@ -60,6 +61,31 @@ func TestConfigureValidator_RESTApiEnabledByEndpoint(t *testing.T) { assert.Equal(t, false, Get().EnableBeaconRESTApi) } +func TestConfigureValidator_ProposerTimingGames(t *testing.T) { + defer Init(&Flags{}) + app := cli.App{} + + // Enabling timing games reads the configured delay. + set := flag.NewFlagSet("test", 0) + set.Bool(enableProposerTimingGames.Name, true, "test") + set.Duration(proposerTimingGameDelay.Name, 2*time.Second, "test") + require.NoError(t, set.Set(enableProposerTimingGames.Name, "true")) + require.NoError(t, set.Set(proposerTimingGameDelay.Name, "2s")) + context := cli.NewContext(&app, set, nil) + require.NoError(t, ConfigureValidator(context)) + assert.Equal(t, true, Get().EnableProposerTimingGames) + assert.Equal(t, 2*time.Second, Get().ProposerTimingGameDelay) + + // Without the gate the delay is ignored and stays zero (behavior unchanged). + set = flag.NewFlagSet("test", 0) + set.Duration(proposerTimingGameDelay.Name, 2*time.Second, "test") + require.NoError(t, set.Set(proposerTimingGameDelay.Name, "2s")) + context = cli.NewContext(&app, set, nil) + require.NoError(t, ConfigureValidator(context)) + assert.Equal(t, false, Get().EnableProposerTimingGames) + assert.Equal(t, time.Duration(0), Get().ProposerTimingGameDelay) +} + func TestConfigureBeaconConfig(t *testing.T) { app := cli.App{} set := flag.NewFlagSet("test", 0) diff --git a/config/features/flags.go b/config/features/flags.go index e486ddf9644c..15793120a741 100644 --- a/config/features/flags.go +++ b/config/features/flags.go @@ -227,6 +227,24 @@ var ( Name: "track-equivocations", Usage: "Records proposer equivocations observed on gossip and marks the slot in forkchoice if the equivocation arrives before the configured early deadline.", } + // enableProposerTimingGames opts the validator into proposer timing games: + // delaying the block proposal request within the slot so builders have more + // time to accrue MEV. Gated behind this experimental flag; off by default. + enableProposerTimingGames = &cli.BoolFlag{ + Name: "enable-proposer-timing-games", + Usage: "(Experimental): Delays the block proposal request within the slot so builders have more time to accrue MEV (proposer timing games). " + + "Opt-in and off by default. Tune the delay with --proposer-timing-game-delay. WARNING: this increases the risk of orphaned/reorged blocks; " + + "the effective delay is clamped to remain before the attestation deadline.", + Value: false, + } + // proposerTimingGameDelay is the target time into the slot at which the block + // proposal request is released when timing games are enabled. + proposerTimingGameDelay = &cli.DurationFlag{ + Name: "proposer-timing-game-delay", + Usage: "(Experimental): Target time into the slot at which the block proposal request is released when --enable-proposer-timing-games is set " + + "(e.g. 1500ms, 2s). Clamped to a safe maximum before the attestation deadline. Has no effect unless timing games are enabled.", + Value: 1500 * time.Millisecond, + } ) // devModeFlags holds list of flags that are set when development mode is on. @@ -249,6 +267,8 @@ var ValidatorFlags = append(deprecatedFlags, []cli.Flag{ EnableBeaconRESTApi, DisableDutiesV2, EnableWebFlag, + enableProposerTimingGames, + proposerTimingGameDelay, }...) // E2EValidatorFlags contains a list of the validator feature flags to be tested in E2E. diff --git a/config/params/config.go b/config/params/config.go index d7bde83ff55b..cdce1a0d7eb8 100644 --- a/config/params/config.go +++ b/config/params/config.go @@ -267,6 +267,7 @@ type BeaconChainConfig struct { LocalBlockValueBoost uint64 // LocalBlockValueBoost is the value boost for local block construction. This is used to prioritize local block construction over relay/builder block construction. MinBuilderBid uint64 // MinBuilderBid is the minimum value that the builder's block can have to be considered by this node. MinBuilderDiff uint64 // MinBuilderDiff is the minimum value above the local block value that the builder has to bid to be considered by this node + BuilderGetHeaderTimeout time.Duration // BuilderGetHeaderTimeout is the maximum time to wait for a builder getHeader response (known as BUILDER_PROPOSAL_DELAY_TOLERANCE in the builder spec). Configurable to support proposer timing games. // Execution engine timeout value ExecutionEngineTimeoutValue uint64 // ExecutionEngineTimeoutValue defines the seconds to wait before timing out engine endpoints with execution payload execution semantics (newPayload, forkchoiceUpdated). diff --git a/config/params/mainnet_config.go b/config/params/mainnet_config.go index 895504f9d413..d7138dee4f69 100644 --- a/config/params/mainnet_config.go +++ b/config/params/mainnet_config.go @@ -303,6 +303,8 @@ var mainnetBeaconConfig = &BeaconChainConfig{ // Mevboost circuit breaker MaxBuilderConsecutiveMissedSlots: 3, MaxBuilderEpochMissedSlots: 5, + // Builder getHeader timeout (BUILDER_PROPOSAL_DELAY_TOLERANCE) + BuilderGetHeaderTimeout: 1 * time.Second, // Execution engine timeout value ExecutionEngineTimeoutValue: 8, // 8 seconds default based on: https://github.com/ethereum/execution-apis/blob/main/src/engine/specification.md#core diff --git a/validator/client/propose.go b/validator/client/propose.go index 9681cc7f41f2..dc9f8fbd5d30 100644 --- a/validator/client/propose.go +++ b/validator/client/propose.go @@ -8,6 +8,7 @@ import ( "github.com/OffchainLabs/prysm/v7/async" "github.com/OffchainLabs/prysm/v7/beacon-chain/core/signing" + "github.com/OffchainLabs/prysm/v7/config/features" fieldparams "github.com/OffchainLabs/prysm/v7/config/fieldparams" "github.com/OffchainLabs/prysm/v7/config/params" "github.com/OffchainLabs/prysm/v7/config/proposer" @@ -59,6 +60,16 @@ func (v *validator) ProposeBlock(ctx context.Context, slot primitives.Slot, pubK span.SetAttributes(trace.StringAttribute("validator", fmtKey)) log := log.WithField("pubkey", fmt.Sprintf("%#x", bytesutil.Trunc(pubKey[:]))) + // Proposer timing games: optionally delay the block request into the slot so + // builders have more time to accrue MEV. Gated behind an experimental flag and + // clamped to remain before the attestation deadline (see proposalReleaseDelay). + if features.Get().EnableProposerTimingGames { + if delay := v.proposalReleaseDelay(slot); delay > 0 { + log.WithField("slot", slot).WithField("delay", delay).Debug("Delaying block proposal for timing games") + v.waitUntilSlotOffset(ctx, slot, delay) + } + } + // Sign randao reveal, it's used to request block from beacon node epoch := primitives.Epoch(slot / params.BeaconConfig().SlotsPerEpoch) randaoReveal, err := v.signRandaoReveal(ctx, pubKey, epoch, slot) diff --git a/validator/client/wait_helpers.go b/validator/client/wait_helpers.go index bda4112bbf5e..44dd2101c568 100644 --- a/validator/client/wait_helpers.go +++ b/validator/client/wait_helpers.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/OffchainLabs/prysm/v7/config/features" "github.com/OffchainLabs/prysm/v7/config/params" "github.com/OffchainLabs/prysm/v7/consensus-types/primitives" "github.com/OffchainLabs/prysm/v7/monitoring/tracing" @@ -12,6 +13,12 @@ import ( "github.com/OffchainLabs/prysm/v7/time/slots" ) +// proposerTimingGameSafetyBudget is the time reserved at the end of the +// timing-game window for the builder getHeader round-trip (~1s) plus block +// propagation, so that a delayed proposal still lands before the attestation +// deadline and the slot is not missed. +const proposerTimingGameSafetyBudget = 1500 * time.Millisecond + // slotComponentDeadline returns the absolute time corresponding to the provided slot component. func (v *validator) slotComponentDeadline(slot primitives.Slot, component primitives.BP) (time.Time, error) { startTime, err := slots.StartTime(v.genesisTime, slot) @@ -73,6 +80,68 @@ func (v *validator) waitForPayloadAvailableOrDeadline(ctx context.Context, slot } } +// waitUntilSlotOffset blocks until the given absolute offset into the slot has +// elapsed (slot start + offset), or the context is cancelled. It is used by the +// proposer timing-game path to release the block proposal request later in the slot. +func (v *validator) waitUntilSlotOffset(ctx context.Context, slot primitives.Slot, offset time.Duration) { + ctx, span := trace.StartSpan(ctx, "validator.waitProposerTimingGame") + defer span.End() + + startTime, err := slots.StartTime(v.genesisTime, slot) + if err != nil { + log.WithError(err).WithField("slot", slot).Error("Slot overflows, unable to wait for proposer timing-game delay") + return + } + wait := prysmTime.Until(startTime.Add(offset)) + if wait <= 0 { + return + } + t := time.NewTimer(wait) + defer t.Stop() + select { + case <-ctx.Done(): + tracing.AnnotateError(span, ctx.Err()) + return + case <-t.C: + return + } +} + +// proposalReleaseDelay returns the proposer timing-game delay to apply before +// releasing the block proposal request for slot. The configured delay is clamped +// so that the request, the builder getHeader round-trip and block propagation +// still complete before the attestation deadline (never missing the slot because +// of the delay). It logs a warning when the value is clamped, or when it is set +// beyond the honest-reorg-safe threshold. +func (v *validator) proposalReleaseDelay(slot primitives.Slot) time.Duration { + configured := features.Get().ProposerTimingGameDelay + if configured <= 0 { + return 0 + } + cfg := params.BeaconConfig() + // The attestation deadline is the point by which attesters vote on the head; + // the block must be requested early enough to fetch the builder bid and + // propagate before it. Use the fork-appropriate deadline. + dueBPS := cfg.AttestationDueBPS + if slots.ToEpoch(slot) >= cfg.GloasForkEpoch { + dueBPS = cfg.AttestationDueBPSGloas + } + maxDelay := cfg.SlotComponentDuration(dueBPS) - proposerTimingGameSafetyBudget + if maxDelay < 0 { + maxDelay = 0 + } + if configured > maxDelay { + log.WithField("configuredDelay", configured).WithField("clampedDelay", maxDelay). + Warn("Proposer timing-game delay exceeds the safe maximum before the attestation deadline; clamping to avoid a missed slot") + return maxDelay + } + if reorgCutoff := cfg.SlotComponentDuration(cfg.ProposerReorgCutoffBPS); configured > reorgCutoff { + log.WithField("configuredDelay", configured).WithField("reorgCutoff", reorgCutoff). + Warn("Proposer timing-game delay is beyond the honest-reorg-safe threshold; the block may be orphaned") + } + return configured +} + func (v *validator) slotComponentSpanName(component primitives.BP) string { cfg := params.BeaconConfig() switch component { diff --git a/validator/client/wait_helpers_test.go b/validator/client/wait_helpers_test.go index 102bfae1417d..43708d4f0dae 100644 --- a/validator/client/wait_helpers_test.go +++ b/validator/client/wait_helpers_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/OffchainLabs/prysm/v7/config/features" "github.com/OffchainLabs/prysm/v7/config/params" "github.com/OffchainLabs/prysm/v7/consensus-types/primitives" "github.com/OffchainLabs/prysm/v7/testing/assert" @@ -85,3 +86,79 @@ func TestWaitUntilSlotComponent_ContextCancelReturnsImmediately(t *testing.T) { t.Fatal("waitUntilSlotComponent did not return after context cancellation") } } + +func TestWaitUntilSlotOffset_ReturnsImmediatelyWhenOffsetElapsed(t *testing.T) { + params.SetupTestConfigCleanup(t) + + // Genesis far enough in the past that slot 1's start + offset is already elapsed. + v := &validator{genesisTime: time.Now().Add(-time.Hour)} + + done := make(chan struct{}) + go func() { + v.waitUntilSlotOffset(context.Background(), 1, 1500*time.Millisecond) + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("waitUntilSlotOffset did not return when the offset had already elapsed") + } +} + +func TestWaitUntilSlotOffset_ContextCancelReturnsImmediately(t *testing.T) { + params.SetupTestConfigCleanup(t) + + // Genesis in the future so the offset has not elapsed; only ctx cancel unblocks. + v := &validator{genesisTime: time.Now().Add(time.Hour)} + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + done := make(chan struct{}) + go func() { + v.waitUntilSlotOffset(ctx, 1, 2*time.Second) + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("waitUntilSlotOffset did not return after context cancellation") + } +} + +func TestProposalReleaseDelay(t *testing.T) { + params.SetupTestConfigCleanup(t) + cfg := params.BeaconConfig() + + v := &validator{genesisTime: time.Now()} + slot := primitives.Slot(5) + + // Compute the fork-appropriate bounds the same way the implementation does. + dueBPS := cfg.AttestationDueBPS + if slots.ToEpoch(slot) >= cfg.GloasForkEpoch { + dueBPS = cfg.AttestationDueBPSGloas + } + maxDelay := cfg.SlotComponentDuration(dueBPS) - proposerTimingGameSafetyBudget + reorgCutoff := cfg.SlotComponentDuration(cfg.ProposerReorgCutoffBPS) + require.Equal(t, true, maxDelay > 0) + require.Equal(t, true, reorgCutoff < maxDelay) + + tests := []struct { + name string + configured time.Duration + want time.Duration + }{ + {name: "disabled returns zero", configured: 0, want: 0}, + {name: "below cutoff passes through", configured: reorgCutoff - time.Second, want: reorgCutoff - time.Second}, + {name: "beyond reorg cutoff still allowed", configured: reorgCutoff + 100*time.Millisecond, want: reorgCutoff + 100*time.Millisecond}, + {name: "above max is clamped", configured: maxDelay + time.Second, want: maxDelay}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reset := features.InitWithReset(&features.Flags{ProposerTimingGameDelay: tt.configured}) + defer reset() + assert.Equal(t, tt.want, v.proposalReleaseDelay(slot)) + }) + } +} From 39a19d268266e0b143fc4f4a269b4c2effb707ff Mon Sep 17 00:00:00 2001 From: Preston Van Loon Date: Wed, 22 Jul 2026 12:27:24 -0500 Subject: [PATCH 03/10] e2e: Update lighthouse version in multiclient testing (#17221) **What type of PR is this?** Other **What does this PR do? Why is it needed?** This updates lighthouse version used in multiclient e2e tests. This comment is taken from PR #17134 with all credits to @nalepae. **Which issue(s) does this PR fix?** **Other notes for review** **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). --------- Co-authored-by: Manu NALEPA --- changelog/pvl-update-e2e-lh.md | 3 +++ testing/endtoend/components/BUILD.bazel | 1 + testing/endtoend/components/beacon_node.go | 1 + .../endtoend/components/lighthouse_beacon.go | 23 ++++++++++++------- .../components/lighthouse_validator.go | 10 ++++---- testing/endtoend/deps.bzl | 4 ++-- testing/endtoend/mainnet_e2e_test.go | 22 +++++++++++++++++- 7 files changed, 48 insertions(+), 16 deletions(-) create mode 100644 changelog/pvl-update-e2e-lh.md diff --git a/changelog/pvl-update-e2e-lh.md b/changelog/pvl-update-e2e-lh.md new file mode 100644 index 000000000000..847b927224fc --- /dev/null +++ b/changelog/pvl-update-e2e-lh.md @@ -0,0 +1,3 @@ +### Ignored + +- Updated lighthouse version for e2e testing diff --git a/testing/endtoend/components/BUILD.bazel b/testing/endtoend/components/BUILD.bazel index 95744f230ddd..e2aaa58b06a7 100644 --- a/testing/endtoend/components/BUILD.bazel +++ b/testing/endtoend/components/BUILD.bazel @@ -31,6 +31,7 @@ go_library( "//config/params:go_default_library", "//crypto/bls:go_default_library", "//io/file:go_default_library", + "//network:go_default_library", "//proto/prysm/v1alpha1/validator-client:go_default_library", "//runtime/interop:go_default_library", "//testing/endtoend/helpers:go_default_library", diff --git a/testing/endtoend/components/beacon_node.go b/testing/endtoend/components/beacon_node.go index 6da1f1e93cc1..68d71f7e5552 100644 --- a/testing/endtoend/components/beacon_node.go +++ b/testing/endtoend/components/beacon_node.go @@ -275,6 +275,7 @@ func (node *BeaconNode) Start(ctx context.Context) error { "--" + cmdshared.ValidatorMonitorIndicesFlag.Name + "=2", "--" + cmdshared.ForceClearDB.Name, "--" + cmdshared.AcceptTosFlag.Name, + "--" + flags.Supernode.Name, } if config.UsePprof { args = append(args, "--pprof", fmt.Sprintf("--pprofport=%d", e2e.TestParams.Ports.PrysmBeaconNodePprofPort+index)) diff --git a/testing/endtoend/components/lighthouse_beacon.go b/testing/endtoend/components/lighthouse_beacon.go index 8de69d8ce7ef..a353b320bf71 100644 --- a/testing/endtoend/components/lighthouse_beacon.go +++ b/testing/endtoend/components/lighthouse_beacon.go @@ -13,6 +13,7 @@ import ( "github.com/OffchainLabs/prysm/v7/config/params" "github.com/OffchainLabs/prysm/v7/io/file" + prysmnetwork "github.com/OffchainLabs/prysm/v7/network" "github.com/OffchainLabs/prysm/v7/testing/endtoend/helpers" e2e "github.com/OffchainLabs/prysm/v7/testing/endtoend/params" e2etypes "github.com/OffchainLabs/prysm/v7/testing/endtoend/types" @@ -172,19 +173,25 @@ func (node *LighthouseBeaconNode) Start(ctx context.Context) error { prysmNodeCount := e2e.TestParams.BeaconNodeCount jwtPath := path.Join(e2e.TestParams.TestPath, "eth1data/"+strconv.Itoa(node.index+prysmNodeCount)+"/") jwtPath = path.Join(jwtPath, "geth/jwtsecret") + + enrAddress, err := prysmnetwork.ExternalIPv4() + if err != nil { + return fmt.Errorf("external ip v4: %w", err) + } + args := []string{ "beacon_node", fmt.Sprintf("--datadir=%s/lighthouse-beacon-node-%d", e2e.TestParams.TestPath, index), fmt.Sprintf("--testnet-dir=%s", testDir), "--staking", - "--enr-address=127.0.0.1", + fmt.Sprintf("--enr-address=%s", enrAddress), fmt.Sprintf("--enr-udp-port=%d", e2e.TestParams.Ports.LighthouseBeaconNodeP2PPort+index*2), // multiply by 2 because LH adds 1 for quic4 port fmt.Sprintf("--enr-tcp-port=%d", e2e.TestParams.Ports.LighthouseBeaconNodeP2PPort+index*2), // multiply by 2 because LH adds 1 for quic4 port fmt.Sprintf("--port=%d", e2e.TestParams.Ports.LighthouseBeaconNodeP2PPort+index*2), // multiply by 2 because LH adds 1 for quic4 port fmt.Sprintf("--http-port=%d", e2e.TestParams.Ports.LighthouseBeaconNodeHTTPPort+index), fmt.Sprintf("--target-peers=%d", 10), fmt.Sprintf("--execution-endpoint=http://127.0.0.1:%d", e2e.TestParams.Ports.Eth1ProxyPort+prysmNodeCount+index), - fmt.Sprintf("--jwt-secrets=%s", jwtPath), + fmt.Sprintf("--execution-jwt=%s", jwtPath), fmt.Sprintf("--boot-nodes=%s", node.enr), fmt.Sprintf("--metrics-port=%d", e2e.TestParams.Ports.LighthouseBeaconNodeMetricsPort+index), "--metrics", @@ -202,23 +209,23 @@ func (node *LighthouseBeaconNode) Start(ctx context.Context) error { args = append(args, fmt.Sprintf("--builder=%s:%d", "http://127.0.0.1", e2e.TestParams.Ports.Eth1ProxyPort+prysmNodeCount+index)) } cmd := exec.CommandContext(ctx, binaryPath, args...) /* #nosec G204 */ - // Write stderr to log files. - stderr, err := os.Create(path.Join(e2e.TestParams.LogPath, fmt.Sprintf("lighthouse_beacon_node_%d_stderr.log", index))) + logFile, err := os.Create(path.Join(e2e.TestParams.LogPath, fmt.Sprintf("lighthouse_beacon_node_%d.log", index))) if err != nil { return err } defer func() { - if err := stderr.Close(); err != nil { - log.WithError(err).Error("Failed to close stderr file") + if err := logFile.Close(); err != nil { + log.WithError(err).Error("Failed to close lighthouse beacon log file") } }() - cmd.Stderr = stderr + cmd.Stdout = logFile + cmd.Stderr = logFile log.Infof("Starting lighthouse beacon chain %d with flags: %s", index, strings.Join(args[2:], " ")) if err = cmd.Start(); err != nil { return fmt.Errorf("failed to start beacon node: %w", err) } - if err = helpers.WaitForTextInFile(stderr, "Metrics HTTP server started"); err != nil { + if err = helpers.WaitForTextInFile(logFile, "Metrics HTTP server started"); err != nil { return fmt.Errorf("could not find initialization for node %d, this means the node had issues starting: %w", index, err) } diff --git a/testing/endtoend/components/lighthouse_validator.go b/testing/endtoend/components/lighthouse_validator.go index c2bfc140fc41..2de30e8a1e4f 100644 --- a/testing/endtoend/components/lighthouse_validator.go +++ b/testing/endtoend/components/lighthouse_validator.go @@ -196,17 +196,17 @@ func (v *LighthouseValidatorNode) Start(ctx context.Context) error { cmd := exec.CommandContext(ctx, binaryPath, args...) // #nosec G204 -- Safe - // Write stderr to log files. - stderr, err := os.Create(path.Join(e2e.TestParams.LogPath, fmt.Sprintf("lighthouse_validator_%d_stderr.log", index))) + logFile, err := os.Create(path.Join(e2e.TestParams.LogPath, fmt.Sprintf("lighthouse_validator_%d.log", index))) if err != nil { return err } defer func() { - if err := stderr.Close(); err != nil { - log.WithError(err).Error("Failed to close stderr file") + if err := logFile.Close(); err != nil { + log.WithError(err).Error("Failed to close lighthouse validator log file") } }() - cmd.Stderr = stderr + cmd.Stdout = logFile + cmd.Stderr = logFile log.Infof("Starting lighthouse validator client %d with flags: %s %s", index, binaryPath, strings.Join(args, " ")) if err = cmd.Start(); err != nil { diff --git a/testing/endtoend/deps.bzl b/testing/endtoend/deps.bzl index 574a1b6d54bd..daa55e2a0b6d 100644 --- a/testing/endtoend/deps.bzl +++ b/testing/endtoend/deps.bzl @@ -1,6 +1,6 @@ load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") # gazelle:keep -lighthouse_version = "v7.0.0-beta.0" +lighthouse_version = "v8.2.0" lighthouse_archive_name = "lighthouse-%s-x86_64-unknown-linux-gnu.tar.gz" % lighthouse_version def e2e_deps(): @@ -14,7 +14,7 @@ def e2e_deps(): http_archive( name = "lighthouse", - integrity = "sha256-qMPifuh7u0epItu8DzZ8YdZ2fVZNW7WKnbmmAgjh/us=", + integrity = "sha256-w3IY/v+yoiVYtfDFGPzxS+i8PVUTMWLH014+UZNFFS0=", build_file = "@prysm//testing/endtoend:lighthouse.BUILD", url = ("https://github.com/sigp/lighthouse/releases/download/%s/" + lighthouse_archive_name) % lighthouse_version, ) diff --git a/testing/endtoend/mainnet_e2e_test.go b/testing/endtoend/mainnet_e2e_test.go index f7135ab1b633..c8fe47c28909 100644 --- a/testing/endtoend/mainnet_e2e_test.go +++ b/testing/endtoend/mainnet_e2e_test.go @@ -15,5 +15,25 @@ func TestEndToEnd_MainnetConfig_ValidatorAtCurrentRelease(t *testing.T) { } func TestEndToEnd_MainnetConfig_MultiClient(t *testing.T) { - e2eMainnet(t, false, true, types.InitForkCfg(version.Bellatrix, version.Electra, params.E2EMainnetTestConfig())).run() + const ( + electraForkEpoch = 0 + fuluForkEpoch = 2 + + BPO1ForkEpoch = fuluForkEpoch + 2 + BPO1MaxBlobsPerBlock = 15 + + BPO2ForkEpoch = fuluForkEpoch + 4 + BPO2MaxBlobsPerBlock = 21 + ) + + cfg := types.InitForkCfg(version.Electra, version.Fulu, params.E2EMainnetTestConfig()) + cfg.FuluForkEpoch = fuluForkEpoch + cfg.BlobSchedule = []params.BlobScheduleEntry{ + {Epoch: electraForkEpoch, MaxBlobsPerBlock: uint64(cfg.DeprecatedMaxBlobsPerBlockElectra)}, + {Epoch: BPO1ForkEpoch, MaxBlobsPerBlock: BPO1MaxBlobsPerBlock}, + {Epoch: BPO2ForkEpoch, MaxBlobsPerBlock: BPO2MaxBlobsPerBlock}, + } + cfg.InitializeForkSchedule() + + e2eMainnet(t, false, true, cfg, types.WithEpochs(8), types.WithLargeBlobs()).run() } From 5245cfe5413c29dfabf3c916c8e0e8cff40d3c2f Mon Sep 17 00:00:00 2001 From: ethermachine <75843061+ethermachine@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:33:36 +0100 Subject: [PATCH 04/10] Address review: isolate builder getHeader timeout config and fix test config dependency - Extract the builder getHeader timeout wiring out of configureBuilderCircuitBreaker into its own configureBuilderGetHeaderTimeout step. - Pin the mainnet config in TestProposalReleaseDelay so it does not depend on the globally active config left by other tests in the package (Gloas shifts the attestation deadline and broke the test's bounds when the whole package ran). --- beacon-chain/node/config.go | 6 +++++- beacon-chain/node/config_test.go | 2 +- beacon-chain/node/node.go | 4 ++++ validator/client/wait_helpers_test.go | 3 +++ 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/beacon-chain/node/config.go b/beacon-chain/node/config.go index 8e2734150df5..e37f4c21f80f 100644 --- a/beacon-chain/node/config.go +++ b/beacon-chain/node/config.go @@ -88,6 +88,11 @@ func configureBuilderCircuitBreaker(cliCtx *cli.Context) error { return err } } + + return nil +} + +func configureBuilderGetHeaderTimeout(cliCtx *cli.Context) error { if cliCtx.IsSet(flags.BuilderGetHeaderTimeout.Name) { c := params.BeaconConfig().Copy() c.BuilderGetHeaderTimeout = cliCtx.Duration(flags.BuilderGetHeaderTimeout.Name) @@ -95,7 +100,6 @@ func configureBuilderCircuitBreaker(cliCtx *cli.Context) error { return err } } - return nil } diff --git a/beacon-chain/node/config_test.go b/beacon-chain/node/config_test.go index 13d5bb9fbf93..56d57994af0e 100644 --- a/beacon-chain/node/config_test.go +++ b/beacon-chain/node/config_test.go @@ -49,7 +49,7 @@ func TestConfigureBuilderGetHeaderTimeout(t *testing.T) { require.NoError(t, set.Set(flags.BuilderGetHeaderTimeout.Name, "950ms")) cliCtx := cli.NewContext(&app, set, nil) - require.NoError(t, configureBuilderCircuitBreaker(cliCtx)) + require.NoError(t, configureBuilderGetHeaderTimeout(cliCtx)) assert.Equal(t, 950*time.Millisecond, params.BeaconConfig().BuilderGetHeaderTimeout) } diff --git a/beacon-chain/node/node.go b/beacon-chain/node/node.go index 5feaf616b211..ca1637cfdfdc 100644 --- a/beacon-chain/node/node.go +++ b/beacon-chain/node/node.go @@ -317,6 +317,10 @@ func configureBeacon(cliCtx *cli.Context) error { return errors.Wrap(err, "could not configure builder circuit breaker") } + if err := configureBuilderGetHeaderTimeout(cliCtx); err != nil { + return errors.Wrap(err, "could not configure builder getHeader timeout") + } + if err := configureSlotsPerArchivedPoint(cliCtx); err != nil { return errors.Wrap(err, "could not configure slots per archived point") } diff --git a/validator/client/wait_helpers_test.go b/validator/client/wait_helpers_test.go index 43708d4f0dae..c3513db531e4 100644 --- a/validator/client/wait_helpers_test.go +++ b/validator/client/wait_helpers_test.go @@ -129,6 +129,9 @@ func TestWaitUntilSlotOffset_ContextCancelReturnsImmediately(t *testing.T) { func TestProposalReleaseDelay(t *testing.T) { params.SetupTestConfigCleanup(t) + // Pin the mainnet config: the bounds below assume the pre-Gloas attestation + // deadline, and other tests in this package activate Gloas globally. + params.OverrideBeaconConfig(params.MainnetConfig().Copy()) cfg := params.BeaconConfig() v := &validator{genesisTime: time.Now()} From cc3d145c7fa516e22992cdd306a8f0e5aa23b552 Mon Sep 17 00:00:00 2001 From: terence Date: Wed, 22 Jul 2026 11:28:10 -0700 Subject: [PATCH 05/10] Increment newPayload node count metrics on Gloas envelope path (#17213) - `new_payload_{valid,optimistic,invalid}_node_count` were only incremented on the pre-Gloas block path, so they stay flat under Gloas while the envelope path makes all the newPayload calls - Increment them in `callNewPayload` --- beacon-chain/blockchain/receive_execution_payload_envelope.go | 3 +++ changelog/terence_gloas_envelope_newpayload_metrics.md | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 changelog/terence_gloas_envelope_newpayload_metrics.md diff --git a/beacon-chain/blockchain/receive_execution_payload_envelope.go b/beacon-chain/blockchain/receive_execution_payload_envelope.go index 9130aec0ff17..dfc300f5910e 100644 --- a/beacon-chain/blockchain/receive_execution_payload_envelope.go +++ b/beacon-chain/blockchain/receive_execution_payload_envelope.go @@ -275,9 +275,11 @@ func (s *Service) callNewPayload( ) (bool, error) { _, err := s.cfg.ExecutionEngineCaller.NewPayload(ctx, payload, versionedHashes, &parentRoot, requests) if err == nil { + newPayloadValidNodeCount.Inc() return true, nil } if errors.Is(err, execution.ErrAcceptedSyncingPayloadStatus) { + newPayloadOptimisticNodeCount.Inc() log.WithFields(logrus.Fields{ "slot": slot, "payloadBlockHash": fmt.Sprintf("%#x", bytesutil.Trunc(payload.BlockHash())), @@ -285,6 +287,7 @@ func (s *Service) callNewPayload( return false, nil } if errors.Is(err, execution.ErrInvalidPayloadStatus) { + newPayloadInvalidNodeCount.Inc() return false, invalidBlock{error: ErrInvalidPayload} } return false, errors.WithMessage(ErrUndefinedExecutionEngineError, err.Error()) diff --git a/changelog/terence_gloas_envelope_newpayload_metrics.md b/changelog/terence_gloas_envelope_newpayload_metrics.md new file mode 100644 index 000000000000..9dd43cff9628 --- /dev/null +++ b/changelog/terence_gloas_envelope_newpayload_metrics.md @@ -0,0 +1,3 @@ +### Fixed + +- Increment `new_payload_*_node_count` metrics on the Gloas payload envelope path. From ef74a0719de18f1f445e22148bab82aa4060c29c Mon Sep 17 00:00:00 2001 From: terence Date: Wed, 22 Jul 2026 11:41:19 -0700 Subject: [PATCH 06/10] Remove unused data_column_obtained_via_el_count metric (#17216) - Defined but never recorded anywhere, permanently zero - `data_columns_recovered_from_el_{attempts,total}` already cover this --- beacon-chain/sync/metrics.go | 7 ------- changelog/terence_remove_dead_column_el_metric.md | 3 +++ 2 files changed, 3 insertions(+), 7 deletions(-) create mode 100644 changelog/terence_remove_dead_column_el_metric.md diff --git a/beacon-chain/sync/metrics.go b/beacon-chain/sync/metrics.go index 4a9e31c97a12..abb836b8af11 100644 --- a/beacon-chain/sync/metrics.go +++ b/beacon-chain/sync/metrics.go @@ -283,13 +283,6 @@ var ( }, ) - dataColumnSidecarsObtainedViaELCount = promauto.NewSummary( - prometheus.SummaryOpts{ - Name: "data_column_obtained_via_el_count", - Help: "Count the number of data column sidecars obtained via the execution layer.", - }, - ) - ignoredPreJustifiedBlockCount = promauto.NewCounter(prometheus.CounterOpts{ Name: "gossip_ignored_pre_justified_block_total", Help: "Count of blocks ignored because their canonical parent is before the justified checkpoint.", diff --git a/changelog/terence_remove_dead_column_el_metric.md b/changelog/terence_remove_dead_column_el_metric.md new file mode 100644 index 000000000000..8407ea9c3bc7 --- /dev/null +++ b/changelog/terence_remove_dead_column_el_metric.md @@ -0,0 +1,3 @@ +### Removed + +- Unused `data_column_obtained_via_el_count` metric that was never recorded. From a09dbb0cb9372805b0ed6e90c7ef8d4928c2a30b Mon Sep 17 00:00:00 2001 From: Bastin <43618253+Inspector-Butters@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:58:00 +0200 Subject: [PATCH 07/10] add ssz functions for progressive merkliezation (#16847) Adding the progressive merkliezation functions for the SSZ package. - `MerkleizeProgressiveChunks` - `MerkleizeVectorSSZProgressive` - `MerkleizeListSSZProgressive` - `SliceRootProgressive` - `ByteSliceRootProgressive` - `MixInActiveFields` --- changelog/bastin_progressive-ssz-functions.md | 3 + encoding/ssz/BUILD.bazel | 3 + encoding/ssz/progressive_merkleize.go | 129 ++++++++++++ encoding/ssz/progressive_merkleize_test.go | 187 ++++++++++++++++++ 4 files changed, 322 insertions(+) create mode 100644 changelog/bastin_progressive-ssz-functions.md create mode 100644 encoding/ssz/progressive_merkleize.go create mode 100644 encoding/ssz/progressive_merkleize_test.go diff --git a/changelog/bastin_progressive-ssz-functions.md b/changelog/bastin_progressive-ssz-functions.md new file mode 100644 index 000000000000..bce7e4da0dd5 --- /dev/null +++ b/changelog/bastin_progressive-ssz-functions.md @@ -0,0 +1,3 @@ +### Added + +- progressive merklization functions in the ssz package diff --git a/encoding/ssz/BUILD.bazel b/encoding/ssz/BUILD.bazel index e932bf7b07bd..fdc76df6fbae 100644 --- a/encoding/ssz/BUILD.bazel +++ b/encoding/ssz/BUILD.bazel @@ -7,6 +7,7 @@ go_library( "helpers.go", "htrutils.go", "merkleize.go", + "progressive_merkleize.go", "slice_root.go", ], importpath = "github.com/OffchainLabs/prysm/v7/encoding/ssz", @@ -14,6 +15,7 @@ go_library( deps = [ "//config/fieldparams:go_default_library", "//container/trie:go_default_library", + "//crypto/hash:go_default_library", "//crypto/hash/htr:go_default_library", "//encoding/bytesutil:go_default_library", "//proto/engine/v1:go_default_library", @@ -34,6 +36,7 @@ go_test( "htrutils_fuzz_test.go", "htrutils_test.go", "merkleize_test.go", + "progressive_merkleize_test.go", ], embed = [":go_default_library"], deps = [ diff --git a/encoding/ssz/progressive_merkleize.go b/encoding/ssz/progressive_merkleize.go new file mode 100644 index 000000000000..a8a6fa965a3d --- /dev/null +++ b/encoding/ssz/progressive_merkleize.go @@ -0,0 +1,129 @@ +package ssz + +import ( + "encoding/binary" + "fmt" + "math" + + "github.com/OffchainLabs/prysm/v7/crypto/hash" +) + +const maxProgressiveActiveFields = 256 + +// MerkleizeProgressiveChunks computes the progressive Merkle root of 32-byte chunks. +// +// This is the EIP-7916 merkleize_progressive(chunks, num_leaves=1) helper. +// Chunks are split into progressively larger subtrees with capacities 1, 4, 16, +// 64, ...; each subtree root is then folded into the spine from deepest to +// shallowest by hashing hash(subtree_root, successor_root). +func MerkleizeProgressiveChunks(chunks [][32]byte) [32]byte { + if len(chunks) == 0 { + return [32]byte{} + } + + n := len(chunks) + start := 0 + subtreeCapacity := 1 + subtreeRoots := make([][32]byte, 0) + + for start < n { + width := min(subtreeCapacity, n-start) + + subtree := chunks[start : start+width : start+width] + subtreeRoots = append(subtreeRoots, MerkleizeVector(subtree, uint64(subtreeCapacity))) + + start += width + if start >= n { + break + } + + if subtreeCapacity > math.MaxInt/4 { + subtreeCapacity = math.MaxInt + } else { + subtreeCapacity *= 4 + } + } + + hashFunc := hash.CustomSHA256Hasher() + + // Fold successor roots from deepest subtree to shallowest subtree. + root := [32]byte{} + for i := len(subtreeRoots) - 1; i >= 0; i-- { + root = hashPair(hashFunc, subtreeRoots[i], root) + } + return root +} + +// MerkleizeVectorSSZProgressive hashes each element and computes the +// merkleize_progressive body root over the resulting field roots. +func MerkleizeVectorSSZProgressive[T Hashable](elements []T) ([32]byte, error) { + roots := make([][32]byte, len(elements)) + for i, el := range elements { + r, err := el.HashTreeRoot() + if err != nil { + return [32]byte{}, err + } + roots[i] = r + } + return MerkleizeProgressiveChunks(roots), nil +} + +// MerkleizeListSSZProgressive hashes each element and computes the progressive +// list root by mixing in the element count. +func MerkleizeListSSZProgressive[T Hashable](elements []T) ([32]byte, error) { + body, err := MerkleizeVectorSSZProgressive(elements) + if err != nil { + return [32]byte{}, err + } + + var length [32]byte + binary.LittleEndian.PutUint64(length[:8], uint64(len(elements))) + return MixInLength(body, length[:]), nil +} + +// SliceRootProgressive computes the progressive list root of hashable elements. +func SliceRootProgressive[T Hashable](slice []T) ([32]byte, error) { + return MerkleizeListSSZProgressive(slice) +} + +// ByteSliceRootProgressive computes the progressive list root of a byte slice +// interpreted as ProgressiveByteList (alias of ProgressiveList[byte]). +func ByteSliceRootProgressive(slice []byte) ([32]byte, error) { + var chunks [][32]byte + if len(slice) > 0 { + var err error + chunks, err = PackByChunk([][]byte{slice}) + if err != nil { + return [32]byte{}, err + } + } + + bytesRoot := MerkleizeProgressiveChunks(chunks) + var length [32]byte + binary.LittleEndian.PutUint64(length[:8], uint64(len(slice))) + return MixInLength(bytesRoot, length[:]), nil +} + +// MixInActiveFields computes hash(root, pack_bits(activeFields)) where +// activeFields is restricted to at most 256 bits. +func MixInActiveFields(root [32]byte, activeFields []bool) ([32]byte, error) { + if len(activeFields) > maxProgressiveActiveFields { + return [32]byte{}, fmt.Errorf("active fields length %d exceeds maximum %d", len(activeFields), maxProgressiveActiveFields) + } + + var packed [32]byte + for i, active := range activeFields { + if !active { + continue + } + packed[i/8] |= 1 << (uint(i) % 8) + } + return hashPair(hash.Hash, root, packed), nil +} + +func hashPair(hashFunc func([]byte) [32]byte, left, right [32]byte) [32]byte { + var input [64]byte + copy(input[:32], left[:]) + copy(input[32:], right[:]) + return hashFunc(input[:]) +} diff --git a/encoding/ssz/progressive_merkleize_test.go b/encoding/ssz/progressive_merkleize_test.go new file mode 100644 index 000000000000..c0a9b3143eff --- /dev/null +++ b/encoding/ssz/progressive_merkleize_test.go @@ -0,0 +1,187 @@ +package ssz_test + +import ( + "encoding/binary" + "errors" + "fmt" + "slices" + "testing" + + "github.com/OffchainLabs/prysm/v7/crypto/hash" + "github.com/OffchainLabs/prysm/v7/encoding/ssz" + "github.com/OffchainLabs/prysm/v7/testing/require" +) + +type staticHashable struct { + root [32]byte + err error +} + +func (s staticHashable) HashTreeRoot() ([32]byte, error) { + if s.err != nil { + return [32]byte{}, s.err + } + return s.root, nil +} + +func chunkFromIndex(i int) [32]byte { + var out [32]byte + binary.LittleEndian.PutUint64(out[:8], uint64(i+1)) + return out +} + +func hashPair(left, right [32]byte) [32]byte { + var input [64]byte + copy(input[:32], left[:]) + copy(input[32:], right[:]) + return hash.Hash(input[:]) +} + +func referenceMerkleizeProgressive(chunks [][32]byte, numLeaves uint64) [32]byte { + if len(chunks) == 0 { + return [32]byte{} + } + if numLeaves == 0 { + panic("numLeaves must be positive") + } + + take := len(chunks) + if uint64(take) > numLeaves { + take = int(numLeaves) + } + left := slices.Clone(chunks[:take]) + a := ssz.MerkleizeVector(left, numLeaves) + b := referenceMerkleizeProgressive(chunks[take:], numLeaves*4) + return hashPair(a, b) +} + +func TestMerkleizeProgressiveChunks_MatchesReference(t *testing.T) { + testCases := []int{0, 1, 2, 3, 5, 6, 21, 22, 85, 86} + + for _, n := range testCases { + t.Run(fmt.Sprintf("len_%d", n), func(t *testing.T) { + chunks := make([][32]byte, n) + for i := range chunks { + chunks[i] = chunkFromIndex(i) + } + + expected := referenceMerkleizeProgressive(chunks, 1) + actual := ssz.MerkleizeProgressiveChunks(chunks) + require.Equal(t, expected, actual) + }) + } +} + +func TestMerkleizeVectorSSZProgressive(t *testing.T) { + elements := []staticHashable{ + {root: chunkFromIndex(0)}, + {root: chunkFromIndex(1)}, + {root: chunkFromIndex(2)}, + } + + root, err := ssz.MerkleizeVectorSSZProgressive(elements) + require.NoError(t, err) + + expected := ssz.MerkleizeProgressiveChunks([][32]byte{ + elements[0].root, + elements[1].root, + elements[2].root, + }) + require.Equal(t, expected, root) +} + +func TestMerkleizeVectorSSZProgressive_Error(t *testing.T) { + e := errors.New("merkleError") + elements := []staticHashable{{root: chunkFromIndex(0)}, {err: e}} + + _, err := ssz.MerkleizeVectorSSZProgressive(elements) + require.ErrorContains(t, "merkleError", err) +} + +func TestMerkleizeListSSZProgressive(t *testing.T) { + elements := []staticHashable{ + {root: chunkFromIndex(10)}, + {root: chunkFromIndex(11)}, + {root: chunkFromIndex(12)}, + } + + got, err := ssz.MerkleizeListSSZProgressive(elements) + require.NoError(t, err) + + body := ssz.MerkleizeProgressiveChunks([][32]byte{ + elements[0].root, + elements[1].root, + elements[2].root, + }) + var length [32]byte + binary.LittleEndian.PutUint64(length[:8], uint64(len(elements))) + expected := ssz.MixInLength(body, length[:]) + require.Equal(t, expected, got) +} + +func TestSliceRootProgressive(t *testing.T) { + elements := []staticHashable{{root: chunkFromIndex(0)}, {root: chunkFromIndex(1)}} + + sliceRoot, err := ssz.SliceRootProgressive(elements) + require.NoError(t, err) + listRoot, err := ssz.MerkleizeListSSZProgressive(elements) + require.NoError(t, err) + require.Equal(t, listRoot, sliceRoot) +} + +func TestByteSliceRootProgressive(t *testing.T) { + testCases := [][]byte{ + nil, + {}, + {0x01}, + {0x01, 0x02, 0x03}, + make([]byte, 32), + make([]byte, 33), + } + for _, input := range testCases { + t.Run(fmt.Sprintf("len_%d", len(input)), func(t *testing.T) { + got, err := ssz.ByteSliceRootProgressive(input) + require.NoError(t, err) + + var chunks [][32]byte + if len(input) > 0 { + chunks, err = ssz.PackByChunk([][]byte{input}) + require.NoError(t, err) + } + body := ssz.MerkleizeProgressiveChunks(chunks) + var length [32]byte + binary.LittleEndian.PutUint64(length[:8], uint64(len(input))) + expected := ssz.MixInLength(body, length[:]) + require.Equal(t, expected, got) + }) + } +} + +func TestByteSliceRootProgressive_EmptyReferenceRoot(t *testing.T) { + for _, input := range [][]byte{nil, {}} { + got, err := ssz.ByteSliceRootProgressive(input) + require.NoError(t, err) + // taken from remerkleable reference implementation: + require.Equal(t, "f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b", fmt.Sprintf("%x", got)) + } +} + +func TestMixInActiveFields(t *testing.T) { + root := chunkFromIndex(42) + activeFields := []bool{true, false, true, true, false, false, false, true, true} + + got, err := ssz.MixInActiveFields(root, activeFields) + require.NoError(t, err) + + var packed [32]byte + packed[0] = 0b10001101 + packed[1] = 0b00000001 + expected := hashPair(root, packed) + require.Equal(t, expected, got) +} + +func TestMixInActiveFields_TooMany(t *testing.T) { + activeFields := make([]bool, 257) + _, err := ssz.MixInActiveFields(chunkFromIndex(0), activeFields) + require.ErrorContains(t, "exceeds maximum 256", err) +} From fb6ca149ebbdcf367382c81c169b29da0cc439bc Mon Sep 17 00:00:00 2001 From: terence Date: Wed, 22 Jul 2026 14:52:59 -0700 Subject: [PATCH 08/10] Validate bid parent block hash correctly (#17217) - Bid validation resolved the expected parent hash via `ForkChoice.BlockHash`, which returns the hash committed in the parent block's bid even when the payload was never revealed. - Add `ForkChoice.HasPayloadBlockHash(root, hash)` - Change `VerifyParentBlockHash` to take a `(root, hash) bool` lookup and wire the new method into bid gossip validation and `SubmitSignedExecutionPayloadBid` --- beacon-chain/blockchain/chain_info.go | 1 + .../blockchain/chain_info_forkchoice.go | 7 ++++++ beacon-chain/blockchain/testing/mock.go | 10 +++++++++ .../forkchoice/doubly-linked-tree/gloas.go | 13 +++++++++++ .../doubly-linked-tree/gloas_test.go | 22 +++++++++++++++++++ beacon-chain/forkchoice/interfaces.go | 1 + beacon-chain/forkchoice/ro.go | 7 ++++++ beacon-chain/forkchoice/ro_test.go | 11 ++++++++++ .../prysm/v1alpha1/validator/proposer_bid.go | 4 +++- .../validator/proposer_bid_builder_test.go | 11 +++++----- .../v1alpha1/validator/proposer_submit_bid.go | 2 +- .../sync/validate_execution_payload_bid.go | 2 +- .../validate_execution_payload_bid_test.go | 2 +- .../verification/execution_payload_bid.go | 16 +++++--------- .../execution_payload_bid_test.go | 9 ++++---- beacon-chain/verification/interface.go | 2 +- ...nce_fix-gloas-bid-parent-payload-status.md | 3 +++ 17 files changed, 98 insertions(+), 25 deletions(-) create mode 100644 changelog/terence_fix-gloas-bid-parent-payload-status.md diff --git a/beacon-chain/blockchain/chain_info.go b/beacon-chain/blockchain/chain_info.go index 234099fd303c..54212fe5c142 100644 --- a/beacon-chain/blockchain/chain_info.go +++ b/beacon-chain/blockchain/chain_info.go @@ -41,6 +41,7 @@ type ChainInfoFetcher interface { type ForkchoiceFetcher interface { Ancestor(context.Context, []byte, primitives.Slot) ([]byte, error) BlockHash(root [32]byte) ([32]byte, error) + HasPayloadBlockHash(root, blockHash [32]byte) bool GasLimit(root [32]byte) (uint64, error) CachedHeadRoot() [32]byte GetProposerHead() [32]byte diff --git a/beacon-chain/blockchain/chain_info_forkchoice.go b/beacon-chain/blockchain/chain_info_forkchoice.go index d0e32aa34cc6..6910df319db5 100644 --- a/beacon-chain/blockchain/chain_info_forkchoice.go +++ b/beacon-chain/blockchain/chain_info_forkchoice.go @@ -56,6 +56,13 @@ func (s *Service) BlockHash(root [32]byte) ([32]byte, error) { return s.cfg.ForkChoiceStore.BlockHash(root) } +// HasPayloadBlockHash reports whether blockHash is an available payload parent at root. +func (s *Service) HasPayloadBlockHash(root, blockHash [32]byte) bool { + s.cfg.ForkChoiceStore.RLock() + defer s.cfg.ForkChoiceStore.RUnlock() + return s.cfg.ForkChoiceStore.HasPayloadBlockHash(root, blockHash) +} + // GasLimit returns the gas limit of the latest full payload at or before the given beacon block root from forkchoice. func (s *Service) GasLimit(root [32]byte) (uint64, error) { s.cfg.ForkChoiceStore.RLock() diff --git a/beacon-chain/blockchain/testing/mock.go b/beacon-chain/blockchain/testing/mock.go index 60531ec99d28..091a34273b4b 100644 --- a/beacon-chain/blockchain/testing/mock.go +++ b/beacon-chain/blockchain/testing/mock.go @@ -635,6 +635,16 @@ func (s *ChainService) BlockHash(root [32]byte) ([32]byte, error) { return [32]byte{}, errors.New("block hash not found") } +// HasPayloadBlockHash mocks the same method in the chain service. +func (s *ChainService) HasPayloadBlockHash(root, blockHash [32]byte) bool { + if s.ForkChoiceStore == nil { + return false + } + s.ForkChoiceStore.RLock() + defer s.ForkChoiceStore.RUnlock() + return s.ForkChoiceStore.HasPayloadBlockHash(root, blockHash) +} + // IsOptimisticForRoot mocks the same method in the chain service. func (s *ChainService) IsOptimisticForRoot(_ context.Context, root [32]byte) (bool, error) { s.OptimisticCheckRootReceived = root diff --git a/beacon-chain/forkchoice/doubly-linked-tree/gloas.go b/beacon-chain/forkchoice/doubly-linked-tree/gloas.go index 5e826b5e64db..fe3577926e52 100644 --- a/beacon-chain/forkchoice/doubly-linked-tree/gloas.go +++ b/beacon-chain/forkchoice/doubly-linked-tree/gloas.go @@ -577,6 +577,19 @@ func (f *ForkChoice) HasFullNode(root [32]byte) bool { return ok } +// HasPayloadBlockHash reports whether blockHash is an available payload parent at root. +func (f *ForkChoice) HasPayloadBlockHash(root, blockHash [32]byte) bool { + en := f.store.emptyNodeByRoot[root] + if en == nil || en.node == nil { + return false + } + if blockHash == en.node.blockHash { + _, ok := f.store.fullNodeByRoot[root] + return ok + } + return blockHash == f.store.parentHash(en) +} + // FullBeatsEmpty returns whether fork choice would select the full payload variant // for the given beacon block root. The caller MUST hold the forkchoice lock. func (f *ForkChoice) FullBeatsEmpty(root [32]byte) bool { diff --git a/beacon-chain/forkchoice/doubly-linked-tree/gloas_test.go b/beacon-chain/forkchoice/doubly-linked-tree/gloas_test.go index b5f81a657ee2..ed7cfd547201 100644 --- a/beacon-chain/forkchoice/doubly-linked-tree/gloas_test.go +++ b/beacon-chain/forkchoice/doubly-linked-tree/gloas_test.go @@ -434,6 +434,28 @@ func TestParentHash_UnknownRoot(t *testing.T) { assert.Equal(t, [32]byte{}, f.ParentHash(indexToHash(999))) } +func TestHasPayloadBlockHash(t *testing.T) { + f := setupGloas(t, 0, 0) + ctx := t.Context() + + root := indexToHash(1) + fullHash := indexToHash(100) + emptyHash := params.BeaconConfig().ZeroHash + st, roblock, err := prepareGloasForkchoiceState(ctx, 1, root, emptyHash, fullHash, emptyHash, 0, 0) + require.NoError(t, err) + require.NoError(t, f.InsertNode(ctx, st, roblock)) + + assert.Equal(t, true, f.HasPayloadBlockHash(root, emptyHash)) + assert.Equal(t, false, f.HasPayloadBlockHash(root, fullHash)) + assert.Equal(t, false, f.HasPayloadBlockHash(root, indexToHash(999))) + + pe, err := prepareGloasForkchoicePayload(root) + require.NoError(t, err) + require.NoError(t, f.InsertPayload(pe)) + assert.Equal(t, true, f.HasPayloadBlockHash(root, fullHash)) + assert.Equal(t, false, f.HasPayloadBlockHash(indexToHash(999), fullHash)) +} + func TestGloasBlock_ChildBuildsOnFull(t *testing.T) { f := setupGloas(t, 0, 0) ctx := t.Context() diff --git a/beacon-chain/forkchoice/interfaces.go b/beacon-chain/forkchoice/interfaces.go index 9d3a41b485b1..ba95be60a09f 100644 --- a/beacon-chain/forkchoice/interfaces.go +++ b/beacon-chain/forkchoice/interfaces.go @@ -96,6 +96,7 @@ type FastGetter interface { Weight(root [32]byte) (uint64, error) ConsensusNodeWeight(root [32]byte) (uint64, error) PayloadWeights(root [32]byte) (emptyWeight, fullWeight uint64, err error) + HasPayloadBlockHash(root, blockHash [32]byte) bool PTCVotedEarlyAndAvailable(root [32]byte) bool PTCVotedLate(root [32]byte) bool ParentRoot(root [32]byte) ([32]byte, error) diff --git a/beacon-chain/forkchoice/ro.go b/beacon-chain/forkchoice/ro.go index 88db2a4a609d..1907a40ab25a 100644 --- a/beacon-chain/forkchoice/ro.go +++ b/beacon-chain/forkchoice/ro.go @@ -177,6 +177,13 @@ func (ro *ROForkChoice) PayloadWeights(root [32]byte) (uint64, uint64, error) { return ro.getter.PayloadWeights(root) } +// HasPayloadBlockHash delegates to the underlying forkchoice call, under a lock. +func (ro *ROForkChoice) HasPayloadBlockHash(root, blockHash [32]byte) bool { + ro.l.RLock() + defer ro.l.RUnlock() + return ro.getter.HasPayloadBlockHash(root, blockHash) +} + // IsOptimistic delegates to the underlying forkchoice call, under a lock. func (ro *ROForkChoice) IsOptimistic(root [32]byte) (bool, error) { ro.l.RLock() diff --git a/beacon-chain/forkchoice/ro_test.go b/beacon-chain/forkchoice/ro_test.go index 8c346ea5216e..c26c5a7271e3 100644 --- a/beacon-chain/forkchoice/ro_test.go +++ b/beacon-chain/forkchoice/ro_test.go @@ -50,6 +50,7 @@ const ( dependentRootForEpochCalled canonicalNodeAtSlotCalled payloadWeightsCalled + hasPayloadBlockHashCalled ) func _discard(t *testing.T, e error) { @@ -197,6 +198,11 @@ func TestROLocking(t *testing.T) { call: gasLimitCalled, cb: func(g FastGetter) { _, err := g.GasLimit([32]byte{}); _discard(t, err) }, }, + { + name: "hasPayloadBlockHashCalled", + call: hasPayloadBlockHashCalled, + cb: func(g FastGetter) { g.HasPayloadBlockHash([32]byte{}, [32]byte{}) }, + }, { name: "parentHashCalled", call: parentHashCalled, @@ -344,6 +350,11 @@ func (ro *mockROForkchoice) PayloadWeights(_ [32]byte) (uint64, uint64, error) { return 0, 0, nil } +func (ro *mockROForkchoice) HasPayloadBlockHash(_, _ [32]byte) bool { + ro.calls = append(ro.calls, hasPayloadBlockHashCalled) + return false +} + func (ro *mockROForkchoice) IsOptimistic(_ [32]byte) (bool, error) { ro.calls = append(ro.calls, isOptimisticCalled) return false, nil diff --git a/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bid.go b/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bid.go index 1915e1046b13..7aacdea7a057 100644 --- a/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bid.go +++ b/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bid.go @@ -223,7 +223,9 @@ func (vs *Server) validateBuilderBid(head state.BeaconState, signed *ethpb.Signe if err := v.VerifyParentBlockRootSeen(func(root [32]byte) bool { return root == q.parentRoot }); err != nil { return err } - if err := v.VerifyParentBlockHash(func([32]byte) ([32]byte, error) { return q.parentHash, nil }); err != nil { + if err := v.VerifyParentBlockHash(func(root, hash [32]byte) bool { + return root == q.parentRoot && hash == q.parentHash + }); err != nil { return err } if err := v.VerifyBuilderActive(head); err != nil { diff --git a/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bid_builder_test.go b/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bid_builder_test.go index 19a3a7565791..b4775366ee42 100644 --- a/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bid_builder_test.go +++ b/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_bid_builder_test.go @@ -27,7 +27,7 @@ type fakeBidVerifier struct { slotErr, activeErr, versionErr, coverErr, blobErr, randaoErr, sigErr error rootSeenErr, parentHashErr, feeErr, gasErr error rootSeenFn func([32]byte) bool - resolveFn func([32]byte) ([32]byte, error) + hasPayloadFn func([32]byte, [32]byte) bool } func (v *fakeBidVerifier) VerifyCurrentOrNextSlot() error { return nil } @@ -49,8 +49,8 @@ func (v *fakeBidVerifier) VerifyParentBlockRootSeen(fn func([32]byte) bool) erro return v.rootSeenErr } func (v *fakeBidVerifier) VerifyBidSlotHigherThanParent(primitives.Slot) error { return nil } -func (v *fakeBidVerifier) VerifyParentBlockHash(fn func([32]byte) ([32]byte, error)) error { - v.resolveFn = fn +func (v *fakeBidVerifier) VerifyParentBlockHash(fn func([32]byte, [32]byte) bool) error { + v.hasPayloadFn = fn return v.parentHashErr } func (v *fakeBidVerifier) VerifyGasLimitTargetCompatible(uint64, uint64) error { return v.gasErr } @@ -203,9 +203,8 @@ func TestValidateBuilderBid(t *testing.T) { // The parent-linkage closures must match only the block being produced. require.Equal(t, true, captured.rootSeenFn(parentRoot)) require.Equal(t, false, captured.rootSeenFn([32]byte{7, 7, 7})) - gotHash, err := captured.resolveFn([32]byte{}) - require.NoError(t, err) - require.Equal(t, parentHash, gotHash) + require.Equal(t, true, captured.hasPayloadFn(parentRoot, parentHash)) + require.Equal(t, false, captured.hasPayloadFn(parentRoot, [32]byte{8, 8, 8})) }) t.Run("verifier check fails", func(t *testing.T) { diff --git a/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_submit_bid.go b/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_submit_bid.go index 256b0123b3a6..1c8c781c84cb 100644 --- a/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_submit_bid.go +++ b/beacon-chain/rpc/prysm/v1alpha1/validator/proposer_submit_bid.go @@ -113,7 +113,7 @@ func (vs *Server) validateSubmittedBid(ctx context.Context, signed *ethpb.Signed if err := v.VerifyBuilderCanCoverBid(st); err != nil { return status.Errorf(codes.InvalidArgument, "%v", err) } - if err := v.VerifyParentBlockHash(vs.ForkchoiceFetcher.BlockHash); err != nil { + if err := v.VerifyParentBlockHash(vs.ForkchoiceFetcher.HasPayloadBlockHash); err != nil { return status.Errorf(codes.InvalidArgument, "%v", err) } parentGasLimit, err := vs.ForkchoiceFetcher.GasLimit(parentRoot) diff --git a/beacon-chain/sync/validate_execution_payload_bid.go b/beacon-chain/sync/validate_execution_payload_bid.go index 6de9ce2f04a4..f438bb8e07e0 100644 --- a/beacon-chain/sync/validate_execution_payload_bid.go +++ b/beacon-chain/sync/validate_execution_payload_bid.go @@ -123,7 +123,7 @@ func (s *Service) validateExecutionPayloadBidGossip(ctx context.Context, pid pee } // [IGNORE] bid.parent_block_hash is the block hash of a known execution payload in fork choice // and bid.gas_limit is compatible with parent_gas_limit and the proposer's target. - if err := v.VerifyParentBlockHash(s.cfg.chain.BlockHash); err != nil { + if err := v.VerifyParentBlockHash(s.cfg.chain.HasPayloadBlockHash); err != nil { return pubsub.ValidationIgnore, err } parentGasLimit, err := s.cfg.chain.GasLimit(parentBlockRoot) diff --git a/beacon-chain/sync/validate_execution_payload_bid_test.go b/beacon-chain/sync/validate_execution_payload_bid_test.go index db1df0ba9c61..3a11edf32b43 100644 --- a/beacon-chain/sync/validate_execution_payload_bid_test.go +++ b/beacon-chain/sync/validate_execution_payload_bid_test.go @@ -463,7 +463,7 @@ func (m *mockExecutionPayloadBidVerifier) VerifyBidSlotHigherThanParent(primitiv return m.errSlotHigherThanParent } -func (m *mockExecutionPayloadBidVerifier) VerifyParentBlockHash(func([32]byte) ([32]byte, error)) error { +func (m *mockExecutionPayloadBidVerifier) VerifyParentBlockHash(func([32]byte, [32]byte) bool) error { return m.errParentBlockHash } diff --git a/beacon-chain/verification/execution_payload_bid.go b/beacon-chain/verification/execution_payload_bid.go index 96d14816cacb..149409d73fd5 100644 --- a/beacon-chain/verification/execution_payload_bid.go +++ b/beacon-chain/verification/execution_payload_bid.go @@ -285,23 +285,19 @@ func (v *BidVerifier) VerifyBidSlotHigherThanParent(parentSlot primitives.Slot) return nil } -// VerifyParentBlockHash verifies the parent execution block hash matches forkchoice for the bid parent root. -func (v *BidVerifier) VerifyParentBlockHash(resolveBlockHash func([32]byte) ([32]byte, error)) (err error) { +// VerifyParentBlockHash verifies that the bid references an available parent payload. +func (v *BidVerifier) VerifyParentBlockHash(hasPayloadBlockHash func([32]byte, [32]byte) bool) (err error) { defer v.record(RequireBidParentBlockHashValid, &err) bid, err := v.b.Bid() if err != nil { return errors.Wrap(err, "failed to get bid") } - if resolveBlockHash == nil { - return fmt.Errorf("%w: no parent block hash resolver", ErrBidParentBlockHashMismatch) + if hasPayloadBlockHash == nil { + return fmt.Errorf("%w: no parent block hash lookup", ErrBidParentBlockHashMismatch) } - parentHash, err := resolveBlockHash(bid.ParentBlockRoot()) - if err != nil { - return errors.Wrap(err, "failed to resolve parent block hash") - } - if parentHash != bid.ParentBlockHash() { - return fmt.Errorf("%w: bid=%#x forkchoice=%#x", ErrBidParentBlockHashMismatch, bid.ParentBlockHash(), parentHash) + if !hasPayloadBlockHash(bid.ParentBlockRoot(), bid.ParentBlockHash()) { + return fmt.Errorf("%w: root=%#x hash=%#x", ErrBidParentBlockHashMismatch, bid.ParentBlockRoot(), bid.ParentBlockHash()) } return nil } diff --git a/beacon-chain/verification/execution_payload_bid_test.go b/beacon-chain/verification/execution_payload_bid_test.go index 7c6bd1c48b0d..1b048e32bf6f 100644 --- a/beacon-chain/verification/execution_payload_bid_test.go +++ b/beacon-chain/verification/execution_payload_bid_test.go @@ -174,14 +174,15 @@ func TestBidVerifier_VerifyParentBlockHash(t *testing.T) { require.NoError(t, err) wantHash := [32]byte(signed.Message.ParentBlockHash) + wantRoot := [32]byte(signed.Message.ParentBlockRoot) verifier := &BidVerifier{results: newResults(RequireBidParentBlockHashValid), b: wrapped} - require.NoError(t, verifier.VerifyParentBlockHash(func([32]byte) ([32]byte, error) { - return wantHash, nil + require.NoError(t, verifier.VerifyParentBlockHash(func(root, hash [32]byte) bool { + return root == wantRoot && hash == wantHash })) verifier = &BidVerifier{results: newResults(RequireBidParentBlockHashValid), b: wrapped} - require.ErrorIs(t, verifier.VerifyParentBlockHash(func([32]byte) ([32]byte, error) { - return [32]byte{0xFF}, nil + require.ErrorIs(t, verifier.VerifyParentBlockHash(func([32]byte, [32]byte) bool { + return false }), ErrBidParentBlockHashMismatch) } diff --git a/beacon-chain/verification/interface.go b/beacon-chain/verification/interface.go index c4a4c0daa677..24089bcceeae 100644 --- a/beacon-chain/verification/interface.go +++ b/beacon-chain/verification/interface.go @@ -110,7 +110,7 @@ type ExecutionPayloadBidVerifier interface { VerifyPrevRandao(state.ReadOnlyBeaconState) error VerifyParentBlockRootSeen(func([32]byte) bool) error VerifyBidSlotHigherThanParent(parentSlot primitives.Slot) error - VerifyParentBlockHash(func([32]byte) ([32]byte, error)) error + VerifyParentBlockHash(func([32]byte, [32]byte) bool) error VerifyGasLimitTargetCompatible(parentGasLimit, targetGasLimit uint64) error VerifyBuilderCanCoverBid(state.ReadOnlyBeaconState) error VerifySignature(state.ReadOnlyBeaconState) error diff --git a/changelog/terence_fix-gloas-bid-parent-payload-status.md b/changelog/terence_fix-gloas-bid-parent-payload-status.md new file mode 100644 index 000000000000..8babdaae00c9 --- /dev/null +++ b/changelog/terence_fix-gloas-bid-parent-payload-status.md @@ -0,0 +1,3 @@ +### Fixed + +- Accept execution payload bids building on the parent's empty branch, fixing valid bid rejections after a missed payload reveal. From 21b1d046b0c9e00a6d1a26fa74496bc4535048a4 Mon Sep 17 00:00:00 2001 From: terence Date: Wed, 22 Jul 2026 15:41:41 -0700 Subject: [PATCH 09/10] Fix PTC blob_data_available to use data column availability (#17222) - `BlobDataAvailable` in payload attestation data was populated from `HasFullNode`, which only flips after the envelope is fully imported - Per spec, `blob_data_available` is `is_data_available(beacon_block_root)`, independent of envelope import - New `DataAvailable` getter checks the non-blocking column store status first, and only reads the block's bid when no columns are stored, since an empty column summary can't distinguish a blobless payload from missing data and this order avoids a DB block read in the common case --- beacon-chain/blockchain/chain_info.go | 9 ++- .../receive_execution_payload_envelope.go | 29 ++++++++ ...receive_execution_payload_envelope_test.go | 35 ++++++++++ beacon-chain/blockchain/testing/mock.go | 10 +++ beacon-chain/rpc/core/validator.go | 14 ++-- beacon-chain/rpc/core/validator_test.go | 69 +++++++++++++++---- .../rpc/eth/validator/handlers_test.go | 8 +-- .../validator/payload_attestation_test.go | 2 +- changelog/terence_fix_ptc_blob_da_getter.md | 3 + 9 files changed, 156 insertions(+), 23 deletions(-) create mode 100644 changelog/terence_fix_ptc_blob_da_getter.md diff --git a/beacon-chain/blockchain/chain_info.go b/beacon-chain/blockchain/chain_info.go index 54212fe5c142..f023b56ad578 100644 --- a/beacon-chain/blockchain/chain_info.go +++ b/beacon-chain/blockchain/chain_info.go @@ -32,6 +32,14 @@ type ChainInfoFetcher interface { ForkFetcher HeadDomainFetcher ForkchoiceFetcher + PayloadAvailabilityFetcher +} + +// PayloadAvailabilityFetcher provides the payload arrival and blob data availability status +// used to build payload attestations. +type PayloadAvailabilityFetcher interface { + PayloadEarly([32]byte) (bool, bool) + DataAvailable(context.Context, [32]byte, primitives.Slot) (bool, error) } // ForkchoiceFetcher defines a common interface for methods that access directly @@ -51,7 +59,6 @@ type ForkchoiceFetcher interface { HighestReceivedBlockRoot() [32]byte HasNode([32]byte) bool HasFullNode([32]byte) bool - PayloadEarly([32]byte) (bool, bool) FullBeatsEmpty([32]byte) bool ReceivedBlocksLastEpoch() (uint64, error) InsertNode(context.Context, state.BeaconState, consensus_blocks.ROBlock) error diff --git a/beacon-chain/blockchain/receive_execution_payload_envelope.go b/beacon-chain/blockchain/receive_execution_payload_envelope.go index dfc300f5910e..ba9633663799 100644 --- a/beacon-chain/blockchain/receive_execution_payload_envelope.go +++ b/beacon-chain/blockchain/receive_execution_payload_envelope.go @@ -381,6 +381,35 @@ func (s *Service) PayloadEarly(root [32]byte) (bool, bool) { return s.payloadArrivals.isEarly(root) } +// DataAvailable reports whether all blob data committed to by the block at root is available now. +func (s *Service) DataAvailable(ctx context.Context, root [32]byte, slot primitives.Slot) (bool, error) { + available, err := s.dataColumnsAvailableNow(ctx, root, slot) + if err != nil { + return false, errors.Wrap(err, "data columns available now") + } + if available { + return true, nil + } + + s.headLock.RLock() + var b interfaces.ReadOnlySignedBeaconBlock + if s.head != nil && s.head.root == root { + b = s.head.block + } + s.headLock.RUnlock() + if b == nil { + b, err = s.getBlock(ctx, root) + if err != nil { + return false, errors.Wrap(err, "could not get block") + } + } + sbid, err := b.Block().Body().SignedExecutionPayloadBid() + if err != nil { + return false, errors.Wrap(err, "could not get signed execution payload bid from block") + } + return len(sbid.GetMessage().GetBlobKzgCommitments()) == 0, nil +} + // notifyForkchoiceUpdateGloas takes the block hash directly because Gloas // blocks don't carry an execution payload in the body. func (s *Service) notifyForkchoiceUpdateGloas(ctx context.Context, blockHash [32]byte, attributes payloadattribute.Attributer) (*enginev1.PayloadIDBytes, error) { diff --git a/beacon-chain/blockchain/receive_execution_payload_envelope_test.go b/beacon-chain/blockchain/receive_execution_payload_envelope_test.go index 7a69efd5016f..ed8fcd6f566c 100644 --- a/beacon-chain/blockchain/receive_execution_payload_envelope_test.go +++ b/beacon-chain/blockchain/receive_execution_payload_envelope_test.go @@ -7,6 +7,7 @@ import ( "github.com/OffchainLabs/prysm/v7/beacon-chain/core/feed" statefeed "github.com/OffchainLabs/prysm/v7/beacon-chain/core/feed/state" "github.com/OffchainLabs/prysm/v7/beacon-chain/core/signing" + "github.com/OffchainLabs/prysm/v7/beacon-chain/db/filesystem" "github.com/OffchainLabs/prysm/v7/beacon-chain/execution" mockExecution "github.com/OffchainLabs/prysm/v7/beacon-chain/execution/testing" state_native "github.com/OffchainLabs/prysm/v7/beacon-chain/state/state-native" @@ -20,6 +21,7 @@ import ( ethpb "github.com/OffchainLabs/prysm/v7/proto/prysm/v1alpha1" "github.com/OffchainLabs/prysm/v7/runtime/version" "github.com/OffchainLabs/prysm/v7/testing/require" + "github.com/OffchainLabs/prysm/v7/testing/util" "github.com/OffchainLabs/prysm/v7/time/slots" ) @@ -248,6 +250,39 @@ func TestReceiveExecutionPayloadEnvelope_EmitsHeadV2Event(t *testing.T) { }) } +func TestDataAvailable(t *testing.T) { + saveGloasBlock := func(t *testing.T, service *Service, commitments [][]byte) [32]byte { + b := util.NewBeaconBlockGloas() + b.Block.Body.SignedExecutionPayloadBid.Message.BlobKzgCommitments = commitments + sb, err := blocks.NewSignedBeaconBlock(b) + require.NoError(t, err) + root, err := sb.Block().HashTreeRoot() + require.NoError(t, err) + require.NoError(t, service.cfg.BeaconDB.SaveBlock(t.Context(), sb)) + return root + } + + t.Run("unknown block returns error", func(t *testing.T) { + service, _ := minimalTestService(t, WithDataColumnStorage(filesystem.NewEphemeralDataColumnStorage(t))) + _, err := service.DataAvailable(t.Context(), [32]byte{'a'}, 0) + require.NotNil(t, err) + }) + t.Run("no blob commitments", func(t *testing.T) { + service, _ := minimalTestService(t, WithDataColumnStorage(filesystem.NewEphemeralDataColumnStorage(t))) + root := saveGloasBlock(t, service, nil) + available, err := service.DataAvailable(t.Context(), root, 0) + require.NoError(t, err) + require.Equal(t, true, available) + }) + t.Run("commitments with no columns stored", func(t *testing.T) { + service, _ := minimalTestService(t, WithDataColumnStorage(filesystem.NewEphemeralDataColumnStorage(t))) + root := saveGloasBlock(t, service, [][]byte{bytesutil.PadTo([]byte{0x01}, 48)}) + available, err := service.DataAvailable(t.Context(), root, 0) + require.NoError(t, err) + require.Equal(t, false, available) + }) +} + // countStateEventsByType is a helper function for counting the number of events // of each type received on a channel. func countStateEventsByType(ch chan *feed.Event) map[feed.EventType]int { diff --git a/beacon-chain/blockchain/testing/mock.go b/beacon-chain/blockchain/testing/mock.go index 091a34273b4b..ef792b215208 100644 --- a/beacon-chain/blockchain/testing/mock.go +++ b/beacon-chain/blockchain/testing/mock.go @@ -52,6 +52,8 @@ type ChainService struct { MockCanonicalRoots map[primitives.Slot][32]byte InitSyncBlockRoots map[[32]byte]bool MockPayloadEarly map[[32]byte]bool + MockDataAvailable map[[32]byte]bool + MockDataAvailableErr error ParentPayloadReadyVal *bool BlockSlot primitives.Slot OptimisticRoots map[[32]byte]bool @@ -819,6 +821,14 @@ func (s *ChainService) PayloadEarly(root [32]byte) (bool, bool) { return early, ok } +// DataAvailable mocks the same method in the chain service. +func (s *ChainService) DataAvailable(_ context.Context, root [32]byte, _ primitives.Slot) (bool, error) { + if s.MockDataAvailableErr != nil { + return false, s.MockDataAvailableErr + } + return s.MockDataAvailable[root], nil +} + // FullBeatsEmpty mocks the same method in the chain service. func (s *ChainService) FullBeatsEmpty(root [32]byte) bool { if s.ForkChoiceStore != nil { diff --git a/beacon-chain/rpc/core/validator.go b/beacon-chain/rpc/core/validator.go index 524bc41ca76f..57a5d86ca580 100644 --- a/beacon-chain/rpc/core/validator.go +++ b/beacon-chain/rpc/core/validator.go @@ -939,7 +939,7 @@ func (s *Service) PayloadAttestationData( ctx context.Context, slot primitives.Slot, ) (*ethpb.PayloadAttestationData, *RpcError) { - _, span := trace.StartSpan(ctx, "coreService.PayloadAttestationData") + ctx, span := trace.StartSpan(ctx, "coreService.PayloadAttestationData") defer span.End() if slots.ToEpoch(slot) < params.BeaconConfig().GloasForkEpoch { @@ -965,7 +965,7 @@ func (s *Service) PayloadAttestationData( if cached := s.payloadAttestationData.Load(); cached != nil && cached.Slot == slot { return cached, nil } - data, rpcErr := s.buildPayloadAttestationData(slot) + data, rpcErr := s.buildPayloadAttestationData(ctx, slot) if rpcErr != nil { return rpcErr, nil } @@ -1012,7 +1012,7 @@ func (s *Service) hasCanonicalShuffling(root [32]byte, slot primitives.Slot) boo // buildPayloadAttestationData builds a payload attestation message for the validator to sign. It attempts first // to build from the highest received slot but only if it is compatible with the head view. -func (s *Service) buildPayloadAttestationData(slot primitives.Slot) (*ethpb.PayloadAttestationData, *RpcError) { +func (s *Service) buildPayloadAttestationData(ctx context.Context, slot primitives.Slot) (*ethpb.PayloadAttestationData, *RpcError) { highestReceivedSlot := s.ForkchoiceFetcher.HighestReceivedBlockSlot() if highestReceivedSlot != slot { return nil, &RpcError{Reason: NoContent, Err: fmt.Errorf("no block found at slot=%d", slot)} @@ -1024,11 +1024,15 @@ func (s *Service) buildPayloadAttestationData(slot primitives.Slot) (*ethpb.Payl if !s.hasCanonicalShuffling(root, slot) { return nil, &RpcError{Reason: Unavailable, Err: fmt.Errorf("no canonical shuffling block for slot %d", slot)} } - payloadEarly, _ := s.ForkchoiceFetcher.PayloadEarly(root) + available, err := s.ChainInfoFetcher.DataAvailable(ctx, root, slot) + if err != nil { + return nil, &RpcError{Reason: Internal, Err: fmt.Errorf("could not check data availability for block root %#x: %w", root, err)} + } + payloadEarly, _ := s.ChainInfoFetcher.PayloadEarly(root) return ðpb.PayloadAttestationData{ BeaconBlockRoot: root[:], Slot: slot, PayloadPresent: payloadEarly, - BlobDataAvailable: s.ForkchoiceFetcher.HasFullNode(root), + BlobDataAvailable: available, }, nil } diff --git a/beacon-chain/rpc/core/validator_test.go b/beacon-chain/rpc/core/validator_test.go index 1ba6f226a754..96281173c427 100644 --- a/beacon-chain/rpc/core/validator_test.go +++ b/beacon-chain/rpc/core/validator_test.go @@ -2,6 +2,7 @@ package core import ( "encoding/binary" + "errors" "sync" "testing" "time" @@ -217,7 +218,7 @@ func TestPayloadAttestationData(t *testing.T) { slot := primitives.Slot(5) root := bytesutil.PadTo([]byte("head-root"), 32) chain := &mockChain.ChainService{Slot: &slot, Root: root} - s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain} + s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain} data, rpcErr := s.PayloadAttestationData(t.Context(), slot) require.IsNil(t, rpcErr) @@ -238,10 +239,10 @@ func TestPayloadAttestationData(t *testing.T) { Slot: &slot, Root: root, MockCanonicalRoots: map[primitives.Slot][32]byte{slot: bytesutil.ToBytes32(root)}, - MockCanonicalFull: map[primitives.Slot]bool{slot: true}, + MockDataAvailable: map[[32]byte]bool{bytesutil.ToBytes32(root): true}, MockPayloadEarly: map[[32]byte]bool{bytesutil.ToBytes32(root): true}, } - s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain} + s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain} data, rpcErr := s.PayloadAttestationData(t.Context(), slot) require.IsNil(t, rpcErr) @@ -250,6 +251,50 @@ func TestPayloadAttestationData(t *testing.T) { assert.Equal(t, true, data.PayloadPresent) assert.Equal(t, true, data.BlobDataAvailable) }) + t.Run("data available while payload not yet inserted", func(t *testing.T) { + params.SetupTestConfigCleanup(t) + cfg := params.BeaconConfig().Copy() + cfg.GloasForkEpoch = 0 + params.OverrideBeaconConfig(cfg) + + slot := primitives.Slot(5) + root := bytesutil.PadTo([]byte("head-root"), 32) + chain := &mockChain.ChainService{ + Slot: &slot, + Root: root, + MockCanonicalRoots: map[primitives.Slot][32]byte{slot: bytesutil.ToBytes32(root)}, + MockCanonicalFull: map[primitives.Slot]bool{slot: false}, + MockDataAvailable: map[[32]byte]bool{bytesutil.ToBytes32(root): true}, + MockPayloadEarly: map[[32]byte]bool{bytesutil.ToBytes32(root): true}, + } + s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain} + + data, rpcErr := s.PayloadAttestationData(t.Context(), slot) + require.IsNil(t, rpcErr) + assert.Equal(t, true, data.PayloadPresent) + assert.Equal(t, true, data.BlobDataAvailable) + }) + t.Run("data availability lookup error → Internal", func(t *testing.T) { + params.SetupTestConfigCleanup(t) + cfg := params.BeaconConfig().Copy() + cfg.GloasForkEpoch = 0 + params.OverrideBeaconConfig(cfg) + + slot := primitives.Slot(5) + root := bytesutil.PadTo([]byte("head-root"), 32) + chain := &mockChain.ChainService{ + Slot: &slot, + Root: root, + MockCanonicalRoots: map[primitives.Slot][32]byte{slot: bytesutil.ToBytes32(root)}, + MockDataAvailableErr: errors.New("block lookup failed"), + } + s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain} + + _, rpcErr := s.PayloadAttestationData(t.Context(), slot) + require.NotNil(t, rpcErr) + assert.Equal(t, ErrorReason(Internal), rpcErr.Reason) + assert.ErrorContains(t, "could not check data availability", rpcErr.Err) + }) t.Run("before PTC deadline and not final → Unavailable", func(t *testing.T) { params.SetupTestConfigCleanup(t) cfg := params.BeaconConfig().Copy() @@ -262,7 +307,7 @@ func TestPayloadAttestationData(t *testing.T) { Genesis: time.Now(), Root: bytesutil.PadTo([]byte{0xAA}, 32), } - s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain} + s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain} _, rpcErr := s.PayloadAttestationData(t.Context(), slot) require.NotNil(t, rpcErr) @@ -283,10 +328,10 @@ func TestPayloadAttestationData(t *testing.T) { Genesis: time.Now(), Root: root, MockCanonicalRoots: map[primitives.Slot][32]byte{slot: bytesutil.ToBytes32(root)}, - MockCanonicalFull: map[primitives.Slot]bool{slot: true}, + MockDataAvailable: map[[32]byte]bool{bytesutil.ToBytes32(root): true}, MockPayloadEarly: map[[32]byte]bool{bytesutil.ToBytes32(root): true}, } - s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain} + s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain} data, rpcErr := s.PayloadAttestationData(t.Context(), slot) require.IsNil(t, rpcErr) @@ -309,7 +354,7 @@ func TestPayloadAttestationData(t *testing.T) { MockCanonicalRoots: map[primitives.Slot][32]byte{slot: bytesutil.ToBytes32(root)}, MockCanonicalFull: map[primitives.Slot]bool{slot: false}, } - s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain} + s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain} first, rpcErr := s.PayloadAttestationData(t.Context(), slot) require.IsNil(t, rpcErr) @@ -355,7 +400,7 @@ func TestPayloadAttestationData(t *testing.T) { MockCanonicalRoots: map[primitives.Slot][32]byte{slot: bytesutil.ToBytes32(root)}, MockCanonicalFull: map[primitives.Slot]bool{slot: false}, } - s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain} + s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain} const callers = 16 results := make([]*ethpb.PayloadAttestationData, callers) @@ -395,7 +440,7 @@ func TestPayloadAttestationData(t *testing.T) { return bytesutil.ToBytes32(bytesutil.PadTo([]byte{0x02}, 32)), nil }, } - s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain} + s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain} _, rpcErr := s.PayloadAttestationData(t.Context(), slot) require.NotNil(t, rpcErr) @@ -416,14 +461,14 @@ func TestPayloadAttestationData(t *testing.T) { Slot: &slot, Root: root, MockCanonicalRoots: map[primitives.Slot][32]byte{slot: bytesutil.ToBytes32(root)}, - MockCanonicalFull: map[primitives.Slot]bool{slot: true}, + MockDataAvailable: map[[32]byte]bool{bytesutil.ToBytes32(root): true}, MockPayloadEarly: map[[32]byte]bool{bytesutil.ToBytes32(root): true}, HeadDependentRoot: dependent, DependentRootCB: func(_ [32]byte, _ primitives.Epoch) ([32]byte, error) { return dependent, nil }, } - s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain} + s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain} data, rpcErr := s.PayloadAttestationData(t.Context(), slot) require.IsNil(t, rpcErr) @@ -453,7 +498,7 @@ func TestPayloadAttestationData(t *testing.T) { return dependent, nil }, } - s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain} + s := &Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain} data, rpcErr := s.PayloadAttestationData(t.Context(), slot) require.IsNil(t, rpcErr) diff --git a/beacon-chain/rpc/eth/validator/handlers_test.go b/beacon-chain/rpc/eth/validator/handlers_test.go index 9df1eda01712..a0ba0726ad4e 100644 --- a/beacon-chain/rpc/eth/validator/handlers_test.go +++ b/beacon-chain/rpc/eth/validator/handlers_test.go @@ -4052,7 +4052,7 @@ func TestGetPayloadAttestationData(t *testing.T) { HeadFetcher: chainService, TimeFetcher: chainService, OptimisticModeFetcher: chainService, - CoreService: &core.Service{GenesisTimeFetcher: chainService, ForkchoiceFetcher: chainService, HeadFetcher: chainService}, + CoreService: &core.Service{GenesisTimeFetcher: chainService, ForkchoiceFetcher: chainService, HeadFetcher: chainService, ChainInfoFetcher: chainService}, } request := httptest.NewRequest(http.MethodGet, "http://example.com/eth/v1/validator/payload_attestation_data?slot=0", nil) @@ -4100,7 +4100,7 @@ func TestGetPayloadAttestationData(t *testing.T) { Slot: &slot, Root: root, MockCanonicalRoots: map[primitives.Slot][32]byte{slot: bytesutil.ToBytes32(root)}, - MockCanonicalFull: map[primitives.Slot]bool{slot: true}, + MockDataAvailable: map[[32]byte]bool{bytesutil.ToBytes32(root): true}, MockPayloadEarly: map[[32]byte]bool{bytesutil.ToBytes32(root): true}, } s := &Server{ @@ -4108,7 +4108,7 @@ func TestGetPayloadAttestationData(t *testing.T) { HeadFetcher: chainService, TimeFetcher: chainService, OptimisticModeFetcher: chainService, - CoreService: &core.Service{GenesisTimeFetcher: chainService, ForkchoiceFetcher: chainService, HeadFetcher: chainService}, + CoreService: &core.Service{GenesisTimeFetcher: chainService, ForkchoiceFetcher: chainService, HeadFetcher: chainService, ChainInfoFetcher: chainService}, } request := httptest.NewRequest(http.MethodGet, "http://example.com/eth/v1/validator/payload_attestation_data?slot=5", nil) @@ -4141,7 +4141,7 @@ func TestGetPayloadAttestationData(t *testing.T) { HeadFetcher: chainService, TimeFetcher: chainService, OptimisticModeFetcher: chainService, - CoreService: &core.Service{GenesisTimeFetcher: chainService, ForkchoiceFetcher: chainService, HeadFetcher: chainService}, + CoreService: &core.Service{GenesisTimeFetcher: chainService, ForkchoiceFetcher: chainService, HeadFetcher: chainService, ChainInfoFetcher: chainService}, } request := httptest.NewRequest(http.MethodGet, "http://example.com/eth/v1/validator/payload_attestation_data?slot=5", nil) diff --git a/beacon-chain/rpc/prysm/v1alpha1/validator/payload_attestation_test.go b/beacon-chain/rpc/prysm/v1alpha1/validator/payload_attestation_test.go index 2b176742bedf..2c9cd154fee7 100644 --- a/beacon-chain/rpc/prysm/v1alpha1/validator/payload_attestation_test.go +++ b/beacon-chain/rpc/prysm/v1alpha1/validator/payload_attestation_test.go @@ -55,7 +55,7 @@ func TestPayloadAttestationData_OK(t *testing.T) { TimeFetcher: chain, HeadFetcher: chain, ForkchoiceFetcher: chain, - CoreService: &core.Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain}, + CoreService: &core.Service{GenesisTimeFetcher: chain, ForkchoiceFetcher: chain, HeadFetcher: chain, ChainInfoFetcher: chain}, } resp, err := vs.PayloadAttestationData(t.Context(), ðpb.PayloadAttestationDataRequest{Slot: slot}) diff --git a/changelog/terence_fix_ptc_blob_da_getter.md b/changelog/terence_fix_ptc_blob_da_getter.md new file mode 100644 index 000000000000..c4f023d6ea27 --- /dev/null +++ b/changelog/terence_fix_ptc_blob_da_getter.md @@ -0,0 +1,3 @@ +### Fixed + +- PTC payload attestation now reports blob data availability from the data column store instead of forkchoice payload insertion. From 3909300213cf3863eac8c1245296362f44f8873e Mon Sep 17 00:00:00 2001 From: ethermachine <75843061+ethermachine@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:34:39 +0100 Subject: [PATCH 10/10] Add changelog fragment --- changelog/ethermachine_proposer-timing-games.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog/ethermachine_proposer-timing-games.md diff --git a/changelog/ethermachine_proposer-timing-games.md b/changelog/ethermachine_proposer-timing-games.md new file mode 100644 index 000000000000..a94e278685e7 --- /dev/null +++ b/changelog/ethermachine_proposer-timing-games.md @@ -0,0 +1,3 @@ +### Added + +- Opt-in proposer timing games support: `--enable-proposer-timing-games` and `--proposer-timing-game-delay` validator flags delay the block proposal request within the slot (clamped to stay safely before the attestation deadline), and a new `--builder-getheader-timeout` beacon node flag makes the previously hardcoded 1s builder getHeader timeout configurable.