diff --git a/.github/workflows/kurtosis/caplin-minimal-assertoor.io b/.github/workflows/kurtosis/caplin-minimal-assertoor.io
index 2f63c98d079..825a7ea1da4 100644
--- a/.github/workflows/kurtosis/caplin-minimal-assertoor.io
+++ b/.github/workflows/kurtosis/caplin-minimal-assertoor.io
@@ -6,11 +6,8 @@ participants:
cl_image: test/erigon:current
cl_log_level: "debug"
use_separate_vc: true
- vc_type: lighthouse
- # Keep lighthouse v7.0.1 here: v8 submits attestations only as Electra
- # SingleAttestation, which caplin's pre-Electra pool endpoint rejects on
- # this Deneb network (the pinned ethereum-package can't do Electra genesis).
- vc_image: sigp/lighthouse:v7.0.1
+ vc_type: lodestar
+ vc_image: chainsafe/lodestar:v1.46.0
network_params:
preset: "minimal"
deneb_fork_epoch: 0
diff --git a/.github/workflows/kurtosis/gloas-caplin-mixed.io b/.github/workflows/kurtosis/gloas-caplin-mixed.io
index e69692cd4bd..db6c29863be 100644
--- a/.github/workflows/kurtosis/gloas-caplin-mixed.io
+++ b/.github/workflows/kurtosis/gloas-caplin-mixed.io
@@ -1,6 +1,6 @@
participants:
- cl_type: lighthouse
- cl_image: ethpandaops/lighthouse:glamsterdam-devnet-7
+ cl_image: ethpandaops/lighthouse:glamsterdam-devnet-8
el_type: erigon
el_image: test/erigon:current
el_log_level: "debug"
@@ -10,14 +10,14 @@ participants:
- cl_type: caplin
cl_image: test/erigon:current
cl_log_level: "debug"
- cl_extra_params: ["--local-discovery", "--caplin.subscribe-all-topics"]
+ cl_extra_params: ["--local-discovery", "--caplin.subscribe-all-topics", "--caplin.builder.allow-private-urls"]
el_type: erigon
el_image: test/erigon:current
el_log_level: "debug"
el_extra_params: ["--experimental.bal"]
use_separate_vc: true
vc_type: lighthouse
- vc_image: ethpandaops/lighthouse:glamsterdam-devnet-7
+ vc_image: ethpandaops/lighthouse:glamsterdam-devnet-8
count: 1
global_log_level: 'debug'
network_params:
@@ -27,7 +27,7 @@ network_params:
fulu_fork_epoch: 0
gloas_fork_epoch: 1
ethereum_genesis_generator_params:
- image: ethpandaops/ethereum-genesis-generator:6.1.4
+ image: ethpandaops/ethereum-genesis-generator:6.2.0
additional_services: [assertoor]
assertoor_params:
run_stability_check: false
diff --git a/.github/workflows/kurtosis/gloas-three-cl-mixed.io b/.github/workflows/kurtosis/gloas-three-cl-mixed.io
index 5c60a0370e3..c41589a2ca3 100644
--- a/.github/workflows/kurtosis/gloas-three-cl-mixed.io
+++ b/.github/workflows/kurtosis/gloas-three-cl-mixed.io
@@ -1,6 +1,6 @@
participants:
- cl_type: lighthouse
- cl_image: ethpandaops/lighthouse:glamsterdam-devnet-7
+ cl_image: ethpandaops/lighthouse:glamsterdam-devnet-8
el_type: erigon
el_image: test/erigon:current
el_log_level: "debug"
@@ -8,26 +8,26 @@ participants:
supernode: true
count: 1
- cl_type: prysm
- cl_image: ethpandaops/prysm-beacon-chain:glamsterdam-devnet-7-minimal
+ cl_image: ethpandaops/prysm-beacon-chain:glamsterdam-devnet-8-minimal
el_type: erigon
el_image: test/erigon:current
el_log_level: "debug"
el_extra_params: ["--experimental.bal"]
use_separate_vc: true
vc_type: prysm
- vc_image: ethpandaops/prysm-validator:glamsterdam-devnet-7-minimal
+ vc_image: ethpandaops/prysm-validator:glamsterdam-devnet-8-minimal
count: 1
- cl_type: caplin
cl_image: test/erigon:current
cl_log_level: "debug"
- cl_extra_params: ["--local-discovery", "--caplin.subscribe-all-topics"]
+ cl_extra_params: ["--local-discovery", "--caplin.subscribe-all-topics", "--caplin.builder.allow-private-urls"]
el_type: erigon
el_image: test/erigon:current
el_log_level: "debug"
el_extra_params: ["--experimental.bal"]
use_separate_vc: true
vc_type: lighthouse
- vc_image: ethpandaops/lighthouse:glamsterdam-devnet-7
+ vc_image: ethpandaops/lighthouse:glamsterdam-devnet-8
count: 1
global_log_level: 'debug'
network_params:
@@ -37,7 +37,7 @@ network_params:
fulu_fork_epoch: 0
gloas_fork_epoch: 1
ethereum_genesis_generator_params:
- image: ethpandaops/ethereum-genesis-generator:6.1.4
+ image: ethpandaops/ethereum-genesis-generator:6.2.0
additional_services: [assertoor]
assertoor_params:
run_stability_check: false
diff --git a/.github/workflows/test-kurtosis-assertoor.yml b/.github/workflows/test-kurtosis-assertoor.yml
index 66fff6ca3c0..7e88457e3ae 100644
--- a/.github/workflows/test-kurtosis-assertoor.yml
+++ b/.github/workflows/test-kurtosis-assertoor.yml
@@ -225,7 +225,8 @@ jobs:
teku=$(yq -e '.participants_matrix.cl[] | select(.cl_type == "teku") | .cl_image' "$reg")
genesis_glamsterdam=$(yq -e '.ethereum_genesis_generator_params.image' "$glamsterdam_io")
spamoor_glamsterdam=$(yq -e '.spamoor_params.image' "$glamsterdam_io")
- lighthouse_vc=$(yq -e '.participants[] | select(.vc_type == "lighthouse") | .vc_image' "$caplin_io")
+ pectra_lighthouse=$(yq -e '.participants_matrix.cl[] | select(.cl_type == "lighthouse") | .cl_image' "$pectra_io")
+ minimal_vc=$(yq -e '.participants[] | select(.use_separate_vc == true) | .vc_image' "$caplin_io")
# pectra's clients and caplin-minimal's assertoor are warmed under the tag
# another suite pins. Bumping one alone leaves it out of the cache, which
# is a slow path rather than a failure: warn, and hand the tag to the
@@ -239,8 +240,6 @@ jobs:
}
check_warmed "$pectra_io" \
"$(yq -e '.participants_matrix.cl[] | select(.cl_type == "teku") | .cl_image' "$pectra_io")" "$teku"
- check_warmed "$pectra_io" \
- "$(yq -e '.participants_matrix.cl[] | select(.cl_type == "lighthouse") | .cl_image' "$pectra_io")" "$lighthouse_vc"
check_warmed "$caplin_io" "$(yq -e '.assertoor_params.image' "$caplin_io")" "$assertoor"
echo "UNCACHED_IMAGES=${uncached# }" >> "$GITHUB_ENV"
{
@@ -251,13 +250,14 @@ jobs:
echo "TEKU_IMAGE=$teku"
echo "GLAMSTERDAM_GENESIS_GENERATOR_IMAGE=$genesis_glamsterdam"
echo "GLAMSTERDAM_SPAMOOR_IMAGE=$spamoor_glamsterdam"
- echo "LIGHTHOUSE_VC_IMAGE=$lighthouse_vc"
+ echo "PECTRA_LIGHTHOUSE_IMAGE=$pectra_lighthouse"
+ echo "MINIMAL_VC_IMAGE=$minimal_vc"
} >> "$GITHUB_ENV"
# Every cached tag stays spelled out in the cache key so a glance at the
# Actions cache list shows what a key holds. Only the registry/org prefix
# is dropped, which keeps the key clear of actions/cache's 512-char cap.
key=docker-cl
- for image in "$lighthouse" "$lighthouse_vc" "$teku" "$assertoor" \
+ for image in "$lighthouse" "$pectra_lighthouse" "$minimal_vc" "$teku" "$assertoor" \
"kurtosis:$KURTOSIS_VERSION" "$KURTOSIS_VECTOR_IMAGE" "$KURTOSIS_FLUENTBIT_IMAGE" \
"$KURTOSIS_CURL_JQ_IMAGE" "$KURTOSIS_TRAEFIK_IMAGE" "$KURTOSIS_ALPINE_IMAGE" \
"$ETH2_VAL_TOOLS_IMAGE" "$KURTOSIS_SNOOPER_IMAGE" "$pectra" "$glamsterdam" \
@@ -290,7 +290,8 @@ jobs:
return 1
}
pull "${LIGHTHOUSE_IMAGE}"
- pull "${LIGHTHOUSE_VC_IMAGE}"
+ pull "${PECTRA_LIGHTHOUSE_IMAGE}"
+ pull "${MINIMAL_VC_IMAGE}"
pull "${TEKU_IMAGE}"
pull "${ASSERTOOR_IMAGE}"
pull "${ASSERTOOR_PECTRA_IMAGE}"
@@ -311,7 +312,8 @@ jobs:
pull "${KURTOSIS_SNOOPER_IMAGE}"
pull "${KURTOSIS_PYTHON_IMAGE}"
docker save "${LIGHTHOUSE_IMAGE}" -o /tmp/docker-cache/lighthouse.tar
- docker save "${LIGHTHOUSE_VC_IMAGE}" -o /tmp/docker-cache/lighthouse-vc.tar
+ docker save "${PECTRA_LIGHTHOUSE_IMAGE}" -o /tmp/docker-cache/pectra-lighthouse.tar
+ docker save "${MINIMAL_VC_IMAGE}" -o /tmp/docker-cache/minimal-vc.tar
docker save "${TEKU_IMAGE}" -o /tmp/docker-cache/teku.tar
docker save "${ASSERTOOR_IMAGE}" -o /tmp/docker-cache/assertoor.tar
docker save "${ASSERTOOR_PECTRA_IMAGE}" -o /tmp/docker-cache/assertoor-pectra.tar
@@ -481,7 +483,8 @@ jobs:
teku=$(yq -e '.participants_matrix.cl[] | select(.cl_type == "teku") | .cl_image' "$reg")
genesis_glamsterdam=$(yq -e '.ethereum_genesis_generator_params.image' "$glamsterdam_io")
spamoor_glamsterdam=$(yq -e '.spamoor_params.image' "$glamsterdam_io")
- lighthouse_vc=$(yq -e '.participants[] | select(.vc_type == "lighthouse") | .vc_image' "$caplin_io")
+ pectra_lighthouse=$(yq -e '.participants_matrix.cl[] | select(.cl_type == "lighthouse") | .cl_image' "$pectra_io")
+ minimal_vc=$(yq -e '.participants[] | select(.use_separate_vc == true) | .vc_image' "$caplin_io")
# pectra's clients and caplin-minimal's assertoor are warmed under the tag
# another suite pins. Bumping one alone leaves it out of the cache, which
# is a slow path rather than a failure: warn, and hand the tag to the
@@ -495,8 +498,6 @@ jobs:
}
check_warmed "$pectra_io" \
"$(yq -e '.participants_matrix.cl[] | select(.cl_type == "teku") | .cl_image' "$pectra_io")" "$teku"
- check_warmed "$pectra_io" \
- "$(yq -e '.participants_matrix.cl[] | select(.cl_type == "lighthouse") | .cl_image' "$pectra_io")" "$lighthouse_vc"
check_warmed "$caplin_io" "$(yq -e '.assertoor_params.image' "$caplin_io")" "$assertoor"
echo "UNCACHED_IMAGES=${uncached# }" >> "$GITHUB_ENV"
{
@@ -507,13 +508,14 @@ jobs:
echo "TEKU_IMAGE=$teku"
echo "GLAMSTERDAM_GENESIS_GENERATOR_IMAGE=$genesis_glamsterdam"
echo "GLAMSTERDAM_SPAMOOR_IMAGE=$spamoor_glamsterdam"
- echo "LIGHTHOUSE_VC_IMAGE=$lighthouse_vc"
+ echo "PECTRA_LIGHTHOUSE_IMAGE=$pectra_lighthouse"
+ echo "MINIMAL_VC_IMAGE=$minimal_vc"
} >> "$GITHUB_ENV"
# Every cached tag stays spelled out in the cache key so a glance at the
# Actions cache list shows what a key holds. Only the registry/org prefix
# is dropped, which keeps the key clear of actions/cache's 512-char cap.
key=docker-cl
- for image in "$lighthouse" "$lighthouse_vc" "$teku" "$assertoor" \
+ for image in "$lighthouse" "$pectra_lighthouse" "$minimal_vc" "$teku" "$assertoor" \
"kurtosis:$KURTOSIS_VERSION" "$KURTOSIS_VECTOR_IMAGE" "$KURTOSIS_FLUENTBIT_IMAGE" \
"$KURTOSIS_CURL_JQ_IMAGE" "$KURTOSIS_TRAEFIK_IMAGE" "$KURTOSIS_ALPINE_IMAGE" \
"$ETH2_VAL_TOOLS_IMAGE" "$KURTOSIS_SNOOPER_IMAGE" "$pectra" "$glamsterdam" \
@@ -536,7 +538,8 @@ jobs:
if: steps.cache-cl-images.outputs.cache-hit == 'true'
run: |
docker load -i /tmp/docker-cache/lighthouse.tar
- docker load -i /tmp/docker-cache/lighthouse-vc.tar
+ docker load -i /tmp/docker-cache/pectra-lighthouse.tar
+ docker load -i /tmp/docker-cache/minimal-vc.tar
docker load -i /tmp/docker-cache/teku.tar
docker load -i /tmp/docker-cache/assertoor.tar
docker load -i /tmp/docker-cache/assertoor-pectra.tar
@@ -570,7 +573,8 @@ jobs:
return 1
}
pull "${LIGHTHOUSE_IMAGE}"
- pull "${LIGHTHOUSE_VC_IMAGE}"
+ pull "${PECTRA_LIGHTHOUSE_IMAGE}"
+ pull "${MINIMAL_VC_IMAGE}"
pull "${TEKU_IMAGE}"
pull "${ASSERTOOR_IMAGE}"
pull "${ASSERTOOR_PECTRA_IMAGE}"
@@ -591,7 +595,8 @@ jobs:
pull "${KURTOSIS_SNOOPER_IMAGE}"
pull "${KURTOSIS_PYTHON_IMAGE}"
docker save "${LIGHTHOUSE_IMAGE}" -o /tmp/docker-cache/lighthouse.tar
- docker save "${LIGHTHOUSE_VC_IMAGE}" -o /tmp/docker-cache/lighthouse-vc.tar
+ docker save "${PECTRA_LIGHTHOUSE_IMAGE}" -o /tmp/docker-cache/pectra-lighthouse.tar
+ docker save "${MINIMAL_VC_IMAGE}" -o /tmp/docker-cache/minimal-vc.tar
docker save "${TEKU_IMAGE}" -o /tmp/docker-cache/teku.tar
docker save "${ASSERTOOR_IMAGE}" -o /tmp/docker-cache/assertoor.tar
docker save "${ASSERTOOR_PECTRA_IMAGE}" -o /tmp/docker-cache/assertoor-pectra.tar
diff --git a/cl/abstract/beacon_state.go b/cl/abstract/beacon_state.go
index 7588df22fad..2ee81b6d7ac 100644
--- a/cl/abstract/beacon_state.go
+++ b/cl/abstract/beacon_state.go
@@ -54,7 +54,7 @@ type BeaconStateExtension interface {
BaseReward(index uint64) (uint64, error)
SyncRewards() (proposerReward, participantReward uint64, err error)
CommitteeCount(epoch uint64) uint64
- GetAttestationParticipationFlagIndicies(data *solid.AttestationData, inclusionDelay uint64, skipAssert bool) ([]uint8, error)
+ GetAttestationParticipationFlagIndicies(data *solid.AttestationData, inclusionDelay, parentSlot uint64, skipAssert bool) ([]uint8, error)
GetBeaconCommitee(slot, committeeIndex uint64) ([]uint64, error)
ComputeNextSyncCommittee() (*solid.SyncCommittee, error)
GetAttestingIndicies(attestation *solid.Attestation, checkBitsLength bool) ([]uint64, error)
diff --git a/cl/abstract/mock_services/beacon_state_mock.go b/cl/abstract/mock_services/beacon_state_mock.go
index aa7e2bb47c0..5180a3984a2 100644
--- a/cl/abstract/mock_services/beacon_state_mock.go
+++ b/cl/abstract/mock_services/beacon_state_mock.go
@@ -1767,18 +1767,18 @@ func (c *MockBeaconStateGetActiveValidatorsIndicesCall) DoAndReturn(f func(uint6
}
// GetAttestationParticipationFlagIndicies mocks base method.
-func (m *MockBeaconState) GetAttestationParticipationFlagIndicies(data *solid.AttestationData, inclusionDelay uint64, skipAssert bool) ([]uint8, error) {
+func (m *MockBeaconState) GetAttestationParticipationFlagIndicies(data *solid.AttestationData, inclusionDelay, parentSlot uint64, skipAssert bool) ([]uint8, error) {
m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "GetAttestationParticipationFlagIndicies", data, inclusionDelay, skipAssert)
+ ret := m.ctrl.Call(m, "GetAttestationParticipationFlagIndicies", data, inclusionDelay, parentSlot, skipAssert)
ret0, _ := ret[0].([]uint8)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetAttestationParticipationFlagIndicies indicates an expected call of GetAttestationParticipationFlagIndicies.
-func (mr *MockBeaconStateMockRecorder) GetAttestationParticipationFlagIndicies(data, inclusionDelay, skipAssert any) *MockBeaconStateGetAttestationParticipationFlagIndiciesCall {
+func (mr *MockBeaconStateMockRecorder) GetAttestationParticipationFlagIndicies(data, inclusionDelay, parentSlot, skipAssert any) *MockBeaconStateGetAttestationParticipationFlagIndiciesCall {
mr.mock.ctrl.T.Helper()
- call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAttestationParticipationFlagIndicies", reflect.TypeOf((*MockBeaconState)(nil).GetAttestationParticipationFlagIndicies), data, inclusionDelay, skipAssert)
+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAttestationParticipationFlagIndicies", reflect.TypeOf((*MockBeaconState)(nil).GetAttestationParticipationFlagIndicies), data, inclusionDelay, parentSlot, skipAssert)
return &MockBeaconStateGetAttestationParticipationFlagIndiciesCall{Call: call}
}
@@ -1794,13 +1794,13 @@ func (c *MockBeaconStateGetAttestationParticipationFlagIndiciesCall) Return(arg0
}
// Do rewrite *gomock.Call.Do
-func (c *MockBeaconStateGetAttestationParticipationFlagIndiciesCall) Do(f func(*solid.AttestationData, uint64, bool) ([]uint8, error)) *MockBeaconStateGetAttestationParticipationFlagIndiciesCall {
+func (c *MockBeaconStateGetAttestationParticipationFlagIndiciesCall) Do(f func(*solid.AttestationData, uint64, uint64, bool) ([]uint8, error)) *MockBeaconStateGetAttestationParticipationFlagIndiciesCall {
c.Call = c.Call.Do(f)
return c
}
// DoAndReturn rewrite *gomock.Call.DoAndReturn
-func (c *MockBeaconStateGetAttestationParticipationFlagIndiciesCall) DoAndReturn(f func(*solid.AttestationData, uint64, bool) ([]uint8, error)) *MockBeaconStateGetAttestationParticipationFlagIndiciesCall {
+func (c *MockBeaconStateGetAttestationParticipationFlagIndiciesCall) DoAndReturn(f func(*solid.AttestationData, uint64, uint64, bool) ([]uint8, error)) *MockBeaconStateGetAttestationParticipationFlagIndiciesCall {
c.Call = c.Call.DoAndReturn(f)
return c
}
diff --git a/cl/beacon/beaconevents/event_notifier.go b/cl/beacon/beaconevents/event_notifier.go
index 411116bb335..22dab8c3190 100644
--- a/cl/beacon/beaconevents/event_notifier.go
+++ b/cl/beacon/beaconevents/event_notifier.go
@@ -1,8 +1,17 @@
package beaconevents
+import "sync"
+
type EventEmitter struct {
stateFeed *stateFeed // block state feed
operationFeed *operationFeed // block operation feed
+ headEventMu sync.Mutex
+}
+
+func (e *EventEmitter) WithHeadEventLock(fn func()) {
+ e.headEventMu.Lock()
+ defer e.headEventMu.Unlock()
+ fn()
}
func NewEventEmitter() *EventEmitter {
diff --git a/cl/beacon/beaconevents/gloas_events_test.go b/cl/beacon/beaconevents/gloas_events_test.go
new file mode 100644
index 00000000000..5f41117e6bb
--- /dev/null
+++ b/cl/beacon/beaconevents/gloas_events_test.go
@@ -0,0 +1,245 @@
+// Copyright 2026 The Erigon Authors
+// This file is part of Erigon.
+//
+// Erigon is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Erigon is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with Erigon. If not, see .
+
+package beaconevents
+
+import (
+ "encoding/json"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/erigontech/erigon/cl/clparams"
+ "github.com/erigontech/erigon/cl/cltypes"
+ "github.com/erigontech/erigon/cl/phase1/core/state"
+ "github.com/erigontech/erigon/common"
+)
+
+func TestVersionedGloasOperationEventsJSONShape(t *testing.T) {
+ emitter := NewEventEmitter()
+ ch := make(chan *EventStream, 2)
+ sub := emitter.Operation().Subscribe(ch)
+ defer sub.Unsubscribe()
+
+ emitter.Operation().SendPayloadAttestationMessage(&PayloadAttestationMessageData{})
+ emitter.Operation().SendExecutionPayloadBid(&SignedExecutionPayloadBidData{})
+
+ for range 2 {
+ event := <-ch
+ encoded, err := json.Marshal(event.Data)
+ require.NoError(t, err)
+ var shape map[string]json.RawMessage
+ require.NoError(t, json.Unmarshal(encoded, &shape))
+ require.JSONEq(t, `"gloas"`, string(shape["version"]))
+ require.Contains(t, shape, "data")
+ }
+}
+
+func TestBuildHeadV2DataUsesGenesisRootInEpochZeroAndOne(t *testing.T) {
+ cfg := clparams.MainnetBeaconConfig
+ genesisRoot := common.Hash{1}
+ epochZeroEndRoot := common.Hash{2}
+ headRoot := common.Hash{3}
+ stateRoot := common.Hash{4}
+
+ tests := []struct {
+ name string
+ slot uint64
+ currentRoot common.Hash
+ nextRoot common.Hash
+ }{
+ {name: "epoch_zero", slot: 1, currentRoot: genesisRoot, nextRoot: genesisRoot},
+ {name: "epoch_one", slot: cfg.SlotsPerEpoch + 1, currentRoot: genesisRoot, nextRoot: epochZeroEndRoot},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ headState := state.New(&cfg)
+ headState.SetVersion(clparams.GloasVersion)
+ require.NoError(t, headState.SetSlot(test.slot))
+ require.NoError(t, headState.SetBlockRootAt(0, genesisRoot))
+ require.NoError(t, headState.SetBlockRootAt(int(cfg.SlotsPerEpoch-1), epochZeroEndRoot))
+
+ event, err := BuildHeadV2Data(&cfg, headState, test.slot, headRoot, stateRoot, "full", true)
+ require.NoError(t, err)
+ require.Equal(t, test.currentRoot, event.Data.CurrentEpochDependentRoot)
+ require.Equal(t, test.nextRoot, event.Data.NextEpochDependentRoot)
+ require.Equal(t, stateRoot, event.Data.State)
+ require.Equal(t, "full", event.Data.PayloadStatus)
+ require.True(t, event.Data.ExecutionOptimistic)
+ })
+ }
+}
+
+func TestBuildHeadV2DataDoesNotReadPrunedGenesisRoot(t *testing.T) {
+ cfg := clparams.MainnetBeaconConfig
+ cfg.SlotsPerEpoch = 2
+ cfg.SlotsPerHistoricalRoot = 8
+ headState := state.New(&cfg)
+ headState.SetVersion(clparams.GloasVersion)
+ require.NoError(t, headState.SetSlot(10))
+ currentRoot := common.Hash{1}
+ nextRoot := common.Hash{2}
+ require.NoError(t, headState.SetBlockRootAt(7, currentRoot))
+ require.NoError(t, headState.SetBlockRootAt(1, nextRoot))
+
+ event, err := BuildHeadV2Data(&cfg, headState, 10, common.Hash{3}, common.Hash{4}, "full", false)
+ require.NoError(t, err)
+ require.Equal(t, currentRoot, event.Data.CurrentEpochDependentRoot)
+ require.Equal(t, nextRoot, event.Data.NextEpochDependentRoot)
+}
+
+func TestGloasEventFeedsDoNotBlockOnSlowSubscriber(t *testing.T) {
+ tests := []struct {
+ name string
+ subscribe func(*EventEmitter, chan *EventStream) func()
+ send func(*EventEmitter) int
+ }{
+ {
+ name: "payload_attestation_message",
+ subscribe: func(emitter *EventEmitter, ch chan *EventStream) func() {
+ sub := emitter.Operation().Subscribe(ch)
+ return sub.Unsubscribe
+ },
+ send: func(emitter *EventEmitter) int {
+ return emitter.Operation().SendPayloadAttestationMessage(&PayloadAttestationMessageData{})
+ },
+ },
+ {
+ name: "execution_payload_bid",
+ subscribe: func(emitter *EventEmitter, ch chan *EventStream) func() {
+ sub := emitter.Operation().Subscribe(ch)
+ return sub.Unsubscribe
+ },
+ send: func(emitter *EventEmitter) int {
+ return emitter.Operation().SendExecutionPayloadBid(&SignedExecutionPayloadBidData{})
+ },
+ },
+ {
+ name: "execution_payload_available",
+ subscribe: func(emitter *EventEmitter, ch chan *EventStream) func() {
+ sub := emitter.Operation().Subscribe(ch)
+ return sub.Unsubscribe
+ },
+ send: func(emitter *EventEmitter) int {
+ return emitter.Operation().SendExecutionPayloadAvailable(&ExecutionPayloadAvailableData{})
+ },
+ },
+ {
+ name: "execution_payload",
+ subscribe: func(emitter *EventEmitter, ch chan *EventStream) func() {
+ sub := emitter.Operation().Subscribe(ch)
+ return sub.Unsubscribe
+ },
+ send: func(emitter *EventEmitter) int {
+ return emitter.Operation().SendExecutionPayload(&ExecutionPayloadData{})
+ },
+ },
+ {
+ name: "execution_payload_gossip",
+ subscribe: func(emitter *EventEmitter, ch chan *EventStream) func() {
+ sub := emitter.Operation().Subscribe(ch)
+ return sub.Unsubscribe
+ },
+ send: func(emitter *EventEmitter) int {
+ return emitter.Operation().SendExecutionPayloadGossip(&ExecutionPayloadGossipData{})
+ },
+ },
+ {
+ name: "proposer_preferences",
+ subscribe: func(emitter *EventEmitter, ch chan *EventStream) func() {
+ sub := emitter.Operation().Subscribe(ch)
+ return sub.Unsubscribe
+ },
+ send: func(emitter *EventEmitter) int {
+ return emitter.Operation().SendProposerPreferences(&VersionedSignedProposerPreferences{})
+ },
+ },
+ {
+ name: "head_v2",
+ subscribe: func(emitter *EventEmitter, ch chan *EventStream) func() {
+ sub := emitter.State().Subscribe(ch)
+ return sub.Unsubscribe
+ },
+ send: func(emitter *EventEmitter) int { return emitter.State().SendHeadV2(&HeadV2Data{}) },
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ emitter := NewEventEmitter()
+ unsubscribe := test.subscribe(emitter, make(chan *EventStream))
+ defer unsubscribe()
+ completed := make(chan int, 1)
+ go func() { completed <- test.send(emitter) }()
+ select {
+ case delivered := <-completed:
+ require.Zero(t, delivered)
+ case <-time.After(time.Second):
+ t.Fatal("Gloas event emission blocked on a slow subscriber")
+ }
+ })
+ }
+}
+
+func TestHeadEventEmissionIsSerialized(t *testing.T) {
+ emitter := NewEventEmitter()
+ entered := make(chan struct{})
+ release := make(chan struct{})
+ second := make(chan struct{})
+ go emitter.WithHeadEventLock(func() {
+ close(entered)
+ <-release
+ })
+ <-entered
+ go emitter.WithHeadEventLock(func() { close(second) })
+ select {
+ case <-second:
+ t.Fatal("second head event entered before the first completed")
+ case <-time.After(10 * time.Millisecond):
+ }
+ close(release)
+ select {
+ case <-second:
+ case <-time.After(time.Second):
+ t.Fatal("second head event did not progress")
+ }
+}
+
+func TestGloasEventFeeds(t *testing.T) {
+ emitter := NewEventEmitter()
+ stateEvents := make(chan *EventStream, 1)
+ stateSubscription := emitter.State().Subscribe(stateEvents)
+ defer stateSubscription.Unsubscribe()
+ operationEvents := make(chan *EventStream, 3)
+ operationSubscription := emitter.Operation().Subscribe(operationEvents)
+ defer operationSubscription.Unsubscribe()
+
+ head := &HeadV2Data{Version: "gloas", Data: HeadV2Content{Block: common.Hash{1}, PayloadStatus: "full"}}
+ emitter.State().SendHeadV2(head)
+ require.Equal(t, &EventStream{Event: StateHeadV2, Data: head}, <-stateEvents)
+
+ payload := &ExecutionPayloadData{BlockRoot: common.Hash{2}}
+ emitter.Operation().SendExecutionPayload(payload)
+ require.Equal(t, &EventStream{Event: OpExecutionPayload, Data: payload}, <-operationEvents)
+ payloadGossip := &ExecutionPayloadGossipData{BlockRoot: common.Hash{2}}
+ emitter.Operation().SendExecutionPayloadGossip(payloadGossip)
+ require.Equal(t, &EventStream{Event: OpExecutionPayloadGossip, Data: payloadGossip}, <-operationEvents)
+
+ preferences := &VersionedSignedProposerPreferences{Version: "gloas", Data: &cltypes.SignedProposerPreferences{}}
+ emitter.Operation().SendProposerPreferences(preferences)
+ require.Equal(t, &EventStream{Event: OpProposerPreferences, Data: preferences}, <-operationEvents)
+}
diff --git a/cl/beacon/beaconevents/head_v2.go b/cl/beacon/beaconevents/head_v2.go
new file mode 100644
index 00000000000..215c1a2e77d
--- /dev/null
+++ b/cl/beacon/beaconevents/head_v2.go
@@ -0,0 +1,82 @@
+// Copyright 2026 The Erigon Authors
+// This file is part of Erigon.
+//
+// Erigon is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Erigon is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with Erigon. If not, see .
+
+package beaconevents
+
+import (
+ "errors"
+ "fmt"
+
+ "github.com/erigontech/erigon/cl/abstract"
+ "github.com/erigontech/erigon/cl/clparams"
+ "github.com/erigontech/erigon/common"
+)
+
+// BuildHeadV2Data derives a head_v2 event from one head-state snapshot.
+func BuildHeadV2Data(
+ beaconCfg *clparams.BeaconChainConfig,
+ headState abstract.BeaconState,
+ headSlot uint64,
+ headRoot common.Hash,
+ stateRoot common.Hash,
+ payloadStatus string,
+ executionOptimistic bool,
+) (*HeadV2Data, error) {
+ if beaconCfg == nil || beaconCfg.SlotsPerEpoch == 0 {
+ return nil, errors.New("invalid beacon configuration")
+ }
+ if headState == nil {
+ return nil, errors.New("nil head state")
+ }
+
+ headEpoch := headSlot / beaconCfg.SlotsPerEpoch
+ genesisRoot := headRoot
+ var err error
+ if headSlot > 0 && headEpoch <= 1 {
+ genesisRoot, err = headState.GetBlockRootAtSlot(0)
+ if err != nil {
+ return nil, fmt.Errorf("get genesis block root: %w", err)
+ }
+ }
+ currentDependentRoot := genesisRoot
+ nextDependentRoot := genesisRoot
+ if headEpoch > 1 {
+ currentDependentRoot, err = headState.GetBlockRootAtSlot((headEpoch-1)*beaconCfg.SlotsPerEpoch - 1)
+ if err != nil {
+ return nil, fmt.Errorf("get current epoch dependent root: %w", err)
+ }
+ }
+ if headEpoch > 0 {
+ nextDependentRoot, err = headState.GetBlockRootAtSlot(headEpoch*beaconCfg.SlotsPerEpoch - 1)
+ if err != nil {
+ return nil, fmt.Errorf("get next epoch dependent root: %w", err)
+ }
+ }
+
+ return &HeadV2Data{
+ Version: headState.Version().String(),
+ Data: HeadV2Content{
+ Slot: headSlot,
+ Block: headRoot,
+ State: stateRoot,
+ PayloadStatus: payloadStatus,
+ EpochTransition: headSlot%beaconCfg.SlotsPerEpoch == 0,
+ CurrentEpochDependentRoot: currentDependentRoot,
+ NextEpochDependentRoot: nextDependentRoot,
+ ExecutionOptimistic: executionOptimistic,
+ },
+ }, nil
+}
diff --git a/cl/beacon/beaconevents/model.go b/cl/beacon/beaconevents/model.go
index a163c5ceb8b..f9e43b435ea 100644
--- a/cl/beacon/beaconevents/model.go
+++ b/cl/beacon/beaconevents/model.go
@@ -28,6 +28,9 @@ const (
OpPayloadAttestationMessage EventTopic = "payload_attestation_message" // [New in Gloas:EIP7732]
OpExecutionPayloadBid EventTopic = "execution_payload_bid" // [New in Gloas:EIP7732]
OpExecutionPayloadAvailable EventTopic = "execution_payload_available" // [New in Gloas:EIP7732]
+ OpExecutionPayload EventTopic = "execution_payload"
+ OpExecutionPayloadGossip EventTopic = "execution_payload_gossip"
+ OpProposerPreferences EventTopic = "proposer_preferences"
)
type (
@@ -44,6 +47,16 @@ type (
SignedExecutionPayloadBidData = cltypes.SignedExecutionPayloadBid
)
+type VersionedPayloadAttestationMessage struct {
+ Version string `json:"version"`
+ Data *PayloadAttestationMessageData `json:"data"`
+}
+
+type VersionedSignedExecutionPayloadBid struct {
+ Version string `json:"version"`
+ Data *SignedExecutionPayloadBidData `json:"data"`
+}
+
// DataColumnSidecarData includes block_root and slot for SSE events
type DataColumnSidecarData struct {
BlockRoot common.Hash `json:"block_root"`
@@ -73,6 +86,7 @@ func NewDataColumnSidecarData(sidecar *cltypes.DataColumnSidecar) *DataColumnSid
// State event topics
const (
StateHead EventTopic = "head"
+ StateHeadV2 EventTopic = "head_v2"
StateBlock EventTopic = "block"
StateBlockGossip EventTopic = "block_gossip"
StateFinalizedCheckpoint EventTopic = "finalized_checkpoint"
@@ -93,6 +107,55 @@ type HeadData struct {
ExecutionOptimistic bool `json:"execution_optimistic"`
}
+type HeadV2Data struct {
+ Version string `json:"version"`
+ Data HeadV2Content `json:"data"`
+}
+
+type HeadV2Content struct {
+ Slot uint64 `json:"slot,string"`
+ Block common.Hash `json:"block"`
+ State common.Hash `json:"state"`
+ PayloadStatus string `json:"payload_status"`
+ EpochTransition bool `json:"epoch_transition"`
+ CurrentEpochDependentRoot common.Hash `json:"current_epoch_dependent_root"`
+ NextEpochDependentRoot common.Hash `json:"next_epoch_dependent_root"`
+ ExecutionOptimistic bool `json:"execution_optimistic"`
+}
+
+type ExecutionPayloadData struct {
+ Slot uint64 `json:"slot,string"`
+ BuilderIndex uint64 `json:"builder_index,string"`
+ BlockHash common.Hash `json:"block_hash"`
+ BlockRoot common.Hash `json:"block_root"`
+ ExecutionOptimistic bool `json:"execution_optimistic"`
+}
+
+type ExecutionPayloadGossipData struct {
+ Slot uint64 `json:"slot,string"`
+ BuilderIndex uint64 `json:"builder_index,string"`
+ BlockHash common.Hash `json:"block_hash"`
+ BlockRoot common.Hash `json:"block_root"`
+}
+
+type VersionedSignedProposerPreferences struct {
+ Version string `json:"version"`
+ Data *cltypes.SignedProposerPreferences `json:"data"`
+}
+
+func PayloadStatusName(status cltypes.PayloadStatus) string {
+ switch status {
+ case cltypes.PayloadStatusEmpty:
+ return "empty"
+ case cltypes.PayloadStatusFull:
+ return "full"
+ case cltypes.PayloadStatusPending:
+ return "pending"
+ default:
+ return "unknown"
+ }
+}
+
type BlockData struct {
Slot uint64 `json:"slot,string"`
Block common.Hash `json:"block"`
diff --git a/cl/beacon/beaconevents/operation_feed.go b/cl/beacon/beaconevents/operation_feed.go
index 70918542b04..6d92249dde4 100644
--- a/cl/beacon/beaconevents/operation_feed.go
+++ b/cl/beacon/beaconevents/operation_feed.go
@@ -81,24 +81,36 @@ func (f *operationFeed) SendDataColumnSidecar(value *DataColumnSidecarData) int
// SendPayloadAttestationMessage emits a payload_attestation_message event. [New in Gloas:EIP7732]
func (f *operationFeed) SendPayloadAttestationMessage(value *PayloadAttestationMessageData) int {
- return f.feed.Send(&EventStream{
+ return f.feed.TrySend(&EventStream{
Event: OpPayloadAttestationMessage,
- Data: value,
+ Data: &VersionedPayloadAttestationMessage{Version: "gloas", Data: value},
})
}
// SendExecutionPayloadBid emits an execution_payload_bid event. [New in Gloas:EIP7732]
func (f *operationFeed) SendExecutionPayloadBid(value *SignedExecutionPayloadBidData) int {
- return f.feed.Send(&EventStream{
+ return f.feed.TrySend(&EventStream{
Event: OpExecutionPayloadBid,
- Data: value,
+ Data: &VersionedSignedExecutionPayloadBid{Version: "gloas", Data: value},
})
}
// SendExecutionPayloadAvailable emits an execution_payload_available event. [New in Gloas:EIP7732]
func (f *operationFeed) SendExecutionPayloadAvailable(value *ExecutionPayloadAvailableData) int {
- return f.feed.Send(&EventStream{
+ return f.feed.TrySend(&EventStream{
Event: OpExecutionPayloadAvailable,
Data: value,
})
}
+
+func (f *operationFeed) SendExecutionPayload(value *ExecutionPayloadData) int {
+ return f.feed.TrySend(&EventStream{Event: OpExecutionPayload, Data: value})
+}
+
+func (f *operationFeed) SendExecutionPayloadGossip(value *ExecutionPayloadGossipData) int {
+ return f.feed.TrySend(&EventStream{Event: OpExecutionPayloadGossip, Data: value})
+}
+
+func (f *operationFeed) SendProposerPreferences(value *VersionedSignedProposerPreferences) int {
+ return f.feed.TrySend(&EventStream{Event: OpProposerPreferences, Data: value})
+}
diff --git a/cl/beacon/beaconevents/state_feed.go b/cl/beacon/beaconevents/state_feed.go
index 3d940cf59e9..f7bee9ec3d4 100644
--- a/cl/beacon/beaconevents/state_feed.go
+++ b/cl/beacon/beaconevents/state_feed.go
@@ -25,6 +25,10 @@ func (f *stateFeed) SendHead(value *HeadData) int {
})
}
+func (f *stateFeed) SendHeadV2(value *HeadV2Data) int {
+ return f.feed.TrySend(&EventStream{Event: StateHeadV2, Data: value})
+}
+
// The node has received a block (from P2P or API) that is successfully imported on the fork-choice on_block handler
func (f *stateFeed) SendBlock(value *BlockData) int {
return f.feed.Send(&EventStream{
diff --git a/cl/beacon/beaconhttp/api.go b/cl/beacon/beaconhttp/api.go
index 293904207a0..2b7c110ab09 100644
--- a/cl/beacon/beaconhttp/api.go
+++ b/cl/beacon/beaconhttp/api.go
@@ -150,6 +150,10 @@ func HandleEndpoint[T any](h EndpointHandler[T]) http.HandlerFunc {
if beaconResponse.Version != nil && w.Header().Get("Eth-Consensus-Version") == "" {
w.Header().Set("Eth-Consensus-Version", beaconResponse.Version.String())
}
+ if beaconResponse.noContent {
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
}
switch responseEncodingForAccept(contentType, supportsSSZ(ans)) {
case responseEncodingJSON:
@@ -186,7 +190,7 @@ func HandleEndpoint[T any](h EndpointHandler[T]) http.HandlerFunc {
case responseEncodingEventStream:
return
default:
- http.Error(w, "content type must include application/json, application/octet-stream, or text/event-stream, got "+contentType, http.StatusBadRequest)
+ http.Error(w, "content type must include application/json, application/octet-stream, or text/event-stream, got "+contentType, http.StatusNotAcceptable)
}
}
}
diff --git a/cl/beacon/beaconhttp/beacon_response.go b/cl/beacon/beaconhttp/beacon_response.go
index c66869c9c24..1bb69968e99 100644
--- a/cl/beacon/beaconhttp/beacon_response.go
+++ b/cl/beacon/beaconhttp/beacon_response.go
@@ -31,8 +31,9 @@ type BeaconResponse struct {
Version *clparams.StateVersion
ExecutionOptimistic *bool
- Extra map[string]any
- headers map[string]string
+ Extra map[string]any
+ headers map[string]string
+ noContent bool
}
func NewBeaconResponse(data any) *BeaconResponse {
@@ -41,6 +42,10 @@ func NewBeaconResponse(data any) *BeaconResponse {
}
}
+func NewNoContentResponse() *BeaconResponse {
+ return &BeaconResponse{noContent: true}
+}
+
func (r *BeaconResponse) Headers() map[string]string {
if r.headers == nil {
return make(map[string]string)
diff --git a/cl/beacon/builder/client.go b/cl/beacon/builder/client.go
index b4f0113f0c4..fb16f84e500 100644
--- a/cl/beacon/builder/client.go
+++ b/cl/beacon/builder/client.go
@@ -23,11 +23,17 @@ import (
"errors"
"fmt"
"io"
+ "net"
"net/http"
+ "net/netip"
"net/url"
"strconv"
+ "strings"
+ "sync"
"time"
+ "golang.org/x/sync/semaphore"
+
"github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/cltypes"
"github.com/erigontech/erigon/cl/cltypes/solid"
@@ -40,28 +46,72 @@ var _ BuilderClient = &builderClient{}
var ErrNoContent = errors.New("no http content")
+const (
+ maxBuilderResponseBodySize = 1 << 20
+ maxBuilderErrorBodySize = 256
+ builderPreferencesTimeout = time.Second
+ builderBeaconBlockTimeout = time.Second
+ defaultBuilderCallLimit = 32
+ defaultPreferenceCallLimit = 24
+)
+
+type BuilderTargetPolicy struct {
+ AllowPrivate bool
+}
+
type builderClient struct {
// ref: https://ethereum.github.io/builder-specs/#/
- httpClient *http.Client
- url *url.URL
- beaconConfig *clparams.BeaconChainConfig
+ httpClient *http.Client
+ url *url.URL
+ beaconConfig *clparams.BeaconChainConfig
+ lookupIP func(context.Context, string) ([]net.IPAddr, error)
+ targetPolicy BuilderTargetPolicy
+ transport http.RoundTripper
+ publicTransport http.RoundTripper
+ admission *semaphore.Weighted
+ admissionOnce sync.Once
+ preferencesAdmission *semaphore.Weighted
+ preferencesAdmissionOnce sync.Once
}
func NewBlockBuilderClient(baseUrl string, beaconConfig *clparams.BeaconChainConfig) *builderClient {
- u, err := url.Parse(baseUrl)
- if err != nil {
- panic(err)
+ return newBlockBuilderClient(baseUrl, beaconConfig, BuilderTargetPolicy{}, true)
+}
+
+func NewDynamicBuilderClient(beaconConfig *clparams.BeaconChainConfig, policy BuilderTargetPolicy) *builderClient {
+ return newBlockBuilderClient("", beaconConfig, policy, false)
+}
+
+func NewBlockBuilderClientWithPolicy(baseURL string, beaconConfig *clparams.BeaconChainConfig, policy BuilderTargetPolicy) *builderClient {
+ return newBlockBuilderClient(baseURL, beaconConfig, policy, true)
+}
+
+func newBlockBuilderClient(baseUrl string, beaconConfig *clparams.BeaconChainConfig, policy BuilderTargetPolicy, checkStatus bool) *builderClient {
+ var u *url.URL
+ var err error
+ if baseUrl != "" {
+ u, err = url.Parse(baseUrl)
+ if err != nil {
+ panic(err)
+ }
}
c := &builderClient{
- httpClient: &http.Client{},
- url: u,
- beaconConfig: beaconConfig,
+ httpClient: &http.Client{},
+ url: u,
+ beaconConfig: beaconConfig,
+ targetPolicy: policy,
+ transport: newPinnedBuilderTransport(nil),
+ publicTransport: newPinnedBuilderTransport(nil),
+ admission: semaphore.NewWeighted(defaultBuilderCallLimit),
+ preferencesAdmission: semaphore.NewWeighted(defaultPreferenceCallLimit),
}
- if err := c.GetStatus(context.Background()); err != nil {
- log.Error("cannot connect to builder client", "url", baseUrl, "error", err)
- panic("cannot connect to builder client")
+ if checkStatus {
+ if err := c.GetStatus(context.Background()); err != nil {
+ log.Error("cannot connect to builder client", "url", baseUrl, "error", err)
+ panic("cannot connect to builder client")
+ }
+ log.Info("Builder client is ready", "url", baseUrl)
}
- log.Info("Builder client is ready", "url", baseUrl)
return c
}
@@ -202,6 +252,359 @@ func (b *builderClient) GetStatus(ctx context.Context) error {
return err
}
+func (b *builderClient) SubmitBuilderPreferences(ctx context.Context, builderURL string, proposerPubkey common.Bytes48, request *cltypes.BuilderPreferencesRequest) error {
+ if request == nil {
+ return errors.New("nil builder preferences request")
+ }
+ payload, err := json.Marshal(request)
+ if err != nil {
+ return err
+ }
+ requestContext, cancel := context.WithTimeout(ctx, builderPreferencesTimeout)
+ defer cancel()
+ preferencesAdmission := b.builderPreferencesAdmission()
+ if err := preferencesAdmission.Acquire(requestContext, 1); err != nil {
+ return err
+ }
+ defer preferencesAdmission.Release(1)
+ admission := b.builderAdmission()
+ if err := admission.Acquire(requestContext, 1); err != nil {
+ return err
+ }
+ defer admission.Release(1)
+ target, err := b.builderEndpoint(requestContext, b.targetPolicy, builderURL, "eth", "v1", "builder", "builder_preferences", proposerPubkey.Hex())
+ if err != nil {
+ return err
+ }
+ response, err := b.builderCall(requestContext, http.MethodPost, target, map[string]string{
+ "Eth-Consensus-Version": clparams.GloasVersion.String(),
+ }, bytes.NewReader(payload))
+ if err != nil {
+ return err
+ }
+ if response.status != http.StatusAccepted {
+ return fmt.Errorf("builder preferences: unexpected status code %d", response.status)
+ }
+ return nil
+}
+
+func (b *builderClient) RequestExecutionPayloadBid(ctx context.Context, builderURL string, slot uint64, parentHash, parentRoot common.Hash, proposerPubkey common.Bytes48, auth *cltypes.SignedBuilderRequestAuth, timeout time.Duration) (*cltypes.SignedExecutionPayloadBid, error) {
+ if auth == nil {
+ return nil, errors.New("nil builder request auth")
+ }
+ timeoutMilliseconds := timeout.Milliseconds()
+ if timeoutMilliseconds <= 0 {
+ return nil, errors.New("builder request timeout must be at least one millisecond")
+ }
+ requestContext, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+ if err := b.builderAdmission().Acquire(requestContext, 1); err != nil {
+ return nil, err
+ }
+ defer b.builderAdmission().Release(1)
+ payload, err := json.Marshal(auth)
+ if err != nil {
+ return nil, err
+ }
+ target, err := b.builderEndpoint(requestContext, b.targetPolicy, builderURL, "eth", "v1", "builder", "execution_payload_bid", strconv.FormatUint(slot, 10), parentHash.Hex(), parentRoot.Hex(), proposerPubkey.Hex())
+ if err != nil {
+ return nil, err
+ }
+ response, err := b.builderCall(requestContext, http.MethodPost, target, map[string]string{
+ "Eth-Consensus-Version": clparams.GloasVersion.String(),
+ "Date-Milliseconds": strconv.FormatInt(time.Now().UnixMilli(), 10),
+ "X-Timeout-Ms": strconv.FormatInt(timeoutMilliseconds, 10),
+ }, bytes.NewReader(payload))
+ if err != nil {
+ return nil, err
+ }
+ if response.status == http.StatusNoContent {
+ return nil, nil
+ }
+ if response.status != http.StatusOK {
+ return nil, fmt.Errorf("execution payload bid: unexpected status code %d", response.status)
+ }
+ if response.header.Get("Eth-Consensus-Version") != clparams.GloasVersion.String() {
+ return nil, errors.New("execution payload bid response is missing the Gloas consensus version")
+ }
+ var responseBody struct {
+ Version string `json:"version"`
+ Data *cltypes.SignedExecutionPayloadBid `json:"data"`
+ }
+ if err := json.Unmarshal(response.body, &responseBody); err != nil {
+ return nil, fmt.Errorf("decode execution payload bid: %w", err)
+ }
+ if responseBody.Version != clparams.GloasVersion.String() || responseBody.Data == nil || responseBody.Data.Message == nil {
+ return nil, errors.New("execution payload bid response is missing Gloas data")
+ }
+ return responseBody.Data, nil
+}
+
+func (b *builderClient) SubmitSignedBeaconBlock(ctx context.Context, builderURL string, block *cltypes.SignedBeaconBlock) error {
+ return b.submitSignedBeaconBlock(ctx, builderURL, block, b.targetPolicy, false)
+}
+
+func (b *builderClient) SubmitSignedBeaconBlockPublic(ctx context.Context, builderURL string, block *cltypes.SignedBeaconBlock) error {
+ return b.submitSignedBeaconBlock(ctx, builderURL, block, BuilderTargetPolicy{}, true)
+}
+
+func (b *builderClient) submitSignedBeaconBlock(ctx context.Context, builderURL string, block *cltypes.SignedBeaconBlock, policy BuilderTargetPolicy, strictPublic bool) error {
+ if block == nil || block.Block == nil || block.Block.Body == nil {
+ return errors.New("nil signed beacon block")
+ }
+ payload, err := json.Marshal(block)
+ if err != nil {
+ return err
+ }
+ requestContext, cancel := context.WithTimeout(ctx, builderBeaconBlockTimeout)
+ defer cancel()
+ if err := b.builderAdmission().Acquire(requestContext, 1); err != nil {
+ return err
+ }
+ defer b.builderAdmission().Release(1)
+ target, err := b.builderEndpoint(requestContext, policy, builderURL, "eth", "v1", "builder", "beacon_blocks")
+ if err != nil {
+ return err
+ }
+ transport := b.transport
+ if strictPublic {
+ transport = b.publicTransport
+ }
+ response, err := b.builderCallWithTransport(requestContext, http.MethodPost, target, map[string]string{
+ "Eth-Consensus-Version": block.Version().String(),
+ }, bytes.NewReader(payload), transport)
+ if err != nil {
+ return err
+ }
+ if response.status != http.StatusAccepted {
+ return fmt.Errorf("signed beacon block: unexpected status code %d", response.status)
+ }
+ return nil
+}
+
+type builderTarget struct {
+ url string
+ hostname string
+ ips []net.IP
+}
+
+func (b *builderClient) builderEndpoint(ctx context.Context, policy BuilderTargetPolicy, rawURL string, path ...string) (builderTarget, error) {
+ target, err := url.Parse(rawURL)
+ if err != nil {
+ return builderTarget{}, err
+ }
+ if (target.Scheme != "http" && target.Scheme != "https") || target.Host == "" || target.User != nil {
+ return builderTarget{}, errors.New("builder URL must be an HTTP(S) URL without user information")
+ }
+ hostname := target.Hostname()
+ addresses := []net.IPAddr(nil)
+ if ip := net.ParseIP(hostname); ip != nil {
+ addresses = []net.IPAddr{{IP: ip}}
+ } else {
+ lookup := b.lookupIP
+ if lookup == nil {
+ lookup = net.DefaultResolver.LookupIPAddr
+ }
+ addresses, err = lookup(ctx, hostname)
+ if err != nil {
+ return builderTarget{}, fmt.Errorf("resolve builder URL: %w", err)
+ }
+ }
+ if len(addresses) == 0 {
+ return builderTarget{}, errors.New("builder URL has no resolved addresses")
+ }
+ for _, address := range addresses {
+ if !isAllowedBuilderIP(address.IP, policy) {
+ return builderTarget{}, fmt.Errorf("builder URL resolves to disallowed address %s", address.IP)
+ }
+ }
+ validatedIPs := make([]net.IP, len(addresses))
+ for i, address := range addresses {
+ validatedIPs[i] = append(net.IP(nil), address.IP...)
+ }
+ return builderTarget{url: target.JoinPath(path...).String(), hostname: hostname, ips: validatedIPs}, nil
+}
+
+func isPublicBuilderIP(ip net.IP) bool {
+ addr, ok := netip.AddrFromSlice(ip)
+ if !ok {
+ return false
+ }
+ addr = addr.Unmap()
+ if addr.Is6() && !publicIPv6BuilderPrefix.Contains(addr) {
+ return false
+ }
+ if !addr.IsGlobalUnicast() {
+ return false
+ }
+ for _, prefix := range nonPublicBuilderPrefixes {
+ if prefix.Contains(addr) {
+ return false
+ }
+ }
+ return true
+}
+
+var publicIPv6BuilderPrefix = netip.MustParsePrefix("2000::/3")
+
+var nonPublicBuilderPrefixes = []netip.Prefix{
+ netip.MustParsePrefix("0.0.0.0/8"), netip.MustParsePrefix("10.0.0.0/8"),
+ netip.MustParsePrefix("100.64.0.0/10"), netip.MustParsePrefix("127.0.0.0/8"),
+ netip.MustParsePrefix("169.254.0.0/16"), netip.MustParsePrefix("172.16.0.0/12"),
+ netip.MustParsePrefix("192.0.0.0/24"), netip.MustParsePrefix("192.0.2.0/24"),
+ netip.MustParsePrefix("192.31.196.0/24"), netip.MustParsePrefix("192.52.193.0/24"),
+ netip.MustParsePrefix("192.88.99.0/24"), netip.MustParsePrefix("192.168.0.0/16"),
+ netip.MustParsePrefix("192.175.48.0/24"),
+ netip.MustParsePrefix("198.18.0.0/15"), netip.MustParsePrefix("198.51.100.0/24"),
+ netip.MustParsePrefix("203.0.113.0/24"), netip.MustParsePrefix("224.0.0.0/4"),
+ netip.MustParsePrefix("240.0.0.0/4"), netip.MustParsePrefix("::/128"),
+ netip.MustParsePrefix("::1/128"), netip.MustParsePrefix("64:ff9b::/96"),
+ netip.MustParsePrefix("64:ff9b:1::/48"), netip.MustParsePrefix("100:0:0:1::/64"),
+ netip.MustParsePrefix("100::/64"),
+ netip.MustParsePrefix("2001::/23"), netip.MustParsePrefix("2001:2::/48"),
+ netip.MustParsePrefix("2001:db8::/32"), netip.MustParsePrefix("2002::/16"),
+ netip.MustParsePrefix("3fff::/20"), netip.MustParsePrefix("5f00::/16"),
+ netip.MustParsePrefix("fc00::/7"), netip.MustParsePrefix("fec0::/10"),
+ netip.MustParsePrefix("fe80::/10"),
+ netip.MustParsePrefix("ff00::/8"),
+}
+
+func isAllowedBuilderIP(ip net.IP, policy BuilderTargetPolicy) bool {
+ if isPublicBuilderIP(ip) {
+ return true
+ }
+ return policy.AllowPrivate && ip != nil && (ip.IsPrivate() || ip.IsLoopback())
+}
+
+type builderHTTPResponse struct {
+ body []byte
+ header http.Header
+ status int
+}
+
+func (b *builderClient) builderCall(ctx context.Context, method string, target builderTarget, headers map[string]string, body io.Reader) (*builderHTTPResponse, error) {
+ return b.builderCallWithTransport(ctx, method, target, headers, body, b.transport)
+}
+
+func (b *builderClient) builderCallWithTransport(ctx context.Context, method string, target builderTarget, headers map[string]string, body io.Reader, transport http.RoundTripper) (*builderHTTPResponse, error) {
+ var attemptTimeout time.Duration
+ if deadline, ok := ctx.Deadline(); ok && len(target.ips) > 1 {
+ attemptTimeout = time.Until(deadline) / time.Duration(len(target.ips))
+ } else if len(target.ips) > 1 {
+ attemptTimeout = time.Second
+ }
+ requestContext := context.WithValue(ctx, pinnedBuilderTargetKey{}, pinnedBuilderTarget{hostname: target.hostname, ips: target.ips, attemptTimeout: attemptTimeout})
+ request, err := http.NewRequestWithContext(requestContext, method, target.url, body)
+ if err != nil {
+ return nil, err
+ }
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Accept", "application/json")
+ for name, value := range headers {
+ request.Header.Set(name, value)
+ }
+ if b.httpClient == nil {
+ return nil, errors.New("nil builder HTTP client")
+ }
+ client := *b.httpClient
+ if transport != nil {
+ client.Transport = transport
+ }
+ client.CheckRedirect = func(*http.Request, []*http.Request) error {
+ return errors.New("builder redirects are not allowed")
+ }
+ response, err := client.Do(request)
+ if err != nil {
+ return nil, err
+ }
+ defer response.Body.Close()
+ bodyLimit := int64(maxBuilderResponseBodySize)
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ bodyLimit = maxBuilderErrorBodySize
+ }
+ bodyBytes, err := io.ReadAll(io.LimitReader(response.Body, bodyLimit+1))
+ if err != nil {
+ return nil, err
+ }
+ if len(bodyBytes) > maxBuilderResponseBodySize && bodyLimit == maxBuilderResponseBodySize {
+ return nil, fmt.Errorf("builder response exceeds %d bytes", maxBuilderResponseBodySize)
+ }
+ if response.StatusCode < 200 || response.StatusCode >= 300 {
+ return nil, fmt.Errorf("builder returned status code %d", response.StatusCode)
+ }
+ return &builderHTTPResponse{body: bodyBytes, header: response.Header.Clone(), status: response.StatusCode}, nil
+}
+
+func (b *builderClient) builderAdmission() *semaphore.Weighted {
+ b.admissionOnce.Do(func() {
+ if b.admission == nil {
+ b.admission = semaphore.NewWeighted(defaultBuilderCallLimit)
+ }
+ })
+ return b.admission
+}
+
+func (b *builderClient) builderPreferencesAdmission() *semaphore.Weighted {
+ b.preferencesAdmissionOnce.Do(func() {
+ if b.preferencesAdmission == nil {
+ b.preferencesAdmission = semaphore.NewWeighted(defaultPreferenceCallLimit)
+ }
+ })
+ return b.preferencesAdmission
+}
+
+type pinnedBuilderTargetKey struct{}
+
+type pinnedBuilderTarget struct {
+ hostname string
+ ips []net.IP
+ attemptTimeout time.Duration
+}
+
+func newPinnedBuilderTransport(dialContext func(context.Context, string, string) (net.Conn, error)) http.RoundTripper {
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.Proxy = nil
+ if dialContext == nil {
+ dialContext = (&net.Dialer{}).DialContext
+ }
+ transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) {
+ pinned, ok := ctx.Value(pinnedBuilderTargetKey{}).(pinnedBuilderTarget)
+ if !ok {
+ return nil, errors.New("builder target was not resolved before dialing")
+ }
+ host, port, err := net.SplitHostPort(address)
+ if err != nil {
+ return nil, err
+ }
+ if !strings.EqualFold(host, pinned.hostname) {
+ return nil, errors.New("builder dial target does not match resolved host")
+ }
+ var dialErrors []error
+ for i, ip := range pinned.ips {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+ pinnedAddress := net.JoinHostPort(ip.String(), port)
+ attemptCtx := ctx
+ cancel := func() {}
+ if i+1 < len(pinned.ips) && pinned.attemptTimeout > 0 {
+ attemptCtx, cancel = context.WithTimeout(ctx, pinned.attemptTimeout)
+ }
+ conn, err := dialContext(attemptCtx, network, pinnedAddress)
+ cancel()
+ if err == nil {
+ return conn, nil
+ }
+ if ctx.Err() != nil {
+ return nil, ctx.Err()
+ }
+ dialErrors = append(dialErrors, fmt.Errorf("dial builder address %s: %w", pinnedAddress, err))
+ }
+ return nil, errors.Join(dialErrors...)
+ }
+ return transport
+}
+
func httpCall[T any](ctx context.Context, client *http.Client, method, rawURL string, headers map[string]string, payloadReader io.Reader, body T) (*T, error) {
request, err := http.NewRequestWithContext(ctx, method, rawURL, payloadReader)
if err != nil {
diff --git a/cl/beacon/builder/client_test.go b/cl/beacon/builder/client_test.go
index d6dd64ba408..079c3a68076 100644
--- a/cl/beacon/builder/client_test.go
+++ b/cl/beacon/builder/client_test.go
@@ -17,18 +17,27 @@
package builder
import (
+ "bufio"
"bytes"
"context"
_ "embed"
"encoding/json"
+ "errors"
"fmt"
"io"
"math"
+ "net"
"net/http"
"net/url"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
"testing"
+ "time"
"github.com/stretchr/testify/require"
+ "golang.org/x/sync/semaphore"
"github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/cltypes"
@@ -334,6 +343,557 @@ func TestSubmitBlindedBlocks(t *testing.T) {
})
}
+func TestRequestExecutionPayloadBid(t *testing.T) {
+ auth := validBuilderRequestAuth()
+ proposer := common.Bytes48{1}
+ parentHash := common.Hash{2}
+ parentRoot := common.Hash{3}
+ client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) {
+ require.Equal(t, "/eth/v1/builder/execution_payload_bid/12/"+parentHash.Hex()+"/"+parentRoot.Hex()+"/"+proposer.Hex(), r.URL.Path)
+ require.Equal(t, "gloas", r.Header.Get("Eth-Consensus-Version"))
+ require.Equal(t, "750", r.Header.Get("X-Timeout-Ms"))
+ date, err := strconv.ParseInt(r.Header.Get("Date-Milliseconds"), 10, 64)
+ require.NoError(t, err)
+ require.NotZero(t, date)
+ var got cltypes.SignedBuilderRequestAuth
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&got))
+ require.Equal(t, auth, &got)
+ return builderTestResponse(r, http.StatusOK, `{"version":"gloas","data":{"message":{"blob_kzg_commitments":[]},"signature":"0x`+strings.Repeat("00", 96)+`"}}`, http.Header{"Eth-Consensus-Version": {"gloas"}}), nil
+ }))
+ bid, err := client.RequestExecutionPayloadBid(context.Background(), "https://builder.example", 12, parentHash, parentRoot, proposer, auth, 750*time.Millisecond)
+ require.NoError(t, err)
+ require.NotNil(t, bid)
+}
+
+func TestRequestExecutionPayloadBidNoContent(t *testing.T) {
+ client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) {
+ return builderTestResponse(r, http.StatusNoContent, "", nil), nil
+ }))
+
+ bid, err := client.RequestExecutionPayloadBid(context.Background(), "https://builder.example", 1, common.Hash{}, common.Hash{}, common.Bytes48{}, validBuilderRequestAuth(), time.Second)
+ require.NoError(t, err)
+ require.Nil(t, bid)
+}
+
+func TestRequestExecutionPayloadBidEnforcesTimeout(t *testing.T) {
+ client := publicBuilderTestClient(mockRoundTripper(func(req *http.Request) (*http.Response, error) {
+ <-req.Context().Done()
+ return nil, req.Context().Err()
+ }))
+ started := time.Now()
+ bid, err := client.RequestExecutionPayloadBid(context.Background(), "https://builder.example", 1, common.Hash{}, common.Hash{}, common.Bytes48{}, validBuilderRequestAuth(), 10*time.Millisecond)
+ require.ErrorIs(t, err, context.DeadlineExceeded)
+ require.Nil(t, bid)
+ require.Less(t, time.Since(started), time.Second)
+}
+
+func TestBuilderTransportRejectsUnsafeTargets(t *testing.T) {
+ for _, rawURL := range []string{"http://127.0.0.1:18550", "http://[::1]:18550", "http://localhost:18550"} {
+ client := &builderClient{httpClient: &http.Client{Transport: mockRoundTripper(func(*http.Request) (*http.Response, error) {
+ t.Fatal("unsafe target reached transport")
+ return nil, nil
+ })}}
+ _, err := client.RequestExecutionPayloadBid(context.Background(), rawURL, 1, common.Hash{}, common.Hash{}, common.Bytes48{}, validBuilderRequestAuth(), time.Second)
+ require.Error(t, err, rawURL)
+ }
+
+ client := &builderClient{
+ httpClient: &http.Client{Transport: mockRoundTripper(func(*http.Request) (*http.Response, error) {
+ t.Fatal("private resolved address reached transport")
+ return nil, nil
+ })},
+ lookupIP: func(context.Context, string) ([]net.IPAddr, error) {
+ return []net.IPAddr{{IP: net.ParseIP("10.0.0.1")}}, nil
+ },
+ }
+ _, err := client.RequestExecutionPayloadBid(context.Background(), "https://builder.example", 1, common.Hash{}, common.Hash{}, common.Bytes48{}, validBuilderRequestAuth(), time.Second)
+ require.Error(t, err)
+
+ client.lookupIP = func(context.Context, string) ([]net.IPAddr, error) {
+ return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}, {IP: net.ParseIP("10.0.0.1")}}, nil
+ }
+ _, err = client.RequestExecutionPayloadBid(context.Background(), "https://builder.example", 1, common.Hash{}, common.Hash{}, common.Bytes48{}, validBuilderRequestAuth(), time.Second)
+ require.Error(t, err)
+}
+
+func TestRequestExecutionPayloadBidBoundsResponseAndRefusesRedirect(t *testing.T) {
+ t.Run("bounded response", func(t *testing.T) {
+ client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) {
+ return builderTestResponse(r, http.StatusOK, string(make([]byte, maxBuilderResponseBodySize+1)), nil), nil
+ }))
+ bid, err := client.RequestExecutionPayloadBid(context.Background(), "https://builder.example", 1, common.Hash{}, common.Hash{}, common.Bytes48{}, validBuilderRequestAuth(), time.Second)
+ require.Error(t, err)
+ require.Nil(t, bid)
+ })
+
+ t.Run("redirect", func(t *testing.T) {
+ var redirected atomic.Bool
+ client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) {
+ if r.URL.Host == "redirected.example" {
+ redirected.Store(true)
+ }
+ return builderTestResponse(r, http.StatusTemporaryRedirect, "", http.Header{"Location": {"https://redirected.example"}}), nil
+ }))
+ bid, err := client.RequestExecutionPayloadBid(context.Background(), "https://builder.example", 1, common.Hash{}, common.Hash{}, common.Bytes48{}, validBuilderRequestAuth(), time.Second)
+ require.Error(t, err)
+ require.Nil(t, bid)
+ require.False(t, redirected.Load())
+ })
+}
+
+func TestSubmitBuilderPreferences(t *testing.T) {
+ auth := validBuilderRequestAuth()
+ proposer := common.Bytes48{4}
+ request := &cltypes.BuilderPreferencesRequest{Preferences: &cltypes.BuilderPreferences{MaxExecutionPayment: 9}, Auth: auth}
+ client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) {
+ require.Equal(t, "/eth/v1/builder/builder_preferences/"+proposer.Hex(), r.URL.Path)
+ require.Equal(t, "gloas", r.Header.Get("Eth-Consensus-Version"))
+ var got cltypes.BuilderPreferencesRequest
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&got))
+ require.Equal(t, request, &got)
+ return builderTestResponse(r, http.StatusAccepted, "", nil), nil
+ }))
+ require.NoError(t, client.SubmitBuilderPreferences(context.Background(), "https://builder.example", proposer, request))
+}
+
+func TestSubmitBuilderPreferencesHasBoundedTimeoutAndError(t *testing.T) {
+ request := &cltypes.BuilderPreferencesRequest{Preferences: &cltypes.BuilderPreferences{}, Auth: validBuilderRequestAuth()}
+ t.Run("timeout", func(t *testing.T) {
+ client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) {
+ <-r.Context().Done()
+ return nil, r.Context().Err()
+ }))
+ started := time.Now()
+ err := client.SubmitBuilderPreferences(context.Background(), "https://builder.example", common.Bytes48{}, request)
+ require.ErrorIs(t, err, context.DeadlineExceeded)
+ require.Less(t, time.Since(started), 2*time.Second)
+ })
+
+ t.Run("error body", func(t *testing.T) {
+ client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) {
+ return builderTestResponse(r, http.StatusBadRequest, strings.Repeat("x", 1<<20), nil), nil
+ }))
+ err := client.SubmitBuilderPreferences(context.Background(), "https://builder.example", common.Bytes48{}, request)
+ require.Error(t, err)
+ require.Less(t, len(err.Error()), 1024)
+ require.NotContains(t, err.Error(), "xxxx")
+ })
+}
+
+func TestDynamicBuilderCallsShareBoundedAdmission(t *testing.T) {
+ var active atomic.Int64
+ var maximum atomic.Int64
+ release := make(chan struct{})
+ client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) {
+ current := active.Add(1)
+ defer active.Add(-1)
+ for current > maximum.Load() && !maximum.CompareAndSwap(maximum.Load(), current) {
+ }
+ select {
+ case <-release:
+ case <-r.Context().Done():
+ return nil, r.Context().Err()
+ }
+ return builderTestResponse(r, http.StatusAccepted, "", nil), nil
+ }))
+ request := &cltypes.BuilderPreferencesRequest{Preferences: &cltypes.BuilderPreferences{}, Auth: validBuilderRequestAuth()}
+
+ var wg sync.WaitGroup
+ for range defaultBuilderCallLimit + 8 {
+ wg.Go(func() {
+ _ = client.SubmitBuilderPreferences(t.Context(), "https://builder.example", common.Bytes48{}, request)
+ })
+ }
+ require.Eventually(t, func() bool { return maximum.Load() == defaultPreferenceCallLimit }, time.Second, time.Millisecond)
+ close(release)
+ wg.Wait()
+ require.EqualValues(t, defaultPreferenceCallLimit, maximum.Load())
+}
+
+func TestBuilderPreferencesAdmissionReservesCriticalCapacity(t *testing.T) {
+ preferenceStarted := make(chan struct{}, 1)
+ releasePreference := make(chan struct{})
+ client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) {
+ if strings.Contains(r.URL.Path, "/builder_preferences/") {
+ preferenceStarted <- struct{}{}
+ select {
+ case <-releasePreference:
+ return builderTestResponse(r, http.StatusAccepted, "", nil), nil
+ case <-r.Context().Done():
+ return nil, r.Context().Err()
+ }
+ }
+ return builderTestResponse(r, http.StatusNoContent, "", nil), nil
+ }))
+ client.admission = semaphore.NewWeighted(2)
+ client.preferencesAdmission = semaphore.NewWeighted(1)
+ request := &cltypes.BuilderPreferencesRequest{Preferences: &cltypes.BuilderPreferences{}, Auth: validBuilderRequestAuth()}
+ firstDone := make(chan error, 1)
+ go func() {
+ firstDone <- client.SubmitBuilderPreferences(t.Context(), "https://builder.example", common.Bytes48{}, request)
+ }()
+ <-preferenceStarted
+ secondContext, cancelSecond := context.WithCancel(t.Context())
+ defer cancelSecond()
+ secondDone := make(chan error, 1)
+ go func() {
+ secondDone <- client.SubmitBuilderPreferences(secondContext, "https://builder.example", common.Bytes48{1}, request)
+ }()
+
+ criticalContext, cancelCritical := context.WithTimeout(t.Context(), 100*time.Millisecond)
+ defer cancelCritical()
+ bid, err := client.RequestExecutionPayloadBid(criticalContext, "https://builder.example", 12, common.Hash{}, common.Hash{}, common.Bytes48{}, validBuilderRequestAuth(), 100*time.Millisecond)
+ require.NoError(t, err)
+ require.Nil(t, bid)
+
+ cancelSecond()
+ require.ErrorIs(t, <-secondDone, context.Canceled)
+ close(releasePreference)
+ require.NoError(t, <-firstDone)
+}
+
+func TestDynamicBuilderAdmissionCancellationDoesNotLeakPermit(t *testing.T) {
+ started := make(chan struct{}, 1)
+ release := make(chan struct{})
+ client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) {
+ started <- struct{}{}
+ select {
+ case <-release:
+ return builderTestResponse(r, http.StatusAccepted, "", nil), nil
+ case <-r.Context().Done():
+ return nil, r.Context().Err()
+ }
+ }))
+ client.admission = semaphore.NewWeighted(1)
+ request := &cltypes.BuilderPreferencesRequest{Preferences: &cltypes.BuilderPreferences{}, Auth: validBuilderRequestAuth()}
+ firstDone := make(chan error, 1)
+ go func() {
+ firstDone <- client.SubmitBuilderPreferences(t.Context(), "https://builder.example", common.Bytes48{}, request)
+ }()
+ <-started
+ waitingContext, cancel := context.WithTimeout(t.Context(), 10*time.Millisecond)
+ defer cancel()
+ require.ErrorIs(t, client.SubmitBuilderPreferences(waitingContext, "https://builder.example", common.Bytes48{}, request), context.DeadlineExceeded)
+ close(release)
+ require.NoError(t, <-firstDone)
+ require.NoError(t, client.SubmitBuilderPreferences(t.Context(), "https://builder.example", common.Bytes48{}, request))
+}
+
+func TestDynamicBuilderClientDoesNotRequireLegacyRelay(t *testing.T) {
+ client := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{})
+ require.NotNil(t, client)
+ require.Nil(t, client.url)
+}
+
+func TestDynamicBuilderDialUsesValidatedAddress(t *testing.T) {
+ var dialed string
+ client := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{})
+ client.lookupIP = func(context.Context, string) ([]net.IPAddr, error) {
+ return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil
+ }
+ client.transport = newPinnedBuilderTransport(func(_ context.Context, _, address string) (net.Conn, error) {
+ dialed = address
+ clientConn, serverConn := net.Pipe()
+ go func() {
+ defer serverConn.Close()
+ request, err := http.ReadRequest(bufio.NewReader(serverConn))
+ if err == nil {
+ request.Body.Close()
+ _, _ = serverConn.Write([]byte("HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"))
+ }
+ }()
+ return clientConn, nil
+ })
+ request := &cltypes.BuilderPreferencesRequest{Preferences: &cltypes.BuilderPreferences{}, Auth: validBuilderRequestAuth()}
+ require.NoError(t, client.SubmitBuilderPreferences(t.Context(), "http://builder.example:18550", common.Bytes48{}, request))
+ require.Equal(t, "93.184.216.34:18550", dialed)
+}
+
+func TestDynamicBuilderDialFallsBackAcrossValidatedAddresses(t *testing.T) {
+ addresses := []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}, {IP: net.ParseIP("93.184.216.35")}}
+ var lookups int
+ var dialed []string
+ client := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{})
+ client.lookupIP = func(context.Context, string) ([]net.IPAddr, error) {
+ lookups++
+ return addresses, nil
+ }
+ client.transport = newPinnedBuilderTransport(func(_ context.Context, _, address string) (net.Conn, error) {
+ dialed = append(dialed, address)
+ if address == "93.184.216.34:18550" {
+ return nil, errors.New("first address unavailable")
+ }
+ clientConn, serverConn := net.Pipe()
+ go func() {
+ defer serverConn.Close()
+ request, err := http.ReadRequest(bufio.NewReader(serverConn))
+ if err == nil {
+ request.Body.Close()
+ _, _ = serverConn.Write([]byte("HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"))
+ }
+ }()
+ return clientConn, nil
+ })
+ request := &cltypes.BuilderPreferencesRequest{Preferences: &cltypes.BuilderPreferences{}, Auth: validBuilderRequestAuth()}
+
+ require.NoError(t, client.SubmitBuilderPreferences(t.Context(), "http://builder.example:18550", common.Bytes48{}, request))
+ require.Equal(t, 1, lookups)
+ require.Equal(t, []string{"93.184.216.34:18550", "93.184.216.35:18550"}, dialed)
+}
+
+func TestDynamicBuilderDialAllFailAndCancellation(t *testing.T) {
+ addresses := []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}, {IP: net.ParseIP("93.184.216.35")}}
+ request := &cltypes.BuilderPreferencesRequest{Preferences: &cltypes.BuilderPreferences{}, Auth: validBuilderRequestAuth()}
+
+ t.Run("all fail", func(t *testing.T) {
+ var dialed []string
+ client := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{})
+ client.lookupIP = func(context.Context, string) ([]net.IPAddr, error) { return addresses, nil }
+ client.transport = newPinnedBuilderTransport(func(_ context.Context, _, address string) (net.Conn, error) {
+ dialed = append(dialed, address)
+ return nil, errors.New("unavailable")
+ })
+
+ require.Error(t, client.SubmitBuilderPreferences(t.Context(), "http://builder.example:18550", common.Bytes48{}, request))
+ require.Equal(t, []string{"93.184.216.34:18550", "93.184.216.35:18550"}, dialed)
+ })
+
+ t.Run("canceled dial stops fallback", func(t *testing.T) {
+ var dialed []string
+ var dialedMu sync.Mutex
+ ctx, cancel := context.WithCancel(t.Context())
+ defer cancel()
+ client := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{})
+ client.lookupIP = func(context.Context, string) ([]net.IPAddr, error) { return addresses, nil }
+ client.transport = newPinnedBuilderTransport(func(dialCtx context.Context, _, address string) (net.Conn, error) {
+ dialedMu.Lock()
+ dialed = append(dialed, address)
+ dialedMu.Unlock()
+ cancel()
+ <-dialCtx.Done()
+ return nil, dialCtx.Err()
+ })
+
+ require.ErrorIs(t, client.SubmitBuilderPreferences(ctx, "http://builder.example:18550", common.Bytes48{}, request), context.Canceled)
+ dialedMu.Lock()
+ defer dialedMu.Unlock()
+ require.Equal(t, []string{"93.184.216.34:18550"}, dialed)
+ })
+}
+
+func TestDynamicBuilderDialBlackholeDoesNotConsumeFallbackDeadline(t *testing.T) {
+ addresses := []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}, {IP: net.ParseIP("93.184.216.35")}}
+ client := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{})
+ client.lookupIP = func(context.Context, string) ([]net.IPAddr, error) { return addresses, nil }
+ client.transport = newPinnedBuilderTransport(func(ctx context.Context, _, address string) (net.Conn, error) {
+ if address == "93.184.216.34:18550" {
+ <-ctx.Done()
+ return nil, ctx.Err()
+ }
+ clientConn, serverConn := net.Pipe()
+ go func() {
+ defer serverConn.Close()
+ request, err := http.ReadRequest(bufio.NewReader(serverConn))
+ if err == nil {
+ request.Body.Close()
+ _, _ = serverConn.Write([]byte("HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"))
+ }
+ }()
+ return clientConn, nil
+ })
+ block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion)
+ require.NoError(t, client.SubmitSignedBeaconBlock(t.Context(), "http://builder.example:18550", block))
+}
+
+func TestPrivateBuilderTargetsRequireExplicitPolicy(t *testing.T) {
+ request := &cltypes.BuilderPreferencesRequest{Preferences: &cltypes.BuilderPreferences{}, Auth: validBuilderRequestAuth()}
+ rejected := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{})
+ rejected.lookupIP = func(context.Context, string) ([]net.IPAddr, error) {
+ return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
+ }
+ require.Error(t, rejected.SubmitBuilderPreferences(t.Context(), "http://builder.local:18550", common.Bytes48{}, request))
+ require.Error(t, rejected.SubmitSignedBeaconBlock(t.Context(), "http://builder.local:18550", cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion)))
+
+ allowed := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{AllowPrivate: true})
+ allowed.lookupIP = rejected.lookupIP
+ allowed.httpClient.Transport = mockRoundTripper(func(r *http.Request) (*http.Response, error) {
+ return builderTestResponse(r, http.StatusAccepted, "", nil), nil
+ })
+ allowed.transport = nil
+ require.NoError(t, allowed.SubmitBuilderPreferences(t.Context(), "http://builder.local:18550", common.Bytes48{}, request))
+ require.NoError(t, allowed.SubmitSignedBeaconBlock(t.Context(), "http://builder.local:18550", cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion)))
+}
+
+func TestSubmitSignedBeaconBlock(t *testing.T) {
+ block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion)
+ client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) {
+ require.Equal(t, "/eth/v1/builder/beacon_blocks", r.URL.Path)
+ require.Equal(t, "gloas", r.Header.Get("Eth-Consensus-Version"))
+ body, err := io.ReadAll(r.Body)
+ require.NoError(t, err)
+ expected, err := json.Marshal(block)
+ require.NoError(t, err)
+ require.JSONEq(t, string(expected), string(body))
+ return builderTestResponse(r, http.StatusAccepted, "", nil), nil
+ }))
+ require.NoError(t, client.SubmitSignedBeaconBlock(context.Background(), "https://builder.example", block))
+}
+
+func TestSubmitSignedBeaconBlockPublicRejectsPrivateTargetWithPrivateConfigured(t *testing.T) {
+ for _, addresses := range [][]net.IPAddr{
+ {{IP: net.ParseIP("127.0.0.1")}},
+ {{IP: net.ParseIP("93.184.216.34")}, {IP: net.ParseIP("127.0.0.1")}},
+ } {
+ client := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{AllowPrivate: true})
+ client.lookupIP = func(context.Context, string) ([]net.IPAddr, error) {
+ return addresses, nil
+ }
+ err := client.SubmitSignedBeaconBlockPublic(t.Context(), "http://builder.example", cltypes.NewSignedBeaconBlock(mockBeaconConfig, clparams.GloasVersion))
+ require.ErrorContains(t, err, "disallowed address")
+ }
+}
+
+func TestIsPublicBuilderIPRejectsSpecialPurposeRanges(t *testing.T) {
+ tests := []struct {
+ address string
+ public bool
+ }{
+ {address: "10.0.0.1"}, {address: "100.64.0.1"}, {address: "127.0.0.1"},
+ {address: "169.254.1.1"}, {address: "192.0.2.1"}, {address: "198.18.0.1"},
+ {address: "198.51.100.1"}, {address: "203.0.113.1"}, {address: "240.0.0.1"},
+ {address: "::1"}, {address: "::2"}, {address: "::127.0.0.1"},
+ {address: "::169.254.169.254"}, {address: "64:ff9b::1"}, {address: "64:ff9b:1::1"},
+ {address: "100::1"}, {address: "100:0:0:1::1"}, {address: "2001:db8::1"},
+ {address: "1fff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"},
+ {address: "2000::", public: true},
+ {address: "3fff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", public: true},
+ {address: "4000::"},
+ {address: "4000::1"}, {address: "6000::1"}, {address: "fc00::1"},
+ {address: "fec0::1"}, {address: "fe80::1"}, {address: "::ffff:100.64.0.1"},
+ {address: "::ffff:127.0.0.1"},
+ {address: "8.8.8.8", public: true}, {address: "93.184.216.34", public: true},
+ {address: "::ffff:8.8.8.8", public: true}, {address: "2001:4860:4860::8888", public: true},
+ }
+ for _, test := range tests {
+ t.Run(test.address, func(t *testing.T) {
+ require.Equal(t, test.public, isPublicBuilderIP(net.ParseIP(test.address)))
+ })
+ }
+}
+
+func TestSubmitSignedBeaconBlockPublicRejectsIPv4CompatibleLiteralBeforeDial(t *testing.T) {
+ client := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{AllowPrivate: true})
+ dialed := false
+ client.publicTransport = mockRoundTripper(func(r *http.Request) (*http.Response, error) {
+ dialed = true
+ return builderTestResponse(r, http.StatusAccepted, "", nil), nil
+ })
+
+ err := client.SubmitSignedBeaconBlockPublic(t.Context(), "http://[::127.0.0.1]:18550", cltypes.NewSignedBeaconBlock(mockBeaconConfig, clparams.GloasVersion))
+ require.ErrorContains(t, err, "disallowed address")
+ require.False(t, dialed)
+}
+
+func TestSubmitSignedBeaconBlockPublicRejectsNewSpecialPurposeMixedAnswers(t *testing.T) {
+ for _, special := range []string{"fec0::1", "64:ff9b::1", "100:0:0:1::1"} {
+ t.Run(special, func(t *testing.T) {
+ client := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{})
+ client.lookupIP = func(context.Context, string) ([]net.IPAddr, error) {
+ return []net.IPAddr{{IP: net.ParseIP("2001:4860:4860::8888")}, {IP: net.ParseIP(special)}}, nil
+ }
+ err := client.SubmitSignedBeaconBlockPublic(t.Context(), "http://builder.example", cltypes.NewSignedBeaconBlock(mockBeaconConfig, clparams.GloasVersion))
+ require.ErrorContains(t, err, "disallowed address")
+ })
+ }
+}
+
+func TestSubmitSignedBeaconBlockPublicDoesNotReuseTrustedPrivateKeepalive(t *testing.T) {
+ client := NewDynamicBuilderClient(mockBeaconConfig, BuilderTargetPolicy{AllowPrivate: true})
+ resolved := net.ParseIP("127.0.0.1")
+ client.lookupIP = func(context.Context, string) ([]net.IPAddr, error) {
+ return []net.IPAddr{{IP: resolved}}, nil
+ }
+ privateRequests := make(chan struct{}, 2)
+ client.transport = newPinnedBuilderTransport(func(_ context.Context, _, _ string) (net.Conn, error) {
+ clientConn, serverConn := net.Pipe()
+ go func() {
+ defer serverConn.Close()
+ reader := bufio.NewReader(serverConn)
+ for {
+ request, err := http.ReadRequest(reader)
+ if err != nil {
+ return
+ }
+ request.Body.Close()
+ privateRequests <- struct{}{}
+ _, _ = serverConn.Write([]byte("HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n"))
+ }
+ }()
+ return clientConn, nil
+ })
+ publicDialed := make(chan string, 1)
+ client.publicTransport = newPinnedBuilderTransport(func(_ context.Context, _, address string) (net.Conn, error) {
+ publicDialed <- address
+ clientConn, serverConn := net.Pipe()
+ go func() {
+ defer serverConn.Close()
+ request, err := http.ReadRequest(bufio.NewReader(serverConn))
+ if err == nil {
+ request.Body.Close()
+ _, _ = serverConn.Write([]byte("HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"))
+ }
+ }()
+ return clientConn, nil
+ })
+ t.Cleanup(func() {
+ client.transport.(*http.Transport).CloseIdleConnections()
+ client.publicTransport.(*http.Transport).CloseIdleConnections()
+ })
+ block := cltypes.NewSignedBeaconBlock(mockBeaconConfig, clparams.GloasVersion)
+ require.NoError(t, client.SubmitSignedBeaconBlock(t.Context(), "http://builder.example:18550", block))
+ <-privateRequests
+ resolved = net.ParseIP("93.184.216.34")
+ require.NoError(t, client.SubmitSignedBeaconBlockPublic(t.Context(), "http://builder.example:18550", block))
+ require.Equal(t, "93.184.216.34:18550", <-publicDialed)
+ select {
+ case <-privateRequests:
+ t.Fatal("strict-public request reused trusted private keepalive")
+ default:
+ }
+}
+
+func TestSubmitSignedBeaconBlockHasHotPathDeadline(t *testing.T) {
+ block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion)
+ client := publicBuilderTestClient(mockRoundTripper(func(r *http.Request) (*http.Response, error) {
+ deadline, ok := r.Context().Deadline()
+ require.True(t, ok)
+ require.LessOrEqual(t, time.Until(deadline), builderBeaconBlockTimeout)
+ return nil, errors.New("builder unavailable")
+ }))
+
+ require.Error(t, client.SubmitSignedBeaconBlock(context.Background(), "https://builder.example", block))
+}
+
+func validBuilderRequestAuth() *cltypes.SignedBuilderRequestAuth {
+ return &cltypes.SignedBuilderRequestAuth{
+ Message: &cltypes.BuilderRequestAuth{Data: []byte("builder-auth"), Slot: 12},
+ }
+}
+
+func publicBuilderTestClient(transport http.RoundTripper) *builderClient {
+ return &builderClient{
+ httpClient: &http.Client{Transport: transport},
+ lookupIP: func(context.Context, string) ([]net.IPAddr, error) {
+ return []net.IPAddr{{IP: net.ParseIP("93.184.216.34")}}, nil
+ },
+ beaconConfig: mockBeaconConfig,
+ }
+}
+
+func builderTestResponse(request *http.Request, status int, body string, header http.Header) *http.Response {
+ return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: header, Request: request}
+}
+
func TestSubmitBlindedBlocksFulu(t *testing.T) {
ctx := context.Background()
expectPath := mockUrl.JoinPath("/eth/v2/builder/blinded_blocks").String()
diff --git a/cl/beacon/builder/interface.go b/cl/beacon/builder/interface.go
index 124a4dd138e..14ed897b754 100644
--- a/cl/beacon/builder/interface.go
+++ b/cl/beacon/builder/interface.go
@@ -18,6 +18,7 @@ package builder
import (
"context"
+ "time"
"github.com/erigontech/erigon/cl/cltypes"
"github.com/erigontech/erigon/common"
@@ -29,5 +30,9 @@ type BuilderClient interface {
RegisterValidator(ctx context.Context, registers []*cltypes.ValidatorRegistration) error
GetHeader(ctx context.Context, slot int64, parentHash common.Hash, pubKey common.Bytes48) (*ExecutionHeader, error)
SubmitBlindedBlocks(ctx context.Context, block *cltypes.SignedBlindedBeaconBlock) (*cltypes.Eth1Block, *engine_types.BlobsBundle, *cltypes.ExecutionRequests, error)
+ SubmitBuilderPreferences(ctx context.Context, builderURL string, proposerPubkey common.Bytes48, request *cltypes.BuilderPreferencesRequest) error
+ RequestExecutionPayloadBid(ctx context.Context, builderURL string, slot uint64, parentHash, parentRoot common.Hash, proposerPubkey common.Bytes48, auth *cltypes.SignedBuilderRequestAuth, timeout time.Duration) (*cltypes.SignedExecutionPayloadBid, error)
+ SubmitSignedBeaconBlock(ctx context.Context, builderURL string, block *cltypes.SignedBeaconBlock) error
+ SubmitSignedBeaconBlockPublic(ctx context.Context, builderURL string, block *cltypes.SignedBeaconBlock) error
GetStatus(ctx context.Context) error
}
diff --git a/cl/beacon/builder/mock_services/builder_client_mock.go b/cl/beacon/builder/mock_services/builder_client_mock.go
index 81654390dd0..d5a61a8be62 100644
--- a/cl/beacon/builder/mock_services/builder_client_mock.go
+++ b/cl/beacon/builder/mock_services/builder_client_mock.go
@@ -12,6 +12,7 @@ package mock_services
import (
context "context"
reflect "reflect"
+ time "time"
builder "github.com/erigontech/erigon/cl/beacon/builder"
cltypes "github.com/erigontech/erigon/cl/cltypes"
@@ -159,6 +160,45 @@ func (c *MockBuilderClientRegisterValidatorCall) DoAndReturn(f func(context.Cont
return c
}
+// RequestExecutionPayloadBid mocks base method.
+func (m *MockBuilderClient) RequestExecutionPayloadBid(ctx context.Context, builderURL string, slot uint64, parentHash, parentRoot common.Hash, proposerPubkey common.Bytes48, auth *cltypes.SignedBuilderRequestAuth, timeout time.Duration) (*cltypes.SignedExecutionPayloadBid, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "RequestExecutionPayloadBid", ctx, builderURL, slot, parentHash, parentRoot, proposerPubkey, auth, timeout)
+ ret0, _ := ret[0].(*cltypes.SignedExecutionPayloadBid)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// RequestExecutionPayloadBid indicates an expected call of RequestExecutionPayloadBid.
+func (mr *MockBuilderClientMockRecorder) RequestExecutionPayloadBid(ctx, builderURL, slot, parentHash, parentRoot, proposerPubkey, auth, timeout any) *MockBuilderClientRequestExecutionPayloadBidCall {
+ mr.mock.ctrl.T.Helper()
+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestExecutionPayloadBid", reflect.TypeOf((*MockBuilderClient)(nil).RequestExecutionPayloadBid), ctx, builderURL, slot, parentHash, parentRoot, proposerPubkey, auth, timeout)
+ return &MockBuilderClientRequestExecutionPayloadBidCall{Call: call}
+}
+
+// MockBuilderClientRequestExecutionPayloadBidCall wrap *gomock.Call
+type MockBuilderClientRequestExecutionPayloadBidCall struct {
+ *gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MockBuilderClientRequestExecutionPayloadBidCall) Return(arg0 *cltypes.SignedExecutionPayloadBid, arg1 error) *MockBuilderClientRequestExecutionPayloadBidCall {
+ c.Call = c.Call.Return(arg0, arg1)
+ return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MockBuilderClientRequestExecutionPayloadBidCall) Do(f func(context.Context, string, uint64, common.Hash, common.Hash, common.Bytes48, *cltypes.SignedBuilderRequestAuth, time.Duration) (*cltypes.SignedExecutionPayloadBid, error)) *MockBuilderClientRequestExecutionPayloadBidCall {
+ c.Call = c.Call.Do(f)
+ return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MockBuilderClientRequestExecutionPayloadBidCall) DoAndReturn(f func(context.Context, string, uint64, common.Hash, common.Hash, common.Bytes48, *cltypes.SignedBuilderRequestAuth, time.Duration) (*cltypes.SignedExecutionPayloadBid, error)) *MockBuilderClientRequestExecutionPayloadBidCall {
+ c.Call = c.Call.DoAndReturn(f)
+ return c
+}
+
// SubmitBlindedBlocks mocks base method.
func (m *MockBuilderClient) SubmitBlindedBlocks(ctx context.Context, block *cltypes.SignedBlindedBeaconBlock) (*cltypes.Eth1Block, *engine_types.BlobsBundle, *cltypes.ExecutionRequests, error) {
m.ctrl.T.Helper()
@@ -199,3 +239,117 @@ func (c *MockBuilderClientSubmitBlindedBlocksCall) DoAndReturn(f func(context.Co
c.Call = c.Call.DoAndReturn(f)
return c
}
+
+// SubmitBuilderPreferences mocks base method.
+func (m *MockBuilderClient) SubmitBuilderPreferences(ctx context.Context, builderURL string, proposerPubkey common.Bytes48, request *cltypes.BuilderPreferencesRequest) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "SubmitBuilderPreferences", ctx, builderURL, proposerPubkey, request)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// SubmitBuilderPreferences indicates an expected call of SubmitBuilderPreferences.
+func (mr *MockBuilderClientMockRecorder) SubmitBuilderPreferences(ctx, builderURL, proposerPubkey, request any) *MockBuilderClientSubmitBuilderPreferencesCall {
+ mr.mock.ctrl.T.Helper()
+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitBuilderPreferences", reflect.TypeOf((*MockBuilderClient)(nil).SubmitBuilderPreferences), ctx, builderURL, proposerPubkey, request)
+ return &MockBuilderClientSubmitBuilderPreferencesCall{Call: call}
+}
+
+// MockBuilderClientSubmitBuilderPreferencesCall wrap *gomock.Call
+type MockBuilderClientSubmitBuilderPreferencesCall struct {
+ *gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MockBuilderClientSubmitBuilderPreferencesCall) Return(arg0 error) *MockBuilderClientSubmitBuilderPreferencesCall {
+ c.Call = c.Call.Return(arg0)
+ return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MockBuilderClientSubmitBuilderPreferencesCall) Do(f func(context.Context, string, common.Bytes48, *cltypes.BuilderPreferencesRequest) error) *MockBuilderClientSubmitBuilderPreferencesCall {
+ c.Call = c.Call.Do(f)
+ return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MockBuilderClientSubmitBuilderPreferencesCall) DoAndReturn(f func(context.Context, string, common.Bytes48, *cltypes.BuilderPreferencesRequest) error) *MockBuilderClientSubmitBuilderPreferencesCall {
+ c.Call = c.Call.DoAndReturn(f)
+ return c
+}
+
+// SubmitSignedBeaconBlock mocks base method.
+func (m *MockBuilderClient) SubmitSignedBeaconBlock(ctx context.Context, builderURL string, block *cltypes.SignedBeaconBlock) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "SubmitSignedBeaconBlock", ctx, builderURL, block)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// SubmitSignedBeaconBlock indicates an expected call of SubmitSignedBeaconBlock.
+func (mr *MockBuilderClientMockRecorder) SubmitSignedBeaconBlock(ctx, builderURL, block any) *MockBuilderClientSubmitSignedBeaconBlockCall {
+ mr.mock.ctrl.T.Helper()
+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitSignedBeaconBlock", reflect.TypeOf((*MockBuilderClient)(nil).SubmitSignedBeaconBlock), ctx, builderURL, block)
+ return &MockBuilderClientSubmitSignedBeaconBlockCall{Call: call}
+}
+
+// MockBuilderClientSubmitSignedBeaconBlockCall wrap *gomock.Call
+type MockBuilderClientSubmitSignedBeaconBlockCall struct {
+ *gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MockBuilderClientSubmitSignedBeaconBlockCall) Return(arg0 error) *MockBuilderClientSubmitSignedBeaconBlockCall {
+ c.Call = c.Call.Return(arg0)
+ return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MockBuilderClientSubmitSignedBeaconBlockCall) Do(f func(context.Context, string, *cltypes.SignedBeaconBlock) error) *MockBuilderClientSubmitSignedBeaconBlockCall {
+ c.Call = c.Call.Do(f)
+ return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MockBuilderClientSubmitSignedBeaconBlockCall) DoAndReturn(f func(context.Context, string, *cltypes.SignedBeaconBlock) error) *MockBuilderClientSubmitSignedBeaconBlockCall {
+ c.Call = c.Call.DoAndReturn(f)
+ return c
+}
+
+// SubmitSignedBeaconBlockPublic mocks base method.
+func (m *MockBuilderClient) SubmitSignedBeaconBlockPublic(ctx context.Context, builderURL string, block *cltypes.SignedBeaconBlock) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "SubmitSignedBeaconBlockPublic", ctx, builderURL, block)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// SubmitSignedBeaconBlockPublic indicates an expected call of SubmitSignedBeaconBlockPublic.
+func (mr *MockBuilderClientMockRecorder) SubmitSignedBeaconBlockPublic(ctx, builderURL, block any) *MockBuilderClientSubmitSignedBeaconBlockPublicCall {
+ mr.mock.ctrl.T.Helper()
+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitSignedBeaconBlockPublic", reflect.TypeOf((*MockBuilderClient)(nil).SubmitSignedBeaconBlockPublic), ctx, builderURL, block)
+ return &MockBuilderClientSubmitSignedBeaconBlockPublicCall{Call: call}
+}
+
+// MockBuilderClientSubmitSignedBeaconBlockPublicCall wrap *gomock.Call
+type MockBuilderClientSubmitSignedBeaconBlockPublicCall struct {
+ *gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MockBuilderClientSubmitSignedBeaconBlockPublicCall) Return(arg0 error) *MockBuilderClientSubmitSignedBeaconBlockPublicCall {
+ c.Call = c.Call.Return(arg0)
+ return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MockBuilderClientSubmitSignedBeaconBlockPublicCall) Do(f func(context.Context, string, *cltypes.SignedBeaconBlock) error) *MockBuilderClientSubmitSignedBeaconBlockPublicCall {
+ c.Call = c.Call.Do(f)
+ return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MockBuilderClientSubmitSignedBeaconBlockPublicCall) DoAndReturn(f func(context.Context, string, *cltypes.SignedBeaconBlock) error) *MockBuilderClientSubmitSignedBeaconBlockPublicCall {
+ c.Call = c.Call.DoAndReturn(f)
+ return c
+}
diff --git a/cl/beacon/handler/block_production.go b/cl/beacon/handler/block_production.go
index ac78bf30fe3..5b96597620f 100644
--- a/cl/beacon/handler/block_production.go
+++ b/cl/beacon/handler/block_production.go
@@ -24,7 +24,9 @@ import (
"errors"
"fmt"
"io"
+ "math"
"math/big"
+ "math/bits"
"net/http"
"slices"
"strconv"
@@ -48,8 +50,10 @@ import (
"github.com/erigontech/erigon/cl/phase1/core/state"
"github.com/erigontech/erigon/cl/phase1/execution_client"
"github.com/erigontech/erigon/cl/phase1/forkchoice"
+ clservices "github.com/erigontech/erigon/cl/phase1/network/services"
"github.com/erigontech/erigon/cl/phase1/network/subnets"
"github.com/erigontech/erigon/cl/pool"
+ ssz2 "github.com/erigontech/erigon/cl/ssz"
"github.com/erigontech/erigon/cl/transition"
"github.com/erigontech/erigon/cl/transition/impl/eth2"
"github.com/erigontech/erigon/cl/transition/machine"
@@ -77,6 +81,8 @@ const (
)
var errBuilderNotEnabled = errors.New("builder is not enabled")
+var errPublishedBlockValidation = errors.New("published block validation failed")
+var errPublishedBlockDataStorage = errors.New("published block data storage failed")
const (
caplinClientCode = "CN"
@@ -618,8 +624,11 @@ func (a *ApiHandler) GetEthV3ValidatorBlock(
log.Debug("[Beacon API] Producing block", "slot", targetSlot)
// builder boost factor controls block choice between local execution node or builder
builderBoostFactor := uint64(100)
+ if options := gloasBlockOptionsFromContext(ctx); options != nil {
+ builderBoostFactor = options.builderConfig.BuilderBoostFactor
+ }
builderBoostFactorStr := r.URL.Query().Get("builder_boost_factor")
- if builderBoostFactorStr != "" {
+ if builderBoostFactorStr != "" && gloasBlockOptionsFromContext(ctx) == nil {
builderBoostFactor, err = strconv.ParseUint(builderBoostFactorStr, 10, 64)
if err != nil {
return nil, beaconhttp.NewEndpointError(
@@ -718,7 +727,7 @@ func (a *ApiHandler) GetEthV3ValidatorBlock(
resp = newBeaconResponse(block.ToExecution())
}
resp = resp.WithVersion(block.Version()).With("execution_payload_blinded", block.IsBlinded()).
- With("execution_payload_value", strconv.FormatUint(block.GetExecutionValue().Uint64(), 10)).
+ With("execution_payload_value", block.GetExecutionValue().String()).
With("consensus_block_value", strconv.FormatUint(consensusValue, 10))
executionPayloadIncluded := false
@@ -750,10 +759,24 @@ func (a *ApiHandler) GetEthV3ValidatorBlock(
}
// Cache envelope by slot so the VC can retrieve it via
// GET /eth/v1/validator/execution_payload_envelope/{slot}/{builder_index}
- a.selfBuildEnvelopes.Add(targetSlot, envelope)
+ a.selfBuildEnvelopes.Add(selfBuildEnvelopeKey{Slot: targetSlot, BeaconBlockRoot: beaconBlockRoot}, envelope)
// SSZ encoding only serializes Data, not Extra — only include
// the envelope in JSON responses to keep the header truthful.
- if !beaconhttp.WillEncodeSSZ(r.Header.Get("Accept")) {
+ options := gloasBlockOptionsFromContext(ctx)
+ switch {
+ case options != nil && options.includePayload:
+ contents := cltypes.NewGloasBlockContents(a.beaconChainCfg, targetSlot)
+ contents.Block = denebBlock.Block
+ contents.ExecutionPayloadEnvelope = envelope
+ contents.KZGProofs = denebBlock.KZGProofs
+ contents.Blobs = denebBlock.Blobs
+ resp = newBeaconResponse(contents).
+ WithVersion(block.Version()).
+ With("execution_payload_blinded", block.IsBlinded()).
+ With("execution_payload_value", block.GetExecutionValue().String()).
+ With("consensus_block_value", strconv.FormatUint(consensusValue, 10))
+ executionPayloadIncluded = true
+ case options == nil && !beaconhttp.WillEncodeSSZ(r.Header.Get("Accept")):
resp = resp.With("execution_payload_envelope", envelope)
executionPayloadIncluded = true
}
@@ -771,13 +794,30 @@ func (a *ApiHandler) GetEthV3ValidatorBlock(
block.Version(),
block.IsBlinded(),
executionPayloadIncluded,
- block.GetExecutionValue().Uint64(),
+ block.GetExecutionValue(),
consensusValue,
)
+ if options := gloasBlockOptionsFromContext(ctx); options != nil && options.selectedBuilderURL != "" {
+ root, err := block.ToExecution().Block.HashSSZ()
+ if err != nil {
+ return nil, err
+ }
+ if err := a.setBuilderRouteHeader(w, root, options.selectedBuilderURL); err != nil {
+ return nil, err
+ }
+ }
return resp, nil
}
+func (a *ApiHandler) setBuilderRouteHeader(w http.ResponseWriter, root common.Hash, builderURL string) error {
+ if a.builderRoutes == nil || !a.builderRoutes.Add(root, builderURL) {
+ return beaconhttp.NewEndpointError(http.StatusServiceUnavailable, errors.New("builder handoff capacity unavailable"))
+ }
+ w.Header().Set("Eth-Builder-Url", builderURL)
+ return nil
+}
+
func (a *ApiHandler) produceBlock(
ctx context.Context,
boostFactor uint64,
@@ -790,11 +830,12 @@ func (a *ApiHandler) produceBlock(
) (block *cltypes.BlindOrExecutionBeaconBlock, err error) {
defer func() { reportProductionFailure(err, targetSlot) }()
+ stateVersion := a.beaconChainCfg.GetCurrentStateVersion(targetSlot / a.beaconChainCfg.SlotsPerEpoch)
var wg sync.WaitGroup
// produce beacon body
var (
beaconBody *cltypes.BeaconBody
- localExecValue uint64
+ localExecValue *big.Int
localErr error
blobs []*cltypes.Blob
kzgProofs []common.Bytes48
@@ -843,7 +884,7 @@ func (a *ApiHandler) produceBlock(
defer func() {
a.logger.Debug("MevBoost", "slot", targetSlot, "duration", time.Since(start))
}()
- if a.routerCfg.Builder && a.builderClient != nil {
+ if stateVersion.Before(clparams.GloasVersion) && a.routerCfg.Builder && a.builderClient != nil {
builderHeader, builderErr = a.getBuilderPayload(ctx, baseState, targetSlot)
if builderErr != nil && !errors.Is(builderErr, errBuilderNotEnabled) {
log.Warn("Failed to get builder payload", "err", builderErr)
@@ -853,10 +894,13 @@ func (a *ApiHandler) produceBlock(
// wait for both tasks to finish
wg.Wait()
- if localErr != nil {
- // if we failed to locally produce the beacon body, we should not proceed with the block production
+ if localErr != nil && (stateVersion.Before(clparams.GloasVersion) || beaconBody == nil) {
return nil, localErr
}
+ if localErr != nil {
+ blobs = nil
+ kzgProofs = nil
+ }
// prepare basic block
// Always use the post-ProcessSlots state to get the proposer index.
// The state has been advanced to targetSlot, so GetBeaconProposerIndex
@@ -872,45 +916,75 @@ func (a *ApiHandler) produceBlock(
ParentRoot: baseBlockRoot,
Cfg: a.beaconChainCfg,
}
- stateVersion := a.beaconChainCfg.GetCurrentStateVersion(targetSlot / a.beaconChainCfg.SlotsPerEpoch)
if !a.routerCfg.Builder || builderErr != nil || stateVersion.AfterOrEqual(clparams.GloasVersion) {
// directly return the block if:
// 1. builder is not enabled
// 2. failed to get builder payload
// 3. GLOAS: MEV-Boost blinded blocks not supported; builders use ePBS gossip bids
- // GLOAS: check epbsPool for an external builder bid that beats the local value.
- if stateVersion.AfterOrEqual(clparams.GloasVersion) && a.epbsPool != nil {
+ // GLOAS: check p2p and configured Builder API bids against the local value.
+ if stateVersion.AfterOrEqual(clparams.GloasVersion) {
selfBid := beaconBody.SignedExecutionPayloadBid.Message
- bidKey := pool.HighestBidKey{
- Slot: targetSlot,
- ParentBlockHash: selfBid.ParentBlockHash,
- ParentBlockRoot: selfBid.ParentBlockRoot,
+ options := gloasBlockOptionsFromContext(ctx)
+ p2pMinBid := uint64(0)
+ if options != nil && options.builderConfig != nil {
+ p2pMinBid = options.builderConfig.MinBid
+ }
+ candidates := make([]gloasBidCandidate, 0, 1)
+ if a.epbsPool != nil {
+ bidKey := pool.HighestBidKey{
+ Slot: targetSlot,
+ ParentBlockHash: selfBid.ParentBlockHash,
+ ParentBlockRoot: selfBid.ParentBlockRoot,
+ }
+ if externalBid, found := a.epbsPool.HighestBids.Get(bidKey); found {
+ candidates = append(candidates, gloasBidCandidate{
+ bid: externalBid,
+ boostFactor: boostFactor,
+ maxExecutionPayment: math.MaxUint64,
+ minBid: p2pMinBid,
+ })
+ }
}
- if externalBid, found := a.epbsPool.HighestBids.Get(bidKey); found &&
- externalBid != nil && externalBid.Message != nil &&
- externalBid.Message.Value > localExecValue {
+ if options != nil && options.builderConfig != nil && a.builderClient != nil {
+ proposerPubkey, pubkeyErr := baseState.ValidatorPublicKey(int(proposerIndex))
+ if pubkeyErr == nil {
+ candidates = append(candidates, a.requestConfiguredBuilderBids(ctx, baseState, targetSlot, proposerPubkey, selfBid, options.builderConfig.Builders)...)
+ }
+ }
+ selected := selectGloasBid(localExecValue, candidates)
+ if selected != nil {
log.Info("GLOAS: selected external builder bid over self-build",
"slot", targetSlot,
- "builderIndex", externalBid.Message.BuilderIndex,
- "bidValue", externalBid.Message.Value,
+ "builderIndex", selected.bid.Message.BuilderIndex,
+ "bidValue", selected.bid.Message.Value,
"localValue", localExecValue)
- beaconBody.SignedExecutionPayloadBid = externalBid
- localExecValue = externalBid.Message.Value
+ beaconBody.SignedExecutionPayloadBid = selected.bid
+ block.BeaconBody = beaconBody
+ block.Blobs = blobs
+ block.KzgProofs = kzgProofs
+ block.ExecutionValue = selected.executionValueWei
+ if options != nil {
+ options.selectedBuilderURL = selected.builderURL
+ }
+ return block, nil
+ }
+ if localErr != nil {
+ return nil, localErr
}
}
block.BeaconBody = beaconBody
block.Blobs = blobs
block.KzgProofs = kzgProofs
- block.ExecutionValue = new(big.Int).SetUint64(localExecValue)
+ block.ExecutionValue = new(big.Int).Set(localExecValue)
return block, nil
}
// determine whether to use local execution node or builder
// if exec_node_payload_value >= builder_boost_factor * (builder_payload_value // 100), then return a full (unblinded) block containing the execution node payload.
// otherwise, return a blinded block containing the builder payload header.
- execValue := new(big.Int).SetUint64(localExecValue)
+ execValue := new(big.Int).Set(localExecValue)
builderValue := builderHeader.BlockValue()
boostFactorBig := new(big.Int).SetUint64(boostFactor)
useLocalExec := new(big.Int).Mul(execValue, big.NewInt(100)).Cmp(new(big.Int).Mul(builderValue, boostFactorBig)) >= 0
@@ -945,6 +1019,176 @@ func (a *ApiHandler) produceBlock(
return block, nil
}
+type gloasBidCandidate struct {
+ bid *cltypes.SignedExecutionPayloadBid
+ boostFactor uint64
+ maxExecutionPayment uint64
+ minBid uint64
+ builderURL string
+ executionValueWei *big.Int
+}
+
+type executionPayloadBidValidator interface {
+ ValidateBid(context.Context, *cltypes.SignedExecutionPayloadBid) error
+}
+
+func selectGloasBid(localExecutionValueWei *big.Int, candidates []gloasBidCandidate) *gloasBidCandidate {
+ var localWeighted *big.Int
+ if localExecutionValueWei != nil {
+ localWeighted = new(big.Int).Mul(new(big.Int).Set(localExecutionValueWei), big.NewInt(100))
+ }
+ var best *gloasBidCandidate
+ bestWeighted := new(big.Int)
+ for i := range candidates {
+ candidate := &candidates[i]
+ if candidate.bid == nil || candidate.bid.Message == nil {
+ continue
+ }
+ countedExecutionPayment := min(candidate.bid.Message.ExecutionPayment, candidate.maxExecutionPayment)
+ selectionValue, carry := bits.Add64(candidate.bid.Message.Value, countedExecutionPayment, 0)
+ if carry != 0 {
+ selectionValue = math.MaxUint64
+ }
+ if selectionValue < candidate.minBid {
+ continue
+ }
+ selectionValueWei := new(big.Int).Mul(new(big.Int).SetUint64(selectionValue), big.NewInt(1_000_000_000))
+ weighted := new(big.Int).Mul(selectionValueWei, new(big.Int).SetUint64(candidate.boostFactor))
+ if best == nil || weighted.Cmp(bestWeighted) > 0 {
+ actualValue := new(big.Int).Add(
+ new(big.Int).SetUint64(candidate.bid.Message.Value),
+ new(big.Int).SetUint64(candidate.bid.Message.ExecutionPayment),
+ )
+ candidate.executionValueWei = new(big.Int).Mul(actualValue, big.NewInt(1_000_000_000))
+ best = candidate
+ bestWeighted.Set(weighted)
+ }
+ }
+ if best == nil || localWeighted != nil && localWeighted.Cmp(bestWeighted) >= 0 {
+ return nil
+ }
+ return best
+}
+
+func (a *ApiHandler) requestConfiguredBuilderBids(
+ ctx context.Context,
+ baseState *state.CachingBeaconState,
+ targetSlot uint64,
+ proposerPubkey common.Bytes48,
+ parentBid *cltypes.ExecutionPayloadBid,
+ entries []*cltypes.BuilderEntry,
+) []gloasBidCandidate {
+ if parentBid == nil || len(entries) == 0 {
+ return nil
+ }
+ parentGasLimit, ok := a.forkchoiceStore.GetExecutionPayloadGasLimit(parentBid.ParentBlockHash)
+ if !ok {
+ a.logger.Debug("Skipping configured builder bids because parent gas limit is unavailable", "parentBlockHash", parentBid.ParentBlockHash)
+ return nil
+ }
+ proposerIndex, err := baseState.GetBeaconProposerIndexForSlot(targetSlot)
+ if err != nil {
+ return nil
+ }
+ expectedFeeRecipient := a.feeRecipientForProposal(proposerIndex, targetSlot)
+ defaultGasLimit := parentBid.GasLimit
+ if latestParentBid := baseState.GetLatestExecutionPayloadBid(); latestParentBid != nil {
+ defaultGasLimit = latestParentBid.GasLimit
+ }
+ targetGasLimit := a.proposalTargetGasLimit(baseState, targetSlot, proposerIndex, defaultGasLimit)
+ timeout := time.Second
+ if deadline, ok := ctx.Deadline(); ok {
+ remaining := time.Until(deadline)
+ if remaining <= 0 {
+ return nil
+ }
+ timeout = min(timeout, remaining)
+ }
+ type result struct {
+ entry *cltypes.BuilderEntry
+ bid *cltypes.SignedExecutionPayloadBid
+ }
+ results := make(chan result, len(entries))
+ var wg sync.WaitGroup
+ for _, entry := range entries {
+ if entry == nil {
+ continue
+ }
+ wg.Go(func() {
+ bid, err := a.builderClient.RequestExecutionPayloadBid(ctx, entry.URL, targetSlot,
+ parentBid.ParentBlockHash, parentBid.ParentBlockRoot, proposerPubkey, entry.Auth, timeout)
+ if err != nil {
+ a.logger.Debug("Builder API bid request failed", "url", entry.URL, "err", err)
+ return
+ }
+ results <- result{entry: entry, bid: bid}
+ })
+ }
+ wg.Wait()
+ close(results)
+
+ builders := baseState.GetBuilders()
+ candidates := make([]gloasBidCandidate, 0, len(results))
+ for result := range results {
+ bid := result.bid
+ if bid == nil || bid.Message == nil || bid.Message.Slot != targetSlot ||
+ bid.Message.ParentBlockHash != parentBid.ParentBlockHash || bid.Message.ParentBlockRoot != parentBid.ParentBlockRoot {
+ continue
+ }
+ if bid.Message.FeeRecipient != expectedFeeRecipient {
+ continue
+ }
+ if !clservices.IsGasLimitTargetCompatible(parentGasLimit, bid.Message.GasLimit, targetGasLimit) {
+ continue
+ }
+ if builders == nil || bid.Message.BuilderIndex >= uint64(builders.Len()) {
+ continue
+ }
+ builderRecord := builders.Get(int(bid.Message.BuilderIndex))
+ if builderRecord == nil || len(result.entry.BuilderPubkeys) != 0 && !slices.Contains(result.entry.BuilderPubkeys, builderRecord.Pubkey) {
+ continue
+ }
+ validator, ok := a.executionPayloadBidService.(executionPayloadBidValidator)
+ if !ok {
+ continue
+ }
+ if err := validator.ValidateBid(ctx, bid); err != nil {
+ a.logger.Debug("Builder API returned invalid bid", "url", result.entry.URL, "err", err)
+ continue
+ }
+ candidates = append(candidates, gloasBidCandidate{
+ bid: bid,
+ boostFactor: result.entry.BuilderBoostFactor,
+ maxExecutionPayment: result.entry.MaxExecutionPayment,
+ minBid: result.entry.MinBid,
+ builderURL: result.entry.URL,
+ })
+ }
+ return candidates
+}
+
+func (a *ApiHandler) proposalTargetGasLimit(
+ baseState *state.CachingBeaconState,
+ targetSlot uint64,
+ proposerIndex uint64,
+ defaultGasLimit uint64,
+) uint64 {
+ if a.epbsPool == nil {
+ return defaultGasLimit
+ }
+ proposalEpoch := state.GetEpochAtSlot(a.beaconChainCfg, targetSlot)
+ dependentRoot, err := state.GetProposerDependentRoot(baseState, proposalEpoch)
+ if err != nil {
+ log.Trace("Skipping proposer preferences target gas limit", "slot", targetSlot, "err", err)
+ return defaultGasLimit
+ }
+ pref, ok := a.epbsPool.GetPreference(targetSlot, dependentRoot)
+ if !ok || pref.Message == nil || pref.Message.ValidatorIndex != proposerIndex {
+ return defaultGasLimit
+ }
+ return pref.Message.TargetGasLimit
+}
+
func (a *ApiHandler) getBuilderPayload(
ctx context.Context,
baseState *state.CachingBeaconState,
@@ -1003,22 +1247,31 @@ func (a *ApiHandler) getBuilderPayload(
}
}
if baseState.Version() >= clparams.ElectraVersion && header.Data.Message.ExecutionRequests != nil {
- // check execution requests
- r := header.Data.Message.ExecutionRequests
- if r.Deposits != nil && r.Deposits.Len() > int(a.beaconChainCfg.MaxDepositRequestsPerPayload) {
- return nil, fmt.Errorf("too many deposit requests: %d", r.Deposits.Len())
- }
- if r.Withdrawals != nil && r.Withdrawals.Len() > int(a.beaconChainCfg.MaxWithdrawalRequestsPerPayload) {
- return nil, fmt.Errorf("too many withdrawal requests: %d", r.Withdrawals.Len())
- }
- if r.Consolidations != nil && r.Consolidations.Len() > int(a.beaconChainCfg.MaxConsolidationRequestsPerPayload) {
- return nil, fmt.Errorf("too many consolidation requests: %d", r.Consolidations.Len())
+ if err := validateBuilderExecutionRequests(a.beaconChainCfg, baseState.Version(), header.Data.Message.ExecutionRequests); err != nil {
+ return nil, err
}
}
return header, nil
}
+func validateBuilderExecutionRequests(cfg *clparams.BeaconChainConfig, version clparams.StateVersion, requests *cltypes.ExecutionRequests) error {
+ depositLimit := int(cfg.MaxDepositRequestsPerPayload)
+ if version >= clparams.GloasVersion {
+ depositLimit = int(clparams.MaxChunkSize) / solid.SizeDepositRequest
+ }
+ if requests.Deposits != nil && requests.Deposits.Len() > depositLimit {
+ return fmt.Errorf("too many deposit requests: %d", requests.Deposits.Len())
+ }
+ if requests.Withdrawals != nil && requests.Withdrawals.Len() > int(cfg.MaxWithdrawalRequestsPerPayload) {
+ return fmt.Errorf("too many withdrawal requests: %d", requests.Withdrawals.Len())
+ }
+ if requests.Consolidations != nil && requests.Consolidations.Len() > int(cfg.MaxConsolidationRequestsPerPayload) {
+ return fmt.Errorf("too many consolidation requests: %d", requests.Consolidations.Len())
+ }
+ return nil
+}
+
func (a *ApiHandler) produceBeaconBody(
ctx context.Context,
apiVersion int,
@@ -1028,9 +1281,9 @@ func (a *ApiHandler) produceBeaconBody(
targetSlot uint64,
randaoReveal common.Bytes96,
graffiti common.Hash,
-) (*cltypes.BeaconBody, uint64, error) {
+) (*cltypes.BeaconBody, *big.Int, error) {
if targetSlot <= baseBlockSlot {
- return nil, 0, fmt.Errorf(
+ return nil, nil, fmt.Errorf(
"target slot %d must be greater than base block slot %d",
targetSlot,
baseBlockSlot,
@@ -1055,47 +1308,56 @@ func (a *ApiHandler) produceBeaconBody(
// we use the cached payload_expected_withdrawals from state.
var gloasWithdrawalsState *state.CachingBeaconState // nil means use baseState for withdrawals
if stateVersion >= clparams.GloasVersion {
+ headNode, err := a.forkchoiceStore.GetHeadNode()
+ if err != nil {
+ return nil, nil, fmt.Errorf("produceBeaconBody: failed to snapshot fork choice head: %w", err)
+ }
+ if err := validateGloasHeadSnapshot(baseBlockRoot, headNode); err != nil {
+ return nil, nil, err
+ }
parentBid := baseState.GetLatestExecutionPayloadBid()
if parentBid != nil {
- // Fork boundary: the initial bid created by UpgradeToGloas has
- // ParentBlockHash == Hash32() (zero) because pre-GLOAS blocks have no
- // parent bid. Pre-GLOAS blocks always had their payloads executed, so
- // the EL head is parentBid.BlockHash (the last pre-GLOAS block hash).
- isPreGloasParent := parentBid.ParentBlockHash == (common.Hash{}) && parentBid.Slot == 0
+ isPreGloasParent := baseBlockSlot/a.beaconChainCfg.SlotsPerEpoch < a.beaconChainCfg.GloasForkEpoch
+ buildOnFull := !isPreGloasParent &&
+ headNode.PayloadStatus == cltypes.PayloadStatusFull &&
+ a.forkchoiceStore.HasEnvelope(baseBlockRoot) &&
+ a.forkchoiceStore.ShouldBuildOnFull(headNode, targetSlot)
+ head = gloasProposalExecutionHead(baseBlockSlot, a.beaconChainCfg, parentBid, buildOnFull)
switch {
case isPreGloasParent:
- head = parentBid.BlockHash
- case a.forkchoiceStore.GetHeadPayloadStatus() == cltypes.PayloadStatusFull &&
- a.forkchoiceStore.HasEnvelope(baseBlockRoot) &&
- a.forkchoiceStore.ShouldBuildOnFull(forkchoice.ForkChoiceNode{Root: baseBlockRoot, PayloadStatus: cltypes.PayloadStatusFull}):
- head = parentBid.BlockHash
+ case buildOnFull:
// Copy state and apply parent execution payload to compute correct withdrawals
stateCopy, err := baseState.Copy()
if err != nil {
- return nil, 0, fmt.Errorf("produceBeaconBody: failed to copy state for FULL payload: %w", err)
+ return nil, nil, fmt.Errorf("produceBeaconBody: failed to copy state for FULL payload: %w", err)
}
envelope, err := a.forkchoiceStore.ReadEnvelopeFromDisk(baseBlockRoot)
if err != nil {
- return nil, 0, fmt.Errorf("produceBeaconBody: failed to read envelope for FULL payload: %w", err)
+ return nil, nil, fmt.Errorf("produceBeaconBody: failed to read envelope for FULL payload: %w", err)
}
if envelope == nil || envelope.Message == nil || envelope.Message.ExecutionRequests == nil {
- return nil, 0, fmt.Errorf("produceBeaconBody: head is FULL but envelope/requests missing for root %x", baseBlockRoot)
+ return nil, nil, fmt.Errorf("produceBeaconBody: head is FULL but envelope/requests missing for root %x", baseBlockRoot)
}
stfMachine := ð2.Impl{}
if err := stfMachine.ApplyParentExecutionPayload(stateCopy, envelope.Message.ExecutionRequests); err != nil {
- return nil, 0, fmt.Errorf("produceBeaconBody: failed to apply parent execution payload: %w", err)
+ return nil, nil, fmt.Errorf("produceBeaconBody: failed to apply parent execution payload: %w", err)
}
gloasWithdrawalsState = stateCopy
// Populate the block body's ParentExecutionRequests so
// ProcessParentExecutionPayload can verify the root match
// against the parent bid's ExecutionRequestsRoot.
beaconBody.ParentExecutionRequests = envelope.Message.ExecutionRequests
- default:
- head = parentBid.ParentBlockHash
}
} else {
head = baseState.GetLatestBlockHash()
}
+ pendingBid := beaconBody.SignedExecutionPayloadBid
+ if pendingBid == nil || pendingBid.Message == nil {
+ return nil, nil, errors.New("produceBeaconBody: missing Gloas execution payload bid")
+ }
+ pendingBid.Message.Slot = targetSlot
+ pendingBid.Message.ParentBlockRoot = baseBlockRoot
+ pendingBid.Message.ParentBlockHash = head
}
finalizedHash := a.forkchoiceStore.GetFinalizedExecutionHash(baseState.FinalizedCheckpoint().Root)
if finalizedHash == (common.Hash{}) {
@@ -1107,27 +1369,17 @@ func (a *ApiHandler) produceBeaconBody(
}
proposerIndex, err := baseState.GetBeaconProposerIndexForSlot(targetSlot)
if err != nil {
- return nil, 0, err
+ return nil, nil, err
}
var targetGasLimit *hexutil.Uint64
if stateVersion.AfterOrEqual(clparams.GloasVersion) {
if parentBid := baseState.GetLatestExecutionPayloadBid(); parentBid != nil {
- tgl := hexutil.Uint64(parentBid.GasLimit)
+ tgl := hexutil.Uint64(a.proposalTargetGasLimit(baseState, targetSlot, proposerIndex, parentBid.GasLimit))
targetGasLimit = &tgl
}
- if a.epbsPool != nil {
- proposalEpoch := state.GetEpochAtSlot(a.beaconChainCfg, targetSlot)
- dependentRoot, err := state.GetProposerDependentRoot(baseState, proposalEpoch)
- if err != nil {
- log.Trace("Skipping proposer preferences target gas limit", "slot", targetSlot, "err", err)
- } else if pref, ok := a.epbsPool.GetPreference(targetSlot, dependentRoot); ok && pref.Message != nil && pref.Message.ValidatorIndex == proposerIndex {
- tgl := hexutil.Uint64(pref.Message.TargetGasLimit)
- targetGasLimit = &tgl
- }
- }
}
var executionPayload *cltypes.Eth1Block
- var executionValue uint64
+ executionValue := new(big.Int)
// One collector per concurrent body step. Sharing one would be a write-write race whenever
// two steps fail together.
var executionErr, syncAggregateErr error
@@ -1188,9 +1440,9 @@ func (a *ApiHandler) produceBeaconBody(
}
// Determine block value
if blockValue == nil {
- executionValue = 0
+ executionValue.SetUint64(0)
} else {
- executionValue = blockValue.Uint64()
+ executionValue.Set(blockValue)
}
if stateVersion.Before(clparams.FuluVersion) {
@@ -1374,14 +1626,17 @@ func (a *ApiHandler) produceBeaconBody(
})
}
wg.Wait()
- if executionErr != nil {
- return nil, 0, executionErr
- }
if syncAggregateErr != nil {
- return nil, 0, syncAggregateErr
+ return nil, nil, syncAggregateErr
+ }
+ if executionErr != nil {
+ if stateVersion.AfterOrEqual(clparams.GloasVersion) {
+ return beaconBody, nil, executionErr
+ }
+ return nil, nil, executionErr
}
if executionPayload == nil {
- return nil, 0, errors.New("failed to produce execution payload")
+ return nil, nil, errors.New("failed to produce execution payload")
}
if stateVersion.AfterOrEqual(clparams.GloasVersion) {
@@ -1401,11 +1656,7 @@ func (a *ApiHandler) produceBeaconBody(
// BlobKzgCommitments are already populated during bundle processing above
beaconBody.SignedExecutionPayloadBid.Signature = common.Bytes96(bls.InfiniteSignature)
- // Cache the execution payload and requests so broadcastBlock can construct
- // the SignedExecutionPayloadEnvelope when the validator publishes the signed
- // block. The envelope needs the beacon block root (only available after the
- // block is fully assembled), so we defer envelope construction to broadcast time.
- // [New in Gloas:EIP7732]
+ // Retain the payload until the validator publishes its signed envelope.
cachedExecReqs := gloasExecRequests
if cachedExecReqs == nil {
cachedExecReqs = cltypes.NewExecutionRequestsWithVersion(a.beaconChainCfg, clparams.GloasVersion)
@@ -1422,6 +1673,20 @@ func (a *ApiHandler) produceBeaconBody(
return beaconBody, executionValue, nil
}
+func validateGloasHeadSnapshot(baseBlockRoot common.Hash, headNode forkchoice.ForkChoiceNode) error {
+ if headNode.Root != baseBlockRoot {
+ return fmt.Errorf("produceBeaconBody: fork choice head changed from %x to %x", baseBlockRoot, headNode.Root)
+ }
+ return nil
+}
+
+func gloasProposalExecutionHead(baseBlockSlot uint64, cfg *clparams.BeaconChainConfig, parentBid *cltypes.ExecutionPayloadBid, buildOnFull bool) common.Hash {
+ if baseBlockSlot/cfg.SlotsPerEpoch < cfg.GloasForkEpoch || buildOnFull {
+ return parentBid.BlockHash
+ }
+ return parentBid.ParentBlockHash
+}
+
func (a *ApiHandler) getBlockOperations(s *state.CachingBeaconState, targetSlot uint64) (
*solid.ListSSZ[*cltypes.AttesterSlashing],
*solid.ListSSZ[*cltypes.ProposerSlashing],
@@ -1538,9 +1803,13 @@ func (a *ApiHandler) setupHeaderReponseForBlockProduction(
consensusVersion clparams.StateVersion,
blinded bool,
executionPayloadIncluded bool,
- executionBlockValue, consensusBlockValue uint64,
+ executionBlockValue *big.Int,
+ consensusBlockValue uint64,
) {
- w.Header().Set("Eth-Execution-Payload-Value", strconv.FormatUint(executionBlockValue, 10))
+ if executionBlockValue == nil {
+ executionBlockValue = new(big.Int)
+ }
+ w.Header().Set("Eth-Execution-Payload-Value", executionBlockValue.String())
w.Header().Set("Eth-Consensus-Block-Value", strconv.FormatUint(consensusBlockValue, 10))
w.Header().Set("Eth-Consensus-Version", clparams.ClVersionToString(consensusVersion))
w.Header().Set("Eth-Execution-Payload-Blinded", strconv.FormatBool(blinded))
@@ -1571,20 +1840,59 @@ func (a *ApiHandler) postBeaconBlocks(w http.ResponseWriter, r *http.Request, ap
if err != nil {
return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err)
}
- validation := a.parseBlockPublishingValidation(w, r, apiVersion)
+ validation, err := a.parseBlockPublishingValidation(r, apiVersion)
+ if err != nil {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err)
+ }
// Decode the block
block, err := a.parseRequestBeaconBlock(version, r)
if err != nil {
return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err)
}
- _ = validation
-
- if err := a.broadcastBlock(ctx, block.SignedBlock, block.SignedExecutionPayloadEnvelope); err != nil {
+ if err := a.broadcastBlock(ctx, block.SignedBlock, validation); err != nil {
+ if errors.Is(err, errPublishedBlockValidation) {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err)
+ }
return nil, beaconhttp.NewEndpointError(http.StatusInternalServerError, err)
}
+ a.forwardPublishedBlockToBuilder(r.Header.Get("Eth-Builder-Url"), block.SignedBlock)
return newBeaconResponse(nil), nil
}
+func (a *ApiHandler) forwardPublishedBlockToBuilder(builderURL string, block *cltypes.SignedBeaconBlock) {
+ if builderURL == "" || block == nil || block.Block == nil || block.Version() < clparams.GloasVersion || a.builderClient == nil || a.builderRoutes == nil {
+ return
+ }
+ root, err := block.Block.HashSSZ()
+ if err != nil {
+ return
+ }
+ trustedRoute := a.builderRoutes.Claim(root, builderURL)
+ if !trustedRoute && !a.builderRoutes.ClaimOrAdd(root, builderURL) {
+ return
+ }
+ go func() {
+ var err error
+ for range 2 {
+ if trustedRoute {
+ err = a.builderClient.SubmitSignedBeaconBlock(context.Background(), builderURL, block)
+ } else {
+ err = a.builderClient.SubmitSignedBeaconBlockPublic(context.Background(), builderURL, block)
+ }
+ if err == nil {
+ a.builderRoutes.Complete(root, builderURL, true)
+ return
+ }
+ }
+ if trustedRoute {
+ a.builderRoutes.Complete(root, builderURL, false)
+ } else {
+ a.builderRoutes.Discard(root, builderURL)
+ }
+ a.logger.Warn("Failed to forward signed block to builder", "err", err)
+ }()
+}
+
func (a *ApiHandler) PostEthV1BlindedBlocks(w http.ResponseWriter, r *http.Request) (*beaconhttp.BeaconResponse, error) {
resp, err := a.publishBlindedBlocks(w, r, 1)
if err != nil {
@@ -1772,7 +2080,7 @@ func (a *ApiHandler) publishBlindedBlocks(w http.ResponseWriter, r *http.Request
}
// broadcast the block
- if err := a.broadcastBlock(r.Context(), signedBlock); err != nil {
+ if err := a.broadcastBlock(r.Context(), signedBlock, BlockPublishingValidationGossip); err != nil {
return nil, beaconhttp.NewEndpointError(http.StatusInternalServerError, err)
}
@@ -1794,17 +2102,21 @@ func (a *ApiHandler) parseEthConsensusVersion(
return clparams.StringToClVersion(str)
}
-func (a *ApiHandler) parseBlockPublishingValidation(
- w http.ResponseWriter,
- r *http.Request,
- apiVersion int,
-) BlockPublishingValidation {
+func (a *ApiHandler) parseBlockPublishingValidation(r *http.Request, apiVersion int) (BlockPublishingValidation, error) {
str := r.URL.Query().Get("broadcast_validation")
- if apiVersion == 1 || str == string(BlockPublishingValidationGossip) {
- return BlockPublishingValidationGossip
+ if apiVersion == 1 {
+ return BlockPublishingValidationGossip, nil
+ }
+ switch BlockPublishingValidation(str) {
+ case "", BlockPublishingValidationGossip:
+ return BlockPublishingValidationGossip, nil
+ case BlockPublishingValidationConsensus:
+ return BlockPublishingValidationConsensus, nil
+ case BlockPublishingValidationConsensusAndEquivocation:
+ return BlockPublishingValidationConsensusAndEquivocation, nil
+ default:
+ return "", fmt.Errorf("invalid broadcast_validation %q", str)
}
- // fall to consensus anyway. equivocation is not supported yet.
- return BlockPublishingValidationConsensus
}
func (a *ApiHandler) parseRequestBeaconBlock(
@@ -1842,8 +2154,8 @@ func (a *ApiHandler) parseRequestBeaconBlock(
return nil, errors.New("invalid content type")
}
-// parseGloasRequestBeaconBlock handles GLOAS block publishing where the VC sends
-// a SignedBeaconBlock (SSZ) or a JSON object with signed_block + optional envelope.
+const maxGloasPublishedBlockSize = 10 << 20
+
func (a *ApiHandler) parseGloasRequestBeaconBlock(
version clparams.StateVersion,
r *http.Request,
@@ -1852,44 +2164,28 @@ func (a *ApiHandler) parseGloasRequestBeaconBlock(
switch r.Header.Get("Content-Type") {
case "application/json":
- body, err := io.ReadAll(r.Body)
+ body, err := readBoundedBody(r.Body, maxGloasPublishedBlockSize)
if err != nil {
return nil, err
}
-
- // Peek at top-level JSON keys to determine the schema.
- // DenebSignedBeaconBlock uses "signed_block"; plain SignedBeaconBlock uses "message".
- // encoding/json silently ignores unknown keys, so a bare SignedBeaconBlock would
- // "successfully" unmarshal into DenebSignedBeaconBlock with all-default fields.
- // We must check for the "signed_block" key explicitly to avoid broadcasting an
- // empty block.
- var probe map[string]json.RawMessage
- if err := json.Unmarshal(body, &probe); err != nil {
- return nil, fmt.Errorf("json probe: %w", err)
- }
- if _, hasSignedBlock := probe["signed_block"]; hasSignedBlock {
- block := cltypes.NewDenebSignedBeaconBlock(a.beaconChainCfg, version)
- if block != nil {
- if err := json.Unmarshal(body, block); err == nil {
- return block, nil
- }
- }
- }
- // Fall back to plain SignedBeaconBlock (keys: "message", "signature")
- if err := json.Unmarshal(body, signedBlock); err != nil {
+ decoder := json.NewDecoder(bytes.NewReader(body))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(signedBlock); err != nil {
return nil, fmt.Errorf("json: %w", err)
}
+ if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) {
+ return nil, errors.New("request body contains trailing data")
+ }
return &cltypes.DenebSignedBeaconBlock{
SignedBlock: signedBlock,
}, nil
case "application/octet-stream":
- octect, err := io.ReadAll(r.Body)
+ octect, err := readBoundedBody(r.Body, maxGloasPublishedBlockSize)
if err != nil {
return nil, err
}
- // In GLOAS, SSZ payload is just SignedBeaconBlock (no KZGProofs/Blobs wrapper)
- if err := signedBlock.DecodeSSZ(octect, int(version)); err != nil {
+ if err := ssz2.UnmarshalSSZStrict(octect, int(version), signedBlock.Block, signedBlock.Signature[:]); err != nil {
return nil, fmt.Errorf("ssz(%w)", err)
}
return &cltypes.DenebSignedBeaconBlock{
@@ -1899,7 +2195,33 @@ func (a *ApiHandler) parseGloasRequestBeaconBlock(
return nil, errors.New("invalid content type")
}
-func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeaconBlock, signedEnvelope ...*cltypes.SignedExecutionPayloadEnvelope) error {
+func readBoundedBody(body io.Reader, limit int64) ([]byte, error) {
+ b, err := io.ReadAll(io.LimitReader(body, limit+1))
+ if err != nil {
+ return nil, err
+ }
+ if int64(len(b)) > limit {
+ return nil, fmt.Errorf("request body exceeds %d bytes", limit)
+ }
+ return b, nil
+}
+
+func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeaconBlock, validation BlockPublishingValidation) error {
+ releaseGossipReservation := false
+ if validation == BlockPublishingValidationGossip {
+ if a.blockService == nil {
+ return errors.New("block gossip validator unavailable")
+ }
+ if err := a.blockService.ValidateGossip(ctx, blk); err != nil {
+ return fmt.Errorf("%w: %w", errPublishedBlockValidation, err)
+ }
+ releaseGossipReservation = true
+ defer func() {
+ if releaseGossipReservation {
+ a.blockService.ReleaseGossipReservation(blk)
+ }
+ }()
+ }
blkSSZ, err := blk.EncodeSSZ(nil)
if err != nil {
return err
@@ -1966,29 +2288,13 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac
}
if kzgCommitments != nil && kzgCommitments.Len() > 0 {
- // Build cellsAndProofsPerBlob (common logic)
- cellsAndProofsPerBlob := make([]peerdasutils.CellsAndKZGProofs, 0, kzgCommitments.Len())
- for i := 0; i < kzgCommitments.Len(); i++ {
- commitment := kzgCommitments.Get(i)
- bundle, has := a.blobBundles.Get(common.Bytes48(*commitment))
- if !has {
- return fmt.Errorf("missing blob bundle for commitment %x", commitment)
- }
- cells, err := das.ComputeCells(bundle.Blob)
- if err != nil {
- return err
- }
-
- cellsAndProof := peerdasutils.CellsAndKZGProofs{}
- cellsAndProof.Blobs = append(cellsAndProof.Blobs, cells...)
- for j := 0; j < len(bundle.KzgProofs); j++ {
- cellsAndProof.Proofs = append(cellsAndProof.Proofs, cltypes.KZGProof(bundle.KzgProofs[j]))
- }
- cellsAndProofsPerBlob = append(cellsAndProofsPerBlob, cellsAndProof)
+ cellsAndProofsPerBlob, payloadDataPending, err := collectPublishedPayloadData(kzgCommitments, isGloas, a.blobBundles.Get)
+ if err != nil {
+ return err
}
// Create sidecars based on version
- if isGloas {
+ if isGloas && !payloadDataPending {
blockRoot, err := blk.Block.HashSSZ()
if err != nil {
return fmt.Errorf("failed to compute block root: %w", err)
@@ -1997,7 +2303,7 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac
if err != nil {
return fmt.Errorf("failed to get data column sidecars: %w", err)
}
- } else {
+ } else if !isGloas {
// Fulu needs inclusion proof
inclusionProofRaw, err := blk.Block.Body.KzgCommitmentsInclusionProof()
if err != nil {
@@ -2015,11 +2321,14 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac
}
}
- go func() {
- if err := a.storeBlockAndBlobs(context.Background(), blk, blobsSidecars, columnsSidecars); err != nil {
- log.Error("BlockPublishing: Failed to store block and blobs", "err", err)
+ store := func(ctx context.Context) error {
+ return a.storeBlockAndBlobs(ctx, blk, blobsSidecars, columnsSidecars, validation == BlockPublishingValidationConsensusAndEquivocation)
+ }
+ if validation != BlockPublishingValidationGossip {
+ if err := store(ctx); err != nil {
+ return err
}
- }()
+ }
lenBlobs := 0
if blk.Version() >= clparams.DenebVersion {
@@ -2036,14 +2345,18 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac
lenBlobs,
)
// Broadcast the block and its blobs
- if err := a.gossipManager.Publish(ctx, gossip.TopicNameBeaconBlock, blkSSZ); err != nil {
- a.logger.Error("Failed to publish block", "err", err)
+ if err := a.publishGossip(ctx, gossip.TopicNameBeaconBlock, blkSSZ); err != nil {
+ return err
+ }
+ if validation == BlockPublishingValidationGossip {
+ a.blockService.CommitGossipReservation(blk)
+ a.blockService.SchedulePublishedBlockForLaterProcessing(blk, store)
}
if blk.Version() < clparams.FuluVersion {
for idx, blob := range blobsSidecarsBytes {
- if err := a.gossipManager.Publish(ctx, gossip.TopicNameBlobSidecar(uint64(idx)), blob); err != nil {
- a.logger.Error("Failed to publish blob sidecar", "err", err)
+ if err := a.publishGossip(ctx, gossip.TopicNameBlobSidecar(uint64(idx)), blob); err != nil {
+ return err
}
}
}
@@ -2053,129 +2366,81 @@ func (a *ApiHandler) broadcastBlock(ctx context.Context, blk *cltypes.SignedBeac
columnSSZ, err := column.EncodeSSZ(nil)
if err != nil {
a.logger.Error("Failed to encode column sidecar", "err", err)
- continue
+ return err
}
subnet := das.ComputeSubnetForDataColumnSidecar(column.Index)
- if err := a.gossipManager.Publish(ctx, gossip.TopicNameDataColumnSidecar(subnet), columnSSZ); err != nil {
- a.logger.Error("Failed to publish data column sidecar", "err", err)
+ if err := a.publishGossip(ctx, gossip.TopicNameDataColumnSidecar(subnet), columnSSZ); err != nil {
+ return err
}
}
}
- // [New in Gloas:EIP7732] For self-built blocks, construct and broadcast the
- // SignedExecutionPayloadEnvelope so the block can transition from PENDING to FULL.
- // If the validator client provided a signed envelope, use it directly (real BLS signature).
- // Otherwise fall back to constructing one from the cache (legacy/fallback path).
if blk.Version() >= clparams.GloasVersion {
- var validatorSignedEnvelope *cltypes.SignedExecutionPayloadEnvelope
- if len(signedEnvelope) > 0 && signedEnvelope[0] != nil {
- validatorSignedEnvelope = signedEnvelope[0]
- }
- if err := a.broadcastSelfBuildEnvelope(ctx, blk, validatorSignedEnvelope); err != nil {
- a.logger.Error("Failed to broadcast self-build execution payload envelope", "err", err)
+ if err := a.validateSelfBuildPayloadAvailable(blk); err != nil {
+ a.logger.Error("Self-build payload unavailable for validator envelope", "err", err)
}
}
+ releaseGossipReservation = false
return nil
}
-// broadcastSelfBuildEnvelope constructs and broadcasts a SignedExecutionPayloadEnvelope
-// for a self-built GLOAS block. If the validator client provided a signed envelope
-// (via the block publish request), it is used directly with the real BLS signature.
-// Otherwise, the envelope is reconstructed from the cache as a fallback.
-//
-// The function:
-// 1. Broadcasts the envelope on the execution_payload gossip topic
-// 2. Processes the envelope through forkchoice (OnExecutionPayload) so the local
-// node transitions the block from PENDING to FULL status
-//
-// [New in Gloas:EIP7732]
-func (a *ApiHandler) broadcastSelfBuildEnvelope(ctx context.Context, blk *cltypes.SignedBeaconBlock, validatorSignedEnvelope *cltypes.SignedExecutionPayloadEnvelope) error {
- bid := blk.Block.Body.GetSignedExecutionPayloadBid()
- if bid == nil || bid.Message == nil {
- return nil // no bid in block, nothing to do
+func (a *ApiHandler) publishGossip(ctx context.Context, topic string, data []byte) error {
+ if a.gossipManager == nil {
+ return errors.New("gossip publisher unavailable")
}
- if bid.Message.BuilderIndex != clparams.BuilderIndexSelfBuild {
- return nil // not a self-build block; builder will broadcast the envelope
- }
-
- // Compute the beacon block root
- blockRoot, err := blk.Block.HashSSZ()
- if err != nil {
- return fmt.Errorf("failed to compute block root: %w", err)
+ if err := a.gossipManager.Publish(ctx, topic, data); err != nil {
+ return fmt.Errorf("publish %s: %w", topic, err)
}
+ return nil
+}
- var signedEnvelope *cltypes.SignedExecutionPayloadEnvelope
-
- if validatorSignedEnvelope != nil && validatorSignedEnvelope.Message != nil {
- // Use the validator-signed envelope directly (real BLS signature).
- signedEnvelope = validatorSignedEnvelope
- log.Debug("BlockPublishing: using validator-signed execution payload envelope",
- "slot", blk.Block.Slot, "blockRoot", blockRoot)
- } else {
- // Fallback: reconstruct from cache. This path uses InfiniteSignature and will
- // fail BLS verification on other nodes — it exists only as a backward-compat
- // safety net during the transition period.
- cached, ok := a.selfBuildPayloads.Get(bid.Message.BlockHash)
+func collectPublishedPayloadData(
+ commitments *solid.ListSSZ[*cltypes.KZGCommitment],
+ isGloas bool,
+ lookup func(common.Bytes48) (BlobBundle, bool),
+) ([]peerdasutils.CellsAndKZGProofs, bool, error) {
+ cellsAndProofs := make([]peerdasutils.CellsAndKZGProofs, 0, commitments.Len())
+ for i := 0; i < commitments.Len(); i++ {
+ commitment := commitments.Get(i)
+ if commitment == nil {
+ return nil, false, fmt.Errorf("missing commitment %d", i)
+ }
+ bundle, ok := lookup(common.Bytes48(*commitment))
if !ok {
- return fmt.Errorf("self-build payload not found in cache for block hash %v", bid.Message.BlockHash)
+ if isGloas {
+ return nil, true, nil
+ }
+ return nil, false, fmt.Errorf("missing blob bundle for commitment %x", commitment)
}
-
- log.Debug("BlockPublishing: no validator-signed envelope provided, falling back to InfiniteSignature (will fail BLS verification on peers)",
- "slot", blk.Block.Slot, "blockRoot", blockRoot, "blockHash", bid.Message.BlockHash)
-
- execReqs := cached.ExecutionRequests
- if execReqs == nil {
- execReqs = cltypes.NewExecutionRequestsWithVersion(a.beaconChainCfg, clparams.GloasVersion)
+ if bundle.Blob == nil {
+ return nil, false, fmt.Errorf("nil blob bundle for commitment %x", commitment)
}
- envelope := &cltypes.ExecutionPayloadEnvelope{
- Payload: cached.Payload,
- ExecutionRequests: execReqs,
- BuilderIndex: clparams.BuilderIndexSelfBuild,
- BeaconBlockRoot: blockRoot,
- ParentBeaconBlockRoot: blk.Block.ParentRoot,
+ cells, err := das.ComputeCells(bundle.Blob)
+ if err != nil {
+ return nil, false, err
}
- signedEnvelope = &cltypes.SignedExecutionPayloadEnvelope{
- Message: envelope,
- Signature: common.Bytes96(bls.InfiniteSignature),
+ proofs := make([]cltypes.KZGProof, len(bundle.KzgProofs))
+ for i := range proofs {
+ proofs[i] = cltypes.KZGProof(bundle.KzgProofs[i])
}
+ cellsAndProofs = append(cellsAndProofs, peerdasutils.CellsAndKZGProofs{Blobs: cells, Proofs: proofs})
}
+ return cellsAndProofs, false, nil
+}
- // Remove from cache after use (regardless of path taken)
- a.selfBuildPayloads.Remove(bid.Message.BlockHash)
-
- // Process through forkchoice so the local node marks the block as FULL.
- // Use ApplyLocalSelfBuildEnvelope instead of OnExecutionPayload: it skips BLS
- // signature verification (we produced this envelope locally and may not have the
- // VC's private key) while still validating the payload with the EL via NewPayload.
- // Note: this typically returns an error because OnBlock (running in a background
- // goroutine) has not finished yet — the forkchoice store queues the envelope in
- // pendingEnvelopes and OnBlock will pick it up. Debug-level to avoid noisy logs.
- if err := a.forkchoiceStore.ApplyLocalSelfBuildEnvelope(ctx, signedEnvelope); err != nil {
- a.logger.Debug("Self-build envelope queued for pending processing", "err", err, "blockRoot", blockRoot)
+func (a *ApiHandler) validateSelfBuildPayloadAvailable(blk *cltypes.SignedBeaconBlock) error {
+ bid := blk.Block.Body.GetSignedExecutionPayloadBid()
+ if bid == nil || bid.Message == nil {
+ return nil // no bid in block, nothing to do
+ }
+ if bid.Message.BuilderIndex != clparams.BuilderIndexSelfBuild {
+ return nil // not a self-build block; builder will broadcast the envelope
}
- // Only broadcast the envelope if it has a real BLS signature.
- // Envelopes with InfiniteSignature (fallback when the VC doesn't provide a
- // pre-signed envelope) will fail BLS verification on peers, causing them to
- // penalize and ban us. Process locally only until the VC supports envelope signing.
- if signedEnvelope.Signature == common.Bytes96(bls.InfiniteSignature) {
- log.Debug("BlockPublishing: skipping gossip of self-build envelope with InfiniteSignature (no valid BLS signature)",
- "slot", blk.Block.Slot, "blockRoot", blockRoot, "blockHash", bid.Message.BlockHash)
- } else {
- // Broadcast the envelope on the execution_payload gossip topic
- encodedSSZ, err := signedEnvelope.EncodeSSZ(nil)
- if err != nil {
- return fmt.Errorf("failed to encode self-build envelope: %w", err)
- }
- if err := a.gossipManager.Publish(ctx, gossip.TopicNameExecutionPayload, encodedSSZ); err != nil {
- a.logger.Error("Failed to publish self-build execution payload envelope", "err", err, "blockRoot", blockRoot)
- } else {
- log.Debug("BlockPublishing: broadcast self-build execution payload envelope",
- "slot", blk.Block.Slot,
- "blockRoot", blockRoot,
- "blockHash", bid.Message.BlockHash)
- }
+ _, ok := a.selfBuildPayloads.Get(bid.Message.BlockHash)
+ if !ok {
+ return fmt.Errorf("self-build payload not found in cache for block hash %v", bid.Message.BlockHash)
}
return nil
@@ -2186,6 +2451,7 @@ func (a *ApiHandler) storeBlockAndBlobs(
block *cltypes.SignedBeaconBlock,
sidecars []*cltypes.BlobSidecar,
columnSidecars []*cltypes.DataColumnSidecar,
+ rejectEquivocation bool,
) error {
finishProduction := a.payloadPreparationGate.beginProduction()
defer finishProduction()
@@ -2194,11 +2460,26 @@ func (a *ApiHandler) storeBlockAndBlobs(
if err != nil {
return err
}
- // TODO: write column sidecars if needed
+ if err := a.storeDataColumnSidecars(ctx, blockRoot, columnSidecars); err != nil {
+ return fmt.Errorf("%w: %w", errPublishedBlockDataStorage, err)
+ }
if block.Version() < clparams.FuluVersion {
if err := a.blobStoage.WriteBlobSidecars(ctx, blockRoot, sidecars); err != nil {
- return err
+ return fmt.Errorf("%w: %w", errPublishedBlockDataStorage, err)
+ }
+ }
+ currentSlot := a.ethClock.GetCurrentSlot()
+ a.forkchoiceStore.OnTick(a.ethClock.GenesisTime() + currentSlot*a.beaconChainCfg.SecondsPerSlot)
+ if _, exists := a.forkchoiceStore.GetHeader(blockRoot); !exists {
+ var blockErr error
+ if rejectEquivocation {
+ blockErr = a.forkchoiceStore.OnBlockWithEquivocationCheck(ctx, block, true, true, false)
+ } else {
+ blockErr = a.forkchoiceStore.OnBlock(ctx, block, true, true, false)
+ }
+ if blockErr != nil {
+ return fmt.Errorf("%w: %w", errPublishedBlockValidation, blockErr)
}
}
@@ -2215,23 +2496,6 @@ func (a *ApiHandler) storeBlockAndBlobs(
return err
}
- // Advance fork choice time to the current slot so OnBlock accepts the block.
- // Normally OnTick is called from the ForkChoice stage, but storeBlockAndBlobs
- // runs from the beacon API handler which may execute before the stage loop.
- currentSlot := a.ethClock.GetCurrentSlot()
- a.forkchoiceStore.OnTick(a.ethClock.GenesisTime() + currentSlot*a.beaconChainCfg.SecondsPerSlot)
-
- // Skip BLS re-verification for locally-produced blocks. The block was just
- // built by this node, so re-verifying the signature is redundant. Additionally,
- // AddChainSegment replays from the nearest checkpoint state, and the replayed
- // state can produce a different proposer shuffling than the head state used
- // during block production (especially on minimal preset with rapid epoch
- // boundaries), causing VerifyBlockSignature to fail.
- // TODO: fix the root cause in state replay so fullValidation can be re-enabled.
- log.Warn("Skipping full validation for locally-produced block", "slot", block.Block.Slot, "proposer", block.Block.ProposerIndex)
- if err := a.forkchoiceStore.OnBlock(ctx, block, true, false, false); err != nil {
- return err
- }
headRoot, headSlot, headState, err := a.selectedHeadState(blockRoot)
if err != nil {
return err
@@ -2261,6 +2525,27 @@ func (a *ApiHandler) storeBlockAndBlobs(
return nil
}
+func (a *ApiHandler) storeDataColumnSidecars(ctx context.Context, blockRoot common.Hash, sidecars []*cltypes.DataColumnSidecar) error {
+ if len(sidecars) == 0 {
+ return nil
+ }
+ if a.columnStorage == nil {
+ return errors.New("data column storage unavailable")
+ }
+ for _, sidecar := range sidecars {
+ if sidecar == nil {
+ return errors.New("nil data column sidecar")
+ }
+ if sidecar.Index > math.MaxInt64 {
+ return fmt.Errorf("data column index %d exceeds storage range", sidecar.Index)
+ }
+ if err := a.columnStorage.WriteColumnSidecars(ctx, blockRoot, int64(sidecar.Index), sidecar); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
func (a *ApiHandler) selectedHeadState(auxiliaryRoot common.Hash) (common.Hash, uint64, *state.CachingBeaconState, error) {
auxiliaryState, err := a.forkchoiceStore.GetStateAtBlockRoot(auxiliaryRoot, false)
if err != nil {
@@ -2666,10 +2951,15 @@ func computeAttestationReward(
currentEpoch := state.Epoch(s)
stateSlot := s.Slot()
beaconConfig := s.BeaconConfig()
+ var parentSlot uint64
+ if s.Version() >= clparams.GloasVersion && s.GetLatestExecutionPayloadBid() != nil {
+ parentSlot = s.GetLatestExecutionPayloadBid().Slot
+ }
participationFlagsIndicies, err := s.GetAttestationParticipationFlagIndicies(
data,
stateSlot-data.Slot,
+ parentSlot,
false,
)
if err != nil {
diff --git a/cl/beacon/handler/block_production_test.go b/cl/beacon/handler/block_production_test.go
index b5b0401f4b7..0f211b8fc07 100644
--- a/cl/beacon/handler/block_production_test.go
+++ b/cl/beacon/handler/block_production_test.go
@@ -22,6 +22,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "math"
"math/big"
"net/http"
"net/http/httptest"
@@ -31,6 +32,7 @@ import (
"time"
"github.com/holiman/uint256"
+ libp2ppeer "github.com/libp2p/go-libp2p/core/peer"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
@@ -39,14 +41,23 @@ import (
"github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/cltypes"
"github.com/erigontech/erigon/cl/cltypes/solid"
+ "github.com/erigontech/erigon/cl/gossip"
+ blob_storage_mock "github.com/erigontech/erigon/cl/persistence/blob_storage/mock_services"
"github.com/erigontech/erigon/cl/phase1/core/state"
"github.com/erigontech/erigon/cl/phase1/core/state/lru"
"github.com/erigontech/erigon/cl/phase1/execution_client"
+ "github.com/erigontech/erigon/cl/phase1/forkchoice"
+ gossip_mock "github.com/erigontech/erigon/cl/phase1/network/gossip/mock_services"
+ clservices "github.com/erigontech/erigon/cl/phase1/network/services"
+ network_services_mock "github.com/erigontech/erigon/cl/phase1/network/services/mock_services"
+ serviceinterface "github.com/erigontech/erigon/cl/phase1/network/services/service_interface"
+ "github.com/erigontech/erigon/cl/pool"
sync_pool_mock "github.com/erigontech/erigon/cl/validator/sync_contribution_pool/mock_services"
"github.com/erigontech/erigon/cl/validator/validator_params"
"github.com/erigontech/erigon/common"
"github.com/erigontech/erigon/common/hexutil"
"github.com/erigontech/erigon/common/log/v3"
+ "github.com/erigontech/erigon/db/kv"
"github.com/erigontech/erigon/execution/chain"
"github.com/erigontech/erigon/execution/engineapi/engine_helpers"
"github.com/erigontech/erigon/execution/engineapi/engine_types"
@@ -59,6 +70,174 @@ import (
"github.com/erigontech/erigon/node/gointerfaces/typesproto"
)
+var _ serviceinterface.Service[*cltypes.SignedExecutionPayloadBid] = acceptingExecutionPayloadBidService{}
+
+type publishingBlockKey struct {
+ proposer uint64
+ slot uint64
+}
+
+type publishingSeenBlock struct {
+ signedRoot common.Hash
+ replayAllowed bool
+}
+
+type replayableBlockService struct {
+ *network_services_mock.MockBlockService
+ mu sync.Mutex
+ pending map[publishingBlockKey]common.Hash
+ seen map[publishingBlockKey]publishingSeenBlock
+ scheduled chan struct{}
+}
+
+func (s *replayableBlockService) ValidateGossip(_ context.Context, block *cltypes.SignedBeaconBlock) error {
+ root, err := block.HashSSZ()
+ if err != nil {
+ return err
+ }
+ key := publishingBlockKey{proposer: block.Block.ProposerIndex, slot: block.Block.Slot}
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if seen, ok := s.seen[key]; ok {
+ if seen.signedRoot == common.Hash(root) && seen.replayAllowed {
+ seen.replayAllowed = false
+ s.seen[key] = seen
+ return nil
+ }
+ return fmt.Errorf("%w: block already seen for proposer and slot", clservices.ErrIgnore)
+ }
+ if _, ok := s.pending[key]; ok {
+ return fmt.Errorf("%w: block reservation pending for proposer and slot", clservices.ErrIgnore)
+ }
+ s.pending[key] = common.Hash(root)
+ return nil
+}
+
+func (s *replayableBlockService) CommitGossipReservation(block *cltypes.SignedBeaconBlock) {
+ key := publishingBlockKey{proposer: block.Block.ProposerIndex, slot: block.Block.Slot}
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if root, ok := s.pending[key]; ok {
+ s.seen[key] = publishingSeenBlock{signedRoot: root}
+ delete(s.pending, key)
+ }
+}
+
+func (s *replayableBlockService) ReleaseGossipReservation(block *cltypes.SignedBeaconBlock) {
+ root, err := block.HashSSZ()
+ if err != nil {
+ return
+ }
+ key := publishingBlockKey{proposer: block.Block.ProposerIndex, slot: block.Block.Slot}
+ s.mu.Lock()
+ if _, ok := s.pending[key]; ok {
+ delete(s.pending, key)
+ } else if seen, ok := s.seen[key]; ok && seen.signedRoot == common.Hash(root) {
+ seen.replayAllowed = true
+ s.seen[key] = seen
+ }
+ s.mu.Unlock()
+}
+
+func (s *replayableBlockService) ScheduleBlockForLaterProcessing(*cltypes.SignedBeaconBlock) {
+ s.scheduled <- struct{}{}
+}
+
+func (s *replayableBlockService) SchedulePublishedBlockForLaterProcessing(*cltypes.SignedBeaconBlock, func(context.Context) error) {
+ s.scheduled <- struct{}{}
+}
+
+type failFirstSidecarGossip struct {
+ *gossip_mock.MockGossip
+ mu sync.Mutex
+ blockPublishes int
+ failBlockAt int
+ sidecarAttempts int
+}
+
+type unavailableUpdateDB struct {
+ kv.RwDB
+}
+
+func (db unavailableUpdateDB) Update(context.Context, func(kv.RwTx) error) error {
+ return errors.New("database unavailable")
+}
+
+type installingForkchoice struct {
+ forkchoice.ForkChoiceStorage
+ headers map[common.Hash]*cltypes.BeaconBlockHeader
+ onBlockCalls int
+}
+
+func (f *installingForkchoice) GetHeader(root common.Hash) (*cltypes.BeaconBlockHeader, bool) {
+ header, ok := f.headers[root]
+ return header, ok
+}
+
+func (f *installingForkchoice) OnBlock(_ context.Context, block *cltypes.SignedBeaconBlock, _, _, _ bool) error {
+ root, err := block.Block.HashSSZ()
+ if err != nil {
+ return err
+ }
+ f.headers[root] = block.SignedBeaconBlockHeader().Header.Copy()
+ f.onBlockCalls++
+ return nil
+}
+
+func (g *failFirstSidecarGossip) Publish(_ context.Context, topic string, _ []byte) error {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ if topic == gossip.TopicNameBeaconBlock {
+ g.blockPublishes++
+ if g.blockPublishes == g.failBlockAt {
+ return errors.New("block unavailable")
+ }
+ return nil
+ }
+ g.sidecarAttempts++
+ if g.sidecarAttempts == 1 {
+ return errors.New("sidecar unavailable")
+ }
+ return nil
+}
+
+type acceptingExecutionPayloadBidService struct{}
+
+func (acceptingExecutionPayloadBidService) Names() []string { return nil }
+
+func (acceptingExecutionPayloadBidService) DecodeGossipMessage(libp2ppeer.ID, []byte, clparams.StateVersion) (*cltypes.SignedExecutionPayloadBid, error) {
+ return nil, nil
+}
+
+func (acceptingExecutionPayloadBidService) ProcessMessage(context.Context, *uint64, *cltypes.SignedExecutionPayloadBid) error {
+ return nil
+}
+
+func (acceptingExecutionPayloadBidService) ValidateBid(context.Context, *cltypes.SignedExecutionPayloadBid) error {
+ return nil
+}
+
+func TestStoreDataColumnSidecars(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ storage := blob_storage_mock.NewMockDataColumnStorage(ctrl)
+ handler := &ApiHandler{columnStorage: storage}
+ root := common.Hash{1}
+ column := &cltypes.DataColumnSidecar{Index: 7}
+
+ storage.EXPECT().WriteColumnSidecars(gomock.Any(), root, int64(7), column).Return(nil)
+ require.NoError(t, handler.storeDataColumnSidecars(context.Background(), root, []*cltypes.DataColumnSidecar{column}))
+}
+
+func TestStoreDataColumnSidecarsRejectsInvalidInput(t *testing.T) {
+ root := common.Hash{1}
+ require.NoError(t, (&ApiHandler{}).storeDataColumnSidecars(context.Background(), root, nil))
+ require.Error(t, (&ApiHandler{}).storeDataColumnSidecars(context.Background(), root, []*cltypes.DataColumnSidecar{{}}))
+ require.Error(t, (&ApiHandler{}).storeDataColumnSidecars(context.Background(), root, []*cltypes.DataColumnSidecar{nil}))
+ require.Error(t, (&ApiHandler{columnStorage: blob_storage_mock.NewMockDataColumnStorage(gomock.NewController(t))}).storeDataColumnSidecars(
+ context.Background(), root, []*cltypes.DataColumnSidecar{{Index: math.MaxUint64}},
+ ))
+}
+
func TestBlockBuilderWindowPreGloas(t *testing.T) {
cfg := &clparams.BeaconChainConfig{
SecondsPerSlot: 12,
@@ -89,6 +268,425 @@ func TestBlockBuilderWindowGloas(t *testing.T) {
require.Equal(t, slotStart.Add(2250*time.Millisecond), window.pollUntil)
}
+func TestGloasProposalExecutionHeadAtForkBoundary(t *testing.T) {
+ cfg := clparams.MainnetBeaconConfig
+ cfg.SlotsPerEpoch = 32
+ cfg.GloasForkEpoch = 3
+ parentBid := &cltypes.ExecutionPayloadBid{
+ ParentBlockHash: common.HexToHash("0xaaaa"),
+ BlockHash: common.HexToHash("0xbbbb"),
+ }
+
+ require.Equal(t, parentBid.BlockHash, gloasProposalExecutionHead(95, &cfg, parentBid, false))
+ require.Equal(t, parentBid.BlockHash, gloasProposalExecutionHead(90, &cfg, parentBid, false))
+ require.Equal(t, parentBid.ParentBlockHash, gloasProposalExecutionHead(96, &cfg, parentBid, false))
+}
+
+func TestValidateGloasHeadSnapshotRejectsMismatchedRoot(t *testing.T) {
+ baseRoot := common.HexToHash("0xa1")
+ headNode := forkchoice.ForkChoiceNode{
+ Root: common.HexToHash("0xb2"),
+ PayloadStatus: cltypes.PayloadStatusFull,
+ }
+
+ err := validateGloasHeadSnapshot(baseRoot, headNode)
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "fork choice head changed")
+ require.NoError(t, validateGloasHeadSnapshot(baseRoot, forkchoice.ForkChoiceNode{
+ Root: baseRoot,
+ PayloadStatus: cltypes.PayloadStatusEmpty,
+ }))
+}
+
+func TestSelectGloasBidUsesGweiAndBoostSemantics(t *testing.T) {
+ localWei := big.NewInt(1_500_000_000)
+ bid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 1, 1)}
+ bid.Message.ExecutionPayment = 1
+
+ selected := selectGloasBid(localWei, []gloasBidCandidate{{
+ bid: bid,
+ boostFactor: 100,
+ maxExecutionPayment: 1,
+ }})
+
+ require.NotNil(t, selected)
+ require.Same(t, bid, selected.bid)
+ require.Equal(t, "2000000000", selected.executionValueWei.String())
+}
+
+func TestSelectGloasBidLocalWinsTieAndZeroBoost(t *testing.T) {
+ bid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 1, 2)}
+
+ require.Nil(t, selectGloasBid(big.NewInt(2_000_000_000), []gloasBidCandidate{{
+ bid: bid,
+ boostFactor: 100,
+ maxExecutionPayment: 0,
+ }}))
+ require.Nil(t, selectGloasBid(big.NewInt(1), []gloasBidCandidate{{
+ bid: bid,
+ boostFactor: 0,
+ maxExecutionPayment: 0,
+ }}))
+}
+
+func TestSelectGloasBidUsesValidBuilderWhenLocalBuildIsUnavailable(t *testing.T) {
+ bid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 1, 0)}
+ selected := selectGloasBid(nil, []gloasBidCandidate{{
+ bid: bid, boostFactor: 100, maxExecutionPayment: math.MaxUint64,
+ }})
+
+ require.NotNil(t, selected)
+ require.Same(t, bid, selected.bid)
+}
+
+func TestProduceBlockUsesConfiguredBuilderWhenLocalExecutionIsUnavailable(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ _, _, _, _, postState, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), false)
+ handler.beaconChainCfg.FuluForkEpoch = 0
+ handler.beaconChainCfg.GloasForkEpoch = 0
+ handler.beaconChainCfg.InitializeForkSchedule()
+ require.NoError(t, postState.UpgradeToFulu())
+ require.NoError(t, postState.UpgradeToGloas())
+ postState.GetBuilders().Append(&cltypes.Builder{Pubkey: common.Bytes48{0x42}})
+
+ baseRoot := common.Hash{0x41}
+ targetSlot := postState.Slot() + 1
+ forkchoiceStore.HeadVal = baseRoot
+ forkchoiceStore.HeadPayloadStatusVal = cltypes.PayloadStatusEmpty
+ engine := execution_client.NewMockExecutionEngine(ctrl)
+ engine.EXPECT().ForkChoiceUpdate(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
+ Return(nil, errors.New("local execution unavailable"))
+ handler.engine = engine
+
+ parentBid := postState.GetLatestExecutionPayloadBid()
+ forkchoiceStore.ExecutionPayloadGasLimitMap[parentBid.ParentBlockHash] = parentBid.GasLimit
+ externalBid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(targetSlot, 0, 0)}
+ externalBid.Message.ParentBlockHash = parentBid.ParentBlockHash
+ externalBid.Message.ParentBlockRoot = baseRoot
+ externalBid.Message.FeeRecipient = common.Address{}
+ externalBid.Message.GasLimit = parentBid.GasLimit
+ builderClient := builder_mock.NewMockBuilderClient(ctrl)
+ builderClient.EXPECT().RequestExecutionPayloadBid(
+ gomock.Any(), "https://builder.example", targetSlot, parentBid.ParentBlockHash, baseRoot, gomock.Any(), gomock.Any(), gomock.Any(),
+ ).Return(externalBid, nil)
+ handler.builderClient = builderClient
+ handler.executionPayloadBidService = acceptingExecutionPayloadBidService{}
+ options := &gloasBlockProductionOptions{builderConfig: &cltypes.BuilderConfig{Builders: []*cltypes.BuilderEntry{{
+ URL: "https://builder.example",
+ Auth: &cltypes.SignedBuilderRequestAuth{Message: &cltypes.BuilderRequestAuth{
+ Data: []byte("https://builder.example"), Slot: targetSlot,
+ }},
+ BuilderBoostFactor: 100,
+ }}}}
+ ctx := context.WithValue(t.Context(), gloasBlockProductionOptionsKey{}, options)
+
+ block, err := handler.produceBlock(ctx, 100, postState.Slot(), baseRoot, postState, targetSlot, common.Bytes96{}, common.Hash{})
+ require.NoError(t, err)
+ require.Same(t, externalBid, block.BeaconBody.SignedExecutionPayloadBid)
+}
+
+func TestRequestConfiguredBuilderBidsAppliesLocalProposalPolicy(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ feeRecipient common.Address
+ parentGas uint64
+ gasLimit uint64
+ execPayment uint64
+ maxPayment uint64
+ preferenceGas uint64
+ want int
+ }{
+ {name: "no P2P preference clamps toward latest bid target", feeRecipient: common.Address{0x42}, parentGas: 30_000_000, gasLimit: 30_029_295, want: 1},
+ {name: "wrong fee recipient", feeRecipient: common.Address{0x43}, parentGas: 30_000_000, gasLimit: 30_000_000},
+ {name: "high target clamps to parent maximum", feeRecipient: common.Address{0x42}, parentGas: 30_000_000, gasLimit: 30_029_295, preferenceGas: 40_000_000, want: 1},
+ {name: "high target rejects unclamped target", feeRecipient: common.Address{0x42}, parentGas: 30_000_000, gasLimit: 40_000_000, preferenceGas: 40_000_000},
+ {name: "low target clamps to parent minimum", feeRecipient: common.Address{0x42}, parentGas: 30_000_000, gasLimit: 29_970_705, preferenceGas: 20_000_000, want: 1},
+ {name: "low target rejects value below clamp", feeRecipient: common.Address{0x42}, parentGas: 30_000_000, gasLimit: 20_000_000, preferenceGas: 20_000_000},
+ {name: "zero parent requires zero", feeRecipient: common.Address{0x42}, parentGas: 0, gasLimit: 0, preferenceGas: 1, want: 1},
+ {name: "one parent requires one", feeRecipient: common.Address{0x42}, parentGas: 1, gasLimit: 1, preferenceGas: math.MaxUint64, want: 1},
+ {name: "execution payment at cap", feeRecipient: common.Address{0x42}, parentGas: 30_000_000, gasLimit: 30_000_000, preferenceGas: 30_000_000, execPayment: 5, maxPayment: 5, want: 1},
+ {name: "execution payment above cap remains a candidate", feeRecipient: common.Address{0x42}, parentGas: 30_000_000, gasLimit: 30_000_000, preferenceGas: 30_000_000, execPayment: 6, maxPayment: 5, want: 1},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ _, _, _, _, postState, handler, _, _, forkchoiceStore, validatorParams := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), false)
+ handler.beaconChainCfg.FuluForkEpoch = 0
+ handler.beaconChainCfg.GloasForkEpoch = 0
+ handler.beaconChainCfg.InitializeForkSchedule()
+ require.NoError(t, postState.UpgradeToFulu())
+ require.NoError(t, postState.UpgradeToGloas())
+ postState.GetBuilders().Append(&cltypes.Builder{Pubkey: common.Bytes48{0x42}})
+ targetSlot := postState.Slot() + 1
+ proposerIndex, err := postState.GetBeaconProposerIndexForSlot(targetSlot)
+ require.NoError(t, err)
+ validatorParams.SetFeeRecipient(proposerIndex, common.Address{0x42})
+ if tc.preferenceGas != 0 {
+ handler.epbsPool = pool.NewEpbsPool()
+ proposalEpoch := state.GetEpochAtSlot(handler.beaconChainCfg, targetSlot)
+ dependentRoot, err := state.GetProposerDependentRoot(postState, proposalEpoch)
+ require.NoError(t, err)
+ handler.epbsPool.ProposerPreferences.Add(
+ pool.ProposerPreferencesKey{Slot: targetSlot, DependentRoot: dependentRoot},
+ &cltypes.SignedProposerPreferences{Message: &cltypes.ProposerPreferences{
+ ProposalSlot: targetSlot, ValidatorIndex: proposerIndex, FeeRecipient: common.Address{0x42},
+ TargetGasLimit: tc.preferenceGas, DependentRoot: dependentRoot,
+ }},
+ )
+ }
+ latestBid := postState.GetLatestExecutionPayloadBid()
+ latestBid.GasLimit = 99_000_000
+ parentBid := latestBid.Clone().(*cltypes.ExecutionPayloadBid)
+ parentBid.ParentBlockHash = common.Hash{0xa1}
+ forkchoiceStore.ExecutionPayloadGasLimitMap[parentBid.ParentBlockHash] = tc.parentGas
+ bid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(targetSlot, 0, 1)}
+ bid.Message.ParentBlockHash = parentBid.ParentBlockHash
+ bid.Message.ParentBlockRoot = parentBid.ParentBlockRoot
+ bid.Message.FeeRecipient = tc.feeRecipient
+ bid.Message.GasLimit = tc.gasLimit
+ bid.Message.ExecutionPayment = tc.execPayment
+ builderClient := builder_mock.NewMockBuilderClient(ctrl)
+ builderClient.EXPECT().RequestExecutionPayloadBid(
+ gomock.Any(), "https://builder.example", targetSlot, parentBid.ParentBlockHash, parentBid.ParentBlockRoot,
+ gomock.Any(), gomock.Any(), gomock.Any(),
+ ).Return(bid, nil)
+ handler.builderClient = builderClient
+ handler.executionPayloadBidService = acceptingExecutionPayloadBidService{}
+
+ candidates := handler.requestConfiguredBuilderBids(t.Context(), postState, targetSlot, common.Bytes48{}, parentBid, []*cltypes.BuilderEntry{{
+ URL: "https://builder.example", BuilderBoostFactor: 100, MaxExecutionPayment: tc.maxPayment,
+ }})
+
+ require.Len(t, candidates, tc.want)
+ })
+ }
+}
+
+func TestSelectGloasBidCapsExecutionPaymentAndAvoidsOverflow(t *testing.T) {
+ capped := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 1, 1)}
+ capped.Message.ExecutionPayment = 100
+ overflow := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 2, ^uint64(0))}
+ overflow.Message.ExecutionPayment = ^uint64(0)
+
+ selected := selectGloasBid(big.NewInt(2_500_000_000), []gloasBidCandidate{
+ {bid: capped, boostFactor: 100, maxExecutionPayment: 1},
+ {bid: overflow, boostFactor: 1, maxExecutionPayment: ^uint64(0)},
+ })
+
+ require.NotNil(t, selected)
+ require.Same(t, overflow, selected.bid)
+ require.Equal(t, new(big.Int).Mul(
+ new(big.Int).Add(new(big.Int).SetUint64(math.MaxUint64), new(big.Int).SetUint64(math.MaxUint64)),
+ big.NewInt(1_000_000_000),
+ ), selected.executionValueWei)
+}
+
+func TestSelectGloasBidSaturatesOverflowTotalsAndKeepsFirstTie(t *testing.T) {
+ a := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 1, math.MaxUint64-1)}
+ a.Message.ExecutionPayment = 2
+ b := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 2, math.MaxUint64)}
+ b.Message.ExecutionPayment = 2
+
+ for _, bids := range [][]*cltypes.SignedExecutionPayloadBid{{a, b}, {b, a}} {
+ selected := selectGloasBid(nil, []gloasBidCandidate{
+ {bid: bids[0], boostFactor: 100, maxExecutionPayment: 2},
+ {bid: bids[1], boostFactor: 100, maxExecutionPayment: 2},
+ })
+
+ require.NotNil(t, selected)
+ require.Same(t, bids[0], selected.bid)
+ expectedValueWei := new(big.Int).Mul(
+ new(big.Int).Add(
+ new(big.Int).SetUint64(bids[0].Message.Value),
+ new(big.Int).SetUint64(bids[0].Message.ExecutionPayment),
+ ),
+ big.NewInt(1_000_000_000),
+ )
+ require.Equal(t, expectedValueWei, selected.executionValueWei)
+ }
+}
+
+func TestSelectGloasBidLocalWinsOneWeiAboveSaturatedExternal(t *testing.T) {
+ bid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 1, math.MaxUint64)}
+ bid.Message.ExecutionPayment = 1
+ localValueWei := new(big.Int).Add(
+ new(big.Int).Mul(new(big.Int).SetUint64(math.MaxUint64), big.NewInt(1_000_000_000)),
+ big.NewInt(1),
+ )
+
+ require.Nil(t, selectGloasBid(localValueWei, []gloasBidCandidate{{
+ bid: bid, boostFactor: 100, maxExecutionPayment: 1,
+ }}))
+}
+
+func TestSelectGloasBidAppliesMinimumBidToCappedValue(t *testing.T) {
+ bid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(10, 1, 5)}
+ bid.Message.ExecutionPayment = 10
+ require.Nil(t, selectGloasBid(new(big.Int), []gloasBidCandidate{{
+ bid: bid, boostFactor: 100, maxExecutionPayment: 2, minBid: 8,
+ }}))
+ require.NotNil(t, selectGloasBid(new(big.Int), []gloasBidCandidate{{
+ bid: bid, boostFactor: 100, maxExecutionPayment: 3, minBid: 8,
+ }}))
+}
+
+func TestDecodeGloasBlockProductionOptionsJSONAndSSZ(t *testing.T) {
+ config := &cltypes.BuilderConfig{
+ MinBid: 4,
+ BuilderBoostFactor: 125,
+ Builders: []*cltypes.BuilderEntry{{
+ URL: "https://builder.example",
+ Auth: &cltypes.SignedBuilderRequestAuth{Message: &cltypes.BuilderRequestAuth{
+ Data: []byte("https://builder.example"), Slot: 10,
+ }},
+ BuilderPubkeys: []common.Bytes48{{1}},
+ MaxExecutionPayment: 5,
+ MinBid: 6,
+ BuilderBoostFactor: 150,
+ }},
+ }
+ jsonBody, err := json.Marshal(config)
+ require.NoError(t, err)
+ sszBody, err := config.EncodeSSZ(nil)
+ require.NoError(t, err)
+
+ for _, tc := range []struct {
+ name string
+ contentType string
+ body []byte
+ }{
+ {name: "json", contentType: "application/json", body: jsonBody},
+ {name: "ssz", contentType: "application/octet-stream", body: sszBody},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v4/validator/blocks/10?include_payload=true", bytes.NewReader(tc.body))
+ req.Header.Set("Content-Type", tc.contentType)
+ req.Header.Set("Eth-Consensus-Version", "gloas")
+ opts, err := decodeGloasBlockProductionOptions(httptest.NewRecorder(), req, 10)
+ require.NoError(t, err)
+ require.True(t, opts.includePayload)
+ require.Equal(t, config.MinBid, opts.builderConfig.MinBid)
+ require.Equal(t, config.Builders[0].URL, opts.builderConfig.Builders[0].URL)
+ })
+ }
+}
+
+func TestDecodeGloasBlockProductionOptionsRejectsInvalidBuildersPerEntry(t *testing.T) {
+ body := `{"min_bid":"0","builder_boost_factor":"100","builders":[null,{"url":"https://missing-auth.example"},{"url":"https://wrong-slot.example","auth":{"message":{"data":"0x01","slot":"11"},"signature":"0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"},"builder_pubkeys":[],"max_execution_payment":"0","min_bid":"0","builder_boost_factor":"0"},{"url":"https://builder.example","auth":{"message":{"data":"0x01","slot":"10"},"signature":"0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"},"builder_pubkeys":[],"max_execution_payment":"0","min_bid":"0","builder_boost_factor":"0"}]}`
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v4/validator/blocks/10?include_payload=true", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Eth-Consensus-Version", "gloas")
+
+ options, err := decodeGloasBlockProductionOptions(httptest.NewRecorder(), req, 10)
+ require.NoError(t, err)
+ require.Len(t, options.builderConfig.Builders, 1)
+ require.Equal(t, "https://builder.example", options.builderConfig.Builders[0].URL)
+}
+
+func TestDecodeGloasBlockProductionOptionsIsolatesDuplicateEntriesJSON(t *testing.T) {
+ entry := &cltypes.BuilderEntry{
+ URL: "https://builder.example",
+ Auth: &cltypes.SignedBuilderRequestAuth{Message: &cltypes.BuilderRequestAuth{
+ Data: []byte("auth-one"), Slot: 10,
+ }},
+ BuilderPubkeys: []common.Bytes48{{1}},
+ }
+ differentAuth := entry.Clone().(*cltypes.BuilderEntry)
+ differentAuth.Auth.Message.Data = []byte("auth-two")
+ first, err := json.Marshal(entry)
+ require.NoError(t, err)
+ second, err := json.Marshal(differentAuth)
+ require.NoError(t, err)
+ body := fmt.Sprintf(`{"min_bid":"0","builder_boost_factor":"100","builders":[%s,%s,%s]}`, first, first, second)
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v4/validator/blocks/10?include_payload=true", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Eth-Consensus-Version", "gloas")
+
+ options, err := decodeGloasBlockProductionOptions(httptest.NewRecorder(), req, 10)
+ require.NoError(t, err)
+ require.Len(t, options.builderConfig.Builders, 2)
+ require.Equal(t, []byte("auth-one"), []byte(options.builderConfig.Builders[0].Auth.Message.Data))
+ require.Equal(t, []byte("auth-two"), []byte(options.builderConfig.Builders[1].Auth.Message.Data))
+}
+
+func TestDecodeGloasBlockProductionOptionsIsolatesInvalidAndDuplicateEntriesSSZ(t *testing.T) {
+ makeEntry := func(url, auth string, slot uint64) *cltypes.BuilderEntry {
+ return &cltypes.BuilderEntry{
+ URL: url,
+ Auth: &cltypes.SignedBuilderRequestAuth{Message: &cltypes.BuilderRequestAuth{
+ Data: []byte(auth), Slot: slot,
+ }},
+ BuilderPubkeys: []common.Bytes48{{1}},
+ }
+ }
+ config := &cltypes.BuilderConfig{Builders: []*cltypes.BuilderEntry{
+ makeEntry("https://builder.example", "auth-one-unique", 10),
+ makeEntry("https://builder.example", "auth-two-unique", 10),
+ makeEntry("https://builder.example", "auth-three-long", 10),
+ makeEntry("https://wrong-slot.example", "auth-wrong-slot", 11),
+ }}
+ body, err := config.EncodeSSZ(nil)
+ require.NoError(t, err)
+ body = bytes.Replace(body, []byte("auth-two-unique"), []byte("auth-one-unique"), 1)
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v4/validator/blocks/10?include_payload=true", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/octet-stream")
+ req.Header.Set("Eth-Consensus-Version", "gloas")
+
+ options, err := decodeGloasBlockProductionOptions(httptest.NewRecorder(), req, 10)
+ require.NoError(t, err)
+ require.Len(t, options.builderConfig.Builders, 2)
+ require.Equal(t, []byte("auth-one-unique"), []byte(options.builderConfig.Builders[0].Auth.Message.Data))
+ require.Equal(t, []byte("auth-three-long"), []byte(options.builderConfig.Builders[1].Auth.Message.Data))
+}
+
+func TestDecodeGloasBlockProductionOptionsRejectsInvalidMetadata(t *testing.T) {
+ valid := `{"min_bid":"0","builder_boost_factor":"100","builders":[]}`
+ for _, tc := range []struct {
+ name string
+ url string
+ version string
+ body string
+ }{
+ {name: "missing include payload", url: "/eth/v4/validator/blocks/10", version: "gloas", body: valid},
+ {name: "invalid include payload", url: "/eth/v4/validator/blocks/10?include_payload=sure", version: "gloas", body: valid},
+ {name: "missing version", url: "/eth/v4/validator/blocks/10?include_payload=true", body: valid},
+ {name: "wrong version", url: "/eth/v4/validator/blocks/10?include_payload=true", version: "fulu", body: valid},
+ {name: "missing body", url: "/eth/v4/validator/blocks/10?include_payload=true", version: "gloas"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, tc.url, strings.NewReader(tc.body))
+ req.Header.Set("Content-Type", "application/json")
+ if tc.version != "" {
+ req.Header.Set("Eth-Consensus-Version", tc.version)
+ }
+ _, err := decodeGloasBlockProductionOptions(httptest.NewRecorder(), req, 10)
+ require.Error(t, err)
+ })
+ }
+}
+
+func TestDecodeGloasBlockProductionOptionsAcceptsMaximumJSONConfig(t *testing.T) {
+ config := &cltypes.BuilderConfig{Builders: make([]*cltypes.BuilderEntry, cltypes.MaxBuilderEntries)}
+ for i := range config.Builders {
+ authData := make([]byte, cltypes.MaxBuilderAuthDataSize)
+ authData[0] = byte(i)
+ config.Builders[i] = &cltypes.BuilderEntry{
+ URL: "https://example.com/" + strings.Repeat("a", 2020) + fmt.Sprintf("%02d", i),
+ Auth: &cltypes.SignedBuilderRequestAuth{Message: &cltypes.BuilderRequestAuth{Data: authData, Slot: 10}},
+ BuilderPubkeys: make([]common.Bytes48, cltypes.MaxBuilderPubkeys),
+ }
+ }
+ body, err := json.Marshal(config)
+ require.NoError(t, err)
+ require.Greater(t, len(body), 1<<20)
+ require.LessOrEqual(t, len(body), maxBuilderConfigRequestSize)
+
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v4/validator/blocks/10?include_payload=false", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Eth-Consensus-Version", "gloas")
+ _, err = decodeGloasBlockProductionOptions(httptest.NewRecorder(), req, 10)
+ require.NoError(t, err)
+}
+
func TestPublishBlindedBlocksRejectsGloas(t *testing.T) {
_, _, _, _, _, h, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true)
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blinded_blocks", bytes.NewReader(nil))
@@ -99,6 +697,551 @@ func TestPublishBlindedBlocksRejectsGloas(t *testing.T) {
require.Contains(t, err.Error(), cltypes.ErrGloasCannotBlind.Error())
}
+func TestParseGloasPublishedBlockRejectsWrapperAndOversizeBodies(t *testing.T) {
+ _, _, _, _, _, h, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true)
+ h.beaconChainCfg.GloasForkEpoch = 0
+ for _, tc := range []struct {
+ name string
+ body string
+ }{
+ {name: "wrapper", body: `{"signed_block":{}}`},
+ {name: "oversize", body: `{"message":{}}` + strings.Repeat(" ", maxGloasPublishedBlockSize)},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blocks", strings.NewReader(tc.body))
+ req.Header.Set("Content-Type", "application/json")
+ _, err := h.parseGloasRequestBeaconBlock(clparams.GloasVersion, req)
+ require.Error(t, err)
+ })
+ }
+}
+
+func TestParseGloasPublishedBlockRejectsNonCanonicalSSZ(t *testing.T) {
+ _, _, _, _, _, h, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true)
+ h.beaconChainCfg.GloasForkEpoch = 0
+ block := cltypes.NewSignedBeaconBlock(h.beaconChainCfg, clparams.GloasVersion)
+ encoded, err := block.EncodeSSZ(nil)
+ require.NoError(t, err)
+ encoded = append(encoded, 0)
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blocks", bytes.NewReader(encoded))
+ req.Header.Set("Content-Type", "application/octet-stream")
+
+ _, err = h.parseGloasRequestBeaconBlock(clparams.GloasVersion, req)
+ require.Error(t, err)
+}
+
+func TestPostEthV2BeaconBlocksForwardsGloasBlockToWinningBuilder(t *testing.T) {
+ _, _, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true)
+ forkchoiceStore.OnTickFn = func(uint64) {}
+ handler.beaconChainCfg.GloasForkEpoch = 0
+ handler.beaconChainCfg.InitializeForkSchedule()
+ block := cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, clparams.GloasVersion)
+ block.Block.Slot = 1
+ block.Block.Body.SignedExecutionPayloadBid.Message.BuilderIndex = 1
+ body, err := json.Marshal(block)
+ require.NoError(t, err)
+
+ ctrl := gomock.NewController(t)
+ builderClient := builder_mock.NewMockBuilderClient(ctrl)
+ builderURL := "https://builder.example"
+ blockRoot, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ require.True(t, handler.builderRoutes.Add(blockRoot, builderURL))
+ forwardedCh := make(chan struct{}, 2)
+ builderClient.EXPECT().SubmitSignedBeaconBlock(gomock.Any(), builderURL, gomock.Any()).DoAndReturn(
+ func(_ context.Context, _ string, forwarded *cltypes.SignedBeaconBlock) error {
+ require.Equal(t, block.Block.Slot, forwarded.Block.Slot)
+ forwardedCh <- struct{}{}
+ return errors.New("builder unavailable")
+ },
+ ).Times(2)
+ handler.builderClient = builderClient
+
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blocks", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Eth-Consensus-Version", clparams.GloasVersion.String())
+ req.Header.Set("Eth-Builder-Url", builderURL)
+
+ _, err = handler.PostEthV2BeaconBlocks(httptest.NewRecorder(), req)
+ require.NoError(t, err)
+ for range 2 {
+ select {
+ case <-forwardedCh:
+ case <-time.After(time.Second):
+ t.Fatal("signed block was not forwarded to the winning builder")
+ }
+ }
+}
+
+func TestPostEthV2BeaconBlocksForwardsUnboundBuilderRoute(t *testing.T) {
+ _, _, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true)
+ forkchoiceStore.OnTickFn = func(uint64) {}
+ handler.beaconChainCfg.GloasForkEpoch = 0
+ handler.beaconChainCfg.InitializeForkSchedule()
+ block := cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, clparams.GloasVersion)
+ block.Block.Slot = 1
+ block.Block.Body.SignedExecutionPayloadBid.Message.BuilderIndex = 1
+ body, err := json.Marshal(block)
+ require.NoError(t, err)
+ builderURL := "https://builder.example"
+ forwarded := make(chan struct{})
+ client := builder_mock.NewMockBuilderClient(gomock.NewController(t))
+ client.EXPECT().SubmitSignedBeaconBlockPublic(gomock.Any(), builderURL, gomock.Any()).DoAndReturn(
+ func(context.Context, string, *cltypes.SignedBeaconBlock) error {
+ close(forwarded)
+ return nil
+ },
+ )
+ handler.builderClient = client
+
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blocks", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Eth-Consensus-Version", clparams.GloasVersion.String())
+ req.Header.Set("Eth-Builder-Url", builderURL)
+
+ _, err = handler.PostEthV2BeaconBlocks(httptest.NewRecorder(), req)
+ require.NoError(t, err)
+ select {
+ case <-forwarded:
+ case <-time.After(time.Second):
+ t.Fatal("signed block was not forwarded by the receiving beacon node")
+ }
+}
+
+func TestForwardPublishedBlockToBuilderOnlyOnce(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true)
+ handler.beaconChainCfg.GloasForkEpoch = 0
+ handler.beaconChainCfg.InitializeForkSchedule()
+ block := cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, clparams.GloasVersion)
+ blockRoot, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ builderURL := "https://builder.example"
+ require.True(t, handler.builderRoutes.Add(blockRoot, builderURL))
+
+ ctrl := gomock.NewController(t)
+ builderClient := builder_mock.NewMockBuilderClient(ctrl)
+ forwardedCh := make(chan struct{})
+ release := make(chan struct{})
+ builderClient.EXPECT().SubmitSignedBeaconBlock(gomock.Any(), builderURL, block).DoAndReturn(
+ func(context.Context, string, *cltypes.SignedBeaconBlock) error {
+ close(forwardedCh)
+ <-release
+ return nil
+ },
+ )
+ handler.builderClient = builderClient
+
+ handler.forwardPublishedBlockToBuilder(builderURL, block)
+ select {
+ case <-forwardedCh:
+ case <-time.After(time.Second):
+ t.Fatal("signed block was not forwarded")
+ }
+ for range 32 {
+ handler.forwardPublishedBlockToBuilder(builderURL, block)
+ }
+ close(release)
+}
+
+func TestForwardPublishedBlockToBuilderDoesNotForwardPreGloasBlock(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true)
+ block := cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, clparams.ElectraVersion)
+ blockRoot, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ builderURL := "https://builder.example"
+ require.True(t, handler.builderRoutes.Add(blockRoot, builderURL))
+ handler.builderClient = builder_mock.NewMockBuilderClient(gomock.NewController(t))
+
+ handler.forwardPublishedBlockToBuilder(builderURL, block)
+
+ require.True(t, handler.builderRoutes.Claim(blockRoot, builderURL))
+}
+
+func TestForwardPublishedBlockToBuilderRetriesAfterFailure(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true)
+ handler.beaconChainCfg.GloasForkEpoch = 0
+ handler.beaconChainCfg.InitializeForkSchedule()
+ block := cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, clparams.GloasVersion)
+ blockRoot, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ builderURL := "https://builder.example"
+ require.True(t, handler.builderRoutes.Add(blockRoot, builderURL))
+
+ ctrl := gomock.NewController(t)
+ builderClient := builder_mock.NewMockBuilderClient(ctrl)
+ firstStarted := make(chan struct{})
+ releaseFirst := make(chan struct{})
+ secondDone := make(chan struct{})
+ gomock.InOrder(
+ builderClient.EXPECT().SubmitSignedBeaconBlock(gomock.Any(), builderURL, block).DoAndReturn(
+ func(context.Context, string, *cltypes.SignedBeaconBlock) error {
+ close(firstStarted)
+ <-releaseFirst
+ return errors.New("temporary failure")
+ },
+ ),
+ builderClient.EXPECT().SubmitSignedBeaconBlock(gomock.Any(), builderURL, block).DoAndReturn(
+ func(context.Context, string, *cltypes.SignedBeaconBlock) error {
+ close(secondDone)
+ return nil
+ },
+ ),
+ )
+ handler.builderClient = builderClient
+
+ handler.forwardPublishedBlockToBuilder(builderURL, block)
+ select {
+ case <-firstStarted:
+ case <-time.After(time.Second):
+ t.Fatal("first submission did not start")
+ }
+ for range 32 {
+ handler.forwardPublishedBlockToBuilder(builderURL, block)
+ }
+ close(releaseFirst)
+ select {
+ case <-secondDone:
+ case <-time.After(time.Second):
+ t.Fatal("submission was not retried automatically")
+ }
+}
+
+func TestForwardPublishedBlockUnboundRetryRemainsPublicOnly(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true)
+ handler.beaconChainCfg.GloasForkEpoch = 0
+ block := cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, clparams.GloasVersion)
+ root, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ builderURL := "https://builder.example"
+ client := builder_mock.NewMockBuilderClient(gomock.NewController(t))
+ failedAttempts := make(chan struct{})
+ succeeded := make(chan struct{})
+ gomock.InOrder(
+ client.EXPECT().SubmitSignedBeaconBlockPublic(gomock.Any(), builderURL, block).Return(errors.New("unavailable")),
+ client.EXPECT().SubmitSignedBeaconBlockPublic(gomock.Any(), builderURL, block).DoAndReturn(
+ func(context.Context, string, *cltypes.SignedBeaconBlock) error {
+ close(failedAttempts)
+ return errors.New("unavailable")
+ },
+ ),
+ client.EXPECT().SubmitSignedBeaconBlockPublic(gomock.Any(), builderURL, block).DoAndReturn(
+ func(context.Context, string, *cltypes.SignedBeaconBlock) error {
+ close(succeeded)
+ return nil
+ },
+ ),
+ )
+ handler.builderClient = client
+
+ handler.forwardPublishedBlockToBuilder(builderURL, block)
+ select {
+ case <-failedAttempts:
+ case <-time.After(time.Second):
+ t.Fatal("unbound route attempts did not fail")
+ }
+ deadline := time.Now().Add(time.Second)
+ for {
+ handler.builderRoutes.mu.Lock()
+ _, exists := handler.builderRoutes.routes[builderRouteKey{root: root, url: builderURL}]
+ handler.builderRoutes.mu.Unlock()
+ if !exists {
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatal("unbound route was not discarded after failure")
+ }
+ time.Sleep(time.Millisecond)
+ }
+ handler.forwardPublishedBlockToBuilder(builderURL, block)
+ select {
+ case <-succeeded:
+ case <-time.After(time.Second):
+ t.Fatal("unbound public retry did not succeed")
+ }
+}
+
+func TestValidateSelfBuildPayloadAvailablePreservesUnsignedPayloadForValidator(t *testing.T) {
+ _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true)
+ handler.beaconChainCfg.GloasForkEpoch = 0
+ block := cltypes.NewSignedBeaconBlock(handler.beaconChainCfg, clparams.GloasVersion)
+ payloadHash := common.HexToHash("0x1234")
+ block.Block.Body.SignedExecutionPayloadBid.Message.BuilderIndex = clparams.BuilderIndexSelfBuild
+ block.Block.Body.SignedExecutionPayloadBid.Message.BlockHash = payloadHash
+ cacheEntry := &selfBuildPayload{Payload: cltypes.NewEth1Block(clparams.GloasVersion, handler.beaconChainCfg)}
+ cacheEntry.Payload.BlockHash = payloadHash
+ handler.selfBuildPayloads.Add(payloadHash, cacheEntry)
+
+ require.NoError(t, handler.validateSelfBuildPayloadAvailable(block))
+ retained, ok := handler.selfBuildPayloads.Get(payloadHash)
+ require.True(t, ok)
+ require.Same(t, cacheEntry, retained)
+ require.Empty(t, fcu.Envelopes)
+}
+
+func TestParseBlockPublishingValidationRejectsUnknownV2Value(t *testing.T) {
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blocks?broadcast_validation=fast", nil)
+ _, err := (&ApiHandler{}).parseBlockPublishingValidation(req, 2)
+ require.Error(t, err)
+
+ for _, value := range []BlockPublishingValidation{
+ BlockPublishingValidationGossip,
+ BlockPublishingValidationConsensus,
+ BlockPublishingValidationConsensusAndEquivocation,
+ } {
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v2/beacon/blocks?broadcast_validation="+string(value), nil)
+ got, err := (&ApiHandler{}).parseBlockPublishingValidation(req, 2)
+ require.NoError(t, err)
+ require.Equal(t, value, got)
+ }
+}
+
+func TestBroadcastBlockRunsGossipValidationBeforePublishing(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ blockService := network_services_mock.NewMockBlockService(ctrl)
+ block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.Phase0Version)
+ validationErr := errors.New("invalid gossip block")
+ blockService.EXPECT().ValidateGossip(gomock.Any(), block).Return(validationErr)
+
+ err := (&ApiHandler{blockService: blockService}).broadcastBlock(t.Context(), block, BlockPublishingValidationGossip)
+ require.ErrorIs(t, err, errPublishedBlockValidation)
+ require.ErrorContains(t, err, validationErr.Error())
+}
+
+func TestBroadcastBlockReleasesGossipReservationAfterPreparationFailure(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ blockService := network_services_mock.NewMockBlockService(ctrl)
+ block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion)
+ block.Block.Body.BlobKzgCommitments.Append(&cltypes.KZGCommitment{1})
+ blobBundles, err := lru.New[common.Bytes48, BlobBundle]("test-blobs", 1)
+ require.NoError(t, err)
+ blockService.EXPECT().ValidateGossip(gomock.Any(), block).Return(nil)
+ blockService.EXPECT().ReleaseGossipReservation(block)
+
+ err = (&ApiHandler{blockService: blockService, blobBundles: blobBundles}).broadcastBlock(t.Context(), block, BlockPublishingValidationGossip)
+ require.ErrorContains(t, err, "missing blob bundle")
+}
+
+func TestBroadcastBlockDoesNotStoreBeforeBlockPublication(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ _, blocks, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), true)
+ block := blocks[1]
+ blockService := network_services_mock.NewMockBlockService(ctrl)
+ blockService.EXPECT().ValidateGossip(gomock.Any(), block).Return(nil)
+ blockService.EXPECT().ReleaseGossipReservation(block)
+ handler.blockService = blockService
+ storeCalled := make(chan struct{})
+ var closeStoreCalled sync.Once
+ blobStorage := blob_storage_mock.NewMockBlobStorage(ctrl)
+ blobStorage.EXPECT().WriteBlobSidecars(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(context.Context, common.Hash, []*cltypes.BlobSidecar) error {
+ closeStoreCalled.Do(func() { close(storeCalled) })
+ return nil
+ }).AnyTimes()
+ handler.blobStoage = blobStorage
+ gossipManager := gossip_mock.NewMockGossip(ctrl)
+ gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameBeaconBlock, gomock.Any()).Return(errors.New("block unavailable"))
+ handler.gossipManager = gossipManager
+
+ err := handler.broadcastBlock(t.Context(), block, BlockPublishingValidationGossip)
+ require.ErrorContains(t, err, "block unavailable")
+ select {
+ case <-storeCalled:
+ t.Fatal("block storage started before beacon block publication succeeded")
+ case <-time.After(50 * time.Millisecond):
+ }
+}
+
+func TestBroadcastBlockSchedulesFullRecoveryAfterBlobStorageFailure(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ _, blocks, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true)
+ block := blocks[1]
+ commitment := &cltypes.KZGCommitment{1}
+ block.Block.Body.BlobKzgCommitments = solid.NewStaticListSSZ[*cltypes.KZGCommitment](1, 48)
+ block.Block.Body.BlobKzgCommitments.Append(commitment)
+ handler.blobBundles.Add(common.Bytes48(*commitment), BlobBundle{
+ Commitment: common.Bytes48(*commitment),
+ Blob: &cltypes.Blob{},
+ KzgProofs: []common.Bytes48{{1}},
+ })
+ blockService := network_services_mock.NewMockBlockService(ctrl)
+ blockService.EXPECT().ValidateGossip(gomock.Any(), block).Return(nil)
+ blockService.EXPECT().CommitGossipReservation(block)
+ blockService.EXPECT().ReleaseGossipReservation(block)
+ blockService.EXPECT().ScheduleBlockForLaterProcessing(block).Times(0)
+ scheduledStore := make(chan func(context.Context) error, 1)
+ blockService.EXPECT().SchedulePublishedBlockForLaterProcessing(block, gomock.Any()).Do(func(_ *cltypes.SignedBeaconBlock, store func(context.Context) error) {
+ scheduledStore <- store
+ })
+ handler.blockService = blockService
+ blobStorage := blob_storage_mock.NewMockBlobStorage(ctrl)
+ blobStorage.EXPECT().WriteBlobSidecars(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(context.Context, common.Hash, []*cltypes.BlobSidecar) error {
+ return errors.New("storage unavailable")
+ }).Times(1)
+ handler.blobStoage = blobStorage
+ gossipManager := gossip_mock.NewMockGossip(ctrl)
+ gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameBeaconBlock, gomock.Any()).Return(nil)
+ gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameBlobSidecar(uint64(0)), gomock.Any()).Return(errors.New("sidecar unavailable"))
+ handler.gossipManager = gossipManager
+
+ err := handler.broadcastBlock(t.Context(), block, BlockPublishingValidationGossip)
+ require.ErrorContains(t, err, "sidecar unavailable")
+ select {
+ case store := <-scheduledStore:
+ require.ErrorContains(t, store(t.Context()), "storage unavailable")
+ case <-time.After(time.Second):
+ t.Fatal("full published-block storage was not scheduled")
+ }
+}
+
+func TestBroadcastBlockSchedulesRecoveryAfterSidecarsAndForkchoiceSucceed(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ _, blocks, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true)
+ block := blocks[1]
+ commitment := &cltypes.KZGCommitment{1}
+ block.Block.Body.BlobKzgCommitments = solid.NewStaticListSSZ[*cltypes.KZGCommitment](1, 48)
+ block.Block.Body.BlobKzgCommitments.Append(commitment)
+ handler.blobBundles.Add(common.Bytes48(*commitment), BlobBundle{
+ Commitment: common.Bytes48(*commitment),
+ Blob: &cltypes.Blob{},
+ KzgProofs: []common.Bytes48{{1}},
+ })
+ handler.indiciesDB = unavailableUpdateDB{RwDB: handler.indiciesDB}
+ fcu.OnTickFn = func(uint64) {}
+
+ blockService := network_services_mock.NewMockBlockService(ctrl)
+ blockService.EXPECT().ValidateGossip(gomock.Any(), block).Return(nil)
+ blockService.EXPECT().CommitGossipReservation(block)
+ scheduledStore := make(chan func(context.Context) error, 1)
+ blockService.EXPECT().SchedulePublishedBlockForLaterProcessing(block, gomock.Any()).Do(func(_ *cltypes.SignedBeaconBlock, store func(context.Context) error) { scheduledStore <- store })
+ handler.blockService = blockService
+ gossipManager := gossip_mock.NewMockGossip(ctrl)
+ gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameBeaconBlock, gomock.Any()).Return(nil)
+ gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameBlobSidecar(uint64(0)), gomock.Any()).Return(nil)
+ handler.gossipManager = gossipManager
+
+ require.NoError(t, handler.broadcastBlock(t.Context(), block, BlockPublishingValidationGossip))
+ select {
+ case store := <-scheduledStore:
+ require.ErrorContains(t, store(t.Context()), "database unavailable")
+ case <-time.After(time.Second):
+ t.Fatal("post-forkchoice database failure was not scheduled")
+ }
+ root, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ sidecars, found, err := handler.blobStoage.ReadBlobSidecars(t.Context(), block.Block.Slot, root)
+ require.NoError(t, err)
+ require.True(t, found)
+ require.Len(t, sidecars, 1)
+}
+
+func TestStoreBlockAndBlobsDoesNotRepeatForkchoiceAfterDatabaseFailure(t *testing.T) {
+ _, blocks, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), true)
+ block := blocks[1]
+ fcu.OnTickFn = func(uint64) {}
+ installing := &installingForkchoice{
+ ForkChoiceStorage: fcu,
+ headers: make(map[common.Hash]*cltypes.BeaconBlockHeader),
+ }
+ handler.forkchoiceStore = installing
+ handler.indiciesDB = unavailableUpdateDB{RwDB: handler.indiciesDB}
+
+ for range 2 {
+ err := handler.storeBlockAndBlobs(t.Context(), block, nil, nil, false)
+ require.ErrorContains(t, err, "database unavailable")
+ }
+ require.Equal(t, 1, installing.onBlockCalls)
+}
+
+func TestBroadcastBlockExactReplayCompletesMissingDataSidecars(t *testing.T) {
+ for _, version := range []clparams.StateVersion{clparams.DenebVersion, clparams.FuluVersion} {
+ t.Run(version.String(), func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ cfg := clparams.MainnetBeaconConfig
+ if clparams.GetBeaconConfig() == nil {
+ clparams.InitGlobalStaticConfig(&cfg, &clparams.CaplinConfig{})
+ }
+ block := cltypes.NewSignedBeaconBlock(&cfg, version)
+ block.Block.Slot = cfg.SlotsPerEpoch
+ commitment := &cltypes.KZGCommitment{1}
+ block.Block.Body.BlobKzgCommitments.Append(commitment)
+ blobBundles, err := lru.New[common.Bytes48, BlobBundle]("test-replay-blobs", 1)
+ require.NoError(t, err)
+ proofs := []common.Bytes48{{1}}
+ if version >= clparams.FuluVersion {
+ proofs = make([]common.Bytes48, cfg.NumberOfColumns)
+ }
+ blobBundles.Add(common.Bytes48(*commitment), BlobBundle{
+ Commitment: common.Bytes48(*commitment),
+ Blob: &cltypes.Blob{},
+ KzgProofs: proofs,
+ })
+
+ blockService := &replayableBlockService{
+ MockBlockService: network_services_mock.NewMockBlockService(ctrl),
+ pending: make(map[publishingBlockKey]common.Hash),
+ seen: make(map[publishingBlockKey]publishingSeenBlock),
+ scheduled: make(chan struct{}, 2),
+ }
+ gossipManager := &failFirstSidecarGossip{MockGossip: gossip_mock.NewMockGossip(ctrl)}
+ handler := &ApiHandler{
+ beaconChainCfg: &cfg,
+ blockService: blockService,
+ blobBundles: blobBundles,
+ gossipManager: gossipManager,
+ logger: log.Root(),
+ }
+ if version < clparams.FuluVersion {
+ blobStorage := blob_storage_mock.NewMockBlobStorage(ctrl)
+ blobStorage.EXPECT().WriteBlobSidecars(gomock.Any(), gomock.Any(), gomock.Any()).Return(errors.New("storage unavailable")).AnyTimes()
+ handler.blobStoage = blobStorage
+ } else {
+ columnStorage := blob_storage_mock.NewMockDataColumnStorage(ctrl)
+ columnStorage.EXPECT().WriteColumnSidecars(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(errors.New("storage unavailable")).AnyTimes()
+ handler.columnStorage = columnStorage
+ }
+
+ err = handler.broadcastBlock(t.Context(), block, BlockPublishingValidationGossip)
+ require.ErrorContains(t, err, "sidecar unavailable")
+ gossipManager.mu.Lock()
+ gossipManager.failBlockAt = 2
+ gossipManager.mu.Unlock()
+ err = handler.broadcastBlock(t.Context(), block, BlockPublishingValidationGossip)
+ require.ErrorContains(t, err, "block unavailable")
+ require.NoError(t, handler.broadcastBlock(t.Context(), block, BlockPublishingValidationGossip))
+ err = handler.broadcastBlock(t.Context(), block, BlockPublishingValidationGossip)
+ require.ErrorIs(t, err, errPublishedBlockValidation)
+ require.ErrorIs(t, err, clservices.ErrIgnore)
+
+ block.Block.StateRoot[0] ^= 1
+ err = handler.broadcastBlock(t.Context(), block, BlockPublishingValidationGossip)
+ require.ErrorIs(t, err, errPublishedBlockValidation)
+ require.ErrorIs(t, err, clservices.ErrIgnore)
+
+ for range 2 {
+ select {
+ case <-blockService.scheduled:
+ case <-time.After(time.Second):
+ t.Fatal("sidecar storage failure did not schedule full recovery")
+ }
+ }
+ gossipManager.mu.Lock()
+ require.Equal(t, 3, gossipManager.blockPublishes)
+ require.GreaterOrEqual(t, gossipManager.sidecarAttempts, 2)
+ gossipManager.mu.Unlock()
+ })
+ }
+}
+
+func TestPublishGossipReturnsPublishFailure(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ gossipManager := gossip_mock.NewMockGossip(ctrl)
+ gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameBeaconBlock, []byte{1}).Return(errors.New("gossip unavailable"))
+ handler := &ApiHandler{gossipManager: gossipManager}
+
+ err := handler.publishGossip(t.Context(), gossip.TopicNameBeaconBlock, []byte{1})
+
+ require.ErrorContains(t, err, "gossip unavailable")
+}
+
func TestPublishBlindedBlocksRejectsPreBellatrix(t *testing.T) {
for _, version := range []clparams.StateVersion{clparams.Phase0Version, clparams.AltairVersion} {
t.Run(version.String(), func(t *testing.T) {
@@ -327,6 +1470,41 @@ func TestValidateBuilderPayloadRejectsMissingElectraExecutionRequests(t *testing
require.NoError(t, validateBuilderPayload(payload, cltypes.NewExecutionRequestsWithVersion(&clparams.MainnetBeaconConfig, clparams.ElectraVersion), clparams.ElectraVersion))
}
+func TestValidateBuilderExecutionRequestsDoesNotApplyLegacyGloasDepositMaximum(t *testing.T) {
+ cfg := clparams.MainnetBeaconConfig
+ cfg.MaxDepositRequestsPerPayload = 1
+ requests := cltypes.NewExecutionRequestsWithVersion(&cfg, clparams.GloasVersion)
+ requests.Deposits.Append(&solid.DepositRequest{})
+ requests.Deposits.Append(&solid.DepositRequest{})
+
+ require.NoError(t, validateBuilderExecutionRequests(&cfg, clparams.GloasVersion, requests))
+}
+
+func TestValidateBuilderExecutionRequestsBoundsGloasDepositResources(t *testing.T) {
+ requests := cltypes.NewExecutionRequestsWithVersion(&clparams.MainnetBeaconConfig, clparams.GloasVersion)
+ request := &solid.DepositRequest{}
+ for range int(clparams.MaxChunkSize)/solid.SizeDepositRequest + 1 {
+ requests.Deposits.Append(request)
+ }
+
+ require.ErrorContains(t, validateBuilderExecutionRequests(&clparams.MainnetBeaconConfig, clparams.GloasVersion, requests), "too many deposit requests")
+}
+
+func TestValidateBuilderExecutionRequestsPreservesOtherProtocolLimits(t *testing.T) {
+ cfg := clparams.MainnetBeaconConfig
+ cfg.MaxDepositRequestsPerPayload = 1
+ cfg.MaxWithdrawalRequestsPerPayload = 1
+ electra := cltypes.NewExecutionRequestsWithVersion(&cfg, clparams.ElectraVersion)
+ electra.Deposits.Append(&solid.DepositRequest{})
+ electra.Deposits.Append(&solid.DepositRequest{})
+ require.ErrorContains(t, validateBuilderExecutionRequests(&cfg, clparams.ElectraVersion, electra), "too many deposit requests")
+
+ gloas := cltypes.NewExecutionRequestsWithVersion(&cfg, clparams.GloasVersion)
+ gloas.Withdrawals.Append(&solid.WithdrawalRequest{})
+ gloas.Withdrawals.Append(&solid.WithdrawalRequest{})
+ require.ErrorContains(t, validateBuilderExecutionRequests(&cfg, clparams.GloasVersion, gloas), "too many withdrawal requests")
+}
+
func TestBlockBuilderWindowLateStartKeepsPublicationMargin(t *testing.T) {
cfg := &clparams.BeaconChainConfig{
SecondsPerSlot: 12,
@@ -527,7 +1705,7 @@ func TestSetupHeaderResponseForBlockProductionGloasPayloadIncluded(t *testing.T)
h := &ApiHandler{}
rr := httptest.NewRecorder()
- h.setupHeaderReponseForBlockProduction(rr, clparams.GloasVersion, false, true, 123, 456)
+ h.setupHeaderReponseForBlockProduction(rr, clparams.GloasVersion, false, true, big.NewInt(123), 456)
require.Equal(t, "gloas", rr.Header().Get("Eth-Consensus-Version"))
require.Equal(t, "123", rr.Header().Get("Eth-Execution-Payload-Value"))
@@ -540,11 +1718,35 @@ func TestSetupHeaderResponseForBlockProductionPreGloasOmitsPayloadIncluded(t *te
h := &ApiHandler{}
rr := httptest.NewRecorder()
- h.setupHeaderReponseForBlockProduction(rr, clparams.ElectraVersion, false, true, 123, 456)
+ h.setupHeaderReponseForBlockProduction(rr, clparams.ElectraVersion, false, true, big.NewInt(123), 456)
require.Empty(t, rr.Header().Get("Eth-Execution-Payload-Included"))
}
+func TestSetupHeaderResponsePreservesExecutionValueAboveUint64(t *testing.T) {
+ h := &ApiHandler{}
+ rr := httptest.NewRecorder()
+ value := new(big.Int).Lsh(big.NewInt(1), 80)
+ h.setupHeaderReponseForBlockProduction(rr, clparams.GloasVersion, false, false, value, 0)
+ require.Equal(t, value.String(), rr.Header().Get("Eth-Execution-Payload-Value"))
+}
+
+func TestBroadcastGloasBuilderBlockDoesNotRequireEnvelopeBlobsYet(t *testing.T) {
+ commitments := solid.NewStaticListSSZ[*cltypes.KZGCommitment](cltypes.MaxBlobsCommittmentsPerBlock, 48)
+ commitment := cltypes.KZGCommitment{1}
+ commitments.Append(&commitment)
+ columns, pending, err := collectPublishedPayloadData(commitments, true, func(common.Bytes48) (BlobBundle, bool) {
+ return BlobBundle{}, false
+ })
+ require.NoError(t, err)
+ require.True(t, pending)
+ require.Empty(t, columns)
+ _, _, err = collectPublishedPayloadData(commitments, false, func(common.Bytes48) (BlobBundle, bool) {
+ return BlobBundle{}, false
+ })
+ require.Error(t, err)
+}
+
// TestCaplinBlockProductionWithWithdrawalRequest tests Caplin's produceBeaconBody
// against a real Erigon execution layer. A withdrawal request transaction is
// submitted to the EIP-7002 system contract, and then Caplin's actual block
@@ -644,6 +1846,8 @@ func TestCaplinBlockProductionWithWithdrawalRequest(t *testing.T) {
targetSlot := baseBlock.Slot + 1
baseBlockRoot, err := baseBlock.HashSSZ()
require.NoError(t, err)
+ fcu.HeadVal = baseBlockRoot
+ fcu.HeadPayloadStatusVal = cltypes.PayloadStatusFull
beaconBody, execValue, err := h.produceBeaconBody(
ctx, 3, baseBlock.Slot, baseBlockRoot, postState, targetSlot,
@@ -755,6 +1959,8 @@ func TestCaplinBlockProductionGlamsterdamSlotNumber(t *testing.T) {
targetSlot := baseBlock.Slot + 1
baseBlockRoot, err := baseBlock.HashSSZ()
require.NoError(t, err)
+ fcu.HeadVal = baseBlockRoot
+ fcu.HeadPayloadStatusVal = cltypes.PayloadStatusFull
// GLOAS deferred payload: the mock returns GetHeadPayloadStatus=FULL and ShouldBuildOnFull=true,
// so block production expects an envelope on disk. Provide one with empty ExecutionRequests.
diff --git a/cl/beacon/handler/block_production_v4.go b/cl/beacon/handler/block_production_v4.go
new file mode 100644
index 00000000000..4961bfe647b
--- /dev/null
+++ b/cl/beacon/handler/block_production_v4.go
@@ -0,0 +1,151 @@
+// Copyright 2026 The Erigon Authors
+// This file is part of Erigon.
+//
+// Erigon is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Erigon is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with Erigon. If not, see .
+
+package handler
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+
+ "github.com/go-chi/chi/v5"
+
+ "github.com/erigontech/erigon/cl/beacon/beaconhttp"
+ "github.com/erigontech/erigon/cl/clparams"
+ "github.com/erigontech/erigon/cl/cltypes"
+)
+
+const maxBuilderConfigRequestSize = 2 << 20
+
+type gloasBlockProductionOptions struct {
+ builderConfig *cltypes.BuilderConfig
+ includePayload bool
+ selectedBuilderURL string
+}
+
+type gloasBlockProductionOptionsKey struct{}
+
+func decodeGloasBlockProductionOptions(w http.ResponseWriter, r *http.Request, targetSlot uint64) (*gloasBlockProductionOptions, error) {
+ version := r.Header.Get("Eth-Consensus-Version")
+ if version == "" {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("Eth-Consensus-Version header is required"))
+ }
+ parsedVersion, err := clparams.StringToClVersion(version)
+ if err != nil || parsedVersion != clparams.GloasVersion {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("v4 block production requires gloas consensus version"))
+ }
+ includePayloadValue := r.URL.Query().Get("include_payload")
+ if includePayloadValue == "" {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("include_payload query parameter is required"))
+ }
+ includePayload, err := strconv.ParseBool(includePayloadValue)
+ if err != nil {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("invalid include_payload: %w", err))
+ }
+ config := new(cltypes.BuilderConfig)
+ contentType, err := requestContentType(r)
+ if err != nil {
+ return nil, beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType, err)
+ }
+ switch contentType {
+ case "application/json":
+ decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBuilderConfigRequestSize))
+ if err := decodeBuilderConfigJSON(decoder, config); err != nil {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err)
+ }
+ if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("request body contains trailing data"))
+ }
+ case "application/octet-stream":
+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBuilderConfigRequestSize))
+ if err != nil {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err)
+ }
+ if err := config.DecodeSSZStrictStructural(body, int(clparams.GloasVersion)); err != nil {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err)
+ }
+ default:
+ return nil, beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType, fmt.Errorf("unsupported content type: %s", contentType))
+ }
+ validBuilders := make([]*cltypes.BuilderEntry, 0, len(config.Builders))
+ seen := make(map[string]struct{}, len(config.Builders))
+ for _, entry := range config.Builders {
+ if entry == nil || entry.Validate() != nil || entry.Auth.Message.Slot != targetSlot {
+ continue
+ }
+ key := entry.URL + "\x00" + string(entry.Auth.Message.Data)
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ validBuilders = append(validBuilders, entry)
+ }
+ config.Builders = validBuilders
+ return &gloasBlockProductionOptions{builderConfig: config, includePayload: includePayload}, nil
+}
+
+func decodeBuilderConfigJSON(decoder *json.Decoder, config *cltypes.BuilderConfig) error {
+ var raw struct {
+ MinBid *uint64 `json:"min_bid,string"`
+ BuilderBoostFactor *uint64 `json:"builder_boost_factor,string"`
+ Builders *[]json.RawMessage `json:"builders"`
+ }
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&raw); err != nil {
+ return err
+ }
+ if raw.MinBid == nil || raw.BuilderBoostFactor == nil || raw.Builders == nil {
+ return errors.New("builder config is missing a required field")
+ }
+ if len(*raw.Builders) > cltypes.MaxBuilderEntries {
+ return fmt.Errorf("builder count %d exceeds %d", len(*raw.Builders), cltypes.MaxBuilderEntries)
+ }
+ config.MinBid = *raw.MinBid
+ config.BuilderBoostFactor = *raw.BuilderBoostFactor
+ config.Builders = make([]*cltypes.BuilderEntry, 0, len(*raw.Builders))
+ for _, encoded := range *raw.Builders {
+ entry := new(cltypes.BuilderEntry)
+ if err := json.Unmarshal(encoded, entry); err == nil {
+ config.Builders = append(config.Builders, entry)
+ }
+ }
+ return nil
+}
+
+func (a *ApiHandler) PostEthV4ValidatorBlock(w http.ResponseWriter, r *http.Request) (*beaconhttp.BeaconResponse, error) {
+ targetSlot, err := strconv.ParseUint(chi.URLParam(r, "slot"), 10, 64)
+ if err != nil {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("invalid slot: %w", err))
+ }
+ if a.beaconChainCfg.SlotsPerEpoch == 0 || targetSlot/a.beaconChainCfg.SlotsPerEpoch < a.beaconChainCfg.GloasForkEpoch {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("v4 block production is unavailable before Gloas"))
+ }
+ options, err := decodeGloasBlockProductionOptions(w, r, targetSlot)
+ if err != nil {
+ return nil, err
+ }
+ r = r.WithContext(context.WithValue(r.Context(), gloasBlockProductionOptionsKey{}, options))
+ return a.GetEthV3ValidatorBlock(w, r)
+}
+
+func gloasBlockOptionsFromContext(ctx context.Context) *gloasBlockProductionOptions {
+ options, _ := ctx.Value(gloasBlockProductionOptionsKey{}).(*gloasBlockProductionOptions)
+ return options
+}
diff --git a/cl/beacon/handler/blocks.go b/cl/beacon/handler/blocks.go
index 531424c1a77..31139758d3f 100644
--- a/cl/beacon/handler/blocks.go
+++ b/cl/beacon/handler/blocks.go
@@ -89,15 +89,14 @@ func (a *ApiHandler) GetEthV1BeaconBlock(w http.ResponseWriter, r *http.Request)
return nil, err
}
- isOptimistic := a.forkchoiceStore.IsRootOptimistic(root)
-
- blk, err := a.blockReader.ReadBlockByRoot(ctx, tx, root)
+ blk, err := a.readBlockByRoot(ctx, tx, root)
if err != nil {
return nil, err
}
if blk == nil {
return nil, beaconhttp.NewEndpointError(http.StatusNotFound, fmt.Errorf("block not found %x", root))
}
+ isOptimistic := a.forkchoiceStore.IsRootOptimistic(root)
// Check if the block is canonical
var canonicalRoot common.Hash
canonicalRoot, err = beacon_indicies.ReadCanonicalBlockRoot(tx, blk.Block.Slot)
@@ -109,6 +108,41 @@ func (a *ApiHandler) GetEthV1BeaconBlock(w http.ResponseWriter, r *http.Request)
WithVersion(blk.Version()).WithOptimistic(isOptimistic), nil
}
+func (a *ApiHandler) readBlockByRoot(ctx context.Context, tx kv.Tx, root common.Hash) (*cltypes.SignedBeaconBlock, error) {
+ blk, err := a.blockReader.ReadBlockByRoot(ctx, tx, root)
+ if err != nil || blk != nil {
+ return blk, err
+ }
+ return a.readLiveBlockByRoot(root)
+}
+
+func (a *ApiHandler) readLiveBlockByRoot(root common.Hash) (*cltypes.SignedBeaconBlock, error) {
+ blk, _, ok := a.liveBlockAndHeader(root)
+ if !ok {
+ return nil, nil
+ }
+ blockRoot, err := blk.Block.HashSSZ()
+ if err != nil {
+ return nil, err
+ }
+ if blockRoot != root {
+ return nil, nil
+ }
+ return blk, nil
+}
+
+func (a *ApiHandler) liveBlockAndHeader(root common.Hash) (*cltypes.SignedBeaconBlock, *cltypes.BeaconBlockHeader, bool) {
+ header, ok := a.forkchoiceStore.GetHeader(root)
+ if !ok || header == nil {
+ return nil, nil, false
+ }
+ blk, ok := a.forkchoiceStore.GetBlock(root)
+ if !ok || blk == nil || blk.Block == nil || blk.Block.Body == nil {
+ return nil, nil, false
+ }
+ return blk, header, true
+}
+
func (a *ApiHandler) GetEthV1BlindedBlock(w http.ResponseWriter, r *http.Request) (*beaconhttp.BeaconResponse, error) {
ctx := r.Context()
tx, err := a.indiciesDB.BeginRo(ctx)
diff --git a/cl/beacon/handler/blocks_test.go b/cl/beacon/handler/blocks_test.go
new file mode 100644
index 00000000000..99d10aa1b12
--- /dev/null
+++ b/cl/beacon/handler/blocks_test.go
@@ -0,0 +1,182 @@
+// Copyright 2026 The Erigon Authors
+// This file is part of Erigon.
+//
+// Erigon is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Erigon is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with Erigon. If not, see .
+
+package handler
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/erigontech/erigon/cl/clparams"
+ "github.com/erigontech/erigon/cl/cltypes"
+ "github.com/erigontech/erigon/common"
+ "github.com/erigontech/erigon/common/log/v3"
+ "github.com/erigontech/erigon/db/kv"
+ "github.com/erigontech/erigon/db/snapshotsync/freezeblocks"
+)
+
+type gatedBeaconBlockReader struct {
+ freezeblocks.BeaconSnapshotReader
+ entered chan struct{}
+ release chan struct{}
+}
+
+func (r *gatedBeaconBlockReader) ReadBlockByRoot(context.Context, kv.Tx, common.Hash) (*cltypes.SignedBeaconBlock, error) {
+ close(r.entered)
+ <-r.release
+ return nil, nil
+}
+
+func TestGetBeaconBlockByRootUsesImportedBlockBeforePersistence(t *testing.T) {
+ _, blocks, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), false)
+ block := blocks[len(blocks)-1].Clone().(*cltypes.SignedBeaconBlock)
+ block.Block.Slot++
+ root, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ forkchoiceStore.Blocks[root] = block
+ forkchoiceStore.Headers[root] = block.SignedBeaconBlockHeader().Header
+
+ for _, version := range []string{"v1", "v2"} {
+ t.Run(version, func(t *testing.T) {
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, fmt.Sprintf("/eth/%s/beacon/blocks/0x%x", version, root), http.NoBody)
+ recorder := httptest.NewRecorder()
+
+ handler.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+ require.Contains(t, recorder.Body.String(), fmt.Sprintf(`"slot":"%d"`, block.Block.Slot))
+ })
+ }
+}
+
+func TestGetBeaconBlockByRootSamplesOptimisticStatusAfterFallback(t *testing.T) {
+ _, blocks, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), false)
+ block := blocks[len(blocks)-1].Clone().(*cltypes.SignedBeaconBlock)
+ block.Block.Slot++
+ root, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ reader := &gatedBeaconBlockReader{
+ BeaconSnapshotReader: handler.blockReader,
+ entered: make(chan struct{}),
+ release: make(chan struct{}),
+ }
+ handler.blockReader = reader
+ response := make(chan *httptest.ResponseRecorder, 1)
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, fmt.Sprintf("/eth/v2/beacon/blocks/0x%x", root), http.NoBody)
+
+ go func() {
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, request)
+ response <- recorder
+ }()
+
+ <-reader.entered
+ forkchoiceStore.IsRootOptimisticVal = true
+ forkchoiceStore.Blocks[root] = block
+ forkchoiceStore.Headers[root] = block.SignedBeaconBlockHeader().Header
+ close(reader.release)
+ recorder := <-response
+
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+ require.Contains(t, recorder.Body.String(), `"execution_optimistic":true`)
+}
+
+func TestGetBeaconBlockByRootKeepsPersistedBlockFastPath(t *testing.T) {
+ _, blocks, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), false)
+ block := blocks[len(blocks)-1]
+ root, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+
+ for _, version := range []string{"v1", "v2"} {
+ t.Run(version, func(t *testing.T) {
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, fmt.Sprintf("/eth/%s/beacon/blocks/0x%x", version, root), http.NoBody)
+ recorder := httptest.NewRecorder()
+
+ handler.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+ require.Contains(t, recorder.Body.String(), fmt.Sprintf(`"slot":"%d"`, block.Block.Slot))
+ })
+ }
+}
+
+func TestGetBeaconBlockByRootRejectsUnvalidatedForkchoiceBlock(t *testing.T) {
+ _, blocks, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), false)
+ block := blocks[len(blocks)-1].Clone().(*cltypes.SignedBeaconBlock)
+ block.Block.Slot++
+ root, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ forkchoiceStore.Blocks[root] = block
+
+ for _, version := range []string{"v1", "v2"} {
+ t.Run(version, func(t *testing.T) {
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, fmt.Sprintf("/eth/%s/beacon/blocks/0x%x", version, root), http.NoBody)
+ recorder := httptest.NewRecorder()
+
+ handler.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusNotFound, recorder.Code, recorder.Body.String())
+ })
+ }
+}
+
+func TestGetBeaconBlockByRootRejectsUnavailableForkchoiceBlock(t *testing.T) {
+ _, blocks, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), false)
+ block := blocks[len(blocks)-1].Clone().(*cltypes.SignedBeaconBlock)
+ block.Block.Slot++
+ mismatchedRoot := common.Hash{0xff}
+ forkchoiceStore.Blocks[mismatchedRoot] = block
+ forkchoiceStore.Headers[mismatchedRoot] = block.SignedBeaconBlockHeader().Header
+ headerOnlyRoot := common.Hash{0xdd}
+ forkchoiceStore.Headers[headerOnlyRoot] = block.SignedBeaconBlockHeader().Header
+ nilBlockRoot := common.Hash{0xcc}
+ forkchoiceStore.Blocks[nilBlockRoot] = &cltypes.SignedBeaconBlock{}
+ forkchoiceStore.Headers[nilBlockRoot] = block.SignedBeaconBlockHeader().Header
+ nilBodyRoot := common.Hash{0xbb}
+ nilBodyBlock := block.Clone().(*cltypes.SignedBeaconBlock)
+ nilBodyBlock.Block.Body = nil
+ forkchoiceStore.Blocks[nilBodyRoot] = nilBodyBlock
+ forkchoiceStore.Headers[nilBodyRoot] = block.SignedBeaconBlockHeader().Header
+
+ for _, testCase := range []struct {
+ name string
+ root common.Hash
+ }{
+ {name: "unknown", root: common.Hash{0xee}},
+ {name: "mismatched", root: mismatchedRoot},
+ {name: "header only", root: headerOnlyRoot},
+ {name: "nil block", root: nilBlockRoot},
+ {name: "nil body", root: nilBodyRoot},
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ for _, version := range []string{"v1", "v2"} {
+ t.Run(version, func(t *testing.T) {
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, fmt.Sprintf("/eth/%s/beacon/blocks/0x%x", version, testCase.root), http.NoBody)
+ recorder := httptest.NewRecorder()
+
+ handler.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusNotFound, recorder.Code, recorder.Body.String())
+ })
+ }
+ })
+ }
+}
diff --git a/cl/beacon/handler/builder.go b/cl/beacon/handler/builder.go
index 84d059f98ff..6ea247549c0 100644
--- a/cl/beacon/handler/builder.go
+++ b/cl/beacon/handler/builder.go
@@ -19,17 +19,238 @@ package handler
import (
"encoding/json"
"errors"
+ "fmt"
+ "io"
+ "math"
"net/http"
+ "strconv"
"github.com/erigontech/erigon/cl/beacon/beaconhttp"
"github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/cltypes"
"github.com/erigontech/erigon/cl/persistence/beacon_indicies"
"github.com/erigontech/erigon/cl/phase1/core/state"
+ "github.com/erigontech/erigon/cl/phase1/forkchoice/fork_graph"
"github.com/erigontech/erigon/common"
"github.com/erigontech/erigon/common/log/v3"
)
+const maxStateBuildersRequestSize = 1 << 20
+
+type stateBuildersRequest struct {
+ Ids []string `json:"ids"`
+ Statuses []string `json:"statuses"`
+}
+
+type stateBuilderResponse struct {
+ Index string `json:"index"`
+ Status string `json:"status"`
+ Builder stateBuilderAPIResponse `json:"builder"`
+}
+
+type stateBuilderAPIResponse struct {
+ Pubkey common.Bytes48 `json:"pubkey"`
+ Version string `json:"version"`
+ ExecutionAddress common.Address `json:"execution_address"`
+ Balance string `json:"balance"`
+ DepositEpoch string `json:"deposit_epoch"`
+ WithdrawableEpoch string `json:"withdrawable_epoch"`
+}
+
+func decodeStateBuildersRequest(w http.ResponseWriter, r *http.Request) (stateBuildersRequest, error) {
+ request := new(stateBuildersRequest)
+ decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxStateBuildersRequestSize))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(&request); err != nil && !errors.Is(err, io.EOF) {
+ return stateBuildersRequest{}, err
+ }
+ if request == nil {
+ return stateBuildersRequest{}, errors.New("request body must be a JSON object")
+ }
+ if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
+ return stateBuildersRequest{}, errors.New("request body must contain one JSON object")
+ }
+ return *request, nil
+}
+
+func parseStateBuilderFilters(request stateBuildersRequest) (map[uint64]struct{}, map[common.Bytes48]struct{}, map[string]struct{}, error) {
+ indices := make(map[uint64]struct{})
+ pubkeys := make(map[common.Bytes48]struct{})
+ for _, id := range request.Ids {
+ if len(id) >= 2 && id[:2] == "0x" {
+ var pubkey common.Bytes48
+ if err := pubkey.UnmarshalText([]byte(id)); err != nil {
+ return nil, nil, nil, fmt.Errorf("invalid builder id %q: %w", id, err)
+ }
+ if _, duplicate := pubkeys[pubkey]; duplicate {
+ return nil, nil, nil, fmt.Errorf("duplicate builder id %q", id)
+ }
+ pubkeys[pubkey] = struct{}{}
+ continue
+ }
+ index, err := strconv.ParseUint(id, 10, 64)
+ if err != nil {
+ return nil, nil, nil, fmt.Errorf("invalid builder id %q: %w", id, err)
+ }
+ if _, duplicate := indices[index]; duplicate {
+ return nil, nil, nil, fmt.Errorf("duplicate builder id %q", id)
+ }
+ indices[index] = struct{}{}
+ }
+ statuses := make(map[string]struct{})
+ for _, status := range request.Statuses {
+ switch status {
+ case "pending", "active", "exited":
+ default:
+ return nil, nil, nil, fmt.Errorf("invalid builder status %q", status)
+ }
+ if _, duplicate := statuses[status]; duplicate {
+ return nil, nil, nil, fmt.Errorf("duplicate builder status %q", status)
+ }
+ statuses[status] = struct{}{}
+ }
+ return indices, pubkeys, statuses, nil
+}
+
+func stateBuilderStatus(builder *cltypes.Builder, finalizedEpoch uint64) string {
+ if builder.WithdrawableEpoch != math.MaxUint64 {
+ return "exited"
+ }
+ if builder.DepositEpoch < finalizedEpoch {
+ return "active"
+ }
+ return "pending"
+}
+
+func stateBuildersResponse(s *state.CachingBeaconState, indices map[uint64]struct{}, pubkeys map[common.Bytes48]struct{}, statuses map[string]struct{}) ([]stateBuilderResponse, error) {
+ if s.Version() < clparams.GloasVersion {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("the specified state is not a gloas state"))
+ }
+ builders := s.GetBuilders()
+ if builders == nil {
+ return nil, errors.New("builder registry is unavailable")
+ }
+ responseCapacity := builders.Len()
+ if selected := len(indices) + len(pubkeys); selected > 0 && selected < responseCapacity {
+ responseCapacity = selected
+ }
+ responses := make([]stateBuilderResponse, 0, responseCapacity)
+ var registryErr error
+ builders.Range(func(index int, builder *cltypes.Builder, _ int) bool {
+ if builder == nil {
+ registryErr = errors.New("builder registry contains a nil builder")
+ return false
+ }
+ if len(indices)+len(pubkeys) > 0 {
+ _, indexSelected := indices[uint64(index)]
+ _, pubkeySelected := pubkeys[builder.Pubkey]
+ if !indexSelected && !pubkeySelected {
+ return true
+ }
+ }
+ status := stateBuilderStatus(builder, s.FinalizedCheckpoint().Epoch)
+ if len(statuses) > 0 {
+ if _, selected := statuses[status]; !selected {
+ return true
+ }
+ }
+ responses = append(responses, stateBuilderResponse{
+ Index: strconv.FormatUint(uint64(index), 10),
+ Status: status,
+ Builder: stateBuilderAPIResponse{
+ Pubkey: builder.Pubkey,
+ Version: strconv.FormatUint(uint64(builder.Version), 10),
+ ExecutionAddress: builder.ExecutionAddress,
+ Balance: strconv.FormatUint(builder.Balance, 10),
+ DepositEpoch: strconv.FormatUint(builder.DepositEpoch, 10),
+ WithdrawableEpoch: strconv.FormatUint(builder.WithdrawableEpoch, 10),
+ },
+ })
+ return true
+ })
+ if registryErr != nil {
+ return nil, registryErr
+ }
+ return responses, nil
+}
+
+func (a *ApiHandler) PostEthV1BeaconStatesBuilders(w http.ResponseWriter, r *http.Request) (*beaconhttp.BeaconResponse, error) {
+ request, err := decodeStateBuildersRequest(w, r)
+ if err != nil {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err)
+ }
+ indices, pubkeys, statuses, err := parseStateBuilderFilters(request)
+ if err != nil {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err)
+ }
+ stateID, err := beaconhttp.StateIdFromRequest(r)
+ if err != nil {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err)
+ }
+ if stateID.Head() {
+ var response *beaconhttp.BeaconResponse
+ err := a.viewHeadStateWithIdentity(func(headState *state.CachingBeaconState, root common.Hash, slot uint64) error {
+ data, err := stateBuildersResponse(headState, indices, pubkeys, statuses)
+ if err != nil {
+ return err
+ }
+ response = newBeaconResponse(data).
+ WithOptimistic(a.forkchoiceStore.IsRootOptimistic(root)).
+ WithFinalized(slot <= a.forkchoiceStore.FinalizedSlot())
+ return nil
+ })
+ return response, err
+ }
+
+ tx, err := a.indiciesDB.BeginRo(r.Context())
+ if err != nil {
+ return nil, err
+ }
+ defer tx.Rollback()
+ root, statusCode, err := a.blockRootFromStateId(r.Context(), tx, stateID)
+ if err != nil {
+ return nil, beaconhttp.NewEndpointError(statusCode, err)
+ }
+ slot, err := beacon_indicies.ReadBlockSlotByBlockRoot(tx, root)
+ if err != nil {
+ return nil, err
+ }
+ if slot == nil {
+ return nil, beaconhttp.NewEndpointError(http.StatusNotFound, errors.New("state not found"))
+ }
+ requestedState, err := a.forkchoiceStore.GetStateAtBlockRoot(root, true)
+ if err != nil && !errors.Is(err, fork_graph.ErrStateNotFound) {
+ return nil, err
+ }
+ if requestedState == nil {
+ canonicalRoot, err := beacon_indicies.ReadCanonicalBlockRoot(tx, *slot)
+ if err != nil {
+ return nil, err
+ }
+ if canonicalRoot != root {
+ return nil, beaconhttp.NewEndpointError(http.StatusNotFound, errors.New("state not found"))
+ }
+ requestedState, err = a.stateReader.ReadHistoricalState(r.Context(), tx, *slot)
+ if err != nil {
+ return nil, err
+ }
+ if requestedState == nil {
+ return nil, beaconhttp.NewEndpointError(http.StatusNotFound, errors.New("state not found"))
+ }
+ }
+ data, err := stateBuildersResponse(requestedState, indices, pubkeys, statuses)
+ if err != nil {
+ return nil, err
+ }
+ canonicalRoot, err := beacon_indicies.ReadCanonicalBlockRoot(tx, *slot)
+ if err != nil {
+ return nil, err
+ }
+ return newBeaconResponse(data).
+ WithOptimistic(a.forkchoiceStore.IsRootOptimistic(root)).
+ WithFinalized(canonicalRoot == root && *slot <= a.forkchoiceStore.FinalizedSlot()), nil
+}
+
func (a *ApiHandler) GetEth1V1BuilderStatesExpectedWithdrawals(w http.ResponseWriter, r *http.Request) (*beaconhttp.BeaconResponse, error) {
ctx := r.Context()
diff --git a/cl/beacon/handler/builder_preferences.go b/cl/beacon/handler/builder_preferences.go
new file mode 100644
index 00000000000..f525cd1b87c
--- /dev/null
+++ b/cl/beacon/handler/builder_preferences.go
@@ -0,0 +1,157 @@
+// Copyright 2026 The Erigon Authors
+// This file is part of Erigon.
+//
+// Erigon is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Erigon is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with Erigon. If not, see .
+
+package handler
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "sort"
+ "sync"
+ "time"
+
+ "github.com/erigontech/erigon/cl/beacon/beaconhttp"
+ "github.com/erigontech/erigon/cl/clparams"
+ "github.com/erigontech/erigon/cl/cltypes"
+)
+
+const (
+ maxBuilderPreferencesRequestSize = 64 << 20
+ maxBuilderFailureMessageSize = 256
+ builderPreferencesWorkers = 32
+ builderPreferencesRequestTimeout = 5 * time.Second
+)
+
+func (a *ApiHandler) PostEthV1ValidatorBuilderPreferences(w http.ResponseWriter, r *http.Request) {
+ version, err := clparams.StringToClVersion(r.Header.Get("Eth-Consensus-Version"))
+ if err != nil || version != clparams.GloasVersion {
+ beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("Gloas Eth-Consensus-Version header is required")).WriteTo(w)
+ return
+ }
+ if a.builderClient == nil {
+ beaconhttp.NewEndpointError(http.StatusServiceUnavailable, errors.New("builder client is unavailable")).WriteTo(w)
+ return
+ }
+ entries := cltypes.BuilderPreferencesEntries{}
+ type indexedEntry struct {
+ index int
+ entry *cltypes.BuilderPreferencesEntry
+ }
+ var indexedEntries []indexedEntry
+ failures := make([]poolingFailure, 0)
+ contentType, err := requestContentType(r)
+ if err != nil {
+ beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType, err).WriteTo(w)
+ return
+ }
+ switch contentType {
+ case "application/json":
+ decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBuilderPreferencesRequestSize))
+ var rawEntries []json.RawMessage
+ if err := decoder.Decode(&rawEntries); err != nil {
+ beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w)
+ return
+ }
+ if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) {
+ beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("request body contains trailing data")).WriteTo(w)
+ return
+ }
+ if rawEntries == nil {
+ beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("builder preferences entries cannot be null")).WriteTo(w)
+ return
+ }
+ if len(rawEntries) > cltypes.MaxBuilderPreferencesEntries {
+ beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("builder preferences entry count %d exceeds %d", len(rawEntries), cltypes.MaxBuilderPreferencesEntries)).WriteTo(w)
+ return
+ }
+ indexedEntries = make([]indexedEntry, 0, len(rawEntries))
+ for i, rawEntry := range rawEntries {
+ entry := new(cltypes.BuilderPreferencesEntry)
+ if err := json.Unmarshal(rawEntry, entry); err != nil {
+ failures = append(failures, poolingFailure{Index: i, Message: builderFailureMessage(err)})
+ continue
+ }
+ indexedEntries = append(indexedEntries, indexedEntry{index: i, entry: entry})
+ }
+ case "application/octet-stream":
+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBuilderPreferencesRequestSize))
+ if err != nil {
+ beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w)
+ return
+ }
+ if err := entries.DecodeSSZStrict(body, int(clparams.GloasVersion)); err != nil {
+ beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w)
+ return
+ }
+ indexedEntries = make([]indexedEntry, len(entries))
+ for i, entry := range entries {
+ indexedEntries[i] = indexedEntry{index: i, entry: entry}
+ }
+ default:
+ beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType, fmt.Errorf("unsupported content type: %s", contentType)).WriteTo(w)
+ return
+ }
+
+ requestContext, cancel := context.WithTimeout(r.Context(), builderPreferencesRequestTimeout)
+ defer cancel()
+ jobs := make(chan indexedEntry, len(indexedEntries))
+ results := make(chan poolingFailure, len(indexedEntries))
+ workerCount := min(builderPreferencesWorkers, len(indexedEntries))
+ var workers sync.WaitGroup
+ workers.Add(workerCount)
+ for range workerCount {
+ go func() {
+ defer workers.Done()
+ for indexed := range jobs {
+ entry := indexed.entry
+ request := &cltypes.BuilderPreferencesRequest{
+ Preferences: &cltypes.BuilderPreferences{MaxExecutionPayment: entry.MaxExecutionPayment},
+ Auth: entry.Auth,
+ }
+ if err := a.builderClient.SubmitBuilderPreferences(requestContext, entry.URL, entry.ProposerPubkey, request); err != nil {
+ results <- poolingFailure{Index: indexed.index, Message: builderFailureMessage(err)}
+ }
+ }
+ }()
+ }
+ for _, indexed := range indexedEntries {
+ jobs <- indexed
+ }
+ close(jobs)
+ workers.Wait()
+ close(results)
+ for failure := range results {
+ failures = append(failures, failure)
+ }
+ sort.Slice(failures, func(i, j int) bool { return failures[i].Index < failures[j].Index })
+ if len(failures) != 0 {
+ a.writePoolingFailures(w, failures)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+}
+
+func builderFailureMessage(err error) string {
+ message := err.Error()
+ if len(message) > maxBuilderFailureMessageSize {
+ return message[:maxBuilderFailureMessageSize]
+ }
+ return message
+}
diff --git a/cl/beacon/handler/builder_preferences_test.go b/cl/beacon/handler/builder_preferences_test.go
new file mode 100644
index 00000000000..5cb8899e1c8
--- /dev/null
+++ b/cl/beacon/handler/builder_preferences_test.go
@@ -0,0 +1,198 @@
+// Copyright 2026 The Erigon Authors
+// This file is part of Erigon.
+//
+// Erigon is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Erigon is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with Erigon. If not, see .
+
+package handler
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+ "go.uber.org/mock/gomock"
+
+ "github.com/erigontech/erigon/cl/beacon/builder/mock_services"
+ "github.com/erigontech/erigon/cl/cltypes"
+ "github.com/erigontech/erigon/common"
+)
+
+func testBuilderPreferencesEntries() cltypes.BuilderPreferencesEntries {
+ entries := make(cltypes.BuilderPreferencesEntries, 2)
+ for i := range entries {
+ entries[i] = &cltypes.BuilderPreferencesEntry{
+ ProposerPubkey: common.Bytes48{byte(i + 1)},
+ URL: "https://builder.example",
+ Auth: &cltypes.SignedBuilderRequestAuth{Message: &cltypes.BuilderRequestAuth{
+ Data: []byte("https://builder.example"), Slot: 10,
+ }},
+ MaxExecutionPayment: uint64(i + 1),
+ }
+ }
+ return entries
+}
+
+func TestPostValidatorBuilderPreferencesReportsPartialFailuresAfterSubmittingAll(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ client := mock_services.NewMockBuilderClient(ctrl)
+ entries := testBuilderPreferencesEntries()
+ client.EXPECT().SubmitBuilderPreferences(gomock.Any(), entries[0].URL, entries[0].ProposerPubkey, gomock.Any()).Return(errors.New("first failed"))
+ client.EXPECT().SubmitBuilderPreferences(gomock.Any(), entries[1].URL, entries[1].ProposerPubkey, gomock.Any()).Return(nil)
+ handler := &ApiHandler{builderClient: client}
+ body, err := entries.MarshalJSON()
+ require.NoError(t, err)
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/validator/builder_preferences", bytes.NewReader(body))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+
+ handler.PostEthV1ValidatorBuilderPreferences(recorder, request)
+
+ require.Equal(t, http.StatusBadRequest, recorder.Code)
+ require.Contains(t, recorder.Body.String(), `"index":0`)
+}
+
+func TestPostValidatorBuilderPreferencesAcceptsSSZ(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ client := mock_services.NewMockBuilderClient(ctrl)
+ entries := testBuilderPreferencesEntries()[:1]
+ client.EXPECT().SubmitBuilderPreferences(gomock.Any(), entries[0].URL, entries[0].ProposerPubkey, gomock.Any()).Return(nil)
+ handler := &ApiHandler{builderClient: client}
+ body, err := entries.EncodeSSZ(nil)
+ require.NoError(t, err)
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/validator/builder_preferences", bytes.NewReader(body))
+ request.Header.Set("Content-Type", "application/octet-stream")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+
+ handler.PostEthV1ValidatorBuilderPreferences(recorder, request)
+
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+}
+
+func TestPostValidatorBuilderPreferencesAcceptsMaximumJSONList(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ client := mock_services.NewMockBuilderClient(ctrl)
+ url := "https://builder.example/" + strings.Repeat("a", 2000)
+ entry := &cltypes.BuilderPreferencesEntry{
+ ProposerPubkey: common.Bytes48{1},
+ URL: url,
+ Auth: &cltypes.SignedBuilderRequestAuth{Message: &cltypes.BuilderRequestAuth{
+ Data: make([]byte, cltypes.MaxBuilderAuthDataSize), Slot: 10,
+ }},
+ }
+ entries := make(cltypes.BuilderPreferencesEntries, cltypes.MaxBuilderPreferencesEntries)
+ for i := range entries {
+ entries[i] = entry
+ }
+ body, err := entries.MarshalJSON()
+ require.NoError(t, err)
+ require.Greater(t, len(body), 32<<20)
+ client.EXPECT().SubmitBuilderPreferences(gomock.Any(), url, entry.ProposerPubkey, gomock.Any()).Return(nil).Times(len(entries))
+ handler := &ApiHandler{builderClient: client}
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/validator/builder_preferences", bytes.NewReader(body))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+
+ handler.PostEthV1ValidatorBuilderPreferences(recorder, request)
+
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+}
+
+func TestPostValidatorBuilderPreferencesReportsMalformedJSONEntryAndContinues(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ client := mock_services.NewMockBuilderClient(ctrl)
+ valid := testBuilderPreferencesEntries()[1]
+ validJSON, err := valid.MarshalJSON()
+ require.NoError(t, err)
+ client.EXPECT().SubmitBuilderPreferences(gomock.Any(), valid.URL, valid.ProposerPubkey, gomock.Any()).Return(nil)
+ handler := &ApiHandler{builderClient: client}
+ body := append([]byte(`[{"url":7},`), validJSON...)
+ body = append(body, ']')
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/validator/builder_preferences", bytes.NewReader(body))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+
+ handler.PostEthV1ValidatorBuilderPreferences(recorder, request)
+
+ require.Equal(t, http.StatusBadRequest, recorder.Code)
+ require.Contains(t, recorder.Body.String(), `"index":0`)
+}
+
+func TestPostValidatorBuilderPreferencesRejectsStructurallyInvalidJSON(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ handler := &ApiHandler{builderClient: mock_services.NewMockBuilderClient(ctrl)}
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/validator/builder_preferences", strings.NewReader(`[{"url":7}`))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+
+ handler.PostEthV1ValidatorBuilderPreferences(recorder, request)
+
+ require.Equal(t, http.StatusBadRequest, recorder.Code)
+ require.NotContains(t, recorder.Body.String(), `"failures"`)
+}
+
+func TestPostValidatorBuilderPreferencesBoundsSlowEntriesAndContinues(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ client := mock_services.NewMockBuilderClient(ctrl)
+ entries := make(cltypes.BuilderPreferencesEntries, 40)
+ for i := range entries {
+ entries[i] = testBuilderPreferencesEntries()[0].Clone().(*cltypes.BuilderPreferencesEntry)
+ entries[i].ProposerPubkey[0] = byte(i + 1)
+ }
+ client.EXPECT().SubmitBuilderPreferences(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+ func(ctx context.Context, _ string, proposer common.Bytes48, _ *cltypes.BuilderPreferencesRequest) error {
+ if proposer == entries[len(entries)-1].ProposerPubkey {
+ return nil
+ }
+ select {
+ case <-time.After(100 * time.Millisecond):
+ return errors.New("slow failure")
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+ },
+ ).Times(len(entries))
+ handler := &ApiHandler{builderClient: client}
+ body, err := entries.MarshalJSON()
+ require.NoError(t, err)
+ requestContext, cancel := context.WithTimeout(t.Context(), time.Second)
+ defer cancel()
+ request := httptest.NewRequestWithContext(requestContext, http.MethodPost, "/eth/v1/validator/builder_preferences", bytes.NewReader(body))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+ started := time.Now()
+
+ handler.PostEthV1ValidatorBuilderPreferences(recorder, request)
+
+ require.Less(t, time.Since(started), 500*time.Millisecond)
+ require.Equal(t, http.StatusBadRequest, recorder.Code)
+ var response poolingError
+ require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
+ require.Len(t, response.Failures, len(entries)-1)
+ for i, failure := range response.Failures {
+ require.Equal(t, i, failure.Index)
+ }
+}
diff --git a/cl/beacon/handler/builder_route_store.go b/cl/beacon/handler/builder_route_store.go
new file mode 100644
index 00000000000..b1f1e40ae04
--- /dev/null
+++ b/cl/beacon/handler/builder_route_store.go
@@ -0,0 +1,183 @@
+// Copyright 2026 The Erigon Authors
+// This file is part of Erigon.
+//
+// Erigon is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Erigon is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with Erigon. If not, see .
+
+package handler
+
+import (
+ "bytes"
+ "sync"
+ "time"
+
+ "github.com/erigontech/erigon/common"
+)
+
+const (
+ builderRouteCapacity = 16
+ builderRouteTTL = 2 * time.Minute
+)
+
+type builderRouteState uint8
+
+const (
+ builderRouteIdle builderRouteState = iota
+ builderRouteInFlight
+ builderRouteDelivered
+)
+
+type builderRouteKey struct {
+ root common.Hash
+ url string
+}
+
+type builderRoute struct {
+ state builderRouteState
+ expiresAt time.Time
+}
+
+type builderRouteStore struct {
+ mu sync.Mutex
+ routes map[builderRouteKey]*builderRoute
+ capacity int
+ ttl time.Duration
+ now func() time.Time
+}
+
+func newBuilderRouteStore(capacity int, ttl time.Duration, now func() time.Time) *builderRouteStore {
+ return &builderRouteStore{
+ routes: make(map[builderRouteKey]*builderRoute, capacity),
+ capacity: capacity,
+ ttl: ttl,
+ now: now,
+ }
+}
+
+func (s *builderRouteStore) Add(root common.Hash, url string) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ now := s.now()
+ s.pruneExpired(now)
+ key := builderRouteKey{root: root, url: url}
+ if route, ok := s.routes[key]; ok {
+ route.expiresAt = now.Add(s.ttl)
+ return true
+ }
+ if len(s.routes) >= s.capacity {
+ var oldestKey builderRouteKey
+ var oldestRoute *builderRoute
+ for existingKey, route := range s.routes {
+ if route.state == builderRouteDelivered && (oldestRoute == nil || route.expiresAt.Before(oldestRoute.expiresAt) ||
+ route.expiresAt.Equal(oldestRoute.expiresAt) && builderRouteKeyLess(existingKey, oldestKey)) {
+ oldestKey = existingKey
+ oldestRoute = route
+ }
+ }
+ if oldestRoute != nil {
+ delete(s.routes, oldestKey)
+ }
+ if len(s.routes) >= s.capacity {
+ return false
+ }
+ }
+ s.routes[key] = &builderRoute{state: builderRouteIdle, expiresAt: now.Add(s.ttl)}
+ return true
+}
+
+func builderRouteKeyLess(left, right builderRouteKey) bool {
+ if order := bytes.Compare(left.root[:], right.root[:]); order != 0 {
+ return order < 0
+ }
+ return left.url < right.url
+}
+
+func (s *builderRouteStore) Claim(root common.Hash, url string) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ now := s.now()
+ s.pruneExpired(now)
+ route, ok := s.routes[builderRouteKey{root: root, url: url}]
+ if !ok || route.state != builderRouteIdle {
+ return false
+ }
+ route.state = builderRouteInFlight
+ return true
+}
+
+func (s *builderRouteStore) ClaimOrAdd(root common.Hash, url string) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ now := s.now()
+ s.pruneExpired(now)
+ key := builderRouteKey{root: root, url: url}
+ route, ok := s.routes[key]
+ if !ok {
+ if len(s.routes) >= s.capacity {
+ var oldestKey builderRouteKey
+ var oldestRoute *builderRoute
+ for existingKey, candidate := range s.routes {
+ if candidate.state == builderRouteDelivered && (oldestRoute == nil || candidate.expiresAt.Before(oldestRoute.expiresAt) ||
+ candidate.expiresAt.Equal(oldestRoute.expiresAt) && builderRouteKeyLess(existingKey, oldestKey)) {
+ oldestKey = existingKey
+ oldestRoute = candidate
+ }
+ }
+ if oldestRoute != nil {
+ delete(s.routes, oldestKey)
+ }
+ if len(s.routes) >= s.capacity {
+ return false
+ }
+ }
+ route = &builderRoute{state: builderRouteIdle, expiresAt: now.Add(s.ttl)}
+ s.routes[key] = route
+ }
+ if route.state != builderRouteIdle {
+ return false
+ }
+ route.state = builderRouteInFlight
+ route.expiresAt = now.Add(s.ttl)
+ return true
+}
+
+func (s *builderRouteStore) Complete(root common.Hash, url string, delivered bool) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ route, ok := s.routes[builderRouteKey{root: root, url: url}]
+ if !ok || route.state != builderRouteInFlight {
+ return
+ }
+ if delivered {
+ route.state = builderRouteDelivered
+ } else {
+ route.state = builderRouteIdle
+ }
+}
+
+func (s *builderRouteStore) Discard(root common.Hash, url string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ key := builderRouteKey{root: root, url: url}
+ if route, ok := s.routes[key]; ok && route.state == builderRouteInFlight {
+ delete(s.routes, key)
+ }
+}
+
+func (s *builderRouteStore) pruneExpired(now time.Time) {
+ for key, route := range s.routes {
+ if route.state != builderRouteInFlight && !now.Before(route.expiresAt) {
+ delete(s.routes, key)
+ }
+ }
+}
diff --git a/cl/beacon/handler/builder_route_store_test.go b/cl/beacon/handler/builder_route_store_test.go
new file mode 100644
index 00000000000..06a729edb7a
--- /dev/null
+++ b/cl/beacon/handler/builder_route_store_test.go
@@ -0,0 +1,162 @@
+// Copyright 2026 The Erigon Authors
+// This file is part of Erigon.
+//
+// Erigon is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Erigon is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with Erigon. If not, see .
+
+package handler
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/erigontech/erigon/cl/beacon/beaconhttp"
+ "github.com/erigontech/erigon/common"
+)
+
+func TestBuilderRouteStoreReaddingDeliveredRouteDoesNotRearm(t *testing.T) {
+ now := time.Unix(100, 0)
+ routes := newBuilderRouteStore(2, time.Minute, func() time.Time { return now })
+ root := common.Hash{1}
+ url := "https://builder.example"
+
+ require.True(t, routes.Add(root, url))
+ require.True(t, routes.Claim(root, url))
+ routes.Complete(root, url, true)
+ require.True(t, routes.Add(root, url))
+ require.False(t, routes.Claim(root, url))
+}
+
+func TestBuilderWinningResponseFailsWhenRouteCapacityIsFull(t *testing.T) {
+ handler := &ApiHandler{builderRoutes: newBuilderRouteStore(1, time.Minute, time.Now)}
+ require.True(t, handler.builderRoutes.Add(common.Hash{1}, "https://one.example"))
+ recorder := httptest.NewRecorder()
+
+ err := handler.setBuilderRouteHeader(recorder, common.Hash{2}, "https://two.example")
+
+ require.Error(t, err)
+ var endpointErr *beaconhttp.EndpointError
+ require.True(t, errors.As(err, &endpointErr))
+ require.Equal(t, http.StatusServiceUnavailable, endpointErr.Code)
+ require.Empty(t, recorder.Header().Get("Eth-Builder-Url"))
+}
+
+func TestBuilderRouteStoreAllowsAliasesForSameRoot(t *testing.T) {
+ routes := newBuilderRouteStore(2, time.Minute, time.Now)
+ root := common.Hash{1}
+
+ require.True(t, routes.Add(root, "https://one.example"))
+ require.True(t, routes.Add(root, "https://two.example"))
+ require.True(t, routes.Claim(root, "https://one.example"))
+ require.True(t, routes.Claim(root, "https://two.example"))
+}
+
+func TestBuilderRouteStoreClaimOrAddIsBoundedAndSingleflight(t *testing.T) {
+ routes := newBuilderRouteStore(1, time.Minute, time.Now)
+ root := common.Hash{1}
+ url := "https://builder.example"
+
+ require.True(t, routes.ClaimOrAdd(root, url))
+ require.False(t, routes.ClaimOrAdd(root, url))
+ require.False(t, routes.ClaimOrAdd(common.Hash{2}, "https://other.example"))
+ routes.Complete(root, url, false)
+ require.True(t, routes.ClaimOrAdd(root, url))
+ routes.Complete(root, url, true)
+ require.False(t, routes.ClaimOrAdd(root, url))
+}
+
+func TestBuilderRouteStoreCapacityPreservesAcceptedRoutes(t *testing.T) {
+ routes := newBuilderRouteStore(2, time.Minute, time.Now)
+
+ require.True(t, routes.Add(common.Hash{1}, "https://one.example"))
+ require.True(t, routes.Add(common.Hash{2}, "https://two.example"))
+ require.False(t, routes.Add(common.Hash{3}, "https://three.example"))
+ require.True(t, routes.Claim(common.Hash{1}, "https://one.example"))
+ require.True(t, routes.Claim(common.Hash{2}, "https://two.example"))
+ require.False(t, routes.Claim(common.Hash{3}, "https://three.example"))
+}
+
+func TestBuilderRouteStoreExpiryFreesCapacity(t *testing.T) {
+ now := time.Unix(100, 0)
+ routes := newBuilderRouteStore(1, time.Minute, func() time.Time { return now })
+
+ require.True(t, routes.Add(common.Hash{1}, "https://one.example"))
+ require.False(t, routes.Add(common.Hash{2}, "https://two.example"))
+ now = now.Add(time.Minute)
+ require.True(t, routes.Add(common.Hash{2}, "https://two.example"))
+ require.False(t, routes.Claim(common.Hash{1}, "https://one.example"))
+ require.True(t, routes.Claim(common.Hash{2}, "https://two.example"))
+}
+
+func TestBuilderRouteStoreEvictsDeliveredRouteBeforeRejectingPromise(t *testing.T) {
+ routes := newBuilderRouteStore(1, time.Minute, time.Now)
+ firstRoot := common.Hash{1}
+ require.True(t, routes.Add(firstRoot, "https://one.example"))
+ require.True(t, routes.Claim(firstRoot, "https://one.example"))
+ routes.Complete(firstRoot, "https://one.example", true)
+
+ require.True(t, routes.Add(common.Hash{2}, "https://two.example"))
+ require.False(t, routes.Claim(firstRoot, "https://one.example"))
+ require.True(t, routes.Claim(common.Hash{2}, "https://two.example"))
+}
+
+func TestBuilderRouteStoreEvictsOldestDeliveredRoute(t *testing.T) {
+ now := time.Unix(100, 0)
+ routes := newBuilderRouteStore(2, time.Minute, func() time.Time { return now })
+ first := builderRouteKey{root: common.Hash{1}, url: "https://one.example"}
+ second := builderRouteKey{root: common.Hash{2}, url: "https://two.example"}
+ require.True(t, routes.Add(first.root, first.url))
+ require.True(t, routes.Claim(first.root, first.url))
+ routes.Complete(first.root, first.url, true)
+ now = now.Add(time.Second)
+ require.True(t, routes.Add(second.root, second.url))
+ require.True(t, routes.Claim(second.root, second.url))
+ routes.Complete(second.root, second.url, true)
+
+ require.True(t, routes.Add(common.Hash{3}, "https://three.example"))
+ _, firstExists := routes.routes[first]
+ _, secondExists := routes.routes[second]
+ require.False(t, firstExists)
+ require.True(t, secondExists)
+}
+
+func TestBuilderRouteStoreEqualExpiryUsesKeyTieBreak(t *testing.T) {
+ for _, reverse := range []bool{false, true} {
+ t.Run(fmt.Sprint("reverse=", reverse), func(t *testing.T) {
+ routes := newBuilderRouteStore(2, time.Minute, func() time.Time { return time.Unix(100, 0) })
+ lower := builderRouteKey{root: common.Hash{1}, url: "https://same.example"}
+ higher := builderRouteKey{root: common.Hash{2}, url: "https://same.example"}
+ keys := []builderRouteKey{lower, higher}
+ if reverse {
+ keys[0], keys[1] = keys[1], keys[0]
+ }
+ for _, key := range keys {
+ require.True(t, routes.Add(key.root, key.url))
+ require.True(t, routes.Claim(key.root, key.url))
+ routes.Complete(key.root, key.url, true)
+ }
+
+ require.True(t, routes.Add(common.Hash{3}, "https://three.example"))
+ _, lowerExists := routes.routes[lower]
+ _, higherExists := routes.routes[higher]
+ require.False(t, lowerExists)
+ require.True(t, higherExists)
+ })
+ }
+}
diff --git a/cl/beacon/handler/epbs.go b/cl/beacon/handler/epbs.go
index 23bb79fc889..6e74a3bb378 100644
--- a/cl/beacon/handler/epbs.go
+++ b/cl/beacon/handler/epbs.go
@@ -19,6 +19,7 @@ package handler
import (
"bytes"
"cmp"
+ "context"
"encoding/json"
"errors"
"fmt"
@@ -28,10 +29,13 @@ import (
"slices"
"strconv"
+ "github.com/erigontech/erigon/cl/beacon/beaconevents"
"github.com/erigontech/erigon/cl/beacon/beaconhttp"
"github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/cltypes"
"github.com/erigontech/erigon/cl/cltypes/solid"
+ "github.com/erigontech/erigon/cl/das"
+ peerdasutils "github.com/erigontech/erigon/cl/das/utils"
"github.com/erigontech/erigon/cl/gossip"
"github.com/erigontech/erigon/cl/phase1/core/state"
"github.com/erigontech/erigon/cl/phase1/forkchoice"
@@ -174,29 +178,32 @@ func (a *ApiHandler) PostEthV1ValidatorDutiesPtc(w http.ResponseWriter, r *http.
// ---- Payload Attestation Data ----
-// payloadAttestationDataResponse matches the PayloadAttestationData spec type.
-type payloadAttestationDataResponse struct {
- BeaconBlockRoot common.Hash `json:"beacon_block_root"`
- Slot uint64 `json:"slot,string"`
- PayloadPresent bool `json:"payload_present"`
- BlobDataAvailable bool `json:"blob_data_available"`
-}
-
// GetEthV1ValidatorPayloadAttestationData returns PayloadAttestationData for PTC validators.
-// GET /eth/v1/validator/payload_attestation_data/{slot}
// [New in Gloas:EIP7732]
func (a *ApiHandler) GetEthV1ValidatorPayloadAttestationData(w http.ResponseWriter, r *http.Request) (*beaconhttp.BeaconResponse, error) {
+ if a.syncedData.Syncing() {
+ return nil, beaconhttp.NewEndpointError(http.StatusServiceUnavailable, errors.New("beacon node is syncing"))
+ }
slotStr, err := beaconhttp.StringFromRequest(r, "slot")
if err != nil {
return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err)
}
+ if slotStr == "" {
+ slotValues, ok := r.URL.Query()["slot"]
+ if !ok || len(slotValues) != 1 || slotValues[0] == "" {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("slot query parameter is required exactly once"))
+ }
+ slotStr = slotValues[0]
+ }
slot, err := strconv.ParseUint(slotStr, 10, 64)
if err != nil {
return nil, beaconhttp.NewEndpointError(http.StatusBadRequest,
fmt.Errorf("invalid slot: %w", err))
}
-
// Must be GLOAS epoch
+ if a.beaconChainCfg.SlotsPerEpoch == 0 {
+ return nil, beaconhttp.NewEndpointError(http.StatusServiceUnavailable, errors.New("slots per epoch is zero"))
+ }
epoch := slot / a.beaconChainCfg.SlotsPerEpoch
if epoch < a.beaconChainCfg.GloasForkEpoch {
return nil, beaconhttp.NewEndpointError(http.StatusBadRequest,
@@ -204,15 +211,14 @@ func (a *ApiHandler) GetEthV1ValidatorPayloadAttestationData(w http.ResponseWrit
}
// Get the beacon block root for this slot from fork choice
- headRoot, headSlot, _, err := a.getSelectedHead()
+ headRoot, headSlot, statusCode, err := a.getSelectedHead()
if err != nil {
- return nil, err
+ return nil, beaconhttp.NewEndpointError(statusCode, err)
}
// The PTC attests to the current slot's block
if slot != headSlot {
- return nil, beaconhttp.NewEndpointError(http.StatusNotFound,
- fmt.Errorf("payload attestation data only available for head slot %d, requested %d", headSlot, slot))
+ return beaconhttp.NewNoContentResponse(), nil
}
// Check payload status: has the execution payload envelope been received?
@@ -224,7 +230,7 @@ func (a *ApiHandler) GetEthV1ValidatorPayloadAttestationData(w http.ResponseWrit
// (b) all local custody columns are present per PeerDAS.
blobDataAvailable := a.forkchoiceStore.IsBlobDataAvailable(slot, headRoot)
- return newBeaconResponse(payloadAttestationDataResponse{
+ return newBeaconResponse(&cltypes.PayloadAttestationData{
BeaconBlockRoot: headRoot,
Slot: slot,
PayloadPresent: payloadPresent,
@@ -466,6 +472,10 @@ func payloadAttestationPTCPositions(ptc []uint64) map[uint64][]int {
// Accepts application/json or application/octet-stream (SSZ).
// [New in Gloas:EIP7732]
func (a *ApiHandler) PostEthV1BeaconPoolPayloadAttestations(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Eth-Consensus-Version") != clparams.GloasVersion.String() {
+ beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("Gloas Eth-Consensus-Version header is required")).WriteTo(w)
+ return
+ }
var req []*cltypes.PayloadAttestationMessage
contentType, err := requestContentType(r)
@@ -486,7 +496,7 @@ func (a *ApiHandler) PostEthV1BeaconPoolPayloadAttestations(w http.ResponseWrite
msgSize := (&cltypes.PayloadAttestationMessage{
Data: new(cltypes.PayloadAttestationData),
}).EncodingSizeSSZ()
- if len(octets) == 0 || len(octets)%msgSize != 0 {
+ if len(octets)%msgSize != 0 {
beaconhttp.NewEndpointError(http.StatusBadRequest,
fmt.Errorf("SSZ body length %d is not a multiple of PayloadAttestationMessage size %d", len(octets), msgSize)).WriteTo(w)
return
@@ -495,7 +505,7 @@ func (a *ApiHandler) PostEthV1BeaconPoolPayloadAttestations(w http.ResponseWrite
req = make([]*cltypes.PayloadAttestationMessage, 0, count)
for i := range count {
msg := &cltypes.PayloadAttestationMessage{}
- if err := msg.DecodeSSZ(octets[i*msgSize:(i+1)*msgSize], int(clparams.GloasVersion)); err != nil {
+ if err := msg.DecodeSSZStrict(octets[i*msgSize:(i+1)*msgSize], int(clparams.GloasVersion)); err != nil {
beaconhttp.NewEndpointError(http.StatusBadRequest,
fmt.Errorf("failed to decode SSZ PayloadAttestationMessage at index %d: %w", i, err)).WriteTo(w)
return
@@ -503,10 +513,20 @@ func (a *ApiHandler) PostEthV1BeaconPoolPayloadAttestations(w http.ResponseWrite
req = append(req, msg)
}
case "application/json":
- if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxEpbsJSONSize)).Decode(&req); err != nil {
+ decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxEpbsJSONSize))
+ if err := decoder.Decode(&req); err != nil {
beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w)
return
}
+ if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) {
+ beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("request body contains trailing data")).WriteTo(w)
+ return
+ }
+ if uint64(len(req)) > a.beaconChainCfg.PtcSize {
+ beaconhttp.NewEndpointError(http.StatusBadRequest,
+ fmt.Errorf("payload attestation count %d exceeds %d", len(req), a.beaconChainCfg.PtcSize)).WriteTo(w)
+ return
+ }
default:
beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType,
fmt.Errorf("unsupported content type: %s", r.Header.Get("Content-Type"))).WriteTo(w)
@@ -525,38 +545,35 @@ func (a *ApiHandler) PostEthV1BeaconPoolPayloadAttestations(w http.ResponseWrite
// Validate via PayloadAttestationService (handles dedup, clock disparity, pending queue,
// and delegates to forkchoice.OnPayloadAttestationMessage for signature + PTC checks)
- if a.payloadAttestationService != nil {
- if err := a.payloadAttestationService.ProcessMessage(r.Context(), nil, msg); err != nil {
- if errors.Is(err, clservices.ErrAttestationQueued) {
- continue
- }
- failures = append(failures, poolingFailure{
- Index: i,
- Message: err.Error(),
- })
- continue
- }
+ if a.payloadAttestationService == nil {
+ failures = append(failures, poolingFailure{Index: i, Message: "payload attestation validation unavailable"})
+ continue
}
-
- // Store in pool for GET endpoint serving
- if a.epbsPool != nil {
- a.epbsPool.PayloadAttestations.Add(pool.PayloadAttestationKey{
- Slot: msg.Data.Slot,
- ValidatorIndex: msg.ValidatorIndex,
- }, msg)
+ encodedSSZ, err := msg.EncodeSSZ(nil)
+ if err != nil {
+ beaconhttp.NewEndpointError(http.StatusInternalServerError, err).WriteTo(w)
+ return
}
-
- // Broadcast to gossip
- if a.sentinel != nil {
- encodedSSZ, err := msg.EncodeSSZ(nil)
- if err != nil {
+ publishFailed := false
+ if err := a.payloadAttestationService.ProcessRESTMessage(r.Context(), msg, func() error {
+ err := a.publishGossip(r.Context(), gossip.TopicNamePayloadAttestation, encodedSSZ)
+ publishFailed = err != nil
+ return err
+ }); err != nil {
+ if publishFailed {
beaconhttp.NewEndpointError(http.StatusInternalServerError, err).WriteTo(w)
return
}
- if err := a.gossipManager.Publish(r.Context(), gossip.TopicNamePayloadAttestation, encodedSSZ); err != nil {
- a.logger.Debug("[Beacon REST] failed to publish payload attestation to gossip", "err", err)
+ if errors.Is(err, clservices.ErrAttestationDuplicate) {
+ continue
}
+ failures = append(failures, poolingFailure{
+ Index: i,
+ Message: err.Error(),
+ })
+ continue
}
+
}
if len(failures) > 0 {
@@ -611,7 +628,7 @@ func (a *ApiHandler) GetEthV1BeaconPoolProposerPreferences(w http.ResponseWriter
// Accepts application/json or application/octet-stream (SSZ).
// [New in Gloas:EIP7732]
func (a *ApiHandler) PostEthV1BeaconPoolProposerPreferences(w http.ResponseWriter, r *http.Request) {
- reqs, ok := decodeProposerPreferencesRequest(w, r)
+ reqs, ok := decodeProposerPreferencesRequest(w, r, false)
if !ok {
return
}
@@ -622,14 +639,18 @@ func (a *ApiHandler) PostEthV1BeaconPoolProposerPreferences(w http.ResponseWrite
// POST /eth/v1/validator/proposer_preferences
// [New in Gloas:EIP7732]
func (a *ApiHandler) PostEthV1ValidatorProposerPreferences(w http.ResponseWriter, r *http.Request) {
- reqs, ok := decodeProposerPreferencesRequest(w, r)
+ if r.Header.Get("Eth-Consensus-Version") != clparams.GloasVersion.String() {
+ beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("Gloas Eth-Consensus-Version header is required")).WriteTo(w)
+ return
+ }
+ reqs, ok := decodeProposerPreferencesRequest(w, r, true)
if !ok {
return
}
a.postProposerPreferences(w, r, reqs)
}
-func decodeProposerPreferencesRequest(w http.ResponseWriter, r *http.Request) ([]*cltypes.SignedProposerPreferences, bool) {
+func decodeProposerPreferencesRequest(w http.ResponseWriter, r *http.Request, canonical bool) ([]*cltypes.SignedProposerPreferences, bool) {
contentType, err := requestContentType(r)
if err != nil {
beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType, err).WriteTo(w)
@@ -669,8 +690,17 @@ func decodeProposerPreferencesRequest(w http.ResponseWriter, r *http.Request) ([
}
var reqs []*cltypes.SignedProposerPreferences
if err := json.Unmarshal(body, &reqs); err == nil {
+ if len(reqs) > maxProposerPreferencesRequestItems {
+ beaconhttp.NewEndpointError(http.StatusBadRequest,
+ fmt.Errorf("proposer preferences count %d exceeds %d", len(reqs), maxProposerPreferencesRequestItems)).WriteTo(w)
+ return nil, false
+ }
return reqs, true
}
+ if canonical {
+ beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("proposer preferences request must be a JSON array")).WriteTo(w)
+ return nil, false
+ }
req := &cltypes.SignedProposerPreferences{}
if err := json.Unmarshal(body, req); err != nil {
beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w)
@@ -689,17 +719,17 @@ func (a *ApiHandler) postProposerPreferences(w http.ResponseWriter, r *http.Requ
beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("empty proposer preferences request")).WriteTo(w)
return
}
+ failures := make([]poolingFailure, 0)
for i, req := range reqs {
if req == nil || req.Message == nil {
- beaconhttp.NewEndpointError(http.StatusBadRequest,
- fmt.Errorf("missing message in signed proposer preferences at index %d", i)).WriteTo(w)
- return
+ failures = append(failures, poolingFailure{Index: i, Message: "missing message in signed proposer preferences"})
+ continue
}
if a.proposerPreferencesService != nil {
if err := a.proposerPreferencesService.ProcessMessage(r.Context(), nil, req); err != nil {
- beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w)
- return
+ failures = append(failures, poolingFailure{Index: i, Message: err.Error()})
+ continue
}
}
@@ -721,7 +751,10 @@ func (a *ApiHandler) postProposerPreferences(w http.ResponseWriter, r *http.Requ
}
}
}
-
+ if len(failures) != 0 {
+ a.writePoolingFailures(w, failures)
+ return
+ }
w.WriteHeader(http.StatusOK)
}
@@ -763,7 +796,9 @@ func (a *ApiHandler) GetEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrite
}
slot := block.Block.Slot
epoch := slot / a.beaconChainCfg.SlotsPerEpoch
- isFinalized := slot <= a.forkchoiceStore.FinalizedSlot()
+ finalized := a.forkchoiceStore.FinalizedCheckpoint()
+ finalizedSlot := finalized.Epoch * a.beaconChainCfg.SlotsPerEpoch
+ isFinalized := slot <= finalizedSlot && a.forkchoiceStore.Ancestor(finalized.Root, slot).Root == blockRoot
return newBeaconResponse(envelope).
WithVersion(a.beaconChainCfg.GetCurrentStateVersion(epoch)).
WithOptimistic(a.forkchoiceStore.IsRootOptimistic(blockRoot)).
@@ -776,34 +811,45 @@ func (a *ApiHandler) GetEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrite
// The envelope is processed through forkchoice and broadcast on gossip.
// [New in Gloas:EIP7732]
func (a *ApiHandler) PostEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWriter, r *http.Request) {
- signedEnvelope := &cltypes.SignedExecutionPayloadEnvelope{
- Message: cltypes.NewExecutionPayloadEnvelope(a.beaconChainCfg),
- }
+ a.postEthV1BeaconExecutionPayloadEnvelope(w, r, true)
+}
+func (a *ApiHandler) postEthV1BeaconExecutionPayloadEnvelopeLegacy(w http.ResponseWriter, r *http.Request) {
+ a.postEthV1BeaconExecutionPayloadEnvelope(w, r, false)
+}
+
+func (a *ApiHandler) postEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWriter, r *http.Request, canonical bool) {
contentType, err := requestContentType(r)
if err != nil {
beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType, err).WriteTo(w)
return
}
- switch contentType {
- case "application/json":
- if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxExecutionPayloadEnvelopeRequestSize)).Decode(signedEnvelope); err != nil {
- beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w)
+ blobDataIncluded := false
+ validation := BlockPublishingValidationGossip
+ if canonical {
+ if r.Header.Get("Eth-Consensus-Version") != clparams.GloasVersion.String() {
+ beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("Gloas Eth-Consensus-Version header is required")).WriteTo(w)
return
}
- case "application/octet-stream":
- octect, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxExecutionPayloadEnvelopeRequestSize))
+ value := r.Header.Get("Eth-Blob-Data-Included")
+ if value == "" {
+ beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("Eth-Blob-Data-Included header is required")).WriteTo(w)
+ return
+ }
+ blobDataIncluded, err = strconv.ParseBool(value)
if err != nil {
- beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w)
+ beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("invalid Eth-Blob-Data-Included: %w", err)).WriteTo(w)
return
}
- if err := signedEnvelope.DecodeSSZ(octect, int(clparams.GloasVersion)); err != nil {
+ validation, err = a.parseBlockPublishingValidation(r, 2)
+ if err != nil {
beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w)
return
}
- default:
- beaconhttp.NewEndpointError(http.StatusUnsupportedMediaType,
- fmt.Errorf("unsupported content type: %s", r.Header.Get("Content-Type"))).WriteTo(w)
+ }
+ signedEnvelope, contents, err := a.decodeExecutionPayloadEnvelopeRequest(w, r, contentType, blobDataIncluded)
+ if err != nil {
+ beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w)
return
}
@@ -811,32 +857,294 @@ func (a *ApiHandler) PostEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit
beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("missing message in signed envelope")).WriteTo(w)
return
}
+ if validation == BlockPublishingValidationConsensusAndEquivocation {
+ block, ok := a.forkchoiceStore.GetBlock(signedEnvelope.Message.BeaconBlockRoot)
+ if !ok || block == nil || block.Block == nil {
+ beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("execution payload envelope block is unavailable")).WriteTo(w)
+ return
+ }
+ if a.forkchoiceStore.HasBlockEquivocation(block.Block.Slot, block.Block.ProposerIndex, signedEnvelope.Message.BeaconBlockRoot) {
+ beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("execution payload envelope block has an equivocation")).WriteTo(w)
+ return
+ }
+ }
+ if validation == BlockPublishingValidationGossip {
+ if err := a.forkchoiceStore.ValidateExecutionPayloadEnvelope(signedEnvelope); err != nil {
+ beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w)
+ return
+ }
+ }
+ contentsIntegrationFailed := false
+ if contents != nil {
+ if err := a.validateAndStoreExecutionPayloadEnvelopeContents(r.Context(), contents); err != nil {
+ if !errors.Is(err, errExecutionPayloadEnvelopeIntegration) {
+ beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w)
+ return
+ }
+ contentsIntegrationFailed = true
+ }
+ }
- // Process through forkchoice so the local node marks the block as FULL.
- // checkBlobData=false because gossip validation handles it; validatePayload=true
- // so the EL receives NewPayload for the execution payload.
- if err := a.forkchoiceStore.OnExecutionPayload(r.Context(), signedEnvelope, false, true); err != nil {
- if errors.Is(err, forkchoice.ErrIgnore) || errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) {
+ status := http.StatusOK
+ if contentsIntegrationFailed {
+ status = http.StatusAccepted
+ }
+ gossipValidated := false
+ emitGossipEvent := false
+ if err := a.forkchoiceStore.OnExecutionPayload(r.Context(), signedEnvelope, canonical, true); err != nil {
+ if canonical && !blobDataIncluded && errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) {
+ beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w)
+ return
+ }
+ if canonical && validation != BlockPublishingValidationGossip {
+ beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w)
+ return
+ }
+ switch {
+ case errors.Is(err, forkchoice.ErrIgnore):
+ persisted, _ := a.forkchoiceStore.ReadEnvelopeFromDisk(signedEnvelope.Message.BeaconBlockRoot)
+ if !signedExecutionPayloadEnvelopesEqual(persisted, signedEnvelope) {
+ if canonical {
+ beaconhttp.NewEndpointError(http.StatusServiceUnavailable, err).WriteTo(w)
+ return
+ }
+ status = http.StatusAccepted
+ break
+ }
+ a.logger.Debug("[Beacon REST] OnExecutionPayload queued or ignored", "err", err)
+ status = http.StatusAccepted
+ gossipValidated = true
+ case errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable):
a.logger.Debug("[Beacon REST] OnExecutionPayload queued or ignored", "err", err)
- } else {
+ status = http.StatusAccepted
+ gossipValidated = true
+ emitGossipEvent = true
+ case canonical && validation == BlockPublishingValidationGossip:
+ status = http.StatusAccepted
+ gossipValidated = true
+ emitGossipEvent = true
+ case canonical:
+ beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w)
+ return
+ default:
beaconhttp.WrapEndpointError(err).WriteTo(w)
return
}
+ } else {
+ gossipValidated = true
+ emitGossipEvent = true
+ }
+ if gossipValidated && (canonical || a.sentinel != nil) && validation == BlockPublishingValidationConsensusAndEquivocation {
+ block, ok := a.forkchoiceStore.GetBlock(signedEnvelope.Message.BeaconBlockRoot)
+ if !ok || block == nil || block.Block == nil || a.forkchoiceStore.HasBlockEquivocation(block.Block.Slot, block.Block.ProposerIndex, signedEnvelope.Message.BeaconBlockRoot) {
+ beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("execution payload envelope block has an equivocation")).WriteTo(w)
+ return
+ }
+ }
+ if emitGossipEvent && (canonical || a.sentinel != nil) && a.emitters != nil && signedEnvelope.Message.Payload != nil {
+ block, ok := a.forkchoiceStore.GetBlock(signedEnvelope.Message.BeaconBlockRoot)
+ if ok && block != nil && block.Block != nil {
+ a.emitters.Operation().SendExecutionPayloadGossip(&beaconevents.ExecutionPayloadGossipData{
+ Slot: block.Block.Slot, BuilderIndex: signedEnvelope.Message.BuilderIndex,
+ BlockHash: signedEnvelope.Message.Payload.BlockHash, BlockRoot: signedEnvelope.Message.BeaconBlockRoot,
+ })
+ }
+ }
+ if status == http.StatusOK && a.emitters != nil {
+ block, ok := a.forkchoiceStore.GetBlock(signedEnvelope.Message.BeaconBlockRoot)
+ if ok && block != nil && block.Block != nil && signedEnvelope.Message.Payload != nil {
+ a.emitters.Operation().SendExecutionPayload(&beaconevents.ExecutionPayloadData{
+ Slot: block.Block.Slot, BuilderIndex: signedEnvelope.Message.BuilderIndex,
+ BlockHash: signedEnvelope.Message.Payload.BlockHash, BlockRoot: signedEnvelope.Message.BeaconBlockRoot,
+ ExecutionOptimistic: a.forkchoiceStore.IsRootOptimistic(signedEnvelope.Message.BeaconBlockRoot),
+ })
+ a.emitters.Operation().SendExecutionPayloadAvailable(&beaconevents.ExecutionPayloadAvailableData{
+ Slot: block.Block.Slot, BlockRoot: signedEnvelope.Message.BeaconBlockRoot,
+ })
+ a.emitFullHeadV2(block, signedEnvelope.Message.BeaconBlockRoot)
+ }
}
- // Broadcast the envelope on the execution_payload gossip topic
- if a.sentinel != nil {
+ if gossipValidated && (canonical || a.sentinel != nil) {
encodedSSZ, err := signedEnvelope.EncodeSSZ(nil)
if err != nil {
beaconhttp.NewEndpointError(http.StatusInternalServerError, err).WriteTo(w)
return
}
- if err := a.gossipManager.Publish(r.Context(), gossip.TopicNameExecutionPayload, encodedSSZ); err != nil {
- a.logger.Debug("[Beacon REST] failed to publish execution payload envelope to gossip", "err", err)
+ if err := a.publishGossip(r.Context(), gossip.TopicNameExecutionPayload, encodedSSZ); err != nil {
+ beaconhttp.NewEndpointError(http.StatusInternalServerError, err).WriteTo(w)
+ return
}
}
- w.WriteHeader(http.StatusOK)
+ w.WriteHeader(status)
+}
+
+func signedExecutionPayloadEnvelopesEqual(left, right *cltypes.SignedExecutionPayloadEnvelope) bool {
+ if left == nil || right == nil {
+ return false
+ }
+ leftSSZ, leftErr := left.EncodeSSZ(nil)
+ rightSSZ, rightErr := right.EncodeSSZ(nil)
+ return leftErr == nil && rightErr == nil && bytes.Equal(leftSSZ, rightSSZ)
+}
+
+func (a *ApiHandler) emitFullHeadV2(block *cltypes.SignedBeaconBlock, blockRoot common.Hash) {
+ headRoot, headSlot, err := a.forkchoiceStore.GetHead(nil)
+ if err != nil || headRoot != blockRoot || a.beaconChainCfg.SlotsPerEpoch == 0 {
+ return
+ }
+ payloadStatus := a.forkchoiceStore.GetHeadPayloadStatus()
+ if payloadStatus != cltypes.PayloadStatusFull {
+ return
+ }
+ optimistic := a.forkchoiceStore.IsRootOptimistic(blockRoot)
+ headState, err := a.forkchoiceStore.GetStateAtBlockRoot(blockRoot, true)
+ if err != nil || headState == nil {
+ return
+ }
+ event, err := beaconevents.BuildHeadV2Data(a.beaconChainCfg, headState, headSlot, headRoot, block.Block.StateRoot, "full", optimistic)
+ if err != nil {
+ return
+ }
+ a.emitters.WithHeadEventLock(func() {
+ currentRoot, currentSlot, err := a.forkchoiceStore.GetHead(nil)
+ if err != nil || currentRoot != headRoot || currentSlot != headSlot ||
+ a.forkchoiceStore.GetHeadPayloadStatus() != payloadStatus ||
+ a.forkchoiceStore.IsRootOptimistic(currentRoot) != optimistic {
+ return
+ }
+ a.emitters.State().SendHeadV2(event)
+ })
+}
+
+func (a *ApiHandler) decodeExecutionPayloadEnvelopeRequest(w http.ResponseWriter, r *http.Request, contentType string, blobDataIncluded bool) (*cltypes.SignedExecutionPayloadEnvelope, *cltypes.SignedExecutionPayloadEnvelopeContents, error) {
+ signedEnvelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(a.beaconChainCfg)}
+ var contents *cltypes.SignedExecutionPayloadEnvelopeContents
+ if blobDataIncluded {
+ contents = cltypes.NewSignedExecutionPayloadEnvelopeContents(a.beaconChainCfg, a.ethClock.GetCurrentSlot())
+ }
+ switch contentType {
+ case "application/json":
+ target := any(signedEnvelope)
+ if contents != nil {
+ target = contents
+ }
+ decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxExecutionPayloadEnvelopeRequestSize))
+ if err := decoder.Decode(target); err != nil {
+ return nil, nil, err
+ }
+ if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) {
+ return nil, nil, errors.New("request body contains trailing data")
+ }
+ case "application/octet-stream":
+ octets, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxExecutionPayloadEnvelopeRequestSize))
+ if err != nil {
+ return nil, nil, err
+ }
+ if contents != nil {
+ if err := contents.DecodeSSZStrict(octets, int(clparams.GloasVersion)); err != nil {
+ return nil, nil, err
+ }
+ } else if err := signedEnvelope.DecodeSSZStrict(octets, int(clparams.GloasVersion)); err != nil {
+ return nil, nil, err
+ }
+ default:
+ return nil, nil, fmt.Errorf("unsupported content type: %s", r.Header.Get("Content-Type"))
+ }
+ if contents != nil {
+ signedEnvelope = contents.SignedExecutionPayloadEnvelope
+ }
+ return signedEnvelope, contents, nil
+}
+
+func (a *ApiHandler) validateAndStoreExecutionPayloadEnvelopeContents(ctx context.Context, contents *cltypes.SignedExecutionPayloadEnvelopeContents) error {
+ if contents == nil || contents.SignedExecutionPayloadEnvelope == nil {
+ return errors.New("execution payload envelope contents has nil envelope")
+ }
+ if err := a.forkchoiceStore.ValidateExecutionPayloadEnvelope(contents.SignedExecutionPayloadEnvelope); err != nil {
+ return err
+ }
+ return a.storeExecutionPayloadEnvelopeContents(ctx, contents)
+}
+
+var errExecutionPayloadEnvelopeIntegration = errors.New("execution payload envelope integration failed")
+
+func (a *ApiHandler) storeExecutionPayloadEnvelopeContents(ctx context.Context, contents *cltypes.SignedExecutionPayloadEnvelopeContents) error {
+ if contents == nil || contents.SignedExecutionPayloadEnvelope == nil || contents.SignedExecutionPayloadEnvelope.Message == nil {
+ return errors.New("execution payload envelope contents has nil envelope")
+ }
+ envelope := contents.SignedExecutionPayloadEnvelope.Message
+ block, ok := a.forkchoiceStore.GetBlock(envelope.BeaconBlockRoot)
+ if !ok || block == nil || block.Block == nil || block.Block.Body == nil {
+ return errors.New("execution payload envelope references an unknown block")
+ }
+ bid := block.Block.Body.GetSignedExecutionPayloadBid()
+ if bid == nil || bid.Message == nil {
+ return errors.New("execution payload envelope block has no bid")
+ }
+ commitments := &bid.Message.BlobKzgCommitments
+ if contents.Blobs == nil || contents.KZGProofs == nil || contents.Blobs.Len() != commitments.Len() ||
+ contents.KZGProofs.Len() != commitments.Len()*int(a.beaconChainCfg.NumberOfColumns) {
+ return errors.New("execution payload envelope blob, proof, and commitment counts do not match")
+ }
+ cellsAndProofs := make([]peerdasutils.CellsAndKZGProofs, 0, commitments.Len())
+ bundles := make([]BlobBundle, 0, commitments.Len())
+ for i := 0; i < commitments.Len(); i++ {
+ blob := contents.Blobs.Get(i)
+ commitment := commitments.Get(i)
+ if blob == nil || commitment == nil {
+ return fmt.Errorf("execution payload envelope blob %d is nil", i)
+ }
+ bundle := BlobBundle{Blob: blob, Commitment: common.Bytes48(*commitment), KzgProofs: make([]common.Bytes48, a.beaconChainCfg.NumberOfColumns)}
+ cells, err := das.ComputeCells(blob)
+ if err != nil {
+ return err
+ }
+ proofs := make([]cltypes.KZGProof, a.beaconChainCfg.NumberOfColumns)
+ for j := range proofs {
+ proof := contents.KZGProofs.Get(i*int(a.beaconChainCfg.NumberOfColumns) + j)
+ if proof == nil {
+ return fmt.Errorf("execution payload envelope proof %d is nil", j)
+ }
+ proofs[j] = *proof
+ bundle.KzgProofs[j] = common.Bytes48(*proof)
+ }
+ bundles = append(bundles, bundle)
+ cellsAndProofs = append(cellsAndProofs, peerdasutils.CellsAndKZGProofs{Blobs: cells, Proofs: proofs})
+ }
+ columns, err := peerdasutils.GetDataColumnSidecarsGloas(block.Block.Slot, envelope.BeaconBlockRoot, cellsAndProofs)
+ if err != nil {
+ return err
+ }
+ for _, column := range columns {
+ if !das.VerifyDataColumnSidecarKZGProofsWithCommitments(column, commitments) {
+ return fmt.Errorf("execution payload envelope column %d has invalid KZG proof", column.Index)
+ }
+ }
+ if len(columns) != 0 && a.columnStorage == nil {
+ return fmt.Errorf("%w: data column storage unavailable", errExecutionPayloadEnvelopeIntegration)
+ }
+ for _, column := range columns {
+ if err := a.columnStorage.WriteColumnSidecars(ctx, envelope.BeaconBlockRoot, int64(column.Index), column); err != nil {
+ return fmt.Errorf("%w: %w", errExecutionPayloadEnvelopeIntegration, err)
+ }
+ }
+ for _, bundle := range bundles {
+ a.blobBundles.Add(bundle.Commitment, bundle)
+ }
+ for _, column := range columns {
+ if a.sentinel != nil {
+ encoded, err := column.EncodeSSZ(nil)
+ if err != nil {
+ return err
+ }
+ if err := a.gossipManager.Publish(ctx, gossip.TopicNameDataColumnSidecar(das.ComputeSubnetForDataColumnSidecar(column.Index)), encoded); err != nil {
+ a.logger.Debug("failed to publish execution payload data column", "err", err)
+ }
+ }
+ }
+ return nil
}
// ---- Execution Payload Bid ----
@@ -845,6 +1153,18 @@ func (a *ApiHandler) PostEthV1BeaconExecutionPayloadEnvelope(w http.ResponseWrit
// POST /eth/v1/beacon/execution_payload_bid
// [New in Gloas:EIP7732]
func (a *ApiHandler) PostEthV1BeaconExecutionPayloadBid(w http.ResponseWriter, r *http.Request) {
+ a.postEthV1BeaconExecutionPayloadBid(w, r, true)
+}
+
+func (a *ApiHandler) postEthV1BeaconExecutionPayloadBidLegacy(w http.ResponseWriter, r *http.Request) {
+ a.postEthV1BeaconExecutionPayloadBid(w, r, false)
+}
+
+func (a *ApiHandler) postEthV1BeaconExecutionPayloadBid(w http.ResponseWriter, r *http.Request, canonical bool) {
+ if canonical && r.Header.Get("Eth-Consensus-Version") != clparams.GloasVersion.String() {
+ beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("Gloas Eth-Consensus-Version header is required")).WriteTo(w)
+ return
+ }
req := new(cltypes.SignedExecutionPayloadBid)
contentType, err := requestContentType(r)
if err != nil {
@@ -853,17 +1173,22 @@ func (a *ApiHandler) PostEthV1BeaconExecutionPayloadBid(w http.ResponseWriter, r
}
switch contentType {
case "application/json":
- if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxEpbsJSONSize)).Decode(req); err != nil {
+ decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxEpbsJSONSize))
+ if err := decoder.Decode(req); err != nil {
beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w)
return
}
+ if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) {
+ beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("request body contains trailing data")).WriteTo(w)
+ return
+ }
case "application/octet-stream":
octets, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxSignedExecutionPayloadBidSSZSize()))
if err != nil {
beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w)
return
}
- if err := req.DecodeSSZ(octets, int(clparams.GloasVersion)); err != nil {
+ if err := req.DecodeSSZStrict(octets, int(clparams.GloasVersion)); err != nil {
beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w)
return
}
@@ -880,11 +1205,8 @@ func (a *ApiHandler) PostEthV1BeaconExecutionPayloadBid(w http.ResponseWriter, r
// Validate via the bid service (checks signature, slot timing, proposer preferences, etc.)
if a.executionPayloadBidService != nil {
if err := a.executionPayloadBidService.ProcessMessage(r.Context(), nil, req); err != nil {
- if !errors.Is(err, clservices.ErrBidQueued) {
- beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w)
- return
- }
- a.logger.Debug("[Beacon REST] queued execution payload bid", "err", err)
+ beaconhttp.NewEndpointError(http.StatusBadRequest, err).WriteTo(w)
+ return
}
}
@@ -925,12 +1247,34 @@ func (a *ApiHandler) GetEthV1ValidatorExecutionPayloadBid(w http.ResponseWriter,
fmt.Errorf("invalid builder_index: %w", err))
}
+ if a.beaconChainCfg.SlotsPerEpoch == 0 {
+ return nil, beaconhttp.NewEndpointError(http.StatusServiceUnavailable, errors.New("slots per epoch is zero"))
+ }
// Must be GLOAS epoch
epoch := slot / a.beaconChainCfg.SlotsPerEpoch
if epoch < a.beaconChainCfg.GloasForkEpoch {
return nil, beaconhttp.NewEndpointError(http.StatusBadRequest,
fmt.Errorf("execution payload bids not available before GLOAS fork"))
}
+ currentSlot := a.ethClock.GetCurrentSlot()
+ if slot < currentSlot || slot-currentSlot > 1 {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest,
+ fmt.Errorf("execution payload bid slot %d is not current or next", slot))
+ }
+ registered := false
+ if a.syncedData != nil {
+ if err := a.syncedData.ViewHeadState(func(headState *state.CachingBeaconState) error {
+ builders := headState.GetBuilders()
+ registered = builders != nil && builderIndex < uint64(builders.Len()) && builders.Get(int(builderIndex)) != nil
+ return nil
+ }); err != nil {
+ return nil, beaconhttp.NewEndpointError(http.StatusServiceUnavailable, err)
+ }
+ }
+ if !registered {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest,
+ fmt.Errorf("builder index %d is not registered", builderIndex))
+ }
if a.epbsPool == nil {
return nil, beaconhttp.NewEndpointError(http.StatusServiceUnavailable,
@@ -999,15 +1343,10 @@ func (a *ApiHandler) GetEthV1ValidatorExecutionPayloadEnvelope(w http.ResponseWr
fmt.Errorf("execution payload envelopes not available before GLOAS fork"))
}
- // Look up the cached self-build envelope for this slot.
- envelope, ok := a.selfBuildEnvelopes.Get(slot)
+ envelope, ok := a.selfBuildEnvelopeForSlot(slot, func(envelope *cltypes.ExecutionPayloadEnvelope) bool {
+ return envelope.BuilderIndex == builderIndex
+ })
if !ok || envelope == nil {
- return nil, beaconhttp.NewEndpointError(http.StatusNotFound,
- fmt.Errorf("no execution payload envelope found for slot %d", slot))
- }
-
- // Validate that the requested builder_index matches the cached envelope.
- if envelope.BuilderIndex != builderIndex {
return nil, beaconhttp.NewEndpointError(http.StatusNotFound,
fmt.Errorf("no execution payload envelope found for slot %d with builder_index %d", slot, builderIndex))
}
@@ -1035,7 +1374,7 @@ func (a *ApiHandler) GetEthV1ValidatorExecutionPayloadEnvelopeBySlot(w http.Resp
fmt.Errorf("execution payload envelopes not available before GLOAS fork"))
}
- envelope, ok := a.selfBuildEnvelopes.Get(slot)
+ envelope, ok := a.selfBuildEnvelopeForSlot(slot, nil)
if !ok || envelope == nil {
return nil, beaconhttp.NewEndpointError(http.StatusNotFound,
fmt.Errorf("no execution payload envelope found for slot %d", slot))
@@ -1044,6 +1383,50 @@ func (a *ApiHandler) GetEthV1ValidatorExecutionPayloadEnvelopeBySlot(w http.Resp
return newBeaconResponse(envelope).WithVersion(a.beaconChainCfg.GetCurrentStateVersion(epoch)), nil
}
+func (a *ApiHandler) GetEthV1ValidatorExecutionPayloadEnvelopeByBlockRoot(w http.ResponseWriter, r *http.Request) (*beaconhttp.BeaconResponse, error) {
+ slotStr, err := beaconhttp.StringFromRequest(r, "slot")
+ if err != nil {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err)
+ }
+ slot, err := strconv.ParseUint(slotStr, 10, 64)
+ if err != nil {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("invalid slot: %w", err))
+ }
+ rootStr, err := beaconhttp.StringFromRequest(r, "beacon_block_root")
+ if err != nil {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, err)
+ }
+ var root common.Hash
+ if err := root.UnmarshalText([]byte(rootStr)); err != nil {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("invalid beacon_block_root: %w", err))
+ }
+ if slot != a.ethClock.GetCurrentSlot() {
+ return nil, beaconhttp.NewEndpointError(http.StatusNotFound, fmt.Errorf("execution payload envelope is only retained for the current slot"))
+ }
+ if slot/a.beaconChainCfg.SlotsPerEpoch < a.beaconChainCfg.GloasForkEpoch {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, fmt.Errorf("execution payload envelopes not available before GLOAS fork"))
+ }
+ envelope, ok := a.selfBuildEnvelopes.Get(selfBuildEnvelopeKey{Slot: slot, BeaconBlockRoot: root})
+ if !ok || envelope == nil {
+ return nil, beaconhttp.NewEndpointError(http.StatusNotFound, fmt.Errorf("no execution payload envelope found for slot %d and block root %s", slot, root))
+ }
+ return newBeaconResponse(envelope).WithVersion(clparams.GloasVersion), nil
+}
+
+func (a *ApiHandler) selfBuildEnvelopeForSlot(slot uint64, accept func(*cltypes.ExecutionPayloadEnvelope) bool) (*cltypes.ExecutionPayloadEnvelope, bool) {
+ keys := a.selfBuildEnvelopes.Keys()
+ for _, key := range slices.Backward(keys) {
+ if key.Slot != slot {
+ continue
+ }
+ envelope, ok := a.selfBuildEnvelopes.Get(key)
+ if ok && envelope != nil && (accept == nil || accept(envelope)) {
+ return envelope, true
+ }
+ }
+ return nil, false
+}
+
// ---- Helpers ----
// blockRootFromBlockId resolves a block_id to a block root hash.
diff --git a/cl/beacon/handler/epbs_test.go b/cl/beacon/handler/epbs_test.go
index 4d83473f30b..a62bf2145b6 100644
--- a/cl/beacon/handler/epbs_test.go
+++ b/cl/beacon/handler/epbs_test.go
@@ -17,6 +17,7 @@
package handler
import (
+ "bytes"
"context"
"encoding/json"
"errors"
@@ -24,28 +25,194 @@ import (
"net/http"
"net/http/httptest"
"strings"
+ "sync/atomic"
"testing"
+ "time"
"github.com/go-chi/chi/v5"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
+ "github.com/erigontech/erigon/cl/beacon/beaconevents"
+ sync_mock_services "github.com/erigontech/erigon/cl/beacon/synced_data/mock_services"
"github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/cltypes"
"github.com/erigontech/erigon/cl/cltypes/solid"
+ "github.com/erigontech/erigon/cl/gossip"
+ "github.com/erigontech/erigon/cl/persistence/beacon_indicies"
+ "github.com/erigontech/erigon/cl/phase1/core/state"
+ "github.com/erigontech/erigon/cl/phase1/forkchoice"
+ gossip_mock "github.com/erigontech/erigon/cl/phase1/network/gossip/mock_services"
"github.com/erigontech/erigon/cl/phase1/network/services"
mock_services "github.com/erigontech/erigon/cl/phase1/network/services/mock_services"
"github.com/erigontech/erigon/cl/pool"
"github.com/erigontech/erigon/cl/utils/bls"
"github.com/erigontech/erigon/common"
"github.com/erigontech/erigon/common/log/v3"
+ "github.com/erigontech/erigon/db/kv"
)
+func TestGetPayloadAttestationDataAcceptsCanonicalSlotQuery(t *testing.T) {
+ _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ handler.beaconChainCfg.GloasForkEpoch = 2
+ fcu.HeadSlotVal = 64
+ fcu.HeadVal = common.HexToHash("0x1234")
+
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/validator/payload_attestation_data?slot=64", http.NoBody)
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+ require.Equal(t, "gloas", recorder.Header().Get("Eth-Consensus-Version"))
+ require.Contains(t, recorder.Body.String(), `"slot":"64"`)
+}
+
+func TestGetPayloadAttestationDataSupportsSSZ(t *testing.T) {
+ _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ handler.beaconChainCfg.GloasForkEpoch = 2
+ fcu.HeadSlotVal = 64
+ fcu.HeadVal = common.HexToHash("0x1234")
+
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/validator/payload_attestation_data?slot=64", http.NoBody)
+ request.Header.Set("Accept", "application/octet-stream")
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+ require.Equal(t, "application/octet-stream", recorder.Header().Get("Content-Type"))
+ require.Equal(t, "gloas", recorder.Header().Get("Eth-Consensus-Version"))
+ data := new(cltypes.PayloadAttestationData)
+ require.NoError(t, data.DecodeSSZStrict(recorder.Body.Bytes(), int(clparams.GloasVersion)))
+ require.Equal(t, uint64(64), data.Slot)
+ require.Equal(t, fcu.HeadVal, data.BeaconBlockRoot)
+}
+
+func TestGetPayloadAttestationDataRejectsPreGloasSlot(t *testing.T) {
+ _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ handler.beaconChainCfg.GloasForkEpoch = 2
+ fcu.HeadSlotVal = 63
+
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/validator/payload_attestation_data?slot=63", http.NoBody)
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
+}
+
+func TestGetPayloadAttestationDataRejectsUnsupportedResponseType(t *testing.T) {
+ _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ handler.beaconChainCfg.GloasForkEpoch = 0
+ fcu.HeadSlotVal = 64
+
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/validator/payload_attestation_data?slot=64", http.NoBody)
+ request.Header.Set("Accept", "text/plain")
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusNotAcceptable, recorder.Code, recorder.Body.String())
+}
+
+func TestGetPayloadAttestationDataReturnsNoContentWithoutSlotBlock(t *testing.T) {
+ _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ handler.beaconChainCfg.GloasForkEpoch = 0
+ fcu.HeadSlotVal = 63
+
+ for _, accept := range []string{"application/json", "application/octet-stream"} {
+ t.Run(accept, func(t *testing.T) {
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/validator/payload_attestation_data?slot=64", http.NoBody)
+ request.Header.Set("Accept", accept)
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusNoContent, recorder.Code, recorder.Body.String())
+ require.Empty(t, recorder.Body.String())
+ })
+ }
+}
+
+func TestGetPayloadAttestationDataReportsSyncing(t *testing.T) {
+ for _, path := range []string{
+ "/eth/v1/validator/payload_attestation_data?slot=64",
+ "/eth/v1/validator/payload_attestation_data?slot=invalid",
+ "/eth/v1/validator/payload_attestation_data",
+ } {
+ t.Run(path, func(t *testing.T) {
+ _, _, _, _, _, handler, _, syncedData, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), false)
+ handler.beaconChainCfg.GloasForkEpoch = 0
+ syncedData.(*sync_mock_services.MockSyncedData).EXPECT().Syncing().Return(true)
+
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, http.NoBody))
+
+ require.Equal(t, http.StatusServiceUnavailable, recorder.Code, recorder.Body.String())
+ })
+ }
+}
+
+func TestGetPayloadAttestationDataPreservesUnavailableHeadStatus(t *testing.T) {
+ _, _, _, _, _, handler, _, syncedData, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), false)
+ handler.beaconChainCfg.GloasForkEpoch = 0
+ handler.enableMemoizedHeadState = true
+ mockSyncedData := syncedData.(*sync_mock_services.MockSyncedData)
+ mockSyncedData.EXPECT().Syncing().Return(false)
+ mockSyncedData.EXPECT().StateHead().Return(common.Hash{}, uint64(0), false)
+
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/validator/payload_attestation_data?slot=64", http.NoBody)
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusServiceUnavailable, recorder.Code, recorder.Body.String())
+}
+
+func TestGetPayloadAttestationDataRejectsInvalidCanonicalSlotQuery(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ handler.beaconChainCfg.GloasForkEpoch = 0
+
+ for _, path := range []string{
+ "/eth/v1/validator/payload_attestation_data",
+ "/eth/v1/validator/payload_attestation_data?slot=",
+ "/eth/v1/validator/payload_attestation_data?slot=64&slot=64",
+ "/eth/v1/validator/payload_attestation_data?slot=invalid",
+ "/eth/v1/validator/payload_attestation_data?slot=18446744073709551616",
+ } {
+ t.Run(path, func(t *testing.T) {
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, http.NoBody))
+ require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
+ })
+ }
+}
+
+func TestGetPayloadAttestationDataPreservesLegacyPathAlias(t *testing.T) {
+ _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ handler.beaconChainCfg.GloasForkEpoch = 0
+ fcu.HeadSlotVal = 64
+
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/validator/payload_attestation_data/64", http.NoBody)
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+}
+
+func TestGetPayloadAttestationDataAcceptsMaximumSlot(t *testing.T) {
+ _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ handler.beaconChainCfg.GloasForkEpoch = 0
+ fcu.HeadSlotVal = ^uint64(0)
+
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/validator/payload_attestation_data?slot=18446744073709551615", http.NoBody)
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+}
+
func TestPostPayloadAttestationsRejectsNullMessage(t *testing.T) {
_, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", strings.NewReader(`[null]`))
request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
recorder := httptest.NewRecorder()
handler.PostEthV1BeaconPoolPayloadAttestations(recorder, request)
@@ -54,6 +221,18 @@ func TestPostPayloadAttestationsRejectsNullMessage(t *testing.T) {
require.Contains(t, recorder.Body.String(), "missing payload attestation message data")
}
+func TestPostPayloadAttestationsRequiresGloasVersion(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ for _, version := range []string{"", "fulu"} {
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", strings.NewReader(`[]`))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Consensus-Version", version)
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, request)
+ require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
+ }
+}
+
func TestPostPayloadAttestationsRejectsOversizedSSZ(t *testing.T) {
_, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
msgSize := (&cltypes.PayloadAttestationMessage{Data: new(cltypes.PayloadAttestationData)}).EncodingSizeSSZ()
@@ -61,6 +240,7 @@ func TestPostPayloadAttestationsRejectsOversizedSSZ(t *testing.T) {
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", strings.NewReader(strings.Repeat("\x00", maxSize+1)))
request.Header.Set("Content-Type", "application/octet-stream")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
recorder := httptest.NewRecorder()
handler.PostEthV1BeaconPoolPayloadAttestations(recorder, request)
@@ -68,17 +248,42 @@ func TestPostPayloadAttestationsRejectsOversizedSSZ(t *testing.T) {
require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
}
+func TestPostPayloadAttestationsAcceptsEmptySSZ(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", http.NoBody)
+ request.Header.Set("Content-Type", "application/octet-stream")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+
+ handler.PostEthV1BeaconPoolPayloadAttestations(recorder, request)
+
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+}
+
func TestPostPayloadAttestationsAcceptsMoreThanBlockAggregateLimitSSZ(t *testing.T) {
_, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ ctrl := gomock.NewController(t)
+ attestationService := mock_services.NewMockPayloadAttestationService(ctrl)
+ handler.payloadAttestationService = attestationService
+ gossipManager := gossip_mock.NewMockGossip(ctrl)
+ handler.gossipManager = gossipManager
msg := &cltypes.PayloadAttestationMessage{
Data: new(cltypes.PayloadAttestationData),
}
encoded, err := msg.EncodeSSZ(nil)
require.NoError(t, err)
body := strings.Repeat(string(encoded), int(handler.beaconChainCfg.MaxPayloadAttestations)+1)
+ count := int(handler.beaconChainCfg.MaxPayloadAttestations) + 1
+ attestationService.EXPECT().ProcessRESTMessage(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+ func(_ context.Context, _ *cltypes.PayloadAttestationMessage, publish func() error) error {
+ return publish()
+ },
+ ).Times(count)
+ gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNamePayloadAttestation, gomock.Any()).Return(nil).Times(count)
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", strings.NewReader(body))
request.Header.Set("Content-Type", "application/octet-stream")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
recorder := httptest.NewRecorder()
handler.PostEthV1BeaconPoolPayloadAttestations(recorder, request)
@@ -88,6 +293,17 @@ func TestPostPayloadAttestationsAcceptsMoreThanBlockAggregateLimitSSZ(t *testing
func TestPostPayloadAttestationsAcceptsSSZContentTypeParameters(t *testing.T) {
_, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ ctrl := gomock.NewController(t)
+ attestationService := mock_services.NewMockPayloadAttestationService(ctrl)
+ attestationService.EXPECT().ProcessRESTMessage(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+ func(_ context.Context, _ *cltypes.PayloadAttestationMessage, publish func() error) error {
+ return publish()
+ },
+ )
+ handler.payloadAttestationService = attestationService
+ gossipManager := gossip_mock.NewMockGossip(ctrl)
+ gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNamePayloadAttestation, gomock.Any()).Return(nil)
+ handler.gossipManager = gossipManager
msg := &cltypes.PayloadAttestationMessage{
Data: new(cltypes.PayloadAttestationData),
}
@@ -96,6 +312,7 @@ func TestPostPayloadAttestationsAcceptsSSZContentTypeParameters(t *testing.T) {
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", strings.NewReader(string(body)))
request.Header.Set("Content-Type", "application/octet-stream; charset=utf-8")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
recorder := httptest.NewRecorder()
handler.PostEthV1BeaconPoolPayloadAttestations(recorder, request)
@@ -103,14 +320,73 @@ func TestPostPayloadAttestationsAcceptsSSZContentTypeParameters(t *testing.T) {
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
}
-func TestPostPayloadAttestationsAcceptsQueuedWithoutPooling(t *testing.T) {
+func TestPostPayloadAttestationsRejectsNonCanonicalSSZAndTrailingJSON(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ message := &cltypes.PayloadAttestationMessage{Data: new(cltypes.PayloadAttestationData)}
+ validSSZ, err := message.EncodeSSZ(nil)
+ require.NoError(t, err)
+ sszBody := append([]byte(nil), validSSZ...)
+ sszBody[8+32+8] = 2
+ jsonBody, err := json.Marshal([]*cltypes.PayloadAttestationMessage{message})
+ require.NoError(t, err)
+
+ for _, tc := range []struct {
+ name string
+ contentType string
+ body []byte
+ }{
+ {name: "invalid boolean", contentType: "application/octet-stream", body: sszBody},
+ {name: "trailing SSZ", contentType: "application/octet-stream", body: append(append([]byte(nil), validSSZ...), 0)},
+ {name: "trailing JSON", contentType: "application/json", body: append(jsonBody, []byte(`{}`)...)},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", bytes.NewReader(tc.body))
+ request.Header.Set("Content-Type", tc.contentType)
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, request)
+ require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
+ })
+ }
+}
+
+func TestPostPayloadAttestationsCapsJSONCardinality(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ maxItems := int(handler.beaconChainCfg.PtcSize)
+ for _, tc := range []struct {
+ name string
+ count int
+ wantIndexed bool
+ }{
+ {name: "maximum", count: maxItems, wantIndexed: true},
+ {name: "maximum plus one", count: maxItems + 1},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ body := "[" + strings.TrimSuffix(strings.Repeat("null,", tc.count), ",") + "]"
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", strings.NewReader(body))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, request)
+ require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
+ if tc.wantIndexed {
+ require.Contains(t, recorder.Body.String(), `"failures"`)
+ } else {
+ require.NotContains(t, recorder.Body.String(), `"failures"`)
+ require.Contains(t, recorder.Body.String(), "exceeds")
+ }
+ })
+ }
+}
+
+func TestPostPayloadAttestationsRejectsQueuedWithoutPooling(t *testing.T) {
_, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
msg := newTestPayloadAttestationMessage(t, 12, common.HexToHash("0x1234"))
attestationService := mock_services.NewMockPayloadAttestationService(ctrl)
- attestationService.EXPECT().ProcessMessage(gomock.Any(), gomock.Nil(), gomock.Any()).Return(fmt.Errorf("%w: %w", services.ErrIgnore, services.ErrAttestationQueued))
+ attestationService.EXPECT().ProcessRESTMessage(gomock.Any(), gomock.Any(), gomock.Any()).Return(fmt.Errorf("%w: %w", services.ErrIgnore, services.ErrAttestationQueued))
handler.payloadAttestationService = attestationService
handler.epbsPool = pool.NewEpbsPool()
@@ -118,6 +394,29 @@ func TestPostPayloadAttestationsAcceptsQueuedWithoutPooling(t *testing.T) {
require.NoError(t, err)
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", strings.NewReader(string(body)))
request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+
+ handler.PostEthV1BeaconPoolPayloadAttestations(recorder, request)
+
+ require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
+ _, found := handler.epbsPool.PayloadAttestations.Get(pool.PayloadAttestationKey{Slot: msg.Data.Slot, ValidatorIndex: msg.ValidatorIndex})
+ require.False(t, found)
+}
+
+func TestPostPayloadAttestationsAcceptsDuplicateWithoutPooling(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ ctrl := gomock.NewController(t)
+ msg := newTestPayloadAttestationMessage(t, 12, common.HexToHash("0x1234"))
+ attestationService := mock_services.NewMockPayloadAttestationService(ctrl)
+ attestationService.EXPECT().ProcessRESTMessage(gomock.Any(), gomock.Any(), gomock.Any()).Return(fmt.Errorf("%w: %w", services.ErrIgnore, services.ErrAttestationDuplicate))
+ handler.payloadAttestationService = attestationService
+ handler.epbsPool = pool.NewEpbsPool()
+ body, err := json.Marshal([]*cltypes.PayloadAttestationMessage{msg})
+ require.NoError(t, err)
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", bytes.NewReader(body))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
recorder := httptest.NewRecorder()
handler.PostEthV1BeaconPoolPayloadAttestations(recorder, request)
@@ -127,11 +426,57 @@ func TestPostPayloadAttestationsAcceptsQueuedWithoutPooling(t *testing.T) {
require.False(t, found)
}
+func TestPostPayloadAttestationsReturnsPublishFailure(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ ctrl := gomock.NewController(t)
+ msg := newTestPayloadAttestationMessage(t, 12, common.HexToHash("0x1234"))
+ attestationService := mock_services.NewMockPayloadAttestationService(ctrl)
+ attestationService.EXPECT().ProcessRESTMessage(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(
+ func(_ context.Context, _ *cltypes.PayloadAttestationMessage, publish func() error) error {
+ return publish()
+ },
+ )
+ handler.payloadAttestationService = attestationService
+ gossipManager := gossip_mock.NewMockGossip(ctrl)
+ gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNamePayloadAttestation, gomock.Any()).Return(errors.New("gossip unavailable"))
+ handler.gossipManager = gossipManager
+ body, err := json.Marshal([]*cltypes.PayloadAttestationMessage{msg})
+ require.NoError(t, err)
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", bytes.NewReader(body))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+
+ handler.PostEthV1BeaconPoolPayloadAttestations(recorder, request)
+
+ require.Equal(t, http.StatusInternalServerError, recorder.Code, recorder.Body.String())
+}
+
+func TestPostPayloadAttestationsRejectsRetryableValidation(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ ctrl := gomock.NewController(t)
+ msg := newTestPayloadAttestationMessage(t, 12, common.HexToHash("0x1234"))
+ attestationService := mock_services.NewMockPayloadAttestationService(ctrl)
+ attestationService.EXPECT().ProcessRESTMessage(gomock.Any(), gomock.Any(), gomock.Any()).Return(fmt.Errorf("%w: %w", services.ErrIgnore, services.ErrAttestationRetryable))
+ handler.payloadAttestationService = attestationService
+ body, err := json.Marshal([]*cltypes.PayloadAttestationMessage{msg})
+ require.NoError(t, err)
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", bytes.NewReader(body))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+
+ handler.PostEthV1BeaconPoolPayloadAttestations(recorder, request)
+
+ require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
+}
+
func TestPostPayloadAttestationsRejectsMalformedContentType(t *testing.T) {
_, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", strings.NewReader(`[]`))
request.Header.Set("Content-Type", "application/octet-stream; bad")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
recorder := httptest.NewRecorder()
handler.PostEthV1BeaconPoolPayloadAttestations(recorder, request)
@@ -144,6 +489,7 @@ func TestPostPayloadAttestationsRejectsUnsupportedContentType(t *testing.T) {
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/payload_attestations", strings.NewReader(`[]`))
request.Header.Set("Content-Type", "text/plain")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
recorder := httptest.NewRecorder()
handler.PostEthV1BeaconPoolPayloadAttestations(recorder, request)
@@ -151,18 +497,500 @@ func TestPostPayloadAttestationsRejectsUnsupportedContentType(t *testing.T) {
require.Equal(t, http.StatusUnsupportedMediaType, recorder.Code, recorder.Body.String())
}
-func TestPostExecutionPayloadEnvelopeReturnsForkchoiceError(t *testing.T) {
+func TestPostExecutionPayloadEnvelopeAcceptsGossipIntegrationError(t *testing.T) {
_, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
fcu.OnExecutionPayloadErr = errors.New("invalid execution payload")
+ handler.emitters = beaconevents.NewEventEmitter()
+ events := make(chan *beaconevents.EventStream, 1)
+ subscription := handler.emitters.Operation().Subscribe(events)
+ defer subscription.Unsubscribe()
+ fcu.Blocks[common.Hash{}] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 12, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}}
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelope", strings.NewReader(`{}`))
request.Header.Set("Content-Type", "application/json; charset=utf-8")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ request.Header.Set("Eth-Blob-Data-Included", "false")
+ recorder := httptest.NewRecorder()
+
+ handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request)
+
+ require.Equal(t, http.StatusAccepted, recorder.Code, recorder.Body.String())
+ require.Equal(t, beaconevents.OpExecutionPayloadGossip, (<-events).Event)
+}
+
+func TestPostExecutionPayloadEnvelopesRequiresBlobDataHeader(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes", strings.NewReader(`{}`))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+
+ handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request)
+
+ require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
+ require.Contains(t, recorder.Body.String(), "Eth-Blob-Data-Included")
+}
+
+func TestPostExecutionPayloadEnvelopesRejectsMalformedContents(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes", strings.NewReader(`{}`))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Blob-Data-Included", "true")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+
+ handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request)
+
+ require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
+}
+
+func TestPostExecutionPayloadEnvelopesRejectsTrailingJSON(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)}
+ body, err := json.Marshal(envelope)
+ require.NoError(t, err)
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes", strings.NewReader(string(body)+`{}`))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Blob-Data-Included", "false")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+
+ handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request)
+
+ require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
+}
+
+func TestPostExecutionPayloadEnvelopesEmitsImportedAndAvailableEvents(t *testing.T) {
+ _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ handler.emitters = beaconevents.NewEventEmitter()
+ events := make(chan *beaconevents.EventStream, 3)
+ subscription := handler.emitters.Operation().Subscribe(events)
+ defer subscription.Unsubscribe()
+
+ root := common.Hash{1}
+ envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)}
+ envelope.Message.BeaconBlockRoot = root
+ envelope.Message.BuilderIndex = 3
+ fcu.Blocks = map[common.Hash]*cltypes.SignedBeaconBlock{
+ root: {Block: &cltypes.BeaconBlock{Slot: 12, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}},
+ }
+ body, err := json.Marshal(envelope)
+ require.NoError(t, err)
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes", strings.NewReader(string(body)))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Blob-Data-Included", "false")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+
+ handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request)
+
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+ require.Equal(t, beaconevents.OpExecutionPayloadGossip, (<-events).Event)
+ require.Equal(t, beaconevents.OpExecutionPayload, (<-events).Event)
+ select {
+ case event := <-events:
+ require.Equal(t, beaconevents.OpExecutionPayloadAvailable, event.Event)
+ case <-time.After(time.Second):
+ t.Fatal("execution_payload_available event was not emitted")
+ }
+}
+
+func TestPostExecutionPayloadEnvelopesRejectsStatefulEnvelopeWhenColumnsAreMissing(t *testing.T) {
+ _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ handler.emitters = beaconevents.NewEventEmitter()
+ events := make(chan *beaconevents.EventStream, 2)
+ subscription := handler.emitters.Operation().Subscribe(events)
+ defer subscription.Unsubscribe()
+
+ root := common.Hash{2}
+ envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)}
+ envelope.Message.BeaconBlockRoot = root
+ envelope.Message.BuilderIndex = 4
+ fcu.Blocks = map[common.Hash]*cltypes.SignedBeaconBlock{
+ root: {Block: &cltypes.BeaconBlock{Slot: 13, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}},
+ }
+ fcu.OnExecutionPayloadErr = forkchoice.ErrEIP7594ColumnDataNotAvailable
+ body, err := json.Marshal(envelope)
+ require.NoError(t, err)
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes", strings.NewReader(string(body)))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Blob-Data-Included", "false")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+
+ handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request)
+
+ require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
+ select {
+ case event := <-events:
+ t.Fatalf("unexpected event %s", event.Event)
+ default:
+ }
+}
+
+func TestPostExecutionPayloadEnvelopesRejectsInvalidBroadcastValidation(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)}
+ body, err := json.Marshal(envelope)
+ require.NoError(t, err)
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes?broadcast_validation=fast", strings.NewReader(string(body)))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Blob-Data-Included", "false")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+
+ handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request)
+
+ require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
+}
+
+func TestPostExecutionPayloadEnvelopesRejectsEquivocatingBlockBeforeGossip(t *testing.T) {
+ _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ ctrl := gomock.NewController(t)
+ handler.gossipManager = gossip_mock.NewMockGossip(ctrl)
+ root := common.Hash{1}
+ fcu.Blocks = map[common.Hash]*cltypes.SignedBeaconBlock{
+ root: {Block: &cltypes.BeaconBlock{Slot: 12, ProposerIndex: 3, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}},
+ {2}: {Block: &cltypes.BeaconBlock{Slot: 12, ProposerIndex: 3, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}},
+ }
+ envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)}
+ envelope.Message.BeaconBlockRoot = root
+ body, err := json.Marshal(envelope)
+ require.NoError(t, err)
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes?broadcast_validation=consensus_and_equivocation", strings.NewReader(string(body)))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Blob-Data-Included", "false")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+
+ handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request)
+
+ require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
+ require.Contains(t, recorder.Body.String(), "equivocation")
+}
+
+func TestPostExecutionPayloadEnvelopesReturnsErrorWhenGossipPublishFails(t *testing.T) {
+ _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ handler.emitters = beaconevents.NewEventEmitter()
+ events := make(chan *beaconevents.EventStream, 4)
+ subscription := handler.emitters.Operation().Subscribe(events)
+ defer subscription.Unsubscribe()
+ ctrl := gomock.NewController(t)
+ gossipManager := gossip_mock.NewMockGossip(ctrl)
+ gomock.InOrder(
+ gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameExecutionPayload, gomock.Any()).Return(errors.New("gossip unavailable")),
+ gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameExecutionPayload, gomock.Any()).Return(nil),
+ )
+ handler.gossipManager = gossipManager
+ root := common.Hash{1}
+ fcu.Blocks = map[common.Hash]*cltypes.SignedBeaconBlock{
+ root: {Block: &cltypes.BeaconBlock{Slot: 12, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}},
+ }
+ envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)}
+ envelope.Message.BeaconBlockRoot = root
+ body, err := json.Marshal(envelope)
+ require.NoError(t, err)
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes", strings.NewReader(string(body)))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Blob-Data-Included", "false")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
recorder := httptest.NewRecorder()
handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request)
require.Equal(t, http.StatusInternalServerError, recorder.Code, recorder.Body.String())
- require.Contains(t, recorder.Body.String(), "invalid execution payload")
+ require.Contains(t, recorder.Body.String(), "gossip unavailable")
+ for range 3 {
+ <-events
+ }
+ fcu.Envelopes[root] = envelope
+ fcu.OnExecutionPayloadErr = forkchoice.ErrIgnore
+ request = httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes", bytes.NewReader(body))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Blob-Data-Included", "false")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder = httptest.NewRecorder()
+ handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request)
+ require.Equal(t, http.StatusAccepted, recorder.Code, recorder.Body.String())
+ select {
+ case event := <-events:
+ t.Fatalf("retry emitted duplicate event %s", event.Event)
+ default:
+ }
+}
+
+func TestPostExecutionPayloadEnvelopeDuplicateDoesNotRepublishOrEmit(t *testing.T) {
+ _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ handler.gossipManager = gossip_mock.NewMockGossip(gomock.NewController(t))
+ handler.emitters = beaconevents.NewEventEmitter()
+ events := make(chan *beaconevents.EventStream, 1)
+ subscription := handler.emitters.Operation().Subscribe(events)
+ defer subscription.Unsubscribe()
+ fcu.OnExecutionPayloadErr = forkchoice.ErrIgnore
+ persisted := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)}
+ fcu.Envelopes[common.Hash{}] = persisted
+ envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)}
+ envelope.Signature[0] = 1
+ body, err := json.Marshal(envelope)
+ require.NoError(t, err)
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes", bytes.NewReader(body))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ request.Header.Set("Eth-Blob-Data-Included", "false")
+ recorder := httptest.NewRecorder()
+
+ handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request)
+
+ require.Equal(t, http.StatusServiceUnavailable, recorder.Code, recorder.Body.String())
+ select {
+ case event := <-events:
+ t.Fatalf("unexpected event %s", event.Event)
+ default:
+ }
+}
+
+func TestPostExecutionPayloadEnvelopeConcurrentIdenticalReconcilesPersistedImport(t *testing.T) {
+ _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ handler.emitters = beaconevents.NewEventEmitter()
+ events := make(chan *beaconevents.EventStream, 4)
+ subscription := handler.emitters.Operation().Subscribe(events)
+ defer subscription.Unsubscribe()
+ gossipManager := gossip_mock.NewMockGossip(gomock.NewController(t))
+ gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameExecutionPayload, gomock.Any()).Return(nil).Times(2)
+ handler.gossipManager = gossipManager
+ root := common.Hash{7}
+ fcu.Blocks[root] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 12, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}}
+ envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)}
+ envelope.Message.BeaconBlockRoot = root
+ body, err := json.Marshal(envelope)
+ require.NoError(t, err)
+ firstPersisted := make(chan struct{})
+ releaseFirst := make(chan struct{})
+ var calls atomic.Int32
+ fcu.OnExecutionPayloadFn = func(_ context.Context, imported *cltypes.SignedExecutionPayloadEnvelope, _, _ bool) error {
+ if calls.Add(1) == 1 {
+ fcu.Envelopes[root] = imported
+ close(firstPersisted)
+ <-releaseFirst
+ return nil
+ }
+ return forkchoice.ErrIgnore
+ }
+ post := func(payload []byte) *httptest.ResponseRecorder {
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes", bytes.NewReader(payload))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ request.Header.Set("Eth-Blob-Data-Included", "false")
+ recorder := httptest.NewRecorder()
+ handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request)
+ return recorder
+ }
+ firstResult := make(chan *httptest.ResponseRecorder, 1)
+ go func() { firstResult <- post(body) }()
+ <-firstPersisted
+ second := post(body)
+ close(releaseFirst)
+ first := <-firstResult
+ require.ElementsMatch(t, []int{http.StatusOK, http.StatusAccepted}, []int{first.Code, second.Code})
+ eventCounts := map[beaconevents.EventTopic]int{}
+ for range 3 {
+ eventCounts[(<-events).Event]++
+ }
+ require.Equal(t, 1, eventCounts[beaconevents.OpExecutionPayloadGossip])
+ require.Equal(t, 1, eventCounts[beaconevents.OpExecutionPayload])
+ require.Equal(t, 1, eventCounts[beaconevents.OpExecutionPayloadAvailable])
+
+ forgedEnvelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)}
+ forgedEnvelope.Message.BeaconBlockRoot = root
+ forgedEnvelope.Signature[0] = 1
+ forged, err := json.Marshal(forgedEnvelope)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusServiceUnavailable, post(forged).Code)
+}
+
+func TestPostExecutionPayloadEnvelopeMissingBlockRequiresRetry(t *testing.T) {
+ _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ ctrl := gomock.NewController(t)
+ gossipManager := gossip_mock.NewMockGossip(ctrl)
+ handler.gossipManager = gossipManager
+ envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)}
+ body, err := json.Marshal(envelope)
+ require.NoError(t, err)
+ post := func() *httptest.ResponseRecorder {
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes", bytes.NewReader(body))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ request.Header.Set("Eth-Blob-Data-Included", "false")
+ recorder := httptest.NewRecorder()
+ handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request)
+ return recorder
+ }
+ fcu.OnExecutionPayloadErr = forkchoice.ErrIgnore
+ require.Equal(t, http.StatusServiceUnavailable, post().Code)
+ fcu.OnExecutionPayloadErr = nil
+ gossipManager.EXPECT().Publish(gomock.Any(), gossip.TopicNameExecutionPayload, gomock.Any()).Return(nil)
+ require.Equal(t, http.StatusOK, post().Code)
+}
+
+func TestEmitFullHeadV2DropsChangedPayloadSnapshot(t *testing.T) {
+ _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ handler.emitters = beaconevents.NewEventEmitter()
+ events := make(chan *beaconevents.EventStream, 1)
+ subscription := handler.emitters.State().Subscribe(events)
+ defer subscription.Unsubscribe()
+ root := common.HexToHash("0x1234")
+ block := &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 1, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}}
+ fcu.HeadVal = root
+ fcu.HeadSlotVal = 1
+ fcu.HeadPayloadStatusVal = cltypes.PayloadStatusFull
+ fcu.GetStateAtBlockRootFn = func(common.Hash, bool) (*state.CachingBeaconState, error) {
+ fcu.HeadPayloadStatusVal = cltypes.PayloadStatusPending
+ headState := state.New(handler.beaconChainCfg)
+ headState.SetVersion(clparams.GloasVersion)
+ require.NoError(t, headState.SetSlot(1))
+ require.NoError(t, headState.SetBlockRootAt(0, common.Hash{1}))
+ return headState, nil
+ }
+
+ handler.emitFullHeadV2(block, root)
+
+ select {
+ case event := <-events:
+ t.Fatalf("unexpected event %s", event.Event)
+ default:
+ }
+}
+
+func TestEmitFullHeadV2DropsChangedOptimismSnapshot(t *testing.T) {
+ _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ handler.emitters = beaconevents.NewEventEmitter()
+ events := make(chan *beaconevents.EventStream, 1)
+ subscription := handler.emitters.State().Subscribe(events)
+ defer subscription.Unsubscribe()
+ root := common.HexToHash("0x1234")
+ block := &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 1, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}}
+ fcu.HeadVal = root
+ fcu.HeadSlotVal = 1
+ fcu.HeadPayloadStatusVal = cltypes.PayloadStatusFull
+ fcu.GetStateAtBlockRootFn = func(common.Hash, bool) (*state.CachingBeaconState, error) {
+ fcu.IsRootOptimisticVal = true
+ headState := state.New(handler.beaconChainCfg)
+ headState.SetVersion(clparams.GloasVersion)
+ require.NoError(t, headState.SetSlot(1))
+ require.NoError(t, headState.SetBlockRootAt(0, common.Hash{1}))
+ return headState, nil
+ }
+
+ handler.emitFullHeadV2(block, root)
+
+ select {
+ case event := <-events:
+ t.Fatalf("unexpected event %s", event.Event)
+ default:
+ }
+}
+
+func TestGetExecutionPayloadEnvelopeDoesNotFinalizeSameSlotSideBranch(t *testing.T) {
+ db, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ root := common.HexToHash("0x1234")
+ slot := uint64(12)
+ fcu.Envelopes[root] = &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)}
+ fcu.Blocks[root] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: slot, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}}
+ fcu.FinalizedSlotVal = slot
+ fcu.FinalizedCheckpointVal = solid.Checkpoint{Epoch: 2, Root: common.HexToHash("0xbeef")}
+ fcu.Ancestors[slot] = forkchoice.ForkChoiceNode{Root: common.HexToHash("0xbeef")}
+ require.NoError(t, db.Update(t.Context(), func(tx kv.RwTx) error {
+ return beacon_indicies.MarkRootCanonical(t.Context(), tx, slot, common.HexToHash("0xbeef"))
+ }))
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/beacon/execution_payload_envelope/"+root.Hex(), http.NoBody)
+ recorder := httptest.NewRecorder()
+
+ handler.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+ require.Contains(t, recorder.Body.String(), `"finalized":false`)
+}
+
+func TestGetExecutionPayloadEnvelopeFinalizesCheckpointRootOnly(t *testing.T) {
+ _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ root := common.HexToHash("0x1234")
+ slot := uint64(64)
+ fcu.Envelopes[root] = &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)}
+ fcu.Blocks[root] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: slot, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}}
+ fcu.FinalizedCheckpointVal = solid.Checkpoint{Epoch: 2, Root: root}
+ fcu.Ancestors[slot] = forkchoice.ForkChoiceNode{Root: root}
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/beacon/execution_payload_envelope/"+root.Hex(), http.NoBody)
+ recorder := httptest.NewRecorder()
+
+ handler.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+ require.Contains(t, recorder.Body.String(), `"finalized":true`)
+}
+
+func TestGetExecutionPayloadEnvelopeFinalityBoundaryMatrix(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ slot uint64
+ ancestor bool
+ finalized bool
+ }{
+ {name: "earlier canonical ancestor", slot: 63, ancestor: true, finalized: true},
+ {name: "descendant", slot: 65, ancestor: true, finalized: false},
+ {name: "earlier side branch", slot: 63, ancestor: false, finalized: false},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ root := common.HexToHash("0x1234")
+ finalizedRoot := common.HexToHash("0xbeef")
+ fcu.Envelopes[root] = &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)}
+ fcu.Blocks[root] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: test.slot, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}}
+ fcu.FinalizedCheckpointVal = solid.Checkpoint{Epoch: 2, Root: finalizedRoot}
+ if test.ancestor {
+ fcu.Ancestors[test.slot] = forkchoice.ForkChoiceNode{Root: root}
+ } else {
+ fcu.Ancestors[test.slot] = forkchoice.ForkChoiceNode{Root: common.HexToHash("0xdead")}
+ }
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/beacon/execution_payload_envelope/"+root.Hex(), http.NoBody)
+ recorder := httptest.NewRecorder()
+
+ handler.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+ require.Contains(t, recorder.Body.String(), fmt.Sprintf(`"finalized":%t`, test.finalized))
+ })
+ }
+}
+
+func TestPostExecutionPayloadEnvelopesClassifiesIntegrationFailureByValidationMode(t *testing.T) {
+ for _, test := range []struct {
+ validation string
+ status int
+ }{
+ {validation: "gossip", status: http.StatusAccepted},
+ {validation: "consensus", status: http.StatusBadRequest},
+ } {
+ t.Run(test.validation, func(t *testing.T) {
+ _, _, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ root := common.Hash{1}
+ fcu.Blocks = map[common.Hash]*cltypes.SignedBeaconBlock{
+ root: {Block: &cltypes.BeaconBlock{Slot: 12, Body: cltypes.NewBeaconBody(handler.beaconChainCfg, clparams.GloasVersion)}},
+ }
+ fcu.OnExecutionPayloadErr = errors.New("integration failed")
+ envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)}
+ envelope.Message.BeaconBlockRoot = root
+ body, err := json.Marshal(envelope)
+ require.NoError(t, err)
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes?broadcast_validation="+test.validation, strings.NewReader(string(body)))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Blob-Data-Included", "false")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+
+ handler.PostEthV1BeaconExecutionPayloadEnvelope(recorder, request)
+ require.Equal(t, test.status, recorder.Code, recorder.Body.String())
+ })
+ }
}
func TestPostPtcDutiesDoesNotCapValidatorCount(t *testing.T) {
@@ -191,6 +1019,7 @@ func TestPostExecutionPayloadBidAcceptsSSZ(t *testing.T) {
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_bid", strings.NewReader(string(body)))
request.Header.Set("Content-Type", "application/octet-stream")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
recorder := httptest.NewRecorder()
handler.PostEthV1BeaconExecutionPayloadBid(recorder, request)
@@ -198,13 +1027,82 @@ func TestPostExecutionPayloadBidAcceptsSSZ(t *testing.T) {
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
}
-func TestPostExecutionPayloadBidAcceptsQueuedBid(t *testing.T) {
+func TestPostExecutionPayloadBidCanonicalPluralRequiresVersionButSingularAliasDoesNot(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ bid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(12, 3, 1000)}
+ body, err := json.Marshal(bid)
+ require.NoError(t, err)
+
+ canonical := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_bids", bytes.NewReader(body))
+ canonical.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, canonical)
+ require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
+
+ legacy := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_bid", bytes.NewReader(body))
+ legacy.Header.Set("Content-Type", "application/json")
+ recorder = httptest.NewRecorder()
+ handler.ServeHTTP(recorder, legacy)
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+}
+
+func TestPostExecutionPayloadEnvelopesCanonicalPluralRequiresHeadersAndValidation(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)}
+ body, err := json.Marshal(envelope)
+ require.NoError(t, err)
+
+ for _, tc := range []struct {
+ name string
+ version string
+ blobHeader string
+ query string
+ message string
+ }{
+ {name: "missing version", message: "Eth-Consensus-Version"},
+ {name: "missing blob header", version: "gloas", message: "Eth-Blob-Data-Included"},
+ {name: "invalid broadcast validation", version: "gloas", blobHeader: "false", query: "?broadcast_validation=fast", message: "broadcast_validation"},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_envelopes"+tc.query, bytes.NewReader(body))
+ request.Header.Set("Content-Type", "application/json")
+ if tc.version != "" {
+ request.Header.Set("Eth-Consensus-Version", tc.version)
+ }
+ if tc.blobHeader != "" {
+ request.Header.Set("Eth-Blob-Data-Included", tc.blobHeader)
+ }
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, request)
+ require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
+ require.Contains(t, recorder.Body.String(), tc.message)
+ })
+ }
+}
+
+func TestPostExecutionPayloadBidsRejectsTrailingJSON(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ bid := &cltypes.SignedExecutionPayloadBid{Message: newTestExecutionPayloadBid(12, 3, 1000)}
+ body, err := json.Marshal(bid)
+ require.NoError(t, err)
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_bids", strings.NewReader(string(body)+`{}`))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+
+ handler.PostEthV1BeaconExecutionPayloadBid(recorder, request)
+
+ require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
+ require.Contains(t, recorder.Body.String(), "trailing data")
+}
+
+func TestPostExecutionPayloadBidRejectsIgnoredBid(t *testing.T) {
_, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
ctrl := gomock.NewController(t)
defer ctrl.Finish()
bidService := mock_services.NewMockExecutionPayloadBidService(ctrl)
- bidService.EXPECT().ProcessMessage(gomock.Any(), gomock.Nil(), gomock.Any()).Return(fmt.Errorf("%w: %w", services.ErrIgnore, services.ErrBidQueued))
+ bidService.EXPECT().ProcessMessage(gomock.Any(), gomock.Nil(), gomock.Any()).Return(fmt.Errorf("%w: proposer preferences unavailable", services.ErrIgnore))
handler.executionPayloadBidService = bidService
bid := &cltypes.SignedExecutionPayloadBid{
@@ -215,11 +1113,12 @@ func TestPostExecutionPayloadBidAcceptsQueuedBid(t *testing.T) {
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_bid", strings.NewReader(string(body)))
request.Header.Set("Content-Type", "application/octet-stream")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
recorder := httptest.NewRecorder()
handler.PostEthV1BeaconExecutionPayloadBid(recorder, request)
- require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+ require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
}
func TestPostExecutionPayloadBidRejectsHardIgnore(t *testing.T) {
@@ -239,6 +1138,7 @@ func TestPostExecutionPayloadBidRejectsHardIgnore(t *testing.T) {
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_bid", strings.NewReader(string(body)))
request.Header.Set("Content-Type", "application/octet-stream")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
recorder := httptest.NewRecorder()
handler.PostEthV1BeaconExecutionPayloadBid(recorder, request)
@@ -251,6 +1151,7 @@ func TestPostExecutionPayloadBidRejectsOversizedSSZ(t *testing.T) {
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_bid", strings.NewReader(strings.Repeat("\x00", int(maxSignedExecutionPayloadBidSSZSize())+1)))
request.Header.Set("Content-Type", "application/octet-stream")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
recorder := httptest.NewRecorder()
handler.PostEthV1BeaconExecutionPayloadBid(recorder, request)
@@ -263,6 +1164,7 @@ func TestPostExecutionPayloadBidRejectsMissingMessage(t *testing.T) {
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_bid", strings.NewReader(`{}`))
request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
recorder := httptest.NewRecorder()
handler.PostEthV1BeaconExecutionPayloadBid(recorder, request)
@@ -276,6 +1178,7 @@ func TestPostExecutionPayloadBidRejectsMalformedContentType(t *testing.T) {
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/execution_payload_bid", strings.NewReader(`{}`))
request.Header.Set("Content-Type", "application/octet-stream; bad")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
recorder := httptest.NewRecorder()
handler.PostEthV1BeaconExecutionPayloadBid(recorder, request)
@@ -284,17 +1187,24 @@ func TestPostExecutionPayloadBidRejectsMalformedContentType(t *testing.T) {
}
func TestGetValidatorExecutionPayloadBidReturnsUnsignedBid(t *testing.T) {
- _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ _, _, _, _, postState, handler, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true)
handler.beaconChainCfg.GloasForkEpoch = 0
+ require.NoError(t, postState.UpgradeToFulu())
+ require.NoError(t, postState.UpgradeToGloas())
+ for range 4 {
+ postState.GetBuilders().Append(&cltypes.Builder{})
+ }
+ require.NoError(t, handler.syncedData.OnHeadState(postState))
handler.epbsPool = pool.NewEpbsPool()
- bid := newTestExecutionPayloadBid(12, 3, 1000)
+ slot := handler.ethClock.GetCurrentSlot()
+ bid := newTestExecutionPayloadBid(slot, 3, 1000)
handler.epbsPool.HighestBids.Add(pool.HighestBidKey{
Slot: bid.Slot,
ParentBlockHash: bid.ParentBlockHash,
ParentBlockRoot: bid.ParentBlockRoot,
}, &cltypes.SignedExecutionPayloadBid{Message: bid})
- request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/validator/execution_payload_bid/12/3", http.NoBody)
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, fmt.Sprintf("/eth/v1/validator/execution_payload_bid/%d/3", slot), http.NoBody)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
@@ -303,6 +1213,16 @@ func TestGetValidatorExecutionPayloadBidReturnsUnsignedBid(t *testing.T) {
require.Contains(t, recorder.Body.String(), `"builder_index":"3"`)
require.NotContains(t, recorder.Body.String(), `"signature"`)
require.NotContains(t, recorder.Body.String(), `"message"`)
+
+ for _, path := range []string{
+ "/eth/v1/validator/execution_payload_bid/0/3",
+ "/eth/v1/validator/execution_payload_bid/18446744073709551615/3",
+ fmt.Sprintf("/eth/v1/validator/execution_payload_bid/%d/4", slot),
+ } {
+ recorder = httptest.NewRecorder()
+ handler.ServeHTTP(recorder, httptest.NewRequestWithContext(t.Context(), http.MethodGet, path, http.NoBody))
+ require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
+ }
}
func TestAggregatePayloadAttestationMessagesFiltersAndLimits(t *testing.T) {
@@ -478,6 +1398,7 @@ func TestPostValidatorProposerPreferencesAcceptsBatchJSON(t *testing.T) {
request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/validator/proposer_preferences", strings.NewReader(string(body)))
request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
@@ -495,6 +1416,116 @@ func TestPostValidatorProposerPreferencesAcceptsBatchJSON(t *testing.T) {
require.True(t, ok)
}
+func TestPostValidatorProposerPreferencesRequiresVersionAndReportsIndexedFailures(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ handler.epbsPool = pool.NewEpbsPool()
+ preferences := []*cltypes.SignedProposerPreferences{
+ {Message: &cltypes.ProposerPreferences{ProposalSlot: 32, DependentRoot: common.Hash{1}}},
+ {Message: &cltypes.ProposerPreferences{ProposalSlot: 33, DependentRoot: common.Hash{2}}},
+ }
+ body, err := json.Marshal(preferences)
+ require.NoError(t, err)
+
+ missingVersion := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/validator/proposer_preferences", bytes.NewReader(body))
+ missingVersion.Header.Set("Content-Type", "application/json")
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, missingVersion)
+ require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
+
+ ctrl := gomock.NewController(t)
+ service := mock_services.NewMockProposerPreferencesService(ctrl)
+ service.EXPECT().ProcessMessage(gomock.Any(), gomock.Nil(), gomock.Any()).DoAndReturn(
+ func(_ context.Context, _ *uint64, preference *cltypes.SignedProposerPreferences) error {
+ if preference.Message.ProposalSlot == 32 {
+ return errors.New("invalid first preference")
+ }
+ return nil
+ },
+ ).Times(2)
+ handler.proposerPreferencesService = service
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/validator/proposer_preferences", bytes.NewReader(body))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder = httptest.NewRecorder()
+ handler.ServeHTTP(recorder, request)
+ require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
+ var response poolingError
+ require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
+ require.Equal(t, []poolingFailure{{Index: 0, Message: "invalid first preference"}}, response.Failures)
+ _, found := handler.epbsPool.ProposerPreferences.Get(pool.ProposerPreferencesKey{Slot: 33, DependentRoot: common.Hash{2}})
+ require.True(t, found)
+}
+
+func TestPostValidatorProposerPreferencesAcceptsBatchSSZ(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ handler.epbsPool = pool.NewEpbsPool()
+ preferences := []*cltypes.SignedProposerPreferences{
+ {Message: &cltypes.ProposerPreferences{ProposalSlot: 32, DependentRoot: common.Hash{1}}},
+ {Message: &cltypes.ProposerPreferences{ProposalSlot: 33, DependentRoot: common.Hash{2}}},
+ }
+ body := make([]byte, 0)
+ for _, preference := range preferences {
+ var err error
+ body, err = preference.EncodeSSZ(body)
+ require.NoError(t, err)
+ }
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/validator/proposer_preferences", bytes.NewReader(body))
+ request.Header.Set("Content-Type", "application/octet-stream")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, request)
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+}
+
+func TestPostValidatorProposerPreferencesRequiresJSONArrayButPoolRetainsSingletonCompatibility(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ handler.epbsPool = pool.NewEpbsPool()
+ preference := &cltypes.SignedProposerPreferences{Message: &cltypes.ProposerPreferences{ProposalSlot: 32, DependentRoot: common.Hash{1}}}
+ body, err := json.Marshal(preference)
+ require.NoError(t, err)
+
+ validatorRequest := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/validator/proposer_preferences", bytes.NewReader(body))
+ validatorRequest.Header.Set("Content-Type", "application/json")
+ validatorRequest.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, validatorRequest)
+ require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
+
+ poolRequest := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/beacon/pool/proposer_preferences", bytes.NewReader(body))
+ poolRequest.Header.Set("Content-Type", "application/json")
+ recorder = httptest.NewRecorder()
+ handler.ServeHTTP(recorder, poolRequest)
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+}
+
+func TestPostValidatorProposerPreferencesCapsJSONCardinality(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ for _, tc := range []struct {
+ name string
+ count int
+ wantIndexed bool
+ }{
+ {name: "maximum", count: maxProposerPreferencesRequestItems, wantIndexed: true},
+ {name: "maximum plus one", count: maxProposerPreferencesRequestItems + 1},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ body := "[" + strings.TrimSuffix(strings.Repeat("null,", tc.count), ",") + "]"
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/eth/v1/validator/proposer_preferences", strings.NewReader(body))
+ request.Header.Set("Content-Type", "application/json")
+ request.Header.Set("Eth-Consensus-Version", "gloas")
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, request)
+ require.Equal(t, http.StatusBadRequest, recorder.Code, recorder.Body.String())
+ if tc.wantIndexed {
+ require.Contains(t, recorder.Body.String(), `"failures"`)
+ } else {
+ require.NotContains(t, recorder.Body.String(), `"failures"`)
+ require.Contains(t, recorder.Body.String(), "exceeds")
+ }
+ })
+ }
+}
+
func TestPostBeaconPoolProposerPreferencesAcceptsBatchJSON(t *testing.T) {
_, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
handler.epbsPool = pool.NewEpbsPool()
@@ -531,7 +1562,7 @@ func TestGetValidatorExecutionPayloadEnvelopesBySlot(t *testing.T) {
slot := uint64(3)
envelope := cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)
envelope.BuilderIndex = 7
- handler.selfBuildEnvelopes.Add(slot, envelope)
+ handler.selfBuildEnvelopes.Add(selfBuildEnvelopeKey{Slot: slot, BeaconBlockRoot: envelope.BeaconBlockRoot}, envelope)
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/validator/execution_payload_envelopes/3", http.NoBody)
recorder := httptest.NewRecorder()
@@ -542,6 +1573,41 @@ func TestGetValidatorExecutionPayloadEnvelopesBySlot(t *testing.T) {
require.Contains(t, recorder.Body.String(), `"builder_index":"7"`)
}
+func TestGetValidatorExecutionPayloadEnvelopeByBlockRoot(t *testing.T) {
+ _, _, _, _, _, handler, _, _, _, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ handler.beaconChainCfg.GloasForkEpoch = 0
+ slot := handler.ethClock.GetCurrentSlot()
+ root := common.HexToHash("0x1234")
+ envelope := cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)
+ envelope.BeaconBlockRoot = root
+ handler.selfBuildEnvelopes.Add(selfBuildEnvelopeKey{Slot: slot, BeaconBlockRoot: root}, envelope)
+ otherRoot := common.HexToHash("0x5678")
+ otherEnvelope := cltypes.NewExecutionPayloadEnvelope(handler.beaconChainCfg)
+ otherEnvelope.BeaconBlockRoot = otherRoot
+ handler.selfBuildEnvelopes.Add(selfBuildEnvelopeKey{Slot: slot, BeaconBlockRoot: otherRoot}, otherEnvelope)
+
+ tests := []struct {
+ name string
+ slot uint64
+ root common.Hash
+ want int
+ }{
+ {name: "matching current slot and root", slot: slot, root: root, want: http.StatusOK},
+ {name: "same slot alternate root", slot: slot, root: otherRoot, want: http.StatusOK},
+ {name: "wrong root", slot: slot, root: common.HexToHash("0x9999"), want: http.StatusNotFound},
+ {name: "old slot", slot: slot - 1, root: root, want: http.StatusNotFound},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
+ fmt.Sprintf("/eth/v1/validator/execution_payload_envelopes/%d/%s", tt.slot, tt.root), http.NoBody)
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, request)
+ require.Equal(t, tt.want, recorder.Code, recorder.Body.String())
+ })
+ }
+}
+
func newTestExecutionPayloadBid(slot, builderIndex, value uint64) *cltypes.ExecutionPayloadBid {
return &cltypes.ExecutionPayloadBid{
ParentBlockHash: common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111"),
diff --git a/cl/beacon/handler/epbs_validation_test.go b/cl/beacon/handler/epbs_validation_test.go
new file mode 100644
index 00000000000..706213e7fc2
--- /dev/null
+++ b/cl/beacon/handler/epbs_validation_test.go
@@ -0,0 +1,39 @@
+// Copyright 2026 The Erigon Authors
+// This file is part of Erigon.
+//
+// Erigon is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Erigon is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with Erigon. If not, see .
+
+package handler
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/erigontech/erigon/cl/clparams"
+ "github.com/erigontech/erigon/cl/cltypes"
+ "github.com/erigontech/erigon/common/log/v3"
+)
+
+func TestEnvelopeContentsAuthenticateBeforeBlobProcessing(t *testing.T) {
+ _, _, _, _, _, handler, _, _, forkchoice, _ := setupTestingHandler(t, clparams.BellatrixVersion, log.Root(), true)
+ want := errors.New("invalid envelope signature")
+ forkchoice.ValidateExecutionPayloadEnvelopeErr = want
+ contents := cltypes.NewSignedExecutionPayloadEnvelopeContents(handler.beaconChainCfg, 0)
+
+ err := handler.validateAndStoreExecutionPayloadEnvelopeContents(t.Context(), contents)
+
+ require.ErrorIs(t, err, want)
+}
diff --git a/cl/beacon/handler/events.go b/cl/beacon/handler/events.go
index 91a955b548e..bcb154232af 100644
--- a/cl/beacon/handler/events.go
+++ b/cl/beacon/handler/events.go
@@ -44,6 +44,9 @@ var validTopics = map[event.EventTopic]struct{}{
event.OpPayloadAttestationMessage: {},
event.OpExecutionPayloadBid: {},
event.OpExecutionPayloadAvailable: {},
+ event.OpExecutionPayload: {},
+ event.OpExecutionPayloadGossip: {},
+ event.OpProposerPreferences: {},
// state events
event.StateBlock: {},
event.StateBlockGossip: {},
@@ -51,6 +54,7 @@ var validTopics = map[event.EventTopic]struct{}{
event.StateLightClientFinalityUpdate: {},
event.StateFinalizedCheckpoint: {},
event.StateHead: {},
+ event.StateHeadV2: {},
event.StateLightClientOptimisticUpdate: {},
event.StatePayloadAttributes: {},
}
diff --git a/cl/beacon/handler/events_gloas_test.go b/cl/beacon/handler/events_gloas_test.go
new file mode 100644
index 00000000000..2b92319b6b0
--- /dev/null
+++ b/cl/beacon/handler/events_gloas_test.go
@@ -0,0 +1,45 @@
+// Copyright 2026 The Erigon Authors
+// This file is part of Erigon.
+//
+// Erigon is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Erigon is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with Erigon. If not, see .
+
+package handler
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/erigontech/erigon/cl/beacon/beaconevents"
+)
+
+func TestGloasEventTopicsAreValid(t *testing.T) {
+ for _, topic := range []beaconevents.EventTopic{
+ beaconevents.StateHeadV2,
+ beaconevents.OpExecutionPayload,
+ beaconevents.OpExecutionPayloadGossip,
+ beaconevents.OpExecutionPayloadAvailable,
+ beaconevents.OpExecutionPayloadBid,
+ beaconevents.OpPayloadAttestationMessage,
+ beaconevents.OpProposerPreferences,
+ } {
+ _, ok := validTopics[topic]
+ require.True(t, ok, topic)
+ }
+}
+
+func TestFastConfirmationTopicIsNotAdvertisedBeforeItHasAProducer(t *testing.T) {
+ _, ok := validTopics[beaconevents.EventTopic("fast_confirmation")]
+ require.False(t, ok)
+}
diff --git a/cl/beacon/handler/handler.go b/cl/beacon/handler/handler.go
index 0fdda5db2e4..0de99fbf42c 100644
--- a/cl/beacon/handler/handler.go
+++ b/cl/beacon/handler/handler.go
@@ -21,6 +21,7 @@ import (
"net/http"
"sync"
"sync/atomic"
+ "time"
"github.com/go-chi/chi/v5"
@@ -65,15 +66,16 @@ type BlobBundle struct {
KzgProofs []common.Bytes48
}
-// selfBuildPayload holds the execution payload and requests built by the local EL
-// during self-build block production. Cached so broadcastBlock can construct the
-// SignedExecutionPayloadEnvelope when the validator publishes the signed block.
-// [New in Gloas:EIP7732]
type selfBuildPayload struct {
Payload *cltypes.Eth1Block
ExecutionRequests *cltypes.ExecutionRequests
}
+type selfBuildEnvelopeKey struct {
+ Slot uint64
+ BeaconBlockRoot common.Hash
+}
+
type ApiHandler struct {
o sync.Once
mux *chi.Mux
@@ -132,6 +134,7 @@ type ApiHandler struct {
voluntaryExitService services.VoluntaryExitService
blsToExecutionChangeService services.BLSToExecutionChangeService
proposerSlashingService services.ProposerSlashingService
+ blockService services.BlockService
builderClient builder.BuilderClient
gossipManager gossip.Gossip
enableMemoizedHeadState bool
@@ -141,18 +144,14 @@ type ApiHandler struct {
executionPayloadBidService services.ExecutionPayloadBidService
payloadAttestationService services.PayloadAttestationService
proposerPreferencesService services.ProposerPreferencesService
- // selfBuildPayloads caches the execution payload + requests built by the local
- // EL during self-build block production, keyed by the execution block hash.
- // When the validator publishes the signed block, broadcastBlock retrieves the
- // cached data, wraps it into a SignedExecutionPayloadEnvelope, and broadcasts
- // it on the execution_payload gossip topic so the block can reach FULL status.
- selfBuildPayloads *lru.Cache[common.Hash, *selfBuildPayload]
+ selfBuildPayloads *lru.Cache[common.Hash, *selfBuildPayload]
// selfBuildEnvelopes caches the unsigned ExecutionPayloadEnvelope by slot so
// the validator client can retrieve it via
// GET /eth/v1/validator/execution_payload_envelope/{slot}/{builder_index}.
// Populated during block production alongside selfBuildPayloads.
// [New in Gloas:EIP7732]
- selfBuildEnvelopes *lru.Cache[uint64, *cltypes.ExecutionPayloadEnvelope]
+ selfBuildEnvelopes *lru.Cache[selfBuildEnvelopeKey, *cltypes.ExecutionPayloadEnvelope]
+ builderRoutes *builderRouteStore
}
func NewApiHandler(
@@ -186,6 +185,7 @@ func NewApiHandler(
voluntaryExitService services.VoluntaryExitService,
blsToExecutionChangeService services.BLSToExecutionChangeService,
proposerSlashingService services.ProposerSlashingService,
+ blockService services.BlockService,
builderClient builder.BuilderClient,
caplinStateSnapshots *snapshotsync.CaplinStateSnapshots,
gossipManager gossip.Gossip,
@@ -217,10 +217,11 @@ func NewApiHandler(
if err != nil {
panic(err)
}
- selfBuildEnvelopes, err := lru.New[uint64, *cltypes.ExecutionPayloadEnvelope]("selfBuildEnvelopes", 4)
+ selfBuildEnvelopes, err := lru.New[selfBuildEnvelopeKey, *cltypes.ExecutionPayloadEnvelope]("selfBuildEnvelopes", 4)
if err != nil {
panic(err)
}
+ builderRoutes := newBuilderRouteStore(builderRouteCapacity, builderRouteTTL, time.Now)
return &ApiHandler{
logger: logger,
validatorParams: validatorParams,
@@ -261,6 +262,7 @@ func NewApiHandler(
voluntaryExitService: voluntaryExitService,
blsToExecutionChangeService: blsToExecutionChangeService,
proposerSlashingService: proposerSlashingService,
+ blockService: blockService,
builderClient: builderClient,
gossipManager: gossipManager,
enableMemoizedHeadState: enableMemoizedHeadState,
@@ -270,6 +272,7 @@ func NewApiHandler(
proposerPreferencesService: proposerPreferencesService,
selfBuildPayloads: selfBuildPayloads,
selfBuildEnvelopes: selfBuildEnvelopes,
+ builderRoutes: builderRoutes,
}
}
@@ -373,9 +376,10 @@ func (a *ApiHandler) init() {
// [New in Gloas:EIP7732]
r.Get("/execution_payload_envelope/{block_id}", beaconhttp.HandleEndpointFunc(a.GetEthV1BeaconExecutionPayloadEnvelope))
r.Get("/execution_payload_envelopes/{block_id}", beaconhttp.HandleEndpointFunc(a.GetEthV1BeaconExecutionPayloadEnvelope))
- r.Post("/execution_payload_envelope", a.PostEthV1BeaconExecutionPayloadEnvelope)
+ r.Post("/execution_payload_envelope", a.postEthV1BeaconExecutionPayloadEnvelopeLegacy)
r.Post("/execution_payload_envelopes", a.PostEthV1BeaconExecutionPayloadEnvelope)
- r.Post("/execution_payload_bid", a.PostEthV1BeaconExecutionPayloadBid)
+ r.Post("/execution_payload_bid", a.postEthV1BeaconExecutionPayloadBidLegacy)
+ r.Post("/execution_payload_bids", a.PostEthV1BeaconExecutionPayloadBid)
r.Route("/states", func(r chi.Router) {
r.Route("/{state_id}", func(r chi.Router) {
r.Get("/randao", beaconhttp.HandleEndpointFunc(a.getRandao))
@@ -386,6 +390,7 @@ func (a *ApiHandler) init() {
r.Get("/fork", beaconhttp.HandleEndpointFunc(a.getStateFork))
r.Get("/validators", a.GetEthV1BeaconStatesValidators)
r.Post("/validators", a.PostEthV1BeaconStatesValidators)
+ r.Post("/builders", beaconhttp.HandleEndpointFunc(a.PostEthV1BeaconStatesBuilders))
r.Get("/validator_balances", beaconhttp.HandleEndpointFunc(a.GetEthV1BeaconValidatorsBalances))
r.Post("/validator_balances", beaconhttp.HandleEndpointFunc(a.PostEthV1BeaconValidatorsBalances))
r.Get("/validators/{validator_id}", beaconhttp.HandleEndpointFunc(a.GetEthV1BeaconStatesValidator))
@@ -418,10 +423,14 @@ func (a *ApiHandler) init() {
r.Post("/prepare_beacon_proposer", a.PostEthV1ValidatorPrepareBeaconProposal)
r.Post("/liveness/{epoch}", beaconhttp.HandleEndpointFunc(a.liveness))
// [New in Gloas:EIP7732]
+ r.Get("/payload_attestation_data", beaconhttp.HandleEndpointFunc(a.GetEthV1ValidatorPayloadAttestationData))
r.Get("/payload_attestation_data/{slot}", beaconhttp.HandleEndpointFunc(a.GetEthV1ValidatorPayloadAttestationData))
r.Post("/proposer_preferences", a.PostEthV1ValidatorProposerPreferences)
+ r.Post("/builder_preferences", a.PostEthV1ValidatorBuilderPreferences)
r.Get("/execution_payload_bid/{slot}/{builder_index}", beaconhttp.HandleEndpointFunc(a.GetEthV1ValidatorExecutionPayloadBid))
+ r.Get("/execution_payload_bids/{slot}/{builder_index}", beaconhttp.HandleEndpointFunc(a.GetEthV1ValidatorExecutionPayloadBid))
r.Get("/execution_payload_envelope/{slot}/{builder_index}", beaconhttp.HandleEndpointFunc(a.GetEthV1ValidatorExecutionPayloadEnvelope))
+ r.Get("/execution_payload_envelopes/{slot}/{beacon_block_root}", beaconhttp.HandleEndpointFunc(a.GetEthV1ValidatorExecutionPayloadEnvelopeByBlockRoot))
r.Get("/execution_payload_envelopes/{slot}", beaconhttp.HandleEndpointFunc(a.GetEthV1ValidatorExecutionPayloadEnvelopeBySlot))
if a.routerCfg.Builder {
r.Post("/register_validator", beaconhttp.HandleEndpointFunc(a.PostEthV1BuilderRegisterValidator))
@@ -470,6 +479,7 @@ func (a *ApiHandler) init() {
if a.routerCfg.Validator {
r.Get("/v3/validator/blocks/{slot}", beaconhttp.HandleEndpointFunc(a.GetEthV3ValidatorBlock))
r.Get("/v4/validator/blocks/{slot}", beaconhttp.HandleEndpointFunc(a.GetEthV3ValidatorBlock))
+ r.Post("/v4/validator/blocks/{slot}", beaconhttp.HandleEndpointFunc(a.PostEthV4ValidatorBlock))
}
})
}
diff --git a/cl/beacon/handler/headers.go b/cl/beacon/handler/headers.go
index e92f99d7b60..7a04995d92c 100644
--- a/cl/beacon/handler/headers.go
+++ b/cl/beacon/handler/headers.go
@@ -17,12 +17,15 @@
package handler
import (
+ "context"
"fmt"
"net/http"
"github.com/erigontech/erigon/cl/beacon/beaconhttp"
+ "github.com/erigontech/erigon/cl/cltypes"
"github.com/erigontech/erigon/cl/persistence/beacon_indicies"
"github.com/erigontech/erigon/common"
+ "github.com/erigontech/erigon/db/kv"
)
func (a *ApiHandler) getHeaders(w http.ResponseWriter, r *http.Request) (*beaconhttp.BeaconResponse, error) {
@@ -116,7 +119,7 @@ func (a *ApiHandler) getHeader(w http.ResponseWriter, r *http.Request) (*beaconh
return nil, err
}
- signedHeader, err := a.blockReader.ReadHeaderByRoot(ctx, tx, root)
+ signedHeader, live, err := a.readHeaderByRoot(ctx, tx, root)
if err != nil {
return nil, err
}
@@ -129,11 +132,36 @@ func (a *ApiHandler) getHeader(w http.ResponseWriter, r *http.Request) (*beaconh
if err != nil {
return nil, err
}
+ canonical := blockId.Head() || canonicalRoot == root
+ if live && !canonical && a.enableMemoizedHeadState {
+ if selectedRoot, _, ok := a.syncedData.SelectedHead(); ok {
+ canonical = selectedRoot == root || a.forkchoiceStore.Ancestor(selectedRoot, signedHeader.Header.Slot).Root == root
+ }
+ }
return newBeaconResponse(&headerResponse{
Root: root,
- Canonical: blockId.Head() || canonicalRoot == root,
+ Canonical: canonical,
Header: signedHeader,
- }).WithFinalized(canonicalRoot == root && signedHeader.Header.Slot <= a.forkchoiceStore.FinalizedSlot()).
+ }).WithFinalized(canonical && signedHeader.Header.Slot <= a.forkchoiceStore.FinalizedSlot()).
WithOptimistic(a.forkchoiceStore.IsRootOptimistic(root)), nil
}
+
+func (a *ApiHandler) readHeaderByRoot(ctx context.Context, tx kv.Tx, root common.Hash) (*cltypes.SignedBeaconBlockHeader, bool, error) {
+ signedHeader, err := a.blockReader.ReadHeaderByRoot(ctx, tx, root)
+ if err != nil || signedHeader != nil {
+ return signedHeader, false, err
+ }
+ blk, header, ok := a.liveBlockAndHeader(root)
+ if !ok {
+ return nil, false, nil
+ }
+ headerRoot, err := header.HashSSZ()
+ if err != nil {
+ return nil, false, err
+ }
+ if headerRoot != root {
+ return nil, false, nil
+ }
+ return &cltypes.SignedBeaconBlockHeader{Header: header.Copy(), Signature: blk.Signature}, true, nil
+}
diff --git a/cl/beacon/handler/headers_test.go b/cl/beacon/handler/headers_test.go
index d6833cc9b9b..1975a21ada0 100644
--- a/cl/beacon/handler/headers_test.go
+++ b/cl/beacon/handler/headers_test.go
@@ -19,6 +19,7 @@ package handler
import (
"context"
"encoding/json"
+ "errors"
"math"
"net/http"
"net/http/httptest"
@@ -27,14 +28,38 @@ import (
"github.com/stretchr/testify/require"
antiquarytests "github.com/erigontech/erigon/cl/antiquary/tests"
+ "github.com/erigontech/erigon/cl/beacon/synced_data"
"github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/cltypes"
"github.com/erigontech/erigon/cl/persistence/beacon_indicies"
+ "github.com/erigontech/erigon/cl/phase1/forkchoice"
"github.com/erigontech/erigon/common"
"github.com/erigontech/erigon/common/log/v3"
"github.com/erigontech/erigon/db/kv"
+ "github.com/erigontech/erigon/db/snapshotsync/freezeblocks"
)
+type gatedBeaconHeaderReader struct {
+ freezeblocks.BeaconSnapshotReader
+ entered chan struct{}
+ release chan struct{}
+}
+
+func (r *gatedBeaconHeaderReader) ReadHeaderByRoot(context.Context, kv.Tx, common.Hash) (*cltypes.SignedBeaconBlockHeader, error) {
+ close(r.entered)
+ <-r.release
+ return nil, nil
+}
+
+type failingBeaconHeaderReader struct {
+ freezeblocks.BeaconSnapshotReader
+ err error
+}
+
+func (r *failingBeaconHeaderReader) ReadHeaderByRoot(context.Context, kv.Tx, common.Hash) (*cltypes.SignedBeaconBlockHeader, error) {
+ return nil, r.err
+}
+
func TestGetHeadersIncludesFinalized(t *testing.T) {
db, blocks, _, _, _, handler, _, _, fcu, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), false)
blockRoot, err := blocks[0].Block.HashSSZ()
@@ -167,3 +192,136 @@ func TestGetHeadHeaderIsCanonicalBeforeDatabasePromotion(t *testing.T) {
require.Equal(t, common.Hash(headRoot), body.Data.Root)
require.True(t, body.Data.Canonical)
}
+
+func TestGetHeaderByRootUsesImportedBlockBeforePersistence(t *testing.T) {
+ _, blocks, _, _, _, handler, _, syncData, forkchoiceStore, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), true)
+ block := blocks[len(blocks)-1].Clone().(*cltypes.SignedBeaconBlock)
+ block.Block.Slot++
+ root, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ forkchoiceStore.Blocks[root] = block
+ forkchoiceStore.Headers[root] = block.SignedBeaconBlockHeader().Header
+ handler.enableMemoizedHeadState = true
+ syncData.(*synced_data.SyncedDataManager).OnSelectedHead(root, block.Block.Slot)
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/beacon/headers/0x"+common.Bytes2Hex(root[:]), http.NoBody)
+ recorder := httptest.NewRecorder()
+
+ handler.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+ var response struct {
+ Data headerResponse `json:"data"`
+ }
+ require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
+ require.Equal(t, common.Hash(root), response.Data.Root)
+ require.True(t, response.Data.Canonical)
+ require.Equal(t, block.Block.Slot, response.Data.Header.Header.Slot)
+}
+
+func TestGetHeaderByRootKeepsLiveSideBranchNonCanonical(t *testing.T) {
+ _, blocks, _, _, _, handler, _, syncData, forkchoiceStore, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), true)
+ block := blocks[len(blocks)-1].Clone().(*cltypes.SignedBeaconBlock)
+ block.Block.Slot++
+ root, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ forkchoiceStore.Blocks[root] = block
+ forkchoiceStore.Headers[root] = block.SignedBeaconBlockHeader().Header
+ handler.enableMemoizedHeadState = true
+ syncData.(*synced_data.SyncedDataManager).OnSelectedHead(common.Hash{0xaa}, block.Block.Slot+1)
+ forkchoiceStore.Ancestors[block.Block.Slot] = forkchoice.ForkChoiceNode{Root: common.Hash{0xbb}}
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/beacon/headers/0x"+common.Bytes2Hex(root[:]), http.NoBody)
+ recorder := httptest.NewRecorder()
+
+ handler.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+ var response struct {
+ Data headerResponse `json:"data"`
+ }
+ require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
+ require.False(t, response.Data.Canonical)
+}
+
+func TestGetHeaderByRootRejectsUnavailableForkchoiceBlock(t *testing.T) {
+ _, blocks, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), false)
+ block := blocks[len(blocks)-1].Clone().(*cltypes.SignedBeaconBlock)
+ block.Block.Slot++
+ unvalidatedRoot, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ forkchoiceStore.Blocks[unvalidatedRoot] = block
+ mismatchedRoot := common.Hash{0xff}
+ forkchoiceStore.Blocks[mismatchedRoot] = block
+ forkchoiceStore.Headers[mismatchedRoot] = block.SignedBeaconBlockHeader().Header
+ headerOnlyRoot := common.Hash{0xdd}
+ forkchoiceStore.Headers[headerOnlyRoot] = block.SignedBeaconBlockHeader().Header
+
+ for _, testCase := range []struct {
+ name string
+ root common.Hash
+ }{
+ {name: "unknown", root: common.Hash{0xee}},
+ {name: "unvalidated", root: unvalidatedRoot},
+ {name: "mismatched", root: mismatchedRoot},
+ {name: "header only", root: headerOnlyRoot},
+ } {
+ t.Run(testCase.name, func(t *testing.T) {
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/beacon/headers/0x"+common.Bytes2Hex(testCase.root[:]), http.NoBody)
+ recorder := httptest.NewRecorder()
+
+ handler.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusNotFound, recorder.Code, recorder.Body.String())
+ })
+ }
+}
+
+func TestGetHeaderByRootPreservesDatabaseReaderError(t *testing.T) {
+ _, blocks, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), false)
+ block := blocks[len(blocks)-1].Clone().(*cltypes.SignedBeaconBlock)
+ block.Block.Slot++
+ root, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ forkchoiceStore.Blocks[root] = block
+ forkchoiceStore.Headers[root] = block.SignedBeaconBlockHeader().Header
+ databaseErr := errors.New("header database unavailable")
+ handler.blockReader = &failingBeaconHeaderReader{BeaconSnapshotReader: handler.blockReader, err: databaseErr}
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/beacon/headers/0x"+common.Bytes2Hex(root[:]), http.NoBody)
+ recorder := httptest.NewRecorder()
+
+ handler.ServeHTTP(recorder, request)
+
+ require.Equal(t, http.StatusInternalServerError, recorder.Code, recorder.Body.String())
+ require.Contains(t, recorder.Body.String(), databaseErr.Error())
+}
+
+func TestGetHeaderByRootSamplesOptimisticStatusAfterFallback(t *testing.T) {
+ _, blocks, _, _, _, handler, _, _, forkchoiceStore, _ := setupTestingHandler(t, clparams.Phase0Version, log.Root(), false)
+ block := blocks[len(blocks)-1].Clone().(*cltypes.SignedBeaconBlock)
+ block.Block.Slot++
+ root, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ reader := &gatedBeaconHeaderReader{
+ BeaconSnapshotReader: handler.blockReader,
+ entered: make(chan struct{}),
+ release: make(chan struct{}),
+ }
+ handler.blockReader = reader
+ response := make(chan *httptest.ResponseRecorder, 1)
+ request := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/eth/v1/beacon/headers/0x"+common.Bytes2Hex(root[:]), http.NoBody)
+
+ go func() {
+ recorder := httptest.NewRecorder()
+ handler.ServeHTTP(recorder, request)
+ response <- recorder
+ }()
+
+ <-reader.entered
+ forkchoiceStore.IsRootOptimisticVal = true
+ forkchoiceStore.Blocks[root] = block
+ forkchoiceStore.Headers[root] = block.SignedBeaconBlockHeader().Header
+ close(reader.release)
+ recorder := <-response
+
+ require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
+ require.Contains(t, recorder.Body.String(), `"execution_optimistic":true`)
+}
diff --git a/cl/beacon/handler/state_builders_test.go b/cl/beacon/handler/state_builders_test.go
new file mode 100644
index 00000000000..a25fb8f553d
--- /dev/null
+++ b/cl/beacon/handler/state_builders_test.go
@@ -0,0 +1,152 @@
+// Copyright 2026 The Erigon Authors
+// This file is part of Erigon.
+//
+// Erigon is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Erigon is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with Erigon. If not, see .
+
+package handler
+
+import (
+ "bytes"
+ "encoding/json"
+ "math"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/erigontech/erigon/cl/clparams"
+ "github.com/erigontech/erigon/cl/cltypes"
+ "github.com/erigontech/erigon/cl/cltypes/solid"
+ "github.com/erigontech/erigon/cl/phase1/core/state"
+ "github.com/erigontech/erigon/common"
+ "github.com/erigontech/erigon/common/log/v3"
+)
+
+type stateBuildersHTTPResponse struct {
+ ExecutionOptimistic bool `json:"execution_optimistic"`
+ Finalized bool `json:"finalized"`
+ Data []struct {
+ Index string `json:"index"`
+ Status string `json:"status"`
+ Builder struct {
+ Pubkey common.Bytes48 `json:"pubkey"`
+ Version string `json:"version"`
+ } `json:"builder"`
+ } `json:"data"`
+}
+
+func setupStateBuildersHandler(t *testing.T) (*ApiHandler, *state.CachingBeaconState, [3]*cltypes.Builder) {
+ t.Helper()
+ _, blocks, _, _, postState, handler, _, syncedData, fcu, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true)
+
+ postState.SetVersion(clparams.GloasVersion)
+ postState.SetFinalizedCheckpoint(solid.Checkpoint{Epoch: 5})
+ builders := [3]*cltypes.Builder{
+ {Pubkey: common.Bytes48{1}, Balance: 11, DepositEpoch: 5, WithdrawableEpoch: math.MaxUint64},
+ {Pubkey: common.Bytes48{2}, Balance: 22, DepositEpoch: 4, WithdrawableEpoch: math.MaxUint64},
+ {Pubkey: common.Bytes48{3}, Balance: 33, DepositEpoch: 1, WithdrawableEpoch: 7},
+ }
+ registry := solid.NewStaticListSSZ[*cltypes.Builder](int(postState.BeaconConfig().BuilderRegistryLimit), new(cltypes.Builder).EncodingSizeSSZ())
+ for _, builder := range builders {
+ registry.Append(builder)
+ }
+ postState.SetBuilders(registry)
+ require.NoError(t, syncedData.OnHeadState(postState))
+ fcu.HeadVal, _ = blocks[len(blocks)-1].Block.HashSSZ()
+ fcu.HeadSlotVal = postState.Slot()
+ fcu.IsRootOptimisticVal = true
+ return handler, postState, builders
+}
+
+func postStateBuilders(t *testing.T, handler *ApiHandler, stateID, body string) (*http.Response, stateBuildersHTTPResponse) {
+ t.Helper()
+ server := httptest.NewServer(handler.mux)
+ t.Cleanup(server.Close)
+ req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, server.URL+"/eth/v1/beacon/states/"+stateID+"/builders", bytes.NewBufferString(body))
+ require.NoError(t, err)
+ if body != "" {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ resp, err := server.Client().Do(req)
+ require.NoError(t, err)
+ var decoded stateBuildersHTTPResponse
+ if resp.StatusCode == http.StatusOK {
+ require.NoError(t, json.NewDecoder(resp.Body).Decode(&decoded))
+ }
+ return resp, decoded
+}
+
+func TestPostStateBuildersReturnsAndFiltersBuilders(t *testing.T) {
+ handler, _, builders := setupStateBuildersHandler(t)
+
+ t.Run("all without body", func(t *testing.T) {
+ resp, decoded := postStateBuilders(t, handler, "head", "")
+ defer resp.Body.Close()
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ require.True(t, decoded.ExecutionOptimistic)
+ require.False(t, decoded.Finalized)
+ require.Equal(t, []string{"0", "1", "2"}, []string{decoded.Data[0].Index, decoded.Data[1].Index, decoded.Data[2].Index})
+ require.Equal(t, []string{"pending", "active", "exited"}, []string{decoded.Data[0].Status, decoded.Data[1].Status, decoded.Data[2].Status})
+ require.Equal(t, "0", decoded.Data[0].Builder.Version)
+ require.Equal(t, builders[0].Pubkey, decoded.Data[0].Builder.Pubkey)
+ })
+
+ t.Run("index pubkey and status", func(t *testing.T) {
+ body, err := json.Marshal(map[string]any{
+ "ids": []string{"0", builders[1].Pubkey.String(), "999"},
+ "statuses": []string{"active", "pending"},
+ })
+ require.NoError(t, err)
+ resp, decoded := postStateBuilders(t, handler, "head", string(body))
+ defer resp.Body.Close()
+ require.Equal(t, http.StatusOK, resp.StatusCode)
+ require.Len(t, decoded.Data, 2)
+ require.Equal(t, []string{"0", "1"}, []string{decoded.Data[0].Index, decoded.Data[1].Index})
+ })
+}
+
+func TestPostStateBuildersRejectsInvalidFilters(t *testing.T) {
+ handler, _, _ := setupStateBuildersHandler(t)
+ tests := []string{
+ `{"ids":["nope"]}`,
+ `{"ids":["0","0"]}`,
+ `{"statuses":["unknown"]}`,
+ `{"statuses":["active","active"]}`,
+ `{"ids":`,
+ `{"unknown":true}`,
+ `null`,
+ `{} {}`,
+ `{"ids":["01","1"]}`,
+ }
+ for _, body := range tests {
+ t.Run(body, func(t *testing.T) {
+ resp, _ := postStateBuilders(t, handler, "head", body)
+ defer resp.Body.Close()
+ require.Equal(t, http.StatusBadRequest, resp.StatusCode)
+ })
+ }
+}
+
+func TestPostStateBuildersRejectsPreGloasAndMissingState(t *testing.T) {
+ _, _, _, _, _, preGloasHandler, _, _, _, _ := setupTestingHandler(t, clparams.ElectraVersion, log.Root(), true)
+ resp, _ := postStateBuilders(t, preGloasHandler, "head", `{}`)
+ resp.Body.Close()
+ require.Equal(t, http.StatusBadRequest, resp.StatusCode)
+
+ handler, _, _ := setupStateBuildersHandler(t)
+ resp, _ = postStateBuilders(t, handler, "finalized", `{}`)
+ defer resp.Body.Close()
+ require.Equal(t, http.StatusNotFound, resp.StatusCode)
+}
diff --git a/cl/beacon/handler/utils_test.go b/cl/beacon/handler/utils_test.go
index b1de28752ed..c1d4fa77cac 100644
--- a/cl/beacon/handler/utils_test.go
+++ b/cl/beacon/handler/utils_test.go
@@ -127,6 +127,12 @@ func setupTestingHandler(t *testing.T, v clparams.StateVersion, logger log.Logge
voluntaryExitService := mock_services.NewMockVoluntaryExitService(ctrl)
blsToExecutionChangeService := mock_services.NewMockBLSToExecutionChangeService(ctrl)
proposerSlashingService := mock_services.NewMockProposerSlashingService(ctrl)
+ blockService := mock_services.NewMockBlockService(ctrl)
+ blockService.EXPECT().ValidateGossip(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
+ blockService.EXPECT().CommitGossipReservation(gomock.Any()).AnyTimes()
+ blockService.EXPECT().ReleaseGossipReservation(gomock.Any()).AnyTimes()
+ blockService.EXPECT().ScheduleBlockForLaterProcessing(gomock.Any()).AnyTimes()
+ blockService.EXPECT().SchedulePublishedBlockForLaterProcessing(gomock.Any(), gomock.Any()).AnyTimes()
// ctx context.Context, subnetID *uint64, msg *cltypes.SyncCommitteeMessage) error
syncCommitteeMessagesService.EXPECT().ProcessMessage(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, subnetID *uint64, msg *services.SyncCommitteeMessageForGossip) error {
@@ -187,6 +193,7 @@ func setupTestingHandler(t *testing.T, v clparams.StateVersion, logger log.Logge
voluntaryExitService,
blsToExecutionChangeService,
proposerSlashingService,
+ blockService,
nil,
nil,
gossipManager,
diff --git a/cl/beacon/handler/validator_test.go b/cl/beacon/handler/validator_test.go
index 2b45245d4ea..ead23dec2a3 100644
--- a/cl/beacon/handler/validator_test.go
+++ b/cl/beacon/handler/validator_test.go
@@ -28,8 +28,11 @@ import (
mockaggregation "github.com/erigontech/erigon/cl/aggregation/mock_services"
"github.com/erigontech/erigon/cl/beacon/beacon_router_configuration"
+ "github.com/erigontech/erigon/cl/beacon/beaconhttp"
+ "github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/cltypes/solid"
"github.com/erigontech/erigon/cl/pool"
+ "github.com/erigontech/erigon/cl/utils/eth_clock"
"github.com/erigontech/erigon/common"
)
@@ -77,6 +80,7 @@ func (t *validatorTestSuite) SetupTest() {
nil,
nil,
nil,
+ nil,
false,
nil,
nil,
@@ -217,6 +221,55 @@ func (t *validatorTestSuite) TestGetEthV1ValidatorAggregateAttestation() {
}
}
+func (t *validatorTestSuite) TestGetEthV2ValidatorAggregateAttestationUsesPreElectraPool() {
+ clock := eth_clock.NewMockEthereumClock(t.gomockCtrl)
+ t.apiHandler.ethClock = clock
+ t.apiHandler.beaconChainCfg = &clparams.BeaconChainConfig{SlotsPerEpoch: 8}
+
+ dataRoot := common.HexToHash("0x123")
+ attestation := &solid.Attestation{Data: &solid.AttestationData{Slot: 1}}
+ clock.EXPECT().StateVersionByEpoch(uint64(0)).Return(clparams.DenebVersion)
+ t.mockAggrPool.EXPECT().GetAggregatationByRoot(dataRoot).Return(attestation)
+
+ req := httptest.NewRequestWithContext(t.T().Context(), http.MethodGet, "/eth/v2/validator/aggregate_attestation?attestation_data_root="+dataRoot.String()+"&slot=1&committee_index=0", nil)
+ response, err := t.apiHandler.GetEthV2ValidatorAggregateAttestation(httptest.NewRecorder(), req)
+ t.Require().NoError(err)
+ t.Require().Same(attestation, response.Data)
+}
+
+func (t *validatorTestSuite) TestGetEthV2ValidatorAggregateAttestationRejectsPreElectraCommitteeMismatch() {
+ clock := eth_clock.NewMockEthereumClock(t.gomockCtrl)
+ t.apiHandler.ethClock = clock
+ t.apiHandler.beaconChainCfg = &clparams.BeaconChainConfig{SlotsPerEpoch: 8}
+
+ dataRoot := common.HexToHash("0x123")
+ attestation := &solid.Attestation{Data: &solid.AttestationData{Slot: 1, CommitteeIndex: 0}}
+ clock.EXPECT().StateVersionByEpoch(uint64(0)).Return(clparams.DenebVersion)
+ t.mockAggrPool.EXPECT().GetAggregatationByRoot(dataRoot).Return(attestation)
+
+ req := httptest.NewRequestWithContext(t.T().Context(), http.MethodGet, "/eth/v2/validator/aggregate_attestation?attestation_data_root="+dataRoot.String()+"&slot=1&committee_index=3", nil)
+ _, err := t.apiHandler.GetEthV2ValidatorAggregateAttestation(httptest.NewRecorder(), req)
+ var endpointErr *beaconhttp.EndpointError
+ t.Require().ErrorAs(err, &endpointErr)
+ t.Require().Equal(http.StatusBadRequest, endpointErr.Code)
+}
+
+func (t *validatorTestSuite) TestGetEthV2ValidatorAggregateAttestationUsesCommitteePoolAfterElectra() {
+ clock := eth_clock.NewMockEthereumClock(t.gomockCtrl)
+ t.apiHandler.ethClock = clock
+ t.apiHandler.beaconChainCfg = &clparams.BeaconChainConfig{SlotsPerEpoch: 8}
+
+ dataRoot := common.HexToHash("0x123")
+ attestation := &solid.Attestation{Data: &solid.AttestationData{Slot: 8}}
+ clock.EXPECT().StateVersionByEpoch(uint64(1)).Return(clparams.ElectraVersion)
+ t.mockAggrPool.EXPECT().GetAggregatationByRootAndCommittee(dataRoot, uint64(3)).Return(attestation)
+
+ req := httptest.NewRequestWithContext(t.T().Context(), http.MethodGet, "/eth/v2/validator/aggregate_attestation?attestation_data_root="+dataRoot.String()+"&slot=8&committee_index=3", nil)
+ response, err := t.apiHandler.GetEthV2ValidatorAggregateAttestation(httptest.NewRecorder(), req)
+ t.Require().NoError(err)
+ t.Require().Same(attestation, response.Data)
+}
+
func TestValidator(t *testing.T) {
suite.Run(t, new(validatorTestSuite))
}
diff --git a/cl/beacon/handler/validators.go b/cl/beacon/handler/validators.go
index 3b5c7f6fed7..37b9e1b41a9 100644
--- a/cl/beacon/handler/validators.go
+++ b/cl/beacon/handler/validators.go
@@ -31,6 +31,7 @@ import (
"github.com/erigontech/erigon/cl/beacon/beaconhttp"
"github.com/erigontech/erigon/cl/beacon/synced_data"
+ "github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/cltypes/solid"
"github.com/erigontech/erigon/cl/persistence/beacon_indicies"
state_accessors "github.com/erigontech/erigon/cl/persistence/state"
@@ -775,16 +776,24 @@ func (a *ApiHandler) GetEthV2ValidatorAggregateAttestation(w http.ResponseWriter
}
attDataRootHash := common.HexToHash(attDataRoot)
- att := a.aggregatePool.GetAggregatationByRootAndCommittee(attDataRootHash, committeeIndexNum)
+ version := a.ethClock.StateVersionByEpoch(slotNum / a.beaconChainCfg.SlotsPerEpoch)
+ var att *solid.Attestation
+ if version.Before(clparams.ElectraVersion) {
+ att = a.aggregatePool.GetAggregatationByRoot(attDataRootHash)
+ } else {
+ att = a.aggregatePool.GetAggregatationByRootAndCommittee(attDataRootHash, committeeIndexNum)
+ }
if att == nil {
return nil, beaconhttp.NewEndpointError(http.StatusNotFound, fmt.Errorf("attestation %s not found", attDataRoot))
}
+ if version.Before(clparams.ElectraVersion) && committeeIndexNum != att.Data.CommitteeIndex {
+ return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("attestation committee index mismatch"))
+ }
if slotNum != att.Data.Slot {
log.Debug("attestation slot does not match", "attestation_data_root", attDataRoot, "slot_inquire", slot)
return nil, beaconhttp.NewEndpointError(http.StatusBadRequest, errors.New("attestation slot mismatch"))
}
- version := a.ethClock.StateVersionByEpoch(slotNum / a.beaconChainCfg.SlotsPerEpoch)
return newBeaconResponse(att).WithVersion(version), nil
}
diff --git a/cl/clparams/config.go b/cl/clparams/config.go
index 907d42d4221..65674fa0148 100644
--- a/cl/clparams/config.go
+++ b/cl/clparams/config.go
@@ -67,6 +67,8 @@ type CaplinConfig struct {
// CaplinMeVRelayUrl is optional and is used to connect to the external builder service.
// If it's set, the node will start in builder mode
MevRelayUrl string
+ // AllowPrivateBuilderURLs permits validator-configured builder URLs to resolve to private addresses.
+ AllowPrivateBuilderURLs bool
// EnableValidatorMonitor is used to enable the validator monitor metrics and corresponding logs
EnableValidatorMonitor bool
diff --git a/cl/cltypes/beacon_block.go b/cl/cltypes/beacon_block.go
index 6a246586b1e..e2362aaca43 100644
--- a/cl/cltypes/beacon_block.go
+++ b/cl/cltypes/beacon_block.go
@@ -127,6 +127,10 @@ func (b *SignedBeaconBlock) DecodeSSZ(buf []byte, s int) error {
return ssz2.UnmarshalSSZ(buf, s, b.Block, b.Signature[:])
}
+func (b *SignedBeaconBlock) DecodeSSZStrict(buf []byte, version int) error {
+ return ssz2.UnmarshalSSZStrict(buf, version, b.Block, b.Signature[:])
+}
+
func (b *SignedBeaconBlock) HashSSZ() ([32]byte, error) {
return merkle_tree.HashTreeRoot(b.Block, b.Signature[:])
}
@@ -201,6 +205,10 @@ func (b *BeaconBlock) DecodeSSZ(buf []byte, version int) error {
return ssz2.UnmarshalSSZ(buf, version, &b.Slot, &b.ProposerIndex, b.ParentRoot[:], b.StateRoot[:], b.Body)
}
+func (b *BeaconBlock) DecodeSSZStrict(buf []byte, version int) error {
+ return ssz2.UnmarshalSSZStrict(buf, version, &b.Slot, &b.ProposerIndex, b.ParentRoot[:], b.StateRoot[:], b.Body)
+}
+
func (b *BeaconBlock) HashSSZ() ([32]byte, error) {
return merkle_tree.HashTreeRoot(b.Slot, b.ProposerIndex, b.ParentRoot[:], b.StateRoot[:], b.Body)
}
@@ -516,6 +524,14 @@ func (b *BeaconBody) EncodingSizeSSZ() (size int) {
}
func (b *BeaconBody) DecodeSSZ(buf []byte, version int) error {
+ return b.decodeSSZ(buf, version, false)
+}
+
+func (b *BeaconBody) DecodeSSZStrict(buf []byte, version int) error {
+ return b.decodeSSZ(buf, version, true)
+}
+
+func (b *BeaconBody) decodeSSZ(buf []byte, version int, strict bool) error {
b.Version = clparams.StateVersion(version)
if b.Version >= clparams.GloasVersion {
b.resetGloasProgressiveLists()
@@ -538,7 +554,13 @@ func (b *BeaconBody) DecodeSSZ(buf []byte, version int) error {
}
b.ParentExecutionRequests = NewExecutionRequestsWithVersion(b.beaconCfg, b.Version)
}
- if err := ssz2.UnmarshalSSZ(buf, version, b.getSchema(false)...); err != nil {
+ var err error
+ if strict {
+ err = ssz2.UnmarshalSSZStrict(buf, version, b.getSchema(false)...)
+ } else {
+ err = ssz2.UnmarshalSSZ(buf, version, b.getSchema(false)...)
+ }
+ if err != nil {
return err
}
@@ -744,6 +766,10 @@ func (b *BeaconBody) KzgCommitmentsInclusionProof() ([][32]byte, error) {
func (b *BeaconBody) UnmarshalJSON(buf []byte) error {
limits := beaconBodyLimitsForConfig(b.beaconCfg, b.Version)
+ ptcSize := clparams.MaxPtcSize
+ if b.beaconCfg != nil && b.beaconCfg.PtcSize > 0 {
+ ptcSize = b.beaconCfg.PtcSize
+ }
var tmp struct {
RandaoReveal common.Bytes96 `json:"randao_reveal"`
@@ -789,10 +815,6 @@ func (b *BeaconBody) UnmarshalJSON(buf []byte) error {
BlobKzgCommitments: *solid.NewStaticProgressiveListSSZ[*KZGCommitment](maxBlobCommitmentsForConfig(b.beaconCfg), 48),
},
}
- ptcSize := clparams.MaxPtcSize
- if b.beaconCfg != nil && b.beaconCfg.PtcSize > 0 {
- ptcSize = b.beaconCfg.PtcSize
- }
tmp.PayloadAttestations = solid.NewStaticProgressiveListSSZ[*PayloadAttestation](maxPayloadAttestationsForConfig(b.beaconCfg), PayloadAttestationSSZSizeWithPtcSize(ptcSize))
tmp.ParentExecutionRequests = NewExecutionRequestsWithVersion(b.beaconCfg, b.Version)
}
@@ -819,6 +841,9 @@ func (b *BeaconBody) UnmarshalJSON(buf []byte) error {
if attestation == nil || attestation.AggregationBits == nil || attestation.Data == nil {
return fmt.Errorf("payload attestation %d is incomplete", i)
}
+ if err := attestation.AggregationBits.ValidateSize(int(ptcSize)); err != nil {
+ return fmt.Errorf("payload attestation %d aggregation bits: %w", i, err)
+ }
return nil
}); err != nil {
return err
@@ -1050,10 +1075,6 @@ type DenebSignedBeaconBlock struct {
SignedBlock *SignedBeaconBlock `json:"signed_block"`
KZGProofs *solid.ListSSZ[*KZGProof] `json:"kzg_proofs"`
Blobs *solid.ListSSZ[*Blob] `json:"blobs"`
- // SignedExecutionPayloadEnvelope is the validator-signed envelope for GLOAS self-build blocks.
- // Present only on POST /eth/v2/beacon/blocks when the validator client submits a signed
- // envelope alongside the signed block. [New in Gloas:EIP7732]
- SignedExecutionPayloadEnvelope *SignedExecutionPayloadEnvelope `json:"signed_execution_payload_envelope,omitempty"`
}
func NewDenebSignedBeaconBlock(beaconCfg *clparams.BeaconChainConfig, version clparams.StateVersion) *DenebSignedBeaconBlock {
diff --git a/cl/cltypes/beacon_block_test.go b/cl/cltypes/beacon_block_test.go
index 51996450c40..5f29f2760d5 100644
--- a/cl/cltypes/beacon_block_test.go
+++ b/cl/cltypes/beacon_block_test.go
@@ -19,6 +19,7 @@ package cltypes
import (
_ "embed"
"encoding/json"
+ "fmt"
"math"
"testing"
@@ -678,3 +679,50 @@ func TestBeaconBodyGloasProgressiveLimitsUseConfig(t *testing.T) {
require.NoError(t, body.ProposerSlashings.DecodeSSZ(make([]byte, 33*416), int(clparams.GloasVersion)))
}
}
+
+func TestSignedBeaconBlockGloasJSONValidatesPayloadAttestationBits(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ ptcSize uint64
+ bits string
+ wantErr string
+ }{
+ {name: "configured width", ptcSize: 16, bits: "0x0000"},
+ {name: "short width", ptcSize: 16, bits: "0x00", wantErr: "invalid bitvector byte length"},
+ {name: "non-byte-aligned width", ptcSize: 10, bits: "0x0003"},
+ {name: "non-byte-aligned unused bits", ptcSize: 10, bits: "0x0004", wantErr: "invalid bitvector unused bits"},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ cfg := clparams.MainnetBeaconConfig
+ cfg.PtcSize = test.ptcSize
+ block := NewSignedBeaconBlock(&cfg, clparams.GloasVersion)
+ input := []byte(fmt.Sprintf(`{"message":{"body":{"payload_attestations":[{"aggregation_bits":%q,"data":{}}]}}}`, test.bits))
+
+ err := json.Unmarshal(input, block)
+ if test.wantErr != "" {
+ require.ErrorContains(t, err, test.wantErr)
+ return
+ }
+ require.NoError(t, err)
+ })
+ }
+}
+
+func TestSignedBeaconBlockJSONPayloadAttestationValidationVersionBoundary(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ version clparams.StateVersion
+ input string
+ }{
+ {name: "Fulu body", version: clparams.FuluVersion, input: `{"message":{"body":{}}}`},
+ {name: "Gloas omitted attestations", version: clparams.GloasVersion, input: `{"message":{"body":{}}}`},
+ {name: "Gloas empty attestations", version: clparams.GloasVersion, input: `{"message":{"body":{"payload_attestations":[]}}}`},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ cfg := clparams.MainnetBeaconConfig
+ block := NewSignedBeaconBlock(&cfg, test.version)
+
+ require.NoError(t, json.Unmarshal([]byte(test.input), block))
+ })
+ }
+}
diff --git a/cl/cltypes/epbs_builder_api.go b/cl/cltypes/epbs_builder_api.go
new file mode 100644
index 00000000000..e93b5016b55
--- /dev/null
+++ b/cl/cltypes/epbs_builder_api.go
@@ -0,0 +1,952 @@
+// Copyright 2026 The Erigon Authors
+// This file is part of Erigon.
+//
+// Erigon is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Erigon is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with Erigon. If not, see .
+
+package cltypes
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/url"
+ "unicode/utf8"
+
+ "github.com/erigontech/erigon/cl/cltypes/solid"
+ "github.com/erigontech/erigon/cl/merkle_tree"
+ ssz2 "github.com/erigontech/erigon/cl/ssz"
+ "github.com/erigontech/erigon/common"
+ "github.com/erigontech/erigon/common/clonable"
+ "github.com/erigontech/erigon/common/hexutil"
+ commonssz "github.com/erigontech/erigon/common/ssz"
+)
+
+const (
+ MaxBuilderAuthDataSize = 4096
+ MaxBuilderEntries = 64
+ MaxBuilderURLSize = 2048
+ MaxBuilderPubkeys = 64
+ MaxBuilderPreferencesEntries = 4096
+)
+
+type BuilderRequestAuth struct {
+ Data hexutil.Bytes `json:"data"`
+ Slot uint64 `json:"slot,string"`
+}
+
+func (b *BuilderRequestAuth) Static() bool { return false }
+
+func (b *BuilderRequestAuth) EncodingSizeSSZ() int { return 4 + 8 + len(b.Data) }
+
+func (b *BuilderRequestAuth) EncodeSSZ(dst []byte) ([]byte, error) {
+ if err := validateBuilderAuthData(b.Data); err != nil {
+ return nil, err
+ }
+ return ssz2.MarshalSSZ(dst, &rawByteList{value: &b.Data, limit: MaxBuilderAuthDataSize}, b.Slot)
+}
+
+func (b *BuilderRequestAuth) DecodeSSZ(buf []byte, version int) error {
+ return b.DecodeSSZStrict(buf, version)
+}
+
+func (b *BuilderRequestAuth) DecodeSSZStrict(buf []byte, version int) error {
+ if err := ssz2.UnmarshalSSZStrict(buf, version, &rawByteList{value: &b.Data, limit: MaxBuilderAuthDataSize}, &b.Slot); err != nil {
+ return err
+ }
+ return validateBuilderAuthData(b.Data)
+}
+
+func (b *BuilderRequestAuth) Clone() clonable.Clonable {
+ if b == nil {
+ return &BuilderRequestAuth{}
+ }
+ return &BuilderRequestAuth{Data: bytes.Clone(b.Data), Slot: b.Slot}
+}
+
+func (b *BuilderRequestAuth) HashSSZ() ([32]byte, error) {
+ if err := validateBuilderAuthData(b.Data); err != nil {
+ return [32]byte{}, err
+ }
+ data := solid.NewByteListSSZ(MaxBuilderAuthDataSize)
+ if err := data.SetBytes(b.Data); err != nil {
+ return [32]byte{}, err
+ }
+ return merkle_tree.HashTreeRoot(data, b.Slot)
+}
+
+func (b *BuilderRequestAuth) UnmarshalJSON(data []byte) error {
+ var value struct {
+ Data *hexutil.Bytes `json:"data"`
+ Slot *uint64 `json:"slot,string"`
+ }
+ if err := decodeStrictJSON(data, &value); err != nil {
+ return err
+ }
+ if value.Data == nil || value.Slot == nil {
+ return errors.New("builder request auth requires data and slot")
+ }
+ if err := validateBuilderAuthData(*value.Data); err != nil {
+ return err
+ }
+ b.Data = bytes.Clone(*value.Data)
+ b.Slot = *value.Slot
+ return nil
+}
+
+func (b BuilderRequestAuth) MarshalJSON() ([]byte, error) {
+ if err := validateBuilderAuthData(b.Data); err != nil {
+ return nil, err
+ }
+ type builderRequestAuth BuilderRequestAuth
+ return json.Marshal(builderRequestAuth(b))
+}
+
+type SignedBuilderRequestAuth struct {
+ Message *BuilderRequestAuth `json:"message"`
+ Signature common.Bytes96 `json:"signature"`
+}
+
+func (s *SignedBuilderRequestAuth) Static() bool { return false }
+
+func (s *SignedBuilderRequestAuth) EncodingSizeSSZ() int {
+ if s.Message == nil {
+ return 4 + len(s.Signature)
+ }
+ return 4 + len(s.Signature) + s.Message.EncodingSizeSSZ()
+}
+
+func (s *SignedBuilderRequestAuth) EncodeSSZ(dst []byte) ([]byte, error) {
+ if s.Message == nil {
+ return nil, errors.New("signed builder request auth has nil message")
+ }
+ return ssz2.MarshalSSZ(dst, s.Message, s.Signature[:])
+}
+
+func (s *SignedBuilderRequestAuth) DecodeSSZ(buf []byte, version int) error {
+ return s.DecodeSSZStrict(buf, version)
+}
+
+func (s *SignedBuilderRequestAuth) DecodeSSZStrict(buf []byte, version int) error {
+ s.Message = new(BuilderRequestAuth)
+ return ssz2.UnmarshalSSZStrict(buf, version, s.Message, s.Signature[:])
+}
+
+func (s *SignedBuilderRequestAuth) Clone() clonable.Clonable {
+ if s == nil {
+ return &SignedBuilderRequestAuth{}
+ }
+ var message *BuilderRequestAuth
+ if s.Message != nil {
+ message = s.Message.Clone().(*BuilderRequestAuth)
+ }
+ return &SignedBuilderRequestAuth{Message: message, Signature: s.Signature}
+}
+
+func (s *SignedBuilderRequestAuth) HashSSZ() ([32]byte, error) {
+ if s.Message == nil {
+ return [32]byte{}, errors.New("signed builder request auth has nil message")
+ }
+ return merkle_tree.HashTreeRoot(s.Message, s.Signature[:])
+}
+
+func (s *SignedBuilderRequestAuth) UnmarshalJSON(data []byte) error {
+ var value struct {
+ Message *BuilderRequestAuth `json:"message"`
+ Signature *common.Bytes96 `json:"signature"`
+ }
+ if err := decodeStrictJSON(data, &value); err != nil {
+ return err
+ }
+ if value.Message == nil || value.Signature == nil {
+ return errors.New("signed builder request auth requires message and signature")
+ }
+ s.Message = value.Message
+ s.Signature = *value.Signature
+ return nil
+}
+
+func (s SignedBuilderRequestAuth) MarshalJSON() ([]byte, error) {
+ if s.Message == nil {
+ return nil, errors.New("signed builder request auth has nil message")
+ }
+ if err := validateBuilderAuthData(s.Message.Data); err != nil {
+ return nil, err
+ }
+ type signedBuilderRequestAuth SignedBuilderRequestAuth
+ return json.Marshal(signedBuilderRequestAuth(s))
+}
+
+type BuilderEntry struct {
+ URL string `json:"url"`
+ Auth *SignedBuilderRequestAuth `json:"auth"`
+ BuilderPubkeys []common.Bytes48 `json:"builder_pubkeys"`
+ MaxExecutionPayment uint64 `json:"max_execution_payment,string"`
+ MinBid uint64 `json:"min_bid,string"`
+ BuilderBoostFactor uint64 `json:"builder_boost_factor,string"`
+}
+
+func (b *BuilderEntry) Static() bool { return false }
+
+func (b *BuilderEntry) EncodingSizeSSZ() int {
+ size := 4 + 4 + 4 + 8 + 8 + 8 + len(b.URL) + len(b.BuilderPubkeys)*len(common.Bytes48{})
+ if b.Auth != nil {
+ size += b.Auth.EncodingSizeSSZ()
+ }
+ return size
+}
+
+func (b *BuilderEntry) EncodeSSZ(dst []byte) ([]byte, error) {
+ if err := b.validate(); err != nil {
+ return nil, err
+ }
+ urlBytes := hexutil.Bytes(b.URL)
+ return ssz2.MarshalSSZ(dst,
+ &rawByteList{value: &urlBytes, limit: MaxBuilderURLSize},
+ b.Auth,
+ &builderPubkeyList{values: &b.BuilderPubkeys},
+ b.MaxExecutionPayment,
+ b.MinBid,
+ b.BuilderBoostFactor,
+ )
+}
+
+func (b *BuilderEntry) DecodeSSZ(buf []byte, version int) error {
+ return b.DecodeSSZStrict(buf, version)
+}
+
+func (b *BuilderEntry) DecodeSSZStrict(buf []byte, version int) error {
+ var urlBytes hexutil.Bytes
+ b.Auth = new(SignedBuilderRequestAuth)
+ b.BuilderPubkeys = nil
+ if err := ssz2.UnmarshalSSZStrict(buf, version,
+ &rawByteList{value: &urlBytes, limit: MaxBuilderURLSize},
+ b.Auth,
+ &builderPubkeyList{values: &b.BuilderPubkeys},
+ &b.MaxExecutionPayment,
+ &b.MinBid,
+ &b.BuilderBoostFactor,
+ ); err != nil {
+ return err
+ }
+ b.URL = string(urlBytes)
+ return b.validate()
+}
+
+func (b *BuilderEntry) Clone() clonable.Clonable {
+ if b == nil {
+ return &BuilderEntry{}
+ }
+ var auth *SignedBuilderRequestAuth
+ if b.Auth != nil {
+ auth = b.Auth.Clone().(*SignedBuilderRequestAuth)
+ }
+ return &BuilderEntry{
+ URL: b.URL, Auth: auth, BuilderPubkeys: append([]common.Bytes48(nil), b.BuilderPubkeys...),
+ MaxExecutionPayment: b.MaxExecutionPayment, MinBid: b.MinBid, BuilderBoostFactor: b.BuilderBoostFactor,
+ }
+}
+
+func (b *BuilderEntry) UnmarshalJSON(data []byte) error {
+ var value struct {
+ URL *string `json:"url"`
+ Auth *SignedBuilderRequestAuth `json:"auth"`
+ BuilderPubkeys *[]common.Bytes48 `json:"builder_pubkeys"`
+ MaxExecutionPayment *uint64 `json:"max_execution_payment,string"`
+ MinBid *uint64 `json:"min_bid,string"`
+ BuilderBoostFactor *uint64 `json:"builder_boost_factor,string"`
+ }
+ if err := decodeStrictJSON(data, &value); err != nil {
+ return err
+ }
+ if value.URL == nil || value.Auth == nil || value.BuilderPubkeys == nil || value.MaxExecutionPayment == nil || value.MinBid == nil || value.BuilderBoostFactor == nil {
+ return errors.New("builder entry is missing a required field")
+ }
+ *b = BuilderEntry{URL: *value.URL, Auth: value.Auth, BuilderPubkeys: *value.BuilderPubkeys, MaxExecutionPayment: *value.MaxExecutionPayment, MinBid: *value.MinBid, BuilderBoostFactor: *value.BuilderBoostFactor}
+ return b.validate()
+}
+
+func (b BuilderEntry) MarshalJSON() ([]byte, error) {
+ if err := b.validate(); err != nil {
+ return nil, err
+ }
+ type builderEntry BuilderEntry
+ return json.Marshal(builderEntry(b))
+}
+
+func (b *BuilderEntry) validate() error {
+ if err := validateBuilderURL(b.URL); err != nil {
+ return err
+ }
+ if b.Auth == nil || b.Auth.Message == nil {
+ return errors.New("builder entry has nil auth")
+ }
+ if len(b.BuilderPubkeys) > MaxBuilderPubkeys {
+ return fmt.Errorf("builder pubkey count %d exceeds %d", len(b.BuilderPubkeys), MaxBuilderPubkeys)
+ }
+ return validateBuilderAuthData(b.Auth.Message.Data)
+}
+
+func (b *BuilderEntry) Validate() error {
+ return b.validate()
+}
+
+type BuilderConfig struct {
+ MinBid uint64 `json:"min_bid,string"`
+ BuilderBoostFactor uint64 `json:"builder_boost_factor,string"`
+ Builders []*BuilderEntry `json:"builders"`
+}
+
+func (b *BuilderConfig) Static() bool { return false }
+
+func (b *BuilderConfig) EncodingSizeSSZ() int {
+ return 8 + 8 + 4 + (&builderEntryList{values: &b.Builders}).EncodingSizeSSZ()
+}
+
+func (b *BuilderConfig) EncodeSSZ(dst []byte) ([]byte, error) {
+ if err := b.validate(); err != nil {
+ return nil, err
+ }
+ return ssz2.MarshalSSZ(dst, b.MinBid, b.BuilderBoostFactor, &builderEntryList{values: &b.Builders})
+}
+
+func (b *BuilderConfig) DecodeSSZ(buf []byte, version int) error {
+ return b.DecodeSSZStrict(buf, version)
+}
+
+func (b *BuilderConfig) DecodeSSZStrict(buf []byte, version int) error {
+ b.Builders = nil
+ if err := ssz2.UnmarshalSSZStrict(buf, version, &b.MinBid, &b.BuilderBoostFactor, &builderEntryList{values: &b.Builders}); err != nil {
+ return err
+ }
+ return b.validate()
+}
+
+func (b *BuilderConfig) DecodeSSZStrictStructural(buf []byte, version int) error {
+ b.Builders = nil
+ rawEntries := make([]*rawBuilderEntry, 0)
+ if err := ssz2.UnmarshalSSZStrict(buf, version, &b.MinBid, &b.BuilderBoostFactor, &rawBuilderEntryList{values: &rawEntries}); err != nil {
+ return err
+ }
+ b.Builders = make([]*BuilderEntry, len(rawEntries))
+ for i, raw := range rawEntries {
+ b.Builders[i] = raw.entry
+ }
+ return nil
+}
+
+func (b *BuilderConfig) Clone() clonable.Clonable {
+ clone := &BuilderConfig{MinBid: b.MinBid, BuilderBoostFactor: b.BuilderBoostFactor, Builders: make([]*BuilderEntry, len(b.Builders))}
+ for i, entry := range b.Builders {
+ if entry != nil {
+ clone.Builders[i] = entry.Clone().(*BuilderEntry)
+ }
+ }
+ return clone
+}
+
+func (b *BuilderConfig) UnmarshalJSON(data []byte) error {
+ var value struct {
+ MinBid *uint64 `json:"min_bid,string"`
+ BuilderBoostFactor *uint64 `json:"builder_boost_factor,string"`
+ Builders *[]*BuilderEntry `json:"builders"`
+ }
+ if err := decodeStrictJSON(data, &value); err != nil {
+ return err
+ }
+ if value.MinBid == nil || value.BuilderBoostFactor == nil || value.Builders == nil {
+ return errors.New("builder config is missing a required field")
+ }
+ if len(*value.Builders) > MaxBuilderEntries {
+ return fmt.Errorf("builder count %d exceeds %d", len(*value.Builders), MaxBuilderEntries)
+ }
+ for i, entry := range *value.Builders {
+ if entry == nil {
+ return fmt.Errorf("builder %d is nil", i)
+ }
+ }
+ *b = BuilderConfig{MinBid: *value.MinBid, BuilderBoostFactor: *value.BuilderBoostFactor, Builders: *value.Builders}
+ return b.validate()
+}
+
+func (b BuilderConfig) MarshalJSON() ([]byte, error) {
+ if b.Builders == nil {
+ return nil, errors.New("builder config has nil builders")
+ }
+ if err := b.validate(); err != nil {
+ return nil, err
+ }
+ type builderConfig BuilderConfig
+ return json.Marshal(builderConfig(b))
+}
+
+func (b *BuilderConfig) validate() error {
+ if len(b.Builders) > MaxBuilderEntries {
+ return fmt.Errorf("builder count %d exceeds %d", len(b.Builders), MaxBuilderEntries)
+ }
+ seen := make(map[string]struct{}, len(b.Builders))
+ for i, entry := range b.Builders {
+ if entry == nil {
+ return fmt.Errorf("builder %d is nil", i)
+ }
+ if err := entry.validate(); err != nil {
+ return fmt.Errorf("builder %d: %w", i, err)
+ }
+ key := entry.URL + "\x00" + string(entry.Auth.Message.Data)
+ if _, ok := seen[key]; ok {
+ return fmt.Errorf("builder %d duplicates URL and auth data", i)
+ }
+ seen[key] = struct{}{}
+ }
+ return nil
+}
+
+type BuilderPreferencesEntry struct {
+ ProposerPubkey common.Bytes48 `json:"proposer_pubkey"`
+ URL string `json:"url"`
+ Auth *SignedBuilderRequestAuth `json:"auth"`
+ MaxExecutionPayment uint64 `json:"max_execution_payment,string"`
+}
+
+type BuilderPreferencesEntries []*BuilderPreferencesEntry
+
+func (b BuilderPreferencesEntries) Static() bool { return false }
+
+func (b BuilderPreferencesEntries) EncodingSizeSSZ() int {
+ size := len(b) * 4
+ for _, entry := range b {
+ if entry != nil {
+ size += entry.EncodingSizeSSZ()
+ }
+ }
+ return size
+}
+
+func (b BuilderPreferencesEntries) EncodeSSZ(dst []byte) ([]byte, error) {
+ if err := b.validate(); err != nil {
+ return nil, err
+ }
+ return commonssz.EncodeDynamicList(dst, b)
+}
+
+func (b *BuilderPreferencesEntries) DecodeSSZ(buf []byte, version int) error {
+ return b.DecodeSSZStrict(buf, version)
+}
+
+func (b *BuilderPreferencesEntries) DecodeSSZStrict(buf []byte, version int) error {
+ entries, err := commonssz.DecodeDynamicListStrict[*BuilderPreferencesEntry](buf, 0, uint32(len(buf)), MaxBuilderPreferencesEntries, version)
+ if err != nil {
+ return err
+ }
+ *b = entries
+ return b.validate()
+}
+
+func (b BuilderPreferencesEntries) Clone() clonable.Clonable {
+ clone := make(BuilderPreferencesEntries, len(b))
+ for i, entry := range b {
+ if entry != nil {
+ clone[i] = entry.Clone().(*BuilderPreferencesEntry)
+ }
+ }
+ return &clone
+}
+
+func (b BuilderPreferencesEntries) MarshalJSON() ([]byte, error) {
+ if err := b.validate(); err != nil {
+ return nil, err
+ }
+ if b == nil {
+ return []byte("[]"), nil
+ }
+ type builderPreferencesEntries BuilderPreferencesEntries
+ return json.Marshal(builderPreferencesEntries(b))
+}
+
+func (b *BuilderPreferencesEntries) UnmarshalJSON(data []byte) error {
+ if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
+ return errors.New("builder preferences entries cannot be null")
+ }
+ type builderPreferencesEntries BuilderPreferencesEntries
+ var entries builderPreferencesEntries
+ if err := json.Unmarshal(data, &entries); err != nil {
+ return err
+ }
+ *b = BuilderPreferencesEntries(entries)
+ return b.validate()
+}
+
+func (b BuilderPreferencesEntries) validate() error {
+ if len(b) > MaxBuilderPreferencesEntries {
+ return fmt.Errorf("builder preferences entry count %d exceeds %d", len(b), MaxBuilderPreferencesEntries)
+ }
+ for i, entry := range b {
+ if entry == nil {
+ return fmt.Errorf("builder preferences entry %d is nil", i)
+ }
+ if err := entry.validate(); err != nil {
+ return fmt.Errorf("builder preferences entry %d: %w", i, err)
+ }
+ }
+ return nil
+}
+
+func (b *BuilderPreferencesEntry) Static() bool { return false }
+
+func (b *BuilderPreferencesEntry) EncodingSizeSSZ() int {
+ size := len(b.ProposerPubkey) + 4 + 4 + 8 + len(b.URL)
+ if b.Auth != nil {
+ size += b.Auth.EncodingSizeSSZ()
+ }
+ return size
+}
+
+func (b *BuilderPreferencesEntry) EncodeSSZ(dst []byte) ([]byte, error) {
+ if err := b.validate(); err != nil {
+ return nil, err
+ }
+ urlBytes := hexutil.Bytes(b.URL)
+ return ssz2.MarshalSSZ(dst, b.ProposerPubkey[:], &rawByteList{value: &urlBytes, limit: MaxBuilderURLSize}, b.Auth, b.MaxExecutionPayment)
+}
+
+func (b *BuilderPreferencesEntry) DecodeSSZ(buf []byte, version int) error {
+ return b.DecodeSSZStrict(buf, version)
+}
+
+func (b *BuilderPreferencesEntry) DecodeSSZStrict(buf []byte, version int) error {
+ var urlBytes hexutil.Bytes
+ b.Auth = new(SignedBuilderRequestAuth)
+ if err := ssz2.UnmarshalSSZStrict(buf, version, b.ProposerPubkey[:], &rawByteList{value: &urlBytes, limit: MaxBuilderURLSize}, b.Auth, &b.MaxExecutionPayment); err != nil {
+ return err
+ }
+ b.URL = string(urlBytes)
+ return b.validate()
+}
+
+func (b *BuilderPreferencesEntry) Clone() clonable.Clonable {
+ if b == nil {
+ return &BuilderPreferencesEntry{}
+ }
+ var auth *SignedBuilderRequestAuth
+ if b.Auth != nil {
+ auth = b.Auth.Clone().(*SignedBuilderRequestAuth)
+ }
+ return &BuilderPreferencesEntry{ProposerPubkey: b.ProposerPubkey, URL: b.URL, Auth: auth, MaxExecutionPayment: b.MaxExecutionPayment}
+}
+
+func (b *BuilderPreferencesEntry) UnmarshalJSON(data []byte) error {
+ var value struct {
+ ProposerPubkey *common.Bytes48 `json:"proposer_pubkey"`
+ URL *string `json:"url"`
+ Auth *SignedBuilderRequestAuth `json:"auth"`
+ MaxExecutionPayment *uint64 `json:"max_execution_payment,string"`
+ }
+ if err := decodeStrictJSON(data, &value); err != nil {
+ return err
+ }
+ if value.ProposerPubkey == nil || value.URL == nil || value.Auth == nil || value.MaxExecutionPayment == nil {
+ return errors.New("builder preferences entry is missing a required field")
+ }
+ *b = BuilderPreferencesEntry{ProposerPubkey: *value.ProposerPubkey, URL: *value.URL, Auth: value.Auth, MaxExecutionPayment: *value.MaxExecutionPayment}
+ return b.validate()
+}
+
+func (b BuilderPreferencesEntry) MarshalJSON() ([]byte, error) {
+ if err := b.validate(); err != nil {
+ return nil, err
+ }
+ type builderPreferencesEntry BuilderPreferencesEntry
+ return json.Marshal(builderPreferencesEntry(b))
+}
+
+func (b *BuilderPreferencesEntry) validate() error {
+ if err := validateBuilderURL(b.URL); err != nil {
+ return err
+ }
+ if b.Auth == nil || b.Auth.Message == nil {
+ return errors.New("builder preferences entry has nil auth")
+ }
+ return validateBuilderAuthData(b.Auth.Message.Data)
+}
+
+type BuilderPreferences struct {
+ MaxExecutionPayment uint64 `json:"max_execution_payment,string"`
+}
+
+func (b *BuilderPreferences) Static() bool { return true }
+func (b *BuilderPreferences) EncodingSizeSSZ() int { return 8 }
+func (b *BuilderPreferences) EncodeSSZ(dst []byte) ([]byte, error) {
+ return ssz2.MarshalSSZ(dst, b.MaxExecutionPayment)
+}
+func (b *BuilderPreferences) DecodeSSZ(buf []byte, version int) error {
+ return ssz2.UnmarshalSSZStrict(buf, version, &b.MaxExecutionPayment)
+}
+func (b *BuilderPreferences) DecodeSSZStrict(buf []byte, version int) error {
+ return b.DecodeSSZ(buf, version)
+}
+func (b *BuilderPreferences) Clone() clonable.Clonable {
+ return &BuilderPreferences{MaxExecutionPayment: b.MaxExecutionPayment}
+}
+
+func (b *BuilderPreferences) HashSSZ() ([32]byte, error) {
+ return merkle_tree.HashTreeRoot(b.MaxExecutionPayment)
+}
+
+func (b *BuilderPreferences) UnmarshalJSON(data []byte) error {
+ var value struct {
+ MaxExecutionPayment *uint64 `json:"max_execution_payment,string"`
+ }
+ if err := decodeStrictJSON(data, &value); err != nil {
+ return err
+ }
+ if value.MaxExecutionPayment == nil {
+ return errors.New("builder preferences requires max_execution_payment")
+ }
+ b.MaxExecutionPayment = *value.MaxExecutionPayment
+ return nil
+}
+
+type BuilderPreferencesRequest struct {
+ Preferences *BuilderPreferences `json:"preferences"`
+ Auth *SignedBuilderRequestAuth `json:"auth"`
+}
+
+func (b *BuilderPreferencesRequest) Static() bool { return false }
+func (b *BuilderPreferencesRequest) EncodingSizeSSZ() int {
+ size := 8 + 4
+ if b.Auth != nil {
+ size += b.Auth.EncodingSizeSSZ()
+ }
+ return size
+}
+func (b *BuilderPreferencesRequest) EncodeSSZ(dst []byte) ([]byte, error) {
+ if b.Preferences == nil || b.Auth == nil || b.Auth.Message == nil {
+ return nil, errors.New("builder preferences request has nil field")
+ }
+ return ssz2.MarshalSSZ(dst, b.Preferences, b.Auth)
+}
+func (b *BuilderPreferencesRequest) DecodeSSZ(buf []byte, version int) error {
+ return b.DecodeSSZStrict(buf, version)
+}
+func (b *BuilderPreferencesRequest) DecodeSSZStrict(buf []byte, version int) error {
+ b.Preferences = new(BuilderPreferences)
+ b.Auth = new(SignedBuilderRequestAuth)
+ return ssz2.UnmarshalSSZStrict(buf, version, b.Preferences, b.Auth)
+}
+func (b *BuilderPreferencesRequest) Clone() clonable.Clonable {
+ clone := &BuilderPreferencesRequest{}
+ if b.Preferences != nil {
+ clone.Preferences = b.Preferences.Clone().(*BuilderPreferences)
+ }
+ if b.Auth != nil {
+ clone.Auth = b.Auth.Clone().(*SignedBuilderRequestAuth)
+ }
+ return clone
+}
+
+func (b *BuilderPreferencesRequest) HashSSZ() ([32]byte, error) {
+ if b.Preferences == nil || b.Auth == nil {
+ return [32]byte{}, errors.New("builder preferences request has nil field")
+ }
+ return merkle_tree.HashTreeRoot(b.Preferences, b.Auth)
+}
+func (b *BuilderPreferencesRequest) UnmarshalJSON(data []byte) error {
+ var value struct {
+ Preferences *BuilderPreferences `json:"preferences"`
+ Auth *SignedBuilderRequestAuth `json:"auth"`
+ }
+ if err := decodeStrictJSON(data, &value); err != nil {
+ return err
+ }
+ if value.Preferences == nil || value.Auth == nil {
+ return errors.New("builder preferences request is missing a required field")
+ }
+ b.Preferences, b.Auth = value.Preferences, value.Auth
+ return nil
+}
+
+func (b BuilderPreferencesRequest) MarshalJSON() ([]byte, error) {
+ if b.Preferences == nil || b.Auth == nil || b.Auth.Message == nil {
+ return nil, errors.New("builder preferences request has nil field")
+ }
+ type builderPreferencesRequest BuilderPreferencesRequest
+ return json.Marshal(builderPreferencesRequest(b))
+}
+
+type rawByteList struct {
+ value *hexutil.Bytes
+ limit int
+}
+
+func (r *rawByteList) Static() bool { return false }
+func (r *rawByteList) EncodingSizeSSZ() int { return len(*r.value) }
+func (r *rawByteList) EncodeSSZ(dst []byte) ([]byte, error) {
+ if len(*r.value) > r.limit {
+ return nil, fmt.Errorf("byte list length %d exceeds %d", len(*r.value), r.limit)
+ }
+ return append(dst, (*r.value)...), nil
+}
+func (r *rawByteList) DecodeSSZ(buf []byte, _ int) error {
+ if len(buf) > r.limit {
+ return fmt.Errorf("byte list length %d exceeds %d", len(buf), r.limit)
+ }
+ *r.value = bytes.Clone(buf)
+ return nil
+}
+func (r *rawByteList) DecodeSSZStrict(buf []byte, version int) error {
+ return r.DecodeSSZ(buf, version)
+}
+func (r *rawByteList) Clone() clonable.Clonable {
+ value := hexutil.Bytes(nil)
+ return &rawByteList{value: &value, limit: r.limit}
+}
+
+type builderPubkeyList struct{ values *[]common.Bytes48 }
+
+func (l *builderPubkeyList) Static() bool { return false }
+func (l *builderPubkeyList) EncodingSizeSSZ() int { return len(*l.values) * len(common.Bytes48{}) }
+func (l *builderPubkeyList) EncodeSSZ(dst []byte) ([]byte, error) {
+ if len(*l.values) > MaxBuilderPubkeys {
+ return nil, fmt.Errorf("builder pubkey count %d exceeds %d", len(*l.values), MaxBuilderPubkeys)
+ }
+ for i := range *l.values {
+ dst = append(dst, (*l.values)[i][:]...)
+ }
+ return dst, nil
+}
+func (l *builderPubkeyList) DecodeSSZ(buf []byte, _ int) error {
+ if len(buf)%len(common.Bytes48{}) != 0 {
+ return commonssz.ErrLowBufferSize
+ }
+ count := len(buf) / len(common.Bytes48{})
+ if count > MaxBuilderPubkeys {
+ return fmt.Errorf("builder pubkey count %d exceeds %d", count, MaxBuilderPubkeys)
+ }
+ *l.values = make([]common.Bytes48, count)
+ for i := range *l.values {
+ copy((*l.values)[i][:], buf[i*len(common.Bytes48{}):])
+ }
+ return nil
+}
+func (l *builderPubkeyList) DecodeSSZStrict(buf []byte, version int) error {
+ return l.DecodeSSZ(buf, version)
+}
+func (l *builderPubkeyList) Clone() clonable.Clonable {
+ values := []common.Bytes48(nil)
+ return &builderPubkeyList{values: &values}
+}
+
+type builderEntryList struct{ values *[]*BuilderEntry }
+
+func (l *builderEntryList) Static() bool { return false }
+func (l *builderEntryList) EncodingSizeSSZ() int {
+ size := len(*l.values) * 4
+ for _, entry := range *l.values {
+ if entry != nil {
+ size += entry.EncodingSizeSSZ()
+ }
+ }
+ return size
+}
+
+type rawBuilderRequestAuth struct {
+ data hexutil.Bytes
+ slot uint64
+}
+
+func (r *rawBuilderRequestAuth) Static() bool { return false }
+func (r *rawBuilderRequestAuth) EncodingSizeSSZ() int {
+ return 4 + 8 + len(r.data)
+}
+func (r *rawBuilderRequestAuth) EncodeSSZ(dst []byte) ([]byte, error) {
+ return ssz2.MarshalSSZ(dst, &rawByteList{value: &r.data, limit: MaxBuilderAuthDataSize}, r.slot)
+}
+func (r *rawBuilderRequestAuth) DecodeSSZ(buf []byte, version int) error {
+ return r.DecodeSSZStrict(buf, version)
+}
+func (r *rawBuilderRequestAuth) DecodeSSZStrict(buf []byte, version int) error {
+ return ssz2.UnmarshalSSZStrict(buf, version, &rawByteList{value: &r.data, limit: MaxBuilderAuthDataSize}, &r.slot)
+}
+func (r *rawBuilderRequestAuth) Clone() clonable.Clonable { return new(rawBuilderRequestAuth) }
+
+type rawSignedBuilderRequestAuth struct {
+ message *rawBuilderRequestAuth
+ signature common.Bytes96
+}
+
+func (r *rawSignedBuilderRequestAuth) Static() bool { return false }
+func (r *rawSignedBuilderRequestAuth) EncodingSizeSSZ() int {
+ if r.message == nil {
+ return 4 + len(r.signature)
+ }
+ return 4 + len(r.signature) + r.message.EncodingSizeSSZ()
+}
+func (r *rawSignedBuilderRequestAuth) EncodeSSZ(dst []byte) ([]byte, error) {
+ return ssz2.MarshalSSZ(dst, r.message, r.signature[:])
+}
+func (r *rawSignedBuilderRequestAuth) DecodeSSZ(buf []byte, version int) error {
+ return r.DecodeSSZStrict(buf, version)
+}
+func (r *rawSignedBuilderRequestAuth) DecodeSSZStrict(buf []byte, version int) error {
+ r.message = new(rawBuilderRequestAuth)
+ return ssz2.UnmarshalSSZStrict(buf, version, r.message, r.signature[:])
+}
+func (r *rawSignedBuilderRequestAuth) Clone() clonable.Clonable {
+ return new(rawSignedBuilderRequestAuth)
+}
+
+type rawBuilderEntry struct{ entry *BuilderEntry }
+
+func (r *rawBuilderEntry) Static() bool { return false }
+func (r *rawBuilderEntry) EncodingSizeSSZ() int {
+ if r.entry == nil {
+ return 0
+ }
+ return r.entry.EncodingSizeSSZ()
+}
+func (r *rawBuilderEntry) EncodeSSZ(dst []byte) ([]byte, error) {
+ if r.entry == nil || r.entry.Auth == nil || r.entry.Auth.Message == nil {
+ return nil, errors.New("raw builder entry has nil auth")
+ }
+ urlBytes := hexutil.Bytes(r.entry.URL)
+ auth := &rawSignedBuilderRequestAuth{
+ message: &rawBuilderRequestAuth{data: r.entry.Auth.Message.Data, slot: r.entry.Auth.Message.Slot},
+ signature: r.entry.Auth.Signature,
+ }
+ return ssz2.MarshalSSZ(dst,
+ &rawByteList{value: &urlBytes, limit: MaxBuilderURLSize},
+ auth,
+ &builderPubkeyList{values: &r.entry.BuilderPubkeys},
+ r.entry.MaxExecutionPayment,
+ r.entry.MinBid,
+ r.entry.BuilderBoostFactor,
+ )
+}
+func (r *rawBuilderEntry) DecodeSSZ(buf []byte, version int) error {
+ return r.DecodeSSZStrict(buf, version)
+}
+func (r *rawBuilderEntry) DecodeSSZStrict(buf []byte, version int) error {
+ var urlBytes hexutil.Bytes
+ var auth rawSignedBuilderRequestAuth
+ var pubkeys []common.Bytes48
+ entry := new(BuilderEntry)
+ if err := ssz2.UnmarshalSSZStrict(buf, version,
+ &rawByteList{value: &urlBytes, limit: MaxBuilderURLSize},
+ &auth,
+ &builderPubkeyList{values: &pubkeys},
+ &entry.MaxExecutionPayment,
+ &entry.MinBid,
+ &entry.BuilderBoostFactor,
+ ); err != nil {
+ return err
+ }
+ entry.URL = string(urlBytes)
+ entry.BuilderPubkeys = pubkeys
+ entry.Auth = &SignedBuilderRequestAuth{
+ Message: &BuilderRequestAuth{Data: auth.message.data, Slot: auth.message.slot},
+ Signature: auth.signature,
+ }
+ r.entry = entry
+ return nil
+}
+func (r *rawBuilderEntry) Clone() clonable.Clonable { return new(rawBuilderEntry) }
+
+type rawBuilderEntryList struct{ values *[]*rawBuilderEntry }
+
+func (l *rawBuilderEntryList) Static() bool { return false }
+func (l *rawBuilderEntryList) EncodingSizeSSZ() int {
+ size := len(*l.values) * 4
+ for _, entry := range *l.values {
+ size += entry.EncodingSizeSSZ()
+ }
+ return size
+}
+func (l *rawBuilderEntryList) EncodeSSZ(dst []byte) ([]byte, error) {
+ return commonssz.EncodeDynamicList(dst, *l.values)
+}
+func (l *rawBuilderEntryList) DecodeSSZ(buf []byte, version int) error {
+ return l.DecodeSSZStrict(buf, version)
+}
+func (l *rawBuilderEntryList) DecodeSSZStrict(buf []byte, version int) error {
+ values, err := commonssz.DecodeDynamicListStrict[*rawBuilderEntry](buf, 0, uint32(len(buf)), MaxBuilderEntries, version)
+ if err != nil {
+ return err
+ }
+ *l.values = values
+ return nil
+}
+func (l *rawBuilderEntryList) Clone() clonable.Clonable {
+ values := []*rawBuilderEntry(nil)
+ return &rawBuilderEntryList{values: &values}
+}
+func (l *builderEntryList) EncodeSSZ(dst []byte) ([]byte, error) {
+ if len(*l.values) > MaxBuilderEntries {
+ return nil, fmt.Errorf("builder count %d exceeds %d", len(*l.values), MaxBuilderEntries)
+ }
+ for i, entry := range *l.values {
+ if entry == nil {
+ return nil, fmt.Errorf("builder %d is nil", i)
+ }
+ }
+ return commonssz.EncodeDynamicList(dst, *l.values)
+}
+func (l *builderEntryList) DecodeSSZ(buf []byte, version int) error {
+ values, err := commonssz.DecodeDynamicListStrict[*BuilderEntry](buf, 0, uint32(len(buf)), MaxBuilderEntries, version)
+ if err != nil {
+ return err
+ }
+ *l.values = values
+ return nil
+}
+func (l *builderEntryList) DecodeSSZStrict(buf []byte, version int) error {
+ return l.DecodeSSZ(buf, version)
+}
+func (l *builderEntryList) Clone() clonable.Clonable {
+ values := []*BuilderEntry(nil)
+ return &builderEntryList{values: &values}
+}
+
+func validateBuilderAuthData(data []byte) error {
+ if len(data) == 0 || len(data) > MaxBuilderAuthDataSize {
+ return fmt.Errorf("builder auth data length %d is outside [1,%d]", len(data), MaxBuilderAuthDataSize)
+ }
+ return nil
+}
+
+func validateBuilderURL(builderURL string) error {
+ if len(builderURL) == 0 || len(builderURL) > MaxBuilderURLSize {
+ return fmt.Errorf("builder URL length %d is outside [1,%d]", len(builderURL), MaxBuilderURLSize)
+ }
+ if !utf8.ValidString(builderURL) {
+ return errors.New("builder URL is not valid UTF-8")
+ }
+ parsed, err := url.Parse(builderURL)
+ if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || parsed.User != nil {
+ return errors.New("builder URL must be an HTTP(S) URL without user information")
+ }
+ return nil
+}
+
+func decodeStrictJSON(data []byte, value any) error {
+ decoder := json.NewDecoder(bytes.NewReader(data))
+ decoder.DisallowUnknownFields()
+ if err := decoder.Decode(value); err != nil {
+ return err
+ }
+ if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) {
+ return errors.New("JSON contains trailing data")
+ }
+ return nil
+}
diff --git a/cl/cltypes/epbs_builder_api_test.go b/cl/cltypes/epbs_builder_api_test.go
new file mode 100644
index 00000000000..254d6aa92d9
--- /dev/null
+++ b/cl/cltypes/epbs_builder_api_test.go
@@ -0,0 +1,211 @@
+// Copyright 2026 The Erigon Authors
+// This file is part of Erigon.
+//
+// Erigon is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Erigon is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with Erigon. If not, see .
+
+package cltypes
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ ssz2 "github.com/erigontech/erigon/cl/ssz"
+ "github.com/erigontech/erigon/common"
+ "github.com/erigontech/erigon/common/hexutil"
+)
+
+func validSignedBuilderRequestAuth() *SignedBuilderRequestAuth {
+ return &SignedBuilderRequestAuth{
+ Message: &BuilderRequestAuth{Data: hexutil.Bytes("builder-auth"), Slot: 12},
+ }
+}
+
+func TestBuilderRequestAuthJSONAndSSZRoundTrip(t *testing.T) {
+ want := &BuilderRequestAuth{Data: hexutil.Bytes{0x12, 0x34}, Slot: 42}
+
+ encodedJSON, err := json.Marshal(want)
+ require.NoError(t, err)
+ require.JSONEq(t, `{"data":"0x1234","slot":"42"}`, string(encodedJSON))
+ var fromJSON BuilderRequestAuth
+ require.NoError(t, json.Unmarshal(encodedJSON, &fromJSON))
+ require.Equal(t, want, &fromJSON)
+
+ encodedSSZ, err := want.EncodeSSZ(nil)
+ require.NoError(t, err)
+ var fromSSZ BuilderRequestAuth
+ require.NoError(t, fromSSZ.DecodeSSZ(encodedSSZ, 0))
+ require.Equal(t, want, &fromSSZ)
+}
+
+func TestBuilderRequestAuthRejectsInvalidData(t *testing.T) {
+ for _, input := range []string{
+ `{"data":"0x","slot":"1"}`,
+ `{"data":"0x01","slot":"1","extra":true}`,
+ `{"data":null,"slot":"1"}`,
+ `{"slot":"1"}`,
+ `{"data":"0x01"}`,
+ `{"data":"0x` + strings.Repeat("ab", MaxBuilderAuthDataSize+1) + `","slot":"1"}`,
+ } {
+ var auth BuilderRequestAuth
+ require.Error(t, json.Unmarshal([]byte(input), &auth), input)
+ }
+
+ for _, data := range [][]byte{nil, make([]byte, MaxBuilderAuthDataSize+1)} {
+ auth := &BuilderRequestAuth{Data: data, Slot: 1}
+ _, err := auth.EncodeSSZ(nil)
+ require.Error(t, err)
+ }
+ var decoded BuilderRequestAuth
+ require.Error(t, decoded.DecodeSSZ(make([]byte, 12), 0))
+}
+
+func TestSignedBuilderRequestAuthRejectsNilMessage(t *testing.T) {
+ var auth SignedBuilderRequestAuth
+ _, err := auth.EncodeSSZ(nil)
+ require.Error(t, err)
+ require.Error(t, json.Unmarshal([]byte(`{"message":null,"signature":"0x`+strings.Repeat("00", 96)+`"}`), &auth))
+}
+
+func TestBuilderEntryJSONAndSSZRoundTrip(t *testing.T) {
+ entry := &BuilderEntry{
+ URL: "https://builder.example",
+ Auth: validSignedBuilderRequestAuth(),
+ BuilderPubkeys: []common.Bytes48{{1}, {2}},
+ MaxExecutionPayment: 20,
+ MinBid: 10,
+ BuilderBoostFactor: 100,
+ }
+
+ encodedJSON, err := json.Marshal(entry)
+ require.NoError(t, err)
+ var fromJSON BuilderEntry
+ require.NoError(t, json.Unmarshal(encodedJSON, &fromJSON))
+ require.Equal(t, entry, &fromJSON)
+
+ encodedSSZ, err := entry.EncodeSSZ(nil)
+ require.NoError(t, err)
+ var fromSSZ BuilderEntry
+ require.NoError(t, fromSSZ.DecodeSSZ(encodedSSZ, 0))
+ require.Equal(t, entry, &fromSSZ)
+}
+
+func TestBuilderEntryRejectsInvalidFields(t *testing.T) {
+ valid, err := json.Marshal(&BuilderEntry{
+ URL: "https://builder.example",
+ Auth: validSignedBuilderRequestAuth(),
+ BuilderPubkeys: []common.Bytes48{},
+ MaxExecutionPayment: 20,
+ MinBid: 10,
+ BuilderBoostFactor: 100,
+ })
+ require.NoError(t, err)
+
+ for _, mutate := range []func(map[string]any){
+ func(v map[string]any) { v["url"] = "" },
+ func(v map[string]any) { v["url"] = strings.Repeat("x", MaxBuilderURLSize+1) },
+ func(v map[string]any) { v["auth"] = nil },
+ func(v map[string]any) { delete(v, "builder_pubkeys") },
+ func(v map[string]any) { v["builder_pubkeys"] = make([]common.Bytes48, MaxBuilderPubkeys+1) },
+ func(v map[string]any) { v["unexpected"] = true },
+ } {
+ var value map[string]any
+ require.NoError(t, json.Unmarshal(valid, &value))
+ mutate(value)
+ input, err := json.Marshal(value)
+ require.NoError(t, err)
+ var entry BuilderEntry
+ require.Error(t, json.Unmarshal(input, &entry), string(input))
+ }
+}
+
+func TestBuilderConfigAndPreferencesLimits(t *testing.T) {
+ configJSON := `{"min_bid":"0","builder_boost_factor":"100","builders":[]}`
+ var config BuilderConfig
+ require.NoError(t, json.Unmarshal([]byte(configJSON), &config))
+ encoded, err := config.EncodeSSZ(nil)
+ require.NoError(t, err)
+ var decoded BuilderConfig
+ require.NoError(t, decoded.DecodeSSZ(encoded, 0))
+ require.Equal(t, &config, &decoded)
+
+ config.Builders = make([]*BuilderEntry, MaxBuilderEntries+1)
+ _, err = config.EncodeSSZ(nil)
+ require.Error(t, err)
+
+ prefs := &BuilderPreferencesRequest{
+ Preferences: &BuilderPreferences{MaxExecutionPayment: 11},
+ Auth: validSignedBuilderRequestAuth(),
+ }
+ encoded, err = prefs.EncodeSSZ(nil)
+ require.NoError(t, err)
+ var decodedPrefs BuilderPreferencesRequest
+ require.NoError(t, decodedPrefs.DecodeSSZ(encoded, 0))
+ require.Equal(t, prefs, &decodedPrefs)
+ require.Error(t, json.Unmarshal([]byte(`{"preferences":null,"auth":null}`), &decodedPrefs))
+}
+
+func TestBuilderConfigStructuralSSZDecodeIsolatesSemanticEntryErrors(t *testing.T) {
+ entries := []*rawBuilderEntry{
+ {entry: &BuilderEntry{URL: "ftp:////builder.example", Auth: &SignedBuilderRequestAuth{Message: &BuilderRequestAuth{Data: []byte("auth"), Slot: 12}}}},
+ {entry: &BuilderEntry{URL: "https://empty-auth.example", Auth: &SignedBuilderRequestAuth{Message: &BuilderRequestAuth{Slot: 12}}}},
+ {entry: &BuilderEntry{URL: "https://builder.example", Auth: validSignedBuilderRequestAuth()}},
+ }
+ encoded, err := ssz2.MarshalSSZ(nil, uint64(0), uint64(100), &rawBuilderEntryList{values: &entries})
+ require.NoError(t, err)
+
+ var strict BuilderConfig
+ require.Error(t, strict.DecodeSSZStrict(encoded, 0))
+ var structural BuilderConfig
+ require.NoError(t, structural.DecodeSSZStrictStructural(encoded, 0))
+ require.Len(t, structural.Builders, 3)
+ require.Error(t, structural.Builders[0].Validate())
+ require.Error(t, structural.Builders[1].Validate())
+ require.NoError(t, structural.Builders[2].Validate())
+}
+
+func TestBuilderPreferencesEntryRejectsEmptyURL(t *testing.T) {
+ entry := &BuilderPreferencesEntry{Auth: validSignedBuilderRequestAuth()}
+ _, err := entry.EncodeSSZ(nil)
+ require.Error(t, err)
+}
+
+func TestBuilderPreferencesEntriesJSONAndSSZ(t *testing.T) {
+ want := BuilderPreferencesEntries{
+ &BuilderPreferencesEntry{
+ ProposerPubkey: common.Bytes48{1},
+ URL: "https://builder.example",
+ Auth: validSignedBuilderRequestAuth(),
+ MaxExecutionPayment: 15,
+ },
+ }
+ encodedJSON, err := json.Marshal(want)
+ require.NoError(t, err)
+ var fromJSON BuilderPreferencesEntries
+ require.NoError(t, json.Unmarshal(encodedJSON, &fromJSON))
+ require.Equal(t, want, fromJSON)
+
+ encodedSSZ, err := want.EncodeSSZ(nil)
+ require.NoError(t, err)
+ var fromSSZ BuilderPreferencesEntries
+ require.NoError(t, fromSSZ.DecodeSSZ(encodedSSZ, 0))
+ require.Equal(t, want, fromSSZ)
+
+ tooMany := make(BuilderPreferencesEntries, MaxBuilderPreferencesEntries+1)
+ _, err = tooMany.EncodeSSZ(nil)
+ require.Error(t, err)
+ require.Error(t, json.Unmarshal([]byte("null"), &fromJSON))
+}
diff --git a/cl/cltypes/epbs_payload.go b/cl/cltypes/epbs_payload.go
index 857b003e234..8db9533558e 100644
--- a/cl/cltypes/epbs_payload.go
+++ b/cl/cltypes/epbs_payload.go
@@ -113,6 +113,10 @@ func (p *PayloadAttestationData) DecodeSSZ(buf []byte, version int) error {
return ssz2.UnmarshalSSZ(buf, version, p.BeaconBlockRoot[:], &p.Slot, &p.PayloadPresent, &p.BlobDataAvailable)
}
+func (p *PayloadAttestationData) DecodeSSZStrict(buf []byte, version int) error {
+ return ssz2.UnmarshalSSZStrict(buf, version, p.BeaconBlockRoot[:], &p.Slot, &p.PayloadPresent, &p.BlobDataAvailable)
+}
+
func (p *PayloadAttestationData) Clone() clonable.Clonable {
return &PayloadAttestationData{
BeaconBlockRoot: p.BeaconBlockRoot,
@@ -152,6 +156,14 @@ func (p *PayloadAttestation) EncodeSSZ(buf []byte) ([]byte, error) {
}
func (p *PayloadAttestation) DecodeSSZ(buf []byte, version int) error {
+ return p.decodeSSZ(buf, version, false)
+}
+
+func (p *PayloadAttestation) DecodeSSZStrict(buf []byte, version int) error {
+ return p.decodeSSZ(buf, version, true)
+}
+
+func (p *PayloadAttestation) decodeSSZ(buf []byte, version int, strict bool) error {
// Infer PTC_SIZE from the buffer length. The fixed structure is:
// AggregationBits (PTC_SIZE/8) + PayloadAttestationData (42) + Signature (96)
// so PTC_SIZE = (len(buf) - 42 - 96) * 8
@@ -162,6 +174,9 @@ func (p *PayloadAttestation) DecodeSSZ(buf []byte, version int) error {
}
p.AggregationBits = solid.NewBitVector(ptcSize)
p.Data = new(PayloadAttestationData)
+ if strict {
+ return ssz2.UnmarshalSSZStrict(buf, version, p.AggregationBits, p.Data, p.Signature[:])
+ }
return ssz2.UnmarshalSSZ(buf, version, p.AggregationBits, p.Data, p.Signature[:])
}
@@ -200,6 +215,11 @@ func (p *PayloadAttestationMessage) DecodeSSZ(buf []byte, version int) error {
return ssz2.UnmarshalSSZ(buf, version, &p.ValidatorIndex, p.Data, p.Signature[:])
}
+func (p *PayloadAttestationMessage) DecodeSSZStrict(buf []byte, version int) error {
+ p.Data = new(PayloadAttestationData)
+ return ssz2.UnmarshalSSZStrict(buf, version, &p.ValidatorIndex, p.Data, p.Signature[:])
+}
+
func (p *PayloadAttestationMessage) HashSSZ() ([32]byte, error) {
return merkle_tree.HashTreeRoot(p.ValidatorIndex, p.Data, p.Signature[:])
}
@@ -367,6 +387,25 @@ func (e *ExecutionPayloadBid) DecodeSSZ(buf []byte, version int) error {
)
}
+func (e *ExecutionPayloadBid) DecodeSSZStrict(buf []byte, version int) error {
+ e.BlobKzgCommitments.EnsureStaticProgressive(maxBlobCommitmentsForConfig(clparams.GetBeaconConfig()), 48)
+ return ssz2.UnmarshalSSZStrict(
+ buf, version,
+ e.ParentBlockHash[:],
+ e.ParentBlockRoot[:],
+ e.BlockHash[:],
+ e.PrevRandao[:],
+ e.FeeRecipient[:],
+ &e.GasLimit,
+ &e.BuilderIndex,
+ &e.Slot,
+ &e.Value,
+ &e.ExecutionPayment,
+ &e.BlobKzgCommitments,
+ e.ExecutionRequestsRoot[:],
+ )
+}
+
func (e *ExecutionPayloadBid) Clone() clonable.Clonable {
commitments := e.BlobKzgCommitments.Clone().(*solid.ListSSZ[*KZGCommitment])
commitments.EnsureStaticProgressive(maxBlobCommitmentsForConfig(clparams.GetBeaconConfig()), 48)
@@ -440,6 +479,15 @@ func (s *SignedExecutionPayloadBid) DecodeSSZ(buf []byte, version int) error {
return ssz2.UnmarshalSSZ(buf, version, s.Message, s.Signature[:])
}
+func (s *SignedExecutionPayloadBid) DecodeSSZStrict(buf []byte, version int) error {
+ if s.Message == nil {
+ s.Message = &ExecutionPayloadBid{
+ BlobKzgCommitments: *solid.NewStaticProgressiveListSSZ[*KZGCommitment](maxBlobCommitmentsForConfig(clparams.GetBeaconConfig()), 48),
+ }
+ }
+ return ssz2.UnmarshalSSZStrict(buf, version, s.Message, s.Signature[:])
+}
+
func (s *SignedExecutionPayloadBid) Clone() clonable.Clonable {
return &SignedExecutionPayloadBid{
Message: s.Message.Clone().(*ExecutionPayloadBid),
@@ -495,12 +543,7 @@ func (e *ExecutionPayloadEnvelope) EncodeSSZ(buf []byte) ([]byte, error) {
}
func (e *ExecutionPayloadEnvelope) DecodeSSZ(buf []byte, version int) error {
- if e.Payload == nil {
- e.Payload = NewEth1Block(clparams.StateVersion(version), e.beaconCfg)
- }
- if e.ExecutionRequests == nil {
- e.ExecutionRequests = NewExecutionRequestsWithVersion(e.beaconCfg, clparams.StateVersion(version))
- }
+ e.initializeDecodeFields(version)
return ssz2.UnmarshalSSZ(
buf, version,
e.Payload,
@@ -511,6 +554,27 @@ func (e *ExecutionPayloadEnvelope) DecodeSSZ(buf []byte, version int) error {
)
}
+func (e *ExecutionPayloadEnvelope) DecodeSSZStrict(buf []byte, version int) error {
+ e.initializeDecodeFields(version)
+ return ssz2.UnmarshalSSZStrict(
+ buf, version,
+ e.Payload,
+ e.ExecutionRequests,
+ &e.BuilderIndex,
+ e.BeaconBlockRoot[:],
+ e.ParentBeaconBlockRoot[:],
+ )
+}
+
+func (e *ExecutionPayloadEnvelope) initializeDecodeFields(version int) {
+ if e.Payload == nil {
+ e.Payload = NewEth1Block(clparams.StateVersion(version), e.beaconCfg)
+ }
+ if e.ExecutionRequests == nil {
+ e.ExecutionRequests = NewExecutionRequestsWithVersion(e.beaconCfg, clparams.StateVersion(version))
+ }
+}
+
func (e *ExecutionPayloadEnvelope) EncodingSizeSSZ() int {
// Payload and ExecutionRequests are dynamic, so each contributes a 4-byte offset.
const dynamicOffsetSize = 4
@@ -565,6 +629,13 @@ func (s *SignedExecutionPayloadEnvelope) DecodeSSZ(buf []byte, version int) erro
return ssz2.UnmarshalSSZ(buf, version, s.Message, s.Signature[:])
}
+func (s *SignedExecutionPayloadEnvelope) DecodeSSZStrict(buf []byte, version int) error {
+ if s.Message == nil {
+ s.Message = NewExecutionPayloadEnvelope(s.beaconCfg)
+ }
+ return ssz2.UnmarshalSSZStrict(buf, version, s.Message, s.Signature[:])
+}
+
func (s *SignedExecutionPayloadEnvelope) EncodingSizeSSZ() int {
return signedDynamicSize(s.Message.EncodingSizeSSZ())
}
diff --git a/cl/cltypes/epbs_proposer.go b/cl/cltypes/epbs_proposer.go
index da19b0b435b..98c418a433c 100644
--- a/cl/cltypes/epbs_proposer.go
+++ b/cl/cltypes/epbs_proposer.go
@@ -70,6 +70,10 @@ func (p *ProposerPreferences) DecodeSSZ(buf []byte, version int) error {
return ssz2.UnmarshalSSZ(buf, version, p.DependentRoot[:], &p.ProposalSlot, &p.ValidatorIndex, p.FeeRecipient[:], &p.TargetGasLimit)
}
+func (p *ProposerPreferences) DecodeSSZStrict(buf []byte, version int) error {
+ return ssz2.UnmarshalSSZStrict(buf, version, p.DependentRoot[:], &p.ProposalSlot, &p.ValidatorIndex, p.FeeRecipient[:], &p.TargetGasLimit)
+}
+
func (p *ProposerPreferences) Clone() clonable.Clonable {
return &ProposerPreferences{
DependentRoot: p.DependentRoot,
@@ -107,6 +111,11 @@ func (s *SignedProposerPreferences) DecodeSSZ(buf []byte, version int) error {
return ssz2.UnmarshalSSZ(buf, version, s.Message, s.Signature[:])
}
+func (s *SignedProposerPreferences) DecodeSSZStrict(buf []byte, version int) error {
+ s.Message = new(ProposerPreferences)
+ return ssz2.UnmarshalSSZStrict(buf, version, s.Message, s.Signature[:])
+}
+
func (s *SignedProposerPreferences) HashSSZ() ([32]byte, error) {
return merkle_tree.HashTreeRoot(s.Message, s.Signature[:])
}
diff --git a/cl/cltypes/execution_requests.go b/cl/cltypes/execution_requests.go
index 5ddfb690aad..04ca3159fae 100644
--- a/cl/cltypes/execution_requests.go
+++ b/cl/cltypes/execution_requests.go
@@ -19,6 +19,8 @@ var (
_ ssz2.SizedObjectSSZ = (*ExecutionRequests)(nil)
)
+const gloasDepositRequestsResourceLimit = int(clparams.MaxChunkSize) / solid.SizeDepositRequest
+
// ExecutionRequests groups execution-layer requests carried by a payload.
// Electra defines deposits, withdrawals, and consolidations; Gloas adds builder
// deposits and exits.
@@ -59,7 +61,7 @@ func (e *ExecutionRequests) ensureLists() {
}
progressive := e.effectiveVersion() >= clparams.GloasVersion
if e.Deposits == nil && progressive {
- e.Deposits = solid.NewStaticProgressiveListSSZ[*solid.DepositRequest](int(e.cfg.MaxDepositRequestsPerPayload), solid.SizeDepositRequest)
+ e.Deposits = newGloasDepositRequests()
} else if e.Deposits == nil {
e.Deposits = solid.NewStaticListSSZ[*solid.DepositRequest](int(e.cfg.MaxDepositRequestsPerPayload), solid.SizeDepositRequest)
}
@@ -85,6 +87,10 @@ func (e *ExecutionRequests) ensureLists() {
}
}
+func newGloasDepositRequests() *solid.ListSSZ[*solid.DepositRequest] {
+ return solid.NewStaticProgressiveListSSZWithResourceLimit[*solid.DepositRequest](gloasDepositRequestsResourceLimit, solid.SizeDepositRequest)
+}
+
func (e *ExecutionRequests) EncodingSizeSSZ() int {
e.ensureLists()
// Every field is a dynamic list, so each contributes a 4-byte offset.
@@ -111,6 +117,22 @@ func (e *ExecutionRequests) EncodeSSZ(buf []byte) ([]byte, error) {
}
func (e *ExecutionRequests) DecodeSSZ(buf []byte, version int) error {
+ e.initializeDecodeLists(version)
+ if e.effectiveVersion() < clparams.GloasVersion {
+ return ssz2.UnmarshalSSZ(buf, version, e.Deposits, e.Withdrawals, e.Consolidations)
+ }
+ return ssz2.UnmarshalSSZ(buf, version, e.Deposits, e.Withdrawals, e.Consolidations, e.BuilderDeposits, e.BuilderExits)
+}
+
+func (e *ExecutionRequests) DecodeSSZStrict(buf []byte, version int) error {
+ e.initializeDecodeLists(version)
+ if e.effectiveVersion() < clparams.GloasVersion {
+ return ssz2.UnmarshalSSZStrict(buf, version, e.Deposits, e.Withdrawals, e.Consolidations)
+ }
+ return ssz2.UnmarshalSSZStrict(buf, version, e.Deposits, e.Withdrawals, e.Consolidations, e.BuilderDeposits, e.BuilderExits)
+}
+
+func (e *ExecutionRequests) initializeDecodeLists(version int) {
decodedVersion := clparams.StateVersion(version)
if (e.effectiveVersion() >= clparams.GloasVersion) != (decodedVersion >= clparams.GloasVersion) {
e.Deposits = nil
@@ -121,10 +143,6 @@ func (e *ExecutionRequests) DecodeSSZ(buf []byte, version int) error {
}
e.version = decodedVersion
e.ensureLists()
- if e.effectiveVersion() < clparams.GloasVersion {
- return ssz2.UnmarshalSSZ(buf, version, e.Deposits, e.Withdrawals, e.Consolidations)
- }
- return ssz2.UnmarshalSSZ(buf, version, e.Deposits, e.Withdrawals, e.Consolidations, e.BuilderDeposits, e.BuilderExits)
}
func (e *ExecutionRequests) Clone() clonable.Clonable {
@@ -213,7 +231,7 @@ func (e *ExecutionRequests) UnmarshalJSON(b []byte) error {
newBuilderDeposits := solid.NewStaticListSSZ[*solid.BuilderDepositRequest](int(e.cfg.MaxBuilderDepositRequestsPerPayload), solid.SizeBuilderDepositRequest)
newBuilderExits := solid.NewStaticListSSZ[*solid.BuilderExitRequest](int(e.cfg.MaxBuilderExitRequestsPerPayload), solid.SizeBuilderExitRequest)
if e.effectiveVersion() >= clparams.GloasVersion {
- newDeposits = solid.NewStaticProgressiveListSSZ[*solid.DepositRequest](int(e.cfg.MaxDepositRequestsPerPayload), solid.SizeDepositRequest)
+ newDeposits = newGloasDepositRequests()
newWithdrawals = solid.NewStaticProgressiveListSSZ[*solid.WithdrawalRequest](int(e.cfg.MaxWithdrawalRequestsPerPayload), solid.SizeWithdrawalRequest)
newConsolidations = solid.NewStaticProgressiveListSSZ[*solid.ConsolidationRequest](int(e.cfg.MaxConsolidationRequestsPerPayload), solid.SizeConsolidationRequest)
newBuilderDeposits = solid.NewStaticProgressiveListSSZ[*solid.BuilderDepositRequest](int(e.cfg.MaxBuilderDepositRequestsPerPayload), solid.SizeBuilderDepositRequest)
diff --git a/cl/cltypes/gloas_api.go b/cl/cltypes/gloas_api.go
new file mode 100644
index 00000000000..9c169d1c553
--- /dev/null
+++ b/cl/cltypes/gloas_api.go
@@ -0,0 +1,90 @@
+// Copyright 2026 The Erigon Authors
+// This file is part of Erigon.
+//
+// Erigon is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Erigon is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with Erigon. If not, see .
+
+package cltypes
+
+import (
+ "github.com/erigontech/erigon/cl/clparams"
+ "github.com/erigontech/erigon/cl/cltypes/solid"
+ ssz2 "github.com/erigontech/erigon/cl/ssz"
+)
+
+type GloasBlockContents struct {
+ Block *BeaconBlock `json:"block"`
+ ExecutionPayloadEnvelope *ExecutionPayloadEnvelope `json:"execution_payload_envelope"`
+ KZGProofs *solid.ListSSZ[*KZGProof] `json:"kzg_proofs"`
+ Blobs *solid.ListSSZ[*Blob] `json:"blobs"`
+}
+
+func NewGloasBlockContents(cfg *clparams.BeaconChainConfig, slot uint64) *GloasBlockContents {
+ maxBlobs := int(cfg.GetBlobParameters(slot / cfg.SlotsPerEpoch).MaxBlobsPerBlock)
+ return &GloasBlockContents{
+ Block: NewBeaconBlock(cfg, clparams.GloasVersion),
+ ExecutionPayloadEnvelope: NewExecutionPayloadEnvelope(cfg),
+ KZGProofs: solid.NewStaticListSSZ[*KZGProof](maxBlobs*int(cfg.NumberOfColumns), BYTES_KZG_PROOF),
+ Blobs: solid.NewStaticListSSZ[*Blob](maxBlobs, int(BYTES_PER_BLOB)),
+ }
+}
+
+func (b *GloasBlockContents) EncodeSSZ(buf []byte) ([]byte, error) {
+ return ssz2.MarshalSSZ(buf, b.Block, b.ExecutionPayloadEnvelope, b.KZGProofs, b.Blobs)
+}
+
+func (b *GloasBlockContents) DecodeSSZ(buf []byte, version int) error {
+ return ssz2.UnmarshalSSZ(buf, version, b.Block, b.ExecutionPayloadEnvelope, b.KZGProofs, b.Blobs)
+}
+
+func (b *GloasBlockContents) EncodingSizeSSZ() int {
+ return 4*4 + b.Block.EncodingSizeSSZ() + b.ExecutionPayloadEnvelope.EncodingSizeSSZ() + b.KZGProofs.EncodingSizeSSZ() + b.Blobs.EncodingSizeSSZ()
+}
+
+func (b *GloasBlockContents) Static() bool { return false }
+
+type SignedExecutionPayloadEnvelopeContents struct {
+ SignedExecutionPayloadEnvelope *SignedExecutionPayloadEnvelope `json:"signed_execution_payload_envelope"`
+ KZGProofs *solid.ListSSZ[*KZGProof] `json:"kzg_proofs"`
+ Blobs *solid.ListSSZ[*Blob] `json:"blobs"`
+}
+
+func NewSignedExecutionPayloadEnvelopeContents(cfg *clparams.BeaconChainConfig, slot uint64) *SignedExecutionPayloadEnvelopeContents {
+ maxBlobs := int(cfg.GetBlobParameters(slot / cfg.SlotsPerEpoch).MaxBlobsPerBlock)
+ return &SignedExecutionPayloadEnvelopeContents{
+ SignedExecutionPayloadEnvelope: &SignedExecutionPayloadEnvelope{
+ Message: NewExecutionPayloadEnvelope(cfg),
+ beaconCfg: cfg,
+ },
+ KZGProofs: solid.NewStaticListSSZ[*KZGProof](maxBlobs*int(cfg.NumberOfColumns), BYTES_KZG_PROOF),
+ Blobs: solid.NewStaticListSSZ[*Blob](maxBlobs, int(BYTES_PER_BLOB)),
+ }
+}
+
+func (c *SignedExecutionPayloadEnvelopeContents) EncodeSSZ(buf []byte) ([]byte, error) {
+ return ssz2.MarshalSSZ(buf, c.SignedExecutionPayloadEnvelope, c.KZGProofs, c.Blobs)
+}
+
+func (c *SignedExecutionPayloadEnvelopeContents) DecodeSSZ(buf []byte, version int) error {
+ return ssz2.UnmarshalSSZ(buf, version, c.SignedExecutionPayloadEnvelope, c.KZGProofs, c.Blobs)
+}
+
+func (c *SignedExecutionPayloadEnvelopeContents) DecodeSSZStrict(buf []byte, version int) error {
+ return ssz2.UnmarshalSSZStrict(buf, version, c.SignedExecutionPayloadEnvelope, c.KZGProofs, c.Blobs)
+}
+
+func (c *SignedExecutionPayloadEnvelopeContents) EncodingSizeSSZ() int {
+ return 3*4 + c.SignedExecutionPayloadEnvelope.EncodingSizeSSZ() + c.KZGProofs.EncodingSizeSSZ() + c.Blobs.EncodingSizeSSZ()
+}
+
+func (c *SignedExecutionPayloadEnvelopeContents) Static() bool { return false }
diff --git a/cl/cltypes/gloas_api_test.go b/cl/cltypes/gloas_api_test.go
new file mode 100644
index 00000000000..6db6a523ab9
--- /dev/null
+++ b/cl/cltypes/gloas_api_test.go
@@ -0,0 +1,133 @@
+// Copyright 2026 The Erigon Authors
+// This file is part of Erigon.
+//
+// Erigon is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Erigon is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with Erigon. If not, see .
+
+package cltypes
+
+import (
+ "encoding/binary"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/erigontech/erigon/cl/clparams"
+ "github.com/erigontech/erigon/cl/cltypes/solid"
+)
+
+func TestGloasBlockContentsSSZRoundTrip(t *testing.T) {
+ cfg := clparams.MainnetBeaconConfig
+ contents := NewGloasBlockContents(&cfg, 64)
+ contents.Block.Slot = 64
+ contents.ExecutionPayloadEnvelope.BuilderIndex = 7
+
+ encoded, err := contents.EncodeSSZ(nil)
+ require.NoError(t, err)
+ require.Equal(t, contents.Block.EncodingSizeSSZ()+contents.ExecutionPayloadEnvelope.EncodingSizeSSZ()+contents.KZGProofs.EncodingSizeSSZ()+contents.Blobs.EncodingSizeSSZ()+4*4, contents.EncodingSizeSSZ())
+
+ decoded := NewGloasBlockContents(&cfg, 64)
+ require.NoError(t, decoded.DecodeSSZ(encoded, int(clparams.GloasVersion)))
+ require.Equal(t, uint64(64), decoded.Block.Slot)
+ require.Equal(t, uint64(7), decoded.ExecutionPayloadEnvelope.BuilderIndex)
+ require.Equal(t, encoded, requireEncodedSSZ(t, decoded))
+}
+
+func TestSignedExecutionPayloadEnvelopeContentsSSZRoundTrip(t *testing.T) {
+ cfg := clparams.MainnetBeaconConfig
+ contents := NewSignedExecutionPayloadEnvelopeContents(&cfg, 64)
+ contents.SignedExecutionPayloadEnvelope.Message.BuilderIndex = 9
+
+ encoded, err := contents.EncodeSSZ(nil)
+ require.NoError(t, err)
+ require.Equal(t, contents.SignedExecutionPayloadEnvelope.EncodingSizeSSZ()+contents.KZGProofs.EncodingSizeSSZ()+contents.Blobs.EncodingSizeSSZ()+3*4, contents.EncodingSizeSSZ())
+
+ decoded := NewSignedExecutionPayloadEnvelopeContents(&cfg, 64)
+ require.NoError(t, decoded.DecodeSSZ(encoded, int(clparams.GloasVersion)))
+ require.Equal(t, uint64(9), decoded.SignedExecutionPayloadEnvelope.Message.BuilderIndex)
+ require.Equal(t, encoded, requireEncodedSSZ(t, decoded))
+}
+
+func TestSignedExecutionPayloadEnvelopeContentsRejectsNonCanonicalSSZOffsets(t *testing.T) {
+ cfg := clparams.MainnetBeaconConfig
+ contents := NewSignedExecutionPayloadEnvelopeContents(&cfg, 64)
+ encoded, err := contents.EncodeSSZ(nil)
+ require.NoError(t, err)
+ nonCanonical := append([]byte(nil), encoded[:12]...)
+ nonCanonical = append(nonCanonical, make([]byte, 4)...)
+ nonCanonical = append(nonCanonical, encoded[12:]...)
+ for offset := 0; offset < 12; offset += 4 {
+ binary.LittleEndian.PutUint32(nonCanonical[offset:], binary.LittleEndian.Uint32(encoded[offset:])+4)
+ }
+ require.NoError(t, NewSignedExecutionPayloadEnvelopeContents(&cfg, 64).DecodeSSZ(nonCanonical, int(clparams.GloasVersion)))
+ require.Error(t, NewSignedExecutionPayloadEnvelopeContents(&cfg, 64).DecodeSSZStrict(nonCanonical, int(clparams.GloasVersion)))
+}
+
+func TestSignedExecutionPayloadEnvelopeRejectsNonCanonicalSSZOffsets(t *testing.T) {
+ cfg := clparams.MainnetBeaconConfig
+ envelope := &SignedExecutionPayloadEnvelope{Message: NewExecutionPayloadEnvelope(&cfg), beaconCfg: &cfg}
+ encoded, err := envelope.EncodeSSZ(nil)
+ require.NoError(t, err)
+ const fixedSize = 4 + 96
+ nonCanonical := append([]byte(nil), encoded[:fixedSize]...)
+ nonCanonical = append(nonCanonical, make([]byte, 4)...)
+ nonCanonical = append(nonCanonical, encoded[fixedSize:]...)
+ binary.LittleEndian.PutUint32(nonCanonical, binary.LittleEndian.Uint32(encoded)+4)
+ decoded := &SignedExecutionPayloadEnvelope{Message: NewExecutionPayloadEnvelope(&cfg), beaconCfg: &cfg}
+ require.NoError(t, decoded.DecodeSSZ(nonCanonical, int(clparams.GloasVersion)))
+ strict := &SignedExecutionPayloadEnvelope{Message: NewExecutionPayloadEnvelope(&cfg), beaconCfg: &cfg}
+ require.Error(t, strict.DecodeSSZStrict(nonCanonical, int(clparams.GloasVersion)))
+}
+
+func TestExecutionRequestsRejectsNonCanonicalSSZOffsets(t *testing.T) {
+ cfg := clparams.MainnetBeaconConfig
+ requests := NewExecutionRequestsWithVersion(&cfg, clparams.GloasVersion)
+ encoded, err := requests.EncodeSSZ(nil)
+ require.NoError(t, err)
+ const fixedSize = 5 * 4
+ nonCanonical := append([]byte(nil), encoded[:fixedSize]...)
+ nonCanonical = append(nonCanonical, make([]byte, 4)...)
+ nonCanonical = append(nonCanonical, encoded[fixedSize:]...)
+ for offset := 0; offset < fixedSize; offset += 4 {
+ binary.LittleEndian.PutUint32(nonCanonical[offset:], binary.LittleEndian.Uint32(encoded[offset:])+4)
+ }
+ require.NoError(t, NewExecutionRequestsWithVersion(&cfg, clparams.GloasVersion).DecodeSSZ(nonCanonical, int(clparams.GloasVersion)))
+ require.Error(t, NewExecutionRequestsWithVersion(&cfg, clparams.GloasVersion).DecodeSSZStrict(nonCanonical, int(clparams.GloasVersion)))
+}
+
+func TestExecutionPayloadBidRejectsNonCanonicalSSZOffsets(t *testing.T) {
+ bid := &ExecutionPayloadBid{
+ BlobKzgCommitments: *solid.NewStaticProgressiveListSSZ[*KZGCommitment](MaxBlobsCommittmentsPerBlock, 48),
+ }
+ encoded, err := bid.EncodeSSZ(nil)
+ require.NoError(t, err)
+ const fixedSize = 224
+ nonCanonical := append([]byte(nil), encoded[:fixedSize]...)
+ nonCanonical = append(nonCanonical, make([]byte, 4)...)
+ nonCanonical = append(nonCanonical, encoded[fixedSize:]...)
+ const commitmentsOffsetPosition = 188
+ binary.LittleEndian.PutUint32(nonCanonical[commitmentsOffsetPosition:], binary.LittleEndian.Uint32(encoded[commitmentsOffsetPosition:])+4)
+ loose := &ExecutionPayloadBid{BlobKzgCommitments: *solid.NewStaticProgressiveListSSZ[*KZGCommitment](MaxBlobsCommittmentsPerBlock, 48)}
+ require.NoError(t, loose.DecodeSSZ(nonCanonical, int(clparams.GloasVersion)))
+ strict := &ExecutionPayloadBid{BlobKzgCommitments: *solid.NewStaticProgressiveListSSZ[*KZGCommitment](MaxBlobsCommittmentsPerBlock, 48)}
+ require.Error(t, strict.DecodeSSZStrict(nonCanonical, int(clparams.GloasVersion)))
+}
+
+func requireEncodedSSZ(t *testing.T, value interface {
+ EncodeSSZ([]byte) ([]byte, error)
+}) []byte {
+ t.Helper()
+ encoded, err := value.EncodeSSZ(nil)
+ require.NoError(t, err)
+ return encoded
+}
diff --git a/cl/cltypes/indexed_attestation.go b/cl/cltypes/indexed_attestation.go
index a68f4f9f1df..56823e8b71a 100644
--- a/cl/cltypes/indexed_attestation.go
+++ b/cl/cltypes/indexed_attestation.go
@@ -123,9 +123,21 @@ func (i *IndexedAttestation) DecodeSSZ(buf []byte, version int) error {
return i.DecodeSSZWithConfig(buf, version, nil)
}
+func (i *IndexedAttestation) DecodeSSZStrict(buf []byte, version int) error {
+ return i.DecodeSSZStrictWithConfig(buf, version, nil)
+}
+
// DecodeSSZWithConfig ssz unmarshals the IndexedAttestation object with preset-aware limits.
// If cfg is nil, mainnet defaults are used.
func (i *IndexedAttestation) DecodeSSZWithConfig(buf []byte, version int, cfg *clparams.BeaconChainConfig) error {
+ return i.decodeSSZWithConfig(buf, version, cfg, false)
+}
+
+func (i *IndexedAttestation) DecodeSSZStrictWithConfig(buf []byte, version int, cfg *clparams.BeaconChainConfig) error {
+ return i.decodeSSZWithConfig(buf, version, cfg, true)
+}
+
+func (i *IndexedAttestation) decodeSSZWithConfig(buf []byte, version int, cfg *clparams.BeaconChainConfig, strict bool) error {
i.version = clparams.StateVersion(version)
i.Data = &solid.AttestationData{}
if version >= int(clparams.ElectraVersion) {
@@ -138,6 +150,9 @@ func (i *IndexedAttestation) DecodeSSZWithConfig(buf []byte, version int, cfg *c
i.AttestingIndices = solid.NewRawUint64List(attestingIndicesLimit, nil)
}
+ if strict {
+ return ssz2.UnmarshalSSZStrict(buf, version, i.AttestingIndices, i.Data, i.Signature[:])
+ }
return ssz2.UnmarshalSSZ(buf, version, i.AttestingIndices, i.Data, i.Signature[:])
}
diff --git a/cl/cltypes/partial_data_column.go b/cl/cltypes/partial_data_column.go
index ce1bc35133c..4ab720464e0 100644
--- a/cl/cltypes/partial_data_column.go
+++ b/cl/cltypes/partial_data_column.go
@@ -235,14 +235,19 @@ func (s *PartialDataColumnSidecar) GetHeader() *PartialDataColumnHeader {
type PartialDataColumnPartsMetadata struct {
Available *solid.BitList `json:"available"`
Requests *solid.BitList `json:"requests"`
+ version clparams.StateVersion
}
-func NewPartialDataColumnPartsMetadata() *PartialDataColumnPartsMetadata {
- m := &PartialDataColumnPartsMetadata{}
+func NewPartialDataColumnPartsMetadata(version clparams.StateVersion) *PartialDataColumnPartsMetadata {
+ m := &PartialDataColumnPartsMetadata{version: version}
m.init()
return m
}
+func (m *PartialDataColumnPartsMetadata) SetVersion(version clparams.StateVersion) {
+ m.version = version
+}
+
func (m *PartialDataColumnPartsMetadata) init() {
cfg := clparams.GetBeaconConfig()
if m.Available == nil {
@@ -259,6 +264,7 @@ func (m *PartialDataColumnPartsMetadata) EncodeSSZ(buf []byte) ([]byte, error) {
}
func (m *PartialDataColumnPartsMetadata) DecodeSSZ(buf []byte, version int) error {
+ m.version = clparams.StateVersion(version)
m.init()
return ssz2.UnmarshalSSZ(buf, version, m.Available, m.Requests)
}
@@ -270,11 +276,25 @@ func (m *PartialDataColumnPartsMetadata) EncodingSizeSSZ() int {
func (m *PartialDataColumnPartsMetadata) HashSSZ() ([32]byte, error) {
m.init()
+ if m.version >= clparams.GloasVersion {
+ availableRoot, err := m.Available.HashSSZProgressive()
+ if err != nil {
+ return [32]byte{}, err
+ }
+ requestsRoot, err := m.Requests.HashSSZProgressive()
+ if err != nil {
+ return [32]byte{}, err
+ }
+ return merkle_tree.HashTreeRoot(availableRoot[:], requestsRoot[:])
+ }
return merkle_tree.HashTreeRoot(m.Available, m.Requests)
}
func (m *PartialDataColumnPartsMetadata) Clone() clonable.Clonable {
- return NewPartialDataColumnPartsMetadata()
+ if m == nil {
+ return NewPartialDataColumnPartsMetadata(clparams.Phase0Version)
+ }
+ return NewPartialDataColumnPartsMetadata(m.version)
}
func (m *PartialDataColumnPartsMetadata) Static() bool {
diff --git a/cl/cltypes/partial_data_column_test.go b/cl/cltypes/partial_data_column_test.go
index 2bab18f9787..a74ce288bfc 100644
--- a/cl/cltypes/partial_data_column_test.go
+++ b/cl/cltypes/partial_data_column_test.go
@@ -109,7 +109,7 @@ func TestPartialDataColumnPartsMetadata_Fulu(t *testing.T) {
if _, err := os.Stat(testDir); os.IsNotExist(err) {
t.Skip("spec test data not found")
}
- obj := cltypes.NewPartialDataColumnPartsMetadata()
+ obj := cltypes.NewPartialDataColumnPartsMetadata(clparams.FuluVersion)
testSSZRoundTrip(t, testDir, clparams.FuluVersion, obj)
}
@@ -118,6 +118,13 @@ func TestPartialDataColumnPartsMetadata_Gloas(t *testing.T) {
if _, err := os.Stat(testDir); os.IsNotExist(err) {
t.Skip("spec test data not found")
}
- obj := cltypes.NewPartialDataColumnPartsMetadata()
+ obj := cltypes.NewPartialDataColumnPartsMetadata(clparams.GloasVersion)
testSSZRoundTrip(t, testDir, clparams.GloasVersion, obj)
}
+
+func TestPartialDataColumnPartsMetadataCloneNil(t *testing.T) {
+ var metadata *cltypes.PartialDataColumnPartsMetadata
+ clone := metadata.Clone()
+ require.NotNil(t, clone)
+ require.IsType(t, &cltypes.PartialDataColumnPartsMetadata{}, clone)
+}
diff --git a/cl/cltypes/slashings.go b/cl/cltypes/slashings.go
index ca5276b989d..fb8363ade2f 100644
--- a/cl/cltypes/slashings.go
+++ b/cl/cltypes/slashings.go
@@ -76,10 +76,25 @@ func (a *AttesterSlashing) DecodeSSZ(buf []byte, version int) error {
return a.DecodeSSZWithConfig(buf, version, nil)
}
+func (a *AttesterSlashing) DecodeSSZStrict(buf []byte, version int) error {
+ return a.DecodeSSZStrictWithConfig(buf, version, nil)
+}
+
// DecodeSSZWithConfig decodes an AttesterSlashing with preset-aware limits.
func (a *AttesterSlashing) DecodeSSZWithConfig(buf []byte, version int, cfg *clparams.BeaconChainConfig) error {
+ return a.decodeSSZWithConfig(buf, version, cfg, false)
+}
+
+func (a *AttesterSlashing) DecodeSSZStrictWithConfig(buf []byte, version int, cfg *clparams.BeaconChainConfig) error {
+ return a.decodeSSZWithConfig(buf, version, cfg, true)
+}
+
+func (a *AttesterSlashing) decodeSSZWithConfig(buf []byte, version int, cfg *clparams.BeaconChainConfig, strict bool) error {
a.Attestation_1 = NewIndexedAttestationWithConfig(clparams.StateVersion(version), cfg)
a.Attestation_2 = NewIndexedAttestationWithConfig(clparams.StateVersion(version), cfg)
+ if strict {
+ return ssz2.UnmarshalSSZStrict(buf, version, a.Attestation_1, a.Attestation_2)
+ }
return ssz2.UnmarshalSSZ(buf, version, a.Attestation_1, a.Attestation_2)
}
diff --git a/cl/cltypes/solid/attestation.go b/cl/cltypes/solid/attestation.go
index d394f03b2c5..22cc47453fb 100644
--- a/cl/cltypes/solid/attestation.go
+++ b/cl/cltypes/solid/attestation.go
@@ -133,8 +133,20 @@ func (a *Attestation) DecodeSSZ(buf []byte, version int) error {
return a.DecodeSSZWithConfig(buf, version, nil)
}
+func (a *Attestation) DecodeSSZStrict(buf []byte, version int) error {
+ return a.DecodeSSZStrictWithConfig(buf, version, nil)
+}
+
// DecodeSSZWithConfig decodes the provided buffer using cfg when it is available.
func (a *Attestation) DecodeSSZWithConfig(buf []byte, version int, cfg *clparams.BeaconChainConfig) error {
+ return a.decodeSSZWithConfig(buf, version, cfg, false)
+}
+
+func (a *Attestation) DecodeSSZStrictWithConfig(buf []byte, version int, cfg *clparams.BeaconChainConfig) error {
+ return a.decodeSSZWithConfig(buf, version, cfg, true)
+}
+
+func (a *Attestation) decodeSSZWithConfig(buf []byte, version int, cfg *clparams.BeaconChainConfig, strict bool) error {
clversion := clparams.StateVersion(version)
a.version = clversion
if clversion.AfterOrEqual(clparams.ElectraVersion) {
@@ -166,6 +178,9 @@ func (a *Attestation) DecodeSSZWithConfig(buf []byte, version int, cfg *clparams
a.AggregationBits = NewBitList(0, aggrBitsLimit)
a.Data = &AttestationData{}
a.CommitteeBits = NewBitVector(committeeBitsLimit)
+ if strict {
+ return ssz2.UnmarshalSSZStrict(buf, version, a.AggregationBits, a.Data, a.Signature[:], a.CommitteeBits)
+ }
return ssz2.UnmarshalSSZ(buf, version, a.AggregationBits, a.Data, a.Signature[:], a.CommitteeBits)
}
@@ -175,6 +190,9 @@ func (a *Attestation) DecodeSSZWithConfig(buf []byte, version int, cfg *clparams
}
a.AggregationBits = NewBitList(0, aggregationBitsSizeDeneb)
a.Data = &AttestationData{}
+ if strict {
+ return ssz2.UnmarshalSSZStrict(buf, version, a.AggregationBits, a.Data, a.Signature[:])
+ }
return ssz2.UnmarshalSSZ(buf, version, a.AggregationBits, a.Data, a.Signature[:])
}
diff --git a/cl/cltypes/solid/attestation_config_test.go b/cl/cltypes/solid/attestation_config_test.go
index 19cf12d9e9e..16d68a9eaba 100644
--- a/cl/cltypes/solid/attestation_config_test.go
+++ b/cl/cltypes/solid/attestation_config_test.go
@@ -48,3 +48,42 @@ func TestAttestationValidateForConfigNormalizesJSONCommitteeBits(t *testing.T) {
attestation.CommitteeBits = tooLong
require.Error(t, attestation.ValidateForConfig(&cfg, clparams.GloasVersion))
}
+
+func TestAttestationDecodeSSZStrictRejectsOffsetGap(t *testing.T) {
+ tests := []struct {
+ name string
+ version clparams.StateVersion
+ value *Attestation
+ }{
+ {
+ name: "deneb",
+ version: clparams.DenebVersion,
+ value: &Attestation{
+ AggregationBits: BitlistFromBytes([]byte{1}, maxValidatorsPerCommittee),
+ Data: &AttestationData{},
+ },
+ },
+ {
+ name: "gloas",
+ version: clparams.GloasVersion,
+ value: &Attestation{
+ AggregationBits: BitlistFromBytes([]byte{1}, aggregationBitsSizeElectra),
+ Data: &AttestationData{},
+ CommitteeBits: NewBitVector(int(clparams.MainnetBeaconConfig.MaxCommitteesPerSlot)),
+ },
+ },
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ encoded, err := test.value.EncodeSSZ(nil)
+ require.NoError(t, err)
+ require.NoError(t, new(Attestation).DecodeSSZStrictWithConfig(encoded, int(test.version), &clparams.MainnetBeaconConfig))
+ fixedSize := int(binary.LittleEndian.Uint32(encoded[:4]))
+ malformed := append([]byte(nil), encoded[:fixedSize]...)
+ malformed = append(malformed, make([]byte, 4)...)
+ malformed = append(malformed, encoded[fixedSize:]...)
+ binary.LittleEndian.PutUint32(malformed, uint32(fixedSize+4))
+ require.Error(t, new(Attestation).DecodeSSZStrictWithConfig(malformed, int(test.version), &clparams.MainnetBeaconConfig))
+ })
+ }
+}
diff --git a/cl/cltypes/solid/list_ssz.go b/cl/cltypes/solid/list_ssz.go
index f8f68985136..052517d2c0b 100644
--- a/cl/cltypes/solid/list_ssz.go
+++ b/cl/cltypes/solid/list_ssz.go
@@ -68,6 +68,10 @@ func NewStaticProgressiveListSSZ[T EncodableHashableSSZ](limit int, bytesPerElem
return &ListSSZ[T]{list: make([]T, 0), limit: progressiveDecodeLimit(limit), static: true, bytesPerElement: bytesPerElement, progressive: true}
}
+func NewStaticProgressiveListSSZWithResourceLimit[T EncodableHashableSSZ](limit int, bytesPerElement int) *ListSSZ[T] {
+ return &ListSSZ[T]{list: make([]T, 0), limit: limit, static: true, bytesPerElement: bytesPerElement, progressive: true}
+}
+
func (l *ListSSZ[T]) EnsureStaticProgressive(limit int, bytesPerElement int) {
if l.progressive && l.static && l.bytesPerElement == bytesPerElement {
return
diff --git a/cl/cltypes/solid/list_ssz_test.go b/cl/cltypes/solid/list_ssz_test.go
index 9b2c70c88e8..628759256f5 100644
--- a/cl/cltypes/solid/list_ssz_test.go
+++ b/cl/cltypes/solid/list_ssz_test.go
@@ -142,6 +142,10 @@ func TestProgressiveListSSZDecodeEnforcesLimit(t *testing.T) {
name: "static",
list: NewStaticProgressiveListSSZ[Validator](1, validatorSize),
},
+ {
+ name: "static resource limit",
+ list: NewStaticProgressiveListSSZWithResourceLimit[Validator](1, validatorSize),
+ },
{
name: "dynamic",
list: NewDynamicProgressiveListSSZ[Validator](1),
diff --git a/cl/cltypes/strict_attestation_test.go b/cl/cltypes/strict_attestation_test.go
new file mode 100644
index 00000000000..1c6ed5aca76
--- /dev/null
+++ b/cl/cltypes/strict_attestation_test.go
@@ -0,0 +1,43 @@
+package cltypes
+
+import (
+ "encoding/binary"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/erigontech/erigon/cl/clparams"
+)
+
+func TestIndexedAttestationDecodeSSZStrictRejectsOffsetGap(t *testing.T) {
+ attestation := NewIndexedAttestation(clparams.GloasVersion)
+ attestation.AttestingIndices.Append(1)
+ encoded, err := attestation.EncodeSSZ(nil)
+ require.NoError(t, err)
+ require.NoError(t, NewIndexedAttestation(clparams.GloasVersion).DecodeSSZStrict(encoded, int(clparams.GloasVersion)))
+
+ const fixedSize = 228
+ malformed := append([]byte(nil), encoded[:fixedSize]...)
+ malformed = append(malformed, make([]byte, 4)...)
+ malformed = append(malformed, encoded[fixedSize:]...)
+ binary.LittleEndian.PutUint32(malformed, fixedSize+4)
+ require.Error(t, NewIndexedAttestation(clparams.GloasVersion).DecodeSSZStrict(malformed, int(clparams.GloasVersion)))
+}
+
+func TestAttesterSlashingDecodeSSZStrictRejectsNestedIndexedGap(t *testing.T) {
+ slashing := NewAttesterSlashing(clparams.GloasVersion)
+ slashing.Attestation_1.AttestingIndices.Append(1)
+ encoded, err := slashing.EncodeSSZ(nil)
+ require.NoError(t, err)
+ require.NoError(t, NewAttesterSlashing(clparams.GloasVersion).DecodeSSZStrict(encoded, int(clparams.GloasVersion)))
+
+ const outerFixedSize = 8
+ const indexedFixedSize = 228
+ insertAt := outerFixedSize + indexedFixedSize
+ malformed := append([]byte(nil), encoded[:insertAt]...)
+ malformed = append(malformed, make([]byte, 4)...)
+ malformed = append(malformed, encoded[insertAt:]...)
+ binary.LittleEndian.PutUint32(malformed[4:], binary.LittleEndian.Uint32(encoded[4:])+4)
+ binary.LittleEndian.PutUint32(malformed[outerFixedSize:], indexedFixedSize+4)
+ require.Error(t, NewAttesterSlashing(clparams.GloasVersion).DecodeSSZStrict(malformed, int(clparams.GloasVersion)))
+}
diff --git a/cl/phase1/core/state/cache_accessors.go b/cl/phase1/core/state/cache_accessors.go
index d90f6002c08..ae6d0ea313d 100644
--- a/cl/phase1/core/state/cache_accessors.go
+++ b/cl/phase1/core/state/cache_accessors.go
@@ -237,6 +237,7 @@ func (b *CachingBeaconState) CommitteeCount(epoch uint64) uint64 {
func (b *CachingBeaconState) GetAttestationParticipationFlagIndicies(
data *solid.AttestationData,
inclusionDelay uint64,
+ parentSlot uint64,
skipAssert bool,
) ([]uint8, error) {
var justifiedCheckpoint solid.Checkpoint
@@ -275,7 +276,7 @@ func (b *CachingBeaconState) GetAttestationParticipationFlagIndicies(
}
payloadMatch = true
} else {
- slotIndex := data.Slot % b.BeaconConfig().SlotsPerHistoricalRoot
+ slotIndex := parentSlot % b.BeaconConfig().SlotsPerHistoricalRoot
aval := b.GetExecutionPayloadAvailability()
payloadAvailable := aval.GetBitAt(int(slotIndex))
payloadMatch = (data.CommitteeIndex == 1) == payloadAvailable
diff --git a/cl/phase1/core/state/upgrade.go b/cl/phase1/core/state/upgrade.go
index bde3dd067bf..bce3d1a099b 100644
--- a/cl/phase1/core/state/upgrade.go
+++ b/cl/phase1/core/state/upgrade.go
@@ -47,7 +47,7 @@ func (b *CachingBeaconState) UpgradeToAltair() error {
// Fill in previous epoch participation from the pre state's pending attestations
if err := solid.RangeErr[*solid.PendingAttestation](b.PreviousEpochAttestations(), func(i1 int, pa *solid.PendingAttestation, i2 int) error {
attestationData := pa.Data
- flags, err := b.GetAttestationParticipationFlagIndicies(attestationData, pa.InclusionDelay, false)
+ flags, err := b.GetAttestationParticipationFlagIndicies(attestationData, pa.InclusionDelay, 0, false)
if err != nil {
return err
}
@@ -304,12 +304,10 @@ func (b *CachingBeaconState) UpgradeToGloas() error {
forkData.CurrentVersion = utils.Uint32ToBytes4(uint32(cfg.GloasForkVersion))
b.SetFork(forkData)
- // Get the latest block hash from the previous execution payload header
- latestBlockHash := b.LatestExecutionPayloadHeader().BlockHash
+ latestPayloadHeader := b.LatestExecutionPayloadHeader()
+ latestBlockHeader := b.LatestBlockHeader()
+ latestBlockHash := latestPayloadHeader.BlockHash
- // Replace latest_execution_payload_header with latest_execution_payload_bid
- // The bid contains only the block_hash from the previous header
- // Compute the execution_requests_root for an empty ExecutionRequests
emptyRequests := cltypes.NewExecutionRequestsWithVersion(cfg, clparams.GloasVersion)
emptyRequestsRoot, err := emptyRequests.HashSSZ()
if err != nil {
@@ -317,16 +315,16 @@ func (b *CachingBeaconState) UpgradeToGloas() error {
}
bid := &cltypes.ExecutionPayloadBid{
- ParentBlockHash: common.Hash{},
+ ParentBlockHash: latestPayloadHeader.ParentHash,
+ ParentBlockRoot: latestBlockHeader.ParentRoot,
BlockHash: latestBlockHash,
- BuilderIndex: 0,
- Slot: 0,
+ PrevRandao: latestPayloadHeader.PrevRandao,
+ GasLimit: latestPayloadHeader.GasLimit,
+ BuilderIndex: clparams.BuilderIndexSelfBuild,
+ Slot: latestBlockHeader.Slot,
Value: 0,
BlobKzgCommitments: *solid.NewStaticListSSZ[*cltypes.KZGCommitment](cltypes.MaxBlobsCommittmentsPerBlock, 48),
ExecutionRequestsRoot: emptyRequestsRoot,
- PrevRandao: common.Hash{},
- GasLimit: b.LatestExecutionPayloadHeader().GasLimit,
- ParentBlockRoot: common.Hash{},
}
b.SetLatestExecutionPayloadBid(bid)
diff --git a/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go b/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go
index fa27fce030f..3b65f0bd3f2 100644
--- a/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go
+++ b/cl/phase1/forkchoice/fork_graph/fork_graph_disk.go
@@ -40,12 +40,18 @@ import (
)
const dumpSlotFrequency = 4
+const pruneBatchSize = 256
type syncCommittees struct {
currentSyncCommittee *solid.SyncCommittee
nextSyncCommittee *solid.SyncCommittee
}
+type validatedChildren struct {
+ slots map[common.Hash]uint64
+ maxSlot uint64
+}
+
var ErrStateNotFound = errors.New("state not found")
type ChainSegmentInsertionResult uint
@@ -89,11 +95,22 @@ func convertHashSliceToHashList(in [][32]byte) solid.HashVectorSSZ {
// ForkGraph is our graph for ETH 2.0 consensus forkchoice. Each node is a (block root, changes) pair and
// each edge is the path described as (prevBlockRoot, currBlockRoot). if we want to go forward we use blocks.
type forkGraphDisk struct {
+ lifecycleMu sync.RWMutex
+ addPruneMu sync.Mutex
+ pruneMu sync.Mutex
+ pruneBoundaryHook func()
+ pruneBatchHook func()
+ pruneChildrenHook func()
+
// Alternate beacon states
- fs afero.Fs
- blocks sync.Map // set of blocks (block root -> block)
- headers sync.Map // set of headers
- badBlocks sync.Map // blocks that are invalid and that leads to automatic fail of extension.
+ fs afero.Fs
+ blocks sync.Map // set of blocks (block root -> block)
+ headers sync.Map // set of headers
+ badBlocks sync.Map // blocks that are invalid and that leads to automatic fail of extension.
+ unavailablePayloads sync.Map
+ acceptedPayloads sync.Map
+ childrenMu sync.RWMutex
+ children map[common.Hash]*validatedChildren
// current state data — dual-protected. AddChainSegment is the sole writer
// and runs under the outer forkchoice f.mu, so reads taken under f.mu are
@@ -128,7 +145,8 @@ type forkGraphDisk struct {
lightClientUpdates sync.Map // period -> lightclientupdate
// in-memory cache of block roots that have envelopes on disk [Optimization for Gloas:EIP7732]
- envelopeExists sync.Map // common.Hash -> struct{}
+ envelopeExists sync.Map // common.Hash -> struct{}
+ envelopeMissing sync.Map // common.Hash -> struct{}
// reusable buffers
sszBuffer []byte
@@ -184,6 +202,7 @@ func NewForkGraphDisk(anchorState *state.CachingBeaconState, syncedData synced_d
anchorRoot: anchorRoot,
rcfg: rcfg,
syncedData: syncedData,
+ children: make(map[common.Hash]*validatedChildren),
}
f.lowestAvailableBlock.Store(anchorState.Slot())
f.headers.Store(common.Hash(anchorRoot), &anchorHeader)
@@ -213,6 +232,9 @@ func (f *forkGraphDisk) isBlockRootTheCurrentState(blockRoot common.Hash) bool {
// Add a new node and edge to the graph
func (f *forkGraphDisk) AddChainSegment(signedBlock *cltypes.SignedBeaconBlock, fullValidation bool) (*state.CachingBeaconState, ChainSegmentInsertionResult, error) {
+ f.addPruneMu.Lock()
+ defer f.addPruneMu.Unlock()
+
block := signedBlock.Block
blockRoot, err := block.HashSSZ()
if err != nil {
@@ -228,6 +250,10 @@ func (f *forkGraphDisk) AddChainSegment(signedBlock *cltypes.SignedBeaconBlock,
f.badBlocks.Store(common.Hash(blockRoot), struct{}{})
return nil, BelowAnchor, nil
}
+ if isBelowPrunedBoundary(block.Slot, f.lowestAvailableBlock.Load()) {
+ f.badBlocks.Store(common.Hash(blockRoot), struct{}{})
+ return nil, BelowAnchor, nil
+ }
isBlockRootTheCurrentState := f.isBlockRootTheCurrentState(blockRoot)
var newState *state.CachingBeaconState
@@ -369,27 +395,48 @@ func (f *forkGraphDisk) AddChainSegment(signedBlock *cltypes.SignedBeaconBlock,
return nil, LogisticError, err
}
- f.headers.Store(common.Hash(blockRoot), &cltypes.BeaconBlockHeader{
+ header := &cltypes.BeaconBlockHeader{
Slot: block.Slot,
ProposerIndex: block.ProposerIndex,
ParentRoot: block.ParentRoot,
Root: block.StateRoot,
BodyRoot: bodyRoot,
- })
+ }
+ currentJustified := newState.CurrentJustifiedCheckpoint()
+ finalized := newState.FinalizedCheckpoint()
- // Lastly add checkpoints to caches as well.
- f.currentJustifiedCheckpoints.Store(common.Hash(blockRoot), newState.CurrentJustifiedCheckpoint())
- f.finalizedCheckpoints.Store(common.Hash(blockRoot), newState.FinalizedCheckpoint())
+ f.lifecycleMu.Lock()
+ f.headers.Store(common.Hash(blockRoot), header)
+ f.addValidatedChild(block.ParentRoot, common.Hash(blockRoot), block.Slot)
+ f.currentJustifiedCheckpoints.Store(common.Hash(blockRoot), currentJustified)
+ f.finalizedCheckpoints.Store(common.Hash(blockRoot), finalized)
+ f.lifecycleMu.Unlock()
return newState, Success, nil
}
+func isBelowPrunedBoundary(slot, lowestAvailable uint64) bool {
+ return lowestAvailable > 0 && slot < lowestAvailable-1
+}
+
+func lastFullyPrunedEpoch(pruneSlot, slotsPerEpoch uint64) (uint64, bool) {
+ completedEpochs := pruneSlot / slotsPerEpoch
+ if completedEpochs == 0 {
+ return 0, false
+ }
+ return completedEpochs - 1, true
+}
+
func (f *forkGraphDisk) GetHeader(blockRoot common.Hash) (*cltypes.BeaconBlockHeader, bool) {
obj, has := f.headers.Load(blockRoot)
if !has {
return nil, false
}
- return obj.(*cltypes.BeaconBlockHeader), true
+ header := obj.(*cltypes.BeaconBlockHeader)
+ if isBelowPrunedBoundary(header.Slot, f.lowestAvailableBlock.Load()) {
+ return nil, false
+ }
+ return header, true
}
func (f *forkGraphDisk) GetBlock(blockRoot common.Hash) (*cltypes.SignedBeaconBlock, bool) {
@@ -398,7 +445,78 @@ func (f *forkGraphDisk) GetBlock(blockRoot common.Hash) (*cltypes.SignedBeaconBl
return nil, false
}
- return obj.(*cltypes.SignedBeaconBlock), true
+ block := obj.(*cltypes.SignedBeaconBlock)
+ if isBelowPrunedBoundary(block.Block.Slot, f.lowestAvailableBlock.Load()) {
+ return nil, false
+ }
+ return block, true
+}
+
+func (f *forkGraphDisk) HasBlockChildAtOrAfter(blockRoot common.Hash, slot uint64) bool {
+ f.childrenMu.RLock()
+ defer f.childrenMu.RUnlock()
+ children := f.children[blockRoot]
+ return children != nil && !isBelowPrunedBoundary(children.maxSlot, f.lowestAvailableBlock.Load()) && children.maxSlot >= slot
+}
+
+func (f *forkGraphDisk) HasBlockEquivocation(slot, proposerIndex uint64, exceptRoot common.Hash) bool {
+ if isBelowPrunedBoundary(slot, f.lowestAvailableBlock.Load()) {
+ return false
+ }
+ found := false
+ f.headers.Range(func(key, value any) bool {
+ root, ok := key.(common.Hash)
+ if !ok || root == exceptRoot {
+ return true
+ }
+ header, ok := value.(*cltypes.BeaconBlockHeader)
+ if ok && header != nil && header.Slot == slot && header.ProposerIndex == proposerIndex {
+ found = true
+ return false
+ }
+ return true
+ })
+ return found
+}
+
+func (f *forkGraphDisk) addValidatedChild(parentRoot, childRoot common.Hash, slot uint64) {
+ f.childrenMu.Lock()
+ defer f.childrenMu.Unlock()
+ if f.children == nil {
+ f.children = make(map[common.Hash]*validatedChildren)
+ }
+ if f.children[parentRoot] == nil {
+ f.children[parentRoot] = &validatedChildren{slots: make(map[common.Hash]uint64)}
+ }
+ children := f.children[parentRoot]
+ children.slots[childRoot] = slot
+ if slot > children.maxSlot {
+ children.maxSlot = slot
+ }
+}
+
+func (f *forkGraphDisk) removeValidatedChildren(rootsByParent map[common.Hash][]common.Hash) {
+ f.childrenMu.Lock()
+ defer f.childrenMu.Unlock()
+ for parentRoot, roots := range rootsByParent {
+ children := f.children[parentRoot]
+ if children == nil {
+ continue
+ }
+ for _, root := range roots {
+ delete(children.slots, root)
+ }
+ if len(children.slots) == 0 {
+ delete(f.children, parentRoot)
+ continue
+ }
+ children.maxSlot = 0
+ for _, slot := range children.slots {
+ if slot > children.maxSlot {
+ children.maxSlot = slot
+ }
+ }
+ }
}
func (f *forkGraphDisk) GetState(blockRoot common.Hash, alwaysCopy bool) (*state.CachingBeaconState, error) {
@@ -559,13 +677,73 @@ func (f *forkGraphDisk) MarkHeaderAsInvalid(blockRoot common.Hash) {
f.badBlocks.Store(blockRoot, struct{}{})
}
+func (f *forkGraphDisk) IsBlockInvalid(blockRoot common.Hash) bool {
+ _, invalid := f.badBlocks.Load(blockRoot)
+ return invalid
+}
+
+func (f *forkGraphDisk) MarkPayloadUnavailable(blockRoot common.Hash) {
+ f.unavailablePayloads.Store(blockRoot, struct{}{})
+}
+
+func (f *forkGraphDisk) MarkPayloadAvailable(blockRoot common.Hash) {
+ f.unavailablePayloads.Delete(blockRoot)
+}
+
+func (f *forkGraphDisk) IsPayloadUnavailable(blockRoot common.Hash) bool {
+ _, unavailable := f.unavailablePayloads.Load(blockRoot)
+ return unavailable
+}
+
+func (f *forkGraphDisk) MarkPayloadAccepted(blockRoot common.Hash, verified bool) {
+ f.acceptedPayloads.Store(blockRoot, verified)
+}
+
+func (f *forkGraphDisk) ClearPayloadAccepted(blockRoot common.Hash) {
+ f.acceptedPayloads.Delete(blockRoot)
+}
+
+func (f *forkGraphDisk) PayloadAccepted(blockRoot common.Hash) (bool, bool) {
+ verified, ok := f.acceptedPayloads.Load(blockRoot)
+ if !ok {
+ return false, false
+ }
+ return verified.(bool), true
+}
+
+func (f *forkGraphDisk) retainedBlock(blockRoot common.Hash) bool {
+ header, ok := f.headers.Load(blockRoot)
+ if !ok {
+ return false
+ }
+ return !isBelowPrunedBoundary(header.(*cltypes.BeaconBlockHeader).Slot, f.lowestAvailableBlock.Load())
+}
+
+func (f *forkGraphDisk) IsBlockRetained(blockRoot common.Hash) bool {
+ f.lifecycleMu.RLock()
+ defer f.lifecycleMu.RUnlock()
+ return f.retainedBlock(blockRoot)
+}
+
+func (f *forkGraphDisk) WithRetainedBlock(blockRoot common.Hash, fn func()) bool {
+ f.lifecycleMu.RLock()
+ defer f.lifecycleMu.RUnlock()
+ if !f.retainedBlock(blockRoot) {
+ return false
+ }
+ fn()
+ return true
+}
+
func (f *forkGraphDisk) hasBeaconState(blockRoot common.Hash) bool {
exists, err := afero.Exists(f.fs, getBeaconStateFilename(blockRoot))
return err == nil && exists
}
func (f *forkGraphDisk) Prune(pruneSlot uint64) (err error) {
- oldRoots := make([]common.Hash, 0, f.beaconCfg.SlotsPerEpoch)
+ f.pruneMu.Lock()
+ defer f.pruneMu.Unlock()
+
highestStoredBeaconStateSlot := uint64(0)
f.blocks.Range(func(key, value any) bool {
hash := key.(common.Hash)
@@ -573,21 +751,14 @@ func (f *forkGraphDisk) Prune(pruneSlot uint64) (err error) {
if f.hasBeaconState(hash) && highestStoredBeaconStateSlot < signedBlock.Block.Slot {
highestStoredBeaconStateSlot = signedBlock.Block.Slot
}
- if signedBlock.Block.Slot >= pruneSlot {
- return true
- }
-
- oldRoots = append(oldRoots, hash)
return true
})
if pruneSlot >= highestStoredBeaconStateSlot {
return
}
- // prune the indicies for the epoch
- f.currentIndicies.prune(pruneSlot / f.beaconCfg.SlotsPerEpoch)
- f.previousIndicies.prune(pruneSlot / f.beaconCfg.SlotsPerEpoch)
-
+ f.addPruneMu.Lock()
+ f.lifecycleMu.Lock()
// Prune runs without the fork choice lock, so concurrent (or stale queued)
// calls may arrive out of order: only ever raise the marker.
for {
@@ -596,19 +767,61 @@ func (f *forkGraphDisk) Prune(pruneSlot uint64) (err error) {
break
}
}
+ f.lifecycleMu.Unlock()
+ if lastPrunedEpoch, ok := lastFullyPrunedEpoch(pruneSlot, f.beaconCfg.SlotsPerEpoch); ok {
+ currentIndexKeys := f.currentIndicies.keysThrough(lastPrunedEpoch)
+ previousIndexKeys := f.previousIndicies.keysThrough(lastPrunedEpoch)
+ f.currentIndicies.deleteKeys(currentIndexKeys)
+ f.previousIndicies.deleteKeys(previousIndexKeys)
+ }
+ f.addPruneMu.Unlock()
+ if f.pruneBoundaryHook != nil {
+ f.pruneBoundaryHook()
+ }
+
+ oldRoots := make([]common.Hash, 0, f.beaconCfg.SlotsPerEpoch)
+ validatedRootsByParent := make(map[common.Hash][]common.Hash)
+ f.blocks.Range(func(key, value any) bool {
+ if value.(*cltypes.SignedBeaconBlock).Block.Slot < pruneSlot {
+ root := key.(common.Hash)
+ oldRoots = append(oldRoots, root)
+ if header, ok := f.headers.Load(root); ok {
+ h := header.(*cltypes.BeaconBlockHeader)
+ validatedRootsByParent[h.ParentRoot] = append(validatedRootsByParent[h.ParentRoot], root)
+ }
+ }
+ return true
+ })
+ for start := 0; start < len(oldRoots); start += pruneBatchSize {
+ end := min(start+pruneBatchSize, len(oldRoots))
+ f.lifecycleMu.Lock()
+ for _, root := range oldRoots[start:end] {
+ f.blocks.Delete(root)
+ f.lightclientBootstraps.Delete(root)
+ f.currentJustifiedCheckpoints.Delete(root)
+ f.finalizedCheckpoints.Delete(root)
+ f.headers.Delete(root)
+ f.blockRewards.Delete(root)
+ f.envelopeExists.Delete(root)
+ f.envelopeMissing.Delete(root)
+ f.unavailablePayloads.Delete(root)
+ f.acceptedPayloads.Delete(root)
+ f.badBlocks.Delete(root)
+ }
+ f.lifecycleMu.Unlock()
+ if f.pruneBatchHook != nil {
+ f.pruneBatchHook()
+ }
+ }
+ if f.pruneChildrenHook != nil {
+ f.pruneChildrenHook()
+ }
+ f.removeValidatedChildren(validatedRootsByParent)
+
for _, root := range oldRoots {
- f.badBlocks.Delete(root)
- f.blocks.Delete(root)
- f.lightclientBootstraps.Delete(root)
- f.currentJustifiedCheckpoints.Delete(root)
- f.finalizedCheckpoints.Delete(root)
- f.headers.Delete(root)
- f.blockRewards.Delete(root)
if err := f.fs.Remove(getBeaconStateFilename(root)); err != nil && !errors.Is(err, fs.ErrNotExist) {
log.Debug("failed to remove pruned beacon state file", "root", root, "err", err)
}
- // [New in Gloas:EIP7732] Also remove envelope files
- f.envelopeExists.Delete(root)
if err := f.fs.Remove(getEnvelopeFilename(root)); err != nil && !errors.Is(err, fs.ErrNotExist) {
log.Debug("failed to remove pruned envelope file", "root", root, "err", err)
}
diff --git a/cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go b/cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go
index ceb59e869d0..293b197c97e 100644
--- a/cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go
+++ b/cl/phase1/forkchoice/fork_graph/fork_graph_disk_fs.go
@@ -18,6 +18,7 @@ package fork_graph
import (
"encoding/binary"
+ "errors"
"fmt"
"io"
"os"
@@ -181,16 +182,35 @@ func (f *forkGraphDisk) DumpBeaconStateOnDisk(blockRoot common.Hash, bs *state.C
// Uses an in-memory cache populated by DumpEnvelopeOnDisk to avoid repeated disk stats.
// [New in Gloas:EIP7732]
func (f *forkGraphDisk) HasEnvelope(blockRoot common.Hash) bool {
+ f.lifecycleMu.RLock()
+ defer f.lifecycleMu.RUnlock()
+ if !f.retainedBlock(blockRoot) {
+ return false
+ }
// Fast path: check in-memory cache
if _, ok := f.envelopeExists.Load(blockRoot); ok {
return true
}
+ if _, ok := f.envelopeMissing.Load(blockRoot); ok {
+ return false
+ }
+ f.stateDumpLock.Lock()
+ defer f.stateDumpLock.Unlock()
+ if _, ok := f.envelopeExists.Load(blockRoot); ok {
+ return true
+ }
+ if _, ok := f.envelopeMissing.Load(blockRoot); ok {
+ return false
+ }
// Slow path: fall back to disk and populate cache on hit
exists, err := afero.Exists(f.fs, getEnvelopeFilename(blockRoot))
if err == nil && exists {
f.envelopeExists.Store(blockRoot, struct{}{})
return true
}
+ if err == nil {
+ f.envelopeMissing.Store(blockRoot, struct{}{})
+ }
return false
}
@@ -198,6 +218,11 @@ func (f *forkGraphDisk) HasEnvelope(blockRoot common.Hash) bool {
// [New in Gloas:EIP7732]
func (f *forkGraphDisk) ReadEnvelopeFromDisk(blockRoot common.Hash) (envelope *cltypes.SignedExecutionPayloadEnvelope, err error) {
var file afero.File
+ f.lifecycleMu.RLock()
+ defer f.lifecycleMu.RUnlock()
+ if !f.retainedBlock(blockRoot) {
+ return nil, ErrStateNotFound
+ }
f.stateDumpLock.Lock()
defer f.stateDumpLock.Unlock()
@@ -249,45 +274,61 @@ func (f *forkGraphDisk) ReadEnvelopeFromDisk(blockRoot common.Hash) (envelope *c
// DumpEnvelopeOnDisk dumps an execution payload envelope to disk.
// [New in Gloas:EIP7732]
func (f *forkGraphDisk) DumpEnvelopeOnDisk(blockRoot common.Hash, envelope *cltypes.SignedExecutionPayloadEnvelope) (err error) {
+ f.lifecycleMu.RLock()
+ defer f.lifecycleMu.RUnlock()
+ header, ok := f.GetHeader(blockRoot)
+ if !ok || header == nil {
+ return fmt.Errorf("cannot dump envelope for unknown block root %x", blockRoot)
+ }
+ if isBelowPrunedBoundary(header.Slot, f.lowestAvailableBlock.Load()) {
+ return fmt.Errorf("cannot dump envelope for pruned block root %x at slot %d", blockRoot, header.Slot)
+ }
f.stateDumpLock.Lock()
defer f.stateDumpLock.Unlock()
- // Populate in-memory cache on successful write
- defer func() {
- if err == nil {
- f.envelopeExists.Store(blockRoot, struct{}{})
- }
- }()
-
// Encode the envelope
f.sszBuffer, err = envelope.EncodeSSZ(f.sszBuffer[:0])
if err != nil {
return
}
- dumpedFile, err := f.fs.OpenFile(getEnvelopeFilename(blockRoot), os.O_TRUNC|os.O_CREATE|os.O_RDWR, 0o755)
+ filename := getEnvelopeFilename(blockRoot)
+ tmpFilename := filename + ".tmp"
+ dumpedFile, err := f.fs.OpenFile(tmpFilename, os.O_TRUNC|os.O_CREATE|os.O_RDWR, 0o755)
if err != nil {
return err
}
- defer dumpedFile.Close()
+ closed := false
+ defer func() {
+ if !closed {
+ err = errors.Join(err, dumpedFile.Close())
+ }
+ if err != nil {
+ f.envelopeExists.Delete(blockRoot)
+ _ = f.fs.Remove(tmpFilename)
+ }
+ }()
sw := snappypool.Writer(dumpedFile)
- defer snappypool.PutWriter(sw)
-
- // Write the length
- length := make([]byte, 8)
- binary.BigEndian.PutUint64(length, uint64(len(f.sszBuffer)))
- if _, err := sw.Write(length); err != nil {
- log.Error("failed to write length", "err", err)
- return err
- }
- // Write the envelope
- if _, err := sw.Write(f.sszBuffer); err != nil {
- log.Error("failed to write ssz buffer", "err", err)
- return err
- }
- if err = sw.Flush(); err != nil {
- log.Error("failed to flush snappy writer", "err", err)
+ err = func() error {
+ defer snappypool.PutWriter(sw)
+ length := make([]byte, 8)
+ binary.BigEndian.PutUint64(length, uint64(len(f.sszBuffer)))
+ if _, writeErr := sw.Write(length); writeErr != nil {
+ log.Error("failed to write length", "err", writeErr)
+ return writeErr
+ }
+ if _, writeErr := sw.Write(f.sszBuffer); writeErr != nil {
+ log.Error("failed to write ssz buffer", "err", writeErr)
+ return writeErr
+ }
+ if flushErr := sw.Flush(); flushErr != nil {
+ log.Error("failed to flush snappy writer", "err", flushErr)
+ return flushErr
+ }
+ return nil
+ }()
+ if err != nil {
return err
}
@@ -295,6 +336,16 @@ func (f *forkGraphDisk) DumpEnvelopeOnDisk(blockRoot common.Hash, envelope *clty
log.Error("failed to sync dumped file", "err", err)
return
}
+ if err = dumpedFile.Close(); err != nil {
+ closed = true
+ return
+ }
+ closed = true
+ if err = f.fs.Rename(tmpFilename, filename); err != nil {
+ return
+ }
+ f.envelopeExists.Store(blockRoot, struct{}{})
+ f.envelopeMissing.Delete(blockRoot)
return
}
diff --git a/cl/phase1/forkchoice/fork_graph/fork_graph_test.go b/cl/phase1/forkchoice/fork_graph/fork_graph_test.go
index ba0a7eafe75..66dc325e205 100644
--- a/cl/phase1/forkchoice/fork_graph/fork_graph_test.go
+++ b/cl/phase1/forkchoice/fork_graph/fork_graph_test.go
@@ -18,7 +18,12 @@ package fork_graph
import (
_ "embed"
+ "errors"
+ "os"
+ "runtime"
+ "sync"
"testing"
+ "time"
"github.com/erigontech/erigon/cl/beacon/beacon_router_configuration"
"github.com/erigontech/erigon/cl/phase1/core/state"
@@ -26,6 +31,7 @@ import (
"github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/cltypes"
+ "github.com/erigontech/erigon/cl/cltypes/solid"
"github.com/erigontech/erigon/cl/utils"
"github.com/erigontech/erigon/common"
"github.com/stretchr/testify/require"
@@ -40,6 +46,97 @@ var block2 []byte
//go:embed test_data/anchor_state.ssz_snappy
var anchor []byte
+type blockingRemoveFs struct {
+ afero.Fs
+ entered chan struct{}
+ release chan struct{}
+ once sync.Once
+}
+
+type blockingPathRemoveFs struct {
+ afero.Fs
+ target string
+ entered chan struct{}
+ release chan struct{}
+ once sync.Once
+}
+
+func (fs *blockingPathRemoveFs) Remove(name string) error {
+ if name == fs.target {
+ fs.once.Do(func() {
+ close(fs.entered)
+ <-fs.release
+ })
+ }
+ return fs.Fs.Remove(name)
+}
+
+var errPartialEnvelopeWrite = errors.New("partial envelope write")
+
+type partialWriteFs struct {
+ afero.Fs
+ fail bool
+}
+
+type countingStatFs struct {
+ afero.Fs
+ mu sync.Mutex
+ stats int
+}
+
+func (fs *countingStatFs) Stat(name string) (os.FileInfo, error) {
+ fs.mu.Lock()
+ fs.stats++
+ fs.mu.Unlock()
+ return fs.Fs.Stat(name)
+}
+
+type blockingRenameFs struct {
+ afero.Fs
+ target string
+ entered chan struct{}
+ release chan struct{}
+ once sync.Once
+}
+
+func (fs *blockingRenameFs) Rename(oldname, newname string) error {
+ if newname == fs.target {
+ fs.once.Do(func() {
+ close(fs.entered)
+ <-fs.release
+ })
+ }
+ return fs.Fs.Rename(oldname, newname)
+}
+
+func (fs *partialWriteFs) OpenFile(name string, flag int, perm os.FileMode) (afero.File, error) {
+ file, err := fs.Fs.OpenFile(name, flag, perm)
+ if err != nil || !fs.fail {
+ return file, err
+ }
+ return partialWriteFile{File: file}, nil
+}
+
+type partialWriteFile struct {
+ afero.File
+}
+
+func (f partialWriteFile) Write(p []byte) (int, error) {
+ if len(p) == 0 {
+ return 0, errPartialEnvelopeWrite
+ }
+ n, _ := f.File.Write(p[:1])
+ return n, errPartialEnvelopeWrite
+}
+
+func (fs *blockingRemoveFs) Remove(name string) error {
+ fs.once.Do(func() {
+ close(fs.entered)
+ <-fs.release
+ })
+ return fs.Fs.Remove(name)
+}
+
func TestForkGraphInDisk(t *testing.T) {
blockA, blockB, blockC := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion),
cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion),
@@ -87,6 +184,89 @@ func TestNewForkGraphDiskReturnsErrorOnDumpFailure(t *testing.T) {
})
}
+func TestDumpEnvelopeErrorDoesNotPublishPartialFile(t *testing.T) {
+ baseFs := afero.NewMemMapFs()
+ fs := &partialWriteFs{Fs: baseFs, fail: true}
+ cfg := clparams.MainnetBeaconConfig
+ f := &forkGraphDisk{fs: fs, beaconCfg: &cfg}
+ root := common.Hash{1}
+ f.headers.Store(root, &cltypes.BeaconBlockHeader{Slot: 1})
+ envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(&cfg)}
+
+ err := f.DumpEnvelopeOnDisk(root, envelope)
+ require.ErrorIs(t, err, errPartialEnvelopeWrite)
+ require.False(t, f.HasEnvelope(root))
+
+ fs.fail = false
+ require.NoError(t, f.DumpEnvelopeOnDisk(root, envelope))
+ require.True(t, f.HasEnvelope(root))
+ _, err = f.ReadEnvelopeFromDisk(root)
+ require.NoError(t, err)
+}
+
+func TestHasEnvelopeCachesMissAndDumpInvalidatesMiss(t *testing.T) {
+ fs := &countingStatFs{Fs: afero.NewMemMapFs()}
+ cfg := clparams.MainnetBeaconConfig
+ f := &forkGraphDisk{fs: fs, beaconCfg: &cfg}
+ root := common.Hash{1}
+ f.headers.Store(root, &cltypes.BeaconBlockHeader{Slot: 1})
+
+ require.False(t, f.HasEnvelope(root))
+ require.False(t, f.HasEnvelope(root))
+ fs.mu.Lock()
+ require.Equal(t, 1, fs.stats)
+ fs.mu.Unlock()
+
+ envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(&cfg)}
+ require.NoError(t, f.DumpEnvelopeOnDisk(root, envelope))
+ require.True(t, f.HasEnvelope(root))
+}
+
+func TestDumpEnvelopeBeforePruneDoesNotSurvivePrune(t *testing.T) {
+ baseFs := afero.NewMemMapFs()
+ oldRoot := common.Hash{1}
+ newerRoot := common.Hash{2}
+ fs := &blockingRenameFs{
+ Fs: baseFs,
+ target: getEnvelopeFilename(oldRoot),
+ entered: make(chan struct{}),
+ release: make(chan struct{}),
+ }
+ f := &forkGraphDisk{fs: fs, beaconCfg: &clparams.MainnetBeaconConfig, children: make(map[common.Hash]*validatedChildren)}
+ oldBlock := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion)
+ oldBlock.Block.Slot = 64
+ newerBlock := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion)
+ newerBlock.Block.Slot = 128
+ f.blocks.Store(oldRoot, oldBlock)
+ f.blocks.Store(newerRoot, newerBlock)
+ f.headers.Store(oldRoot, &cltypes.BeaconBlockHeader{Slot: oldBlock.Block.Slot})
+ f.headers.Store(newerRoot, &cltypes.BeaconBlockHeader{Slot: newerBlock.Block.Slot})
+ require.NoError(t, afero.WriteFile(baseFs, getBeaconStateFilename(oldRoot), []byte{1}, 0o644))
+ require.NoError(t, afero.WriteFile(baseFs, getBeaconStateFilename(newerRoot), []byte{1}, 0o644))
+ envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(&clparams.MainnetBeaconConfig)}
+
+ dumpDone := make(chan error, 1)
+ go func() { dumpDone <- f.DumpEnvelopeOnDisk(oldRoot, envelope) }()
+ select {
+ case <-fs.entered:
+ case <-time.After(time.Second):
+ t.Fatal("envelope dump did not reach rename")
+ }
+ pruneDone := make(chan error, 1)
+ go func() { pruneDone <- f.Prune(100) }()
+ select {
+ case err := <-pruneDone:
+ close(fs.release)
+ require.NoError(t, err)
+ t.Fatal("prune crossed an active envelope publication")
+ case <-time.After(50 * time.Millisecond):
+ }
+ close(fs.release)
+ require.NoError(t, <-dumpDone)
+ require.NoError(t, <-pruneDone)
+ require.False(t, f.HasEnvelope(oldRoot))
+}
+
func TestNewForkGraphDiskCachesAnchorStateRoot(t *testing.T) {
for _, tc := range []struct {
name string
@@ -142,10 +322,574 @@ func TestPruneKeepsLowestAvailableBlockMonotonic(t *testing.T) {
}
addBlockWithState(100, common.Hash{1})
addBlockWithState(200, common.Hash{2})
+ f.MarkHeaderAsInvalid(common.Hash{1})
+ require.True(t, f.IsBlockInvalid(common.Hash{1}))
+ f.MarkPayloadAccepted(common.Hash{1}, true)
+ verified, accepted := f.PayloadAccepted(common.Hash{1})
+ require.True(t, accepted)
+ require.True(t, verified)
require.NoError(t, f.Prune(150))
require.Equal(t, uint64(151), f.LowestAvailableSlot())
+ require.False(t, f.IsBlockInvalid(common.Hash{1}))
+ _, accepted = f.PayloadAccepted(common.Hash{1})
+ require.False(t, accepted)
require.NoError(t, f.Prune(120))
require.Equal(t, uint64(151), f.LowestAvailableSlot())
}
+
+func TestPruneKeepsParticipationIndicesFromRetainedConcurrentAdd(t *testing.T) {
+ const blockSlot = uint64(65)
+ const pruneSlot = blockSlot - 1
+ cfg := &clparams.MainnetBeaconConfig
+ beaconState := state.New(cfg)
+ beaconState.SetVersion(clparams.AltairVersion)
+ require.NoError(t, beaconState.SetSlot(blockSlot))
+ beaconState.SetLatestBlockHeader(&cltypes.BeaconBlockHeader{Slot: blockSlot})
+ require.NoError(t, beaconState.SetCurrentSyncCommittee(solid.NewSyncCommittee()))
+ require.NoError(t, beaconState.SetNextSyncCommittee(solid.NewSyncCommittee()))
+ current := solid.ParticipationBitListFromBytes([]byte{1, 2}, int(cfg.ValidatorRegistryLimit))
+ previous := solid.ParticipationBitListFromBytes([]byte{3, 4}, int(cfg.ValidatorRegistryLimit))
+ beaconState.SetCurrentEpochParticipation(current)
+ beaconState.SetPreviousEpochParticipation(previous)
+
+ block := cltypes.NewSignedBeaconBlock(cfg, clparams.AltairVersion)
+ block.Block.Slot = blockSlot
+ blockRoot, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ beaconState.SetPreviousStateRoot(common.Hash(blockRoot))
+ boundaryPublished := make(chan struct{})
+ continuePrune := make(chan struct{})
+ f := &forkGraphDisk{
+ fs: afero.NewMemMapFs(),
+ beaconCfg: cfg,
+ rcfg: beacon_router_configuration.RouterConfiguration{Beacon: true},
+ children: make(map[common.Hash]*validatedChildren),
+ currentState: beaconState,
+ currentStateBlockRoot: common.Hash(blockRoot),
+ pruneBoundaryHook: func() {
+ close(boundaryPublished)
+ <-continuePrune
+ },
+ }
+ newerRoot := common.Hash{0xff}
+ newerBlock := cltypes.NewSignedBeaconBlock(cfg, clparams.AltairVersion)
+ newerBlock.Block.Slot = blockSlot + cfg.SlotsPerEpoch
+ f.blocks.Store(newerRoot, newerBlock)
+ require.NoError(t, afero.WriteFile(f.fs, getBeaconStateFilename(newerRoot), []byte{1}, 0o644))
+
+ pruneDone := make(chan error, 1)
+ go func() { pruneDone <- f.Prune(pruneSlot) }()
+ select {
+ case <-boundaryPublished:
+ case <-time.After(time.Second):
+ t.Fatal("prune did not publish its boundary")
+ }
+ require.Equal(t, blockSlot, f.LowestAvailableSlot())
+ _, result, err := f.AddChainSegment(block, false)
+ require.NoError(t, err)
+ require.Equal(t, Success, result)
+ close(continuePrune)
+ require.NoError(t, <-pruneDone)
+
+ epoch := blockSlot / cfg.SlotsPerEpoch
+ gotCurrent, err := f.GetCurrentParticipationIndicies(epoch)
+ require.NoError(t, err)
+ require.NotNil(t, gotCurrent)
+ require.Equal(t, current.Bytes(), gotCurrent.Bytes())
+ gotPrevious, err := f.GetPreviousParticipationIndicies(epoch)
+ require.NoError(t, err)
+ require.NotNil(t, gotPrevious)
+ require.Equal(t, previous.Bytes(), gotPrevious.Bytes())
+}
+
+func TestPruneKeepsParticipationIndicesFromRetainedPriorAdd(t *testing.T) {
+ const blockSlot = uint64(65)
+ const pruneSlot = uint64(64)
+ cfg := &clparams.MainnetBeaconConfig
+ beaconState := state.New(cfg)
+ beaconState.SetVersion(clparams.AltairVersion)
+ require.NoError(t, beaconState.SetSlot(blockSlot))
+ beaconState.SetLatestBlockHeader(&cltypes.BeaconBlockHeader{Slot: blockSlot})
+ require.NoError(t, beaconState.SetCurrentSyncCommittee(solid.NewSyncCommittee()))
+ require.NoError(t, beaconState.SetNextSyncCommittee(solid.NewSyncCommittee()))
+ current := solid.ParticipationBitListFromBytes([]byte{5, 6}, int(cfg.ValidatorRegistryLimit))
+ previous := solid.ParticipationBitListFromBytes([]byte{7, 8}, int(cfg.ValidatorRegistryLimit))
+ beaconState.SetCurrentEpochParticipation(current)
+ beaconState.SetPreviousEpochParticipation(previous)
+
+ block := cltypes.NewSignedBeaconBlock(cfg, clparams.AltairVersion)
+ block.Block.Slot = blockSlot
+ blockRoot, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ beaconState.SetPreviousStateRoot(common.Hash(blockRoot))
+ f := &forkGraphDisk{
+ fs: afero.NewMemMapFs(),
+ beaconCfg: cfg,
+ rcfg: beacon_router_configuration.RouterConfiguration{Beacon: true},
+ children: make(map[common.Hash]*validatedChildren),
+ currentState: beaconState,
+ currentStateBlockRoot: common.Hash(blockRoot),
+ }
+ _, result, err := f.AddChainSegment(block, false)
+ require.NoError(t, err)
+ require.Equal(t, Success, result)
+ newerRoot := common.Hash{0xfe}
+ newerBlock := cltypes.NewSignedBeaconBlock(cfg, clparams.AltairVersion)
+ newerBlock.Block.Slot = blockSlot + cfg.SlotsPerEpoch
+ f.blocks.Store(newerRoot, newerBlock)
+ require.NoError(t, afero.WriteFile(f.fs, getBeaconStateFilename(newerRoot), []byte{1}, 0o644))
+
+ require.NoError(t, f.Prune(pruneSlot))
+ require.Equal(t, blockSlot, f.LowestAvailableSlot())
+ _, retained := f.GetHeader(common.Hash(blockRoot))
+ require.True(t, retained)
+ epoch := blockSlot / cfg.SlotsPerEpoch
+ gotCurrent, err := f.GetCurrentParticipationIndicies(epoch)
+ require.NoError(t, err)
+ require.NotNil(t, gotCurrent)
+ require.Equal(t, current.Bytes(), gotCurrent.Bytes())
+ gotPrevious, err := f.GetPreviousParticipationIndicies(epoch)
+ require.NoError(t, err)
+ require.NotNil(t, gotPrevious)
+ require.Equal(t, previous.Bytes(), gotPrevious.Bytes())
+}
+
+func TestLastFullyPrunedEpoch(t *testing.T) {
+ for _, tc := range []struct {
+ pruneSlot uint64
+ epoch uint64
+ ok bool
+ }{
+ {pruneSlot: 0},
+ {pruneSlot: 31},
+ {pruneSlot: 32, epoch: 0, ok: true},
+ {pruneSlot: 63, epoch: 0, ok: true},
+ {pruneSlot: 64, epoch: 1, ok: true},
+ {pruneSlot: 65, epoch: 1, ok: true},
+ } {
+ epoch, ok := lastFullyPrunedEpoch(tc.pruneSlot, 32)
+ require.Equal(t, tc.ok, ok, "prune slot %d", tc.pruneSlot)
+ require.Equal(t, tc.epoch, epoch, "prune slot %d", tc.pruneSlot)
+ }
+}
+
+func TestOrphanEnvelopeIsNotRediscoveredAfterRootRemoval(t *testing.T) {
+ fs := afero.NewMemMapFs()
+ f := &forkGraphDisk{fs: fs, beaconCfg: &clparams.MainnetBeaconConfig}
+ root := common.Hash{1}
+ require.NoError(t, afero.WriteFile(fs, getEnvelopeFilename(root), []byte{1}, 0o644))
+
+ require.False(t, f.HasEnvelope(root))
+ _, err := f.ReadEnvelopeFromDisk(root)
+ require.ErrorIs(t, err, ErrStateNotFound)
+ _, err = fs.Stat(getEnvelopeFilename(root))
+ require.NoError(t, err)
+}
+
+func TestHasBlockChildAtOrAfterUsesValidatedChildren(t *testing.T) {
+ f := &forkGraphDisk{}
+ parentRoot := common.Hash{1}
+ child := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion)
+ child.Block.ParentRoot = parentRoot
+ child.Block.Slot = 64
+ f.blocks.Store(common.Hash{2}, child)
+
+ require.False(t, f.HasBlockChildAtOrAfter(parentRoot, 64))
+ f.addValidatedChild(parentRoot, common.Hash{2}, 64)
+ require.True(t, f.HasBlockChildAtOrAfter(parentRoot, 64))
+ require.False(t, f.HasBlockChildAtOrAfter(parentRoot, 65))
+ require.False(t, f.HasBlockChildAtOrAfter(common.Hash{3}, 64))
+ f.removeValidatedChildren(map[common.Hash][]common.Hash{parentRoot: {{2}}})
+ require.False(t, f.HasBlockChildAtOrAfter(parentRoot, 64))
+}
+
+func TestHasBlockEquivocationUsesValidatedHeaders(t *testing.T) {
+ f := &forkGraphDisk{}
+ root := common.Hash{1}
+ block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion)
+ block.Block.Slot = 64
+ block.Block.ProposerIndex = 9
+ f.blocks.Store(root, block)
+
+ require.False(t, f.HasBlockEquivocation(64, 9, common.Hash{2}))
+ f.headers.Store(root, &cltypes.BeaconBlockHeader{Slot: 64, ProposerIndex: 9})
+ require.True(t, f.HasBlockEquivocation(64, 9, common.Hash{2}))
+ require.False(t, f.HasBlockEquivocation(64, 9, root))
+ require.False(t, f.HasBlockEquivocation(64, 8, common.Hash{2}))
+ require.False(t, f.HasBlockEquivocation(65, 9, common.Hash{2}))
+}
+
+func TestHasBlockEquivocationUsesPruneBoundary(t *testing.T) {
+ f := &forkGraphDisk{}
+ f.headers.Store(common.Hash{1}, &cltypes.BeaconBlockHeader{Slot: 64, ProposerIndex: 9})
+ f.headers.Store(common.Hash{2}, &cltypes.BeaconBlockHeader{Slot: 63, ProposerIndex: 9})
+ f.lowestAvailableBlock.Store(65)
+
+ require.True(t, f.HasBlockEquivocation(64, 9, common.Hash{3}))
+ require.False(t, f.HasBlockEquivocation(63, 9, common.Hash{3}))
+}
+
+func TestRemoveValidatedChildrenBulkKeepsSameSlotSurvivor(t *testing.T) {
+ f := &forkGraphDisk{children: make(map[common.Hash]*validatedChildren)}
+ parentRoot := common.Hash{1}
+ removed := make([]common.Hash, 1024)
+ for i := range removed {
+ removed[i][0] = byte(i)
+ removed[i][1] = byte(i >> 8)
+ f.addValidatedChild(parentRoot, removed[i], 128)
+ }
+ survivor := common.Hash{0xff, 0xff}
+ f.addValidatedChild(parentRoot, survivor, 127)
+
+ f.removeValidatedChildren(map[common.Hash][]common.Hash{parentRoot: removed})
+
+ require.True(t, f.HasBlockChildAtOrAfter(parentRoot, 127))
+ require.False(t, f.HasBlockChildAtOrAfter(parentRoot, 128))
+ require.Equal(t, map[common.Hash]uint64{survivor: 127}, f.children[parentRoot].slots)
+}
+
+func TestValidatedChildQueryProgressesDuringPruneLifecycle(t *testing.T) {
+ baseFs := afero.NewMemMapFs()
+ fs := &blockingRemoveFs{Fs: baseFs, entered: make(chan struct{}), release: make(chan struct{})}
+ f := &forkGraphDisk{fs: fs, beaconCfg: &clparams.MainnetBeaconConfig, children: make(map[common.Hash]*validatedChildren)}
+ parentRoot := common.Hash{1}
+ childRoot := common.Hash{2}
+ newerRoot := common.Hash{3}
+ child := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion)
+ child.Block.ParentRoot = parentRoot
+ child.Block.Slot = 64
+ newer := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion)
+ newer.Block.Slot = 128
+ f.blocks.Store(childRoot, child)
+ f.blocks.Store(newerRoot, newer)
+ f.headers.Store(childRoot, &cltypes.BeaconBlockHeader{ParentRoot: parentRoot, Slot: child.Block.Slot})
+ f.headers.Store(newerRoot, &cltypes.BeaconBlockHeader{Slot: newer.Block.Slot})
+ require.NoError(t, afero.WriteFile(baseFs, getBeaconStateFilename(childRoot), []byte{1}, 0o644))
+ require.NoError(t, afero.WriteFile(baseFs, getBeaconStateFilename(newerRoot), []byte{1}, 0o644))
+ f.addValidatedChild(parentRoot, childRoot, 64)
+
+ pruneDone := make(chan error, 1)
+ go func() { pruneDone <- f.Prune(100) }()
+ select {
+ case <-fs.entered:
+ case <-time.After(time.Second):
+ t.Fatal("prune did not reach filesystem removal")
+ }
+
+ result := make(chan bool, 1)
+ go func() { result <- f.HasBlockChildAtOrAfter(parentRoot, 64) }()
+ select {
+ case found := <-result:
+ require.False(t, found)
+ case <-time.After(time.Second):
+ close(fs.release)
+ t.Fatal("validated-child query blocked on the prune lifecycle")
+ }
+
+ oldBlock := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion)
+ oldBlock.Block.ParentRoot = parentRoot
+ oldBlock.Block.Slot = 63
+ addDone := make(chan ChainSegmentInsertionResult, 1)
+ go func() {
+ _, result, _ := f.AddChainSegment(oldBlock, true)
+ addDone <- result
+ }()
+ select {
+ case result := <-addDone:
+ require.Equal(t, BelowAnchor, result)
+ case <-time.After(time.Second):
+ close(fs.release)
+ t.Fatal("below-boundary add blocked on filesystem cleanup")
+ }
+ aboveBlock := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion)
+ aboveBlock.Block.ParentRoot = newerRoot
+ aboveBlock.Block.Slot = 129
+ aboveAddDone := make(chan ChainSegmentInsertionResult, 1)
+ go func() {
+ _, result, _ := f.AddChainSegment(aboveBlock, true)
+ aboveAddDone <- result
+ }()
+ select {
+ case result := <-aboveAddDone:
+ require.NotEqual(t, BelowAnchor, result)
+ case <-time.After(time.Second):
+ close(fs.release)
+ t.Fatal("above-boundary add blocked on filesystem cleanup")
+ }
+ statusDone := make(chan bool, 1)
+ go func() {
+ statusDone <- f.WithRetainedBlock(newerRoot, func() { f.MarkPayloadAccepted(newerRoot, false) })
+ }()
+ select {
+ case retained := <-statusDone:
+ require.True(t, retained)
+ case <-time.After(time.Second):
+ close(fs.release)
+ t.Fatal("retained status update blocked on filesystem cleanup")
+ }
+ hasDone := make(chan bool, 1)
+ go func() { hasDone <- f.HasEnvelope(newerRoot) }()
+ select {
+ case found := <-hasDone:
+ require.False(t, found)
+ case <-time.After(time.Second):
+ close(fs.release)
+ t.Fatal("retained envelope query blocked on filesystem cleanup")
+ }
+ readDone := make(chan error, 1)
+ go func() { _, err := f.ReadEnvelopeFromDisk(newerRoot); readDone <- err }()
+ select {
+ case err := <-readDone:
+ require.Error(t, err)
+ case <-time.After(time.Second):
+ close(fs.release)
+ t.Fatal("retained envelope read blocked on filesystem cleanup")
+ }
+ close(fs.release)
+ require.NoError(t, <-pruneDone)
+
+ require.False(t, f.HasBlockChildAtOrAfter(parentRoot, 64))
+ oldRoot, err := oldBlock.Block.HashSSZ()
+ require.NoError(t, err)
+ _, found := f.GetHeader(oldRoot)
+ require.False(t, found)
+}
+
+func TestPruneYieldsLifecycleBetweenBatches(t *testing.T) {
+ fs := afero.NewMemMapFs()
+ entered := make(chan struct{})
+ release := make(chan struct{})
+ var once sync.Once
+ cleanupCalls := 0
+ f := &forkGraphDisk{
+ fs: fs,
+ beaconCfg: &clparams.MainnetBeaconConfig,
+ children: make(map[common.Hash]*validatedChildren),
+ pruneBatchHook: func() {
+ once.Do(func() { close(entered); <-release })
+ },
+ pruneChildrenHook: func() { cleanupCalls++ },
+ }
+ parentRoot := common.Hash{0xaa}
+ staleParentRoot := common.Hash{0xbb}
+ oldRoots := make([]common.Hash, pruneBatchSize+1)
+ for i := range oldRoots {
+ oldRoots[i][0] = byte(i)
+ oldRoots[i][1] = byte(i >> 8)
+ block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion)
+ block.Block.Slot = uint64(i + 1)
+ f.blocks.Store(oldRoots[i], block)
+ f.headers.Store(oldRoots[i], &cltypes.BeaconBlockHeader{ParentRoot: parentRoot, Slot: block.Block.Slot})
+ f.addValidatedChild(parentRoot, oldRoots[i], block.Block.Slot)
+ }
+ f.addValidatedChild(staleParentRoot, oldRoots[len(oldRoots)-1], uint64(len(oldRoots)))
+ newRoot := common.Hash{0xff, 0xff, 0xff}
+ newBlock := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion)
+ newBlock.Block.Slot = 512
+ f.blocks.Store(newRoot, newBlock)
+ f.headers.Store(newRoot, &cltypes.BeaconBlockHeader{Slot: newBlock.Block.Slot})
+ f.addValidatedChild(parentRoot, newRoot, newBlock.Block.Slot)
+ require.NoError(t, afero.WriteFile(fs, getBeaconStateFilename(newRoot), []byte{1}, 0o644))
+
+ done := make(chan error, 1)
+ go func() { done <- f.Prune(300) }()
+ select {
+ case <-entered:
+ case <-time.After(time.Second):
+ t.Fatal("prune did not yield between batches")
+ }
+ require.Equal(t, uint64(301), f.LowestAvailableSlot())
+ _, found := f.GetBlock(oldRoots[len(oldRoots)-1])
+ require.False(t, found)
+ require.False(t, f.HasBlockChildAtOrAfter(staleParentRoot, 1))
+ progress := make(chan bool, 1)
+ go func() { progress <- f.WithRetainedBlock(newRoot, func() { f.MarkPayloadAccepted(newRoot, false) }) }()
+ select {
+ case retained := <-progress:
+ require.True(t, retained)
+ case <-time.After(time.Second):
+ close(release)
+ t.Fatal("retained operation blocked between prune batches")
+ }
+ hasDone := make(chan bool, 1)
+ go func() { hasDone <- f.HasEnvelope(newRoot) }()
+ select {
+ case found := <-hasDone:
+ require.False(t, found)
+ case <-time.After(time.Second):
+ close(release)
+ t.Fatal("envelope query blocked between prune batches")
+ }
+ readDone := make(chan error, 1)
+ go func() { _, err := f.ReadEnvelopeFromDisk(newRoot); readDone <- err }()
+ select {
+ case err := <-readDone:
+ require.Error(t, err)
+ case <-time.After(time.Second):
+ close(release)
+ t.Fatal("envelope read blocked between prune batches")
+ }
+ close(release)
+ require.NoError(t, <-done)
+ for _, root := range oldRoots {
+ _, found := f.blocks.Load(root)
+ require.False(t, found)
+ }
+ require.Equal(t, 1, cleanupCalls)
+ require.True(t, f.HasBlockChildAtOrAfter(parentRoot, newBlock.Block.Slot))
+ require.False(t, f.HasBlockChildAtOrAfter(parentRoot, newBlock.Block.Slot+1))
+ require.False(t, f.HasBlockChildAtOrAfter(staleParentRoot, 1))
+}
+
+func TestHasEnvelopeDoesNotRepopulateCacheDuringPrune(t *testing.T) {
+ baseFs := afero.NewMemMapFs()
+ oldRoot := common.Hash{1}
+ newerRoot := common.Hash{2}
+ fs := &blockingPathRemoveFs{
+ Fs: baseFs,
+ target: getEnvelopeFilename(oldRoot),
+ entered: make(chan struct{}),
+ release: make(chan struct{}),
+ }
+ f := &forkGraphDisk{fs: fs, beaconCfg: &clparams.MainnetBeaconConfig, children: make(map[common.Hash]*validatedChildren)}
+ oldBlock := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion)
+ oldBlock.Block.Slot = 64
+ newerBlock := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion)
+ newerBlock.Block.Slot = 128
+ f.blocks.Store(oldRoot, oldBlock)
+ f.blocks.Store(newerRoot, newerBlock)
+ f.headers.Store(oldRoot, &cltypes.BeaconBlockHeader{Slot: oldBlock.Block.Slot})
+ f.headers.Store(newerRoot, &cltypes.BeaconBlockHeader{Slot: newerBlock.Block.Slot})
+ require.NoError(t, afero.WriteFile(baseFs, getBeaconStateFilename(oldRoot), []byte{1}, 0o644))
+ require.NoError(t, afero.WriteFile(baseFs, getBeaconStateFilename(newerRoot), []byte{1}, 0o644))
+ require.NoError(t, afero.WriteFile(baseFs, getEnvelopeFilename(oldRoot), []byte{1}, 0o644))
+ f.MarkHeaderAsInvalid(oldRoot)
+ f.MarkPayloadUnavailable(oldRoot)
+
+ pruneDone := make(chan error, 1)
+ go func() { pruneDone <- f.Prune(100) }()
+ select {
+ case <-fs.entered:
+ case <-time.After(time.Second):
+ t.Fatal("prune did not reach envelope removal")
+ }
+ require.False(t, f.IsBlockInvalid(oldRoot))
+ require.False(t, f.IsPayloadUnavailable(oldRoot))
+
+ queryDone := make(chan bool, 1)
+ go func() { queryDone <- f.HasEnvelope(oldRoot) }()
+ lateDumpDone := make(chan error, 1)
+ lateEnvelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(&clparams.MainnetBeaconConfig)}
+ go func() { lateDumpDone <- f.DumpEnvelopeOnDisk(oldRoot, lateEnvelope) }()
+ select {
+ case found := <-queryDone:
+ close(fs.release)
+ require.NoError(t, <-pruneDone)
+ require.False(t, found)
+ case <-time.After(50 * time.Millisecond):
+ close(fs.release)
+ require.NoError(t, <-pruneDone)
+ select {
+ case found := <-queryDone:
+ require.False(t, found)
+ case <-time.After(time.Second):
+ t.Fatal("envelope query did not progress after prune completed")
+ }
+ }
+ select {
+ case err := <-lateDumpDone:
+ require.Error(t, err)
+ case <-time.After(time.Second):
+ t.Fatal("late envelope dump did not progress after prune completed")
+ }
+ require.False(t, f.HasEnvelope(oldRoot))
+ require.False(t, f.IsBlockInvalid(oldRoot))
+ require.False(t, f.IsPayloadUnavailable(oldRoot))
+}
+
+func TestAddChainSegmentRejectsSlotBelowPrunedBoundary(t *testing.T) {
+ f := &forkGraphDisk{
+ anchorSlot: 0,
+ children: make(map[common.Hash]*validatedChildren),
+ }
+ f.lowestAvailableBlock.Store(65)
+ block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion)
+ block.Block.Slot = 63
+
+ _, status, err := f.AddChainSegment(block, true)
+ require.NoError(t, err)
+ require.Equal(t, BelowAnchor, status)
+ root, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ _, headerFound := f.GetHeader(root)
+ require.False(t, headerFound)
+ require.False(t, f.HasBlockChildAtOrAfter(block.Block.ParentRoot, block.Block.Slot))
+ require.False(t, isBelowPrunedBoundary(64, 65))
+ require.False(t, isBelowPrunedBoundary(^uint64(0), ^uint64(0)))
+}
+
+func TestAddChainSegmentDoesNotExcludeLifecycleReaders(t *testing.T) {
+ block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion)
+ anchorState := state.New(&clparams.MainnetBeaconConfig)
+ require.NoError(t, utils.DecodeSSZSnappy(block, block1, int(clparams.Phase0Version)))
+ require.NoError(t, utils.DecodeSSZSnappy(anchorState, anchor, int(clparams.Phase0Version)))
+ graph, err := NewForkGraphDisk(anchorState, nil, afero.NewMemMapFs(), beacon_router_configuration.RouterConfiguration{})
+ require.NoError(t, err)
+ f := graph.(*forkGraphDisk)
+ blockRoot, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+
+ f.lifecycleMu.RLock()
+ lifecycleReaderHeld := true
+ t.Cleanup(func() {
+ if lifecycleReaderHeld {
+ f.lifecycleMu.RUnlock()
+ }
+ })
+
+ done := make(chan struct {
+ result ChainSegmentInsertionResult
+ err error
+ }, 1)
+ go func() {
+ _, result, err := f.AddChainSegment(block, true)
+ done <- struct {
+ result ChainSegmentInsertionResult
+ err error
+ }{result: result, err: err}
+ }()
+
+ statePublished := make(chan struct{})
+ go func() {
+ for {
+ f.currentStateMu.RLock()
+ published := f.currentStateBlockRoot == common.Hash(blockRoot)
+ f.currentStateMu.RUnlock()
+ if published {
+ close(statePublished)
+ return
+ }
+ runtime.Gosched()
+ }
+ }()
+ select {
+ case <-statePublished:
+ case <-time.After(time.Second):
+ t.Fatal("block insertion did not reach final lifecycle publication")
+ }
+ select {
+ case result := <-done:
+ t.Fatalf("block insertion completed before lifecycle reader released: %v", result)
+ default:
+ }
+
+ f.lifecycleMu.RUnlock()
+ lifecycleReaderHeld = false
+ result := <-done
+ require.NoError(t, result.err)
+ require.Equal(t, Success, result.result)
+ _, headerFound := f.GetHeader(common.Hash(blockRoot))
+ require.True(t, headerFound)
+}
diff --git a/cl/phase1/forkchoice/fork_graph/interface.go b/cl/phase1/forkchoice/fork_graph/interface.go
index 17c6ce89834..72f66776ed8 100644
--- a/cl/phase1/forkchoice/fork_graph/interface.go
+++ b/cl/phase1/forkchoice/fork_graph/interface.go
@@ -39,11 +39,20 @@ type ForkGraph interface {
AddChainSegment(signedBlock *cltypes.SignedBeaconBlock, fullValidation bool) (*state.CachingBeaconState, ChainSegmentInsertionResult, error)
GetHeader(blockRoot common.Hash) (*cltypes.BeaconBlockHeader, bool)
GetBlock(blockRoot common.Hash) (*cltypes.SignedBeaconBlock, bool)
+ HasBlockChildAtOrAfter(blockRoot common.Hash, slot uint64) bool
+ HasBlockEquivocation(slot, proposerIndex uint64, exceptRoot common.Hash) bool
GetState(blockRoot common.Hash, alwaysCopy bool) (*state.CachingBeaconState, error)
GetCurrentJustifiedCheckpoint(blockRoot common.Hash) (solid.Checkpoint, bool)
GetFinalizedCheckpoint(blockRoot common.Hash) (solid.Checkpoint, bool)
GetSyncCommittees(period uint64) (*solid.SyncCommittee, *solid.SyncCommittee, bool)
MarkHeaderAsInvalid(blockRoot common.Hash)
+ IsBlockInvalid(blockRoot common.Hash) bool
+ MarkPayloadUnavailable(blockRoot common.Hash)
+ MarkPayloadAvailable(blockRoot common.Hash)
+ IsPayloadUnavailable(blockRoot common.Hash) bool
+ MarkPayloadAccepted(blockRoot common.Hash, verified bool)
+ ClearPayloadAccepted(blockRoot common.Hash)
+ PayloadAccepted(blockRoot common.Hash) (verified bool, ok bool)
AnchorSlot() uint64
AnchorRoot() common.Hash
Prune(uint64) error
diff --git a/cl/phase1/forkchoice/fork_graph/participation_indicies_store.go b/cl/phase1/forkchoice/fork_graph/participation_indicies_store.go
index 4c407920bf0..86a98d5d603 100644
--- a/cl/phase1/forkchoice/fork_graph/participation_indicies_store.go
+++ b/cl/phase1/forkchoice/fork_graph/participation_indicies_store.go
@@ -29,12 +29,19 @@ func (p *participationIndiciesStore) add(epoch uint64, participations []byte) {
p.s.Store(epoch, append(prevBitlist, participations...))
}
-func (p *participationIndiciesStore) prune(epoch uint64) {
- // iterate over the map and delete all keys less or equal than epoch
- p.s.Range(func(key, value any) bool {
+func (p *participationIndiciesStore) keysThrough(epoch uint64) []uint64 {
+ keys := []uint64{}
+ p.s.Range(func(key, _ any) bool {
if key.(uint64) <= epoch {
- p.s.Delete(key)
+ keys = append(keys, key.(uint64))
}
return true
})
+ return keys
+}
+
+func (p *participationIndiciesStore) deleteKeys(keys []uint64) {
+ for _, key := range keys {
+ p.s.Delete(key)
+ }
}
diff --git a/cl/phase1/forkchoice/forkchoice.go b/cl/phase1/forkchoice/forkchoice.go
index b8e16d967e1..b860a92bd4e 100644
--- a/cl/phase1/forkchoice/forkchoice.go
+++ b/cl/phase1/forkchoice/forkchoice.go
@@ -458,10 +458,7 @@ func (f *ForkChoiceStore) GetRecentExecutionPayloadStatus(executionBlockHash com
}
func (f *ForkChoiceStore) GetRecentExecutionPayloadStatusByRoot(blockRoot common.Hash) (execution_client.PayloadStatus, bool) {
- if f.payloadStatusByRoot == nil {
- return execution_client.PayloadStatusNone, false
- }
- return f.payloadStatusByRoot.Get(blockRoot)
+ return f.payloadStatusAuthority(blockRoot)
}
// GetExecutionPayloadGasLimit returns the gas_limit of a recently validated execution payload.
@@ -782,6 +779,14 @@ func (f *ForkChoiceStore) GetBlock(blockRoot common.Hash) (*cltypes.SignedBeacon
return f.forkGraph.GetBlock(blockRoot)
}
+func (f *ForkChoiceStore) HasBlockChildAtOrAfter(blockRoot common.Hash, slot uint64) bool {
+ return f.forkGraph.HasBlockChildAtOrAfter(blockRoot, slot)
+}
+
+func (f *ForkChoiceStore) HasBlockEquivocation(slot, proposerIndex uint64, exceptRoot common.Hash) bool {
+ return f.forkGraph.HasBlockEquivocation(slot, proposerIndex, exceptRoot)
+}
+
// HasEnvelope delegates to forkGraph.HasEnvelope.
// [New in Gloas:EIP7732]
func (f *ForkChoiceStore) HasEnvelope(blockRoot common.Hash) bool {
@@ -792,52 +797,137 @@ func (f *ForkChoiceStore) HasEnvelope(blockRoot common.Hash) bool {
// has been accepted by the execution layer.
// [New in Gloas:EIP7732]
func (f *ForkChoiceStore) IsPayloadVerified(blockRoot common.Hash) bool {
- if f.verifiedExecutionPayload == nil {
+ if f.forkGraph == nil {
+ return false
+ }
+ verified, accepted := f.forkGraph.PayloadAccepted(blockRoot)
+ if !accepted || !verified {
return false
}
- return f.verifiedExecutionPayload.Contains(blockRoot)
+ return f.forkGraph.HasEnvelope(blockRoot)
}
func (f *ForkChoiceStore) MarkPayloadVerified(blockRoot common.Hash, executionBlockHash common.Hash) {
+ f.MarkPayloadStatus(blockRoot, executionBlockHash, execution_client.PayloadStatusValidated)
+}
+
+func (f *ForkChoiceStore) MarkPayloadStatus(blockRoot common.Hash, executionBlockHash common.Hash, status execution_client.PayloadStatus) execution_client.PayloadStatus {
f.mu.Lock()
defer f.mu.Unlock()
- f.markPayloadVerifiedLocked(blockRoot, executionBlockHash)
+ return f.markPayloadStatusLocked(blockRoot, executionBlockHash, status)
}
-func (f *ForkChoiceStore) markPayloadVerifiedLocked(blockRoot common.Hash, executionBlockHash common.Hash) {
- if f.verifiedExecutionPayload == nil {
- return
- }
- f.verifiedExecutionPayload.Add(blockRoot, struct{}{})
- if f.executionPayloadStatus != nil {
- f.executionPayloadStatus.Add(executionBlockHash, execution_client.PayloadStatusValidated)
- }
- if f.payloadStatusByRoot != nil {
- f.payloadStatusByRoot.Add(blockRoot, execution_client.PayloadStatusValidated)
- }
- f.headHash = common.Hash{}
- f.headPayloadStatus = cltypes.PayloadStatusPending
+type retainedBlockGuard interface {
+ WithRetainedBlock(common.Hash, func()) bool
+ IsBlockRetained(common.Hash) bool
}
-func (f *ForkChoiceStore) MarkPayloadInvalid(blockRoot common.Hash, executionBlockHash common.Hash) {
+func (f *ForkChoiceStore) MarkPayloadStatusIfRetained(blockRoot common.Hash, executionBlockHash common.Hash, status execution_client.PayloadStatus) (execution_client.PayloadStatus, bool) {
f.mu.Lock()
defer f.mu.Unlock()
- f.markPayloadInvalidLocked(blockRoot, executionBlockHash)
+ guard, ok := f.forkGraph.(retainedBlockGuard)
+ if !ok {
+ return f.markPayloadStatusLocked(blockRoot, executionBlockHash, status), true
+ }
+ effective := status
+ retained := guard.WithRetainedBlock(blockRoot, func() {
+ effective = f.markPayloadStatusRetainedLocked(blockRoot, executionBlockHash, status)
+ })
+ return effective, retained
}
-func (f *ForkChoiceStore) markPayloadInvalidLocked(blockRoot common.Hash, executionBlockHash common.Hash) {
+func (f *ForkChoiceStore) MarkPayloadInvalid(blockRoot common.Hash, executionBlockHash common.Hash) {
+ f.MarkPayloadStatus(blockRoot, executionBlockHash, execution_client.PayloadStatusInvalidated)
+}
+
+func (f *ForkChoiceStore) markPayloadStatusLocked(blockRoot common.Hash, executionBlockHash common.Hash, status execution_client.PayloadStatus) execution_client.PayloadStatus {
+ return f.markPayloadStatus(blockRoot, executionBlockHash, status, false)
+}
+
+func (f *ForkChoiceStore) markPayloadStatusRetainedLocked(blockRoot common.Hash, executionBlockHash common.Hash, status execution_client.PayloadStatus) execution_client.PayloadStatus {
+ return f.markPayloadStatus(blockRoot, executionBlockHash, status, true)
+}
+
+func (f *ForkChoiceStore) markPayloadStatus(blockRoot common.Hash, executionBlockHash common.Hash, status execution_client.PayloadStatus, retained bool) execution_client.PayloadStatus {
+ current, known := f.payloadStatusAuthorityWithRetention(blockRoot, retained)
+ effective := status
+ if known {
+ switch current {
+ case execution_client.PayloadStatusInvalidated:
+ effective = execution_client.PayloadStatusInvalidated
+ case execution_client.PayloadStatusValidated:
+ if status != execution_client.PayloadStatusInvalidated {
+ effective = execution_client.PayloadStatusValidated
+ }
+ case execution_client.PayloadStatusNotValidated:
+ if status == execution_client.PayloadStatusNone {
+ effective = execution_client.PayloadStatusNotValidated
+ }
+ }
+ }
if f.verifiedExecutionPayload != nil {
- f.verifiedExecutionPayload.Remove(blockRoot)
+ if effective == execution_client.PayloadStatusValidated {
+ f.verifiedExecutionPayload.Add(blockRoot, struct{}{})
+ } else {
+ f.verifiedExecutionPayload.Remove(blockRoot)
+ }
}
if f.executionPayloadStatus != nil {
- f.executionPayloadStatus.Add(executionBlockHash, execution_client.PayloadStatusInvalidated)
+ f.executionPayloadStatus.Add(executionBlockHash, effective)
}
if f.payloadStatusByRoot != nil {
- f.payloadStatusByRoot.Add(blockRoot, execution_client.PayloadStatusInvalidated)
+ f.payloadStatusByRoot.Add(blockRoot, effective)
+ }
+ if f.forkGraph != nil {
+ switch effective {
+ case execution_client.PayloadStatusNone:
+ f.forkGraph.ClearPayloadAccepted(blockRoot)
+ f.forkGraph.MarkPayloadUnavailable(blockRoot)
+ case execution_client.PayloadStatusNotValidated:
+ f.forkGraph.MarkPayloadAvailable(blockRoot)
+ f.forkGraph.MarkPayloadAccepted(blockRoot, false)
+ case execution_client.PayloadStatusValidated:
+ f.forkGraph.MarkPayloadAvailable(blockRoot)
+ f.forkGraph.MarkPayloadAccepted(blockRoot, true)
+ case execution_client.PayloadStatusInvalidated:
+ f.forkGraph.MarkPayloadAvailable(blockRoot)
+ f.forkGraph.ClearPayloadAccepted(blockRoot)
+ f.forkGraph.MarkHeaderAsInvalid(blockRoot)
+ }
}
- f.forkGraph.MarkHeaderAsInvalid(blockRoot)
- f.headHash = common.Hash{}
- f.headPayloadStatus = cltypes.PayloadStatusPending
+ if !known || current != effective {
+ f.headHash = common.Hash{}
+ f.headPayloadStatus = cltypes.PayloadStatusPending
+ }
+ return effective
+}
+
+func (f *ForkChoiceStore) payloadStatusAuthority(blockRoot common.Hash) (execution_client.PayloadStatus, bool) {
+ return f.payloadStatusAuthorityWithRetention(blockRoot, false)
+}
+
+func (f *ForkChoiceStore) payloadStatusAuthorityWithRetention(blockRoot common.Hash, retained bool) (execution_client.PayloadStatus, bool) {
+ if f.forkGraph != nil {
+ if guard, ok := f.forkGraph.(retainedBlockGuard); ok && !retained && !guard.IsBlockRetained(blockRoot) {
+ return execution_client.PayloadStatusNone, false
+ }
+ if f.forkGraph.IsBlockInvalid(blockRoot) {
+ return execution_client.PayloadStatusInvalidated, true
+ }
+ if verified, accepted := f.forkGraph.PayloadAccepted(blockRoot); accepted {
+ if verified {
+ return execution_client.PayloadStatusValidated, true
+ }
+ return execution_client.PayloadStatusNotValidated, true
+ }
+ if f.forkGraph.IsPayloadUnavailable(blockRoot) {
+ return execution_client.PayloadStatusNone, true
+ }
+ }
+ if f.payloadStatusByRoot != nil {
+ return f.payloadStatusByRoot.Get(blockRoot)
+ }
+ return execution_client.PayloadStatusNone, false
}
// ReadEnvelopeFromDisk delegates to forkGraph.ReadEnvelopeFromDisk.
@@ -1094,6 +1184,14 @@ func pendingELPayloadRoot(p PendingELPayload) (common.Hash, bool) {
// RequeuePendingELPayload queues a drained execution payload for another EL validation attempt.
// [New in Gloas:EIP7732]
func (f *ForkChoiceStore) RequeuePendingELPayload(p PendingELPayload) {
+ root, ok := pendingELPayloadRoot(p)
+ if !ok {
+ return
+ }
+ if guard, guarded := f.forkGraph.(retainedBlockGuard); guarded {
+ guard.WithRetainedBlock(root, func() { f.addPendingELPayload(p.Block, p.Envelope) })
+ return
+ }
f.addPendingELPayload(p.Block, p.Envelope)
}
diff --git a/cl/phase1/forkchoice/forkchoice_test.go b/cl/phase1/forkchoice/forkchoice_test.go
index a9141295cba..eb39df5a9ff 100644
--- a/cl/phase1/forkchoice/forkchoice_test.go
+++ b/cl/phase1/forkchoice/forkchoice_test.go
@@ -17,6 +17,7 @@
package forkchoice
import (
+ "context"
"errors"
"slices"
"sync"
@@ -27,16 +28,215 @@ import (
"github.com/stretchr/testify/require"
"github.com/erigontech/erigon/cl/beacon/beaconevents"
+ "github.com/erigontech/erigon/cl/beacon/synced_data"
"github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/cltypes"
"github.com/erigontech/erigon/cl/cltypes/solid"
"github.com/erigontech/erigon/cl/phase1/core/state"
+ "github.com/erigontech/erigon/cl/phase1/execution_client"
"github.com/erigontech/erigon/cl/phase1/forkchoice/fork_graph"
+ "github.com/erigontech/erigon/cl/phase1/forkchoice/public_keys_registry"
"github.com/erigontech/erigon/cl/pool"
"github.com/erigontech/erigon/cl/transition/impl/eth2"
+ "github.com/erigontech/erigon/cl/utils/eth_clock"
+ "github.com/erigontech/erigon/cl/validator/validator_params"
"github.com/erigontech/erigon/common"
)
+type embeddedPtcVoteForkGraph struct {
+ *getFinalizedExecutionHashForkGraph
+ postState *state.CachingBeaconState
+ envelopes map[common.Hash]bool
+}
+
+func (g *embeddedPtcVoteForkGraph) AddChainSegment(block *cltypes.SignedBeaconBlock, _ bool) (*state.CachingBeaconState, fork_graph.ChainSegmentInsertionResult, error) {
+ root, err := block.Block.HashSSZ()
+ if err != nil {
+ return nil, fork_graph.InvalidBlock, err
+ }
+ g.blocks[root] = block
+ g.headers[root] = &cltypes.BeaconBlockHeader{Slot: block.Block.Slot, ParentRoot: block.Block.ParentRoot}
+ g.addChainSegmentCalled = true
+ return g.postState, fork_graph.Success, nil
+}
+
+func (g *embeddedPtcVoteForkGraph) HasEnvelope(root common.Hash) bool {
+ return g.envelopes[root]
+}
+
+func TestOnBlockFromForwardSyncExpandsEmbeddedPtcVotesForDuplicateValidator(t *testing.T) {
+ committee := make([]uint64, clparams.MaxPtcSize)
+ for i := range committee {
+ committee[i] = uint64(i + 1)
+ }
+ committee[clparams.MaxPtcSize/2] = committee[0]
+ selectedPositions := make([]int, clparams.MaxPtcSize/2)
+ for i := range selectedPositions {
+ selectedPositions[i] = i
+ }
+
+ store, anchorRoot, child := runEmbeddedPtcVoteBlock(t, clparams.MaxPtcSize, committee, selectedPositions)
+ require.True(t, store.payloadTimeliness(anchorRoot, false))
+ require.True(t, store.payloadDataAvailability(anchorRoot, false))
+ require.False(t, store.ShouldBuildOnFull(ForkChoiceNode{Root: anchorRoot, PayloadStatus: cltypes.PayloadStatusFull}, 2))
+ head, err := store.GetHeadNode()
+ require.NoError(t, err)
+ childRoot, err := child.Block.HashSSZ()
+ require.NoError(t, err)
+ require.Equal(t, ForkChoiceNode{Root: childRoot, PayloadStatus: cltypes.PayloadStatusEmpty}, head)
+}
+
+func TestOnBlockFromForwardSyncAppliesEmbeddedPtcVotesForUniqueValidators(t *testing.T) {
+ committee := make([]uint64, clparams.MaxPtcSize)
+ for i := range committee {
+ committee[i] = uint64(i + 1)
+ }
+ selectedPositions := make([]int, clparams.MaxPtcSize/2+1)
+ for i := range selectedPositions {
+ selectedPositions[i] = i
+ }
+
+ store, anchorRoot, child := runEmbeddedPtcVoteBlock(t, clparams.MaxPtcSize, committee, selectedPositions)
+ require.True(t, store.payloadTimeliness(anchorRoot, false))
+ require.True(t, store.payloadDataAvailability(anchorRoot, false))
+ head, err := store.GetHeadNode()
+ require.NoError(t, err)
+ childRoot, err := child.Block.HashSSZ()
+ require.NoError(t, err)
+ require.Equal(t, ForkChoiceNode{Root: childRoot, PayloadStatus: cltypes.PayloadStatusEmpty}, head)
+}
+
+func TestOnBlockFromForwardSyncUsesMaxPtcSizeForZeroConfig(t *testing.T) {
+ committee := make([]uint64, clparams.MaxPtcSize)
+ for i := range committee {
+ committee[i] = uint64(i + 1)
+ }
+
+ store, anchorRoot, _ := runEmbeddedPtcVoteBlock(t, 0, committee, []int{int(clparams.MaxPtcSize - 1)})
+ votes := store.payloadTimelinessVoteValue(anchorRoot)
+ require.Equal(t, int8(-1), votes[clparams.MaxPtcSize-1])
+ require.Equal(t, int8(1), votes[clparams.MaxPtcSize-2])
+}
+
+func runEmbeddedPtcVoteBlock(
+ t *testing.T,
+ configuredPtcSize uint64,
+ committee []uint64,
+ selectedPositions []int,
+) (*ForkChoiceStore, common.Hash, *cltypes.SignedBeaconBlock) {
+ t.Helper()
+ cfg := clparams.MainnetBeaconConfig
+ cfg.PtcSize = configuredPtcSize
+ cfg.AltairForkEpoch = 0
+ cfg.BellatrixForkEpoch = 0
+ cfg.CapellaForkEpoch = 0
+ cfg.DenebForkEpoch = 0
+ cfg.ElectraForkEpoch = 0
+ cfg.FuluForkEpoch = 0
+ cfg.GloasForkEpoch = 0
+ cfg.InitializeForkSchedule()
+
+ anchor := state.New(&cfg)
+ anchor.SetVersion(clparams.GloasVersion)
+ require.NoError(t, anchor.SetSlot(1))
+ ptcSize := configuredPtcSize
+ if ptcSize == 0 {
+ ptcSize = clparams.MaxPtcSize
+ }
+ ptcWindow := solid.NewUint64VectorOfVectors(int((2+cfg.MinSeedLookahead)*cfg.SlotsPerEpoch), int(ptcSize))
+ ptc := ptcWindow.Get(int(cfg.SlotsPerEpoch + anchor.Slot()%cfg.SlotsPerEpoch))
+ for i, validatorIndex := range committee {
+ ptc.Set(i, validatorIndex)
+ }
+ anchor.SetPtcWindow(ptcWindow)
+ anchorRoot, err := anchor.BlockRoot()
+ require.NoError(t, err)
+
+ postState, err := anchor.Copy()
+ require.NoError(t, err)
+ require.NoError(t, postState.SetSlot(2))
+ graph := &embeddedPtcVoteForkGraph{
+ getFinalizedExecutionHashForkGraph: &getFinalizedExecutionHashForkGraph{
+ blocks: make(map[common.Hash]*cltypes.SignedBeaconBlock),
+ headers: map[common.Hash]*cltypes.BeaconBlockHeader{anchorRoot: {Slot: 1}},
+ states: map[common.Hash]*state.CachingBeaconState{anchorRoot: anchor},
+ anchorRoot: anchorRoot,
+ anchorSlot: 1,
+ },
+ postState: postState,
+ envelopes: map[common.Hash]bool{anchorRoot: true},
+ }
+ clock := eth_clock.NewEthereumClock(0, common.Hash{}, &cfg)
+ store, err := NewForkChoiceStore(
+ clock,
+ anchor,
+ nil,
+ pool.NewOperationsPool(&cfg),
+ graph,
+ beaconevents.NewEventEmitter(),
+ synced_data.NewSyncedDataManager(&cfg, true),
+ nil,
+ public_keys_registry.NewInMemoryPublicKeysRegistry(),
+ validator_params.NewValidatorParams(),
+ false,
+ nil,
+ )
+ require.NoError(t, err)
+ store.OnTick(2 * cfg.SecondsPerSlot)
+ store.payloadStatusByRoot.Add(anchorRoot, execution_client.PayloadStatusValidated)
+
+ child := cltypes.NewSignedBeaconBlock(&cfg, clparams.GloasVersion)
+ child.Block.Slot = 2
+ child.Block.ParentRoot = anchorRoot
+ votes := solid.NewBitVector(int(ptcSize))
+ for _, position := range selectedPositions {
+ require.NoError(t, votes.SetBitAt(position, true))
+ }
+ child.Block.Body.PayloadAttestations.Append(&cltypes.PayloadAttestation{
+ AggregationBits: votes,
+ Data: &cltypes.PayloadAttestationData{
+ BeaconBlockRoot: anchorRoot,
+ Slot: 1,
+ PayloadPresent: false,
+ BlobDataAvailable: false,
+ },
+ })
+
+ require.NoError(t, store.OnBlock(context.Background(), child, false, false, false))
+ return store, anchorRoot, child
+}
+
+type headerOnlyAnchorForkGraph struct {
+ fork_graph.ForkGraph
+ root common.Hash
+ slot uint64
+}
+
+func (g headerOnlyAnchorForkGraph) AnchorRoot() common.Hash { return g.root }
+func (g headerOnlyAnchorForkGraph) AnchorSlot() uint64 { return g.slot }
+func (g headerOnlyAnchorForkGraph) GetHeader(root common.Hash) (*cltypes.BeaconBlockHeader, bool) {
+ if root == g.root {
+ return &cltypes.BeaconBlockHeader{Slot: g.slot}, true
+ }
+ return nil, false
+}
+
+func TestGetHeadNodeCachesHeaderOnlyAnchorFallback(t *testing.T) {
+ anchorRoot := common.HexToHash("0xa1")
+ store := &ForkChoiceStore{
+ forkGraph: headerOnlyAnchorForkGraph{root: anchorRoot, slot: 42},
+ beaconCfg: &clparams.MainnetBeaconConfig,
+ }
+ store.justifiedCheckpoint.Store(solid.Checkpoint{Root: common.HexToHash("0xb2")})
+
+ node, err := store.GetHeadNode()
+ require.NoError(t, err)
+ require.Equal(t, anchorRoot, node.Root)
+ require.Equal(t, cltypes.PayloadStatusPending, node.PayloadStatus)
+ require.Equal(t, anchorRoot, store.headHash)
+ require.Equal(t, uint64(42), store.headSlot)
+}
+
func TestGetFinalizedExecutionHash(t *testing.T) {
cache, err := lru.New[common.Hash, common.Hash](16)
require.NoError(t, err)
@@ -393,6 +593,10 @@ type getFinalizedExecutionHashForkGraph struct {
currentJustified solid.Checkpoint
}
+func (f *getFinalizedExecutionHashForkGraph) HasBlockEquivocation(uint64, uint64, common.Hash) bool {
+ return false
+}
+
func (g *getFinalizedExecutionHashForkGraph) AddChainSegment(*cltypes.SignedBeaconBlock, bool) (*state.CachingBeaconState, fork_graph.ChainSegmentInsertionResult, error) {
g.addChainSegmentCalled = true
return nil, g.addChainSegmentStatus, g.addChainSegmentErr
@@ -408,6 +612,15 @@ func (g *getFinalizedExecutionHashForkGraph) GetBlock(blockRoot common.Hash) (*c
return block, block != nil
}
+func (g *getFinalizedExecutionHashForkGraph) HasBlockChildAtOrAfter(blockRoot common.Hash, slot uint64) bool {
+ for _, block := range g.blocks {
+ if block != nil && block.Block != nil && block.Block.ParentRoot == blockRoot && block.Block.Slot >= slot {
+ return true
+ }
+ }
+ return false
+}
+
func (g *getFinalizedExecutionHashForkGraph) GetState(blockRoot common.Hash, alwaysCopy bool) (*state.CachingBeaconState, error) {
g.getStateMu.Lock()
g.getStateRoots = append(g.getStateRoots, blockRoot)
@@ -451,6 +664,21 @@ func (g *getFinalizedExecutionHashForkGraph) MarkHeaderAsInvalid(common.Hash) {
panic("not used")
}
+func (g *getFinalizedExecutionHashForkGraph) IsBlockInvalid(common.Hash) bool {
+ return false
+}
+
+func (g *getFinalizedExecutionHashForkGraph) MarkPayloadUnavailable(common.Hash) {}
+func (g *getFinalizedExecutionHashForkGraph) MarkPayloadAvailable(common.Hash) {}
+func (g *getFinalizedExecutionHashForkGraph) IsPayloadUnavailable(common.Hash) bool {
+ return false
+}
+func (g *getFinalizedExecutionHashForkGraph) MarkPayloadAccepted(common.Hash, bool) {}
+func (g *getFinalizedExecutionHashForkGraph) ClearPayloadAccepted(common.Hash) {}
+func (g *getFinalizedExecutionHashForkGraph) PayloadAccepted(common.Hash) (bool, bool) {
+ return false, false
+}
+
func (g *getFinalizedExecutionHashForkGraph) AnchorSlot() uint64 {
return g.anchorSlot
}
@@ -519,5 +747,5 @@ func (g *getFinalizedExecutionHashForkGraph) ReadEnvelopeFromDisk(common.Hash) (
}
func (g *getFinalizedExecutionHashForkGraph) HasEnvelope(common.Hash) bool {
- panic("not used")
+ return false
}
diff --git a/cl/phase1/forkchoice/get_head.go b/cl/phase1/forkchoice/get_head.go
index bafeacd4ee0..18421500f68 100644
--- a/cl/phase1/forkchoice/get_head.go
+++ b/cl/phase1/forkchoice/get_head.go
@@ -132,8 +132,11 @@ func (f *ForkChoiceStore) GetHead(auxilliaryState *state.CachingBeaconState) (co
return f.headHash, f.headSlot, nil
}
headRoot, headSlot := f.forkGraph.AnchorRoot(), f.forkGraph.AnchorSlot()
- f.publishSelectedHead(headRoot, headSlot)
- return headRoot, headSlot, nil
+ f.headHash = headRoot
+ f.headSlot = headSlot
+ f.headPayloadStatus = cltypes.PayloadStatusPending
+ f.publishSelectedHead(f.headHash, f.headSlot)
+ return f.headHash, f.headSlot, nil
}
currentEpoch := f.computeEpochAtSlot(f.Slot())
@@ -151,6 +154,23 @@ func (f *ForkChoiceStore) GetHeadPayloadStatus() cltypes.PayloadStatus {
return f.headPayloadStatus
}
+func (f *ForkChoiceStore) GetHeadNode() (ForkChoiceNode, error) {
+ for range 3 {
+ root, slot, err := f.GetHead(nil)
+ if err != nil {
+ return ForkChoiceNode{}, err
+ }
+ f.mu.RLock()
+ if f.headHash == root && f.headSlot == slot {
+ node := ForkChoiceNode{Root: root, PayloadStatus: f.headPayloadStatus}
+ f.mu.RUnlock()
+ return node, nil
+ }
+ f.mu.RUnlock()
+ }
+ return ForkChoiceNode{}, errors.New("fork choice head changed during snapshot")
+}
+
// getHeadGloas returns the head using GLOAS fork choice rules.
// [New in Gloas:EIP7732]
func (f *ForkChoiceStore) getHeadGloas() (common.Hash, uint64, error) {
diff --git a/cl/phase1/forkchoice/interface.go b/cl/phase1/forkchoice/interface.go
index 9211048f700..de15c8c32d4 100644
--- a/cl/phase1/forkchoice/interface.go
+++ b/cl/phase1/forkchoice/interface.go
@@ -46,6 +46,7 @@ type ForkChoiceStorageReader interface {
// GetFinalizedExecutionHash returns the EL block hash for finalized/justified checkpoints.
GetFinalizedExecutionHash(eth2Root common.Hash) common.Hash
GetHead(auxilliaryState *state.CachingBeaconState) (common.Hash, uint64, error)
+ GetHeadNode() (ForkChoiceNode, error)
HighestSeen() uint64
JustifiedCheckpoint() solid.Checkpoint
JustifiedSlot() uint64
@@ -73,9 +74,11 @@ type ForkChoiceStorageReader interface {
GetHeader(blockRoot common.Hash) (*cltypes.BeaconBlockHeader, bool)
// [New in Gloas:EIP7732] GetBlock returns the full block for a given block root.
GetBlock(blockRoot common.Hash) (*cltypes.SignedBeaconBlock, bool)
+ HasBlockChildAtOrAfter(blockRoot common.Hash, slot uint64) bool
+ HasBlockEquivocation(slot, proposerIndex uint64, exceptRoot common.Hash) bool
// [New in Gloas:EIP7732] HasEnvelope checks if a signed execution payload envelope exists.
HasEnvelope(blockRoot common.Hash) bool
- // [New in Gloas:EIP7732] IsPayloadVerified checks whether the execution payload was accepted by the EL.
+ // IsPayloadVerified reports whether the EL has fully validated the payload.
IsPayloadVerified(blockRoot common.Hash) bool
// [New in Gloas:EIP7732] ReadEnvelopeFromDisk reads a signed execution payload envelope from disk.
ReadEnvelopeFromDisk(blockRoot common.Hash) (*cltypes.SignedExecutionPayloadEnvelope, error)
@@ -95,7 +98,7 @@ type ForkChoiceStorageReader interface {
ShouldExtendPayload(root common.Hash) bool
// [New in Gloas:EIP7732] ShouldBuildOnFull returns whether the proposer should build on
// the full payload for the given head node. Used for proposer reorg of unavailable blocks.
- ShouldBuildOnFull(head ForkChoiceNode) bool
+ ShouldBuildOnFull(head ForkChoiceNode, slot uint64) bool
GetBalances(blockRoot common.Hash) (solid.Uint64ListSSZ, error)
GetInactivitiesScores(blockRoot common.Hash) (solid.Uint64ListSSZ, error)
@@ -133,10 +136,18 @@ type ForkChoiceStorageWriter interface {
fullValidation bool,
checkDataAvaibility bool,
) error
+ OnBlockWithEquivocationCheck(
+ ctx context.Context,
+ block *cltypes.SignedBeaconBlock,
+ newPayload bool,
+ fullValidation bool,
+ checkDataAvaibility bool,
+ ) error
// [New in Gloas:EIP7732] OnExecutionPayload processes an execution payload envelope from the builder.
// checkBlobData: verify blob data availability via PeerDAS
// validatePayload: call engine.NewPayload() to validate with EL
OnExecutionPayload(ctx context.Context, signedEnvelope *cltypes.SignedExecutionPayloadEnvelope, checkBlobData, validatePayload bool) error
+ ValidateExecutionPayloadEnvelope(signedEnvelope *cltypes.SignedExecutionPayloadEnvelope) error
// [New in Gloas:EIP7732] ApplyLocalSelfBuildEnvelope processes a locally-produced
// self-build envelope, skipping BLS signature verification. EL validation still runs.
// MUST only be called from the local block production path.
diff --git a/cl/phase1/forkchoice/mock_services/forkchoice_mock.go b/cl/phase1/forkchoice/mock_services/forkchoice_mock.go
index caf98dc0f3e..10e21f166a7 100644
--- a/cl/phase1/forkchoice/mock_services/forkchoice_mock.go
+++ b/cl/phase1/forkchoice/mock_services/forkchoice_mock.go
@@ -18,6 +18,7 @@ package mock_services
import (
"context"
+ "errors"
"testing"
"go.uber.org/mock/gomock"
@@ -49,6 +50,8 @@ type ForkChoiceStorageMock struct {
HeadVal common.Hash
HeadSlotVal uint64
HeadPayloadStatusVal cltypes.PayloadStatus
+ GetHeadNodeFn func() (forkchoice.ForkChoiceNode, error)
+ GetStateAtBlockRootFn func(common.Hash, bool) (*state.CachingBeaconState, error)
HighestSeenVal uint64
JustifiedCheckpointVal solid.Checkpoint
JustifiedSlotVal uint64
@@ -60,24 +63,27 @@ type ForkChoiceStorageMock struct {
IsRootOptimisticVal bool
- StateAtBlockRootVal map[common.Hash]*state.CachingBeaconState
- StateAtSlotVal map[uint64]*state.CachingBeaconState
- GetSyncCommitteesVal map[uint64][2]*solid.SyncCommittee
- GetFinalityCheckpointsVal map[common.Hash][3]solid.Checkpoint
- PendingConsolidationsVal map[common.Hash]*solid.ListSSZ[*solid.PendingConsolidation]
- PendingDepositsVal map[common.Hash]*solid.ListSSZ[*solid.PendingDeposit]
- PendingPartialWithdrawalsVal map[common.Hash]*solid.ListSSZ[*solid.PendingPartialWithdrawal]
- WeightsMock []forkchoice.ForkNode
- LightClientBootstraps map[common.Hash]*cltypes.LightClientBootstrap
- NewestLCUpdate *cltypes.LightClientUpdate
- LCUpdates map[uint64]*cltypes.LightClientUpdate
- SyncContributionPool sync_contribution_pool.SyncContributionPool
- Headers map[common.Hash]*cltypes.BeaconBlockHeader
- Blocks map[common.Hash]*cltypes.SignedBeaconBlock
- Envelopes map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope
- VerifiedPayloads map[common.Hash]bool
- OnExecutionPayloadErr error
- GetBeaconCommitteeMock func(slot, committeeIndex uint64) ([]uint64, error)
+ StateAtBlockRootVal map[common.Hash]*state.CachingBeaconState
+ StateAtSlotVal map[uint64]*state.CachingBeaconState
+ GetSyncCommitteesVal map[uint64][2]*solid.SyncCommittee
+ GetFinalityCheckpointsVal map[common.Hash][3]solid.Checkpoint
+ PendingConsolidationsVal map[common.Hash]*solid.ListSSZ[*solid.PendingConsolidation]
+ PendingDepositsVal map[common.Hash]*solid.ListSSZ[*solid.PendingDeposit]
+ PendingPartialWithdrawalsVal map[common.Hash]*solid.ListSSZ[*solid.PendingPartialWithdrawal]
+ WeightsMock []forkchoice.ForkNode
+ LightClientBootstraps map[common.Hash]*cltypes.LightClientBootstrap
+ NewestLCUpdate *cltypes.LightClientUpdate
+ LCUpdates map[uint64]*cltypes.LightClientUpdate
+ SyncContributionPool sync_contribution_pool.SyncContributionPool
+ Headers map[common.Hash]*cltypes.BeaconBlockHeader
+ Blocks map[common.Hash]*cltypes.SignedBeaconBlock
+ Envelopes map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope
+ VerifiedPayloads map[common.Hash]bool
+ OnExecutionPayloadErr error
+ OnExecutionPayloadFn func(context.Context, *cltypes.SignedExecutionPayloadEnvelope, bool, bool) error
+ OnTickFn func(uint64)
+ ValidateExecutionPayloadEnvelopeErr error
+ GetBeaconCommitteeMock func(slot, committeeIndex uint64) ([]uint64, error)
Pool pool.OperationsPool
@@ -290,6 +296,9 @@ func (f *ForkChoiceStorageMock) GetStateAtBlockRoot(
blockRoot common.Hash,
alwaysCopy bool,
) (*state.CachingBeaconState, error) {
+ if f.GetStateAtBlockRootFn != nil {
+ return f.GetStateAtBlockRootFn(blockRoot, alwaysCopy)
+ }
st := f.StateAtBlockRootVal[blockRoot]
if st == nil || !alwaysCopy {
return st, nil
@@ -351,10 +360,36 @@ func (f *ForkChoiceStorageMock) OnBlock(
return nil
}
+func (f *ForkChoiceStorageMock) OnBlockWithEquivocationCheck(
+ ctx context.Context,
+ block *cltypes.SignedBeaconBlock,
+ newPayload bool,
+ fullValidation bool,
+ checkDataAvaiability bool,
+) error {
+ if block != nil && block.Block != nil {
+ root, err := block.Block.HashSSZ()
+ if err != nil {
+ return err
+ }
+ if f.HasBlockEquivocation(block.Block.Slot, block.Block.ProposerIndex, root) {
+ return errors.New("block conflicts with a previously validated proposal")
+ }
+ }
+ return f.OnBlock(ctx, block, newPayload, fullValidation, checkDataAvaiability)
+}
+
func (f *ForkChoiceStorageMock) OnExecutionPayload(ctx context.Context, signedEnvelope *cltypes.SignedExecutionPayloadEnvelope, checkBlobData, validatePayload bool) error {
+ if f.OnExecutionPayloadFn != nil {
+ return f.OnExecutionPayloadFn(ctx, signedEnvelope, checkBlobData, validatePayload)
+ }
return f.OnExecutionPayloadErr
}
+func (f *ForkChoiceStorageMock) ValidateExecutionPayloadEnvelope(signedEnvelope *cltypes.SignedExecutionPayloadEnvelope) error {
+ return f.ValidateExecutionPayloadEnvelopeErr
+}
+
func (f *ForkChoiceStorageMock) ApplyLocalSelfBuildEnvelope(ctx context.Context, signedEnvelope *cltypes.SignedExecutionPayloadEnvelope) error {
return nil
}
@@ -368,6 +403,10 @@ func (f *ForkChoiceStorageMock) OnPayloadAttestationMessage(ctx context.Context,
}
func (f *ForkChoiceStorageMock) OnTick(time uint64) {
+ if f.OnTickFn != nil {
+ f.OnTickFn(time)
+ return
+ }
panic("implement me")
}
@@ -402,6 +441,24 @@ func (f *ForkChoiceStorageMock) ForkNodes() []forkchoice.ForkNode {
return f.WeightsMock
}
+func (f *ForkChoiceStorageMock) HasBlockChildAtOrAfter(blockRoot common.Hash, slot uint64) bool {
+ for _, header := range f.Headers {
+ if header != nil && header.ParentRoot == blockRoot && header.Slot >= slot {
+ return true
+ }
+ }
+ return false
+}
+
+func (f *ForkChoiceStorageMock) HasBlockEquivocation(slot, proposerIndex uint64, exceptRoot common.Hash) bool {
+ for root, block := range f.Blocks {
+ if root != exceptRoot && block != nil && block.Block != nil && block.Block.Slot == slot && block.Block.ProposerIndex == proposerIndex {
+ return true
+ }
+ }
+ return false
+}
+
func (f *ForkChoiceStorageMock) Synced() bool {
return true
}
@@ -462,11 +519,18 @@ func (f *ForkChoiceStorageMock) GetHeadPayloadStatus() cltypes.PayloadStatus {
return f.HeadPayloadStatusVal
}
+func (f *ForkChoiceStorageMock) GetHeadNode() (forkchoice.ForkChoiceNode, error) {
+ if f.GetHeadNodeFn != nil {
+ return f.GetHeadNodeFn()
+ }
+ return forkchoice.ForkChoiceNode{Root: f.HeadVal, PayloadStatus: f.HeadPayloadStatusVal}, nil
+}
+
func (f *ForkChoiceStorageMock) ShouldExtendPayload(root common.Hash) bool {
return f.ShouldExtendPayloadVal
}
-func (f *ForkChoiceStorageMock) ShouldBuildOnFull(head forkchoice.ForkChoiceNode) bool {
+func (f *ForkChoiceStorageMock) ShouldBuildOnFull(head forkchoice.ForkChoiceNode, slot uint64) bool {
return true
}
diff --git a/cl/phase1/forkchoice/on_block.go b/cl/phase1/forkchoice/on_block.go
index b61c3a1a307..207d169488a 100644
--- a/cl/phase1/forkchoice/on_block.go
+++ b/cl/phase1/forkchoice/on_block.go
@@ -87,6 +87,14 @@ func collectOnBlockLatencyToUnixTime(ethClock eth_clock.EthereumClock, slot, cur
}
func (f *ForkChoiceStore) OnBlock(ctx context.Context, block *cltypes.SignedBeaconBlock, newPayload, fullValidation, checkDataAvaiability bool) error {
+ return f.onBlock(ctx, block, newPayload, fullValidation, checkDataAvaiability, false)
+}
+
+func (f *ForkChoiceStore) OnBlockWithEquivocationCheck(ctx context.Context, block *cltypes.SignedBeaconBlock, newPayload, fullValidation, checkDataAvaiability bool) error {
+ return f.onBlock(ctx, block, newPayload, fullValidation, checkDataAvaiability, true)
+}
+
+func (f *ForkChoiceStore) onBlock(ctx context.Context, block *cltypes.SignedBeaconBlock, newPayload, fullValidation, checkDataAvaiability, rejectEquivocation bool) error {
f.mu.Lock()
unlocked := false
defer f.drainQueuedWork()
@@ -95,13 +103,21 @@ func (f *ForkChoiceStore) OnBlock(ctx context.Context, block *cltypes.SignedBeac
f.mu.Unlock()
}
}()
- f.headHash = common.Hash{}
- f.headPayloadStatus = cltypes.PayloadStatusPending
start := time.Now()
blockRoot, err := block.Block.HashSSZ()
if err != nil {
return err
}
+ if block.Version() >= clparams.GloasVersion {
+ if _, ok := f.forkGraph.GetHeader(blockRoot); ok {
+ return nil
+ }
+ }
+ if rejectEquivocation && f.forkGraph.HasBlockEquivocation(block.Block.Slot, block.Block.ProposerIndex, blockRoot) {
+ return errors.New("block conflicts with a previously validated proposal")
+ }
+ f.headHash = common.Hash{}
+ f.headPayloadStatus = cltypes.PayloadStatusPending
// Use the store's current slot (set via OnTick) to validate the block is not from the future.
// The spec says: assert get_current_slot(store) >= block.slot
if f.Slot() < block.Block.Slot {
@@ -340,10 +356,7 @@ func (f *ForkChoiceStore) OnBlock(ctx context.Context, block *cltypes.SignedBeac
f.payloadTimelinessVote.Store(common.Hash(blockRoot), [clparams.PtcSize]int8{})
f.payloadDataAvailabilityVote.Store(common.Hash(blockRoot), [clparams.PtcSize]int8{})
- // Notify PTC messages from payload attestations in the block.
- // Skip during forward sync (newPayload=false) — PTC votes only matter
- // for fork choice at the chain tip, not for historical blocks.
- if block.Block.Body.PayloadAttestations != nil && newPayload {
+ if block.Block.Body.PayloadAttestations != nil {
f.notifyPtcMessages(lastProcessedState, block.Block.Body.PayloadAttestations)
}
diff --git a/cl/phase1/forkchoice/on_block_fork_consistency_test.go b/cl/phase1/forkchoice/on_block_fork_consistency_test.go
index 7a22f7e23e4..77ae720ab64 100644
--- a/cl/phase1/forkchoice/on_block_fork_consistency_test.go
+++ b/cl/phase1/forkchoice/on_block_fork_consistency_test.go
@@ -25,6 +25,7 @@ import (
"github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/cltypes"
"github.com/erigontech/erigon/cl/utils"
+ "github.com/erigontech/erigon/common"
)
// A response's decoded schema comes from the peer-chosen fork digest, so it is
@@ -55,3 +56,19 @@ func TestOnBlockRejectsForkSchemaSlotMismatch(t *testing.T) {
err := store.OnBlock(context.Background(), mismatched, false, true, true)
require.ErrorIs(t, err, ErrForkSchemaSlotMismatch)
}
+
+func TestOnBlockDoesNotShortCircuitKnownPreGloasBlock(t *testing.T) {
+ cfg := clparams.MainnetBeaconConfig
+ block := cltypes.NewSignedBeaconBlock(&cfg, clparams.DenebVersion)
+ block.Block.Slot = 1
+ root, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ graph := &getFinalizedExecutionHashForkGraph{
+ headers: map[common.Hash]*cltypes.BeaconBlockHeader{root: {Slot: block.Block.Slot}},
+ }
+ store := &ForkChoiceStore{beaconCfg: &cfg, forkGraph: graph}
+
+ err = store.OnBlock(context.Background(), block, true, false, false)
+
+ require.ErrorContains(t, err, "block is too early")
+}
diff --git a/cl/phase1/forkchoice/on_execution_payload.go b/cl/phase1/forkchoice/on_execution_payload.go
index c5ac7d71f90..4c6523326c1 100644
--- a/cl/phase1/forkchoice/on_execution_payload.go
+++ b/cl/phase1/forkchoice/on_execution_payload.go
@@ -341,7 +341,16 @@ func (f *ForkChoiceStore) applyPayloadValidationResultLocked(
// Track payload status and gas limit by execution block hash for parent payload validation
executionBlockHash := envelope.Payload.BlockHash
- f.executionPayloadStatus.Add(executionBlockHash, payloadStatus)
+ if guard, ok := f.forkGraph.(retainedBlockGuard); ok {
+ retained := guard.WithRetainedBlock(beaconBlockRoot, func() {
+ payloadStatus = f.markPayloadStatusRetainedLocked(beaconBlockRoot, executionBlockHash, payloadStatus)
+ })
+ if !retained {
+ return fmt.Errorf("%w: block disappeared during payload validation for beacon_block_root %v", ErrIgnore, beaconBlockRoot)
+ }
+ } else {
+ payloadStatus = f.markPayloadStatusLocked(beaconBlockRoot, executionBlockHash, payloadStatus)
+ }
f.executionPayloadGasLimit.Add(executionBlockHash, envelope.Payload.GasLimit)
switch payloadStatus {
@@ -364,11 +373,9 @@ func (f *ForkChoiceStore) applyPayloadValidationResultLocked(
}
case execution_client.PayloadStatusInvalidated:
log.Warn("validatePayloadWithEL: payload is invalid", "beaconBlockRoot", beaconBlockRoot, "err", validationErr)
- f.markPayloadInvalidLocked(beaconBlockRoot, executionBlockHash)
return fmt.Errorf("%w: execution payload is invalid", errInvalidExecutionPayloadEnvelope)
case execution_client.PayloadStatusValidated:
log.Trace("validatePayloadWithEL: payload is validated", "beaconBlockRoot", beaconBlockRoot)
- f.markPayloadVerifiedLocked(beaconBlockRoot, executionBlockHash)
}
if validationErr != nil {
@@ -588,10 +595,34 @@ func (f *ForkChoiceStore) OnExecutionPayload(ctx context.Context, signedEnvelope
f.pendingEnvelopes.Add(common.Hash(beaconBlockRoot), indexEnvelope)
return fmt.Errorf("OnExecutionPayload: failed to write execution payload indices: %w", err)
}
+ if !applied {
+ return fmt.Errorf("%w: execution payload envelope already processed", ErrIgnore)
+ }
return nil
}
+func (f *ForkChoiceStore) ValidateExecutionPayloadEnvelope(signedEnvelope *cltypes.SignedExecutionPayloadEnvelope) error {
+ if signedEnvelope == nil || signedEnvelope.Message == nil {
+ return errors.New("nil execution payload envelope")
+ }
+ blockRoot := common.Hash(signedEnvelope.Message.BeaconBlockRoot)
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ blockState, err := f.forkGraph.GetState(blockRoot, false)
+ if err != nil {
+ return fmt.Errorf("failed to get block state: %w", err)
+ }
+ if blockState == nil {
+ return fmt.Errorf("block state not found for beacon_block_root %v", blockRoot)
+ }
+ block, ok := f.forkGraph.GetBlock(blockRoot)
+ if !ok || block == nil {
+ return fmt.Errorf("block not found for beacon_block_root %v", blockRoot)
+ }
+ return f.validateEnvelopeAgainstBlock(signedEnvelope, block, blockState)
+}
+
// ApplyLocalSelfBuildEnvelope processes a locally-produced self-build envelope
// that carries InfiniteSignature. The CL node constructs these when the VC does
// not provide a pre-signed envelope; the private key lives in the VC and is not
diff --git a/cl/phase1/forkchoice/on_execution_payload_test.go b/cl/phase1/forkchoice/on_execution_payload_test.go
index 6ace68fd93c..57f783fdf12 100644
--- a/cl/phase1/forkchoice/on_execution_payload_test.go
+++ b/cl/phase1/forkchoice/on_execution_payload_test.go
@@ -791,7 +791,7 @@ func TestOnExecutionPayloadRedeliveryRepairsMissingIndices(t *testing.T) {
graph := &countingEnvelopeReadForkGraph{pendingRetryForkGraph: pendingRetryForkGraph{completed: blockRoot, completedEnvelope: persisted}}
f := &ForkChoiceStore{forkGraph: graph, db: db}
- require.NoError(t, f.OnExecutionPayload(context.Background(), redelivered, false, true))
+ require.ErrorIs(t, f.OnExecutionPayload(context.Background(), redelivered, false, true), ErrIgnore)
require.Equal(t, int32(1), graph.reads.Load())
require.Equal(t, 1, db.calls)
require.NoError(t, rwdb.View(context.Background(), func(tx kv.Tx) error {
@@ -826,7 +826,7 @@ func TestOnExecutionPayloadRedeliveryRepairsZeroHashIndices(t *testing.T) {
graph := &countingEnvelopeReadForkGraph{pendingRetryForkGraph: pendingRetryForkGraph{completed: blockRoot, completedEnvelope: persisted}}
f := &ForkChoiceStore{forkGraph: graph, db: db}
- require.NoError(t, f.OnExecutionPayload(context.Background(), redelivered, false, true))
+ require.ErrorIs(t, f.OnExecutionPayload(context.Background(), redelivered, false, true), ErrIgnore)
require.Equal(t, int32(1), graph.reads.Load())
require.Equal(t, 1, db.calls)
require.NoError(t, rwdb.View(context.Background(), func(tx kv.Tx) error {
@@ -908,7 +908,7 @@ func TestOnExecutionPayloadRedeliverySkipsExistingIndices(t *testing.T) {
db: db,
}
- require.NoError(t, f.OnExecutionPayload(context.Background(), redelivered, false, true))
+ require.ErrorIs(t, f.OnExecutionPayload(context.Background(), redelivered, false, true), ErrIgnore)
require.Zero(t, graph.reads.Load())
require.Zero(t, db.calls)
}
@@ -1313,7 +1313,7 @@ func TestValidatePayloadWithELDoesNotRelockForkChoiceMu(t *testing.T) {
{
name: "validated",
status: execution_client.PayloadStatusValidated,
- wantVerify: true,
+ wantVerify: false,
},
{
name: "invalidated",
diff --git a/cl/phase1/forkchoice/on_payload_attestation_message.go b/cl/phase1/forkchoice/on_payload_attestation_message.go
index ca29bc1e904..32930ec5775 100644
--- a/cl/phase1/forkchoice/on_payload_attestation_message.go
+++ b/cl/phase1/forkchoice/on_payload_attestation_message.go
@@ -44,14 +44,6 @@ func (f *ForkChoiceStore) OnPayloadAttestationMessage(
data := msg.Data
blockRoot := data.BeaconBlockRoot
- if !isFromBlock {
- // Wall-clock time is authoritative for gossip because store time can lag OnTick.
- currentSlot := f.ethClock.GetCurrentSlot()
- if data.Slot != currentSlot {
- return fmt.Errorf("%w: attestation slot %d is not current slot %d", ErrIgnore, data.Slot, currentSlot)
- }
- }
-
validationContext, err := f.payloadAttestationValidationContext(ctx, blockRoot, data.Slot)
if err != nil {
return err
diff --git a/cl/phase1/forkchoice/payload_attestation_validation.go b/cl/phase1/forkchoice/payload_attestation_validation.go
index 06c04ae8d0f..ff38dff74ce 100644
--- a/cl/phase1/forkchoice/payload_attestation_validation.go
+++ b/cl/phase1/forkchoice/payload_attestation_validation.go
@@ -176,7 +176,7 @@ func (f *ForkChoiceStore) payloadAttestationValidationContext(
return f.payloadAttestationContexts.get(ctx, blockRoot, func() (*payloadAttestationValidationContext, error) {
blockState, err := f.GetStateAtBlockRoot(blockRoot, true)
if err != nil {
- return nil, err
+ return nil, fmt.Errorf("%w: block state unavailable for root %v: %w", ErrIgnore, blockRoot, err)
}
if blockState == nil {
return nil, fmt.Errorf("%w: block state not found for root %v", ErrIgnore, blockRoot)
diff --git a/cl/phase1/forkchoice/payload_attestation_validation_test.go b/cl/phase1/forkchoice/payload_attestation_validation_test.go
index e87a37b6eaf..97dcd861b25 100644
--- a/cl/phase1/forkchoice/payload_attestation_validation_test.go
+++ b/cl/phase1/forkchoice/payload_attestation_validation_test.go
@@ -29,6 +29,7 @@ import (
"github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/cltypes"
"github.com/erigontech/erigon/cl/fork"
+ "github.com/erigontech/erigon/cl/phase1/core/state"
"github.com/erigontech/erigon/cl/utils/bls"
"github.com/erigontech/erigon/common"
)
@@ -68,6 +69,42 @@ func TestOnPayloadAttestationMessageRejectsNil(t *testing.T) {
require.Error(t, f.OnPayloadAttestationMessage(context.Background(), &cltypes.PayloadAttestationMessage{}, false))
}
+func TestOnPayloadAttestationMessageIgnoresUnavailableKnownBlockState(t *testing.T) {
+ root := common.HexToHash("0x1234")
+ contexts, err := newPayloadAttestationValidationContexts()
+ require.NoError(t, err)
+ graph := &getFinalizedExecutionHashForkGraph{
+ headers: map[common.Hash]*cltypes.BeaconBlockHeader{root: {Slot: 100}},
+ states: map[common.Hash]*state.CachingBeaconState{},
+ }
+ f := &ForkChoiceStore{forkGraph: graph, payloadAttestationContexts: contexts}
+ msg := &cltypes.PayloadAttestationMessage{Data: &cltypes.PayloadAttestationData{Slot: 100, BeaconBlockRoot: root}}
+
+ err = f.OnPayloadAttestationMessage(context.Background(), msg, true)
+
+ require.ErrorIs(t, err, ErrIgnore)
+}
+
+func TestOnPayloadAttestationMessageWireReliesOnIngressClockValidation(t *testing.T) {
+ root := common.HexToHash("0x1234")
+ contexts, err := newPayloadAttestationValidationContexts()
+ require.NoError(t, err)
+ f := &ForkChoiceStore{
+ forkGraph: &getFinalizedExecutionHashForkGraph{
+ headers: map[common.Hash]*cltypes.BeaconBlockHeader{root: {Slot: 100}},
+ states: map[common.Hash]*state.CachingBeaconState{},
+ },
+ payloadAttestationContexts: contexts,
+ }
+ msg := &cltypes.PayloadAttestationMessage{
+ Data: &cltypes.PayloadAttestationData{Slot: 100, BeaconBlockRoot: root},
+ }
+
+ err = f.OnPayloadAttestationMessage(context.Background(), msg, false)
+
+ require.ErrorIs(t, err, ErrIgnore)
+}
+
func TestApplyValidatedPayloadAttestationAcceptsOnlyFirstGossipVote(t *testing.T) {
f := &ForkChoiceStore{}
root := common.HexToHash("0x1234")
diff --git a/cl/phase1/forkchoice/payload_vote.go b/cl/phase1/forkchoice/payload_vote.go
index 893549c2c5e..c5eb708b797 100644
--- a/cl/phase1/forkchoice/payload_vote.go
+++ b/cl/phase1/forkchoice/payload_vote.go
@@ -55,7 +55,8 @@ func (f *ForkChoiceStore) notifyPtcMessages(
// Pre-compute PTC per unique blockRoot to avoid redundant state lookups
// for every attesting validator (PtcSize can be 512).
type cachedPTC struct {
- ptc []uint64
+ ptc []uint64
+ positions map[uint64][]int
}
ptcCache := make(map[common.Hash]*cachedPTC)
@@ -80,7 +81,19 @@ func (f *ForkChoiceStore) notifyPtcMessages(
if err != nil {
continue
}
- cached = &cachedPTC{ptc: ptc}
+ ptcSize := f.beaconCfg.PtcSize
+ if ptcSize == 0 {
+ ptcSize = clparams.MaxPtcSize
+ }
+ ptcSize = min(ptcSize, clparams.MaxPtcSize)
+ if uint64(len(ptc)) > ptcSize {
+ ptc = ptc[:int(ptcSize)]
+ }
+ positions := make(map[uint64][]int, len(ptc))
+ for position, validatorIndex := range ptc {
+ positions[validatorIndex] = append(positions[validatorIndex], position)
+ }
+ cached = &cachedPTC{ptc: ptc, positions: positions}
ptcCache[blockRoot] = cached
}
@@ -88,18 +101,25 @@ func (f *ForkChoiceStore) notifyPtcMessages(
continue
}
- for j := range cached.ptc {
- if payloadAttestation.AggregationBits.GetBitAt(j) {
- f.applyPayloadAttestationVote(j, data, blockRoot)
+ selectedValidators := make(map[uint64]struct{}, len(cached.ptc))
+ for position, validatorIndex := range cached.ptc {
+ if payloadAttestation.AggregationBits.GetBitAt(position) {
+ selectedValidators[validatorIndex] = struct{}{}
}
}
+ if len(selectedValidators) == 0 {
+ continue
+ }
+ ptcIndices := make([]int, 0, len(cached.ptc))
+ for validatorIndex := range selectedValidators {
+ ptcIndices = append(ptcIndices, cached.positions[validatorIndex]...)
+ }
+ f.applyPayloadAttestationVotes(ptcIndices, data, blockRoot)
}
}
-// applyPayloadAttestationVote updates PTC vote tracking for a single PTC position.
-// ptcIndex is the position in the PTC (the aggregation bit index).
-func (f *ForkChoiceStore) applyPayloadAttestationVote(
- ptcIndex int,
+func (f *ForkChoiceStore) applyPayloadAttestationVotes(
+ ptcIndices []int,
data *cltypes.PayloadAttestationData,
blockRoot common.Hash,
) {
@@ -115,8 +135,10 @@ func (f *ForkChoiceStore) applyPayloadAttestationVote(
if existing, ok := f.payloadDataAvailabilityVote.Load(blockRoot); ok {
dataAvailabilityVotes = existing.([clparams.PtcSize]int8)
}
- timelinessVotes[ptcIndex] = boolToVote(data.PayloadPresent)
- dataAvailabilityVotes[ptcIndex] = boolToVote(data.BlobDataAvailable)
+ for _, ptcIndex := range ptcIndices {
+ timelinessVotes[ptcIndex] = boolToVote(data.PayloadPresent)
+ dataAvailabilityVotes[ptcIndex] = boolToVote(data.BlobDataAvailable)
+ }
f.payloadTimelinessVote.Store(blockRoot, timelinessVotes)
f.payloadDataAvailabilityVote.Store(blockRoot, dataAvailabilityVotes)
@@ -132,10 +154,8 @@ func (f *ForkChoiceStore) payloadTimeliness(root common.Hash, timely bool) bool
return false
}
- // If the payload has not been accepted by the execution layer, the payload
- // is not considered available regardless of the PTC vote.
- if !f.IsPayloadVerified(root) {
- return false
+ if !f.isPayloadAvailable(root) {
+ return !timely
}
votes := voteRaw.([clparams.PtcSize]int8)
target := boolToVote(timely)
@@ -157,10 +177,8 @@ func (f *ForkChoiceStore) payloadDataAvailability(root common.Hash, available bo
return false
}
- // If the payload has not been accepted by the execution layer, the blob data
- // is not considered available regardless of the PTC vote.
- if !f.IsPayloadVerified(root) {
- return false
+ if !f.isPayloadAvailable(root) {
+ return !available
}
votes := voteRaw.([clparams.PtcSize]int8)
target := boolToVote(available)
@@ -289,7 +307,7 @@ func (f *ForkChoiceStore) isPreviousSlotPayloadDecision(node ForkChoiceNode) boo
// Used by prepare_execution_payload to decide FULL vs EMPTY path.
// [New in Gloas:EIP7732]
func (f *ForkChoiceStore) ShouldExtendPayload(root common.Hash) bool {
- if !f.IsPayloadVerified(root) {
+ if !f.isPayloadAvailable(root) {
return false
}
@@ -325,16 +343,20 @@ func (f *ForkChoiceStore) ShouldExtendPayload(root common.Hash) bool {
// for the given head node. Returns false for EMPTY heads. For FULL heads, returns
// true unless the PTC voted the payload as late or blob data as unavailable.
// [New in Gloas:EIP7732]
-func (f *ForkChoiceStore) ShouldBuildOnFull(head ForkChoiceNode) bool {
+func (f *ForkChoiceStore) ShouldBuildOnFull(head ForkChoiceNode, slot uint64) bool {
+ header, has := f.forkGraph.GetHeader(head.Root)
+ if !has || header == nil {
+ return false
+ }
+ if header.Slot+1 != slot {
+ return head.PayloadStatus == cltypes.PayloadStatusFull
+ }
if head.PayloadStatus == cltypes.PayloadStatusEmpty {
return false
}
if head.PayloadStatus == cltypes.PayloadStatusPending {
return false
}
- if !f.isPreviousSlotPayloadDecision(head) {
- return true
- }
if f.payloadDataAvailability(head.Root, false) {
return false
}
@@ -371,7 +393,7 @@ func (f *ForkChoiceStore) getNodeChildren(node ForkChoiceNode, blocks map[common
children := []ForkChoiceNode{
{Root: node.Root, PayloadStatus: cltypes.PayloadStatusEmpty},
}
- if f.IsPayloadVerified(node.Root) {
+ if f.isPayloadAvailable(node.Root) {
children = append(children, ForkChoiceNode{
Root: node.Root, PayloadStatus: cltypes.PayloadStatusFull,
})
@@ -400,6 +422,23 @@ func (f *ForkChoiceStore) getNodeChildren(node ForkChoiceNode, blocks map[common
return result
}
+func (f *ForkChoiceStore) isPayloadAvailable(root common.Hash) bool {
+ if !f.HasEnvelope(root) {
+ return false
+ }
+ if f.forkGraph.IsBlockInvalid(root) {
+ return false
+ }
+ if f.forkGraph.IsPayloadUnavailable(root) {
+ return false
+ }
+ status, ok := f.GetRecentExecutionPayloadStatusByRoot(root)
+ if !ok {
+ return false
+ }
+ return status == execution_client.PayloadStatusValidated
+}
+
// validateParentPayloadPath validates that the block builds on the correct parent payload path.
// If parent is FULL, the parent must have an execution payload state.
// If parent is EMPTY, the block's parent_block_hash must match the parent's parent_block_hash.
@@ -420,12 +459,12 @@ func (f *ForkChoiceStore) validateParentPayloadPath(block *cltypes.BeaconBlock)
}
if f.isParentNodeFull(block) {
- // Parent is FULL - verify execution payload envelope exists on disk.
+ // Parent is FULL - verify the execution payload is locally available.
// Return ErrParentEnvelopePending (not a hard error) when the envelope is
// missing. During forward sync the envelope may not yet be persisted (it
// arrives in the same batch or in a later batch), so a hard error would
// permanently reject the block and ban the peer.
- if !f.forkGraph.HasEnvelope(block.ParentRoot) {
+ if !f.isPayloadAvailable(block.ParentRoot) {
return ErrParentEnvelopePending
}
} else {
diff --git a/cl/phase1/forkchoice/payload_vote_test.go b/cl/phase1/forkchoice/payload_vote_test.go
index 3c27f881c19..f84a075c130 100644
--- a/cl/phase1/forkchoice/payload_vote_test.go
+++ b/cl/phase1/forkchoice/payload_vote_test.go
@@ -1,6 +1,8 @@
package forkchoice
import (
+ "errors"
+ "fmt"
"testing"
lru "github.com/hashicorp/golang-lru/v2"
@@ -18,24 +20,64 @@ import (
type ptcVoteForkGraph struct {
fork_graph.ForkGraph
- envelopes map[common.Hash]bool
- blocks map[common.Hash]*cltypes.SignedBeaconBlock
+ envelopes map[common.Hash]bool
+ blocks map[common.Hash]*cltypes.SignedBeaconBlock
+ acceptedPayloads map[common.Hash]bool
}
func (g ptcVoteForkGraph) HasEnvelope(root common.Hash) bool {
return g.envelopes[root]
}
+func (g ptcVoteForkGraph) IsBlockInvalid(common.Hash) bool {
+ return false
+}
+
+func (g ptcVoteForkGraph) MarkPayloadUnavailable(common.Hash) {}
+func (g ptcVoteForkGraph) MarkPayloadAvailable(common.Hash) {}
+func (g ptcVoteForkGraph) IsPayloadUnavailable(common.Hash) bool {
+ return false
+}
+func (g ptcVoteForkGraph) MarkPayloadAccepted(common.Hash, bool) {}
+func (g ptcVoteForkGraph) ClearPayloadAccepted(common.Hash) {}
+func (g ptcVoteForkGraph) PayloadAccepted(root common.Hash) (bool, bool) {
+ verified, ok := g.acceptedPayloads[root]
+ return verified, ok
+}
+
func (g ptcVoteForkGraph) GetBlock(root common.Hash) (*cltypes.SignedBeaconBlock, bool) {
block, ok := g.blocks[root]
return block, ok
}
+func (g ptcVoteForkGraph) GetHeader(root common.Hash) (*cltypes.BeaconBlockHeader, bool) {
+ block, ok := g.blocks[root]
+ if !ok || block == nil || block.Block == nil {
+ return nil, false
+ }
+ return &cltypes.BeaconBlockHeader{Slot: block.Block.Slot, ParentRoot: block.Block.ParentRoot}, true
+}
+
type payloadVoteForkGraph struct {
fork_graph.ForkGraph
- hasEnvelope bool
- dumpedEnvelope *common.Hash
- invalidatedHeader *common.Hash
+ hasEnvelope bool
+ dumpedEnvelope *common.Hash
+ invalidatedHeader *common.Hash
+ unavailablePayload *common.Hash
+ acceptedPayloads map[common.Hash]bool
+ retained *bool
+}
+
+func (g payloadVoteForkGraph) IsBlockRetained(common.Hash) bool {
+ return g.retained == nil || *g.retained
+}
+
+func (g payloadVoteForkGraph) WithRetainedBlock(_ common.Hash, fn func()) bool {
+ if !g.IsBlockRetained(common.Hash{}) {
+ return false
+ }
+ fn()
+ return true
}
func (g payloadVoteForkGraph) HasEnvelope(common.Hash) bool {
@@ -59,6 +101,41 @@ func (g payloadVoteForkGraph) MarkHeaderAsInvalid(blockRoot common.Hash) {
}
}
+func (g payloadVoteForkGraph) IsBlockInvalid(blockRoot common.Hash) bool {
+ return g.invalidatedHeader != nil && *g.invalidatedHeader == blockRoot
+}
+
+func (g payloadVoteForkGraph) MarkPayloadUnavailable(blockRoot common.Hash) {
+ if g.unavailablePayload != nil {
+ *g.unavailablePayload = blockRoot
+ }
+}
+
+func (g payloadVoteForkGraph) MarkPayloadAvailable(blockRoot common.Hash) {
+ if g.unavailablePayload != nil && *g.unavailablePayload == blockRoot {
+ *g.unavailablePayload = common.Hash{}
+ }
+}
+
+func (g payloadVoteForkGraph) IsPayloadUnavailable(blockRoot common.Hash) bool {
+ return g.unavailablePayload != nil && *g.unavailablePayload == blockRoot
+}
+
+func (g payloadVoteForkGraph) MarkPayloadAccepted(blockRoot common.Hash, verified bool) {
+ if g.acceptedPayloads != nil {
+ g.acceptedPayloads[blockRoot] = verified
+ }
+}
+
+func (g payloadVoteForkGraph) ClearPayloadAccepted(blockRoot common.Hash) {
+ delete(g.acceptedPayloads, blockRoot)
+}
+
+func (g payloadVoteForkGraph) PayloadAccepted(blockRoot common.Hash) (bool, bool) {
+ verified, ok := g.acceptedPayloads[blockRoot]
+ return verified, ok
+}
+
func TestGetPTCFromWindow(t *testing.T) {
cfg := &clparams.MainnetBeaconConfig
s := state2.New(cfg)
@@ -228,10 +305,10 @@ func TestPtcShouldBuildOnFullNoVotesCast(t *testing.T) {
f := newPtcVoteTestStore(root)
head := ForkChoiceNode{Root: root, PayloadStatus: cltypes.PayloadStatusFull}
- require.True(t, f.ShouldBuildOnFull(head))
+ require.True(t, f.ShouldBuildOnFull(head, f.Slot()))
f.payloadDataAvailabilityVote.Store(root, ptcVotes(0, 0))
- require.True(t, f.ShouldBuildOnFull(head))
+ require.True(t, f.ShouldBuildOnFull(head, f.Slot()))
}
func TestPtcShouldBuildOnFullWithUnavailableMajority(t *testing.T) {
@@ -242,11 +319,11 @@ func TestPtcShouldBuildOnFullWithUnavailableMajority(t *testing.T) {
require.False(t, f.ShouldBuildOnFull(ForkChoiceNode{
Root: root,
PayloadStatus: cltypes.PayloadStatusFull,
- }))
+ }, f.Slot()))
require.False(t, f.ShouldBuildOnFull(ForkChoiceNode{
Root: root,
PayloadStatus: cltypes.PayloadStatusEmpty,
- }))
+ }, f.Slot()))
}
func TestPtcShouldBuildOnFullWithLatePayloadMajority(t *testing.T) {
@@ -257,7 +334,7 @@ func TestPtcShouldBuildOnFullWithLatePayloadMajority(t *testing.T) {
require.False(t, f.ShouldBuildOnFull(ForkChoiceNode{
Root: root,
PayloadStatus: cltypes.PayloadStatusFull,
- }))
+ }, f.Slot()))
}
func TestPtcShouldBuildOnFullIgnoresVotesBeforePreviousSlot(t *testing.T) {
@@ -270,7 +347,22 @@ func TestPtcShouldBuildOnFullIgnoresVotesBeforePreviousSlot(t *testing.T) {
require.True(t, f.ShouldBuildOnFull(ForkChoiceNode{
Root: root,
PayloadStatus: cltypes.PayloadStatusFull,
- }))
+ }, f.Slot()))
+}
+
+func TestShouldBuildOnFullUsesExplicitTargetSlot(t *testing.T) {
+ root := common.HexToHash("0x08")
+ f := newPtcVoteTestStore(root)
+ f.forkGraph.(ptcVoteForkGraph).blocks[root].Block.Slot = 10
+ f.payloadDataAvailabilityVote.Store(root, ptcVotes(0, ptcVoteThreshold()+1))
+ f.payloadTimelinessVote.Store(root, ptcVotes(0, ptcVoteThreshold()+1))
+
+ require.True(t, f.ShouldBuildOnFull(ForkChoiceNode{Root: root, PayloadStatus: cltypes.PayloadStatusFull}, 12))
+ require.False(t, f.ShouldBuildOnFull(ForkChoiceNode{Root: root, PayloadStatus: cltypes.PayloadStatusEmpty}, 12))
+ require.False(t, f.ShouldBuildOnFull(ForkChoiceNode{Root: root, PayloadStatus: cltypes.PayloadStatusPending}, 12))
+ require.False(t, f.ShouldBuildOnFull(ForkChoiceNode{Root: root, PayloadStatus: cltypes.PayloadStatusFull}, 11))
+ require.False(t, f.ShouldBuildOnFull(ForkChoiceNode{Root: root, PayloadStatus: cltypes.PayloadStatusEmpty}, 11))
+ require.False(t, f.ShouldBuildOnFull(ForkChoiceNode{Root: root, PayloadStatus: cltypes.PayloadStatusPending}, 11))
}
func TestPtcIsPreviousSlotPayloadDecision(t *testing.T) {
@@ -297,21 +389,28 @@ func TestPtcIsPreviousSlotPayloadDecision(t *testing.T) {
}))
}
-func TestGloasForkChoiceRequiresVerifiedPayload(t *testing.T) {
+func TestGloasForkChoiceUsesPersistedPayload(t *testing.T) {
root := common.HexToHash("0x1234")
tests := []struct {
name string
hasEnvelope bool
verified bool
+ optimistic bool
wantFullChild bool
}{
{
- name: "envelope present but not verified means EMPTY only",
+ name: "envelope present without EL status remains EMPTY only",
hasEnvelope: true,
verified: false,
wantFullChild: false,
},
+ {
+ name: "optimistic EL status remains EMPTY only",
+ hasEnvelope: true,
+ optimistic: true,
+ wantFullChild: false,
+ },
{
name: "envelope present and verified produces FULL child",
hasEnvelope: true,
@@ -329,6 +428,9 @@ func TestGloasForkChoiceRequiresVerifiedPayload(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newPayloadVoteTestStore(t, root, tt.hasEnvelope, tt.verified)
+ if tt.optimistic {
+ f.payloadStatusByRoot.Add(root, execution_client.PayloadStatusNotValidated)
+ }
children := f.getNodeChildren(ForkChoiceNode{
Root: root,
@@ -352,11 +454,26 @@ func TestIsPayloadVerifiedStrictSemantics(t *testing.T) {
require.False(t, f.IsPayloadVerified(root))
})
+ t.Run("missing envelope is locally unavailable in both vote directions", func(t *testing.T) {
+ f := newPayloadVoteTestStore(t, root, false, false)
+ require.False(t, f.payloadTimeliness(root, true))
+ require.True(t, f.payloadTimeliness(root, false))
+ require.False(t, f.payloadDataAvailability(root, true))
+ require.True(t, f.payloadDataAvailability(root, false))
+ })
+
t.Run("EL-verified and envelope present", func(t *testing.T) {
f := newPayloadVoteTestStore(t, root, true, true)
require.True(t, f.IsPayloadVerified(root))
})
+ t.Run("EL-verified before envelope publication", func(t *testing.T) {
+ f := newPayloadVoteTestStore(t, root, false, false)
+ f.MarkPayloadVerified(root, common.HexToHash("0xabcd"))
+ require.False(t, f.HasEnvelope(root))
+ require.False(t, f.IsPayloadVerified(root))
+ })
+
t.Run("mark verified", func(t *testing.T) {
f := newPayloadVoteTestStore(t, root, true, false)
execHash := common.HexToHash("0xabcd")
@@ -387,6 +504,8 @@ func TestMarkPayloadInvalidRecordsELRejection(t *testing.T) {
f.MarkPayloadInvalid(root, execHash)
require.False(t, f.IsPayloadVerified(root))
+ children := f.getNodeChildren(ForkChoiceNode{Root: root, PayloadStatus: cltypes.PayloadStatusPending}, nil)
+ require.False(t, hasPayloadStatus(children, cltypes.PayloadStatusFull))
status, ok := f.GetRecentExecutionPayloadStatus(execHash)
require.True(t, ok)
require.Equal(t, execution_client.PayloadStatus(execution_client.PayloadStatusInvalidated), status)
@@ -396,6 +515,247 @@ func TestMarkPayloadInvalidRecordsELRejection(t *testing.T) {
require.Equal(t, root, invalidatedHeader)
}
+func TestPayloadValidationResultAfterPruneDoesNotRestoreStatus(t *testing.T) {
+ root := common.HexToHash("0x5678")
+ retained := false
+ accepted := map[common.Hash]bool{}
+ f := newPayloadVoteTestStore(t, root, true, false)
+ f.forkGraph = payloadVoteForkGraph{hasEnvelope: true, retained: &retained, acceptedPayloads: accepted}
+ envelope := &cltypes.ExecutionPayloadEnvelope{Payload: &cltypes.Eth1Block{BlockHash: common.HexToHash("0xabcd")}}
+ block := &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{}}
+
+ err := f.applyPayloadValidationResultLocked(execution_client.PayloadStatusValidated, nil, envelope, block, root)
+ require.ErrorIs(t, err, ErrIgnore)
+ require.Empty(t, accepted)
+ _, ok := f.GetRecentExecutionPayloadStatusByRoot(root)
+ require.False(t, ok)
+}
+
+func TestInvalidPayloadRemainsUnavailableAfterRootStatusEviction(t *testing.T) {
+ root := common.HexToHash("0x5678")
+ invalidatedHeader := common.Hash{}
+ f := newPayloadVoteTestStore(t, root, true, false)
+ f.forkGraph = payloadVoteForkGraph{hasEnvelope: true, invalidatedHeader: &invalidatedHeader}
+ statusByRoot, err := lru.New[common.Hash, execution_client.PayloadStatus](1)
+ require.NoError(t, err)
+ f.payloadStatusByRoot = statusByRoot
+
+ f.MarkPayloadInvalid(root, common.HexToHash("0xabcd"))
+ f.payloadStatusByRoot.Add(common.HexToHash("0x9999"), execution_client.PayloadStatusValidated)
+ _, cached := f.payloadStatusByRoot.Get(root)
+ require.False(t, cached)
+ status, found := f.GetRecentExecutionPayloadStatusByRoot(root)
+ require.True(t, found)
+ require.Equal(t, execution_client.PayloadStatus(execution_client.PayloadStatusInvalidated), status)
+ require.Equal(t, root, invalidatedHeader)
+
+ require.False(t, f.isPayloadAvailable(root))
+ require.False(t, f.IsPayloadVerified(root))
+}
+
+func TestPayloadAvailabilityByEngineStatus(t *testing.T) {
+ root := common.HexToHash("0x5678")
+ for _, test := range []struct {
+ name string
+ status execution_client.PayloadStatus
+ want bool
+ }{
+ {name: "engine error", status: execution_client.PayloadStatusNone},
+ {name: "optimistic", status: execution_client.PayloadStatusNotValidated},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ f := newPayloadVoteTestStore(t, root, true, false)
+ f.payloadStatusByRoot.Add(root, test.status)
+
+ require.Equal(t, test.want, f.isPayloadAvailable(root))
+ require.False(t, f.IsPayloadVerified(root))
+ })
+ }
+}
+
+func TestValidateParentPayloadPathUsesValidationAvailability(t *testing.T) {
+ cfg := &clparams.MainnetBeaconConfig
+ parentRoot := common.HexToHash("0x5678")
+ executionHash := common.HexToHash("0xabcd")
+ parent := cltypes.NewSignedBeaconBlock(cfg, clparams.GloasVersion)
+ parent.Block.Body.SignedExecutionPayloadBid = &cltypes.SignedExecutionPayloadBid{Message: &cltypes.ExecutionPayloadBid{BlockHash: executionHash}}
+ child := cltypes.NewBeaconBlock(cfg, clparams.GloasVersion)
+ child.ParentRoot = parentRoot
+ child.Body.SignedExecutionPayloadBid = &cltypes.SignedExecutionPayloadBid{Message: &cltypes.ExecutionPayloadBid{ParentBlockHash: executionHash}}
+
+ for _, test := range []struct {
+ name string
+ status execution_client.PayloadStatus
+ withStatus bool
+ wantErr bool
+ }{
+ {name: "engine error", status: execution_client.PayloadStatusNone, withStatus: true, wantErr: true},
+ {name: "optimistic", status: execution_client.PayloadStatusNotValidated, withStatus: true, wantErr: true},
+ {name: "status absent", wantErr: true},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ f := newPayloadVoteTestStore(t, parentRoot, true, false)
+ f.forkGraph = ptcVoteForkGraph{
+ envelopes: map[common.Hash]bool{parentRoot: true},
+ blocks: map[common.Hash]*cltypes.SignedBeaconBlock{parentRoot: parent},
+ }
+ if test.withStatus {
+ f.payloadStatusByRoot.Add(parentRoot, test.status)
+ }
+
+ err := f.validateParentPayloadPath(child)
+ if test.wantErr {
+ require.ErrorIs(t, err, ErrParentEnvelopePending)
+ } else {
+ require.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestApplyPayloadValidationResultRecordsRootAvailability(t *testing.T) {
+ root := common.HexToHash("0x5678")
+ for _, test := range []struct {
+ name string
+ status execution_client.PayloadStatus
+ wantErr error
+ available bool
+ }{
+ {name: "engine error", status: execution_client.PayloadStatusNone, wantErr: errELBehind},
+ {name: "optimistic", status: execution_client.PayloadStatusNotValidated},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ f := newPayloadVoteTestStore(t, root, true, false)
+ unavailableRoot := common.Hash{}
+ invalidRoot := common.Hash{}
+ f.forkGraph = payloadVoteForkGraph{
+ hasEnvelope: true,
+ invalidatedHeader: &invalidRoot,
+ unavailablePayload: &unavailableRoot,
+ acceptedPayloads: make(map[common.Hash]bool),
+ }
+ gasLimits, err := lru.New[common.Hash, uint64](16)
+ require.NoError(t, err)
+ f.executionPayloadGasLimit = gasLimits
+ statusByRoot, err := lru.New[common.Hash, execution_client.PayloadStatus](1)
+ require.NoError(t, err)
+ f.payloadStatusByRoot = statusByRoot
+ f.headHash = root
+ f.headPayloadStatus = cltypes.PayloadStatusFull
+ envelope := cltypes.NewExecutionPayloadEnvelope(&clparams.MainnetBeaconConfig)
+ envelope.Payload.BlockHash = common.HexToHash("0xabcd")
+ block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.GloasVersion)
+
+ var validationErr error
+ if test.status == execution_client.PayloadStatusNone {
+ validationErr = errors.New("engine unavailable")
+ }
+ err = f.applyPayloadValidationResultLocked(test.status, validationErr, envelope, block, root)
+ if test.wantErr != nil {
+ require.ErrorIs(t, err, test.wantErr)
+ } else {
+ require.NoError(t, err)
+ }
+ status, found := f.GetRecentExecutionPayloadStatusByRoot(root)
+ require.True(t, found)
+ require.Equal(t, test.status, status)
+ f.payloadStatusByRoot.Add(common.HexToHash("0x9999"), execution_client.PayloadStatusValidated)
+ require.Equal(t, test.available, f.isPayloadAvailable(root))
+ require.Equal(t, common.Hash{}, f.headHash)
+ require.Equal(t, cltypes.PayloadStatusPending, f.headPayloadStatus)
+ })
+ }
+}
+
+func TestPayloadStatusTransitionsUpdateDurableAvailability(t *testing.T) {
+ root := common.HexToHash("0x5678")
+ execHash := common.HexToHash("0xabcd")
+ for _, test := range []struct {
+ name string
+ initial execution_client.PayloadStatus
+ next execution_client.PayloadStatus
+ available bool
+ verified bool
+ changed bool
+ effective execution_client.PayloadStatus
+ }{
+ {name: "none to optimistic", initial: execution_client.PayloadStatusNone, next: execution_client.PayloadStatusNotValidated, changed: true, effective: execution_client.PayloadStatusNotValidated},
+ {name: "none to validated", initial: execution_client.PayloadStatusNone, next: execution_client.PayloadStatusValidated, available: true, verified: true, changed: true, effective: execution_client.PayloadStatusValidated},
+ {name: "none to invalidated", initial: execution_client.PayloadStatusNone, next: execution_client.PayloadStatusInvalidated, changed: true, effective: execution_client.PayloadStatusInvalidated},
+ {name: "optimistic to none", initial: execution_client.PayloadStatusNotValidated, next: execution_client.PayloadStatusNone, effective: execution_client.PayloadStatusNotValidated},
+ {name: "validated to none", initial: execution_client.PayloadStatusValidated, next: execution_client.PayloadStatusNone, available: true, verified: true, effective: execution_client.PayloadStatusValidated},
+ {name: "validated to optimistic", initial: execution_client.PayloadStatusValidated, next: execution_client.PayloadStatusNotValidated, available: true, verified: true, effective: execution_client.PayloadStatusValidated},
+ {name: "invalidated to none", initial: execution_client.PayloadStatusInvalidated, next: execution_client.PayloadStatusNone, effective: execution_client.PayloadStatusInvalidated},
+ {name: "invalidated to optimistic", initial: execution_client.PayloadStatusInvalidated, next: execution_client.PayloadStatusNotValidated, effective: execution_client.PayloadStatusInvalidated},
+ {name: "invalidated to validated", initial: execution_client.PayloadStatusInvalidated, next: execution_client.PayloadStatusValidated, effective: execution_client.PayloadStatusInvalidated},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ unavailableRoot := common.Hash{}
+ invalidRoot := common.Hash{}
+ f := newPayloadVoteTestStore(t, root, true, false)
+ f.forkGraph = payloadVoteForkGraph{
+ hasEnvelope: true,
+ invalidatedHeader: &invalidRoot,
+ unavailablePayload: &unavailableRoot,
+ acceptedPayloads: make(map[common.Hash]bool),
+ }
+ statusByRoot, err := lru.New[common.Hash, execution_client.PayloadStatus](1)
+ require.NoError(t, err)
+ f.payloadStatusByRoot = statusByRoot
+
+ f.MarkPayloadStatus(root, execHash, test.initial)
+ f.payloadStatusByRoot.Add(common.HexToHash("0x9999"), execution_client.PayloadStatusValidated)
+ f.headHash = root
+ f.headPayloadStatus = cltypes.PayloadStatusFull
+
+ effective := f.MarkPayloadStatus(root, execHash, test.next)
+ require.Equal(t, test.effective, effective)
+ require.Equal(t, test.available, f.isPayloadAvailable(root))
+ require.Equal(t, test.verified, f.IsPayloadVerified(root))
+ f.verifiedExecutionPayload.Add(common.HexToHash("0x9999"), struct{}{})
+ require.Equal(t, test.verified, f.IsPayloadVerified(root))
+ if test.changed {
+ require.Equal(t, common.Hash{}, f.headHash)
+ require.Equal(t, cltypes.PayloadStatusPending, f.headPayloadStatus)
+ } else {
+ require.Equal(t, root, f.headHash)
+ require.Equal(t, cltypes.PayloadStatusFull, f.headPayloadStatus)
+ }
+ })
+ }
+}
+
+func TestPayloadStatusGetterUsesDurableAuthorityAfterEviction(t *testing.T) {
+ root := common.HexToHash("0x5678")
+ for _, status := range []execution_client.PayloadStatus{
+ execution_client.PayloadStatusNone,
+ execution_client.PayloadStatusNotValidated,
+ execution_client.PayloadStatusValidated,
+ execution_client.PayloadStatusInvalidated,
+ } {
+ t.Run(fmt.Sprintf("status_%d", status), func(t *testing.T) {
+ unavailableRoot := common.Hash{}
+ invalidRoot := common.Hash{}
+ f := newPayloadVoteTestStore(t, root, true, false)
+ f.forkGraph = payloadVoteForkGraph{
+ hasEnvelope: true,
+ invalidatedHeader: &invalidRoot,
+ unavailablePayload: &unavailableRoot,
+ acceptedPayloads: make(map[common.Hash]bool),
+ }
+ statusByRoot, err := lru.New[common.Hash, execution_client.PayloadStatus](1)
+ require.NoError(t, err)
+ f.payloadStatusByRoot = statusByRoot
+
+ f.MarkPayloadStatus(root, common.HexToHash("0xabcd"), status)
+ f.payloadStatusByRoot.Add(common.HexToHash("0x9999"), execution_client.PayloadStatusValidated)
+ got, found := f.GetRecentExecutionPayloadStatusByRoot(root)
+ require.True(t, found)
+ require.Equal(t, status, got)
+ })
+ }
+}
+
func TestStoreAnchorEnvelopePersistsWithoutMarkingVerified(t *testing.T) {
root := common.HexToHash("0x5678")
execHash := common.HexToHash("0xabcd")
@@ -447,7 +807,11 @@ func newPtcVoteTestStore(root common.Hash) *ForkChoiceStore {
verifiedExecutionPayload.Add(root, struct{}{})
blocks := map[common.Hash]*cltypes.SignedBeaconBlock{root: block}
envelopes := map[common.Hash]bool{root: true}
- fg := ptcVoteForkGraph{envelopes: envelopes, blocks: blocks}
+ fg := ptcVoteForkGraph{
+ envelopes: envelopes,
+ blocks: blocks,
+ acceptedPayloads: map[common.Hash]bool{root: true},
+ }
f := &ForkChoiceStore{
genesisTime: 0,
beaconCfg: cfg,
@@ -474,9 +838,13 @@ func newPayloadVoteTestStore(t *testing.T, root common.Hash, hasEnvelope, verifi
eth2Roots, err := lru.New[common.Hash, common.Hash](16)
require.NoError(t, err)
+ acceptedPayloads := map[common.Hash]bool{}
+ if verified {
+ acceptedPayloads[root] = true
+ }
f := &ForkChoiceStore{
beaconCfg: &clparams.MainnetBeaconConfig,
- forkGraph: payloadVoteForkGraph{hasEnvelope: hasEnvelope},
+ forkGraph: payloadVoteForkGraph{hasEnvelope: hasEnvelope, acceptedPayloads: acceptedPayloads},
eth2Roots: eth2Roots,
verifiedExecutionPayload: verifiedExecutionPayload,
executionPayloadStatus: executionPayloadStatus,
diff --git a/cl/phase1/forkchoice/pending_el_payload_test.go b/cl/phase1/forkchoice/pending_el_payload_test.go
index ec55fe6d27c..0b51894015d 100644
--- a/cl/phase1/forkchoice/pending_el_payload_test.go
+++ b/cl/phase1/forkchoice/pending_el_payload_test.go
@@ -4,10 +4,25 @@ import (
"testing"
"github.com/erigontech/erigon/cl/cltypes"
+ "github.com/erigontech/erigon/cl/phase1/execution_client"
"github.com/erigontech/erigon/common"
"github.com/stretchr/testify/require"
)
+func TestStalePayloadRetryAfterPruneIsDropped(t *testing.T) {
+ root := common.HexToHash("0x1234")
+ retained := false
+ accepted := map[common.Hash]bool{}
+ f := &ForkChoiceStore{forkGraph: payloadVoteForkGraph{hasEnvelope: true, retained: &retained, acceptedPayloads: accepted}}
+ envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: &cltypes.ExecutionPayloadEnvelope{BeaconBlockRoot: root}}
+
+ _, applied := f.MarkPayloadStatusIfRetained(root, common.HexToHash("0xabcd"), execution_client.PayloadStatusNotValidated)
+ require.False(t, applied)
+ require.Empty(t, accepted)
+ f.RequeuePendingELPayload(PendingELPayload{Block: &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 1}}, Envelope: envelope})
+ require.Empty(t, f.DrainPendingELPayloads())
+}
+
func TestPendingELPayloadsDropOldestAtCap(t *testing.T) {
f := &ForkChoiceStore{}
diff --git a/cl/phase1/forkchoice/timing.go b/cl/phase1/forkchoice/timing.go
index 03ae1466485..8daadbcd472 100644
--- a/cl/phase1/forkchoice/timing.go
+++ b/cl/phase1/forkchoice/timing.go
@@ -125,13 +125,17 @@ func (f *ForkChoiceStore) recordBlockTimeliness(block *cltypes.BeaconBlock, bloc
func (f *ForkChoiceStore) getDependentRoot(root common.Hash) common.Hash {
epoch := f.computeEpochAtSlot(f.Slot())
- if epoch <= f.beaconCfg.MinSeedLookahead {
- return common.Hash{}
- }
- dependentSlot := f.computeStartSlotAtEpoch(epoch-f.beaconCfg.MinSeedLookahead) - 1
+ dependentSlot := computeShufflingDependentSlot(epoch, f.beaconCfg.MinSeedLookahead, f.beaconCfg.SlotsPerEpoch)
return f.getAncestor(f.getNodeForRoot(root), dependentSlot).Root
}
+func computeShufflingDependentSlot(epoch, minSeedLookahead, slotsPerEpoch uint64) uint64 {
+ if epoch <= minSeedLookahead {
+ return 0
+ }
+ return (epoch-minSeedLookahead)*slotsPerEpoch - 1
+}
+
// updateProposerBoostRoot implements update_proposer_boost_root from the spec.
func (f *ForkChoiceStore) updateProposerBoostRoot(headRoot common.Hash, blockRoot common.Hash) {
timeliness, ok := f.getBlockTimeliness(blockRoot)
@@ -189,20 +193,20 @@ func (f *ForkChoiceStore) shouldApplyProposerBoostGloas(proposerBoostRoot common
}
func (f *ForkChoiceStore) shouldApplyProposerBoostGloasWith(proposerBoostRoot common.Hash, isHeadWeak func(common.Hash) bool) bool {
- boostBlock, ok := f.forkGraph.GetBlock(proposerBoostRoot)
- if !ok || boostBlock == nil {
+ boostBlock, ok := f.forkGraph.GetHeader(proposerBoostRoot)
+ if !ok {
return false
}
- parentRoot := boostBlock.Block.ParentRoot
- slot := boostBlock.Block.Slot
+ parentRoot := boostBlock.ParentRoot
+ slot := boostBlock.Slot
- parentBlock, ok := f.forkGraph.GetBlock(parentRoot)
- if !ok || parentBlock == nil {
+ parentBlock, ok := f.forkGraph.GetHeader(parentRoot)
+ if !ok {
return false
}
- if parentBlock.Block.Slot+1 < slot {
+ if parentBlock.Slot+1 < slot {
return true
}
@@ -210,7 +214,7 @@ func (f *ForkChoiceStore) shouldApplyProposerBoostGloasWith(proposerBoostRoot co
return true
}
- parentProposerIndex := parentBlock.Block.ProposerIndex
+ parentProposerIndex := parentBlock.ProposerIndex
hasEquivocation := false
f.blockTimeliness.Range(func(key, value any) bool {
root := key.(common.Hash)
@@ -222,11 +226,11 @@ func (f *ForkChoiceStore) shouldApplyProposerBoostGloasWith(proposerBoostRoot co
if !timeliness[clparams.PtcTimelinessIndex] {
return true
}
- blk, blkOk := f.forkGraph.GetBlock(root)
- if !blkOk || blk == nil {
+ blk, blkOk := f.forkGraph.GetHeader(root)
+ if !blkOk {
return true
}
- if blk.Block.ProposerIndex == parentProposerIndex && blk.Block.Slot+1 == slot {
+ if blk.ProposerIndex == parentProposerIndex && blk.Slot+1 == slot {
hasEquivocation = true
return false
}
@@ -318,8 +322,7 @@ func (f *ForkChoiceStore) isHeadWeakWith(root common.Hash, checkpointState *chec
}
vi := int(validatorIndex)
if vi < checkpointState.validatorSetSize &&
- readFromBitset(checkpointState.actives, vi) &&
- !readFromBitset(checkpointState.slasheds, vi) {
+ readFromBitset(checkpointState.actives, vi) {
weight += checkpointState.balances[vi]
}
}
diff --git a/cl/phase1/forkchoice/timing_test.go b/cl/phase1/forkchoice/timing_test.go
index 43a82ecc09d..f3100c33a69 100644
--- a/cl/phase1/forkchoice/timing_test.go
+++ b/cl/phase1/forkchoice/timing_test.go
@@ -79,6 +79,12 @@ func TestGetPayloadAttestationDueMs(t *testing.T) {
require.Equal(t, uint64(9000), f.getPayloadAttestationDueMs(10))
}
+func TestComputeShufflingDependentSlotUsesGenesisInEarlyEpochs(t *testing.T) {
+ require.Zero(t, computeShufflingDependentSlot(0, 1, 32))
+ require.Zero(t, computeShufflingDependentSlot(1, 1, 32))
+ require.Equal(t, uint64(31), computeShufflingDependentSlot(2, 1, 32))
+}
+
func TestShouldApplyProposerBoost_PreGloas(t *testing.T) {
// Mainnet config: 12s slots, 3 intervals, GLOAS not activated
// Threshold: 12/3 = 4 seconds into slot
diff --git a/cl/phase1/network/beacon_downloader.go b/cl/phase1/network/beacon_downloader.go
index 259c9c75f85..48fb6839eee 100644
--- a/cl/phase1/network/beacon_downloader.go
+++ b/cl/phase1/network/beacon_downloader.go
@@ -48,26 +48,37 @@ type ProcessFn func(
newHighestSlotProcessed uint64,
err error)
+var ErrUnattributableProcess = errors.New("unattributable process error")
+
type ForwardBeaconDownloader struct {
ctx context.Context
highestSlotProcessed uint64
highestSlotUpdateTime time.Time
minSlot uint64 // earliest requestable slot (e.g. checkpoint anchor)
rpc *rpc.BeaconRpcP2P
+ requestBlocksByRange func(context.Context, uint64, uint64) ([]*cltypes.SignedBeaconBlock, string, error)
+ banPeer func(string)
process ProcessFn
beaconCfg *clparams.BeaconChainConfig
httpFallbackURL string // beacon API base URL for HTTP fallback when P2P fails
httpPreferred atomic.Bool // set after first HTTP fallback success; skips P2P probing
- mu sync.Mutex
+ mu sync.Mutex
+ gloasLookahead *cltypes.SignedBeaconBlock
+ gloasNextUnscanned uint64
}
func NewForwardBeaconDownloader(ctx context.Context, rpc *rpc.BeaconRpcP2P, beaconCfg *clparams.BeaconChainConfig) *ForwardBeaconDownloader {
- return &ForwardBeaconDownloader{
+ f := &ForwardBeaconDownloader{
ctx: ctx,
rpc: rpc,
beaconCfg: beaconCfg,
}
+ if rpc != nil {
+ f.requestBlocksByRange = rpc.SendBeaconBlocksByRangeReq
+ f.banPeer = rpc.BanPeer
+ }
+ return f
}
// SetProcessFunction sets the function used to process segments.
@@ -99,6 +110,9 @@ func (f *ForwardBeaconDownloader) SetMinSlot(slot uint64) {
f.mu.Lock()
defer f.mu.Unlock()
f.minSlot = slot
+ if f.gloasLookahead != nil && f.gloasLookahead.Block.Slot < slot {
+ f.clearGloasScan()
+ }
}
// SetHighestProcessedSlot sets the highest processed slot so far.
@@ -108,12 +122,18 @@ func (f *ForwardBeaconDownloader) SetHighestProcessedSlot(highestSlotProcessed u
if highestSlotProcessed > f.highestSlotProcessed {
f.highestSlotProcessed = highestSlotProcessed
f.highestSlotUpdateTime = time.Now()
+ if f.gloasLookahead != nil && f.gloasLookahead.Block.Slot <= highestSlotProcessed {
+ f.clearGloasScan()
+ }
}
}
type peerAndBlocks struct {
- peerId string
- blocks []*cltypes.SignedBeaconBlock
+ peerId string
+ blocks []*cltypes.SignedBeaconBlock
+ rangeStart uint64
+ rangeCount uint64
+ hadGloasPending bool
}
func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) {
@@ -123,11 +143,16 @@ func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) {
// Fast path: when HTTP has been working, skip P2P probing entirely.
if f.httpPreferred.Load() && f.httpFallbackURL != "" {
- httpStart := f.highestSlotProcessed + 1
- httpBlocks, httpErr := fetchBlocksFromBeaconAPI(ctx, f.httpFallbackURL, httpStart, count+10, f.beaconCfg)
- if httpErr == nil && len(httpBlocks) > 0 {
- atomicResp.Store(peerAndBlocks{"http-fallback", httpBlocks})
- } else {
+ httpStart, hadGloasPending := f.nextRequestStart(false)
+ httpCount := capRequestCount(httpStart, count+10)
+ httpBlocks, httpErr := fetchBlocksFromBeaconAPI(ctx, f.httpFallbackURL, httpStart, httpCount, f.beaconCfg)
+ switch {
+ case httpErr == nil && len(httpBlocks) > 0:
+ atomicResp.Store(peerAndBlocks{"http-fallback", httpBlocks, httpStart, httpCount, hadGloasPending})
+ case httpErr == nil && hadGloasPending:
+ f.recordEmptyRange(httpStart, httpCount, true, "http-fallback")
+ return
+ default:
// HTTP failed — fall back to P2P probing.
f.httpPreferred.Store(false)
}
@@ -140,6 +165,41 @@ func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) {
// Start with a base interval; backoff increases it on repeated failures.
baseInterval := 300 * time.Millisecond
var consecutiveFailures atomic.Int32
+ var requestsMu sync.Mutex
+ inFlightRequests := 0
+ responseAccepted := false
+ type emptyRangeResult struct {
+ lastSlot uint64
+ apply func()
+ }
+ var pendingEmpty *emptyRangeResult
+ beginRequest := func() bool {
+ requestsMu.Lock()
+ defer requestsMu.Unlock()
+ if responseAccepted || pendingEmpty != nil {
+ return false
+ }
+ inFlightRequests++
+ return true
+ }
+ completeRequest := func(response *peerAndBlocks, empty *emptyRangeResult) {
+ requestsMu.Lock()
+ defer requestsMu.Unlock()
+ if response != nil && !responseAccepted {
+ responseAccepted = true
+ atomicResp.Store(*response)
+ }
+ if empty != nil && (pendingEmpty == nil || empty.lastSlot > pendingEmpty.lastSlot) {
+ pendingEmpty = empty
+ }
+ inFlightRequests--
+ if inFlightRequests == 0 {
+ if !responseAccepted && pendingEmpty != nil {
+ pendingEmpty.apply()
+ }
+ pendingEmpty = nil
+ }
+ }
reqInterval := time.NewTicker(baseInterval)
defer reqInterval.Stop()
// Timeout: if no blocks received within this duration, return to let the caller
@@ -151,21 +211,21 @@ func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) {
for {
select {
case <-reqInterval.C:
+ if !beginRequest() {
+ continue
+ }
go func() {
+ var acceptedResponse *peerAndBlocks
+ var emptyResponse *emptyRangeResult
+ defer func() { completeRequest(acceptedResponse, emptyResponse) }()
if len(atomicResp.Load().(peerAndBlocks).blocks) > 0 {
return
}
- var reqSlot uint64
- if f.highestSlotProcessed > 2 {
- reqSlot = f.highestSlotProcessed - 2
- }
- if reqSlot < f.minSlot {
- reqSlot = f.minSlot
- }
+ reqSlot, hadGloasPending := f.nextRequestStart(true)
// Request one extra block beyond the batch for GLOAS lookahead:
// the extra block lets determineFullGloasRoots check whether the
// last batch block is FULL or EMPTY, instead of guessing FULL.
- reqCount := count + 1
+ reqCount := capRequestCount(reqSlot, count+1)
// Cap the request at the next fork epoch boundary. The Eth2 spec
// says peers SHOULD NOT serve blocks across fork boundaries in a
@@ -178,7 +238,7 @@ func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) {
if time.Since(f.highestSlotUpdateTime) > 90*time.Second {
log.Trace("Forward beacon downloader gets stuck", "time", time.Since(f.highestSlotUpdateTime).Seconds(), "highestSlotProcessed", f.highestSlotProcessed)
}
- responses, peerId, err := f.rpc.SendBeaconBlocksByRangeReq(ctx, reqSlot, reqCount)
+ responses, peerId, err := f.requestBlocksByRange(ctx, reqSlot, reqCount)
if err != nil {
if errors.Is(err, peers.ErrNoPeers) {
log.Debug("[Caplin] no peers available for beacon blocks by range request", "slot", reqSlot, "reqCount", reqCount)
@@ -199,14 +259,23 @@ func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) {
if len(atomicResp.Load().(peerAndBlocks).blocks) > 0 {
return
}
- httpStart := f.highestSlotProcessed + 1
- httpBlocks, httpErr := fetchBlocksFromBeaconAPI(ctx, f.httpFallbackURL, httpStart, count+10, f.beaconCfg)
+ httpStart, httpHadGloasPending := f.nextRequestStart(false)
+ httpCount := capRequestCount(httpStart, count+10)
+ httpBlocks, httpErr := fetchBlocksFromBeaconAPI(ctx, f.httpFallbackURL, httpStart, httpCount, f.beaconCfg)
if httpErr == nil && len(httpBlocks) > 0 {
log.Debug("[ForwardBeaconDownloader] P2P failed, fetched blocks from beacon API",
"fromSlot", httpStart, "count", len(httpBlocks))
consecutiveFailures.Store(0)
f.httpPreferred.Store(true)
- atomicResp.Store(peerAndBlocks{"http-fallback", httpBlocks})
+ response := peerAndBlocks{"http-fallback", httpBlocks, httpStart, httpCount, httpHadGloasPending}
+ acceptedResponse = &response
+ return
+ }
+ if httpErr == nil && httpHadGloasPending {
+ emptyResponse = &emptyRangeResult{
+ lastSlot: lastSlotInRange(httpStart, httpCount),
+ apply: func() { f.recordEmptyRange(httpStart, httpCount, true, "http-fallback") },
+ }
return
}
if httpErr != nil {
@@ -224,23 +293,17 @@ func (f *ForwardBeaconDownloader) RequestMore(ctx context.Context) {
if len(responses) == 0 {
// Empty response: no blocks in this slot range.
// Advance past the requested range so we don't get stuck requesting the same empty range.
- f.mu.Lock()
- newSlot := reqSlot + count
- if newSlot > f.highestSlotProcessed {
- log.Debug("Empty block range response, advancing past gap", "from", f.highestSlotProcessed, "to", newSlot, "peer", peerId)
- f.highestSlotProcessed = newSlot
- f.highestSlotUpdateTime = time.Now()
+ emptyResponse = &emptyRangeResult{
+ lastSlot: lastSlotInRange(reqSlot, reqCount),
+ apply: func() { f.recordEmptyRange(reqSlot, reqCount, hadGloasPending, peerId) },
}
- f.mu.Unlock()
return
}
// Success: reset backoff
consecutiveFailures.Store(0)
reqInterval.Reset(baseInterval)
- if len(atomicResp.Load().(peerAndBlocks).blocks) > 0 {
- return
- }
- atomicResp.Store(peerAndBlocks{peerId, responses})
+ response := peerAndBlocks{peerId, responses, reqSlot, reqCount, hadGloasPending}
+ acceptedResponse = &response
}()
case <-ctx.Done():
return
@@ -260,10 +323,32 @@ Process:
resp := atomicResp.Load().(peerAndBlocks)
processBlocks := resp.blocks
pid := resp.peerId
+ slices.SortFunc(processBlocks, func(a, b *cltypes.SignedBeaconBlock) int {
+ return cmp.Compare(a.Block.Slot, b.Block.Slot)
+ })
+ if anyGloasBlock(processBlocks) && !connectedGloasBlocks(processBlocks) {
+ f.rejectInvalidGloasResponse(pid)
+ return
+ }
+ f.mu.Lock()
+ lookahead := f.gloasLookahead
+ f.mu.Unlock()
+ if lookahead != nil {
+ processBlocks = mergeGloasLookahead(processBlocks, lookahead)
+ }
slices.SortFunc(processBlocks, func(a, b *cltypes.SignedBeaconBlock) int {
return cmp.Compare(a.Block.Slot, b.Block.Slot)
})
+ hasGloasBlocks := anyGloasBlock(processBlocks)
+ if hasGloasBlocks && !connectedGloasBlocks(processBlocks) {
+ f.mu.Lock()
+ if lookahead != nil && f.gloasLookahead == lookahead {
+ f.clearGloasScan()
+ }
+ f.mu.Unlock()
+ return
+ }
// For GLOAS blocks, fetch envelopes only for FULL blocks (whose payload was delivered).
// EMPTY blocks never have envelopes on the network, so requesting them causes a 30s stall.
@@ -274,15 +359,18 @@ Process:
// last batch block's FULL/EMPTY status accurately. Use all blocks for determination,
// then trim to `count` before processing.
var envelopes map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope
- if anyGloasBlock(processBlocks) {
+ var nextGloasLookahead *cltypes.SignedBeaconBlock
+ nextGloasCursor := nextSlotAfterRange(resp.rangeStart, resp.rangeCount)
+ if hasGloasBlocks {
// Always keep at least 1 block as lookahead so the last processed
// block's FULL/EMPTY status is determined from the actual next block
// rather than guessed as EMPTY. Without this, a FULL block at the
// batch boundary has its envelope skipped, and the next batch's first
// block fails with ErrParentEnvelopePending.
processCount := min(int(count), len(processBlocks)-1)
- if processCount < 1 {
- processCount = len(processBlocks) // single block: process it (best-effort)
+ nextGloasLookahead = processBlocks[processCount]
+ if processCount+1 < len(processBlocks) {
+ nextGloasCursor = saturatingIncrement(nextGloasLookahead.Block.Slot)
}
fullRoots := determineFullGloasRoots(processBlocks, processCount)
processBlocks = processBlocks[:processCount]
@@ -317,6 +405,14 @@ Process:
"batchBlocks", len(processBlocks),
"firstSlot", processBlocks[0].Block.Slot,
"lastSlot", processBlocks[len(processBlocks)-1].Block.Slot)
+ retained := retainBlocksBeforeMissingGloasEnvelope(processBlocks, fullRoots, envelopes)
+ if len(retained) < len(processBlocks) {
+ log.Debug("[ForwardBeaconDownloader] retaining frontier before missing GLOAS envelope",
+ "retainedBlocks", len(retained), "batchBlocks", len(processBlocks))
+ nextGloasLookahead = processBlocks[len(retained)]
+ nextGloasCursor = saturatingIncrement(nextGloasLookahead.Block.Slot)
+ processBlocks = retained
+ }
}
} else if uint64(len(processBlocks)) > count {
// Non-GLOAS: still trim the extra lookahead block.
@@ -329,8 +425,20 @@ Process:
var highestSlotProcessed uint64
var err error
if highestSlotProcessed, err = f.process(f.highestSlotProcessed, processBlocks, envelopes); err != nil {
- if pid != "http-fallback" {
- f.rpc.BanPeer(pid)
+ if lookahead != nil && f.gloasLookahead == lookahead {
+ f.clearGloasScan()
+ }
+ if lookahead == nil && shouldBanProcessPeer(pid, err) && f.banPeer != nil {
+ f.banPeer(pid)
+ }
+ return
+ }
+ attemptedNewSlot := slices.ContainsFunc(processBlocks, func(block *cltypes.SignedBeaconBlock) bool {
+ return block.Block.Slot > f.highestSlotProcessed
+ })
+ if attemptedNewSlot && nextGloasLookahead != nil && highestSlotProcessed <= f.highestSlotProcessed {
+ if lookahead != nil && f.gloasLookahead == lookahead {
+ f.clearGloasScan()
}
return
}
@@ -338,6 +446,156 @@ Process:
f.highestSlotProcessed = highestSlotProcessed
f.highestSlotUpdateTime = time.Now()
}
+ if nextGloasLookahead != nil {
+ for _, block := range processBlocks {
+ if block.Block.Slot > f.highestSlotProcessed {
+ nextGloasLookahead = block
+ nextGloasCursor = saturatingIncrement(block.Block.Slot)
+ break
+ }
+ }
+ }
+ if nextGloasLookahead != nil && nextGloasLookahead.Block.Slot > f.highestSlotProcessed {
+ f.gloasLookahead = nextGloasLookahead
+ f.gloasNextUnscanned = max(nextGloasCursor, saturatingIncrement(nextGloasLookahead.Block.Slot))
+ } else {
+ f.clearGloasScan()
+ }
+}
+
+func (f *ForwardBeaconDownloader) nextRequestStart(overlap bool) (uint64, bool) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if f.gloasLookahead != nil {
+ return f.gloasNextUnscanned, true
+ }
+ start := saturatingIncrement(f.highestSlotProcessed)
+ if overlap && f.highestSlotProcessed > 2 {
+ start = f.highestSlotProcessed - 2
+ }
+ if start < f.minSlot {
+ start = f.minSlot
+ }
+ return start, false
+}
+
+func (f *ForwardBeaconDownloader) recordEmptyRange(start, count uint64, hadGloasPending bool, peerID string) {
+ if count == 0 {
+ return
+ }
+ lastSlot := lastSlotInRange(start, count)
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if hadGloasPending || f.gloasLookahead != nil {
+ if f.gloasLookahead != nil {
+ f.gloasNextUnscanned = max(f.gloasNextUnscanned, saturatingIncrement(lastSlot))
+ }
+ return
+ }
+ if lastSlot > f.highestSlotProcessed {
+ log.Debug("Empty block range response, advancing past gap", "from", f.highestSlotProcessed, "to", lastSlot, "peer", peerID)
+ f.highestSlotProcessed = lastSlot
+ f.highestSlotUpdateTime = time.Now()
+ }
+}
+
+func (f *ForwardBeaconDownloader) clearGloasScan() {
+ f.gloasLookahead = nil
+ f.gloasNextUnscanned = 0
+}
+
+func mergeGloasLookahead(blocks []*cltypes.SignedBeaconBlock, lookahead *cltypes.SignedBeaconBlock) []*cltypes.SignedBeaconBlock {
+ lookaheadRoot, lookaheadErr := lookahead.Block.HashSSZ()
+ merged := make([]*cltypes.SignedBeaconBlock, 0, len(blocks)+1)
+ merged = append(merged, lookahead)
+ for _, block := range blocks {
+ if lookaheadErr == nil {
+ root, err := block.Block.HashSSZ()
+ if err == nil && root == lookaheadRoot {
+ continue
+ }
+ }
+ merged = append(merged, block)
+ }
+ return merged
+}
+
+func connectedGloasBlocks(blocks []*cltypes.SignedBeaconBlock) bool {
+ for i := 1; i < len(blocks); i++ {
+ root, err := blocks[i-1].Block.HashSSZ()
+ if err != nil || blocks[i].Block.ParentRoot != root {
+ return false
+ }
+ }
+ return true
+}
+
+const (
+ banInvalidGloasResponse = "ban-peer"
+ disableHTTPInvalidGloasResponse = "disable-http"
+)
+
+func invalidGloasResponseAction(pid string) string {
+ if pid == "http-fallback" {
+ return disableHTTPInvalidGloasResponse
+ }
+ return banInvalidGloasResponse
+}
+
+func (f *ForwardBeaconDownloader) rejectInvalidGloasResponse(pid string) {
+ switch invalidGloasResponseAction(pid) {
+ case banInvalidGloasResponse:
+ if f.banPeer != nil {
+ f.banPeer(pid)
+ }
+ case disableHTTPInvalidGloasResponse:
+ f.httpPreferred.Store(false)
+ }
+}
+
+func capRequestCount(start, count uint64) uint64 {
+ if count == 0 || count-1 <= math.MaxUint64-start {
+ return count
+ }
+ return math.MaxUint64 - start + 1
+}
+
+func lastSlotInRange(start, count uint64) uint64 {
+ if count == 0 {
+ return start
+ }
+ return start + capRequestCount(start, count) - 1
+}
+
+func nextSlotAfterRange(start, count uint64) uint64 {
+ return saturatingIncrement(lastSlotInRange(start, count))
+}
+
+func saturatingIncrement(slot uint64) uint64 {
+ if slot == math.MaxUint64 {
+ return slot
+ }
+ return slot + 1
+}
+
+func retainBlocksBeforeMissingGloasEnvelope(blocks []*cltypes.SignedBeaconBlock, fullRoots [][32]byte, envelopes map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) []*cltypes.SignedBeaconBlock {
+ for _, root := range fullRoots {
+ if envelopes[common.Hash(root)] != nil {
+ continue
+ }
+ for i, block := range blocks {
+ blockRoot, err := block.Block.HashSSZ()
+ if err != nil || blockRoot == root {
+ return blocks[:i]
+ }
+ }
+ return nil
+ }
+ return blocks
+}
+
+func shouldBanProcessPeer(pid string, err error) bool {
+ return pid != "http-fallback" && !errors.Is(err, ErrUnattributableProcess)
}
// anyGloasBlock returns true if any block in the list is GLOAS version or later.
@@ -400,6 +658,7 @@ func determineFullGloasRoots(blocks []*cltypes.SignedBeaconBlock, processCount i
// boundary instead of capping — otherwise the downloader re-requests the same
// already-processed slots and never makes progress.
func (f *ForwardBeaconDownloader) capAtForkBoundary(reqSlot, reqCount uint64) (uint64, uint64) {
+ reqCount = capRequestCount(reqSlot, reqCount)
slotsPerEpoch := f.beaconCfg.SlotsPerEpoch
forkEpochs := []uint64{
f.beaconCfg.AltairForkEpoch,
@@ -420,27 +679,26 @@ func (f *ForwardBeaconDownloader) capAtForkBoundary(reqSlot, reqCount uint64) (u
}
slices.Sort(boundaries)
- endSlot := reqSlot + reqCount
for _, boundarySlot := range boundaries {
if boundarySlot <= reqSlot {
continue
}
- if boundarySlot >= endSlot {
+ distance := boundarySlot - reqSlot
+ if distance >= reqCount {
break
}
- // boundarySlot is in (reqSlot, endSlot).
- if boundarySlot <= f.highestSlotProcessed+1 {
+ if boundarySlot <= saturatingIncrement(f.highestSlotProcessed) {
// Already processed past this boundary — skip the pre-boundary
// overlap and start from the boundary so the request stays
// within a single fork.
reqSlot = boundarySlot
+ reqCount -= distance
} else {
// Haven't reached this boundary yet — cap the request here.
- reqCount = boundarySlot - reqSlot
+ reqCount = distance
return reqSlot, reqCount
}
}
- reqCount = endSlot - reqSlot
return reqSlot, reqCount
}
diff --git a/cl/phase1/network/beacon_downloader_test.go b/cl/phase1/network/beacon_downloader_test.go
new file mode 100644
index 00000000000..fad008a1029
--- /dev/null
+++ b/cl/phase1/network/beacon_downloader_test.go
@@ -0,0 +1,901 @@
+package network
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "math"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/erigontech/erigon/cl/clparams"
+ "github.com/erigontech/erigon/cl/cltypes"
+ "github.com/erigontech/erigon/common"
+)
+
+func linkBeaconBlocks(t *testing.T, blocks ...*cltypes.SignedBeaconBlock) {
+ t.Helper()
+ for i := 1; i < len(blocks); i++ {
+ root, err := blocks[i-1].Block.HashSSZ()
+ require.NoError(t, err)
+ blocks[i].Block.ParentRoot = root
+ }
+}
+
+func TestForwardBeaconDownloaderEmptyCappedRangeStopsAtForkBoundary(t *testing.T) {
+ cfg := clparams.MainnetBeaconConfig
+ cfg.SlotsPerEpoch = 10
+ cfg.AltairForkEpoch = ^uint64(0)
+ cfg.BellatrixForkEpoch = ^uint64(0)
+ cfg.CapellaForkEpoch = ^uint64(0)
+ cfg.DenebForkEpoch = ^uint64(0)
+ cfg.ElectraForkEpoch = ^uint64(0)
+ cfg.FuluForkEpoch = ^uint64(0)
+ cfg.GloasForkEpoch = 11
+
+ requests := make(chan cltypes.BeaconBlocksByRangeRequest, 2)
+ downloader := &ForwardBeaconDownloader{
+ beaconCfg: &cfg,
+ requestBlocksByRange: func(_ context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) {
+ requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count}
+ return []*cltypes.SignedBeaconBlock{}, "block-peer", nil
+ },
+ }
+ downloader.SetHighestProcessedSlot(100)
+
+ runRequest := func() cltypes.BeaconBlocksByRangeRequest {
+ frontier := downloader.GetHighestProcessedSlot()
+ ctx, cancel := context.WithCancel(t.Context())
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ downloader.RequestMore(ctx)
+ }()
+ request := <-requests
+ require.Eventually(t, func() bool {
+ return downloader.GetHighestProcessedSlot() != frontier
+ }, time.Second, 10*time.Millisecond)
+ cancel()
+ <-done
+ return request
+ }
+
+ first := runRequest()
+ require.Equal(t, uint64(98), first.StartSlot)
+ require.Equal(t, uint64(12), first.Count)
+ require.Equal(t, uint64(109), downloader.GetHighestProcessedSlot())
+
+ second := runRequest()
+ require.Equal(t, uint64(110), second.StartSlot)
+ require.Equal(t, uint64(30), second.Count)
+}
+
+func TestCapAtForkBoundaryKeepsHalfOpenBoundaryRanges(t *testing.T) {
+ cfg := clparams.MainnetBeaconConfig
+ cfg.SlotsPerEpoch = 10
+ cfg.AltairForkEpoch = ^uint64(0)
+ cfg.BellatrixForkEpoch = ^uint64(0)
+ cfg.CapellaForkEpoch = ^uint64(0)
+ cfg.DenebForkEpoch = ^uint64(0)
+ cfg.ElectraForkEpoch = ^uint64(0)
+ cfg.FuluForkEpoch = ^uint64(0)
+ cfg.GloasForkEpoch = 11
+ downloader := &ForwardBeaconDownloader{beaconCfg: &cfg, highestSlotProcessed: 100}
+
+ start, count := downloader.capAtForkBoundary(98, 12)
+ require.Equal(t, uint64(98), start)
+ require.Equal(t, uint64(12), count)
+
+ start, count = downloader.capAtForkBoundary(110, 33)
+ require.Equal(t, uint64(110), start)
+ require.Equal(t, uint64(33), count)
+}
+
+func TestShouldBanProcessPeer(t *testing.T) {
+ processErr := errors.New("process failed")
+
+ require.False(t, shouldBanProcessPeer("block-peer", fmt.Errorf("%w: %w", ErrUnattributableProcess, processErr)))
+ require.True(t, shouldBanProcessPeer("block-peer", processErr))
+ require.False(t, shouldBanProcessPeer("http-fallback", processErr))
+}
+
+func TestForwardBeaconDownloaderRetainsFrontierWhenHTTPFullEnvelopeIsMissing(t *testing.T) {
+ first := makeGloasBlock(10, hash(0xaa), common.Hash{})
+ lookahead := makeGloasBlock(11, hash(0xbb), hash(0xaa))
+ linkBeaconBlocks(t, first, lookahead)
+ firstEncoded, err := first.EncodeSSZ(nil)
+ require.NoError(t, err)
+ lookaheadEncoded, err := lookahead.EncodeSSZ(nil)
+ require.NoError(t, err)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Eth-Consensus-Version", "gloas")
+ switch {
+ case strings.HasSuffix(r.URL.Path, "/beacon/blocks/10"):
+ _, _ = w.Write(firstEncoded)
+ case strings.HasSuffix(r.URL.Path, "/beacon/blocks/11"):
+ _, _ = w.Write(lookaheadEncoded)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ downloader := NewForwardBeaconDownloader(context.Background(), nil, &clparams.MainnetBeaconConfig)
+ downloader.SetHighestProcessedSlot(9)
+ downloader.SetHTTPFallbackURL(server.URL)
+ downloader.httpPreferred.Store(true)
+ processed := 0
+ downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) {
+ processed += len(blocks)
+ if len(blocks) == 0 {
+ return highest, nil
+ }
+ return blocks[len(blocks)-1].Block.Slot, nil
+ })
+
+ downloader.RequestMore(t.Context())
+
+ require.Zero(t, processed)
+ require.Equal(t, uint64(9), downloader.GetHighestProcessedSlot())
+}
+
+func TestForwardBeaconDownloaderRetainsSingleHTTPGloasBlockUntilLookahead(t *testing.T) {
+ block := makeGloasBlock(10, hash(0xaa), common.Hash{})
+ encoded, err := block.EncodeSSZ(nil)
+ require.NoError(t, err)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, "/beacon/blocks/10") {
+ w.Header().Set("Eth-Consensus-Version", "gloas")
+ _, _ = w.Write(encoded)
+ return
+ }
+ http.NotFound(w, r)
+ }))
+ defer server.Close()
+
+ downloader := NewForwardBeaconDownloader(context.Background(), nil, &clparams.MainnetBeaconConfig)
+ downloader.SetHighestProcessedSlot(9)
+ downloader.SetHTTPFallbackURL(server.URL)
+ downloader.httpPreferred.Store(true)
+ processed := 0
+ downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) {
+ processed += len(blocks)
+ if len(blocks) == 0 {
+ return highest, nil
+ }
+ return blocks[len(blocks)-1].Block.Slot, nil
+ })
+
+ downloader.RequestMore(t.Context())
+
+ require.Zero(t, processed)
+ require.Equal(t, uint64(9), downloader.GetHighestProcessedSlot())
+}
+
+func TestForwardBeaconDownloaderScansPastGloasLookaheadGap(t *testing.T) {
+ const frontier = uint64(100)
+ first := makeGloasBlock(frontier+1, hash(0xaa), common.Hash{})
+ successor := makeGloasBlock(frontier+66, hash(0xbb), hash(0xcc))
+ linkBeaconBlocks(t, first, successor)
+
+ requests := make(chan cltypes.BeaconBlocksByRangeRequest, 3)
+ allowSuccessor := make(chan struct{})
+ var requestCount atomic.Int32
+ downloader := &ForwardBeaconDownloader{
+ beaconCfg: &clparams.MainnetBeaconConfig,
+ requestBlocksByRange: func(_ context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) {
+ requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count}
+ switch requestCount.Add(1) {
+ case 1:
+ return []*cltypes.SignedBeaconBlock{first}, "block-peer", nil
+ case 2:
+ return []*cltypes.SignedBeaconBlock{}, "block-peer", nil
+ case 3:
+ <-allowSuccessor
+ return []*cltypes.SignedBeaconBlock{successor}, "block-peer", nil
+ default:
+ return nil, "block-peer", errors.New("unexpected request")
+ }
+ },
+ }
+ downloader.SetHighestProcessedSlot(frontier)
+ var processedSlots []uint64
+ downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) {
+ for _, block := range blocks {
+ processedSlots = append(processedSlots, block.Block.Slot)
+ highest = block.Block.Slot
+ }
+ return highest, nil
+ })
+
+ downloader.RequestMore(t.Context())
+ firstRequest := <-requests
+ require.Equal(t, cltypes.BeaconBlocksByRangeRequest{StartSlot: frontier - 2, Count: 33}, firstRequest)
+ require.Empty(t, processedSlots)
+ require.Equal(t, frontier, downloader.GetHighestProcessedSlot())
+
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ downloader.RequestMore(t.Context())
+ }()
+ emptyRequest := <-requests
+ require.Equal(t, cltypes.BeaconBlocksByRangeRequest{StartSlot: frontier + 31, Count: 33}, emptyRequest)
+ farRequest := <-requests
+ require.Equal(t, cltypes.BeaconBlocksByRangeRequest{StartSlot: frontier + 64, Count: 33}, farRequest)
+ require.Equal(t, frontier, downloader.GetHighestProcessedSlot())
+ close(allowSuccessor)
+ <-done
+ require.Equal(t, []uint64{frontier + 1}, processedSlots)
+ require.Equal(t, frontier+1, downloader.GetHighestProcessedSlot())
+}
+
+func TestForwardBeaconDownloaderHTTPScansPastGloasLookaheadGap(t *testing.T) {
+ const frontier = uint64(100)
+ first := makeGloasBlock(frontier+1, hash(0xaa), common.Hash{})
+ successor := makeGloasBlock(frontier+45, hash(0xbb), hash(0xcc))
+ linkBeaconBlocks(t, first, successor)
+ firstEncoded, err := first.EncodeSSZ(nil)
+ require.NoError(t, err)
+ successorEncoded, err := successor.EncodeSSZ(nil)
+ require.NoError(t, err)
+
+ requestedSlots := make(chan uint64, 84)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var slot uint64
+ _, _ = fmt.Sscanf(r.URL.Path, "/eth/v2/beacon/blocks/%d", &slot)
+ requestedSlots <- slot
+ w.Header().Set("Eth-Consensus-Version", "gloas")
+ switch slot {
+ case first.Block.Slot:
+ _, _ = w.Write(firstEncoded)
+ case successor.Block.Slot:
+ _, _ = w.Write(successorEncoded)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ downloader := NewForwardBeaconDownloader(context.Background(), nil, &clparams.MainnetBeaconConfig)
+ downloader.SetHighestProcessedSlot(frontier)
+ downloader.SetHTTPFallbackURL(server.URL)
+ downloader.httpPreferred.Store(true)
+ var processedSlots []uint64
+ downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) {
+ for _, block := range blocks {
+ processedSlots = append(processedSlots, block.Block.Slot)
+ highest = block.Block.Slot
+ }
+ return highest, nil
+ })
+
+ downloader.RequestMore(t.Context())
+ firstRange := make([]uint64, 42)
+ for i := range firstRange {
+ firstRange[i] = <-requestedSlots
+ }
+ require.Contains(t, firstRange, frontier+1)
+ require.Contains(t, firstRange, frontier+42)
+ require.Empty(t, processedSlots)
+ require.Equal(t, frontier, downloader.GetHighestProcessedSlot())
+
+ downloader.RequestMore(t.Context())
+ secondRange := make([]uint64, 42)
+ for i := range secondRange {
+ secondRange[i] = <-requestedSlots
+ }
+ require.Contains(t, secondRange, frontier+43)
+ require.NotContains(t, secondRange, frontier+1)
+ require.Equal(t, []uint64{frontier + 1}, processedSlots)
+ require.Equal(t, frontier+1, downloader.GetHighestProcessedSlot())
+}
+
+func TestForwardBeaconDownloaderRetainsGloasScanAcrossDuplicateAndCancellation(t *testing.T) {
+ const frontier = uint64(100)
+ first := makeGloasBlock(frontier+1, hash(0xaa), common.Hash{})
+ successor := makeGloasBlock(frontier+66, hash(0xbb), hash(0xcc))
+ linkBeaconBlocks(t, first, successor)
+ requests := make(chan cltypes.BeaconBlocksByRangeRequest, 4)
+ var requestCount atomic.Int32
+ downloader := &ForwardBeaconDownloader{
+ beaconCfg: &clparams.MainnetBeaconConfig,
+ requestBlocksByRange: func(ctx context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) {
+ requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count}
+ switch requestCount.Add(1) {
+ case 1:
+ return []*cltypes.SignedBeaconBlock{first}, "block-peer", nil
+ case 2:
+ return []*cltypes.SignedBeaconBlock{first}, "block-peer", nil
+ case 3:
+ <-ctx.Done()
+ return nil, "block-peer", ctx.Err()
+ case 4:
+ return []*cltypes.SignedBeaconBlock{successor}, "block-peer", nil
+ default:
+ return nil, "block-peer", errors.New("unexpected request")
+ }
+ },
+ }
+ downloader.SetHighestProcessedSlot(frontier)
+ var processedSlots []uint64
+ downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) {
+ for _, block := range blocks {
+ processedSlots = append(processedSlots, block.Block.Slot)
+ highest = block.Block.Slot
+ }
+ return highest, nil
+ })
+
+ downloader.RequestMore(t.Context())
+ require.Equal(t, frontier-2, (<-requests).StartSlot)
+ downloader.RequestMore(t.Context())
+ require.Equal(t, frontier+31, (<-requests).StartSlot)
+ require.Empty(t, processedSlots)
+ require.Equal(t, frontier, downloader.GetHighestProcessedSlot())
+
+ cancelCtx, cancel := context.WithCancel(t.Context())
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ downloader.RequestMore(cancelCtx)
+ }()
+ canceledRequest := <-requests
+ cancel()
+ <-done
+ require.Equal(t, frontier+64, canceledRequest.StartSlot)
+ require.Equal(t, frontier, downloader.GetHighestProcessedSlot())
+
+ downloader.RequestMore(t.Context())
+ retryRequest := <-requests
+ require.Equal(t, canceledRequest, retryRequest)
+ require.Equal(t, []uint64{frontier + 1}, processedSlots)
+ require.Equal(t, frontier+1, downloader.GetHighestProcessedSlot())
+}
+
+func TestForwardBeaconDownloaderPendingGloasScanDoesNotWrapAtMaxSlot(t *testing.T) {
+ frontier := uint64(math.MaxUint64 - 1)
+ last := makeGloasBlock(math.MaxUint64, hash(0xaa), common.Hash{})
+ requests := make(chan cltypes.BeaconBlocksByRangeRequest, 2)
+ var requestCount atomic.Int32
+ downloader := &ForwardBeaconDownloader{
+ beaconCfg: &clparams.MainnetBeaconConfig,
+ requestBlocksByRange: func(ctx context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) {
+ requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count}
+ if requestCount.Add(1) == 1 {
+ return []*cltypes.SignedBeaconBlock{last}, "block-peer", nil
+ }
+ <-ctx.Done()
+ return nil, "block-peer", ctx.Err()
+ },
+ }
+ downloader.SetHighestProcessedSlot(frontier)
+ downloader.SetProcessFunction(func(highest uint64, _ []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) {
+ return highest, nil
+ })
+
+ downloader.RequestMore(t.Context())
+ firstRequest := <-requests
+ require.Equal(t, frontier-2, firstRequest.StartSlot)
+ require.Equal(t, uint64(4), firstRequest.Count)
+ require.Equal(t, frontier, downloader.GetHighestProcessedSlot())
+
+ ctx, cancel := context.WithCancel(t.Context())
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ downloader.RequestMore(ctx)
+ }()
+ secondRequest := <-requests
+ cancel()
+ <-done
+ require.Equal(t, uint64(math.MaxUint64), secondRequest.StartSlot)
+ require.Equal(t, uint64(1), secondRequest.Count)
+ require.Equal(t, frontier, downloader.GetHighestProcessedSlot())
+}
+
+func TestForwardBeaconDownloaderRetainsUnprocessedGloasSuffix(t *testing.T) {
+ const frontier = uint64(100)
+ first := makeGloasBlock(frontier+1, hash(0xa1), common.Hash{})
+ second := makeGloasBlock(frontier+2, hash(0xa2), hash(0xb1))
+ third := makeGloasBlock(frontier+3, hash(0xa3), hash(0xb2))
+ fourth := makeGloasBlock(frontier+4, hash(0xa4), hash(0xb3))
+ linkBeaconBlocks(t, first, second, third, fourth)
+ requests := make(chan cltypes.BeaconBlocksByRangeRequest, 2)
+ var requestCount atomic.Int32
+ downloader := &ForwardBeaconDownloader{
+ beaconCfg: &clparams.MainnetBeaconConfig,
+ requestBlocksByRange: func(_ context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) {
+ requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count}
+ if requestCount.Add(1) == 1 {
+ return []*cltypes.SignedBeaconBlock{first, second, third}, "block-peer", nil
+ }
+ return []*cltypes.SignedBeaconBlock{third, fourth}, "block-peer", nil
+ },
+ }
+ downloader.SetHighestProcessedSlot(frontier)
+ var processedBatches [][]uint64
+ downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) {
+ batch := make([]uint64, len(blocks))
+ for i, block := range blocks {
+ batch[i] = block.Block.Slot
+ }
+ processedBatches = append(processedBatches, batch)
+ if len(processedBatches) == 1 {
+ return first.Block.Slot, nil
+ }
+ return blocks[len(blocks)-1].Block.Slot, nil
+ })
+
+ downloader.RequestMore(t.Context())
+ <-requests
+ require.Equal(t, frontier+1, downloader.GetHighestProcessedSlot())
+ require.Equal(t, [][]uint64{{frontier + 1, frontier + 2}}, processedBatches)
+
+ downloader.RequestMore(t.Context())
+ secondRequest := <-requests
+ require.Equal(t, frontier+3, secondRequest.StartSlot)
+ require.Equal(t, [][]uint64{{frontier + 1, frontier + 2}, {frontier + 2, frontier + 3}}, processedBatches)
+ require.Equal(t, frontier+3, downloader.GetHighestProcessedSlot())
+}
+
+func TestForwardBeaconDownloaderMixedOwnerProcessErrorRestartsOverlap(t *testing.T) {
+ const frontier = uint64(100)
+ cached := makeGloasBlock(frontier+1, hash(0xa1), common.Hash{})
+ successor := makeGloasBlock(frontier+2, hash(0xa2), hash(0xb1))
+ cachedRoot, err := cached.Block.HashSSZ()
+ require.NoError(t, err)
+ successor.Block.ParentRoot = cachedRoot
+
+ requests := make(chan cltypes.BeaconBlocksByRangeRequest, 3)
+ var requestCount atomic.Int32
+ downloader := &ForwardBeaconDownloader{
+ beaconCfg: &clparams.MainnetBeaconConfig,
+ requestBlocksByRange: func(ctx context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) {
+ requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count}
+ switch requestCount.Add(1) {
+ case 1:
+ return []*cltypes.SignedBeaconBlock{cached}, "peer-a", nil
+ case 2:
+ return []*cltypes.SignedBeaconBlock{successor}, "peer-b", nil
+ default:
+ <-ctx.Done()
+ return nil, "peer-c", ctx.Err()
+ }
+ },
+ }
+ downloader.SetHighestProcessedSlot(frontier)
+ downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) {
+ if len(blocks) == 0 {
+ return highest, nil
+ }
+ return highest, errors.New("cached block failed")
+ })
+
+ downloader.RequestMore(t.Context())
+ <-requests
+ downloader.RequestMore(t.Context())
+ <-requests
+ require.Equal(t, frontier, downloader.GetHighestProcessedSlot())
+
+ ctx, cancel := context.WithCancel(t.Context())
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ downloader.RequestMore(ctx)
+ }()
+ retry := <-requests
+ cancel()
+ <-done
+ require.Equal(t, frontier-2, retry.StartSlot)
+}
+
+func TestForwardBeaconDownloaderSingleOwnerProcessErrorRestartsOverlap(t *testing.T) {
+ const frontier = uint64(100)
+ first := makeGloasBlock(frontier+1, hash(0xa1), common.Hash{})
+ successor := makeGloasBlock(frontier+2, hash(0xa2), hash(0xb1))
+ firstRoot, err := first.Block.HashSSZ()
+ require.NoError(t, err)
+ successor.Block.ParentRoot = firstRoot
+
+ requests := make(chan cltypes.BeaconBlocksByRangeRequest, 2)
+ var requestCount atomic.Int32
+ downloader := &ForwardBeaconDownloader{
+ beaconCfg: &clparams.MainnetBeaconConfig,
+ requestBlocksByRange: func(ctx context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) {
+ requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count}
+ if requestCount.Add(1) == 1 {
+ return []*cltypes.SignedBeaconBlock{first, successor}, "peer-a", nil
+ }
+ <-ctx.Done()
+ return nil, "peer-b", ctx.Err()
+ },
+ }
+ downloader.SetHighestProcessedSlot(frontier)
+ downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) {
+ if len(blocks) == 0 {
+ return highest, nil
+ }
+ return highest, fmt.Errorf("%w: state gap", ErrUnattributableProcess)
+ })
+
+ downloader.RequestMore(t.Context())
+ <-requests
+ require.Equal(t, frontier, downloader.GetHighestProcessedSlot())
+
+ ctx, cancel := context.WithCancel(t.Context())
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ downloader.RequestMore(ctx)
+ }()
+ retry := <-requests
+ cancel()
+ <-done
+ require.Equal(t, frontier-2, retry.StartSlot)
+}
+
+func TestForwardBeaconDownloaderNoProgressRestartsOverlap(t *testing.T) {
+ const frontier = uint64(100)
+ first := makeGloasBlock(frontier+1, hash(0xa1), common.Hash{})
+ successor := makeGloasBlock(frontier+2, hash(0xa2), hash(0xb1))
+ firstRoot, err := first.Block.HashSSZ()
+ require.NoError(t, err)
+ successor.Block.ParentRoot = firstRoot
+
+ requests := make(chan cltypes.BeaconBlocksByRangeRequest, 2)
+ var requestCount atomic.Int32
+ downloader := &ForwardBeaconDownloader{
+ beaconCfg: &clparams.MainnetBeaconConfig,
+ requestBlocksByRange: func(_ context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) {
+ requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count}
+ return []*cltypes.SignedBeaconBlock{first, successor}, fmt.Sprintf("peer-%d", requestCount.Add(1)), nil
+ },
+ }
+ downloader.SetHighestProcessedSlot(frontier)
+ var processCount atomic.Int32
+ downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) {
+ if len(blocks) == 0 || processCount.Add(1) == 1 {
+ return highest, nil
+ }
+ return blocks[len(blocks)-1].Block.Slot, nil
+ })
+
+ downloader.RequestMore(t.Context())
+ firstRequest := <-requests
+ require.Equal(t, frontier-2, firstRequest.StartSlot)
+ require.Equal(t, frontier, downloader.GetHighestProcessedSlot())
+
+ downloader.RequestMore(t.Context())
+ secondRequest := <-requests
+ require.Equal(t, frontier-2, secondRequest.StartSlot)
+ require.Equal(t, frontier+1, downloader.GetHighestProcessedSlot())
+}
+
+func TestForwardBeaconDownloaderOverlapOnlyProgressRetainsLookahead(t *testing.T) {
+ const frontier = uint64(100)
+ first := makeGloasBlock(frontier-2, hash(0xa1), common.Hash{})
+ second := makeGloasBlock(frontier-1, hash(0xa2), hash(0xb1))
+ third := makeGloasBlock(frontier, hash(0xa3), hash(0xb2))
+ lookahead := makeGloasBlock(frontier+1, hash(0xa4), hash(0xb3))
+ linkBeaconBlocks(t, first, second, third, lookahead)
+
+ requests := make(chan cltypes.BeaconBlocksByRangeRequest, 2)
+ var requestCount atomic.Int32
+ downloader := &ForwardBeaconDownloader{
+ beaconCfg: &clparams.MainnetBeaconConfig,
+ requestBlocksByRange: func(ctx context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) {
+ requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count}
+ if requestCount.Add(1) == 1 {
+ return []*cltypes.SignedBeaconBlock{first, second, third, lookahead}, "peer-a", nil
+ }
+ <-ctx.Done()
+ return nil, "peer-b", ctx.Err()
+ },
+ }
+ downloader.SetHighestProcessedSlot(frontier)
+ downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) {
+ for _, block := range blocks {
+ require.LessOrEqual(t, block.Block.Slot, highest)
+ }
+ return highest, nil
+ })
+
+ downloader.RequestMore(t.Context())
+ firstRequest := <-requests
+ require.Equal(t, frontier-2, firstRequest.StartSlot)
+ require.Equal(t, frontier, downloader.GetHighestProcessedSlot())
+
+ ctx, cancel := context.WithCancel(t.Context())
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ downloader.RequestMore(ctx)
+ }()
+ next := <-requests
+ cancel()
+ <-done
+ require.Equal(t, frontier+31, next.StartSlot)
+}
+
+func TestInvalidGloasResponseAction(t *testing.T) {
+ require.Equal(t, "ban-peer", invalidGloasResponseAction("peer-a"))
+ require.Equal(t, "disable-http", invalidGloasResponseAction("http-fallback"))
+}
+
+func TestForwardBeaconDownloaderRejectsDisconnectedGloasLookahead(t *testing.T) {
+ const frontier = uint64(100)
+ cached := makeGloasBlock(frontier+1, hash(0xa1), common.Hash{})
+ disconnected := makeGloasBlock(frontier+2, hash(0xa2), hash(0xb1))
+ disconnected.Block.ParentRoot = hash(0xff)
+
+ requests := make(chan cltypes.BeaconBlocksByRangeRequest, 3)
+ var requestCount atomic.Int32
+ var banned atomic.Int32
+ downloader := &ForwardBeaconDownloader{
+ beaconCfg: &clparams.MainnetBeaconConfig,
+ banPeer: func(string) {
+ banned.Add(1)
+ },
+ requestBlocksByRange: func(ctx context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) {
+ requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count}
+ switch requestCount.Add(1) {
+ case 1:
+ return []*cltypes.SignedBeaconBlock{cached}, "peer-a", nil
+ case 2:
+ return []*cltypes.SignedBeaconBlock{disconnected}, "peer-b", nil
+ default:
+ <-ctx.Done()
+ return nil, "peer-c", ctx.Err()
+ }
+ },
+ }
+ downloader.SetHighestProcessedSlot(frontier)
+ var processed atomic.Int32
+ downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) {
+ processed.Add(int32(len(blocks)))
+ if len(blocks) == 0 {
+ return highest, nil
+ }
+ return blocks[len(blocks)-1].Block.Slot, nil
+ })
+
+ downloader.RequestMore(t.Context())
+ <-requests
+ downloader.RequestMore(t.Context())
+ <-requests
+ require.Zero(t, processed.Load())
+ require.Zero(t, banned.Load())
+ require.Equal(t, frontier, downloader.GetHighestProcessedSlot())
+
+ ctx, cancel := context.WithCancel(t.Context())
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ downloader.RequestMore(ctx)
+ }()
+ retry := <-requests
+ cancel()
+ <-done
+ require.Equal(t, frontier-2, retry.StartSlot)
+}
+
+func TestForwardBeaconDownloaderRejectsDisconnectedGloasResponseSuffix(t *testing.T) {
+ const frontier = uint64(100)
+ cached := makeGloasBlock(frontier+1, hash(0xa1), common.Hash{})
+ linked := makeGloasBlock(frontier+2, hash(0xa2), hash(0xb1))
+ disconnected := makeGloasBlock(frontier+3, hash(0xa3), hash(0xb2))
+ cachedRoot, err := cached.Block.HashSSZ()
+ require.NoError(t, err)
+ linked.Block.ParentRoot = cachedRoot
+ disconnected.Block.ParentRoot = hash(0xff)
+
+ requests := make(chan cltypes.BeaconBlocksByRangeRequest, 3)
+ var requestCount atomic.Int32
+ banned := make(chan string, 1)
+ downloader := &ForwardBeaconDownloader{
+ beaconCfg: &clparams.MainnetBeaconConfig,
+ banPeer: func(pid string) {
+ banned <- pid
+ },
+ requestBlocksByRange: func(ctx context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) {
+ requests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count}
+ switch requestCount.Add(1) {
+ case 1:
+ return []*cltypes.SignedBeaconBlock{cached}, "peer-a", nil
+ case 2:
+ return []*cltypes.SignedBeaconBlock{linked, disconnected}, "peer-b", nil
+ default:
+ <-ctx.Done()
+ return nil, "peer-c", ctx.Err()
+ }
+ },
+ }
+ downloader.SetHighestProcessedSlot(frontier)
+ var processed atomic.Int32
+ downloader.SetProcessFunction(func(highest uint64, blocks []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) {
+ processed.Add(int32(len(blocks)))
+ if len(blocks) == 0 {
+ return highest, nil
+ }
+ return blocks[len(blocks)-1].Block.Slot, nil
+ })
+
+ downloader.RequestMore(t.Context())
+ <-requests
+ downloader.RequestMore(t.Context())
+ <-requests
+ require.Zero(t, processed.Load())
+ require.Equal(t, "peer-b", <-banned)
+ require.Equal(t, frontier, downloader.GetHighestProcessedSlot())
+
+ ctx, cancel := context.WithCancel(t.Context())
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ downloader.RequestMore(ctx)
+ }()
+ retry := <-requests
+ cancel()
+ <-done
+ require.Equal(t, frontier+31, retry.StartSlot)
+}
+
+func TestForwardBeaconDownloaderDisconnectedHTTPResponseFallsBackToP2P(t *testing.T) {
+ const frontier = uint64(100)
+ first := makeGloasBlock(frontier+1, hash(0xa1), common.Hash{})
+ second := makeGloasBlock(frontier+2, hash(0xa2), hash(0xb1))
+ second.Block.ParentRoot = hash(0xff)
+ firstEncoded, err := first.EncodeSSZ(nil)
+ require.NoError(t, err)
+ secondEncoded, err := second.EncodeSSZ(nil)
+ require.NoError(t, err)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Eth-Consensus-Version", "gloas")
+ switch {
+ case strings.HasSuffix(r.URL.Path, "/beacon/blocks/101"):
+ _, _ = w.Write(firstEncoded)
+ case strings.HasSuffix(r.URL.Path, "/beacon/blocks/102"):
+ _, _ = w.Write(secondEncoded)
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ downloader := NewForwardBeaconDownloader(context.Background(), nil, &clparams.MainnetBeaconConfig)
+ downloader.SetHighestProcessedSlot(frontier)
+ downloader.SetHTTPFallbackURL(server.URL)
+ downloader.httpPreferred.Store(true)
+ p2pRequests := make(chan cltypes.BeaconBlocksByRangeRequest, 1)
+ downloader.requestBlocksByRange = func(ctx context.Context, start, count uint64) ([]*cltypes.SignedBeaconBlock, string, error) {
+ p2pRequests <- cltypes.BeaconBlocksByRangeRequest{StartSlot: start, Count: count}
+ <-ctx.Done()
+ return nil, "peer-a", ctx.Err()
+ }
+ downloader.SetProcessFunction(func(highest uint64, _ []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) {
+ return highest, nil
+ })
+
+ downloader.RequestMore(t.Context())
+ require.False(t, downloader.httpPreferred.Load())
+ require.Equal(t, frontier, downloader.GetHighestProcessedSlot())
+
+ ctx, cancel := context.WithCancel(t.Context())
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ downloader.RequestMore(ctx)
+ }()
+ request := <-p2pRequests
+ cancel()
+ <-done
+ require.Equal(t, frontier-2, request.StartSlot)
+}
+
+func TestForwardBeaconDownloaderOverlappingEmptyDoesNotSkipGloasLookahead(t *testing.T) {
+ block := makeGloasBlock(101, hash(0xaa), common.Hash{})
+ firstStarted := make(chan struct{})
+ secondReturned := make(chan struct{})
+ releaseFirst := make(chan struct{})
+ var requestCount atomic.Int32
+ downloader := &ForwardBeaconDownloader{
+ beaconCfg: &clparams.MainnetBeaconConfig,
+ requestBlocksByRange: func(_ context.Context, _, _ uint64) ([]*cltypes.SignedBeaconBlock, string, error) {
+ switch requestCount.Add(1) {
+ case 1:
+ close(firstStarted)
+ <-releaseFirst
+ return []*cltypes.SignedBeaconBlock{block}, "block-peer", nil
+ case 2:
+ close(secondReturned)
+ return []*cltypes.SignedBeaconBlock{}, "block-peer", nil
+ default:
+ return nil, "block-peer", errors.New("unexpected request")
+ }
+ },
+ }
+ downloader.SetHighestProcessedSlot(100)
+ downloader.SetProcessFunction(func(highest uint64, _ []*cltypes.SignedBeaconBlock, _ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope) (uint64, error) {
+ return highest, nil
+ })
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ downloader.RequestMore(t.Context())
+ }()
+
+ <-firstStarted
+ <-secondReturned
+ require.Equal(t, uint64(100), downloader.GetHighestProcessedSlot())
+ close(releaseFirst)
+ <-done
+ require.Equal(t, uint64(100), downloader.GetHighestProcessedSlot())
+}
+
+func TestForwardBeaconDownloaderAppliesPendingEmptyAfterOverlappingError(t *testing.T) {
+ firstStarted := make(chan struct{})
+ emptyReturned := make(chan struct{})
+ releaseError := make(chan struct{})
+ var requestCount atomic.Int32
+ downloader := &ForwardBeaconDownloader{
+ beaconCfg: &clparams.MainnetBeaconConfig,
+ requestBlocksByRange: func(ctx context.Context, _, _ uint64) ([]*cltypes.SignedBeaconBlock, string, error) {
+ switch requestCount.Add(1) {
+ case 1:
+ close(firstStarted)
+ <-releaseError
+ return nil, "block-peer", errors.New("request failed")
+ case 2:
+ close(emptyReturned)
+ return []*cltypes.SignedBeaconBlock{}, "block-peer", nil
+ default:
+ <-ctx.Done()
+ return nil, "block-peer", ctx.Err()
+ }
+ },
+ }
+ downloader.SetHighestProcessedSlot(100)
+ ctx, cancel := context.WithCancel(t.Context())
+ done := make(chan struct{})
+ go func() {
+ defer close(done)
+ downloader.RequestMore(ctx)
+ }()
+
+ <-firstStarted
+ <-emptyReturned
+ require.Equal(t, uint64(100), downloader.GetHighestProcessedSlot())
+ close(releaseError)
+ require.Eventually(t, func() bool {
+ return downloader.GetHighestProcessedSlot() == 130
+ }, time.Second, 10*time.Millisecond)
+ cancel()
+ <-done
+}
+
+func TestRetainBlocksBeforeMissingGloasEnvelopeKeepsCompletePrefix(t *testing.T) {
+ first := makeGloasBlock(10, hash(0xaa), common.Hash{})
+ second := makeGloasBlock(11, hash(0xbb), hash(0xaa))
+ lookahead := makeGloasBlock(12, hash(0xcc), hash(0xbb))
+ blocks := []*cltypes.SignedBeaconBlock{first, second}
+ fullRoots := determineFullGloasRoots([]*cltypes.SignedBeaconBlock{first, second, lookahead}, len(blocks))
+ firstRoot, err := first.Block.HashSSZ()
+ require.NoError(t, err)
+
+ retained := retainBlocksBeforeMissingGloasEnvelope(
+ blocks,
+ fullRoots,
+ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope{firstRoot: {}},
+ )
+
+ require.Equal(t, blocks[:1], retained)
+}
diff --git a/cl/phase1/network/services/block_service.go b/cl/phase1/network/services/block_service.go
index 990a11a95ff..d6cd9634935 100644
--- a/cl/phase1/network/services/block_service.go
+++ b/cl/phase1/network/services/block_service.go
@@ -36,6 +36,7 @@ import (
"github.com/erigontech/erigon/cl/phase1/core/state/lru"
"github.com/erigontech/erigon/cl/phase1/execution_client"
"github.com/erigontech/erigon/cl/phase1/forkchoice"
+ "github.com/erigontech/erigon/cl/transition"
"github.com/erigontech/erigon/cl/transition/impl/eth2"
"github.com/erigontech/erigon/cl/utils/eth_clock"
"github.com/erigontech/erigon/common"
@@ -52,9 +53,22 @@ type proposerIndexAndSlot struct {
type blockJob struct {
block *cltypes.SignedBeaconBlock
+ store func(context.Context) error
creationTime time.Time
}
+type blockReservation struct {
+ pending chan struct{}
+ root common.Hash
+ version uint64
+ validators uint64
+}
+
+type seenBlock struct {
+ signedRoot common.Hash
+ replayAllowed bool
+}
+
type blockService struct {
forkchoiceStore forkchoice.ForkChoiceStorage
syncedData *synced_data.SyncedDataManager
@@ -62,7 +76,9 @@ type blockService struct {
beaconCfg *clparams.BeaconChainConfig
// reference: https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md#beacon_block
- seenBlocksCache *lru.Cache[proposerIndexAndSlot, struct{}]
+ seenBlocksCache *lru.Cache[proposerIndexAndSlot, seenBlock]
+ reservations map[proposerIndexAndSlot]*blockReservation
+ seenBlocksMu sync.Mutex
// blocks that should be scheduled for later execution (e.g missing blobs).
emitter *beaconevents.EventEmitter
@@ -81,7 +97,7 @@ func NewBlockService(
beaconCfg *clparams.BeaconChainConfig,
emitter *beaconevents.EventEmitter,
) BlockService {
- seenBlocksCache, err := lru.New[proposerIndexAndSlot, struct{}]("seenblocks", seenBlockCacheSize)
+ seenBlocksCache, err := lru.New[proposerIndexAndSlot, seenBlock]("seenblocks", seenBlockCacheSize)
if err != nil {
panic(err)
}
@@ -91,6 +107,7 @@ func NewBlockService(
ethClock: ethClock,
beaconCfg: beaconCfg,
seenBlocksCache: seenBlocksCache,
+ reservations: make(map[proposerIndexAndSlot]*blockReservation),
emitter: emitter,
db: db,
}
@@ -108,7 +125,7 @@ func (b *blockService) IsMyGossipMessage(name string) bool {
func (b *blockService) DecodeGossipMessage(_ peer.ID, data []byte, version clparams.StateVersion) (*cltypes.SignedBeaconBlock, error) {
obj := cltypes.NewSignedBeaconBlock(b.beaconCfg, version)
- if err := obj.DecodeSSZ(data, int(version)); err != nil {
+ if err := obj.DecodeSSZStrict(data, int(version)); err != nil {
return nil, err
}
return obj, nil
@@ -116,37 +133,256 @@ func (b *blockService) DecodeGossipMessage(_ peer.ID, data []byte, version clpar
// ProcessMessage processes a block message according to https://github.com/ethereum/consensus-specs/blob/dev/specs/phase0/p2p-interface.md#beacon_block
func (b *blockService) ProcessMessage(ctx context.Context, _ *uint64, msg *cltypes.SignedBeaconBlock) error {
+ if msg == nil || msg.Block == nil || msg.Block.Body == nil {
+ return errors.New("missing beacon block")
+ }
log.Trace("Received block via gossip", "slot", msg.Block.Slot)
- blockEpoch := msg.Block.Slot / b.beaconCfg.SlotsPerEpoch
+ // [IGNORE] The block is the first block with valid signature received for the proposer for the slot, signed_beacon_block.message.slot.
+ if err := b.validateFirstGossip(ctx, msg, func() { b.ScheduleBlockForLaterProcessing(msg) }, true); err != nil {
+ return err
+ }
+ b.publishBlockGossipEvent(msg)
+ if err := b.processAndStoreBlock(ctx, msg); err != nil {
+ if errors.Is(err, forkchoice.ErrEIP4844DataNotAvailable) || errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) || errors.Is(err, forkchoice.ErrParentEnvelopePending) {
+ b.ScheduleBlockForLaterProcessing(msg)
+ return nil
+ }
+ return err
+ }
+ return nil
+}
+
+func (b *blockService) ValidateGossip(ctx context.Context, msg *cltypes.SignedBeaconBlock) error {
+ if msg == nil || msg.Block == nil || msg.Block.Body == nil {
+ return errors.New("missing beacon block")
+ }
+ root, err := msg.HashSSZ()
+ if err != nil {
+ return err
+ }
+ key := blockGossipKey(msg)
+ b.seenBlocksMu.Lock()
+ claimed, claimErr := b.claimGossipReplayLocked(key, common.Hash(root))
+ b.seenBlocksMu.Unlock()
+ if claimErr != nil {
+ return claimErr
+ }
+ if claimed {
+ return nil
+ }
+ if err := b.validateGossip(ctx, msg, nil); err != nil {
+ return err
+ }
+ return b.reserveGossipKey(key, common.Hash(root))
+}
+
+func (b *blockService) CommitGossipReservation(msg *cltypes.SignedBeaconBlock) {
+ if msg == nil || msg.Block == nil {
+ return
+ }
+ b.commitGossipKey(blockGossipKey(msg))
+}
+
+func (b *blockService) ReleaseGossipReservation(msg *cltypes.SignedBeaconBlock) {
+ if msg == nil || msg.Block == nil {
+ return
+ }
+ root, err := msg.HashSSZ()
+ if err != nil {
+ return
+ }
+ b.releaseGossipKey(blockGossipKey(msg), common.Hash(root))
+}
+
+func (b *blockService) validateFirstGossip(ctx context.Context, msg *cltypes.SignedBeaconBlock, schedule func(), waitForPending bool) error {
+ key := blockGossipKey(msg)
+ for {
+ if err := ctx.Err(); err != nil {
+ return fmt.Errorf("%w: block validation canceled: %w", ErrIgnore, err)
+ }
+ b.seenBlocksMu.Lock()
+ if b.seenBlocksCache.Contains(key) {
+ b.seenBlocksMu.Unlock()
+ return fmt.Errorf("%w: block already seen for proposer and slot", ErrIgnore)
+ }
+ reservation := b.reservations[key]
+ if reservation != nil && reservation.pending != nil {
+ done := reservation.pending
+ b.seenBlocksMu.Unlock()
+ if !waitForPending {
+ return fmt.Errorf("%w: block reservation pending for proposer and slot", ErrIgnore)
+ }
+ select {
+ case <-ctx.Done():
+ return fmt.Errorf("%w: block reservation pending: %w", ErrIgnore, ctx.Err())
+ case <-done:
+ }
+ b.seenBlocksMu.Lock()
+ committed := b.seenBlocksCache.Contains(key)
+ b.seenBlocksMu.Unlock()
+ if committed {
+ return fmt.Errorf("%w: block already seen for proposer and slot", ErrIgnore)
+ }
+ continue
+ }
+ if reservation == nil {
+ reservation = &blockReservation{}
+ b.reservations[key] = reservation
+ }
+ reservationVersion := reservation.version
+ reservation.validators++
+ b.seenBlocksMu.Unlock()
+
+ validationErr := b.validateGossip(ctx, msg, schedule)
+ var root [32]byte
+ if validationErr == nil && ctx.Err() == nil {
+ root, validationErr = msg.HashSSZ()
+ }
+
+ b.seenBlocksMu.Lock()
+ reservation.validators--
+ if err := ctx.Err(); err != nil {
+ b.cleanupReservationLocked(key, reservation)
+ b.seenBlocksMu.Unlock()
+ return fmt.Errorf("%w: block validation canceled: %w", ErrIgnore, err)
+ }
+ if reservation.version != reservationVersion {
+ b.cleanupReservationLocked(key, reservation)
+ b.seenBlocksMu.Unlock()
+ continue
+ }
+ if validationErr != nil {
+ b.cleanupReservationLocked(key, reservation)
+ b.seenBlocksMu.Unlock()
+ return validationErr
+ }
+ if b.seenBlocksCache.Contains(key) || reservation.pending != nil {
+ b.cleanupReservationLocked(key, reservation)
+ b.seenBlocksMu.Unlock()
+ continue
+ }
+ b.seenBlocksCache.Add(key, seenBlock{signedRoot: common.Hash(root)})
+ b.cleanupReservationLocked(key, reservation)
+ b.seenBlocksMu.Unlock()
+ return nil
+ }
+}
+
+func (b *blockService) reserveGossipKey(key proposerIndexAndSlot, root common.Hash) error {
+ b.seenBlocksMu.Lock()
+ defer b.seenBlocksMu.Unlock()
+ reservation := b.reservations[key]
+ claimed, err := b.claimGossipReplayLocked(key, root)
+ if err != nil {
+ return err
+ }
+ if claimed {
+ return nil
+ }
+ if reservation != nil && reservation.pending != nil {
+ return fmt.Errorf("%w: block already seen for proposer and slot", ErrIgnore)
+ }
+ if reservation == nil {
+ reservation = &blockReservation{}
+ b.reservations[key] = reservation
+ }
+ reservation.pending = make(chan struct{})
+ reservation.root = root
+ reservation.version++
+ return nil
+}
+
+func (b *blockService) commitGossipKey(key proposerIndexAndSlot) {
+ b.seenBlocksMu.Lock()
+ defer b.seenBlocksMu.Unlock()
+ reservation := b.reservations[key]
+ if reservation == nil || reservation.pending == nil {
+ return
+ }
+ done := reservation.pending
+ reservation.pending = nil
+ reservation.version++
+ b.seenBlocksCache.Add(key, seenBlock{signedRoot: reservation.root})
+ close(done)
+ b.cleanupReservationLocked(key, reservation)
+}
+
+func (b *blockService) releaseGossipKey(key proposerIndexAndSlot, root common.Hash) {
+ b.seenBlocksMu.Lock()
+ defer b.seenBlocksMu.Unlock()
+ reservation := b.reservations[key]
+ if reservation == nil || reservation.pending == nil {
+ seen, ok := b.seenBlocksCache.Get(key)
+ if ok && seen.signedRoot == root {
+ seen.replayAllowed = true
+ b.seenBlocksCache.Add(key, seen)
+ }
+ return
+ }
+ done := reservation.pending
+ reservation.pending = nil
+ reservation.version++
+ close(done)
+ b.cleanupReservationLocked(key, reservation)
+}
+
+func (b *blockService) claimGossipReplayLocked(key proposerIndexAndSlot, root common.Hash) (bool, error) {
+ seen, ok := b.seenBlocksCache.Get(key)
+ if !ok {
+ return false, nil
+ }
+ if seen.signedRoot != root || !seen.replayAllowed {
+ return false, fmt.Errorf("%w: block already seen for proposer and slot", ErrIgnore)
+ }
+ seen.replayAllowed = false
+ b.seenBlocksCache.Add(key, seen)
+ return true, nil
+}
+
+func (b *blockService) cleanupReservationLocked(key proposerIndexAndSlot, reservation *blockReservation) {
+ if reservation.pending == nil && reservation.validators == 0 && b.reservations[key] == reservation {
+ delete(b.reservations, key)
+ }
+}
+
+func blockGossipKey(msg *cltypes.SignedBeaconBlock) proposerIndexAndSlot {
+ return proposerIndexAndSlot{proposerIndex: msg.Block.ProposerIndex, slot: msg.Block.Slot}
+}
+
+func (b *blockService) validateGossip(_ context.Context, msg *cltypes.SignedBeaconBlock, schedule func()) error {
+ if msg == nil || msg.Block == nil || msg.Block.Body == nil {
+ return errors.New("missing beacon block")
+ }
if b.syncedData.Syncing() {
return fmt.Errorf("%w: syncing", ErrIgnore)
}
-
currentSlot := b.syncedData.HeadSlot()
-
- // [IGNORE] The block is not from a future slot (with a MAXIMUM_GOSSIP_CLOCK_DISPARITY allowance) -- i.e. validate that
- // signed_beacon_block.message.slot <= current_slot (a client MAY queue future blocks for processing at the appropriate slot).
if currentSlot < msg.Block.Slot && !b.ethClock.IsSlotCurrentSlotWithMaximumClockDisparity(msg.Block.Slot) {
return fmt.Errorf("%w: block is not from a future slot: %d > %d", ErrIgnore, currentSlot, msg.Block.Slot)
}
-
- // [IGNORE] The block is the first block with valid signature received for the proposer for the slot, signed_beacon_block.message.slot.
- seenCacheKey := proposerIndexAndSlot{
- proposerIndex: msg.Block.ProposerIndex,
- slot: msg.Block.Slot,
+ if b.beaconCfg.SlotsPerEpoch == 0 {
+ return errors.New("slots per epoch is zero")
}
- if b.seenBlocksCache.Contains(seenCacheKey) {
- return nil
+ epoch := msg.Block.Slot / b.beaconCfg.SlotsPerEpoch
+ blockVersion := b.beaconCfg.GetCurrentStateVersion(epoch)
+ if blockVersion >= clparams.GloasVersion {
+ if err := validateGloasBlockBodyLimits(b.beaconCfg, msg.Block.Body); err != nil {
+ return err
+ }
}
+ finalizedCheckpoint := b.forkchoiceStore.FinalizedCheckpoint()
if err := b.syncedData.ViewHeadState(func(headState *state.CachingBeaconState) error {
// [IGNORE] The block is from a slot greater than the latest finalized slot -- i.e. validate that signed_beacon_block.message.slot > compute_start_slot_at_epoch(store.finalized_checkpoint.epoch)
// (a client MAY choose to validate and store such blocks for additional purposes -- e.g. slashing detection, archive nodes, etc).
- if blockEpoch <= headState.FinalizedCheckpoint().Epoch {
- return fmt.Errorf("%w: block is not from a slot greater than the latest finalized slot: %d > %d", ErrIgnore, blockEpoch, headState.FinalizedCheckpoint().Epoch)
+ finalizedStartSlot, ok := safeMultiplyUint64(finalizedCheckpoint.Epoch, b.beaconCfg.SlotsPerEpoch)
+ if !ok {
+ return errors.New("finalized checkpoint slot is not representable")
+ }
+ if msg.Block.Slot <= finalizedStartSlot {
+ return fmt.Errorf("%w: block slot %d is not after finalized slot %d", ErrIgnore, msg.Block.Slot, finalizedStartSlot)
}
-
if ok, err := eth2.VerifyBlockSignature(headState, msg); err != nil {
return err
} else if !ok {
@@ -154,8 +390,8 @@ func (b *blockService) ProcessMessage(ctx context.Context, _ *uint64, msg *cltyp
}
return nil
}); err != nil {
- if errors.Is(err, ErrIgnore) {
- b.scheduleBlockForLaterProcessing(msg)
+ if errors.Is(err, ErrIgnore) && schedule != nil {
+ schedule()
}
return err
}
@@ -163,15 +399,66 @@ func (b *blockService) ProcessMessage(ctx context.Context, _ *uint64, msg *cltyp
// [IGNORE] The block's parent (defined by block.parent_root) has been seen (via both gossip and non-gossip sources) (a client MAY queue blocks for processing once the parent block is retrieved).
parentHeader, ok := b.forkchoiceStore.GetHeader(msg.Block.ParentRoot)
if !ok {
- b.scheduleBlockForLaterProcessing(msg)
+ if schedule != nil {
+ schedule()
+ }
return fmt.Errorf("%w: parent header not found: %v", ErrIgnore, msg.Block.ParentRoot)
}
if parentHeader.Slot >= msg.Block.Slot {
return ErrBlockYoungerThanParent
}
+ var gloasBid *cltypes.ExecutionPayloadBid
+ parentIsFull := false
+ if blockVersion >= clparams.GloasVersion {
+ signedBid := msg.Block.Body.GetSignedExecutionPayloadBid()
+ if signedBid == nil || signedBid.Message == nil {
+ return errors.New("missing signed_execution_payload_bid in GLOAS block")
+ }
+ gloasBid = signedBid.Message
+ parentBlock, ok := b.forkchoiceStore.GetBlock(msg.Block.ParentRoot)
+ if !ok || parentBlock == nil || parentBlock.Block == nil || parentBlock.Block.Body == nil {
+ return errors.New("parent block not found")
+ }
+ parentBid := parentBlock.Block.Body.GetSignedExecutionPayloadBid()
+ parentIsFull = parentBid != nil && parentBid.Message != nil && gloasBid.ParentBlockHash == parentBid.Message.BlockHash
+ if parentIsFull {
+ status, seen := b.forkchoiceStore.GetRecentExecutionPayloadStatusByRoot(msg.Block.ParentRoot)
+ if !seen || status != execution_client.PayloadStatusValidated {
+ if schedule != nil {
+ schedule()
+ }
+ return fmt.Errorf("%w: parent payload is not verified", ErrIgnore)
+ }
+ }
+ }
+ finalizedSlot, ok := safeMultiplyUint64(finalizedCheckpoint.Epoch, b.beaconCfg.SlotsPerEpoch)
+ if !ok {
+ return errors.New("finalized checkpoint slot is not representable")
+ }
+ if anchorSlot := b.forkchoiceStore.AnchorSlot(); finalizedSlot < anchorSlot {
+ finalizedSlot = anchorSlot
+ }
+ if b.forkchoiceStore.Ancestor(msg.Block.ParentRoot, finalizedSlot).Root != finalizedCheckpoint.Root {
+ return errors.New("finalized checkpoint is not an ancestor of block")
+ }
+ parentState, err := b.forkchoiceStore.GetStateAtBlockRoot(msg.Block.ParentRoot, true)
+ if err != nil {
+ return fmt.Errorf("get parent block state: %w", err)
+ }
+ if parentState == nil {
+ return errors.New("parent block state not found")
+ }
+ if err := transition.DefaultMachine.ProcessSlots(parentState, msg.Block.Slot); err != nil {
+ return fmt.Errorf("process parent state to block slot: %w", err)
+ }
+ expectedProposer, err := parentState.GetBeaconProposerIndexForSlot(msg.Block.Slot)
+ if err != nil {
+ return fmt.Errorf("get expected proposer: %w", err)
+ }
+ if msg.Block.ProposerIndex != expectedProposer {
+ return fmt.Errorf("block proposer index %d does not match expected proposer %d", msg.Block.ProposerIndex, expectedProposer)
+ }
- epoch := msg.Block.Slot / b.beaconCfg.SlotsPerEpoch
- blockVersion := b.beaconCfg.GetCurrentStateVersion(epoch)
var maxBlobsPerBlock uint64
if blockVersion >= clparams.FuluVersion {
maxBlobsPerBlock = b.beaconCfg.GetBlobParameters(epoch).MaxBlobsPerBlock
@@ -182,49 +469,81 @@ func (b *blockService) ProcessMessage(ctx context.Context, _ *uint64, msg *cltyp
// [Modified in Gloas:EIP7732] KZG commitments and execution payload validations moved from block.body to bid
if blockVersion >= clparams.GloasVersion {
// GLOAS: validate using bid = signed_execution_payload_bid.message
- bid := msg.Block.Body.GetSignedExecutionPayloadBid()
- if bid == nil || bid.Message == nil {
- return errors.New("missing signed_execution_payload_bid in GLOAS block")
- }
-
// [REJECT] The length of KZG commitments is less than or equal to the limitation defined in Consensus Layer
// i.e. validate that len(bid.blob_kzg_commitments) <= get_blob_parameters(get_current_epoch(state)).max_blobs_per_block
- if bid.Message.BlobKzgCommitments.Len() > int(maxBlobsPerBlock) {
+ if gloasBid.BlobKzgCommitments.Len() > int(maxBlobsPerBlock) {
return ErrInvalidCommitmentsCount
}
// [REJECT] The bid's parent (defined by bid.parent_block_root) equals the block's parent (defined by block.parent_root)
- if bid.Message.ParentBlockRoot != msg.Block.ParentRoot {
+ if gloasBid.ParentBlockRoot != msg.Block.ParentRoot {
return errors.New("bid.parent_block_root does not match block.parent_root")
}
- // [IGNORE] The block's parent execution payload (defined by bid.parent_block_hash) has been seen
- // (via gossip or non-gossip sources). A client MAY queue blocks for processing once the parent payload is retrieved.
- // If execution_payload verification of block's execution payload parent by an execution node is complete:
- // [REJECT] The block's execution payload parent (defined by bid.parent_block_hash) passes all validation.
- parentBlockHash := bid.Message.ParentBlockHash
- status, seen := b.forkchoiceStore.GetRecentExecutionPayloadStatus(parentBlockHash)
- if !seen {
- // Parent execution payload not seen yet, queue for later
- b.scheduleBlockForLaterProcessing(msg)
- return fmt.Errorf("%w: parent execution payload not seen: %v", ErrIgnore, parentBlockHash)
- }
- if status == execution_client.PayloadStatusInvalidated {
- return errors.New("parent execution payload is invalid")
+ if !parentIsFull && gloasBid.ParentBlockHash != parentState.GetLatestBlockHash() {
+ return errors.New("bid does not build on the parent's execution head")
}
} else if msg.Block.Body.BlobKzgCommitments != nil && msg.Block.Body.BlobKzgCommitments.Len() > int(maxBlobsPerBlock) {
// Pre-GLOAS: [REJECT] The length of KZG commitments is less than or equal to the limitation defined in Consensus Layer
// i.e. validate that len(body.signed_beacon_block.message.blob_kzg_commitments) <= MAX_BLOBS_PER_BLOCK
return ErrInvalidCommitmentsCount
}
- b.publishBlockGossipEvent(msg)
- // the rest of the validation is done in the forkchoice store
- if err := b.processAndStoreBlock(ctx, msg); err != nil {
- if errors.Is(err, forkchoice.ErrEIP4844DataNotAvailable) || errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) || errors.Is(err, forkchoice.ErrParentEnvelopePending) {
- b.scheduleBlockForLaterProcessing(msg)
- return nil
+ return nil
+}
+
+func validateGloasBlockBodyLimits(cfg *clparams.BeaconChainConfig, body *cltypes.BeaconBody) error {
+ if cfg == nil || body == nil {
+ return errors.New("missing Gloas block body configuration")
+ }
+ if body.ProposerSlashings == nil || body.AttesterSlashings == nil || body.Attestations == nil || body.Deposits == nil ||
+ body.VoluntaryExits == nil || body.ExecutionChanges == nil || body.PayloadAttestations == nil {
+ return errors.New("missing Gloas block body operation list")
+ }
+ checks := []struct {
+ name string
+ count int
+ limit uint64
+ }{
+ {"proposer slashings", body.ProposerSlashings.Len(), cfg.MaxProposerSlashings},
+ {"attester slashings", body.AttesterSlashings.Len(), cfg.MaxAttesterSlashingsElectra},
+ {"attestations", body.Attestations.Len(), cfg.MaxAttestationsElectra},
+ {"voluntary exits", body.VoluntaryExits.Len(), cfg.MaxVoluntaryExits},
+ {"BLS to execution changes", body.ExecutionChanges.Len(), cfg.MaxBlsToExecutionChanges},
+ {"payload attestations", body.PayloadAttestations.Len(), cfg.MaxPayloadAttestations},
+ }
+ if body.Deposits.Len() != 0 {
+ return fmt.Errorf("deposits count %d exceeds Gloas limit 0", body.Deposits.Len())
+ }
+ for _, check := range checks {
+ if uint64(check.count) > check.limit {
+ return fmt.Errorf("%s count %d exceeds limit %d", check.name, check.count, check.limit)
+ }
+ }
+ return validateExecutionRequestsLimits(cfg, body.ParentExecutionRequests)
+}
+
+func validateExecutionRequestsLimits(cfg *clparams.BeaconChainConfig, requests *cltypes.ExecutionRequests) error {
+ if cfg == nil || requests == nil {
+ return errors.New("missing execution requests")
+ }
+ if requests.Deposits == nil || requests.Withdrawals == nil || requests.Consolidations == nil ||
+ requests.BuilderDeposits == nil || requests.BuilderExits == nil {
+ return errors.New("missing execution request list")
+ }
+ checks := []struct {
+ name string
+ count int
+ limit uint64
+ }{
+ {"withdrawal requests", requests.Withdrawals.Len(), cfg.MaxWithdrawalRequestsPerPayload},
+ {"consolidation requests", requests.Consolidations.Len(), cfg.MaxConsolidationRequestsPerPayload},
+ {"builder deposit requests", requests.BuilderDeposits.Len(), cfg.MaxBuilderDepositRequestsPerPayload},
+ {"builder exit requests", requests.BuilderExits.Len(), cfg.MaxBuilderExitRequestsPerPayload},
+ }
+ for _, check := range checks {
+ if uint64(check.count) > check.limit {
+ return fmt.Errorf("%s count %d exceeds limit %d", check.name, check.count, check.limit)
}
- return err
}
return nil
}
@@ -246,8 +565,16 @@ func (b *blockService) publishBlockGossipEvent(block *cltypes.SignedBeaconBlock)
})
}
-// scheduleBlockForLaterProcessing schedules a block for later processing
-func (b *blockService) scheduleBlockForLaterProcessing(block *cltypes.SignedBeaconBlock) {
+// ScheduleBlockForLaterProcessing schedules a block for later processing.
+func (b *blockService) ScheduleBlockForLaterProcessing(block *cltypes.SignedBeaconBlock) {
+ b.scheduleBlockForLaterProcessing(block, nil)
+}
+
+func (b *blockService) SchedulePublishedBlockForLaterProcessing(block *cltypes.SignedBeaconBlock, store func(context.Context) error) {
+ b.scheduleBlockForLaterProcessing(block, store)
+}
+
+func (b *blockService) scheduleBlockForLaterProcessing(block *cltypes.SignedBeaconBlock, store func(context.Context) error) {
// [Modified in Gloas:EIP7732] ExecutionPayload is not in block.body for GLOAS
var blockNum uint64
if block.Block.Body.ExecutionPayload != nil {
@@ -260,10 +587,24 @@ func (b *blockService) scheduleBlockForLaterProcessing(block *cltypes.SignedBeac
return
}
- b.blocksScheduledForLaterExecution.Store(blockRoot, &blockJob{
+ job := &blockJob{
block: block,
+ store: store,
creationTime: time.Now(),
- })
+ }
+ for {
+ existingValue, loaded := b.blocksScheduledForLaterExecution.LoadOrStore(blockRoot, job)
+ if !loaded {
+ return
+ }
+ existing := existingValue.(*blockJob)
+ if store == nil || (existing.store != nil && !job.creationTime.After(existing.creationTime)) {
+ return
+ }
+ if b.blocksScheduledForLaterExecution.CompareAndSwap(blockRoot, existing, job) {
+ return
+ }
+ }
}
// processAndStoreBlock processes and stores a block
@@ -273,9 +614,7 @@ func (b *blockService) processAndStoreBlock(ctx context.Context, block *cltypes.
return err
}
- if _, ok := b.forkchoiceStore.GetHeader(blockRoot); ok {
- return nil
- }
+ _, headerExists := b.forkchoiceStore.GetHeader(blockRoot)
if err := b.db.Update(ctx, func(tx kv.RwTx) error {
return beacon_indicies.WriteBeaconBlockAndIndicies(ctx, tx, block, false)
@@ -283,10 +622,12 @@ func (b *blockService) processAndStoreBlock(ctx context.Context, block *cltypes.
return err
}
- if err := b.forkchoiceStore.OnBlock(ctx, block, true, true, true); err != nil {
- return err
+ if !headerExists {
+ if err := b.forkchoiceStore.OnBlock(ctx, block, true, true, true); err != nil {
+ return err
+ }
+ go b.importBlockOperations(block)
}
- go b.importBlockOperations(block)
if err := b.db.Update(ctx, func(tx kv.RwTx) error {
return beacon_indicies.WriteHighestFinalized(tx, b.forkchoiceStore.FinalizedSlot())
}); err != nil {
@@ -331,18 +672,24 @@ func (b *blockService) loop(ctx context.Context) {
case <-ticker.C:
}
b.blocksScheduledForLaterExecution.Range(func(key, value any) bool {
- blockJob := value.(*blockJob)
- // check if it has expired
- if time.Since(blockJob.creationTime) > blockJobExpiry {
- b.blocksScheduledForLaterExecution.Delete(key.([32]byte))
- return true
- }
- if err := b.processAndStoreBlock(ctx, blockJob.block); err != nil {
- log.Trace("Failed to process and store block", "block", blockJob.block, "error", err)
- return true
- }
- b.blocksScheduledForLaterExecution.Delete(key.([32]byte))
+ b.processScheduledBlock(ctx, key.([32]byte), value.(*blockJob), time.Now())
return true
})
}
}
+
+func (b *blockService) processScheduledBlock(ctx context.Context, key [32]byte, job *blockJob, now time.Time) {
+ if now.Sub(job.creationTime) > blockJobExpiry {
+ b.blocksScheduledForLaterExecution.CompareAndDelete(key, job)
+ return
+ }
+ store := job.store
+ if store == nil {
+ store = func(ctx context.Context) error { return b.processAndStoreBlock(ctx, job.block) }
+ }
+ if err := store(ctx); err != nil {
+ log.Trace("Failed to process and store block", "block", job.block, "error", err)
+ return
+ }
+ b.blocksScheduledForLaterExecution.CompareAndDelete(key, job)
+}
diff --git a/cl/phase1/network/services/block_service_test.go b/cl/phase1/network/services/block_service_test.go
index 27e61ededdf..734e34a0b29 100644
--- a/cl/phase1/network/services/block_service_test.go
+++ b/cl/phase1/network/services/block_service_test.go
@@ -19,8 +19,11 @@ package services
import (
"bytes"
"context"
+ "encoding/binary"
"errors"
+ "sync"
"testing"
+ "time"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
@@ -30,11 +33,20 @@ import (
"github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/cltypes"
"github.com/erigontech/erigon/cl/cltypes/solid"
+ "github.com/erigontech/erigon/cl/fork"
+ "github.com/erigontech/erigon/cl/persistence/beacon_indicies"
+ "github.com/erigontech/erigon/cl/phase1/core/state"
+ "github.com/erigontech/erigon/cl/phase1/execution_client"
"github.com/erigontech/erigon/cl/phase1/forkchoice"
"github.com/erigontech/erigon/cl/phase1/forkchoice/mock_services"
+ "github.com/erigontech/erigon/cl/transition"
+ "github.com/erigontech/erigon/cl/utils/bls"
"github.com/erigontech/erigon/cl/utils/eth_clock"
+ "github.com/erigontech/erigon/common"
"github.com/erigontech/erigon/common/log/v3"
+ "github.com/erigontech/erigon/db/kv"
"github.com/erigontech/erigon/db/kv/dbcfg"
+ "github.com/erigontech/erigon/db/kv/dbutils"
"github.com/erigontech/erigon/db/kv/mdbx/mdbxtest"
)
@@ -43,6 +55,19 @@ type attesterSlashingErrorStore struct {
err error
}
+type failFirstUpdateDB struct {
+ kv.RwDB
+ failed bool
+}
+
+func (db *failFirstUpdateDB) Update(ctx context.Context, f func(kv.RwTx) error) error {
+ if !db.failed {
+ db.failed = true
+ return errors.New("database unavailable")
+ }
+ return db.RwDB.Update(ctx, f)
+}
+
func (s attesterSlashingErrorStore) OnAttesterSlashing(*cltypes.AttesterSlashing, bool) error {
return s.err
}
@@ -153,17 +178,645 @@ func TestBlockServiceSuccess(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
+ blocks, pre, post := tests.GetBellatrixRandom()
+ parentState, err := pre.Copy()
+ require.NoError(t, err)
+ require.NoError(t, transition.TransitionState(parentState, blocks[0], nil, false))
+
+ service, syncedData, ethClock, fcu := setupBlockService(t, ctrl)
+ require.NoError(t, syncedData.OnHeadState(post))
+ ethClock.EXPECT().GetCurrentSlot().Return(uint64(0)).AnyTimes()
+ ethClock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(gomock.Any()).Return(true).AnyTimes()
+ fcu.FinalizedCheckpointVal = post.FinalizedCheckpoint()
+ fcu.Headers[blocks[1].Block.ParentRoot] = blocks[0].SignedBeaconBlockHeader().Header.Copy()
+ fcu.StateAtBlockRootVal[blocks[1].Block.ParentRoot] = parentState
+ finalizedSlot := post.FinalizedCheckpoint().Epoch * post.BeaconConfig().SlotsPerEpoch
+ fcu.Ancestors[finalizedSlot] = forkchoice.ForkChoiceNode{Root: post.FinalizedCheckpoint().Root}
+ blocks[1].Block.Body.BlobKzgCommitments = solid.NewStaticListSSZ[*cltypes.KZGCommitment](100, 48)
+
+ require.NoError(t, service.ProcessMessage(context.Background(), nil, blocks[1]))
+ key := proposerIndexAndSlot{
+ proposerIndex: blocks[1].Block.ProposerIndex,
+ slot: blocks[1].Block.Slot,
+ }
+ seen, ok := service.(*blockService).seenBlocksCache.Get(key)
+ require.True(t, ok)
+ signedRoot, err := blocks[1].HashSSZ()
+ require.NoError(t, err)
+ require.Equal(t, common.Hash(signedRoot), seen.signedRoot)
+}
+
+func TestBlockServiceGossipRejectsBlockOutsideFinalizedChain(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
blocks, _, post := tests.GetBellatrixRandom()
+ blockService, syncedData, ethClock, fcu := setupBlockService(t, ctrl)
+ require.NoError(t, syncedData.OnHeadState(post))
+ ethClock.EXPECT().GetCurrentSlot().Return(uint64(0)).AnyTimes()
+ ethClock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(gomock.Any()).Return(true).AnyTimes()
+ fcu.FinalizedCheckpointVal = post.FinalizedCheckpoint()
+ fcu.Headers[blocks[1].Block.ParentRoot] = blocks[0].SignedBeaconBlockHeader().Header.Copy()
+ finalizedSlot := post.FinalizedCheckpoint().Epoch * post.BeaconConfig().SlotsPerEpoch
+ fcu.Ancestors[finalizedSlot] = forkchoice.ForkChoiceNode{Root: common.Hash{0xff}}
+
+ err := blockService.ValidateGossip(t.Context(), blocks[1])
+ require.ErrorContains(t, err, "finalized checkpoint is not an ancestor")
+}
+func TestBlockServiceGossipUsesCheckpointSyncAnchorForFinalizedAncestor(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ blocks, pre, post := tests.GetBellatrixRandom()
+ parentState, err := pre.Copy()
+ require.NoError(t, err)
+ require.NoError(t, transition.TransitionState(parentState, blocks[0], nil, false))
blockService, syncedData, ethClock, fcu := setupBlockService(t, ctrl)
require.NoError(t, syncedData.OnHeadState(post))
ethClock.EXPECT().GetCurrentSlot().Return(uint64(0)).AnyTimes()
ethClock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(gomock.Any()).Return(true).AnyTimes()
fcu.FinalizedCheckpointVal = post.FinalizedCheckpoint()
fcu.Headers[blocks[1].Block.ParentRoot] = blocks[0].SignedBeaconBlockHeader().Header.Copy()
- blocks[1].Block.Body.BlobKzgCommitments = solid.NewStaticListSSZ[*cltypes.KZGCommitment](100, 48)
+ fcu.StateAtBlockRootVal[blocks[1].Block.ParentRoot] = parentState
+ fcu.AnchorSlotVal = blocks[0].Block.Slot
+ fcu.Ancestors[fcu.AnchorSlotVal] = forkchoice.ForkChoiceNode{Root: post.FinalizedCheckpoint().Root}
+
+ require.NoError(t, blockService.ValidateGossip(t.Context(), blocks[1]))
+}
+
+func TestBlockServiceGossipUsesForkChoiceFinalizedCheckpointAtGenesis(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ blocks, pre, post := tests.GetBellatrixRandom()
+ parentState, err := pre.Copy()
+ require.NoError(t, err)
+ require.NoError(t, transition.TransitionState(parentState, blocks[0], nil, false))
+ headState, err := post.Copy()
+ require.NoError(t, err)
+ headState.SetFinalizedCheckpoint(solid.Checkpoint{})
- require.NoError(t, blockService.ProcessMessage(context.Background(), nil, blocks[1]))
+ blockService, syncedData, ethClock, fcu := setupBlockService(t, ctrl)
+ require.NoError(t, syncedData.OnHeadState(headState))
+ ethClock.EXPECT().GetCurrentSlot().Return(uint64(0)).AnyTimes()
+ ethClock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(gomock.Any()).Return(true).AnyTimes()
+ anchorRoot := blocks[1].Block.ParentRoot
+ fcu.FinalizedCheckpointVal = solid.Checkpoint{Root: anchorRoot}
+ fcu.Headers[anchorRoot] = blocks[0].SignedBeaconBlockHeader().Header.Copy()
+ fcu.StateAtBlockRootVal[anchorRoot] = parentState
+ fcu.AnchorRootVal = anchorRoot
+ fcu.AnchorSlotVal = blocks[0].Block.Slot
+ fcu.Ancestors[fcu.AnchorSlotVal] = forkchoice.ForkChoiceNode{Root: anchorRoot}
+
+ require.NoError(t, blockService.ValidateGossip(t.Context(), blocks[1]))
+}
+
+func TestBlockServiceGossipRejectsUnexpectedProposer(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ blocks, pre, post := tests.GetBellatrixRandom()
+ parentState, err := pre.Copy()
+ require.NoError(t, err)
+ require.NoError(t, transition.TransitionState(parentState, blocks[0], nil, false))
+ targetEpoch := blocks[1].Block.Slot / parentState.BeaconConfig().SlotsPerEpoch
+ mixPosition := (targetEpoch + parentState.BeaconConfig().EpochsPerHistoricalVector - parentState.BeaconConfig().MinSeedLookahead - 1) % parentState.BeaconConfig().EpochsPerHistoricalVector
+ foundUnexpectedProposer := false
+ for nonce := 1; nonce <= 255; nonce++ {
+ require.NoError(t, parentState.SetRandaoMixAt(int(mixPosition), common.Hash{byte(nonce)}))
+ expected, proposerErr := parentState.GetBeaconProposerIndexForSlot(blocks[1].Block.Slot)
+ require.NoError(t, proposerErr)
+ if expected != blocks[1].Block.ProposerIndex {
+ foundUnexpectedProposer = true
+ break
+ }
+ }
+ require.True(t, foundUnexpectedProposer)
+
+ blockService, syncedData, ethClock, fcu := setupBlockService(t, ctrl)
+ require.NoError(t, syncedData.OnHeadState(post))
+ ethClock.EXPECT().GetCurrentSlot().Return(uint64(0)).AnyTimes()
+ ethClock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(gomock.Any()).Return(true).AnyTimes()
+ fcu.FinalizedCheckpointVal = post.FinalizedCheckpoint()
+ fcu.Headers[blocks[1].Block.ParentRoot] = blocks[0].SignedBeaconBlockHeader().Header.Copy()
+ fcu.Blocks[blocks[1].Block.ParentRoot] = blocks[0]
+ fcu.StateAtBlockRootVal[blocks[1].Block.ParentRoot] = parentState
+ finalizedSlot := post.FinalizedCheckpoint().Epoch * post.BeaconConfig().SlotsPerEpoch
+ fcu.Ancestors[finalizedSlot] = forkchoice.ForkChoiceNode{Root: post.FinalizedCheckpoint().Root}
+
+ err = blockService.ValidateGossip(t.Context(), blocks[1])
+ require.ErrorContains(t, err, "does not match expected proposer")
+}
+
+func TestBlockServiceGossipWaitsForFullParentPayloadVerification(t *testing.T) {
+ service, child, fcu, parentRoot, parentBlockHash := newGloasGossipValidationFixture(t, nil)
+ fcu.ExecutionPayloadStatusMap[parentBlockHash] = execution_client.PayloadStatusValidated
+
+ err := service.ValidateGossip(t.Context(), child)
+ require.ErrorContains(t, err, "parent payload is not verified")
+ require.NotContains(t, fcu.PayloadStatusByRootMap, parentRoot)
+}
+
+func TestBlockServiceGossipAcceptsVerifiedFullParentPayload(t *testing.T) {
+ service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil)
+ fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated
+ require.NoError(t, service.ValidateGossip(t.Context(), child))
+}
+
+func TestBlockServiceGossipFirstValidReservationIsAtomic(t *testing.T) {
+ service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil)
+ fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated
+ errs := make(chan error, 2)
+ var wg sync.WaitGroup
+ for range 2 {
+ wg.Go(func() { errs <- service.ValidateGossip(t.Context(), child) })
+ }
+ wg.Wait()
+ close(errs)
+ accepted := 0
+ ignored := 0
+ for err := range errs {
+ if err == nil {
+ accepted++
+ } else if errors.Is(err, ErrIgnore) {
+ ignored++
+ }
+ }
+ require.Equal(t, 1, accepted)
+ require.Equal(t, 1, ignored)
+}
+
+func TestBlockServiceGossipReservationCanBeReleased(t *testing.T) {
+ service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil)
+ fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated
+ require.NoError(t, service.ValidateGossip(t.Context(), child))
+ service.ReleaseGossipReservation(child)
+ require.NoError(t, service.ValidateGossip(t.Context(), child))
+}
+
+func TestBlockServiceCommittedReservationAllowsExactRESTReplayOnly(t *testing.T) {
+ service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil)
+ fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated
+ require.NoError(t, service.ValidateGossip(t.Context(), child))
+ service.CommitGossipReservation(child)
+ require.ErrorIs(t, service.ValidateGossip(t.Context(), child), ErrIgnore)
+ service.ReleaseGossipReservation(child)
+
+ child.Block.StateRoot[0] ^= 1
+ err := service.ValidateGossip(t.Context(), child)
+ require.ErrorIs(t, err, ErrIgnore)
+ require.ErrorContains(t, err, "already seen")
+ child.Block.StateRoot[0] ^= 1
+
+ require.NoError(t, service.ValidateGossip(t.Context(), child))
+ require.ErrorIs(t, service.ValidateGossip(t.Context(), child), ErrIgnore)
+ child.Signature[0] ^= 1
+ require.ErrorIs(t, service.ValidateGossip(t.Context(), child), ErrIgnore)
+}
+
+func TestBlockServiceExactRESTReplayClaimIsAtomic(t *testing.T) {
+ service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil)
+ fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated
+ require.NoError(t, service.ValidateGossip(t.Context(), child))
+ service.CommitGossipReservation(child)
+ service.ReleaseGossipReservation(child)
+
+ errs := make(chan error, 2)
+ var wg sync.WaitGroup
+ for range 2 {
+ wg.Go(func() { errs <- service.ValidateGossip(t.Context(), child) })
+ }
+ wg.Wait()
+ close(errs)
+ accepted := 0
+ ignored := 0
+ for err := range errs {
+ if err == nil {
+ accepted++
+ } else if errors.Is(err, ErrIgnore) {
+ ignored++
+ }
+ }
+ require.Equal(t, 1, accepted)
+ require.Equal(t, 1, ignored)
+}
+
+func TestBlockServiceFailedExactRESTReplayRestoresClaim(t *testing.T) {
+ service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil)
+ fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated
+ require.NoError(t, service.ValidateGossip(t.Context(), child))
+ service.CommitGossipReservation(child)
+ service.ReleaseGossipReservation(child)
+ require.NoError(t, service.ValidateGossip(t.Context(), child))
+
+ service.ReleaseGossipReservation(child)
+ require.NoError(t, service.ValidateGossip(t.Context(), child))
+}
+
+func TestBlockServiceValidateGossipRejectsMissingBodyBeforeHashing(t *testing.T) {
+ service := &blockService{}
+ block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion)
+ block.Block.Body = nil
+ require.ErrorContains(t, service.ValidateGossip(t.Context(), block), "missing beacon block")
+}
+
+func TestBlockServiceP2PDuplicateIsIgnoredBeforeHashing(t *testing.T) {
+ service, child, _, _, _ := newGloasGossipValidationFixture(t, nil)
+ child.Block.Body.Eth1Data = nil
+ key := blockGossipKey(child)
+ service.(*blockService).seenBlocksCache.Add(key, seenBlock{})
+
+ err := service.ProcessMessage(t.Context(), nil, child)
+
+ require.ErrorIs(t, err, ErrIgnore)
+ require.Nil(t, child.Block.Body.Eth1Data)
+}
+
+func TestScheduledBlockRepairsDatabaseWhenHeaderAlreadyExists(t *testing.T) {
+ underlying := mdbxtest.NewTestDB(t, dbcfg.ChainDB)
+ db := &failFirstUpdateDB{RwDB: underlying}
+ fcu := mock_services.NewForkChoiceStorageMock(t)
+ block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.Phase0Version)
+ root, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ fcu.Headers[root] = block.SignedBeaconBlockHeader().Header.Copy()
+ service := &blockService{db: db, forkchoiceStore: fcu}
+
+ service.ScheduleBlockForLaterProcessing(block)
+ jobValue, ok := service.blocksScheduledForLaterExecution.Load(root)
+ require.True(t, ok)
+ job := jobValue.(*blockJob)
+ service.processScheduledBlock(t.Context(), root, job, job.creationTime)
+ require.True(t, db.failed)
+ _, ok = service.blocksScheduledForLaterExecution.Load(root)
+ require.True(t, ok)
+ service.processScheduledBlock(t.Context(), root, job, job.creationTime)
+ _, ok = service.blocksScheduledForLaterExecution.Load(root)
+ require.False(t, ok)
+
+ require.NoError(t, underlying.View(t.Context(), func(tx kv.Tx) error {
+ body, err := tx.GetOne(kv.BeaconBlocks, dbutils.BlockBodyKey(block.Block.Slot, root))
+ require.NoError(t, err)
+ require.NotEmpty(t, body)
+ return nil
+ }))
+}
+
+func TestPublishedBlockJobRetainsFullStoreUntilSuccess(t *testing.T) {
+ block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion)
+ root, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ db := mdbxtest.NewTestDB(t, dbcfg.ChainDB)
+ service := &blockService{}
+ attempts := 0
+ storedSidecars := false
+ imported := false
+ service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error {
+ attempts++
+ if attempts == 1 {
+ return errors.New("sidecar storage unavailable")
+ }
+ storedSidecars = true
+ imported = true
+ return db.Update(t.Context(), func(tx kv.RwTx) error {
+ return beacon_indicies.WriteBeaconBlockAndIndicies(t.Context(), tx, block, false)
+ })
+ })
+ jobValue, ok := service.blocksScheduledForLaterExecution.Load(root)
+ require.True(t, ok)
+ job := jobValue.(*blockJob)
+ service.processScheduledBlock(t.Context(), root, job, job.creationTime)
+ _, ok = service.blocksScheduledForLaterExecution.Load(root)
+ require.True(t, ok)
+ require.False(t, storedSidecars)
+ service.processScheduledBlock(t.Context(), root, job, job.creationTime)
+ _, ok = service.blocksScheduledForLaterExecution.Load(root)
+ require.False(t, ok)
+ require.True(t, storedSidecars)
+ require.True(t, imported)
+ require.NoError(t, db.View(t.Context(), func(tx kv.Tx) error {
+ body, err := tx.GetOne(kv.BeaconBlocks, dbutils.BlockBodyKey(block.Block.Slot, root))
+ require.NoError(t, err)
+ require.NotEmpty(t, body)
+ return nil
+ }))
+}
+
+func TestPublishedBlockJobUpgradesBlockOnlyRecovery(t *testing.T) {
+ block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion)
+ root, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ service := &blockService{}
+ service.ScheduleBlockForLaterProcessing(block)
+ service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { return nil })
+ jobValue, ok := service.blocksScheduledForLaterExecution.Load(root)
+ require.True(t, ok)
+ require.NotNil(t, jobValue.(*blockJob).store)
+}
+
+func TestPublishedBlockJobIsNotDowngradedByBlockOnlyRecovery(t *testing.T) {
+ block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion)
+ root, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ service := &blockService{}
+ service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { return nil })
+ fullValue, ok := service.blocksScheduledForLaterExecution.Load(root)
+ require.True(t, ok)
+ service.ScheduleBlockForLaterProcessing(block)
+ currentValue, ok := service.blocksScheduledForLaterExecution.Load(root)
+ require.True(t, ok)
+ require.Same(t, fullValue, currentValue)
+}
+
+func TestOlderPublishedBlockJobDoesNotReplaceNewerFullStore(t *testing.T) {
+ block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion)
+ root, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ newer := &blockJob{
+ block: block,
+ store: func(context.Context) error { return nil },
+ creationTime: time.Now().Add(time.Minute),
+ }
+ service := &blockService{}
+ service.blocksScheduledForLaterExecution.Store(root, newer)
+
+ service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error {
+ return errors.New("older store should not replace newer store")
+ })
+
+ currentValue, ok := service.blocksScheduledForLaterExecution.Load(root)
+ require.True(t, ok)
+ require.Same(t, newer, currentValue)
+}
+
+func TestPublishedBlockUpgradeSurvivesStaleBlockOnlyWorker(t *testing.T) {
+ block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion)
+ root, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ db := mdbxtest.NewTestDB(t, dbcfg.ChainDB)
+ fcu := mock_services.NewForkChoiceStorageMock(t)
+ fcu.Headers[root] = block.SignedBeaconBlockHeader().Header.Copy()
+ service := &blockService{db: db, forkchoiceStore: fcu}
+ service.ScheduleBlockForLaterProcessing(block)
+ staleValue, ok := service.blocksScheduledForLaterExecution.Load(root)
+ require.True(t, ok)
+ staleJob := staleValue.(*blockJob)
+ service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error { return nil })
+ service.processScheduledBlock(t.Context(), root, staleJob, staleJob.creationTime)
+ currentValue, ok := service.blocksScheduledForLaterExecution.Load(root)
+ require.True(t, ok)
+ require.NotSame(t, staleJob, currentValue)
+ require.NotNil(t, currentValue.(*blockJob).store)
+}
+
+func TestPublishedBlockRefreshSurvivesStaleFullStoreWorker(t *testing.T) {
+ block := cltypes.NewSignedBeaconBlock(&clparams.MainnetBeaconConfig, clparams.DenebVersion)
+ root, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ service := &blockService{}
+ service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error {
+ return errors.New("stale store should not run")
+ })
+ staleValue, ok := service.blocksScheduledForLaterExecution.Load(root)
+ require.True(t, ok)
+ staleJob := staleValue.(*blockJob)
+ freshStoreCalls := 0
+ service.SchedulePublishedBlockForLaterProcessing(block, func(context.Context) error {
+ freshStoreCalls++
+ return nil
+ })
+ staleJob.creationTime = time.Now().Add(-blockJobExpiry - time.Second)
+ service.processScheduledBlock(t.Context(), root, staleJob, time.Now())
+
+ freshValue, ok := service.blocksScheduledForLaterExecution.Load(root)
+ require.True(t, ok)
+ require.NotSame(t, staleJob, freshValue)
+ service.processScheduledBlock(t.Context(), root, freshValue.(*blockJob), time.Now())
+ require.Equal(t, 1, freshStoreCalls)
+ _, ok = service.blocksScheduledForLaterExecution.Load(root)
+ require.False(t, ok)
+}
+
+func TestBlockServicePendingGossipReservationHandsOffToP2P(t *testing.T) {
+ service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil)
+ fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated
+ require.NoError(t, service.ValidateGossip(t.Context(), child))
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- service.(*blockService).validateFirstGossip(t.Context(), child, nil, true)
+ }()
+ select {
+ case err := <-errCh:
+ t.Fatalf("P2P validation returned before REST reservation resolved: %v", err)
+ case <-time.After(20 * time.Millisecond):
+ }
+ service.ReleaseGossipReservation(child)
+ require.NoError(t, <-errCh)
+}
+
+func TestBlockServiceCommittedGossipReservationRejectsWaitingP2P(t *testing.T) {
+ service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil)
+ fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated
+ require.NoError(t, service.ValidateGossip(t.Context(), child))
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- service.(*blockService).validateFirstGossip(t.Context(), child, nil, true)
+ }()
+ select {
+ case err := <-errCh:
+ t.Fatalf("P2P validation returned before REST reservation resolved: %v", err)
+ case <-time.After(20 * time.Millisecond):
+ }
+ service.CommitGossipReservation(child)
+ require.ErrorIs(t, <-errCh, ErrIgnore)
+}
+
+func TestBlockServiceRevalidatesP2PAfterReservationRelease(t *testing.T) {
+ service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil)
+ fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated
+ require.NoError(t, service.ValidateGossip(t.Context(), child))
+ type result struct {
+ err error
+ scheduled bool
+ }
+ resultCh := make(chan result, 1)
+ go func() {
+ scheduled := false
+ err := service.(*blockService).validateFirstGossip(t.Context(), child, func() { scheduled = true }, true)
+ resultCh <- result{err: err, scheduled: scheduled}
+ }()
+ select {
+ case got := <-resultCh:
+ t.Fatalf("P2P validation returned before REST reservation resolved: %v", got.err)
+ case <-time.After(20 * time.Millisecond):
+ }
+ fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusInvalidated
+ service.ReleaseGossipReservation(child)
+ got := <-resultCh
+ require.ErrorIs(t, got.err, ErrIgnore)
+ require.True(t, got.scheduled)
+}
+
+func TestBlockServiceUnrelatedReservationDoesNotRevalidateP2P(t *testing.T) {
+ service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil)
+ fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated
+ parentState := fcu.StateAtBlockRootVal[parentRoot]
+ validationEntered := make(chan struct{})
+ finishValidation := make(chan struct{})
+ validationCalls := 0
+ fcu.GetStateAtBlockRootFn = func(root common.Hash, alwaysCopy bool) (*state.CachingBeaconState, error) {
+ require.Equal(t, parentRoot, root)
+ require.True(t, alwaysCopy)
+ validationCalls++
+ if validationCalls == 1 {
+ close(validationEntered)
+ <-finishValidation
+ }
+ return parentState.Copy()
+ }
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- service.(*blockService).validateFirstGossip(t.Context(), child, nil, true)
+ }()
+ <-validationEntered
+ otherKey := proposerIndexAndSlot{proposerIndex: child.Block.ProposerIndex + 1, slot: child.Block.Slot}
+ require.NoError(t, service.(*blockService).reserveGossipKey(otherKey, common.Hash{1}))
+ service.(*blockService).releaseGossipKey(otherKey, common.Hash{1})
+ close(finishValidation)
+ require.NoError(t, <-errCh)
+ require.Equal(t, 1, validationCalls)
+}
+
+func TestBlockServiceCanceledHandoffDoesNotClaimSeen(t *testing.T) {
+ service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil)
+ fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusValidated
+ parentState := fcu.StateAtBlockRootVal[parentRoot]
+ revalidationEntered := make(chan struct{})
+ finishRevalidation := make(chan struct{})
+ require.NoError(t, service.ValidateGossip(t.Context(), child))
+ fcu.GetStateAtBlockRootFn = func(root common.Hash, alwaysCopy bool) (*state.CachingBeaconState, error) {
+ close(revalidationEntered)
+ <-finishRevalidation
+ return parentState.Copy()
+ }
+ ctx, cancel := context.WithCancel(t.Context())
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- service.(*blockService).validateFirstGossip(ctx, child, nil, true)
+ }()
+ service.ReleaseGossipReservation(child)
+ <-revalidationEntered
+ cancel()
+ close(finishRevalidation)
+ require.ErrorIs(t, <-errCh, ErrIgnore)
+ key := blockGossipKey(child)
+ blockService := service.(*blockService)
+ blockService.seenBlocksMu.Lock()
+ require.False(t, blockService.seenBlocksCache.Contains(key))
+ require.NotContains(t, blockService.reservations, key)
+ blockService.seenBlocksMu.Unlock()
+ fcu.GetStateAtBlockRootFn = func(common.Hash, bool) (*state.CachingBeaconState, error) {
+ return parentState.Copy()
+ }
+ require.NoError(t, blockService.validateFirstGossip(t.Context(), child, nil, true))
+}
+
+func TestBlockServiceGossipIgnoresInvalidatedFullParentPayload(t *testing.T) {
+ service, child, fcu, parentRoot, _ := newGloasGossipValidationFixture(t, nil)
+ fcu.PayloadStatusByRootMap[parentRoot] = execution_client.PayloadStatusInvalidated
+ scheduled := false
+ err := service.(*blockService).validateFirstGossip(t.Context(), child, func() { scheduled = true }, false)
+ require.ErrorIs(t, err, ErrIgnore)
+ require.True(t, scheduled)
+}
+
+func TestBlockServiceGossipRejectsWrongEmptyParentExecutionHead(t *testing.T) {
+ service, child, _, _, _ := newGloasGossipValidationFixture(t, func(common.Hash, common.Hash) common.Hash {
+ return common.Hash{0x99}
+ })
+ require.ErrorContains(t, service.ValidateGossip(t.Context(), child), "does not build on the parent's execution head")
+}
+
+func TestBlockServiceGossipAcceptsEmptyParentExecutionHead(t *testing.T) {
+ service, child, _, _, _ := newGloasGossipValidationFixture(t, func(parentExecutionHead, _ common.Hash) common.Hash {
+ return parentExecutionHead
+ })
+ require.NoError(t, service.ValidateGossip(t.Context(), child))
+}
+
+func newGloasGossipValidationFixture(t *testing.T, childParentHash func(parentExecutionHead, parentBlockHash common.Hash) common.Hash) (BlockService, *cltypes.SignedBeaconBlock, *mock_services.ForkChoiceStorageMock, common.Hash, common.Hash) {
+ t.Helper()
+ ctrl := gomock.NewController(t)
+ cfg := clparams.MainnetBeaconConfig
+ cfg.AltairForkEpoch = 0
+ cfg.BellatrixForkEpoch = 0
+ cfg.CapellaForkEpoch = 0
+ cfg.DenebForkEpoch = 0
+ cfg.ElectraForkEpoch = 0
+ cfg.FuluForkEpoch = 0
+ cfg.GloasForkEpoch = 0
+ parentSlot := cfg.SlotsPerEpoch
+ childSlot := parentSlot + 1
+
+ privateKey, err := bls.GenerateKey()
+ require.NoError(t, err)
+ validator := solid.NewValidator()
+ var pubkey [48]byte
+ copy(pubkey[:], bls.CompressPublicKey(privateKey.PublicKey()))
+ validator.SetPublicKey(pubkey)
+ validator.SetActivationEpoch(0)
+ validator.SetExitEpoch(cfg.FarFutureEpoch)
+ validator.SetEffectiveBalance(cfg.MaxEffectiveBalance)
+ parentState := state.New(&cfg)
+ parentState.SetVersion(clparams.GloasVersion)
+ require.NoError(t, parentState.SetSlot(parentSlot))
+ require.NoError(t, parentState.AddValidator(validator, cfg.MaxEffectiveBalance))
+ parentState.SetProposerLookahead(solid.NewUint64VectorSSZ(int((cfg.MinSeedLookahead + 1) * cfg.SlotsPerEpoch)))
+ parentExecutionHead := common.Hash{0x11}
+ parentState.SetLatestBlockHash(parentExecutionHead)
+
+ parentBlockHash := common.Hash{0x22}
+ parent := cltypes.NewSignedBeaconBlock(&cfg, clparams.GloasVersion)
+ parent.Block.Slot = parentSlot
+ parent.Block.ProposerIndex = 0
+ parent.Block.Body.SignedExecutionPayloadBid = &cltypes.SignedExecutionPayloadBid{Message: &cltypes.ExecutionPayloadBid{
+ ParentBlockHash: parentExecutionHead,
+ BlockHash: parentBlockHash,
+ }}
+ parentRoot, err := parent.Block.HashSSZ()
+ require.NoError(t, err)
+
+ child := cltypes.NewSignedBeaconBlock(&cfg, clparams.GloasVersion)
+ child.Block.Slot = childSlot
+ child.Block.ProposerIndex = 0
+ child.Block.ParentRoot = parentRoot
+ selectedParentHash := parentBlockHash
+ if childParentHash != nil {
+ selectedParentHash = childParentHash(parentExecutionHead, parentBlockHash)
+ }
+ child.Block.Body.SignedExecutionPayloadBid = &cltypes.SignedExecutionPayloadBid{Message: &cltypes.ExecutionPayloadBid{
+ ParentBlockHash: selectedParentHash,
+ ParentBlockRoot: parentRoot,
+ }}
+ domain, err := parentState.GetDomain(cfg.DomainBeaconProposer, childSlot/cfg.SlotsPerEpoch)
+ require.NoError(t, err)
+ signingRoot, err := fork.ComputeSigningRoot(child.Block, domain)
+ require.NoError(t, err)
+ copy(child.Signature[:], privateKey.Sign(signingRoot[:]).Bytes())
+
+ db := mdbxtest.NewTestDB(t, dbcfg.ChainDB)
+ syncedDataManager := synced_data.NewSyncedDataManager(&cfg, true)
+ require.NoError(t, syncedDataManager.OnHeadState(parentState))
+ ethClock := eth_clock.NewMockEthereumClock(ctrl)
+ ethClock.EXPECT().GetCurrentSlot().Return(childSlot).AnyTimes()
+ ethClock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(gomock.Any()).Return(true).AnyTimes()
+ fcu := mock_services.NewForkChoiceStorageMock(t)
+ fcu.Headers[parentRoot] = parent.SignedBeaconBlockHeader().Header.Copy()
+ fcu.Blocks[parentRoot] = parent
+ fcu.StateAtBlockRootVal[parentRoot] = parentState
+ service := NewBlockService(t.Context(), db, fcu, syncedDataManager, ethClock, &cfg, nil)
+ return service, child, fcu, parentRoot, parentBlockHash
}
func TestImportBlockOperationsAttesterSlashingLogging(t *testing.T) {
@@ -214,3 +867,62 @@ func TestImportBlockOperationsAttesterSlashingLogging(t *testing.T) {
// - TestBlockServiceGloasSuccess
//
// For now, the GLOAS validation code path is verified by code review and integration tests.
+
+func TestValidateGloasBlockBodyLimitsRejectsOversizedOperationAndRequests(t *testing.T) {
+ cfg := clparams.MainnetBeaconConfig
+ cfg.MaxProposerSlashings = 1
+ cfg.MaxBuilderDepositRequestsPerPayload = 1
+ body := cltypes.NewBeaconBody(&cfg, clparams.GloasVersion)
+ body.ProposerSlashings.Append(&cltypes.ProposerSlashing{})
+ body.ProposerSlashings.Append(&cltypes.ProposerSlashing{})
+ require.Error(t, validateGloasBlockBodyLimits(&cfg, body))
+
+ body = cltypes.NewBeaconBody(&cfg, clparams.GloasVersion)
+ body.ParentExecutionRequests.BuilderDeposits.Append(&solid.BuilderDepositRequest{})
+ body.ParentExecutionRequests.BuilderDeposits.Append(&solid.BuilderDepositRequest{})
+ require.Error(t, validateGloasBlockBodyLimits(&cfg, body))
+}
+
+func TestValidateGloasBlockBodyLimitsRejectsDeposit(t *testing.T) {
+ cfg := clparams.MainnetBeaconConfig
+ body := cltypes.NewBeaconBody(&cfg, clparams.GloasVersion)
+ require.NoError(t, validateGloasBlockBodyLimits(&cfg, body))
+ body.Deposits.Append(&cltypes.Deposit{})
+ require.ErrorContains(t, validateGloasBlockBodyLimits(&cfg, body), "deposits")
+}
+
+func TestBlockServiceDecodeGossipMessageStrict(t *testing.T) {
+ cfg := clparams.MainnetBeaconConfig
+ service := &blockService{beaconCfg: &cfg}
+ block := cltypes.NewSignedBeaconBlock(&cfg, clparams.GloasVersion)
+ encoded, err := block.EncodeSSZ(nil)
+ require.NoError(t, err)
+ _, err = service.DecodeGossipMessage("peer", encoded, clparams.GloasVersion)
+ require.NoError(t, err)
+
+ outerGap := append([]byte(nil), encoded[:100]...)
+ outerGap = append(outerGap, make([]byte, 4)...)
+ outerGap = append(outerGap, encoded[100:]...)
+ binary.LittleEndian.PutUint32(outerGap, 104)
+ _, err = service.DecodeGossipMessage("peer", outerGap, clparams.GloasVersion)
+ require.Error(t, err)
+
+ const blockStart = 100
+ const blockFixedSize = 84
+ nestedGap := append([]byte(nil), encoded[:blockStart+blockFixedSize]...)
+ nestedGap = append(nestedGap, make([]byte, 4)...)
+ nestedGap = append(nestedGap, encoded[blockStart+blockFixedSize:]...)
+ binary.LittleEndian.PutUint32(nestedGap[blockStart+80:], blockFixedSize+4)
+ _, err = service.DecodeGossipMessage("peer", nestedGap, clparams.GloasVersion)
+ require.Error(t, err)
+}
+
+func TestBlockServiceDecodeGossipMessageStrictPreGloasCompatibility(t *testing.T) {
+ cfg := clparams.MainnetBeaconConfig
+ service := &blockService{beaconCfg: &cfg}
+ block := cltypes.NewSignedBeaconBlock(&cfg, clparams.DenebVersion)
+ encoded, err := block.EncodeSSZ(nil)
+ require.NoError(t, err)
+ _, err = service.DecodeGossipMessage("peer", encoded, clparams.DenebVersion)
+ require.NoError(t, err)
+}
diff --git a/cl/phase1/network/services/constants.go b/cl/phase1/network/services/constants.go
index 5977b35aa5a..493729e9339 100644
--- a/cl/phase1/network/services/constants.go
+++ b/cl/phase1/network/services/constants.go
@@ -41,7 +41,6 @@ const (
var (
ErrIgnore = errors.New("ignore") // ErrIgnore is used to indicate that the message should be ignored.
- ErrBidQueued = errors.New("bid queued")
ErrAttestationQueued = errors.New("attestation queued")
ErrBlockYoungerThanParent = errors.New("block is younger than parent")
ErrInvalidCommitmentsCount = errors.New("invalid commitments count")
diff --git a/cl/phase1/network/services/execution_payload_bid_service.go b/cl/phase1/network/services/execution_payload_bid_service.go
index 0d2c413a9c5..1e591ffb2dd 100644
--- a/cl/phase1/network/services/execution_payload_bid_service.go
+++ b/cl/phase1/network/services/execution_payload_bid_service.go
@@ -20,8 +20,8 @@ import (
"context"
"errors"
"fmt"
+ "math"
"sync"
- "sync/atomic"
"time"
"github.com/erigontech/erigon/cl/beacon/beaconevents"
@@ -40,24 +40,55 @@ import (
"github.com/libp2p/go-libp2p/core/peer"
)
-// seenBidKey tracks seen bids per (builderIndex, slot).
-// Spec: [IGNORE] The signed_execution_payload_bid is the first valid bid from this builder for this slot.
type seenBidKey struct {
- builderIndex uint64
- slot uint64
+ builderIndex uint64
+ slot uint64
+ parentBlockHash common.Hash
+ parentBlockRoot common.Hash
+}
+
+type seenBidStore struct {
+ mu sync.RWMutex
+ bySlot map[uint64]map[seenBidKey]struct{}
}
-// pendingBidKey tracks bids waiting for proposer preferences.
-type pendingBidKey struct {
- builderIndex uint64
- slot uint64
- messageRoot common.Hash
+func newSeenBidStore() *seenBidStore {
+ return &seenBidStore{bySlot: make(map[uint64]map[seenBidKey]struct{})}
}
-// pendingBidJob represents a pending bid waiting for proposer preferences to arrive.
-type pendingBidJob struct {
- msg *cltypes.SignedExecutionPayloadBid
- creationTime time.Time
+func (s *seenBidStore) Contains(key seenBidKey) bool {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ _, ok := s.bySlot[key.slot][key]
+ return ok
+}
+
+func (s *seenBidStore) Add(key seenBidKey) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.bySlot[key.slot] == nil {
+ s.bySlot[key.slot] = make(map[seenBidKey]struct{})
+ }
+ s.bySlot[key.slot][key] = struct{}{}
+}
+
+func (s *seenBidStore) PruneExpired(clock eth_clock.EthereumClock, beaconCfg *clparams.BeaconChainConfig, now time.Time) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for slot := range s.bySlot {
+ if isPastBidWindow(clock, beaconCfg, now, slot) {
+ delete(s.bySlot, slot)
+ }
+ }
+}
+
+func newSeenBidKey(bid *cltypes.ExecutionPayloadBid) seenBidKey {
+ return seenBidKey{
+ builderIndex: bid.BuilderIndex,
+ slot: bid.Slot,
+ parentBlockHash: bid.ParentBlockHash,
+ parentBlockRoot: bid.ParentBlockRoot,
+ }
}
type bidValidationStateKey struct {
@@ -66,22 +97,18 @@ type bidValidationStateKey struct {
}
type bidValidationStateEntry struct {
- mu sync.Mutex
- state *state.CachingBeaconState
+ mu sync.Mutex
+ state *state.CachingBeaconState
+ parentSlot uint64
+ parentRandao common.Hash
}
var errBidDependencyUnavailable = fmt.Errorf("%w: bid dependency unavailable", ErrIgnore)
const (
- // seenBidCacheSize: multiple builders can bid per slot.
- // With clock disparity we may see bids for ~2 slots.
- // 256 builders * 2 slots = 512 provides safety margin.
- seenBidCacheSize = 512
- pendingBidExpiry = 12 * time.Second // 1 slot
- pendingBidCheckInterval = 100 * time.Millisecond
- maxPendingBids = 1024
bidValidationStateCacheSize = 4
bidValidationStateTTLSlots = 2
+ gloasMaximumClockDisparity = 500 * time.Millisecond
)
type executionPayloadBidService struct {
@@ -91,23 +118,18 @@ type executionPayloadBidService struct {
beaconCfg *clparams.BeaconChainConfig
epbsPool *pool.EpbsPool
emitters *beaconevents.EventEmitter
+ now func() time.Time
- seenCache *lru.Cache[seenBidKey, struct{}]
+ seenCache *seenBidStore
bidStoreMu sync.Mutex
validationStateMu sync.Mutex
validationStateCache *lru.CacheWithTTL[bidValidationStateKey, *bidValidationStateEntry]
-
- // Pending bids waiting for proposer preferences
- pendingBids sync.Map // pendingBidKey -> *pendingBidJob
- pendingMu sync.Mutex
- pendingCount atomic.Int32
- pendingCond *sync.Cond
}
// NewExecutionPayloadBidService creates a new execution payload bid gossip service.
// [New in Gloas:EIP7732]
func NewExecutionPayloadBidService(
- ctx context.Context,
+ _ context.Context,
syncedDataManager synced_data.SyncedData,
forkchoiceStore forkchoice.ForkChoiceStorageReader,
ethClock eth_clock.EthereumClock,
@@ -115,10 +137,6 @@ func NewExecutionPayloadBidService(
epbsPool *pool.EpbsPool,
emitters *beaconevents.EventEmitter,
) ExecutionPayloadBidService {
- seenCache, err := lru.New[seenBidKey, struct{}]("seen_execution_payload_bids", seenBidCacheSize)
- if err != nil {
- panic(err)
- }
validationStateCache := lru.NewWithTTL[bidValidationStateKey, *bidValidationStateEntry](
"execution_payload_bid_validation_states",
bidValidationStateCacheSize,
@@ -131,11 +149,10 @@ func NewExecutionPayloadBidService(
beaconCfg: beaconCfg,
epbsPool: epbsPool,
emitters: emitters,
- seenCache: seenCache,
+ now: time.Now,
+ seenCache: newSeenBidStore(),
validationStateCache: validationStateCache,
- pendingCond: sync.NewCond(&sync.Mutex{}),
}
- go s.loop(ctx)
return s
}
@@ -145,7 +162,7 @@ func (s *executionPayloadBidService) Names() []string {
func (s *executionPayloadBidService) DecodeGossipMessage(_ peer.ID, data []byte, version clparams.StateVersion) (*cltypes.SignedExecutionPayloadBid, error) {
msg := &cltypes.SignedExecutionPayloadBid{}
- if err := msg.DecodeSSZ(data, int(version)); err != nil {
+ if err := msg.DecodeSSZStrict(data, int(version)); err != nil {
return nil, err
}
return msg, nil
@@ -169,54 +186,157 @@ func (s *executionPayloadBidService) ProcessMessage(ctx context.Context, _ *uint
"value", bid.Value,
"parentBlockHash", bid.ParentBlockHash)
+ now := s.now()
// [IGNORE] bid.slot is the current or next slot
- currentSlot := s.ethClock.GetCurrentSlot()
- if slot != currentSlot && slot != currentSlot+1 {
- return fmt.Errorf("%w: bid slot %d is not current (%d) or next slot", ErrIgnore, slot, currentSlot)
+ if !isCurrentOrNextSlot(s.ethClock, s.beaconCfg, now, slot, gloasMaximumClockDisparity) {
+ return fmt.Errorf("%w: bid slot %d is not current or next slot", ErrIgnore, slot)
}
+ s.epbsPool.HighestBids.PruneSlots(func(entrySlot uint64) bool {
+ return isPastBidWindow(s.ethClock, s.beaconCfg, now, entrySlot)
+ })
+ s.epbsPool.ProposerPreferences.PruneSlots(func(entrySlot uint64) bool {
+ return isPastBidWindow(s.ethClock, s.beaconCfg, now, entrySlot)
+ })
- seenKey := seenBidKey{builderIndex: builderIndex, slot: slot}
+ seenKey := newSeenBidKey(bid)
if s.seenCache.Contains(seenKey) {
return fmt.Errorf("%w: already seen bid from builder %d for slot %d",
ErrIgnore, builderIndex, slot)
}
- if err := s.validateBidStateless(bid); err != nil {
+ if err := s.validateHighestBid(bid); err != nil {
return err
}
- if err := s.validateHighestBid(bid); err != nil {
+ parentHeader, ok := s.forkchoiceStore.GetHeader(bid.ParentBlockRoot)
+ if ok && slot <= parentHeader.Slot {
+ return fmt.Errorf("bid slot %d is not greater than parent block slot %d", slot, parentHeader.Slot)
+ }
+ if err := s.validateBidStateless(bid); err != nil {
return err
}
-
+ if !ok {
+ return fmt.Errorf("%w: parent_block_root %v not known in fork choice", ErrIgnore, bid.ParentBlockRoot)
+ }
preferences, ok, err := s.matchingProposerPreferences(msg)
if err != nil {
- if errors.Is(err, errBidDependencyUnavailable) {
- s.queuePendingBid(msg)
- log.Trace("Queued execution payload bid waiting for dependencies",
- "slot", slot, "builderIndex", builderIndex, "err", err)
- return fmt.Errorf("%w: %w: %w", ErrIgnore, ErrBidQueued, err)
- }
return err
}
if !ok {
- // Queue as pending — preferences may arrive later
- s.queuePendingBid(msg)
- log.Trace("Queued execution payload bid waiting for proposer preferences",
- "slot", slot, "builderIndex", builderIndex)
- return fmt.Errorf("%w: %w: proposer preferences not available", ErrIgnore, ErrBidQueued)
- }
-
- if err := s.validateAndStoreBid(msg, preferences); err != nil {
- if errors.Is(err, errBidDependencyUnavailable) {
- s.queuePendingBid(msg)
- log.Trace("Queued execution payload bid waiting for dependencies",
- "slot", slot, "builderIndex", builderIndex, "err", err)
- return fmt.Errorf("%w: %w: %w", ErrIgnore, ErrBidQueued, err)
- }
+ return fmt.Errorf("%w: proposer preferences not available", ErrIgnore)
+ }
+
+ prefs := preferences.Message
+ if prefs == nil {
+ return fmt.Errorf("%w: proposer preferences not available", ErrIgnore)
+ }
+ if bid.FeeRecipient != prefs.FeeRecipient {
+ return fmt.Errorf("%w: bid fee_recipient %v does not match proposer preferences %v",
+ ErrIgnore, bid.FeeRecipient, prefs.FeeRecipient)
+ }
+ if _, ok := s.forkchoiceStore.GetRecentExecutionPayloadStatus(bid.ParentBlockHash); !ok {
+ return fmt.Errorf("%w: parent_block_hash %v not known in fork choice", ErrIgnore, bid.ParentBlockHash)
+ }
+ parentGasLimit, ok := s.forkchoiceStore.GetExecutionPayloadGasLimit(bid.ParentBlockHash)
+ if !ok {
+ return fmt.Errorf("%w: gas limit for parent_block_hash %v not available", ErrIgnore, bid.ParentBlockHash)
+ }
+ if !IsGasLimitTargetCompatible(parentGasLimit, bid.GasLimit, prefs.TargetGasLimit) {
+ return fmt.Errorf("%w: bid gas_limit %d not compatible with target %d (parent %d)",
+ ErrIgnore, bid.GasLimit, prefs.TargetGasLimit, parentGasLimit)
+ }
+ compatible, err := s.isBidCompatibleWithHead(bid)
+ if err != nil {
+ return err
+ }
+ if !compatible {
+ return fmt.Errorf("%w: bid is not compatible with the current head branch", ErrIgnore)
+ }
+ validationStateEntry, err := s.bidValidationState(bid.ParentBlockRoot, bid.Slot)
+ if err != nil {
+ return err
+ }
+ if err := s.validateBidAuthentication(msg, validationStateEntry); err != nil {
return err
}
+ if err := s.storeValidBidAt(msg, now); err != nil {
+ return err
+ }
+ s.emitters.Operation().SendExecutionPayloadBid(msg)
+ log.Trace("Processed execution payload bid via gossip", "slot", slot, "builderIndex", builderIndex,
+ "value", bid.Value, "parentBlockHash", bid.ParentBlockHash)
return nil
}
+func (s *executionPayloadBidService) ValidateBid(_ context.Context, msg *cltypes.SignedExecutionPayloadBid) error {
+ if msg == nil || msg.Message == nil {
+ return errors.New("nil execution payload bid message")
+ }
+ bid := msg.Message
+ if !isCurrentOrNextSlot(s.ethClock, s.beaconCfg, s.now(), bid.Slot, gloasMaximumClockDisparity) {
+ return fmt.Errorf("%w: bid slot %d is not current or next slot", ErrIgnore, bid.Slot)
+ }
+ parentHeader, ok := s.forkchoiceStore.GetHeader(bid.ParentBlockRoot)
+ if !ok {
+ return fmt.Errorf("%w: parent_block_root %v not known in fork choice", ErrIgnore, bid.ParentBlockRoot)
+ }
+ if bid.Slot <= parentHeader.Slot {
+ return fmt.Errorf("bid slot %d is not greater than parent block slot %d", bid.Slot, parentHeader.Slot)
+ }
+ if err := s.validateBidBlobLimit(bid); err != nil {
+ return err
+ }
+ if _, ok := s.forkchoiceStore.GetRecentExecutionPayloadStatus(bid.ParentBlockHash); !ok {
+ return fmt.Errorf("%w: parent_block_hash %v not known in fork choice", ErrIgnore, bid.ParentBlockHash)
+ }
+ validationStateEntry, err := s.bidValidationState(bid.ParentBlockRoot, bid.Slot)
+ if err != nil {
+ return err
+ }
+ return s.validateBidAuthentication(msg, validationStateEntry)
+}
+
+func isCurrentOrNextSlot(clock eth_clock.EthereumClock, beaconCfg *clparams.BeaconChainConfig, now time.Time, slot uint64, disparity time.Duration) bool {
+ if slot == ^uint64(0) {
+ return false
+ }
+ lowerSlot := slot
+ if slot > 0 {
+ lowerSlot--
+ }
+ lowerSlotTime, ok := safeSlotTime(clock, beaconCfg, lowerSlot)
+ if !ok {
+ return false
+ }
+ upperSlotTime, ok := safeSlotTime(clock, beaconCfg, slot+1)
+ if !ok {
+ return false
+ }
+ lowerBound := lowerSlotTime.Add(-disparity)
+ upperBound := upperSlotTime.Add(disparity)
+ return !now.Before(lowerBound) && !now.After(upperBound)
+}
+
+func safeSlotTime(clock eth_clock.EthereumClock, beaconCfg *clparams.BeaconChainConfig, slot uint64) (time.Time, bool) {
+ if beaconCfg == nil || beaconCfg.SecondsPerSlot == 0 {
+ return time.Time{}, false
+ }
+ genesisTime := clock.GenesisTime()
+ if slot > (math.MaxUint64-genesisTime)/beaconCfg.SecondsPerSlot {
+ return time.Time{}, false
+ }
+ slotTime := genesisTime + slot*beaconCfg.SecondsPerSlot
+ if slotTime > math.MaxInt64 {
+ return time.Time{}, false
+ }
+ return time.Unix(int64(slotTime), 0), true
+}
+
+func safeMultiplyUint64(a, b uint64) (uint64, bool) {
+ if a != 0 && b > math.MaxUint64/a {
+ return 0, false
+ }
+ return a * b, true
+}
+
func (s *executionPayloadBidService) matchingProposerPreferences(msg *cltypes.SignedExecutionPayloadBid) (*cltypes.SignedProposerPreferences, bool, error) {
bid := msg.Message
if _, ok := s.forkchoiceStore.GetHeader(bid.ParentBlockRoot); !ok {
@@ -235,10 +355,10 @@ func (s *executionPayloadBidService) matchingProposerPreferences(msg *cltypes.Si
}
func (s *executionPayloadBidService) shufflingDependentRoot(root common.Hash, epoch uint64) (common.Hash, error) {
- if epoch <= s.beaconCfg.MinSeedLookahead {
- return common.Hash{}, fmt.Errorf("%w: cannot compute proposer dependent root for epoch %d before or at min seed lookahead %d", ErrIgnore, epoch, s.beaconCfg.MinSeedLookahead)
+ dependentSlot := uint64(0)
+ if epoch > s.beaconCfg.MinSeedLookahead {
+ dependentSlot = (epoch-s.beaconCfg.MinSeedLookahead)*s.beaconCfg.SlotsPerEpoch - 1
}
- dependentSlot := (epoch-s.beaconCfg.MinSeedLookahead)*s.beaconCfg.SlotsPerEpoch - 1
return s.forkchoiceStore.Ancestor(root, dependentSlot).Root, nil
}
@@ -246,6 +366,10 @@ func (s *executionPayloadBidService) validateBidStateless(bid *cltypes.Execution
if bid.ExecutionPayment != 0 {
return fmt.Errorf("bid execution_payment must be 0, got %d", bid.ExecutionPayment)
}
+ return s.validateBidBlobLimit(bid)
+}
+
+func (s *executionPayloadBidService) validateBidBlobLimit(bid *cltypes.ExecutionPayloadBid) error {
epoch := state.GetEpochAtSlot(s.beaconCfg, bid.Slot)
maxBlobsPerBlock := int(s.beaconCfg.GetBlobParameters(epoch).MaxBlobsPerBlock)
if bid.BlobKzgCommitments.Len() > maxBlobsPerBlock {
@@ -263,54 +387,34 @@ func bidValidationStateCacheTTL(beaconCfg *clparams.BeaconChainConfig) time.Dura
return time.Duration(secondsPerSlot*bidValidationStateTTLSlots) * time.Second
}
-// validateAndStoreBid performs all remaining validation checks after preferences are confirmed.
-func (s *executionPayloadBidService) validateAndStoreBid(
- msg *cltypes.SignedExecutionPayloadBid,
- preferences *cltypes.SignedProposerPreferences,
-) error {
+func (s *executionPayloadBidService) validateBidAuthentication(msg *cltypes.SignedExecutionPayloadBid, validationStateEntry *bidValidationStateEntry) error {
bid := msg.Message
- slot := bid.Slot
- builderIndex := bid.BuilderIndex
- prefs := preferences.Message
-
- // [REJECT] fee_recipient must match proposer preferences
- if bid.FeeRecipient != prefs.FeeRecipient {
- return fmt.Errorf("bid fee_recipient %v does not match proposer preferences %v",
- bid.FeeRecipient, prefs.FeeRecipient)
- }
-
- // [IGNORE] parent_block_root is known in fork choice
- parentHeader, ok := s.forkchoiceStore.GetHeader(bid.ParentBlockRoot)
- if !ok {
- return fmt.Errorf("%w: parent_block_root %v not known in fork choice",
- ErrIgnore, bid.ParentBlockRoot)
- }
- if slot <= parentHeader.Slot {
- return fmt.Errorf("bid slot %d is not greater than parent block slot %d", slot, parentHeader.Slot)
- }
- if err := s.validateHighestBid(bid); err != nil {
- return err
- }
- validationStateEntry, err := s.bidValidationState(bid.ParentBlockRoot, slot)
- if err != nil {
- return fmt.Errorf("bid validation failed: %w", err)
+ if bid.Slot <= validationStateEntry.parentSlot {
+ return fmt.Errorf("bid slot %d is not greater than parent block slot %d", bid.Slot, validationStateEntry.parentSlot)
}
validationStateEntry.mu.Lock()
- validationState := validationStateEntry.state
- if bid.PrevRandao != validationState.GetRandaoMixes(state.Epoch(validationState)) {
+ if bid.PrevRandao != validationStateEntry.parentRandao {
validationStateEntry.mu.Unlock()
return fmt.Errorf("bid prev_randao does not match parent state randao mix")
}
+ validationState, err := validationStateEntry.state.Copy()
+ validationStateEntry.mu.Unlock()
+ if err != nil {
+ return fmt.Errorf("bid validation failed: failed to copy parent state: %w", err)
+ }
+ if validationState.Slot() != bid.Slot {
+ if err := transition.DefaultMachine.ProcessSlots(validationState, bid.Slot); err != nil {
+ return fmt.Errorf("bid validation failed: failed to advance parent state: %w", err)
+ }
+ }
builder, err := s.validateBuilderAvailability(bid, validationState)
if err != nil {
- validationStateEntry.mu.Unlock()
return fmt.Errorf("bid validation failed: %w", err)
}
builderPubkey := builder.Pubkey
epoch := state.GetEpochAtSlot(s.beaconCfg, bid.Slot)
domain, err := validationState.GetDomain(s.beaconCfg.DomainBeaconBuilder, epoch)
- validationStateEntry.mu.Unlock()
if err != nil {
return fmt.Errorf("bid validation failed: failed to get domain: %w", err)
}
@@ -318,46 +422,21 @@ func (s *executionPayloadBidService) validateAndStoreBid(
return fmt.Errorf("bid validation failed: %w", err)
}
- // [IGNORE] parent_block_hash is known in fork choice
- if _, ok := s.forkchoiceStore.GetRecentExecutionPayloadStatus(bid.ParentBlockHash); !ok {
- return fmt.Errorf("%w: parent_block_hash %v not known in fork choice",
- ErrIgnore, bid.ParentBlockHash)
- }
-
- // [IGNORE] gas_limit compatibility check — skipped (not rejected) when parent is absent from the LRU.
- if parentGasLimit, ok := s.forkchoiceStore.GetExecutionPayloadGasLimit(bid.ParentBlockHash); ok {
- if !IsGasLimitTargetCompatible(parentGasLimit, bid.GasLimit, prefs.TargetGasLimit) {
- return fmt.Errorf("%w: bid gas_limit %d not compatible with target %d (parent %d)",
- ErrIgnore, bid.GasLimit, prefs.TargetGasLimit, parentGasLimit)
- }
- }
-
- if err := s.validateHighestBid(bid); err != nil {
- return err
- }
-
- if err := s.storeValidBid(msg); err != nil {
- return err
- }
-
- // Emit SSE event for execution_payload_bid [New in Gloas:EIP7732]
- s.emitters.Operation().SendExecutionPayloadBid(msg)
-
- log.Trace("Processed execution payload bid via gossip",
- "slot", slot,
- "builderIndex", builderIndex,
- "value", bid.Value,
- "parentBlockHash", bid.ParentBlockHash)
-
return nil
}
-func (s *executionPayloadBidService) storeValidBid(msg *cltypes.SignedExecutionPayloadBid) error {
+func (s *executionPayloadBidService) storeValidBidAt(msg *cltypes.SignedExecutionPayloadBid, now time.Time) error {
bid := msg.Message
- seenKey := seenBidKey{builderIndex: bid.BuilderIndex, slot: bid.Slot}
+ seenKey := newSeenBidKey(bid)
s.bidStoreMu.Lock()
defer s.bidStoreMu.Unlock()
+ if !now.IsZero() {
+ s.seenCache.PruneExpired(s.ethClock, s.beaconCfg, now)
+ s.epbsPool.HighestBids.PruneSlots(func(entrySlot uint64) bool {
+ return isPastBidWindow(s.ethClock, s.beaconCfg, now, entrySlot)
+ })
+ }
if s.seenCache.Contains(seenKey) {
return fmt.Errorf("%w: already seen bid from builder %d for slot %d",
@@ -366,12 +445,65 @@ func (s *executionPayloadBidService) storeValidBid(msg *cltypes.SignedExecutionP
if err := s.validateHighestBid(bid); err != nil {
return err
}
- s.seenCache.Add(seenKey, struct{}{})
+ s.seenCache.Add(seenKey)
bidKey := pool.HighestBidKey{Slot: bid.Slot, ParentBlockHash: bid.ParentBlockHash, ParentBlockRoot: bid.ParentBlockRoot}
s.epbsPool.HighestBids.Add(bidKey, msg)
return nil
}
+func (s *executionPayloadBidService) isBidCompatibleWithHead(bid *cltypes.ExecutionPayloadBid) (bool, error) {
+ headNode, err := s.forkchoiceStore.GetHeadNode()
+ if err != nil {
+ return false, fmt.Errorf("%w: head unavailable: %w", errBidDependencyUnavailable, err)
+ }
+ headRoot := headNode.Root
+ headHeader, ok := s.forkchoiceStore.GetHeader(headRoot)
+ if !ok {
+ return false, fmt.Errorf("%w: head block header unavailable", errBidDependencyUnavailable)
+ }
+ headBlock, hasBlock := s.forkchoiceStore.GetBlock(headRoot)
+ if hasBlock && headBlock != nil && headBlock.Block != nil && headBlock.Block.Body != nil {
+ signedHeadBid := headBlock.Block.Body.GetSignedExecutionPayloadBid()
+ if signedHeadBid != nil && signedHeadBid.Message != nil {
+ return bidCompatibleWithHead(bid, headRoot, headHeader, signedHeadBid.Message, s.forkchoiceStore.ShouldBuildOnFull(headNode, bid.Slot)), nil
+ }
+ if headPayload := headBlock.Block.Body.ExecutionPayload; headPayload != nil {
+ return bid.ParentBlockRoot == headRoot && bid.ParentBlockHash == headPayload.BlockHash, nil
+ }
+ }
+ headState, err := s.forkchoiceStore.GetStateAtBlockRoot(headRoot, true)
+ if err != nil || headState == nil {
+ return false, fmt.Errorf("%w: head state unavailable", errBidDependencyUnavailable)
+ }
+ if headState.Version() >= clparams.GloasVersion {
+ headBid := headState.GetLatestExecutionPayloadBid()
+ if headBid == nil {
+ return false, fmt.Errorf("%w: head bid unavailable", errBidDependencyUnavailable)
+ }
+ return bidCompatibleWithHead(bid, headRoot, headHeader, headBid, s.forkchoiceStore.ShouldBuildOnFull(headNode, bid.Slot)), nil
+ }
+ headPayload := headState.LatestExecutionPayloadHeader()
+ if headPayload == nil {
+ return false, fmt.Errorf("%w: head execution payload unavailable", errBidDependencyUnavailable)
+ }
+ return bid.ParentBlockRoot == headRoot && bid.ParentBlockHash == headPayload.BlockHash, nil
+}
+
+func bidCompatibleWithHead(bid *cltypes.ExecutionPayloadBid, headRoot common.Hash, headHeader *cltypes.BeaconBlockHeader, headBid *cltypes.ExecutionPayloadBid, buildOnFull bool) bool {
+ buildsOnParentBlock := bid.ParentBlockRoot == headHeader.ParentRoot
+ buildsOnParentPayload := bid.ParentBlockHash == headBid.ParentBlockHash
+ if buildsOnParentBlock && buildsOnParentPayload {
+ return true
+ }
+ if bid.ParentBlockRoot != headRoot {
+ return false
+ }
+ if buildOnFull {
+ return bid.ParentBlockHash == headBid.BlockHash
+ }
+ return buildsOnParentPayload
+}
+
func (s *executionPayloadBidService) validateHighestBid(bid *cltypes.ExecutionPayloadBid) error {
bidKey := pool.HighestBidKey{Slot: bid.Slot, ParentBlockHash: bid.ParentBlockHash, ParentBlockRoot: bid.ParentBlockRoot}
existing, found := s.epbsPool.HighestBids.Get(bidKey)
@@ -412,16 +544,14 @@ func (s *executionPayloadBidService) bidValidationState(parentBlockRoot common.H
s.removeBidValidationState(cacheKey, entry)
return nil, fmt.Errorf("parent state slot %d is after bid slot %d", parentState.Slot(), bidSlot)
}
- validationState := parentState
- if parentState.Slot() == bidSlot {
- entry.state = validationState
- return entry, nil
- }
- if err := transition.DefaultMachine.ProcessSlots(validationState, bidSlot); err != nil {
+ entry.parentSlot = parentState.Slot()
+ entry.parentRandao = parentState.GetRandaoMixes(state.Epoch(parentState))
+ proposalEpoch := state.GetEpochAtSlot(s.beaconCfg, bidSlot)
+ if proposalEpoch > state.Epoch(parentState)+s.beaconCfg.MinSeedLookahead {
s.removeBidValidationState(cacheKey, entry)
- return nil, err
+ return nil, fmt.Errorf("%w: bid slot is past the parent's proposer lookahead", ErrIgnore)
}
- entry.state = validationState
+ entry.state = parentState
return entry, nil
}
@@ -465,168 +595,14 @@ func (s *executionPayloadBidService) validateBuilderAvailability(
if builder == nil {
return nil, fmt.Errorf("builder %d not found", builderIndex)
}
- if builder.Version != s.beaconCfg.PayloadBuilderVersion {
- return nil, fmt.Errorf("builder %d has unsupported version %d", builderIndex, builder.Version)
+ if !state.CanBuilderCoverBid(validationState, builderIndex, bid.Value) {
+ return nil, fmt.Errorf("%w: builder %d cannot cover bid value %d", ErrIgnore, builderIndex, bid.Value)
}
if !state.IsActiveBuilder(validationState, builderIndex) {
return nil, fmt.Errorf("builder %d is not active", builderIndex)
}
- if !state.CanBuilderCoverBid(validationState, builderIndex, bid.Value) {
- return nil, fmt.Errorf("%w: builder %d cannot cover bid value %d", ErrIgnore, builderIndex, bid.Value)
+ if builder.Version != s.beaconCfg.PayloadBuilderVersion {
+ return nil, fmt.Errorf("builder %d has unsupported version %d", builderIndex, builder.Version)
}
return builder, nil
}
-
-// queuePendingBid adds a bid to the pending queue for later processing when preferences arrive.
-func (s *executionPayloadBidService) queuePendingBid(msg *cltypes.SignedExecutionPayloadBid) {
- key := pendingBidKeyFor(msg)
- job := &pendingBidJob{
- msg: msg,
- creationTime: time.Now(),
- }
-
- s.pendingMu.Lock()
- if _, loaded := s.pendingBids.Load(key); loaded {
- s.pendingMu.Unlock()
- return
- }
- if s.pendingCount.Load() >= maxPendingBids {
- s.pendingMu.Unlock()
- return
- }
- s.pendingBids.Store(key, job)
- s.pendingCount.Add(1)
- s.pendingMu.Unlock()
-
- s.signalPendingBids()
-}
-
-func (s *executionPayloadBidService) deletePendingBid(key pendingBidKey, job *pendingBidJob) bool {
- s.pendingMu.Lock()
- defer s.pendingMu.Unlock()
- current, ok := s.pendingBids.Load(key)
- if !ok || current != job {
- return false
- }
- s.pendingBids.Delete(key)
- s.pendingCount.Add(-1)
- return true
-}
-
-func (s *executionPayloadBidService) signalPendingBids() {
- s.pendingCond.L.Lock()
- s.pendingCond.Signal()
- s.pendingCond.L.Unlock()
-}
-
-func pendingBidKeyFor(msg *cltypes.SignedExecutionPayloadBid) pendingBidKey {
- root, _ := msg.HashSSZ()
- return pendingBidKey{
- builderIndex: msg.Message.BuilderIndex,
- slot: msg.Message.Slot,
- messageRoot: common.Hash(root),
- }
-}
-
-// loop is the background goroutine that processes pending bids.
-func (s *executionPayloadBidService) loop(ctx context.Context) {
- // Wake any blocked Wait() on context cancellation to prevent deadlock.
- go func() {
- <-ctx.Done()
- s.pendingCond.L.Lock()
- s.pendingCond.Broadcast()
- s.pendingCond.L.Unlock()
- }()
-
- for {
- // Wait until there are pending bids
- s.pendingCond.L.Lock()
- for s.pendingCount.Load() == 0 {
- select {
- case <-ctx.Done():
- s.pendingCond.L.Unlock()
- return
- default:
- }
- s.pendingCond.Wait()
- }
- s.pendingCond.L.Unlock()
-
- // Poll until all pending bids are processed
- ticker := time.NewTicker(pendingBidCheckInterval)
- for s.pendingCount.Load() > 0 {
- select {
- case <-ctx.Done():
- ticker.Stop()
- return
- case <-ticker.C:
- s.processPendingBids()
- }
- }
- ticker.Stop()
- }
-}
-
-// processPendingBids checks pending bids whose proposer preferences may have arrived.
-func (s *executionPayloadBidService) processPendingBids() {
- s.pendingBids.Range(func(key, value any) bool {
- pendingKey := key.(pendingBidKey)
- job := value.(*pendingBidJob)
-
- // Check expiry
- if time.Since(job.creationTime) > pendingBidExpiry {
- if s.deletePendingBid(pendingKey, job) {
- log.Trace("Pending execution payload bid expired",
- "slot", pendingKey.slot, "builderIndex", pendingKey.builderIndex)
- }
- return true
- }
-
- // Check if bid slot is still valid
- currentSlot := s.ethClock.GetCurrentSlot()
- if pendingKey.slot != currentSlot && pendingKey.slot != currentSlot+1 {
- if s.deletePendingBid(pendingKey, job) {
- log.Trace("Pending execution payload bid slot expired",
- "slot", pendingKey.slot, "builderIndex", pendingKey.builderIndex)
- }
- return true
- }
-
- if s.seenCache.Contains(seenBidKey{builderIndex: pendingKey.builderIndex, slot: pendingKey.slot}) {
- s.deletePendingBid(pendingKey, job)
- return true
- }
-
- preferences, ok, err := s.matchingProposerPreferences(job.msg)
- if err != nil {
- if errors.Is(err, errBidDependencyUnavailable) {
- return true
- }
- if s.deletePendingBid(pendingKey, job) {
- log.Trace("Failed to match pending execution payload bid",
- "slot", pendingKey.slot,
- "builderIndex", pendingKey.builderIndex,
- "err", err)
- }
- return true
- }
- if !ok {
- return true // Preferences still not here, keep waiting
- }
-
- if err := s.validateAndStoreBid(job.msg, preferences); err != nil {
- if errors.Is(err, errBidDependencyUnavailable) {
- return true
- }
- if s.deletePendingBid(pendingKey, job) {
- log.Trace("Failed to process pending execution payload bid",
- "slot", pendingKey.slot,
- "builderIndex", pendingKey.builderIndex,
- "err", err)
- }
- return true
- }
- s.deletePendingBid(pendingKey, job)
- return true
- })
-}
diff --git a/cl/phase1/network/services/execution_payload_bid_service_test.go b/cl/phase1/network/services/execution_payload_bid_service_test.go
index 6c2097f479b..a47e92a149c 100644
--- a/cl/phase1/network/services/execution_payload_bid_service_test.go
+++ b/cl/phase1/network/services/execution_payload_bid_service_test.go
@@ -2,9 +2,11 @@ package services
import (
"context"
+ "encoding/binary"
"errors"
- "sync"
+ "math"
"testing"
+ "time"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
@@ -33,6 +35,10 @@ func setupExecutionPayloadBidService(t *testing.T, ctrl *gomock.Controller) (
) {
mockSyncedData := synced_data_mock.NewMockSyncedData(ctrl)
ethClockMock := eth_clock.NewMockEthereumClock(ctrl)
+ ethClockMock.EXPECT().GenesisTime().Return(uint64(0)).AnyTimes()
+ ethClockMock.EXPECT().GetSlotTime(gomock.Any()).DoAndReturn(func(slot uint64) time.Time {
+ return time.Unix(int64(slot*12), 0)
+ }).AnyTimes()
fcMock := forkchoice_mock.NewForkChoiceStorageMock(t)
epbsPool := pool.NewEpbsPool()
beaconCfg := clparams.MainnetBeaconConfig
@@ -41,13 +47,23 @@ func setupExecutionPayloadBidService(t *testing.T, ctrl *gomock.Controller) (
beaconCfg.MinSeedLookahead = 1
beaconCfg.DomainBeaconBuilder = [4]byte{0x0B, 0x00, 0x00, 0x00}
fcMock.StateAtBlockRootVal[common.HexToHash("0xbbbb")] = newBidParentState(&beaconCfg, testDependentRoot)
+ fcMock.Headers[common.HexToHash("0xbbbb")] = &cltypes.BeaconBlockHeader{Slot: 99}
+ fcMock.ExecutionPayloadGasLimitMap[common.HexToHash("0xaaaa")] = 30_000_000
fcMock.Ancestors[63] = forkchoice.ForkChoiceNode{Root: testDependentRoot, PayloadStatus: cltypes.PayloadStatusPending}
+ headRoot := common.HexToHash("0xeeee")
+ headBlock := cltypes.NewBeaconBlock(&beaconCfg, clparams.GloasVersion)
+ headBlock.ParentRoot = common.HexToHash("0xbbbb")
+ headBlock.Body.SignedExecutionPayloadBid = &cltypes.SignedExecutionPayloadBid{Message: &cltypes.ExecutionPayloadBid{
+ ParentBlockHash: common.HexToHash("0xaaaa"),
+ BlockHash: common.HexToHash("0xdddd"),
+ }}
+ fcMock.HeadVal = headRoot
+ fcMock.Headers[headRoot] = &cltypes.BeaconBlockHeader{ParentRoot: headBlock.ParentRoot, Slot: 99}
+ fcMock.Blocks[headRoot] = &cltypes.SignedBeaconBlock{Block: headBlock}
prevBlsVerify := blsVerify
blsVerify = func(_ []byte, _ []byte, _ []byte) (bool, error) { return true, nil }
t.Cleanup(func() { blsVerify = prevBlsVerify })
- seenCache, err := lru.New[seenBidKey, struct{}]("seen_bids_test", seenBidCacheSize)
- require.NoError(t, err)
validationStateCache := lru.NewWithTTL[bidValidationStateKey, *bidValidationStateEntry](
"bid_validation_states_test",
bidValidationStateCacheSize,
@@ -55,15 +71,17 @@ func setupExecutionPayloadBidService(t *testing.T, ctrl *gomock.Controller) (
)
service := &executionPayloadBidService{
- syncedDataManager: mockSyncedData,
- forkchoiceStore: fcMock,
- ethClock: ethClockMock,
- beaconCfg: &beaconCfg,
- epbsPool: epbsPool,
- emitters: beaconevents.NewEventEmitter(),
- seenCache: seenCache,
+ syncedDataManager: mockSyncedData,
+ forkchoiceStore: fcMock,
+ ethClock: ethClockMock,
+ beaconCfg: &beaconCfg,
+ epbsPool: epbsPool,
+ emitters: beaconevents.NewEventEmitter(),
+ now: func() time.Time {
+ return ethClockMock.GetSlotTime(ethClockMock.GetCurrentSlot())
+ },
+ seenCache: newSeenBidStore(),
validationStateCache: validationStateCache,
- pendingCond: sync.NewCond(&sync.Mutex{}),
}
return service, mockSyncedData, ethClockMock, fcMock, epbsPool
@@ -109,7 +127,7 @@ func addPreferencesToPoolWithRoot(epbsPool *pool.EpbsPool, slot uint64, dependen
func newBidParentState(cfg *clparams.BeaconChainConfig, dependentRoot common.Hash) *state2.CachingBeaconState {
s := state2.New(cfg)
s.SetVersion(clparams.GloasVersion)
- if err := s.SetSlot(100); err != nil {
+ if err := s.SetSlot(99); err != nil {
panic(err)
}
if err := s.SetBlockRootAt(63, dependentRoot); err != nil {
@@ -165,6 +183,153 @@ func TestExecutionPayloadBidServiceNilMessage(t *testing.T) {
require.Contains(t, err.Error(), "nil execution payload bid message")
}
+func TestExecutionPayloadBidServiceRejectsUnrepresentableSlotBeforeDependencies(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ slot uint64
+ secondsPerSlot uint64
+ }{
+ {name: "slot addition overflow", slot: math.MaxUint64, secondsPerSlot: 12},
+ {name: "unix second overflow", slot: math.MaxInt64/12 + 1, secondsPerSlot: 12},
+ {name: "zero seconds per slot", slot: 100, secondsPerSlot: 0},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ clock := eth_clock.NewMockEthereumClock(ctrl)
+ clock.EXPECT().GenesisTime().Return(uint64(0)).AnyTimes()
+ cfg := clparams.MainnetBeaconConfig
+ cfg.SecondsPerSlot = test.secondsPerSlot
+ stateCalls := 0
+ fc := forkchoice_mock.NewForkChoiceStorageMock(t)
+ fc.GetStateAtBlockRootFn = func(common.Hash, bool) (*state2.CachingBeaconState, error) {
+ stateCalls++
+ return nil, nil
+ }
+ service := &executionPayloadBidService{
+ forkchoiceStore: fc,
+ ethClock: clock,
+ beaconCfg: &cfg,
+ epbsPool: pool.NewEpbsPool(),
+ now: func() time.Time { return time.Unix(0, 0) },
+ seenCache: newSeenBidStore(),
+ }
+
+ err := service.ProcessMessage(context.Background(), nil, newTestSignedExecutionPayloadBid(test.slot, 1, 1))
+ require.ErrorIs(t, err, ErrIgnore)
+ require.Zero(t, stateCalls)
+ })
+ }
+}
+
+func TestSafeSlotTimeRepresentabilityBoundaries(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ clock := eth_clock.NewMockEthereumClock(ctrl)
+ clock.EXPECT().GenesisTime().Return(uint64(7)).AnyTimes()
+ cfg := clparams.MainnetBeaconConfig
+ cfg.SecondsPerSlot = 12
+ lastSlot := uint64((math.MaxInt64 - 7) / 12)
+ wantUnixSeconds := uint64(7) + lastSlot*cfg.SecondsPerSlot
+
+ got, ok := safeSlotTime(clock, &cfg, lastSlot)
+ require.True(t, ok)
+ require.Equal(t, time.Unix(int64(wantUnixSeconds), 0), got)
+ _, ok = safeSlotTime(clock, &cfg, lastSlot+1)
+ require.False(t, ok)
+ _, ok = safeSlotTime(clock, &cfg, math.MaxUint64)
+ require.False(t, ok)
+
+ cfg.SecondsPerSlot = 0
+ _, ok = safeSlotTime(clock, &cfg, 1)
+ require.False(t, ok)
+}
+
+func TestExecutionPayloadBidServiceOrdersHighestBeforeStatelessChecks(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, _, ethClockMock, _, epbsPool := setupExecutionPayloadBidService(t, ctrl)
+ msg := newTestSignedExecutionPayloadBid(100, 1, 1)
+ msg.Message.ExecutionPayment = 10
+ msg.Message.ExecutionPayment = 1
+ epbsPool.HighestBids.Add(pool.HighestBidKey{
+ Slot: msg.Message.Slot, ParentBlockHash: msg.Message.ParentBlockHash, ParentBlockRoot: msg.Message.ParentBlockRoot,
+ }, newTestSignedExecutionPayloadBid(100, 2, 2))
+ ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100))
+
+ err := service.ProcessMessage(context.Background(), nil, msg)
+ require.ErrorIs(t, err, ErrIgnore)
+ require.Contains(t, err.Error(), "not higher")
+}
+
+func TestExecutionPayloadBidServiceAuthenticatesAcceptedBidOnce(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, _, ethClockMock, fcMock, epbsPool := setupExecutionPayloadBidService(t, ctrl)
+ msg := newTestSignedExecutionPayloadBid(100, 1, 1)
+ addPreferencesToPool(epbsPool, 100)
+ fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated
+ ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100))
+ var calls int
+ blsVerify = func(_, _, _ []byte) (bool, error) {
+ calls++
+ return true, nil
+ }
+
+ require.NoError(t, service.ProcessMessage(context.Background(), nil, msg))
+ require.Equal(t, 1, calls)
+}
+
+func TestValidateDirectBidDoesNotApplyGossipHighestFilter(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, _, ethClockMock, fcMock, epbsPool := setupExecutionPayloadBidService(t, ctrl)
+ msg := newTestSignedExecutionPayloadBid(100, 1, 1)
+ addPreferencesToPool(epbsPool, 100)
+ fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated
+ epbsPool.HighestBids.Add(pool.HighestBidKey{
+ Slot: msg.Message.Slot, ParentBlockHash: msg.Message.ParentBlockHash, ParentBlockRoot: msg.Message.ParentBlockRoot,
+ }, newTestSignedExecutionPayloadBid(100, 2, 2))
+ ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100)).Times(2)
+
+ require.NoError(t, service.ValidateBid(context.Background(), msg))
+ require.Error(t, service.ProcessMessage(context.Background(), nil, msg))
+}
+
+func TestValidateDirectBidDoesNotRequireGossipProposerPreferences(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, _, ethClockMock, fcMock, epbsPool := setupExecutionPayloadBidService(t, ctrl)
+ msg := newTestSignedExecutionPayloadBid(100, 1, 1)
+ fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated
+ ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100))
+
+ require.Empty(t, epbsPool.ProposerPreferences.Keys())
+ require.NoError(t, service.ValidateBid(context.Background(), msg))
+}
+
+func TestValidateDirectBidAllowsExecutionPayment(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, _, ethClockMock, fcMock, _ := setupExecutionPayloadBidService(t, ctrl)
+ msg := newTestSignedExecutionPayloadBid(100, 1, 1)
+ msg.Message.ExecutionPayment = 1
+ fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated
+ ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100))
+
+ require.NoError(t, service.ValidateBid(context.Background(), msg))
+}
+
+func TestValidateDirectBidUsesFrozenParentWhenHeadFlipsToSibling(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, _, ethClockMock, fcMock, _ := setupExecutionPayloadBidService(t, ctrl)
+ msg := newTestSignedExecutionPayloadBid(100, 1, 1)
+ fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated
+ siblingRoot := common.Hash{0xfa}
+ fcMock.HeadVal = siblingRoot
+ fcMock.Headers[siblingRoot] = &cltypes.BeaconBlockHeader{ParentRoot: common.Hash{0xfb}, Slot: 99}
+ siblingBlock := cltypes.NewBeaconBlock(service.beaconCfg, clparams.GloasVersion)
+ siblingBlock.Body.SignedExecutionPayloadBid.Message.ParentBlockHash = common.Hash{0xfc}
+ siblingBlock.Body.SignedExecutionPayloadBid.Message.BlockHash = common.Hash{0xfd}
+ fcMock.Blocks[siblingRoot] = &cltypes.SignedBeaconBlock{Block: siblingBlock}
+ ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100))
+
+ require.NoError(t, service.ValidateBid(context.Background(), msg))
+}
+
func TestExecutionPayloadBidServiceWrongSlot(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -181,6 +346,31 @@ func TestExecutionPayloadBidServiceWrongSlot(t *testing.T) {
require.Contains(t, err.Error(), "not current")
}
+func TestIsCurrentOrNextSlotClockDisparityBoundaries(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, _, _, _, _ := setupExecutionPayloadBidService(t, ctrl)
+ lower := service.ethClock.GetSlotTime(99).Add(-gloasMaximumClockDisparity)
+ upper := service.ethClock.GetSlotTime(101).Add(gloasMaximumClockDisparity)
+
+ tests := []struct {
+ name string
+ now time.Time
+ want bool
+ }{
+ {name: "before lower", now: lower.Add(-time.Millisecond), want: false},
+ {name: "at lower", now: lower, want: true},
+ {name: "after lower", now: lower.Add(time.Millisecond), want: true},
+ {name: "before upper", now: upper.Add(-time.Millisecond), want: true},
+ {name: "at upper", now: upper, want: true},
+ {name: "after upper", now: upper.Add(time.Millisecond), want: false},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ require.Equal(t, test.want, isCurrentOrNextSlot(service.ethClock, service.beaconCfg, test.now, 100, gloasMaximumClockDisparity))
+ })
+ }
+}
+
func TestExecutionPayloadBidServiceCurrentSlot(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -241,7 +431,7 @@ func TestExecutionPayloadBidServiceNextSlot(t *testing.T) {
err := service.ProcessMessage(context.Background(), nil, msg)
require.NoError(t, err)
- require.Equal(t, uint64(100), parentState.Slot())
+ require.Equal(t, uint64(99), parentState.Slot())
}
func TestExecutionPayloadBidServiceNoPreferences(t *testing.T) {
@@ -262,15 +452,17 @@ func TestExecutionPayloadBidServiceNoPreferences(t *testing.T) {
bidKey := pool.HighestBidKey{Slot: 100, ParentBlockHash: common.HexToHash("0xaaaa"), ParentBlockRoot: common.HexToHash("0xbbbb")}
_, found := epbsPool.HighestBids.Get(bidKey)
require.False(t, found)
+ require.Zero(t, service.validationStateCache.Len())
}
-func TestExecutionPayloadBidServiceRejectsNonZeroExecutionPaymentBeforeQueue(t *testing.T) {
+func TestExecutionPayloadBidServiceRejectsNonZeroExecutionPaymentWithMissingStateBeforeQueue(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
- service, _, ethClockMock, _, _ := setupExecutionPayloadBidService(t, ctrl)
+ service, _, ethClockMock, fcMock, _ := setupExecutionPayloadBidService(t, ctrl)
msg := newTestSignedExecutionPayloadBid(100, 1, 1000)
msg.Message.ExecutionPayment = 1
+ delete(fcMock.StateAtBlockRootVal, msg.Message.ParentBlockRoot)
ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100))
@@ -278,15 +470,15 @@ func TestExecutionPayloadBidServiceRejectsNonZeroExecutionPaymentBeforeQueue(t *
require.Error(t, err)
require.Contains(t, err.Error(), "execution_payment must be 0")
- require.Equal(t, int32(0), service.pendingCount.Load())
}
-func TestExecutionPayloadBidServiceRejectsTooManyBlobCommitmentsBeforeQueue(t *testing.T) {
+func TestExecutionPayloadBidServiceRejectsTooManyBlobCommitmentsWithMissingStateBeforeQueue(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
- service, _, ethClockMock, _, _ := setupExecutionPayloadBidService(t, ctrl)
+ service, _, ethClockMock, fcMock, _ := setupExecutionPayloadBidService(t, ctrl)
msg := newTestSignedExecutionPayloadBid(100, 1, 1000)
+ delete(fcMock.StateAtBlockRootVal, msg.Message.ParentBlockRoot)
maxBlobs := int(service.beaconCfg.GetBlobParameters(100 / service.beaconCfg.SlotsPerEpoch).MaxBlobsPerBlock)
for i := 0; i <= maxBlobs; i++ {
msg.Message.BlobKzgCommitments.Append(&cltypes.KZGCommitment{})
@@ -298,7 +490,6 @@ func TestExecutionPayloadBidServiceRejectsTooManyBlobCommitmentsBeforeQueue(t *t
require.Error(t, err)
require.Contains(t, err.Error(), "too many blob_kzg_commitments")
- require.Equal(t, int32(0), service.pendingCount.Load())
}
func TestExecutionPayloadBidServiceWaitsForMatchingDependentRootPreference(t *testing.T) {
@@ -319,21 +510,20 @@ func TestExecutionPayloadBidServiceWaitsForMatchingDependentRootPreference(t *te
})
ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100))
- require.True(t, errors.Is(service.ProcessMessage(context.Background(), nil, msg), ErrIgnore))
- require.Equal(t, int32(1), service.pendingCount.Load())
+ err := service.ProcessMessage(context.Background(), nil, msg)
+ require.ErrorIs(t, err, ErrIgnore)
addPreferencesToPool(epbsPool, 100)
fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated
fcMock.Headers[msg.Message.ParentBlockRoot] = &cltypes.BeaconBlockHeader{}
ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100))
- service.processPendingBids()
- require.Equal(t, int32(0), service.pendingCount.Load())
+ require.NoError(t, service.ProcessMessage(context.Background(), nil, msg))
_, found := epbsPool.HighestBids.Get(pool.HighestBidKey{Slot: 100, ParentBlockHash: msg.Message.ParentBlockHash, ParentBlockRoot: msg.Message.ParentBlockRoot})
require.True(t, found)
}
-func TestExecutionPayloadBidServiceWaitsForParentState(t *testing.T) {
+func TestExecutionPayloadBidServiceMissingParentStateIsNotQueued(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -341,25 +531,14 @@ func TestExecutionPayloadBidServiceWaitsForParentState(t *testing.T) {
msg := newTestSignedExecutionPayloadBid(100, 1, 1000)
addPreferencesToPool(epbsPool, 100)
fcMock.Headers[msg.Message.ParentBlockRoot] = &cltypes.BeaconBlockHeader{}
+ fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated
delete(fcMock.StateAtBlockRootVal, msg.Message.ParentBlockRoot)
ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100))
- require.True(t, errors.Is(service.ProcessMessage(context.Background(), nil, msg), ErrIgnore))
- require.Equal(t, int32(1), service.pendingCount.Load())
-
- ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100))
- service.processPendingBids()
- require.Equal(t, int32(1), service.pendingCount.Load())
-
- fcMock.StateAtBlockRootVal[msg.Message.ParentBlockRoot] = newBidParentState(service.beaconCfg, testDependentRoot)
- fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated
- fcMock.Headers[msg.Message.ParentBlockRoot] = &cltypes.BeaconBlockHeader{}
- ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100))
-
- service.processPendingBids()
- require.Equal(t, int32(0), service.pendingCount.Load())
+ err := service.ProcessMessage(context.Background(), nil, msg)
+ require.ErrorIs(t, err, ErrIgnore)
_, found := epbsPool.HighestBids.Get(pool.HighestBidKey{Slot: 100, ParentBlockHash: msg.Message.ParentBlockHash, ParentBlockRoot: msg.Message.ParentBlockRoot})
- require.True(t, found)
+ require.False(t, found)
}
func TestExecutionPayloadBidServiceUsesDependentRootFromForkchoiceStore(t *testing.T) {
@@ -380,25 +559,89 @@ func TestExecutionPayloadBidServiceUsesDependentRootFromForkchoiceStore(t *testi
err := service.ProcessMessage(context.Background(), nil, msg)
require.NoError(t, err)
- require.Equal(t, uint64(100), parentState.Slot())
+ require.Equal(t, uint64(99), parentState.Slot())
_, found := epbsPool.HighestBids.Get(pool.HighestBidKey{Slot: 100, ParentBlockHash: msg.Message.ParentBlockHash, ParentBlockRoot: msg.Message.ParentBlockRoot})
require.True(t, found)
}
-func TestExecutionPayloadBidServiceRejectsEarlyEpochDependentRootLookup(t *testing.T) {
+func TestExecutionPayloadBidServiceUsesGenesisDependentRootInEarlyEpoch(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
- service, _, ethClockMock, fcMock, _ := setupExecutionPayloadBidService(t, ctrl)
+ service, _, ethClockMock, fcMock, epbsPool := setupExecutionPayloadBidService(t, ctrl)
msg := newTestSignedExecutionPayloadBid(1, 1, 1000)
+ genesisRoot := common.HexToHash("0x1234")
+ fcMock.Ancestors[0] = forkchoice.ForkChoiceNode{Root: genesisRoot}
fcMock.Headers[msg.Message.ParentBlockRoot] = &cltypes.BeaconBlockHeader{}
+ fcMock.StateAtBlockRootVal[msg.Message.ParentBlockRoot] = newBidParentState(service.beaconCfg, genesisRoot)
+ require.NoError(t, fcMock.StateAtBlockRootVal[msg.Message.ParentBlockRoot].SetSlot(0))
+ fcMock.StateAtBlockRootVal[msg.Message.ParentBlockRoot].GetBuilders().Get(1).DepositEpoch = 0
+ fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated
+ addPreferencesToPoolWithRoot(epbsPool, 1, genesisRoot)
ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(1))
err := service.ProcessMessage(context.Background(), nil, msg)
- require.Error(t, err)
- require.True(t, errors.Is(err, ErrIgnore))
- require.Contains(t, err.Error(), "cannot compute proposer dependent root")
+ require.NoError(t, err)
+}
+
+func TestBidCompatibleWithHead(t *testing.T) {
+ headRoot := common.HexToHash("0x10")
+ parentRoot := common.HexToHash("0x20")
+ parentPayload := common.HexToHash("0x30")
+ headPayload := common.HexToHash("0x40")
+ headHeader := &cltypes.BeaconBlockHeader{ParentRoot: parentRoot, Slot: 99}
+ headBid := &cltypes.ExecutionPayloadBid{ParentBlockHash: parentPayload, BlockHash: headPayload}
+
+ buildsOnParent := &cltypes.ExecutionPayloadBid{Slot: 100, ParentBlockRoot: parentRoot, ParentBlockHash: parentPayload}
+ require.True(t, bidCompatibleWithHead(buildsOnParent, headRoot, headHeader, headBid, true))
+ buildsOnHead := &cltypes.ExecutionPayloadBid{Slot: 100, ParentBlockRoot: headRoot, ParentBlockHash: headPayload}
+ require.True(t, bidCompatibleWithHead(buildsOnHead, headRoot, headHeader, headBid, true))
+ require.False(t, bidCompatibleWithHead(buildsOnHead, headRoot, headHeader, headBid, false))
+ stale := &cltypes.ExecutionPayloadBid{Slot: 100, ParentBlockRoot: common.HexToHash("0x50"), ParentBlockHash: parentPayload}
+ require.False(t, bidCompatibleWithHead(stale, headRoot, headHeader, headBid, false))
+}
+
+func TestExecutionPayloadBidServiceFirstGloasSlotBuildsOnFuluHead(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ service, _, _, fc, _ := setupExecutionPayloadBidService(t, ctrl)
+ headRoot := common.HexToHash("0xf001")
+ payloadHash := common.HexToHash("0xf002")
+ headBlock := cltypes.NewBeaconBlock(service.beaconCfg, clparams.FuluVersion)
+ headBlock.Body.ExecutionPayload.BlockHash = payloadHash
+ fc.HeadVal = headRoot
+ fc.Headers[headRoot] = &cltypes.BeaconBlockHeader{Slot: 99}
+ fc.Blocks[headRoot] = &cltypes.SignedBeaconBlock{Block: headBlock}
+
+ compatible, err := service.isBidCompatibleWithHead(&cltypes.ExecutionPayloadBid{
+ Slot: 100, ParentBlockRoot: headRoot, ParentBlockHash: payloadHash,
+ })
+ require.NoError(t, err)
+ require.True(t, compatible)
+}
+
+func TestExecutionPayloadBidServiceUsesCoherentHeadNodeSnapshot(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ service, _, _, fc, _ := setupExecutionPayloadBidService(t, ctrl)
+ headRoot := fc.HeadVal
+ fc.GetHeadNodeFn = func() (forkchoice.ForkChoiceNode, error) {
+ fc.HeadVal = common.HexToHash("0xdead")
+ return forkchoice.ForkChoiceNode{Root: headRoot, PayloadStatus: cltypes.PayloadStatusFull}, nil
+ }
+ compatible, err := service.isBidCompatibleWithHead(&cltypes.ExecutionPayloadBid{
+ Slot: 100, ParentBlockRoot: headRoot, ParentBlockHash: common.HexToHash("0xdddd"),
+ })
+ require.NoError(t, err)
+ require.True(t, compatible)
+}
+
+func TestSeenBidKeyIncludesCompatibleParentTuple(t *testing.T) {
+ bid1 := newTestSignedExecutionPayloadBid(100, 1, 1000).Message
+ bid2 := newTestSignedExecutionPayloadBid(100, 1, 1001).Message
+ bid2.ParentBlockRoot = common.HexToHash("0xdddd")
+ require.NotEqual(t, newSeenBidKey(bid1), newSeenBidKey(bid2))
}
func TestExecutionPayloadBidServiceGasLimitIncompatible(t *testing.T) {
@@ -424,6 +667,44 @@ func TestExecutionPayloadBidServiceGasLimitIncompatible(t *testing.T) {
require.Contains(t, err.Error(), "gas_limit")
}
+func TestExecutionPayloadBidServiceKnownPayloadWithoutGasLimitIsIgnored(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, _, ethClockMock, fcMock, epbsPool := setupExecutionPayloadBidService(t, ctrl)
+ msg := newTestSignedExecutionPayloadBid(100, 1, 1000)
+ addPreferencesToPool(epbsPool, 100)
+ fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated
+ delete(fcMock.ExecutionPayloadGasLimitMap, msg.Message.ParentBlockHash)
+ ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100))
+ var blsCalls int
+ blsVerify = func(_, _, _ []byte) (bool, error) {
+ blsCalls++
+ return true, nil
+ }
+
+ err := service.ProcessMessage(context.Background(), nil, msg)
+ require.ErrorIs(t, err, ErrIgnore)
+ require.Contains(t, err.Error(), "gas limit")
+ require.Zero(t, blsCalls)
+ require.Zero(t, service.validationStateCache.Len())
+}
+
+func TestExecutionPayloadBidServiceHeadUnavailableDoesNotFetchValidationState(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, _, ethClockMock, fcMock, epbsPool := setupExecutionPayloadBidService(t, ctrl)
+ msg := newTestSignedExecutionPayloadBid(100, 1, 1000)
+ addPreferencesToPool(epbsPool, 100)
+ fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated
+ fcMock.GetHeadNodeFn = func() (forkchoice.ForkChoiceNode, error) {
+ return forkchoice.ForkChoiceNode{}, errors.New("head unavailable")
+ }
+ ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100))
+
+ err := service.ProcessMessage(context.Background(), nil, msg)
+ require.ErrorIs(t, err, ErrIgnore)
+ require.Contains(t, err.Error(), "head unavailable")
+ require.Zero(t, service.validationStateCache.Len())
+}
+
func TestExecutionPayloadBidServiceDuplicate(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -463,6 +744,7 @@ func TestExecutionPayloadBidServiceBuilderInactiveError(t *testing.T) {
parentState.GetBuilders().Get(1).WithdrawableEpoch = 3
fcMock.StateAtBlockRootVal[msg.Message.ParentBlockRoot] = parentState
fcMock.Headers[msg.Message.ParentBlockRoot] = &cltypes.BeaconBlockHeader{}
+ fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated
addPreferencesToPool(epbsPool, 100)
ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100))
@@ -473,7 +755,7 @@ func TestExecutionPayloadBidServiceBuilderInactiveError(t *testing.T) {
require.Contains(t, err.Error(), "not active")
// Should NOT be marked as seen
- seenKey := seenBidKey{builderIndex: 1, slot: 100}
+ seenKey := newSeenBidKey(msg.Message)
require.False(t, service.seenCache.Contains(seenKey))
}
@@ -512,6 +794,8 @@ func TestExecutionPayloadBidServiceParentBlockRootUnknown(t *testing.T) {
// parent_block_hash known, but parent_block_root NOT known
fcMock.ExecutionPayloadStatusMap[common.HexToHash("0xaaaa")] = execution_client.PayloadStatusValidated
// Headers map is empty → parent_block_root not found
+ delete(fcMock.Headers, msg.Message.ParentBlockRoot)
+ delete(fcMock.StateAtBlockRootVal, msg.Message.ParentBlockRoot)
err := service.ProcessMessage(context.Background(), nil, msg)
require.Error(t, err)
@@ -574,11 +858,12 @@ func TestExecutionPayloadBidServiceStoreValidBidDoesNotOverwriteHigherBid(t *tes
defer ctrl.Finish()
service, _, _, _, epbsPool := setupExecutionPayloadBidService(t, ctrl)
+ service.now = func() time.Time { return time.Unix(100*12, 0) }
high := newTestSignedExecutionPayloadBid(100, 1, 2000)
low := newTestSignedExecutionPayloadBid(100, 2, 500)
- require.NoError(t, service.storeValidBid(high))
- err := service.storeValidBid(low)
+ require.NoError(t, service.storeValidBidAt(high, time.Time{}))
+ err := service.storeValidBidAt(low, time.Time{})
require.Error(t, err)
require.True(t, errors.Is(err, ErrIgnore))
@@ -586,7 +871,84 @@ func TestExecutionPayloadBidServiceStoreValidBidDoesNotOverwriteHigherBid(t *tes
stored, found := epbsPool.HighestBids.Get(bidKey)
require.True(t, found)
require.Equal(t, high, stored)
- require.False(t, service.seenCache.Contains(seenBidKey{builderIndex: 2, slot: 100}))
+ require.False(t, service.seenCache.Contains(newSeenBidKey(low.Message)))
+}
+
+func TestExecutionPayloadBidServiceSeenBidsRetainsEveryBidInGossipWindow(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ service, _, _, _, _ := setupExecutionPayloadBidService(t, ctrl)
+ service.now = func() time.Time { return time.Unix(100*12, 0) }
+
+ const builders = 576
+ for builderIndex := range uint64(builders) {
+ bid := newTestSignedExecutionPayloadBid(100, builderIndex, 1000)
+ bid.Message.ParentBlockHash[0] = byte(builderIndex)
+ bid.Message.ParentBlockHash[1] = byte(builderIndex >> 8)
+ bid.Message.ParentBlockRoot[0] = byte(builderIndex)
+ bid.Message.ParentBlockRoot[1] = byte(builderIndex >> 8)
+ require.NoError(t, service.storeValidBidAt(bid, service.now()))
+ }
+
+ first := newTestSignedExecutionPayloadBid(100, 0, 1000)
+ require.True(t, service.seenCache.Contains(newSeenBidKey(first.Message)))
+ firstKey := pool.HighestBidKey{Slot: 100, ParentBlockHash: first.Message.ParentBlockHash, ParentBlockRoot: first.Message.ParentBlockRoot}
+ _, found := service.epbsPool.HighestBids.Get(firstKey)
+ require.True(t, found)
+ lower := newTestSignedExecutionPayloadBid(100, builders+1, 999)
+ err := service.storeValidBidAt(lower, service.now())
+ require.ErrorIs(t, err, ErrIgnore)
+ stored, found := service.epbsPool.HighestBids.Get(firstKey)
+ require.True(t, found)
+ require.Equal(t, uint64(1000), stored.Message.Value)
+}
+
+func TestExecutionPayloadBidServiceSeenBidsPrunesAfterGossipWindow(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ service, _, _, _, _ := setupExecutionPayloadBidService(t, ctrl)
+ boundary := time.Unix(101*12, 0).Add(gloasMaximumClockDisparity)
+ service.now = func() time.Time { return boundary }
+ stale := newTestSignedExecutionPayloadBid(100, 1, 1000)
+ require.NoError(t, service.storeValidBidAt(stale, service.now()))
+ require.True(t, service.seenCache.Contains(newSeenBidKey(stale.Message)))
+
+ service.now = func() time.Time { return boundary.Add(time.Nanosecond) }
+ trigger := newTestSignedExecutionPayloadBid(101, 2, 1000)
+ trigger.Message.ParentBlockHash = common.Hash{2}
+ trigger.Message.ParentBlockRoot = common.Hash{3}
+ require.NoError(t, service.storeValidBidAt(trigger, service.now()))
+ require.False(t, service.seenCache.Contains(newSeenBidKey(stale.Message)))
+ _, found := service.epbsPool.HighestBids.Get(pool.HighestBidKey{
+ Slot: stale.Message.Slot,
+ ParentBlockHash: stale.Message.ParentBlockHash,
+ ParentBlockRoot: stale.Message.ParentBlockRoot,
+ })
+ require.False(t, found)
+}
+
+func TestExecutionPayloadBidServiceRetainsPreferencesThroughBidWindow(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ service, _, _, fc, epbsPool := setupExecutionPayloadBidService(t, ctrl)
+ bid := newTestSignedExecutionPayloadBid(100, 1, 1000)
+ dependentRoot := fc.Ancestors[63].Root
+ preferences := &cltypes.SignedProposerPreferences{Message: &cltypes.ProposerPreferences{
+ ProposalSlot: bid.Message.Slot,
+ DependentRoot: dependentRoot,
+ }}
+ epbsPool.ProposerPreferences.Add(pool.ProposerPreferencesKey{Slot: bid.Message.Slot, DependentRoot: dependentRoot}, preferences)
+ now := time.Unix(100*12, 0).Add(gloasMaximumClockDisparity + time.Nanosecond)
+ epbsPool.ProposerPreferences.PruneSlots(func(entrySlot uint64) bool {
+ return isPastBidWindow(service.ethClock, service.beaconCfg, now, entrySlot)
+ })
+
+ matched, ok, err := service.matchingProposerPreferences(bid)
+ require.NoError(t, err)
+ require.True(t, ok)
+ require.Same(t, preferences, matched)
}
func TestExecutionPayloadBidServiceRejectsLowerBidBeforeStateFetch(t *testing.T) {
@@ -608,26 +970,27 @@ func TestExecutionPayloadBidServiceRejectsLowerBidBeforeStateFetch(t *testing.T)
require.True(t, errors.Is(err, ErrIgnore))
require.Contains(t, err.Error(), "not higher than existing")
require.Zero(t, service.validationStateCache.Len())
- require.Equal(t, int32(0), service.pendingCount.Load())
}
-func TestExecutionPayloadBidServiceDifferentParentHashes(t *testing.T) {
+func TestExecutionPayloadBidServiceSameBuilderDistinctCompatibleParents(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
service, _, ethClockMock, fcMock, epbsPool := setupExecutionPayloadBidService(t, ctrl)
- parentHash1 := common.HexToHash("0x1111")
- parentHash2 := common.HexToHash("0x2222")
- parentRoot := common.HexToHash("0xbbbb")
+ parentHash1 := common.HexToHash("0xaaaa")
+ parentHash2 := common.HexToHash("0xdddd")
+ parentRoot1 := common.HexToHash("0xbbbb")
+ parentRoot2 := common.HexToHash("0xeeee")
fcMock.ExecutionPayloadStatusMap[parentHash1] = execution_client.PayloadStatusValidated
fcMock.ExecutionPayloadStatusMap[parentHash2] = execution_client.PayloadStatusValidated
- fcMock.Headers[parentRoot] = &cltypes.BeaconBlockHeader{}
+ fcMock.ExecutionPayloadGasLimitMap[parentHash2] = 30_000_000
+ fcMock.Headers[parentRoot1] = &cltypes.BeaconBlockHeader{Slot: 99}
+ fcMock.StateAtBlockRootVal[parentRoot2] = newBidParentState(service.beaconCfg, testDependentRoot)
addPreferencesToPool(epbsPool, 100)
- // Bid 1: parentBlockHash = 0x1111, value 1000
msg1 := newTestSignedExecutionPayloadBid(100, 1, 1000)
msg1.Message.ParentBlockHash = parentHash1
ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100))
@@ -635,17 +998,17 @@ func TestExecutionPayloadBidServiceDifferentParentHashes(t *testing.T) {
err := service.ProcessMessage(context.Background(), nil, msg1)
require.NoError(t, err)
- // Bid 2: parentBlockHash = 0x2222, value 500 (separate market → should succeed)
- msg2 := newTestSignedExecutionPayloadBid(100, 2, 500)
+ msg2 := newTestSignedExecutionPayloadBid(100, 1, 500)
msg2.Message.ParentBlockHash = parentHash2
+ msg2.Message.ParentBlockRoot = parentRoot2
ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100))
err = service.ProcessMessage(context.Background(), nil, msg2)
require.NoError(t, err)
// Both should have their own highest bid
- bidKey1 := pool.HighestBidKey{Slot: 100, ParentBlockHash: parentHash1, ParentBlockRoot: parentRoot}
- bidKey2 := pool.HighestBidKey{Slot: 100, ParentBlockHash: parentHash2, ParentBlockRoot: parentRoot}
+ bidKey1 := pool.HighestBidKey{Slot: 100, ParentBlockHash: parentHash1, ParentBlockRoot: parentRoot1}
+ bidKey2 := pool.HighestBidKey{Slot: 100, ParentBlockHash: parentHash2, ParentBlockRoot: parentRoot2}
stored1, found1 := epbsPool.HighestBids.Get(bidKey1)
stored2, found2 := epbsPool.HighestBids.Get(bidKey2)
require.True(t, found1)
@@ -672,7 +1035,7 @@ func TestExecutionPayloadBidServiceSuccess(t *testing.T) {
require.NoError(t, err)
// Verify stored in seen cache
- seenKey := seenBidKey{builderIndex: 1, slot: 100}
+ seenKey := newSeenBidKey(msg.Message)
require.True(t, service.seenCache.Contains(seenKey))
// Verify stored in pool
@@ -682,94 +1045,59 @@ func TestExecutionPayloadBidServiceSuccess(t *testing.T) {
require.Equal(t, msg, stored)
}
-func TestExecutionPayloadBidServicePendingQueueCap(t *testing.T) {
+func TestExecutionPayloadBidServiceRejectsWhenPreferencesAreMissing(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
+ service, _, ethClock, _, _ := setupExecutionPayloadBidService(t, ctrl)
+ msg := newTestSignedExecutionPayloadBid(100, 1, 1000)
+ ethClock.EXPECT().GetCurrentSlot().Return(uint64(100))
- service, _, _, _, _ := setupExecutionPayloadBidService(t, ctrl)
-
- // Fill the queue to the cap
- service.pendingCount.Store(maxPendingBids)
-
- msg := newTestSignedExecutionPayloadBid(100, 999, 1000)
-
- service.queuePendingBid(msg)
-
- // Should still be at cap — new item was rejected
- require.Equal(t, int32(maxPendingBids), service.pendingCount.Load())
- key := pendingBidKeyFor(msg)
- _, exists := service.pendingBids.Load(key)
- require.False(t, exists)
-}
-
-func TestExecutionPayloadBidServicePendingQueueKeepsDistinctSameBuilderSlot(t *testing.T) {
- ctrl := gomock.NewController(t)
- defer ctrl.Finish()
-
- service, _, _, _, _ := setupExecutionPayloadBidService(t, ctrl)
- first := newTestSignedExecutionPayloadBid(100, 1, 1000)
- second := newTestSignedExecutionPayloadBid(100, 1, 1000)
- second.Signature[0] = 1
-
- service.queuePendingBid(first)
- service.queuePendingBid(first)
- service.queuePendingBid(second)
-
- require.Equal(t, int32(2), service.pendingCount.Load())
- stored, firstExists := service.pendingBids.Load(pendingBidKeyFor(first))
- require.True(t, firstExists)
- require.Same(t, first, stored.(*pendingBidJob).msg)
- stored, secondExists := service.pendingBids.Load(pendingBidKeyFor(second))
- require.True(t, secondExists)
- require.Same(t, second, stored.(*pendingBidJob).msg)
+ err := service.ProcessMessage(context.Background(), nil, msg)
+ require.ErrorIs(t, err, ErrIgnore)
+ require.Contains(t, err.Error(), "proposer preferences not available")
}
-func TestExecutionPayloadBidServiceDeletePendingBidDoesNotRemoveOtherSameBuilderSlot(t *testing.T) {
+func TestExecutionPayloadBidServiceRejectsInvalidSignatureVariantsBeforeValidBid(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
- service, _, _, _, _ := setupExecutionPayloadBidService(t, ctrl)
- first := newTestSignedExecutionPayloadBid(100, 1, 1000)
- second := newTestSignedExecutionPayloadBid(100, 1, 2000)
-
- service.queuePendingBid(first)
- firstKey := pendingBidKeyFor(first)
- firstJob, exists := service.pendingBids.Load(firstKey)
- require.True(t, exists)
-
- service.queuePendingBid(second)
- require.True(t, service.deletePendingBid(firstKey, firstJob.(*pendingBidJob)))
- require.Equal(t, int32(1), service.pendingCount.Load())
+ service, _, ethClock, fc, epbsPool := setupExecutionPayloadBidService(t, ctrl)
+ valid := newTestSignedExecutionPayloadBid(100, 1, 1000)
+ blsVerify = func(signature, _, _ []byte) (bool, error) { return signature[0] == 0, nil }
+ fc.Headers[valid.Message.ParentBlockRoot] = &cltypes.BeaconBlockHeader{Slot: 99}
+ fc.ExecutionPayloadStatusMap[valid.Message.ParentBlockHash] = execution_client.PayloadStatusValidated
+ addPreferencesToPool(epbsPool, 100)
+ const invalidVariants = 5
+ ethClock.EXPECT().GetCurrentSlot().Return(uint64(100)).Times(invalidVariants + 1)
+
+ for i := range invalidVariants {
+ invalid := newTestSignedExecutionPayloadBid(100, 1, 1000)
+ invalid.Signature[0] = byte(i + 1)
+ err := service.ProcessMessage(context.Background(), nil, invalid)
+ require.ErrorContains(t, err, "invalid builder signature")
+ }
+ require.NoError(t, service.ProcessMessage(context.Background(), nil, valid))
- current, exists := service.pendingBids.Load(pendingBidKeyFor(second))
- require.True(t, exists)
- require.Same(t, second, current.(*pendingBidJob).msg)
+ stored, ok := epbsPool.HighestBids.Get(pool.HighestBidKey{
+ Slot: valid.Message.Slot,
+ ParentBlockHash: valid.Message.ParentBlockHash,
+ ParentBlockRoot: valid.Message.ParentBlockRoot,
+ })
+ require.True(t, ok)
+ require.Equal(t, valid.Signature, stored.Signature)
}
-func TestExecutionPayloadBidServicePendingQueueCapConcurrent(t *testing.T) {
+func TestExecutionPayloadBidServiceRejectsNonAdvancingKnownParentBeforeQueue(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
+ service, _, ethClock, fc, _ := setupExecutionPayloadBidService(t, ctrl)
+ msg := newTestSignedExecutionPayloadBid(100, 1, 1000)
+ fc.Headers[msg.Message.ParentBlockRoot] = &cltypes.BeaconBlockHeader{Slot: 100}
+ require.NoError(t, fc.StateAtBlockRootVal[msg.Message.ParentBlockRoot].SetSlot(100))
+ ethClock.EXPECT().GetCurrentSlot().Return(uint64(100))
- service, _, _, _, _ := setupExecutionPayloadBidService(t, ctrl)
-
- service.pendingCount.Store(maxPendingBids - 5)
-
- var wg sync.WaitGroup
- for i := range 100 {
- wg.Go(func() {
- msg := newTestSignedExecutionPayloadBid(uint64(10000+i), uint64(i), 1000)
- service.queuePendingBid(msg)
- })
- }
- wg.Wait()
-
- require.Equal(t, int32(maxPendingBids), service.pendingCount.Load())
- stored := 0
- service.pendingBids.Range(func(_, _ any) bool {
- stored++
- return true
- })
- require.Equal(t, 5, stored)
+ err := service.ProcessMessage(context.Background(), nil, msg)
+ require.Error(t, err)
}
func TestExecutionPayloadBidServiceDecodeGossipMessage(t *testing.T) {
@@ -802,6 +1130,24 @@ func TestExecutionPayloadBidServiceDecodeGossipMessageInvalid(t *testing.T) {
require.Error(t, err)
}
+func TestExecutionPayloadBidServiceDecodeGossipMessageRejectsNonCanonicalOffsets(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, _, _, _, _ := setupExecutionPayloadBidService(t, ctrl)
+ encoded, err := newTestSignedExecutionPayloadBid(100, 1, 1000).EncodeSSZ(nil)
+ require.NoError(t, err)
+ const signedFixedSize = 100
+ const bidFixedSize = 224
+ const commitmentsOffsetPosition = 188
+ nonCanonical := append([]byte(nil), encoded[:signedFixedSize+bidFixedSize]...)
+ nonCanonical = append(nonCanonical, make([]byte, 4)...)
+ nonCanonical = append(nonCanonical, encoded[signedFixedSize+bidFixedSize:]...)
+ offset := signedFixedSize + commitmentsOffsetPosition
+ binary.LittleEndian.PutUint32(nonCanonical[offset:], binary.LittleEndian.Uint32(encoded[offset:])+4)
+
+ _, err = service.DecodeGossipMessage("peer123", nonCanonical, clparams.GloasVersion)
+ require.Error(t, err)
+}
+
func TestExecutionPayloadBidServiceNonZeroExecutionPayment(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -834,6 +1180,7 @@ func TestExecutionPayloadBidServiceFeeRecipientMismatch(t *testing.T) {
err := service.ProcessMessage(context.Background(), nil, msg)
require.Error(t, err)
+ require.ErrorIs(t, err, ErrIgnore)
require.Contains(t, err.Error(), "fee_recipient")
require.Contains(t, err.Error(), "does not match")
}
@@ -869,6 +1216,7 @@ func TestExecutionPayloadBidServiceRejectsPrevRandaoMismatch(t *testing.T) {
require.NoError(t, parentState.SetRandaoMixAt(int(state2.Epoch(parentState)%service.beaconCfg.EpochsPerHistoricalVector), common.Hash{0x42}))
fcMock.StateAtBlockRootVal[msg.Message.ParentBlockRoot] = parentState
fcMock.Headers[msg.Message.ParentBlockRoot] = &cltypes.BeaconBlockHeader{}
+ fcMock.ExecutionPayloadStatusMap[msg.Message.ParentBlockHash] = execution_client.PayloadStatusValidated
addPreferencesToPool(epbsPool, 100)
ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100))
@@ -914,7 +1262,7 @@ func TestExecutionPayloadBidServiceFailedValidationNotStored(t *testing.T) {
require.Error(t, err)
// Should NOT be in seen cache
- seenKey := seenBidKey{builderIndex: 1, slot: 100}
+ seenKey := newSeenBidKey(msg.Message)
require.False(t, service.seenCache.Contains(seenKey))
// Should NOT be in pool
diff --git a/cl/phase1/network/services/execution_payload_service.go b/cl/phase1/network/services/execution_payload_service.go
index e9fa597abd9..bb7740bf411 100644
--- a/cl/phase1/network/services/execution_payload_service.go
+++ b/cl/phase1/network/services/execution_payload_service.go
@@ -56,6 +56,7 @@ type pendingEnvelopeKey struct {
type envelopeJob struct {
envelope *cltypes.SignedExecutionPayloadEnvelope
creationTime time.Time
+ ownedBytes uint64
}
const (
@@ -63,6 +64,7 @@ const (
pendingEnvelopeExpiry = 30 * time.Second
pendingEnvelopeCheckInterval = 100 * time.Millisecond
maxPendingEnvelopes = 1024
+ maxPendingEnvelopeBytes = 4 * clparams.MaxChunkSize
)
type executionPayloadService struct {
@@ -76,6 +78,8 @@ type executionPayloadService struct {
// Pending envelopes waiting for block to arrive
pendingEnvelopes sync.Map // pendingEnvelopeKey -> *envelopeJob
pendingCount atomic.Int32
+ pendingBytes atomic.Uint64
+ pendingMu sync.Mutex
pendingCond *sync.Cond
}
@@ -113,7 +117,7 @@ func (s *executionPayloadService) DecodeGossipMessage(_ peer.ID, data []byte, ve
obj := &cltypes.SignedExecutionPayloadEnvelope{
Message: cltypes.NewExecutionPayloadEnvelope(s.beaconCfg),
}
- if err := obj.DecodeSSZ(data, int(version)); err != nil {
+ if err := obj.DecodeSSZStrict(data, int(version)); err != nil {
return nil, err
}
return obj, nil
@@ -128,6 +132,9 @@ func (s *executionPayloadService) ProcessMessage(ctx context.Context, _ *uint64,
}
envelope := signedEnvelope.Message
+ if err := validateEnvelopeLimits(s.beaconCfg, envelope); err != nil {
+ return err
+ }
beaconBlockRoot := envelope.BeaconBlockRoot
builderIndex := envelope.BuilderIndex
@@ -139,18 +146,12 @@ func (s *executionPayloadService) ProcessMessage(ctx context.Context, _ *uint64,
// A client MAY queue payload for processing once the block is retrieved.
block, ok := s.forkchoiceStore.GetBlock(beaconBlockRoot)
if !ok || block == nil {
- // Block hasn't arrived yet, queue envelope for later processing
- s.queuePendingEnvelope(beaconBlockRoot, signedEnvelope)
- // Also store in forkchoice's pendingEnvelopes so OnBlock can process it immediately
- // when the block arrives, instead of waiting for the 100ms polling loop.
- // validatePayload must be true: if the block arrives (via OnBlock) before this call
- // acquires f.mu, the envelope will be applied with validatePayload — ensuring
- // NewPayload is sent to the EL. With false, a mutex-contention race silently
- // marks the envelope as processed without ever notifying the EL, permanently
- // breaking the chain.
- if err := s.forkchoiceStore.OnExecutionPayload(ctx, signedEnvelope, false, true); err != nil {
- log.Warn("Failed to eagerly store pending execution payload envelope in forkchoice",
- "beaconBlockRoot", beaconBlockRoot, "builderIndex", builderIndex, "err", err)
+ queued, err := s.queuePendingEnvelope(beaconBlockRoot, signedEnvelope)
+ if err != nil {
+ return fmt.Errorf("%w: %w", ErrIgnore, err)
+ }
+ if !queued {
+ return fmt.Errorf("%w: execution payload envelope already queued", ErrIgnore)
}
log.Trace("Queued execution payload envelope for later processing",
"beaconBlockRoot", beaconBlockRoot,
@@ -169,14 +170,20 @@ func (s *executionPayloadService) ProcessMessage(ctx context.Context, _ *uint64,
}
// [IGNORE] The envelope is from a slot greater than or equal to the latest finalized slot
- finalizedSlot := s.forkchoiceStore.FinalizedSlot()
- if block.Block.Slot < finalizedSlot {
- return fmt.Errorf("%w: envelope slot %d < finalized slot %d", ErrIgnore, block.Block.Slot, finalizedSlot)
+ finalizedSlot := s.forkchoiceStore.FinalizedCheckpoint().Epoch * s.beaconCfg.SlotsPerEpoch
+ if envelope.Payload == nil {
+ return errors.New("nil execution payload")
+ }
+ if envelope.Payload.SlotNumber < finalizedSlot {
+ return fmt.Errorf("%w: envelope slot %d < finalized slot %d", ErrIgnore, envelope.Payload.SlotNumber, finalizedSlot)
}
// Process the execution payload through forkchoice
// Note: bid matching and signature verification are done in OnExecutionPayload.validateEnvelopeAgainstBlock
if err := s.forkchoiceStore.OnExecutionPayload(ctx, signedEnvelope, true, true); err != nil {
+ if errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) {
+ s.emitExecutionPayloadGossip(block, envelope)
+ }
if errors.Is(err, forkchoice.ErrIgnore) || errors.Is(err, forkchoice.ErrEIP7594ColumnDataNotAvailable) ||
errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return fmt.Errorf("%w: %v", ErrIgnore, err) //nolint:errorlint // converting, not wrapping: the forkchoice sentinels must not stay matchable
@@ -188,6 +195,13 @@ func (s *executionPayloadService) ProcessMessage(ctx context.Context, _ *uint64,
// This ensures invalid envelopes (e.g., with forged signatures) don't block valid ones
s.seenEnvelopesCache.Add(seenKey, struct{}{})
+ s.emitExecutionPayloadGossip(block, envelope)
+ s.emitters.Operation().SendExecutionPayload(&beaconevents.ExecutionPayloadData{
+ Slot: block.Block.Slot, BuilderIndex: builderIndex, BlockHash: envelope.Payload.BlockHash, BlockRoot: beaconBlockRoot,
+ ExecutionOptimistic: s.forkchoiceStore.IsRootOptimistic(beaconBlockRoot),
+ })
+ s.emitFullHeadUpdate(block, beaconBlockRoot)
+
// Emit SSE event for execution_payload_available [New in Gloas:EIP7732]
s.emitters.Operation().SendExecutionPayloadAvailable(&beaconevents.ExecutionPayloadAvailableData{
Slot: block.Block.Slot,
@@ -202,36 +216,121 @@ func (s *executionPayloadService) ProcessMessage(ctx context.Context, _ *uint64,
return nil
}
-// queuePendingEnvelope adds an envelope to the pending queue for later processing
-func (s *executionPayloadService) queuePendingEnvelope(blockRoot common.Hash, envelope *cltypes.SignedExecutionPayloadEnvelope) {
- if s.pendingCount.Add(1) > maxPendingEnvelopes {
- s.pendingCount.Add(-1)
+func validateEnvelopeLimits(cfg *clparams.BeaconChainConfig, envelope *cltypes.ExecutionPayloadEnvelope) error {
+ if cfg == nil || envelope == nil || envelope.Payload == nil {
+ return errors.New("missing execution payload envelope fields")
+ }
+ if err := validateExecutionRequestsLimits(cfg, envelope.ExecutionRequests); err != nil {
+ return err
+ }
+ if envelope.Payload.Withdrawals == nil {
+ return errors.New("missing payload withdrawals")
+ }
+ if uint64(envelope.Payload.Withdrawals.Len()) > cfg.MaxWithdrawalsPerPayload {
+ return fmt.Errorf("payload withdrawals count %d exceeds limit %d", envelope.Payload.Withdrawals.Len(), cfg.MaxWithdrawalsPerPayload)
+ }
+ return nil
+}
+
+func (s *executionPayloadService) emitExecutionPayloadGossip(block *cltypes.SignedBeaconBlock, envelope *cltypes.ExecutionPayloadEnvelope) {
+ s.emitters.Operation().SendExecutionPayloadGossip(&beaconevents.ExecutionPayloadGossipData{
+ Slot: block.Block.Slot,
+ BuilderIndex: envelope.BuilderIndex,
+ BlockHash: envelope.Payload.BlockHash,
+ BlockRoot: envelope.BeaconBlockRoot,
+ })
+}
+
+func (s *executionPayloadService) emitFullHeadUpdate(block *cltypes.SignedBeaconBlock, blockRoot common.Hash) {
+ headRoot, headSlot, err := s.forkchoiceStore.GetHead(nil)
+ if err != nil || headRoot != blockRoot || s.beaconCfg.SlotsPerEpoch == 0 {
+ return
+ }
+ headState, err := s.forkchoiceStore.GetStateAtBlockRoot(blockRoot, true)
+ if err != nil || headState == nil {
+ return
+ }
+ headEvent, err := beaconevents.BuildHeadV2Data(
+ s.beaconCfg,
+ headState,
+ headSlot,
+ headRoot,
+ block.Block.StateRoot,
+ "full",
+ s.forkchoiceStore.IsRootOptimistic(blockRoot),
+ )
+ if err != nil {
return
}
+ s.emitters.WithHeadEventLock(func() {
+ currentHeadRoot, currentHeadSlot, err := s.forkchoiceStore.GetHead(nil)
+ currentPayloadStatus := beaconevents.PayloadStatusName(s.forkchoiceStore.GetHeadPayloadStatus())
+ currentOptimistic := s.forkchoiceStore.IsRootOptimistic(currentHeadRoot)
+ if err != nil || currentHeadRoot != headRoot || currentHeadSlot != headSlot ||
+ currentPayloadStatus != headEvent.Data.PayloadStatus || currentOptimistic != headEvent.Data.ExecutionOptimistic {
+ return
+ }
+ s.emitters.State().SendHeadV2(headEvent)
+ })
+}
+// queuePendingEnvelope adds an envelope to the pending queue for later processing
+func (s *executionPayloadService) queuePendingEnvelope(blockRoot common.Hash, envelope *cltypes.SignedExecutionPayloadEnvelope) (bool, error) {
// Compute envelope hash to allow multiple candidates per block
envelopeHash, err := envelope.HashSSZ()
if err != nil {
- s.pendingCount.Add(-1)
- log.Warn("Failed to hash envelope for pending queue", "blockRoot", blockRoot, "err", err)
- return
+ return false, fmt.Errorf("failed to hash envelope for pending queue: %w", err)
}
key := pendingEnvelopeKey{
blockRoot: blockRoot,
envelopeHash: envelopeHash,
}
+ ownedBytes := envelope.EncodingSizeSSZ()
+ if ownedBytes <= 0 {
+ return false, errors.New("invalid pending execution payload envelope size")
+ }
+ ownedBytes64 := uint64(ownedBytes)
+
+ s.pendingMu.Lock()
+ defer s.pendingMu.Unlock()
+ if _, loaded := s.pendingEnvelopes.Load(key); loaded {
+ return false, nil
+ }
+ currentBytes := s.pendingBytes.Load()
+ if s.pendingCount.Load() >= maxPendingEnvelopes || ownedBytes64 > maxPendingEnvelopeBytes || currentBytes > maxPendingEnvelopeBytes-ownedBytes64 {
+ return false, errors.New("pending execution payload envelope capacity reached")
+ }
- if _, loaded := s.pendingEnvelopes.LoadOrStore(key, &envelopeJob{
+ s.pendingEnvelopes.Store(key, &envelopeJob{
envelope: envelope,
creationTime: time.Now(),
- }); loaded {
- s.pendingCount.Add(-1)
+ ownedBytes: ownedBytes64,
+ })
+ s.pendingCount.Add(1)
+ s.pendingBytes.Store(currentBytes + ownedBytes64)
+ s.pendingCond.L.Lock()
+ s.pendingCond.Signal()
+ s.pendingCond.L.Unlock()
+ return true, nil
+}
+
+func (s *executionPayloadService) removePendingEnvelope(key pendingEnvelopeKey) (*envelopeJob, bool) {
+ s.pendingMu.Lock()
+ defer s.pendingMu.Unlock()
+ value, loaded := s.pendingEnvelopes.LoadAndDelete(key)
+ if !loaded {
+ return nil, false
+ }
+ job := value.(*envelopeJob)
+ s.pendingCount.Add(-1)
+ currentBytes := s.pendingBytes.Load()
+ if job.ownedBytes >= currentBytes {
+ s.pendingBytes.Store(0)
} else {
- s.pendingCond.L.Lock()
- s.pendingCond.Signal()
- s.pendingCond.L.Unlock()
+ s.pendingBytes.Store(currentBytes - job.ownedBytes)
}
+ return job, true
}
// loop is the background goroutine that processes pending envelopes
@@ -282,9 +381,9 @@ func (s *executionPayloadService) processPendingEnvelopes(ctx context.Context) {
// Check expiry
if time.Since(job.creationTime) > pendingEnvelopeExpiry {
- s.pendingEnvelopes.Delete(pendingKey)
- s.pendingCount.Add(-1)
- log.Trace("Pending envelope expired", "blockRoot", pendingKey.blockRoot)
+ if _, removed := s.removePendingEnvelope(pendingKey); removed {
+ log.Trace("Pending envelope expired", "blockRoot", pendingKey.blockRoot)
+ }
return true
}
@@ -295,8 +394,10 @@ func (s *executionPayloadService) processPendingEnvelopes(ctx context.Context) {
}
// Block arrived, remove from pending and process
- s.pendingEnvelopes.Delete(pendingKey)
- s.pendingCount.Add(-1)
+ job, removed := s.removePendingEnvelope(pendingKey)
+ if !removed {
+ return true
+ }
// Re-run full validation via ProcessMessage
if err := s.ProcessMessage(ctx, nil, job.envelope); err != nil {
diff --git a/cl/phase1/network/services/execution_payload_service_test.go b/cl/phase1/network/services/execution_payload_service_test.go
index 1578d4d630d..1949dda86d8 100644
--- a/cl/phase1/network/services/execution_payload_service_test.go
+++ b/cl/phase1/network/services/execution_payload_service_test.go
@@ -18,8 +18,10 @@ package services
import (
"context"
+ "encoding/binary"
"errors"
"sync"
+ "sync/atomic"
"testing"
"time"
@@ -29,7 +31,9 @@ import (
"github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/cltypes"
"github.com/erigontech/erigon/cl/cltypes/solid"
+ "github.com/erigontech/erigon/cl/phase1/core/state"
"github.com/erigontech/erigon/cl/phase1/core/state/lru"
+ "github.com/erigontech/erigon/cl/phase1/forkchoice"
"github.com/erigontech/erigon/cl/phase1/forkchoice/mock_services"
"github.com/erigontech/erigon/common"
)
@@ -47,8 +51,10 @@ func newTestSignedEnvelope(slot uint64, blockRoot common.Hash, builderIndex uint
envelope.BuilderIndex = builderIndex
// Initialize Eth1Block fields needed for HashSSZ
if envelope.Payload != nil {
+ envelope.Payload.SlotNumber = slot
envelope.Payload.Extra = solid.NewExtraData()
envelope.Payload.Transactions = &solid.TransactionsSSZ{}
+ envelope.Payload.Withdrawals = solid.NewStaticListSSZ[*cltypes.Withdrawal](int(clparams.MainnetBeaconConfig.MaxWithdrawalsPerPayload), 44)
}
return &cltypes.SignedExecutionPayloadEnvelope{
Message: envelope,
@@ -56,6 +62,23 @@ func newTestSignedEnvelope(slot uint64, blockRoot common.Hash, builderIndex uint
}
}
+func TestExecutionPayloadServiceDecodeRejectsNonCanonicalOffsets(t *testing.T) {
+ service, _ := setupExecutionPayloadService(t)
+ encoded, err := newTestSignedEnvelope(100, common.Hash{1}, 1).EncodeSSZ(nil)
+ require.NoError(t, err)
+ const signedFixedSize = 100
+ const envelopeFixedSize = 80
+ nonCanonical := append([]byte(nil), encoded[:signedFixedSize+envelopeFixedSize]...)
+ nonCanonical = append(nonCanonical, make([]byte, 4)...)
+ nonCanonical = append(nonCanonical, encoded[signedFixedSize+envelopeFixedSize:]...)
+ for offset := signedFixedSize; offset < signedFixedSize+8; offset += 4 {
+ binary.LittleEndian.PutUint32(nonCanonical[offset:], binary.LittleEndian.Uint32(encoded[offset:])+4)
+ }
+
+ _, err = service.DecodeGossipMessage("peer123", nonCanonical, clparams.GloasVersion)
+ require.Error(t, err)
+}
+
func TestExecutionPayloadServiceNilEnvelope(t *testing.T) {
service, _ := setupExecutionPayloadService(t)
@@ -97,6 +120,194 @@ func TestExecutionPayloadServiceBlockNotFound(t *testing.T) {
require.NoError(t, err)
}
+func TestExecutionPayloadServiceEmitsGossipAndImportedEvents(t *testing.T) {
+ cfg := &clparams.MainnetBeaconConfig
+ forkchoiceMock := mock_services.NewForkChoiceStorageMock(t)
+ emitter := beaconevents.NewEventEmitter()
+ service := NewExecutionPayloadService(t.Context(), forkchoiceMock, cfg, emitter)
+ events := make(chan *beaconevents.EventStream, 3)
+ subscription := emitter.Operation().Subscribe(events)
+ defer subscription.Unsubscribe()
+ stateEvents := make(chan *beaconevents.EventStream, 1)
+ stateSubscription := emitter.State().Subscribe(stateEvents)
+ defer stateSubscription.Unsubscribe()
+
+ blockRoot := common.Hash{1}
+ stateRoot := common.Hash{2}
+ forkchoiceMock.Blocks[blockRoot] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 100, StateRoot: stateRoot}}
+ headState := state.New(cfg)
+ headState.SetVersion(clparams.GloasVersion)
+ require.NoError(t, headState.SetSlot(100))
+ require.NoError(t, headState.SetBlockRootAt(63, common.Hash{3}))
+ require.NoError(t, headState.SetBlockRootAt(95, common.Hash{4}))
+ forkchoiceMock.GetStateAtBlockRootFn = func(root common.Hash, alwaysCopy bool) (*state.CachingBeaconState, error) {
+ require.Equal(t, blockRoot, root)
+ require.True(t, alwaysCopy)
+ return headState, nil
+ }
+ forkchoiceMock.HeadVal = blockRoot
+ forkchoiceMock.HeadSlotVal = 100
+ envelope := newTestSignedEnvelope(100, blockRoot, 7)
+ require.NoError(t, service.ProcessMessage(t.Context(), nil, envelope))
+
+ require.Equal(t, beaconevents.OpExecutionPayloadGossip, (<-events).Event)
+ require.Equal(t, beaconevents.OpExecutionPayload, (<-events).Event)
+ require.Equal(t, beaconevents.OpExecutionPayloadAvailable, (<-events).Event)
+ headEvent := <-stateEvents
+ require.Equal(t, beaconevents.StateHeadV2, headEvent.Event)
+ require.Equal(t, "full", headEvent.Data.(*beaconevents.HeadV2Data).Data.PayloadStatus)
+ require.Equal(t, blockRoot, headEvent.Data.(*beaconevents.HeadV2Data).Data.Block)
+}
+
+func TestExecutionPayloadServiceEmitsGossipWhenValidatedEnvelopeWaitsForColumns(t *testing.T) {
+ cfg := &clparams.MainnetBeaconConfig
+ forkchoiceMock := mock_services.NewForkChoiceStorageMock(t)
+ emitter := beaconevents.NewEventEmitter()
+ service := NewExecutionPayloadService(t.Context(), forkchoiceMock, cfg, emitter)
+ events := make(chan *beaconevents.EventStream, 1)
+ subscription := emitter.Operation().Subscribe(events)
+ defer subscription.Unsubscribe()
+
+ blockRoot := common.Hash{1}
+ forkchoiceMock.Blocks[blockRoot] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 100}}
+ forkchoiceMock.OnExecutionPayloadErr = forkchoice.ErrEIP7594ColumnDataNotAvailable
+ envelope := newTestSignedEnvelope(100, blockRoot, 7)
+
+ require.ErrorIs(t, service.ProcessMessage(t.Context(), nil, envelope), ErrIgnore)
+ select {
+ case event := <-events:
+ require.Equal(t, beaconevents.OpExecutionPayloadGossip, event.Event)
+ require.Equal(t, blockRoot, event.Data.(*beaconevents.ExecutionPayloadGossipData).BlockRoot)
+ default:
+ t.Fatal("validated gossip envelope did not emit execution_payload_gossip while waiting for columns")
+ }
+}
+
+func TestExecutionPayloadServiceDoesNotEmitStaleHeadV2AfterReorg(t *testing.T) {
+ cfg := &clparams.MainnetBeaconConfig
+ forkchoiceMock := mock_services.NewForkChoiceStorageMock(t)
+ emitter := beaconevents.NewEventEmitter()
+ service := NewExecutionPayloadService(t.Context(), forkchoiceMock, cfg, emitter)
+ stateEvents := make(chan *beaconevents.EventStream, 1)
+ stateSubscription := emitter.State().Subscribe(stateEvents)
+ defer stateSubscription.Unsubscribe()
+
+ blockRoot := common.Hash{1}
+ reorgRoot := common.Hash{9}
+ forkchoiceMock.Blocks[blockRoot] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 100, StateRoot: common.Hash{2}}}
+ headState := state.New(cfg)
+ headState.SetVersion(clparams.GloasVersion)
+ require.NoError(t, headState.SetSlot(100))
+ require.NoError(t, headState.SetBlockRootAt(63, common.Hash{3}))
+ require.NoError(t, headState.SetBlockRootAt(95, common.Hash{4}))
+ forkchoiceMock.GetStateAtBlockRootFn = func(root common.Hash, alwaysCopy bool) (*state.CachingBeaconState, error) {
+ require.Equal(t, blockRoot, root)
+ require.True(t, alwaysCopy)
+ forkchoiceMock.HeadVal = reorgRoot
+ return headState, nil
+ }
+ forkchoiceMock.HeadVal = blockRoot
+ forkchoiceMock.HeadSlotVal = 100
+
+ require.NoError(t, service.ProcessMessage(t.Context(), nil, newTestSignedEnvelope(100, blockRoot, 7)))
+ select {
+ case event := <-stateEvents:
+ t.Fatalf("emitted stale event after reorg: %#v", event)
+ default:
+ }
+}
+
+func TestExecutionPayloadServiceDoesNotEmitFullHeadV2AfterStatusChanges(t *testing.T) {
+ cfg := &clparams.MainnetBeaconConfig
+ forkchoiceMock := mock_services.NewForkChoiceStorageMock(t)
+ emitter := beaconevents.NewEventEmitter()
+ service := NewExecutionPayloadService(t.Context(), forkchoiceMock, cfg, emitter)
+ stateEvents := make(chan *beaconevents.EventStream, 1)
+ stateSubscription := emitter.State().Subscribe(stateEvents)
+ defer stateSubscription.Unsubscribe()
+
+ blockRoot := common.Hash{1}
+ forkchoiceMock.Blocks[blockRoot] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 100, StateRoot: common.Hash{2}}}
+ headState := state.New(cfg)
+ headState.SetVersion(clparams.GloasVersion)
+ require.NoError(t, headState.SetSlot(100))
+ require.NoError(t, headState.SetBlockRootAt(63, common.Hash{3}))
+ require.NoError(t, headState.SetBlockRootAt(95, common.Hash{4}))
+ forkchoiceMock.GetStateAtBlockRootFn = func(common.Hash, bool) (*state.CachingBeaconState, error) {
+ forkchoiceMock.HeadPayloadStatusVal = cltypes.PayloadStatusEmpty
+ return headState, nil
+ }
+ forkchoiceMock.HeadVal = blockRoot
+ forkchoiceMock.HeadSlotVal = 100
+ forkchoiceMock.HeadPayloadStatusVal = cltypes.PayloadStatusFull
+
+ require.NoError(t, service.ProcessMessage(t.Context(), nil, newTestSignedEnvelope(100, blockRoot, 7)))
+ select {
+ case event := <-stateEvents:
+ t.Fatalf("emitted full head event after status changed: %#v", event)
+ default:
+ }
+}
+
+func TestExecutionPayloadServiceDoesNotEmitGossipWhenValidationFails(t *testing.T) {
+ cfg := &clparams.MainnetBeaconConfig
+ forkchoiceMock := mock_services.NewForkChoiceStorageMock(t)
+ emitter := beaconevents.NewEventEmitter()
+ service := NewExecutionPayloadService(t.Context(), forkchoiceMock, cfg, emitter)
+ events := make(chan *beaconevents.EventStream, 1)
+ subscription := emitter.Operation().Subscribe(events)
+ defer subscription.Unsubscribe()
+
+ blockRoot := common.Hash{1}
+ forkchoiceMock.Blocks[blockRoot] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 100}}
+ forkchoiceMock.OnExecutionPayloadErr = errors.New("invalid envelope signature")
+
+ require.Error(t, service.ProcessMessage(t.Context(), nil, newTestSignedEnvelope(100, blockRoot, 7)))
+ select {
+ case event := <-events:
+ t.Fatalf("emitted gossip event for invalid envelope: %#v", event)
+ default:
+ }
+}
+
+func TestExecutionPayloadServiceProgressesWhileEventFeedIsBlocked(t *testing.T) {
+ cfg := &clparams.MainnetBeaconConfig
+ forkchoiceMock := mock_services.NewForkChoiceStorageMock(t)
+ emitter := beaconevents.NewEventEmitter()
+ service := NewExecutionPayloadService(t.Context(), forkchoiceMock, cfg, emitter)
+ slow := make(chan *beaconevents.EventStream)
+ slowSubscription := emitter.Operation().Subscribe(slow)
+ defer slowSubscription.Unsubscribe()
+ ready := make(chan *beaconevents.EventStream)
+ readySubscription := emitter.Operation().Subscribe(ready)
+ defer readySubscription.Unsubscribe()
+ blockedSendDone := make(chan struct{})
+ go func() {
+ emitter.Operation().SendAttestation(&beaconevents.AttestationData{})
+ close(blockedSendDone)
+ }()
+ <-ready
+
+ blockRoot := common.Hash{1}
+ forkchoiceMock.Blocks[blockRoot] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 100}}
+ processDone := make(chan error, 1)
+ ctx := t.Context()
+ go func() { processDone <- service.ProcessMessage(ctx, nil, newTestSignedEnvelope(100, blockRoot, 7)) }()
+ select {
+ case err := <-processDone:
+ require.NoError(t, err)
+ case <-time.After(time.Second):
+ t.Fatal("execution payload gossip processing blocked on the event feed")
+ }
+
+ slowSubscription.Unsubscribe()
+ select {
+ case <-blockedSendDone:
+ case <-time.After(time.Second):
+ t.Fatal("legacy event send remained blocked after unsubscribe")
+ }
+}
+
func TestExecutionPayloadServiceAlreadySeen(t *testing.T) {
service, fcu := setupExecutionPayloadService(t)
@@ -130,17 +341,45 @@ func TestExecutionPayloadServiceSlotBelowFinalized(t *testing.T) {
// Add block to forkchoice
fcu.Blocks[blockRoot] = &cltypes.SignedBeaconBlock{
Block: &cltypes.BeaconBlock{
- Slot: 50,
+ Slot: 51,
},
}
// Set finalized slot higher than envelope slot
fcu.FinalizedSlotVal = 100
+ fcu.FinalizedCheckpointVal = solid.Checkpoint{Epoch: 2}
err := service.ProcessMessage(context.Background(), nil, envelope)
require.Error(t, err)
require.True(t, errors.Is(err, ErrIgnore))
- require.Contains(t, err.Error(), "envelope slot 50 < finalized slot 100")
+ require.Contains(t, err.Error(), "envelope slot 50 < finalized slot 64")
+}
+
+func TestExecutionPayloadServiceUsesFinalizedEpochStartBoundary(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ slot uint64
+ ignored bool
+ }{
+ {name: "below", slot: 63, ignored: true},
+ {name: "exact", slot: 64},
+ {name: "above", slot: 65},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ service, fcu := setupExecutionPayloadService(t)
+ root := common.Hash{byte(tc.slot)}
+ fcu.FinalizedCheckpointVal = solid.Checkpoint{Epoch: 2}
+ fcu.FinalizedSlotVal = 95
+ fcu.Blocks[root] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: tc.slot}}
+
+ err := service.ProcessMessage(context.Background(), nil, newTestSignedEnvelope(tc.slot, root, 1))
+ if tc.ignored {
+ require.ErrorIs(t, err, ErrIgnore)
+ return
+ }
+ require.NoError(t, err)
+ })
+ }
}
func TestExecutionPayloadServiceSuccess(t *testing.T) {
@@ -242,13 +481,16 @@ func TestExecutionPayloadServicePendingEnvelopeExpiry(t *testing.T) {
impl.pendingEnvelopes.Store(key, &envelopeJob{
envelope: envelope,
creationTime: time.Now().Add(-pendingEnvelopeExpiry - time.Second), // expired
+ ownedBytes: uint64(envelope.EncodingSizeSSZ()),
})
impl.pendingCount.Store(1)
+ impl.pendingBytes.Store(uint64(envelope.EncodingSizeSSZ()))
// Process pending - should remove expired
impl.processPendingEnvelopes(ctx)
require.Equal(t, int32(0), impl.pendingCount.Load())
+ require.Zero(t, impl.pendingBytes.Load())
_, exists := impl.pendingEnvelopes.Load(key)
require.False(t, exists)
}
@@ -282,8 +524,10 @@ func TestExecutionPayloadServicePendingEnvelopeProcessing(t *testing.T) {
impl.pendingEnvelopes.Store(key, &envelopeJob{
envelope: envelope,
creationTime: time.Now(),
+ ownedBytes: uint64(envelope.EncodingSizeSSZ()),
})
impl.pendingCount.Store(1)
+ impl.pendingBytes.Store(uint64(envelope.EncodingSizeSSZ()))
// Block not yet available - should keep pending
impl.processPendingEnvelopes(ctx)
@@ -299,6 +543,7 @@ func TestExecutionPayloadServicePendingEnvelopeProcessing(t *testing.T) {
// Process again - should process and remove
impl.processPendingEnvelopes(ctx)
require.Equal(t, int32(0), impl.pendingCount.Load())
+ require.Zero(t, impl.pendingBytes.Load())
_, exists := impl.pendingEnvelopes.Load(key)
require.False(t, exists)
@@ -375,7 +620,9 @@ func TestExecutionPayloadServicePendingQueueCap(t *testing.T) {
blockRoot := common.HexToHash("0xffff")
envelope := newTestSignedEnvelope(100, blockRoot, 999)
- impl.queuePendingEnvelope(blockRoot, envelope)
+ queued, err := impl.queuePendingEnvelope(blockRoot, envelope)
+ require.Error(t, err)
+ require.False(t, queued)
require.Equal(t, int32(maxPendingEnvelopes), impl.pendingCount.Load())
envelopeHash, err := envelope.HashSSZ()
@@ -405,7 +652,7 @@ func TestExecutionPayloadServicePendingQueueCapConcurrent(t *testing.T) {
wg.Go(func() {
blockRoot := common.Hash{byte(i), byte(i >> 8)}
envelope := newTestSignedEnvelope(100, blockRoot, uint64(10000+i))
- impl.queuePendingEnvelope(blockRoot, envelope)
+ _, _ = impl.queuePendingEnvelope(blockRoot, envelope)
})
}
wg.Wait()
@@ -419,6 +666,178 @@ func TestExecutionPayloadServicePendingQueueCapConcurrent(t *testing.T) {
require.Equal(t, 5, stored)
}
+func TestExecutionPayloadServicePendingQueueOwnsBoundedBytes(t *testing.T) {
+ service, forkchoiceMock := setupExecutionPayloadService(t)
+ var forkchoiceAdmissions atomic.Int32
+ forkchoiceMock.OnExecutionPayloadFn = func(context.Context, *cltypes.SignedExecutionPayloadEnvelope, bool, bool) error {
+ forkchoiceAdmissions.Add(1)
+ return nil
+ }
+
+ largeTransaction := make([]byte, int(clparams.MaxChunkSize)-1024)
+ for i := range 5 {
+ root := common.Hash{byte(i + 1)}
+ envelope := newTestSignedEnvelope(100, root, uint64(i+1))
+ envelope.Message.Payload.Transactions = solid.NewTransactionsSSZFromTransactions([][]byte{largeTransaction})
+ err := service.ProcessMessage(t.Context(), nil, envelope)
+ require.ErrorIs(t, err, ErrIgnore)
+ if i == 4 {
+ require.ErrorContains(t, err, "capacity reached")
+ }
+ }
+
+ impl := service.(*executionPayloadService)
+ require.Equal(t, int32(4), impl.pendingCount.Load())
+ require.Zero(t, forkchoiceAdmissions.Load())
+}
+
+func TestExecutionPayloadServiceProcessesEnvelopeWhenBlockArrivesAfterAdmission(t *testing.T) {
+ service, forkchoiceMock := setupExecutionPayloadService(t)
+ blockRoot := common.Hash{1}
+ envelope := newTestSignedEnvelope(100, blockRoot, 1)
+ type call struct {
+ checkBlobData bool
+ validatePayload bool
+ }
+ calls := make(chan call, 2)
+ forkchoiceMock.OnExecutionPayloadFn = func(_ context.Context, got *cltypes.SignedExecutionPayloadEnvelope, checkBlobData, validatePayload bool) error {
+ require.Same(t, envelope, got)
+ calls <- call{checkBlobData: checkBlobData, validatePayload: validatePayload}
+ return nil
+ }
+
+ require.ErrorIs(t, service.ProcessMessage(t.Context(), nil, envelope), ErrIgnore)
+ require.Empty(t, calls)
+ forkchoiceMock.Blocks[blockRoot] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 100}}
+ service.(*executionPayloadService).processPendingEnvelopes(t.Context())
+
+ require.Equal(t, call{checkBlobData: true, validatePayload: true}, <-calls)
+ require.Empty(t, calls)
+ require.Zero(t, service.(*executionPayloadService).pendingCount.Load())
+ require.Zero(t, service.(*executionPayloadService).pendingBytes.Load())
+}
+
+func TestExecutionPayloadServiceProcessesEnvelopeWhenBlockArrivesBeforeAdmission(t *testing.T) {
+ service, forkchoiceMock := setupExecutionPayloadService(t)
+ blockRoot := common.Hash{1}
+ envelope := newTestSignedEnvelope(100, blockRoot, 1)
+ forkchoiceMock.Blocks[blockRoot] = &cltypes.SignedBeaconBlock{Block: &cltypes.BeaconBlock{Slot: 100}}
+ var calls atomic.Int32
+ forkchoiceMock.OnExecutionPayloadFn = func(_ context.Context, got *cltypes.SignedExecutionPayloadEnvelope, checkBlobData, validatePayload bool) error {
+ require.Same(t, envelope, got)
+ require.True(t, checkBlobData)
+ require.True(t, validatePayload)
+ calls.Add(1)
+ return nil
+ }
+
+ require.NoError(t, service.ProcessMessage(t.Context(), nil, envelope))
+ require.Equal(t, int32(1), calls.Load())
+ require.Zero(t, service.(*executionPayloadService).pendingCount.Load())
+ require.Zero(t, service.(*executionPayloadService).pendingBytes.Load())
+}
+
+func TestExecutionPayloadServicePendingByteAdmissionConcurrent(t *testing.T) {
+ cfg := &clparams.MainnetBeaconConfig
+ forkchoiceMock := mock_services.NewForkChoiceStorageMock(t)
+ impl := &executionPayloadService{
+ forkchoiceStore: forkchoiceMock,
+ beaconCfg: cfg,
+ emitters: beaconevents.NewEventEmitter(),
+ pendingCond: sync.NewCond(&sync.Mutex{}),
+ }
+ envelopeSize := uint64(newTestSignedEnvelope(100, common.Hash{1}, 1).EncodingSizeSSZ())
+ impl.pendingBytes.Store(maxPendingEnvelopeBytes - 5*envelopeSize)
+
+ type result struct {
+ queued bool
+ err error
+ }
+ results := make(chan result, 100)
+ var wg sync.WaitGroup
+ for i := range 100 {
+ wg.Go(func() {
+ queued, err := impl.queuePendingEnvelope(
+ common.Hash{byte(i), byte(i >> 8)},
+ newTestSignedEnvelope(100, common.Hash{byte(i), byte(i >> 8)}, uint64(i+1)),
+ )
+ results <- result{queued: queued, err: err}
+ })
+ }
+ wg.Wait()
+ close(results)
+
+ admitted := 0
+ for result := range results {
+ if result.queued {
+ require.NoError(t, result.err)
+ admitted++
+ continue
+ }
+ require.ErrorContains(t, result.err, "capacity reached")
+ }
+ require.Equal(t, 5, admitted)
+ require.Equal(t, int32(5), impl.pendingCount.Load())
+ require.Equal(t, maxPendingEnvelopeBytes, impl.pendingBytes.Load())
+}
+
+func TestExecutionPayloadServiceDuplicateAtByteCapacityDoesNotReadmitForkchoice(t *testing.T) {
+ service, forkchoiceMock := setupExecutionPayloadService(t)
+ var forkchoiceAdmissions atomic.Int32
+ forkchoiceMock.OnExecutionPayloadFn = func(context.Context, *cltypes.SignedExecutionPayloadEnvelope, bool, bool) error {
+ forkchoiceAdmissions.Add(1)
+ return nil
+ }
+ envelope := newTestSignedEnvelope(100, common.Hash{1}, 1)
+ require.ErrorIs(t, service.ProcessMessage(t.Context(), nil, envelope), ErrIgnore)
+
+ impl := service.(*executionPayloadService)
+ impl.pendingBytes.Store(maxPendingEnvelopeBytes)
+ require.ErrorIs(t, service.ProcessMessage(t.Context(), nil, envelope), ErrIgnore)
+ require.Equal(t, int32(1), impl.pendingCount.Load())
+ require.Zero(t, forkchoiceAdmissions.Load())
+}
+
+func TestExecutionPayloadServiceConcurrentDuplicateRemovalConservesOwnership(t *testing.T) {
+ impl := &executionPayloadService{pendingCond: sync.NewCond(&sync.Mutex{})}
+ blockRoot := common.Hash{1}
+ envelope := newTestSignedEnvelope(100, blockRoot, 1)
+ envelopeHash, err := envelope.HashSSZ()
+ require.NoError(t, err)
+ key := pendingEnvelopeKey{blockRoot: blockRoot, envelopeHash: envelopeHash}
+ queued, err := impl.queuePendingEnvelope(blockRoot, envelope)
+ require.NoError(t, err)
+ require.True(t, queued)
+
+ results := make(chan error, 50)
+ var wg sync.WaitGroup
+ for i := range 100 {
+ if i%2 == 0 {
+ wg.Go(func() { _, _ = impl.removePendingEnvelope(key) })
+ continue
+ }
+ wg.Go(func() {
+ _, err := impl.queuePendingEnvelope(blockRoot, envelope)
+ results <- err
+ })
+ }
+ wg.Wait()
+ close(results)
+ for err := range results {
+ require.NoError(t, err)
+ }
+
+ stored := 0
+ ownedBytes := uint64(0)
+ impl.pendingEnvelopes.Range(func(_, value any) bool {
+ stored++
+ ownedBytes += value.(*envelopeJob).ownedBytes
+ return true
+ })
+ require.Equal(t, int32(stored), impl.pendingCount.Load())
+ require.Equal(t, ownedBytes, impl.pendingBytes.Load())
+}
+
func TestExecutionPayloadServiceNames(t *testing.T) {
service, _ := setupExecutionPayloadService(t)
impl := service.(*executionPayloadService)
@@ -430,3 +849,48 @@ func TestExecutionPayloadServiceNames(t *testing.T) {
require.True(t, impl.IsMyGossipMessage("execution_payload"))
require.False(t, impl.IsMyGossipMessage("beacon_block"))
}
+
+func TestValidateEnvelopeLimitsDoesNotApplyLegacyDepositRequestMaximum(t *testing.T) {
+ cfg := clparams.MainnetBeaconConfig
+ cfg.MaxDepositRequestsPerPayload = 1
+ envelope := cltypes.NewExecutionPayloadEnvelope(&cfg)
+ envelope.ExecutionRequests.Deposits.Append(&solid.DepositRequest{})
+ envelope.ExecutionRequests.Deposits.Append(&solid.DepositRequest{})
+ envelope.Payload.Withdrawals = solid.NewStaticListSSZ[*cltypes.Withdrawal](int(cfg.MaxWithdrawalsPerPayload), 44)
+ require.NoError(t, validateEnvelopeLimits(&cfg, envelope))
+}
+
+func TestExecutionPayloadServiceDecodesProgressiveDepositRequestsAboveLegacyGuard(t *testing.T) {
+ service, _ := setupExecutionPayloadService(t)
+ envelope := newTestSignedEnvelope(100, common.Hash{1}, 1)
+ const depositCount = 16_385
+ for range depositCount {
+ envelope.Message.ExecutionRequests.Deposits.Append(&solid.DepositRequest{})
+ }
+ encoded, err := envelope.EncodeSSZ(nil)
+ require.NoError(t, err)
+ require.Less(t, uint64(len(encoded)), clparams.MaxChunkSize)
+
+ decoded, err := service.DecodeGossipMessage("peer123", encoded, clparams.GloasVersion)
+ require.NoError(t, err)
+ require.Equal(t, depositCount, decoded.Message.ExecutionRequests.Deposits.Len())
+}
+
+func TestValidateEnvelopeLimitsRejectsOversizedRequestsAndWithdrawals(t *testing.T) {
+ cfg := clparams.MainnetBeaconConfig
+ cfg.MaxWithdrawalsPerPayload = 1
+ envelope := cltypes.NewExecutionPayloadEnvelope(&cfg)
+ envelope.Payload.Withdrawals = solid.NewStaticListSSZ[*cltypes.Withdrawal](16, 44)
+ envelope.Payload.Withdrawals.Append(&cltypes.Withdrawal{})
+ envelope.Payload.Withdrawals.Append(&cltypes.Withdrawal{})
+ require.Error(t, validateEnvelopeLimits(&cfg, envelope))
+}
+
+func TestValidateEnvelopeLimitsRequiresWithdrawalsList(t *testing.T) {
+ cfg := clparams.MainnetBeaconConfig
+ envelope := cltypes.NewExecutionPayloadEnvelope(&cfg)
+ require.ErrorContains(t, validateEnvelopeLimits(&cfg, envelope), "missing payload withdrawals")
+
+ envelope.Payload.Withdrawals = solid.NewStaticListSSZ[*cltypes.Withdrawal](int(cfg.MaxWithdrawalsPerPayload), 44)
+ require.NoError(t, validateEnvelopeLimits(&cfg, envelope))
+}
diff --git a/cl/phase1/network/services/mock_services/block_service_mock.go b/cl/phase1/network/services/mock_services/block_service_mock.go
index 30c1dff006f..2f3d16572b1 100644
--- a/cl/phase1/network/services/mock_services/block_service_mock.go
+++ b/cl/phase1/network/services/mock_services/block_service_mock.go
@@ -43,6 +43,42 @@ func (m *MockBlockService) EXPECT() *MockBlockServiceMockRecorder {
return m.recorder
}
+// CommitGossipReservation mocks base method.
+func (m *MockBlockService) CommitGossipReservation(arg0 *cltypes.SignedBeaconBlock) {
+ m.ctrl.T.Helper()
+ m.ctrl.Call(m, "CommitGossipReservation", arg0)
+}
+
+// CommitGossipReservation indicates an expected call of CommitGossipReservation.
+func (mr *MockBlockServiceMockRecorder) CommitGossipReservation(arg0 any) *MockBlockServiceCommitGossipReservationCall {
+ mr.mock.ctrl.T.Helper()
+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CommitGossipReservation", reflect.TypeOf((*MockBlockService)(nil).CommitGossipReservation), arg0)
+ return &MockBlockServiceCommitGossipReservationCall{Call: call}
+}
+
+// MockBlockServiceCommitGossipReservationCall wrap *gomock.Call
+type MockBlockServiceCommitGossipReservationCall struct {
+ *gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MockBlockServiceCommitGossipReservationCall) Return() *MockBlockServiceCommitGossipReservationCall {
+ c.Call = c.Call.Return()
+ return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MockBlockServiceCommitGossipReservationCall) Do(f func(*cltypes.SignedBeaconBlock)) *MockBlockServiceCommitGossipReservationCall {
+ c.Call = c.Call.Do(f)
+ return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MockBlockServiceCommitGossipReservationCall) DoAndReturn(f func(*cltypes.SignedBeaconBlock)) *MockBlockServiceCommitGossipReservationCall {
+ c.Call = c.Call.DoAndReturn(f)
+ return c
+}
+
// DecodeGossipMessage mocks base method.
func (m *MockBlockService) DecodeGossipMessage(pid peer.ID, data []byte, version clparams.StateVersion) (*cltypes.SignedBeaconBlock, error) {
m.ctrl.T.Helper()
@@ -157,3 +193,149 @@ func (c *MockBlockServiceProcessMessageCall) DoAndReturn(f func(context.Context,
c.Call = c.Call.DoAndReturn(f)
return c
}
+
+// ReleaseGossipReservation mocks base method.
+func (m *MockBlockService) ReleaseGossipReservation(arg0 *cltypes.SignedBeaconBlock) {
+ m.ctrl.T.Helper()
+ m.ctrl.Call(m, "ReleaseGossipReservation", arg0)
+}
+
+// ReleaseGossipReservation indicates an expected call of ReleaseGossipReservation.
+func (mr *MockBlockServiceMockRecorder) ReleaseGossipReservation(arg0 any) *MockBlockServiceReleaseGossipReservationCall {
+ mr.mock.ctrl.T.Helper()
+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReleaseGossipReservation", reflect.TypeOf((*MockBlockService)(nil).ReleaseGossipReservation), arg0)
+ return &MockBlockServiceReleaseGossipReservationCall{Call: call}
+}
+
+// MockBlockServiceReleaseGossipReservationCall wrap *gomock.Call
+type MockBlockServiceReleaseGossipReservationCall struct {
+ *gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MockBlockServiceReleaseGossipReservationCall) Return() *MockBlockServiceReleaseGossipReservationCall {
+ c.Call = c.Call.Return()
+ return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MockBlockServiceReleaseGossipReservationCall) Do(f func(*cltypes.SignedBeaconBlock)) *MockBlockServiceReleaseGossipReservationCall {
+ c.Call = c.Call.Do(f)
+ return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MockBlockServiceReleaseGossipReservationCall) DoAndReturn(f func(*cltypes.SignedBeaconBlock)) *MockBlockServiceReleaseGossipReservationCall {
+ c.Call = c.Call.DoAndReturn(f)
+ return c
+}
+
+// ScheduleBlockForLaterProcessing mocks base method.
+func (m *MockBlockService) ScheduleBlockForLaterProcessing(arg0 *cltypes.SignedBeaconBlock) {
+ m.ctrl.T.Helper()
+ m.ctrl.Call(m, "ScheduleBlockForLaterProcessing", arg0)
+}
+
+// ScheduleBlockForLaterProcessing indicates an expected call of ScheduleBlockForLaterProcessing.
+func (mr *MockBlockServiceMockRecorder) ScheduleBlockForLaterProcessing(arg0 any) *MockBlockServiceScheduleBlockForLaterProcessingCall {
+ mr.mock.ctrl.T.Helper()
+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ScheduleBlockForLaterProcessing", reflect.TypeOf((*MockBlockService)(nil).ScheduleBlockForLaterProcessing), arg0)
+ return &MockBlockServiceScheduleBlockForLaterProcessingCall{Call: call}
+}
+
+// MockBlockServiceScheduleBlockForLaterProcessingCall wrap *gomock.Call
+type MockBlockServiceScheduleBlockForLaterProcessingCall struct {
+ *gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MockBlockServiceScheduleBlockForLaterProcessingCall) Return() *MockBlockServiceScheduleBlockForLaterProcessingCall {
+ c.Call = c.Call.Return()
+ return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MockBlockServiceScheduleBlockForLaterProcessingCall) Do(f func(*cltypes.SignedBeaconBlock)) *MockBlockServiceScheduleBlockForLaterProcessingCall {
+ c.Call = c.Call.Do(f)
+ return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MockBlockServiceScheduleBlockForLaterProcessingCall) DoAndReturn(f func(*cltypes.SignedBeaconBlock)) *MockBlockServiceScheduleBlockForLaterProcessingCall {
+ c.Call = c.Call.DoAndReturn(f)
+ return c
+}
+
+// SchedulePublishedBlockForLaterProcessing mocks base method.
+func (m *MockBlockService) SchedulePublishedBlockForLaterProcessing(arg0 *cltypes.SignedBeaconBlock, arg1 func(context.Context) error) {
+ m.ctrl.T.Helper()
+ m.ctrl.Call(m, "SchedulePublishedBlockForLaterProcessing", arg0, arg1)
+}
+
+// SchedulePublishedBlockForLaterProcessing indicates an expected call of SchedulePublishedBlockForLaterProcessing.
+func (mr *MockBlockServiceMockRecorder) SchedulePublishedBlockForLaterProcessing(arg0, arg1 any) *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall {
+ mr.mock.ctrl.T.Helper()
+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SchedulePublishedBlockForLaterProcessing", reflect.TypeOf((*MockBlockService)(nil).SchedulePublishedBlockForLaterProcessing), arg0, arg1)
+ return &MockBlockServiceSchedulePublishedBlockForLaterProcessingCall{Call: call}
+}
+
+// MockBlockServiceSchedulePublishedBlockForLaterProcessingCall wrap *gomock.Call
+type MockBlockServiceSchedulePublishedBlockForLaterProcessingCall struct {
+ *gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall) Return() *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall {
+ c.Call = c.Call.Return()
+ return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall) Do(f func(*cltypes.SignedBeaconBlock, func(context.Context) error)) *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall {
+ c.Call = c.Call.Do(f)
+ return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall) DoAndReturn(f func(*cltypes.SignedBeaconBlock, func(context.Context) error)) *MockBlockServiceSchedulePublishedBlockForLaterProcessingCall {
+ c.Call = c.Call.DoAndReturn(f)
+ return c
+}
+
+// ValidateGossip mocks base method.
+func (m *MockBlockService) ValidateGossip(arg0 context.Context, arg1 *cltypes.SignedBeaconBlock) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "ValidateGossip", arg0, arg1)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// ValidateGossip indicates an expected call of ValidateGossip.
+func (mr *MockBlockServiceMockRecorder) ValidateGossip(arg0, arg1 any) *MockBlockServiceValidateGossipCall {
+ mr.mock.ctrl.T.Helper()
+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateGossip", reflect.TypeOf((*MockBlockService)(nil).ValidateGossip), arg0, arg1)
+ return &MockBlockServiceValidateGossipCall{Call: call}
+}
+
+// MockBlockServiceValidateGossipCall wrap *gomock.Call
+type MockBlockServiceValidateGossipCall struct {
+ *gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MockBlockServiceValidateGossipCall) Return(arg0 error) *MockBlockServiceValidateGossipCall {
+ c.Call = c.Call.Return(arg0)
+ return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MockBlockServiceValidateGossipCall) Do(f func(context.Context, *cltypes.SignedBeaconBlock) error) *MockBlockServiceValidateGossipCall {
+ c.Call = c.Call.Do(f)
+ return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MockBlockServiceValidateGossipCall) DoAndReturn(f func(context.Context, *cltypes.SignedBeaconBlock) error) *MockBlockServiceValidateGossipCall {
+ c.Call = c.Call.DoAndReturn(f)
+ return c
+}
diff --git a/cl/phase1/network/services/mock_services/payload_attestation_service_mock.go b/cl/phase1/network/services/mock_services/payload_attestation_service_mock.go
index 982b68217d8..51b4d3a46a8 100644
--- a/cl/phase1/network/services/mock_services/payload_attestation_service_mock.go
+++ b/cl/phase1/network/services/mock_services/payload_attestation_service_mock.go
@@ -157,3 +157,41 @@ func (c *MockPayloadAttestationServiceProcessMessageCall) DoAndReturn(f func(con
c.Call = c.Call.DoAndReturn(f)
return c
}
+
+// ProcessRESTMessage mocks base method.
+func (m *MockPayloadAttestationService) ProcessRESTMessage(arg0 context.Context, arg1 *cltypes.PayloadAttestationMessage, arg2 func() error) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "ProcessRESTMessage", arg0, arg1, arg2)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// ProcessRESTMessage indicates an expected call of ProcessRESTMessage.
+func (mr *MockPayloadAttestationServiceMockRecorder) ProcessRESTMessage(arg0, arg1, arg2 any) *MockPayloadAttestationServiceProcessRESTMessageCall {
+ mr.mock.ctrl.T.Helper()
+ call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProcessRESTMessage", reflect.TypeOf((*MockPayloadAttestationService)(nil).ProcessRESTMessage), arg0, arg1, arg2)
+ return &MockPayloadAttestationServiceProcessRESTMessageCall{Call: call}
+}
+
+// MockPayloadAttestationServiceProcessRESTMessageCall wrap *gomock.Call
+type MockPayloadAttestationServiceProcessRESTMessageCall struct {
+ *gomock.Call
+}
+
+// Return rewrite *gomock.Call.Return
+func (c *MockPayloadAttestationServiceProcessRESTMessageCall) Return(arg0 error) *MockPayloadAttestationServiceProcessRESTMessageCall {
+ c.Call = c.Call.Return(arg0)
+ return c
+}
+
+// Do rewrite *gomock.Call.Do
+func (c *MockPayloadAttestationServiceProcessRESTMessageCall) Do(f func(context.Context, *cltypes.PayloadAttestationMessage, func() error) error) *MockPayloadAttestationServiceProcessRESTMessageCall {
+ c.Call = c.Call.Do(f)
+ return c
+}
+
+// DoAndReturn rewrite *gomock.Call.DoAndReturn
+func (c *MockPayloadAttestationServiceProcessRESTMessageCall) DoAndReturn(f func(context.Context, *cltypes.PayloadAttestationMessage, func() error) error) *MockPayloadAttestationServiceProcessRESTMessageCall {
+ c.Call = c.Call.DoAndReturn(f)
+ return c
+}
diff --git a/cl/phase1/network/services/payload_attestation_service.go b/cl/phase1/network/services/payload_attestation_service.go
index 20296ea4ef7..89bdf2df14c 100644
--- a/cl/phase1/network/services/payload_attestation_service.go
+++ b/cl/phase1/network/services/payload_attestation_service.go
@@ -20,6 +20,7 @@ import (
"context"
"errors"
"fmt"
+ "math"
"sync"
"sync/atomic"
"time"
@@ -32,6 +33,7 @@ import (
"github.com/erigontech/erigon/cl/gossip"
"github.com/erigontech/erigon/cl/phase1/core/state/lru"
"github.com/erigontech/erigon/cl/phase1/forkchoice"
+ "github.com/erigontech/erigon/cl/pool"
"github.com/erigontech/erigon/cl/utils/eth_clock"
"github.com/erigontech/erigon/common"
"github.com/erigontech/erigon/common/log/v3"
@@ -57,6 +59,14 @@ type pendingPayloadAttestationJob struct {
creationTime time.Time
}
+type validatedRESTPayloadAttestation struct {
+ mu sync.Mutex
+ messageRoot common.Hash
+ creationTime time.Time
+ refs int
+ validated atomic.Bool
+}
+
const (
// seenPayloadAttestationCacheSize: PTC has 512 validators per slot.
// With clock disparity, we may see attestations for ~2 slots.
@@ -73,6 +83,7 @@ type payloadAttestationService struct {
ethClock eth_clock.EthereumClock
netCfg *clparams.NetworkConfig
emitters *beaconevents.EventEmitter
+ epbsPool *pool.EpbsPool
// Cache to track seen attestations: (slot, validatorIndex) -> struct{}
seenAttestationsCache *lru.Cache[seenPayloadAttestationKey, struct{}]
@@ -82,6 +93,9 @@ type payloadAttestationService struct {
pendingCount atomic.Int32
pendingCond *sync.Cond
validationAdmission chan struct{}
+ validatedRESTMu sync.Mutex
+ validatedREST map[seenPayloadAttestationKey]*validatedRESTPayloadAttestation
+ now func() time.Time
}
// NewPayloadAttestationService creates a new payload attestation service.
@@ -91,6 +105,7 @@ func NewPayloadAttestationService(
forkchoiceStore forkchoice.ForkChoiceStorage,
ethClock eth_clock.EthereumClock,
netCfg *clparams.NetworkConfig,
+ epbsPool *pool.EpbsPool,
emitters *beaconevents.EventEmitter,
) PayloadAttestationService {
seenCache, err := lru.New[seenPayloadAttestationKey, struct{}]("seen_payload_attestations", seenPayloadAttestationCacheSize)
@@ -101,10 +116,13 @@ func NewPayloadAttestationService(
forkchoiceStore: forkchoiceStore,
ethClock: ethClock,
netCfg: netCfg,
+ epbsPool: epbsPool,
emitters: emitters,
seenAttestationsCache: seenCache,
pendingCond: sync.NewCond(&sync.Mutex{}),
validationAdmission: make(chan struct{}, maxConcurrentPayloadAttestationValidations),
+ validatedREST: make(map[seenPayloadAttestationKey]*validatedRESTPayloadAttestation),
+ now: time.Now,
}
go s.loop(ctx)
return s
@@ -116,7 +134,7 @@ func (s *payloadAttestationService) Names() []string {
func (s *payloadAttestationService) DecodeGossipMessage(_ peer.ID, data []byte, version clparams.StateVersion) (*cltypes.PayloadAttestationMessage, error) {
msg := &cltypes.PayloadAttestationMessage{}
- if err := msg.DecodeSSZ(data, int(version)); err != nil {
+ if err := msg.DecodeSSZStrict(data, int(version)); err != nil {
return nil, err
}
return msg, nil
@@ -126,6 +144,21 @@ func (s *payloadAttestationService) DecodeGossipMessage(_ peer.ID, data []byte,
// Reference: https://github.com/ethereum/consensus-specs/blob/dev/specs/_features/epbs/p2p-interface.md#payload_attestation_message
// [New in Gloas:EIP7732]
func (s *payloadAttestationService) ProcessMessage(ctx context.Context, _ *uint64, msg *cltypes.PayloadAttestationMessage) error {
+ return s.processMessage(ctx, msg, true, nil)
+}
+
+func (s *payloadAttestationService) ProcessRESTMessage(ctx context.Context, msg *cltypes.PayloadAttestationMessage, publish func() error) error {
+ return s.processMessage(ctx, msg, false, publish)
+}
+
+var (
+ ErrAttestationDuplicate = errors.New("payload attestation duplicate")
+ ErrAttestationRetryable = errors.New("payload attestation retryable")
+ ErrAttestationConflict = errors.New("payload attestation conflicts with validated message")
+ ErrAttestationCapacity = errors.New("validated payload attestation retry capacity reached")
+)
+
+func (s *payloadAttestationService) processMessage(ctx context.Context, msg *cltypes.PayloadAttestationMessage, queueMissing bool, publish func() error) error {
if msg == nil || msg.Data == nil {
return fmt.Errorf("nil payload attestation message")
}
@@ -140,8 +173,7 @@ func (s *payloadAttestationService) ProcessMessage(ctx context.Context, _ *uint6
"validatorIndex", validatorIndex,
"blockRoot", blockRoot)
- // [IGNORE] The message's slot is for the current slot (with a MAXIMUM_GOSSIP_CLOCK_DISPARITY allowance)
- if !s.ethClock.IsSlotCurrentSlotWithMaximumClockDisparity(slot) {
+ if !isPayloadAttestationSlotCurrent(s.ethClock, s.now(), slot) {
return fmt.Errorf("%w: payload attestation slot %d is not current slot (with clock disparity)", ErrIgnore, slot)
}
@@ -151,15 +183,19 @@ func (s *payloadAttestationService) ProcessMessage(ctx context.Context, _ *uint6
validatorIndex: validatorIndex,
}
if s.seenAttestationsCache.Contains(seenKey) {
- return fmt.Errorf("%w: already seen payload attestation from validator %d for slot %d", ErrIgnore, validatorIndex, slot)
+ return fmt.Errorf("%w: %w: already seen payload attestation from validator %d for slot %d", ErrIgnore, ErrAttestationDuplicate, validatorIndex, slot)
}
// [IGNORE] The message's block root has been seen (via gossip or non-gossip sources)
// A client MAY queue attestation for processing once the block is retrieved.
blockHeader, ok := s.forkchoiceStore.GetHeader(blockRoot)
if !ok {
- // Block hasn't arrived yet, queue attestation for later processing
- s.queuePendingAttestation(blockRoot, msg)
+ if !queueMissing {
+ return fmt.Errorf("%w: block not available", ErrIgnore)
+ }
+ if !s.queuePendingAttestation(blockRoot, msg) {
+ return fmt.Errorf("%w: %w: block not available", ErrIgnore, ErrAttestationCapacity)
+ }
log.Trace("Queued payload attestation for later processing",
"blockRoot", blockRoot,
"validatorIndex", validatorIndex)
@@ -169,61 +205,186 @@ func (s *payloadAttestationService) ProcessMessage(ctx context.Context, _ *uint6
if blockHeader.Slot != slot {
return fmt.Errorf("%w: payload attestation slot %d does not match referenced block slot %d", ErrIgnore, slot, blockHeader.Slot)
}
+ messageRoot, err := msg.HashSSZ()
+ if err != nil {
+ return fmt.Errorf("hash payload attestation: %w", err)
+ }
+ if publish == nil {
+ coordinator := s.acquireExistingRESTAttestation(seenKey, messageRoot)
+ if coordinator != nil {
+ coordinator.mu.Lock()
+ if coordinator.validated.Load() {
+ if s.seenAttestationsCache.Contains(seenKey) {
+ coordinator.mu.Unlock()
+ s.releaseValidatedRESTAttestation(seenKey, coordinator, false)
+ return fmt.Errorf("%w: %w: already seen payload attestation from validator %d for slot %d", ErrIgnore, ErrAttestationDuplicate, validatorIndex, slot)
+ }
+ s.commitPayloadAttestation(seenKey, msg)
+ coordinator.mu.Unlock()
+ s.releaseValidatedRESTAttestation(seenKey, coordinator, false)
+ return nil
+ }
+ coordinator.mu.Unlock()
+ s.releaseValidatedRESTAttestation(seenKey, coordinator, false)
+ }
+ if err := s.validatePayloadAttestation(ctx, msg); err != nil {
+ return err
+ }
+ s.commitPayloadAttestation(seenKey, msg)
+ return nil
+ }
+ coordinator, err := s.acquireValidatedRESTAttestation(seenKey, messageRoot)
+ if err != nil {
+ return err
+ }
+ retainCoordinator := false
+ defer func() { s.releaseValidatedRESTAttestation(seenKey, coordinator, retainCoordinator) }()
+ coordinator.mu.Lock()
+ defer coordinator.mu.Unlock()
+ if s.seenAttestationsCache.Contains(seenKey) {
+ return fmt.Errorf("%w: %w: already seen payload attestation from validator %d for slot %d", ErrIgnore, ErrAttestationDuplicate, validatorIndex, slot)
+ }
+ if !coordinator.validated.Load() {
+ if err := s.validatePayloadAttestation(ctx, msg); err != nil {
+ return err
+ }
+ coordinator.validated.Store(true)
+ }
+ if err := publish(); err != nil {
+ retainCoordinator = true
+ return err
+ }
+ s.commitPayloadAttestation(seenKey, msg)
+ return nil
+}
+
+func isPayloadAttestationSlotCurrent(clock eth_clock.EthereumClock, now time.Time, slot uint64) bool {
+ if slot == math.MaxUint64 {
+ return false
+ }
+ slotStart := clock.GetSlotTime(slot)
+ nextSlotStart := clock.GetSlotTime(slot + 1)
+ slotUnix := slotStart.Unix()
+ nextSlotUnix := nextSlotStart.Unix()
+ if slotUnix < 0 || nextSlotUnix <= slotUnix {
+ return false
+ }
+ secondsPerSlot := uint64(nextSlotUnix - slotUnix)
+ genesisTime := clock.GenesisTime()
+ if genesisTime > math.MaxInt64 || slot > (math.MaxUint64-genesisTime)/secondsPerSlot {
+ return false
+ }
+ expectedSlotUnix := genesisTime + slot*secondsPerSlot
+ if expectedSlotUnix > math.MaxInt64 || slotUnix != int64(expectedSlotUnix) || uint64(nextSlotUnix)-expectedSlotUnix != secondsPerSlot {
+ return false
+ }
+ lowerBound := slotStart.Add(-gloasMaximumClockDisparity)
+ upperBound := nextSlotStart.Add(gloasMaximumClockDisparity)
+ if lowerBound.After(slotStart) || upperBound.Before(nextSlotStart) {
+ return false
+ }
+ return !now.Before(lowerBound) && !now.After(upperBound)
+}
+
+func (s *payloadAttestationService) validatePayloadAttestation(ctx context.Context, msg *cltypes.PayloadAttestationMessage) error {
select {
case s.validationAdmission <- struct{}{}:
- defer func() { <-s.validationAdmission }()
case <-ctx.Done():
- return fmt.Errorf("%w: payload attestation validation canceled: %v", ErrIgnore, ctx.Err()) //nolint:errorlint // converting cancellation to IGNORE
- }
-
- // Process through forkchoice which handles:
- // [IGNORE] block state not found
- // [REJECT] validator is not in PTC
- // [REJECT] signature verification
- if err := s.forkchoiceStore.OnPayloadAttestationMessage(ctx, msg, false); err != nil {
- // Preserve IGNORE vs REJECT distinction from forkchoice
- // forkchoice.ErrIgnore != services.ErrIgnore, so we need to convert
- if errors.Is(err, forkchoice.ErrIgnore) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
- return fmt.Errorf("%w: %v", ErrIgnore, err) //nolint:errorlint // converting, not wrapping: forkchoice.ErrIgnore must not stay matchable
- }
- return fmt.Errorf("forkchoice rejected payload attestation: %w", err)
+ return fmt.Errorf("%w: %w: payload attestation validation canceled: %v", ErrIgnore, ErrAttestationRetryable, ctx.Err()) //nolint:errorlint // converting cancellation to IGNORE
+ }
+ err := s.forkchoiceStore.OnPayloadAttestationMessage(ctx, msg, false)
+ <-s.validationAdmission
+ if err == nil {
+ return nil
}
+ if errors.Is(err, forkchoice.ErrIgnore) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ return fmt.Errorf("%w: %w: %v", ErrIgnore, ErrAttestationRetryable, err) //nolint:errorlint // converting forkchoice errors to gossip outcomes
+ }
+ return fmt.Errorf("forkchoice rejected payload attestation: %w", err)
+}
- // Mark as seen AFTER successful validation
+func (s *payloadAttestationService) commitPayloadAttestation(seenKey seenPayloadAttestationKey, msg *cltypes.PayloadAttestationMessage) {
s.seenAttestationsCache.Add(seenKey, struct{}{})
-
- // Emit SSE event for payload_attestation_message [New in Gloas:EIP7732]
+ if s.epbsPool != nil {
+ s.epbsPool.PayloadAttestations.Add(pool.PayloadAttestationKey{Slot: seenKey.slot, ValidatorIndex: seenKey.validatorIndex}, msg)
+ }
s.emitters.Operation().SendPayloadAttestationMessage(msg)
+}
- log.Trace("Processed payload attestation message via gossip",
- "slot", slot,
- "validatorIndex", validatorIndex,
- "blockRoot", blockRoot,
- "payloadPresent", data.PayloadPresent,
- "blobDataAvailable", data.BlobDataAvailable)
+func (s *payloadAttestationService) acquireValidatedRESTAttestation(key seenPayloadAttestationKey, messageRoot common.Hash) (*validatedRESTPayloadAttestation, error) {
+ s.validatedRESTMu.Lock()
+ defer s.validatedRESTMu.Unlock()
+ if s.validatedREST == nil {
+ s.validatedREST = make(map[seenPayloadAttestationKey]*validatedRESTPayloadAttestation)
+ }
+ now := time.Now()
+ for candidateKey, candidate := range s.validatedREST {
+ if candidate.refs == 0 && now.Sub(candidate.creationTime) > pendingPayloadAttestationExpiry {
+ delete(s.validatedREST, candidateKey)
+ }
+ }
+ if existing := s.validatedREST[key]; existing != nil {
+ if existing.messageRoot != messageRoot {
+ return nil, fmt.Errorf("%w for slot %d validator %d", ErrAttestationConflict, key.slot, key.validatorIndex)
+ }
+ existing.refs++
+ return existing, nil
+ }
+ if len(s.validatedREST) >= maxPendingAttestations {
+ return nil, ErrAttestationCapacity
+ }
+ entry := &validatedRESTPayloadAttestation{messageRoot: messageRoot, creationTime: now, refs: 1}
+ s.validatedREST[key] = entry
+ return entry, nil
+}
- return nil
+func (s *payloadAttestationService) acquireExistingRESTAttestation(key seenPayloadAttestationKey, messageRoot common.Hash) *validatedRESTPayloadAttestation {
+ s.validatedRESTMu.Lock()
+ defer s.validatedRESTMu.Unlock()
+ entry := s.validatedREST[key]
+ if entry == nil || entry.messageRoot != messageRoot {
+ return nil
+ }
+ entry.refs++
+ return entry
+}
+
+func (s *payloadAttestationService) releaseValidatedRESTAttestation(key seenPayloadAttestationKey, entry *validatedRESTPayloadAttestation, retain bool) {
+ s.validatedRESTMu.Lock()
+ defer s.validatedRESTMu.Unlock()
+ entry.refs--
+ if current := s.validatedREST[key]; current == entry && entry.refs == 0 && !retain {
+ delete(s.validatedREST, key)
+ }
}
// queuePendingAttestation adds an attestation to the pending queue for later processing.
-func (s *payloadAttestationService) queuePendingAttestation(blockRoot common.Hash, msg *cltypes.PayloadAttestationMessage) {
+func (s *payloadAttestationService) queuePendingAttestation(blockRoot common.Hash, msg *cltypes.PayloadAttestationMessage) bool {
+ key := pendingPayloadAttestationKeyFor(blockRoot, msg)
+ if _, loaded := s.pendingAttestations.Load(key); loaded {
+ return true
+ }
if s.pendingCount.Add(1) > maxPendingAttestations {
s.pendingCount.Add(-1)
- return
+ if _, loaded := s.pendingAttestations.Load(key); loaded {
+ return true
+ }
+ return false
}
- key := pendingPayloadAttestationKeyFor(blockRoot, msg)
-
- if _, loaded := s.pendingAttestations.LoadOrStore(key, &pendingPayloadAttestationJob{
+ job := &pendingPayloadAttestationJob{
msg: msg,
creationTime: time.Now(),
- }); loaded {
+ }
+ _, loaded := s.pendingAttestations.LoadOrStore(key, job)
+ if loaded {
s.pendingCount.Add(-1)
} else {
s.pendingCond.L.Lock()
s.pendingCond.Signal()
s.pendingCond.L.Unlock()
}
+ return true
}
func pendingPayloadAttestationKeyFor(blockRoot common.Hash, msg *cltypes.PayloadAttestationMessage) pendingPayloadAttestationKey {
@@ -289,7 +450,7 @@ func (s *payloadAttestationService) processPendingAttestations(ctx context.Conte
}
// Check if attestation is still for current slot (with clock disparity allowance)
- if !s.ethClock.IsSlotCurrentSlotWithMaximumClockDisparity(job.msg.Data.Slot) {
+ if !isPayloadAttestationSlotCurrent(s.ethClock, s.now(), job.msg.Data.Slot) {
s.pendingAttestations.Delete(pendingKey)
s.pendingCount.Add(-1)
log.Trace("Pending payload attestation slot mismatch", "blockRoot", pendingKey.blockRoot)
@@ -301,14 +462,18 @@ func (s *payloadAttestationService) processPendingAttestations(ctx context.Conte
return true // Block still not here, keep waiting
}
- // Block arrived, remove from pending and process
- s.pendingAttestations.Delete(pendingKey)
- s.pendingCount.Add(-1)
-
- // Re-run validation via ProcessMessage
- if err := s.ProcessMessage(ctx, nil, job.msg); err != nil {
+ err := s.processMessage(ctx, job.msg, false, nil)
+ if err != nil {
+ if errors.Is(err, ErrAttestationRetryable) {
+ return true
+ }
+ s.pendingAttestations.Delete(pendingKey)
+ s.pendingCount.Add(-1)
log.Trace("Failed to process pending payload attestation", "blockRoot", pendingKey.blockRoot, "err", err)
+ return true
}
+ s.pendingAttestations.Delete(pendingKey)
+ s.pendingCount.Add(-1)
return true
})
}
diff --git a/cl/phase1/network/services/payload_attestation_service_test.go b/cl/phase1/network/services/payload_attestation_service_test.go
index 3b6384aa246..a810582fa7f 100644
--- a/cl/phase1/network/services/payload_attestation_service_test.go
+++ b/cl/phase1/network/services/payload_attestation_service_test.go
@@ -19,6 +19,7 @@ package services
import (
"context"
"errors"
+ "math"
"sync"
"sync/atomic"
"testing"
@@ -33,6 +34,7 @@ import (
"github.com/erigontech/erigon/cl/phase1/core/state/lru"
"github.com/erigontech/erigon/cl/phase1/forkchoice"
"github.com/erigontech/erigon/cl/phase1/forkchoice/mock_services"
+ "github.com/erigontech/erigon/cl/pool"
"github.com/erigontech/erigon/cl/utils/eth_clock"
"github.com/erigontech/erigon/common"
)
@@ -70,6 +72,19 @@ type retryPayloadAttestationForkchoice struct {
releaseFirst chan struct{}
}
+type retryablePayloadAttestationForkchoice struct {
+ forkchoice.ForkChoiceStorage
+ first error
+ calls atomic.Int32
+}
+
+func (f *retryablePayloadAttestationForkchoice) OnPayloadAttestationMessage(context.Context, *cltypes.PayloadAttestationMessage, bool) error {
+ if f.calls.Add(1) == 1 {
+ return f.first
+ }
+ return nil
+}
+
type candidatePayloadAttestationForkchoice struct {
forkchoice.ForkChoiceStorage
started chan byte
@@ -98,6 +113,10 @@ func (f *retryPayloadAttestationForkchoice) OnPayloadAttestationMessage(context.
func setupPayloadAttestationService(t *testing.T, ctrl *gomock.Controller) (*payloadAttestationService, *mock_services.ForkChoiceStorageMock, *eth_clock.MockEthereumClock) {
forkchoiceMock := mock_services.NewForkChoiceStorageMock(t)
ethClockMock := eth_clock.NewMockEthereumClock(ctrl)
+ ethClockMock.EXPECT().GenesisTime().Return(uint64(0)).AnyTimes()
+ ethClockMock.EXPECT().GetSlotTime(gomock.Any()).DoAndReturn(func(slot uint64) time.Time {
+ return time.Unix(int64(slot*12), 0)
+ }).AnyTimes()
seenCache, err := lru.New[seenPayloadAttestationKey, struct{}]("seen_payload_attestations", seenPayloadAttestationCacheSize)
require.NoError(t, err)
@@ -108,8 +127,10 @@ func setupPayloadAttestationService(t *testing.T, ctrl *gomock.Controller) (*pay
netCfg: nil, // Not used in current implementation
seenAttestationsCache: seenCache,
emitters: beaconevents.NewEventEmitter(),
+ epbsPool: pool.NewEpbsPool(),
pendingCond: sync.NewCond(&sync.Mutex{}), // Needed for queuePendingAttestation
validationAdmission: make(chan struct{}, maxConcurrentPayloadAttestationValidations),
+ now: func() time.Time { return time.Unix(100*12+6, 0) },
}
return service, forkchoiceMock, ethClockMock
@@ -119,7 +140,7 @@ func TestPayloadAttestationServiceBoundsKnownBlockValidation(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
- service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl)
+ service, fcu, _ := setupPayloadAttestationService(t, ctrl)
service.validationAdmission = make(chan struct{}, 1)
blockRoot := common.HexToHash("0x1234")
fcu.Headers[blockRoot] = &cltypes.BeaconBlockHeader{Slot: 100}
@@ -129,8 +150,6 @@ func TestPayloadAttestationServiceBoundsKnownBlockValidation(t *testing.T) {
release: make(chan struct{}),
}
service.forkchoiceStore = blockingForkchoice
- ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2)
-
firstDone := make(chan error, 1)
go func() {
firstDone <- service.ProcessMessage(context.Background(), nil, &cltypes.PayloadAttestationMessage{
@@ -155,7 +174,7 @@ func TestPayloadAttestationServiceBackpressuresInsteadOfDroppingValidCandidate(t
ctrl := gomock.NewController(t)
defer ctrl.Finish()
- service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl)
+ service, fcu, _ := setupPayloadAttestationService(t, ctrl)
service.validationAdmission = make(chan struct{}, 1)
blockRoot := common.HexToHash("0x1234")
fcu.Headers[blockRoot] = &cltypes.BeaconBlockHeader{Slot: 100}
@@ -165,8 +184,6 @@ func TestPayloadAttestationServiceBackpressuresInsteadOfDroppingValidCandidate(t
release: make(chan struct{}),
}
service.forkchoiceStore = validations
- ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2)
-
invalid := newTestPayloadAttestationMessage(100, 1, blockRoot)
invalid.Signature[0] = 1
firstResult := make(chan error, 1)
@@ -193,7 +210,7 @@ func TestPayloadAttestationServiceDoesNotDropValidCandidateBehindInvalidCandidat
ctrl := gomock.NewController(t)
defer ctrl.Finish()
- service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl)
+ service, fcu, _ := setupPayloadAttestationService(t, ctrl)
blockRoot := common.HexToHash("0x1234")
fcu.Headers[blockRoot] = &cltypes.BeaconBlockHeader{Slot: 100}
blockingForkchoice := &candidatePayloadAttestationForkchoice{
@@ -202,8 +219,6 @@ func TestPayloadAttestationServiceDoesNotDropValidCandidateBehindInvalidCandidat
release: make(chan struct{}),
}
service.forkchoiceStore = blockingForkchoice
- ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(3)
-
results := make(chan error, 3)
for candidate := byte(1); candidate <= 3; candidate++ {
msg := newTestPayloadAttestationMessage(100, 1, blockRoot)
@@ -236,7 +251,7 @@ func TestPayloadAttestationServiceAllowsConcurrentValidationForDifferentValidato
ctrl := gomock.NewController(t)
defer ctrl.Finish()
- service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl)
+ service, fcu, _ := setupPayloadAttestationService(t, ctrl)
blockRoot := common.HexToHash("0x1234")
fcu.Headers[blockRoot] = &cltypes.BeaconBlockHeader{Slot: 100}
@@ -246,8 +261,6 @@ func TestPayloadAttestationServiceAllowsConcurrentValidationForDifferentValidato
release: make(chan struct{}),
}
service.forkchoiceStore = blockingForkchoice
- ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(8)
-
var wg sync.WaitGroup
results := make(chan error, 8)
for i := range 8 {
@@ -279,7 +292,7 @@ func TestPayloadAttestationServiceAllowsConcurrentValidationForSameValidator(t *
ctrl := gomock.NewController(t)
defer ctrl.Finish()
- service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl)
+ service, fcu, _ := setupPayloadAttestationService(t, ctrl)
blockRoot := common.HexToHash("0x1234")
fcu.Headers[blockRoot] = &cltypes.BeaconBlockHeader{Slot: 100}
@@ -289,8 +302,6 @@ func TestPayloadAttestationServiceAllowsConcurrentValidationForSameValidator(t *
release: make(chan struct{}),
}
service.forkchoiceStore = blockingForkchoice
- ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2)
-
results := make(chan error, 2)
for range 2 {
go func() {
@@ -316,7 +327,7 @@ func TestPayloadAttestationServiceRetriesAfterInvalidDuplicate(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
- service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl)
+ service, fcu, _ := setupPayloadAttestationService(t, ctrl)
blockRoot := common.HexToHash("0x1234")
fcu.Headers[blockRoot] = &cltypes.BeaconBlockHeader{Slot: 100}
@@ -326,8 +337,6 @@ func TestPayloadAttestationServiceRetriesAfterInvalidDuplicate(t *testing.T) {
releaseFirst: make(chan struct{}),
}
service.forkchoiceStore = retryForkchoice
- ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2)
-
firstResult := make(chan error, 1)
go func() {
firstResult <- service.ProcessMessage(context.Background(), nil, newTestPayloadAttestationMessage(100, 42, blockRoot))
@@ -353,7 +362,7 @@ func TestPayloadAttestationServiceIgnoresCanceledDuplicateWaiter(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
- service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl)
+ service, fcu, _ := setupPayloadAttestationService(t, ctrl)
blockRoot := common.HexToHash("0x1234")
fcu.Headers[blockRoot] = &cltypes.BeaconBlockHeader{Slot: 100}
blockingForkchoice := &blockingPayloadAttestationForkchoice{
@@ -362,8 +371,6 @@ func TestPayloadAttestationServiceIgnoresCanceledDuplicateWaiter(t *testing.T) {
release: make(chan struct{}),
}
service.forkchoiceStore = blockingForkchoice
- ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2)
-
firstResult := make(chan error, 1)
go func() {
firstResult <- service.ProcessMessage(context.Background(), nil, newTestPayloadAttestationMessage(100, 42, blockRoot))
@@ -413,13 +420,12 @@ func TestPayloadAttestationServiceSlotMismatch(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
- service, _, ethClockMock := setupPayloadAttestationService(t, ctrl)
+ service, _, _ := setupPayloadAttestationService(t, ctrl)
blockRoot := common.HexToHash("0x1234")
msg := newTestPayloadAttestationMessage(100, 1, blockRoot)
- // Mock: slot 100 is NOT current slot (with disparity)
- ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(false)
+ service.now = func() time.Time { return time.Unix(100*12, 0).Add(-gloasMaximumClockDisparity - time.Millisecond) }
err := service.ProcessMessage(context.Background(), nil, msg)
require.Error(t, err)
@@ -427,11 +433,50 @@ func TestPayloadAttestationServiceSlotMismatch(t *testing.T) {
require.Contains(t, err.Error(), "not current slot")
}
+func TestPayloadAttestationServiceRejectsTooEarlyNextSlot(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ service, _, _ := setupPayloadAttestationService(t, ctrl)
+ service.now = func() time.Time { return time.Unix(100*12, 0).Add(-gloasMaximumClockDisparity - time.Millisecond) }
+
+ err := service.ProcessMessage(context.Background(), nil, newTestPayloadAttestationMessage(100, 1, common.HexToHash("0x1234")))
+ require.ErrorIs(t, err, ErrIgnore)
+ require.NotErrorIs(t, err, ErrAttestationQueued)
+}
+
+func TestPayloadAttestationSlotCurrentBoundaries(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ service, _, _ := setupPayloadAttestationService(t, ctrl)
+ slotStart := service.ethClock.GetSlotTime(100)
+ nextSlotStart := service.ethClock.GetSlotTime(101)
+ for _, tc := range []struct {
+ name string
+ now time.Time
+ slot uint64
+ want bool
+ }{
+ {name: "too early next slot", now: slotStart.Add(-gloasMaximumClockDisparity - time.Millisecond), slot: 100, want: false},
+ {name: "exact lower boundary", now: slotStart.Add(-gloasMaximumClockDisparity), slot: 100, want: true},
+ {name: "current slot interior", now: slotStart.Add(6 * time.Second), slot: 100, want: true},
+ {name: "exact upper boundary", now: nextSlotStart.Add(gloasMaximumClockDisparity), slot: 100, want: true},
+ {name: "too late previous slot", now: nextSlotStart.Add(gloasMaximumClockDisparity + time.Millisecond), slot: 100, want: false},
+ {name: "slot time overflow", now: slotStart, slot: uint64(math.MaxInt64/12) + 1, want: false},
+ {name: "maximum slot", now: slotStart, slot: math.MaxUint64, want: false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ require.Equal(t, tc.want, isPayloadAttestationSlotCurrent(service.ethClock, tc.now, tc.slot))
+ })
+ }
+}
+
func TestPayloadAttestationServiceDuplicateValidator(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
- service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl)
+ service, fcu, _ := setupPayloadAttestationService(t, ctrl)
blockRoot := common.HexToHash("0x1234")
msg := newTestPayloadAttestationMessage(100, 42, blockRoot)
@@ -441,16 +486,10 @@ func TestPayloadAttestationServiceDuplicateValidator(t *testing.T) {
Slot: 100,
}
- // First call setup
- ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true)
-
// First call should succeed
err := service.ProcessMessage(context.Background(), nil, msg)
require.NoError(t, err)
- // Second call setup
- ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true)
-
// Second call with same (slot, validatorIndex) should be ignored
err = service.ProcessMessage(context.Background(), nil, msg)
require.Error(t, err)
@@ -462,14 +501,11 @@ func TestPayloadAttestationServiceBlockNotFound(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
- service, _, ethClockMock := setupPayloadAttestationService(t, ctrl)
+ service, _, _ := setupPayloadAttestationService(t, ctrl)
blockRoot := common.HexToHash("0x1234")
msg := newTestPayloadAttestationMessage(100, 1, blockRoot)
- // Mock: slot is current
- ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true)
-
// Block not in forkchoice - should queue and report queued.
err := service.ProcessMessage(context.Background(), nil, msg)
require.Error(t, err)
@@ -485,6 +521,56 @@ func TestPayloadAttestationServiceBlockNotFound(t *testing.T) {
require.True(t, exists)
}
+func TestPayloadAttestationServiceReportsCapacityWhenMissingBlockQueueIsFull(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ service, _, _ := setupPayloadAttestationService(t, ctrl)
+ for i := range maxPendingAttestations {
+ key := pendingPayloadAttestationKey{
+ blockRoot: common.Hash{byte(i), byte(i >> 8)},
+ validatorIndex: uint64(i),
+ messageRoot: common.Hash{byte(i >> 8), byte(i)},
+ }
+ service.pendingAttestations.Store(key, &pendingPayloadAttestationJob{})
+ }
+ service.pendingCount.Store(maxPendingAttestations)
+
+ blockRoot := common.HexToHash("0x1234")
+ msg := newTestPayloadAttestationMessage(100, 1, blockRoot)
+ err := service.ProcessMessage(context.Background(), nil, msg)
+ require.ErrorIs(t, err, ErrIgnore)
+ require.ErrorIs(t, err, ErrAttestationCapacity)
+ require.NotErrorIs(t, err, ErrAttestationQueued)
+ require.Equal(t, int32(maxPendingAttestations), service.pendingCount.Load())
+ _, exists := service.pendingAttestations.Load(pendingPayloadAttestationKeyFor(blockRoot, msg))
+ require.False(t, exists)
+}
+
+func TestPayloadAttestationServiceReportsQueuedWhenExactWorkExistsAtCapacity(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ service, _, _ := setupPayloadAttestationService(t, ctrl)
+ blockRoot := common.HexToHash("0x1234")
+ msg := newTestPayloadAttestationMessage(100, 1, blockRoot)
+ service.pendingAttestations.Store(pendingPayloadAttestationKeyFor(blockRoot, msg), &pendingPayloadAttestationJob{msg: msg})
+ for i := range maxPendingAttestations - 1 {
+ key := pendingPayloadAttestationKey{
+ blockRoot: common.Hash{byte(i), byte(i >> 8)},
+ validatorIndex: uint64(i + 2),
+ messageRoot: common.Hash{byte(i >> 8), byte(i)},
+ }
+ service.pendingAttestations.Store(key, &pendingPayloadAttestationJob{})
+ }
+ service.pendingCount.Store(maxPendingAttestations)
+ err := service.ProcessMessage(context.Background(), nil, msg)
+ require.ErrorIs(t, err, ErrIgnore)
+ require.ErrorIs(t, err, ErrAttestationQueued)
+ require.NotErrorIs(t, err, ErrAttestationCapacity)
+ require.Equal(t, int32(maxPendingAttestations), service.pendingCount.Load())
+}
+
func TestPayloadAttestationServicePendingQueueKeepsDistinctSameValidatorBlock(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -511,7 +597,7 @@ func TestPayloadAttestationServiceReferencedBlockSlotMismatch(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
- service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl)
+ service, fcu, _ := setupPayloadAttestationService(t, ctrl)
blockRoot := common.HexToHash("0x1234")
msg := newTestPayloadAttestationMessage(100, 1, blockRoot)
@@ -520,8 +606,6 @@ func TestPayloadAttestationServiceReferencedBlockSlotMismatch(t *testing.T) {
Slot: 99,
}
- ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true)
-
err := service.ProcessMessage(context.Background(), nil, msg)
require.Error(t, err)
require.True(t, errors.Is(err, ErrIgnore))
@@ -533,7 +617,7 @@ func TestPayloadAttestationServiceSuccess(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
- service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl)
+ service, fcu, _ := setupPayloadAttestationService(t, ctrl)
blockRoot := common.HexToHash("0x1234")
msg := newTestPayloadAttestationMessage(100, 42, blockRoot)
@@ -543,9 +627,6 @@ func TestPayloadAttestationServiceSuccess(t *testing.T) {
Slot: 100,
}
- // Mock expectations
- ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true)
-
// Process should succeed
err := service.ProcessMessage(context.Background(), nil, msg)
require.NoError(t, err)
@@ -556,13 +637,230 @@ func TestPayloadAttestationServiceSuccess(t *testing.T) {
validatorIndex: 42,
}
require.True(t, service.seenAttestationsCache.Contains(seenKey))
+ pooled, ok := service.epbsPool.PayloadAttestations.Get(pool.PayloadAttestationKey{Slot: 100, ValidatorIndex: 42})
+ require.True(t, ok)
+ require.Same(t, msg, pooled)
+}
+
+func TestPayloadAttestationServiceRESTCommitsOnlyAfterPublish(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, fcu, _ := setupPayloadAttestationService(t, ctrl)
+ root := common.HexToHash("0x1234")
+ msg := newTestPayloadAttestationMessage(100, 42, root)
+ fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100}
+ require.Error(t, service.ProcessRESTMessage(t.Context(), msg, func() error { return errors.New("publish failed") }))
+ require.False(t, service.seenAttestationsCache.Contains(seenPayloadAttestationKey{100, 42}))
+ _, ok := service.epbsPool.PayloadAttestations.Get(pool.PayloadAttestationKey{Slot: 100, ValidatorIndex: 42})
+ require.False(t, ok)
+ require.NoError(t, service.ProcessRESTMessage(t.Context(), msg, func() error { return nil }))
+ require.True(t, service.seenAttestationsCache.Contains(seenPayloadAttestationKey{100, 42}))
+}
+
+type consumeOncePayloadAttestationForkchoice struct {
+ forkchoice.ForkChoiceStorage
+ calls atomic.Int32
+}
+
+type coordinatedPayloadAttestationForkchoice struct {
+ forkchoice.ForkChoiceStorage
+ calls atomic.Int32
+ firstStarted chan struct{}
+ releaseFirst chan struct{}
+ firstErr error
+}
+
+func (f *coordinatedPayloadAttestationForkchoice) OnPayloadAttestationMessage(context.Context, *cltypes.PayloadAttestationMessage, bool) error {
+ if f.calls.Add(1) == 1 {
+ close(f.firstStarted)
+ <-f.releaseFirst
+ return f.firstErr
+ }
+ return nil
+}
+
+func (f *consumeOncePayloadAttestationForkchoice) OnPayloadAttestationMessage(context.Context, *cltypes.PayloadAttestationMessage, bool) error {
+ if f.calls.Add(1) == 1 {
+ return nil
+ }
+ return forkchoice.ErrIgnore
+}
+
+func TestPayloadAttestationServiceRESTRetriesPublishWithoutRevalidating(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, fcu, _ := setupPayloadAttestationService(t, ctrl)
+ root := common.HexToHash("0x1234")
+ msg := newTestPayloadAttestationMessage(100, 42, root)
+ fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100}
+ consumeOnce := &consumeOncePayloadAttestationForkchoice{ForkChoiceStorage: fcu}
+ service.forkchoiceStore = consumeOnce
+ require.Error(t, service.ProcessRESTMessage(t.Context(), msg, func() error { return errors.New("publish failed") }))
+ require.NoError(t, service.ProcessRESTMessage(t.Context(), msg, func() error { return nil }))
+ require.Equal(t, int32(1), consumeOnce.calls.Load())
+ require.True(t, service.seenAttestationsCache.Contains(seenPayloadAttestationKey{100, 42}))
+}
+
+func TestPayloadAttestationServiceP2PSatisfiesPendingRESTPublish(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, fcu, _ := setupPayloadAttestationService(t, ctrl)
+ root := common.HexToHash("0x1234")
+ msg := newTestPayloadAttestationMessage(100, 42, root)
+ fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100}
+ consumeOnce := &consumeOncePayloadAttestationForkchoice{ForkChoiceStorage: fcu}
+ service.forkchoiceStore = consumeOnce
+ require.Error(t, service.ProcessRESTMessage(t.Context(), msg, func() error { return errors.New("publish failed") }))
+ require.NoError(t, service.ProcessMessage(t.Context(), nil, msg))
+ require.Equal(t, int32(1), consumeOnce.calls.Load())
+ require.True(t, service.seenAttestationsCache.Contains(seenPayloadAttestationKey{100, 42}))
+}
+
+func TestPayloadAttestationServiceMismatchedP2PValidatesBehindPendingREST(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, fcu, _ := setupPayloadAttestationService(t, ctrl)
+ root := common.HexToHash("0x1234")
+ restMsg := newTestPayloadAttestationMessage(100, 42, root)
+ p2pMsg := newTestPayloadAttestationMessage(100, 42, root)
+ p2pMsg.Signature[0] = 1
+ fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100}
+ consumeOnce := &consumeOncePayloadAttestationForkchoice{ForkChoiceStorage: fcu}
+ service.forkchoiceStore = consumeOnce
+ require.Error(t, service.ProcessRESTMessage(t.Context(), restMsg, func() error { return errors.New("publish failed") }))
+ require.ErrorIs(t, service.ProcessMessage(t.Context(), nil, p2pMsg), ErrAttestationRetryable)
+ require.Equal(t, int32(2), consumeOnce.calls.Load())
+}
+
+func TestPayloadAttestationServiceExactP2PJoinsRESTValidationBeforePublishFailure(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, fcu, _ := setupPayloadAttestationService(t, ctrl)
+ root := common.HexToHash("0x1234")
+ msg := newTestPayloadAttestationMessage(100, 42, root)
+ fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100}
+ coordinated := &coordinatedPayloadAttestationForkchoice{
+ ForkChoiceStorage: fcu,
+ firstStarted: make(chan struct{}),
+ releaseFirst: make(chan struct{}),
+ }
+ service.forkchoiceStore = coordinated
+ restResult := make(chan error, 1)
+ go func() {
+ restResult <- service.ProcessRESTMessage(t.Context(), msg, func() error { return errors.New("publish failed") })
+ }()
+ <-coordinated.firstStarted
+ p2pResult := make(chan error, 1)
+ go func() { p2pResult <- service.ProcessMessage(t.Context(), nil, msg) }()
+ waitForValidatedRESTCoordinatorRefs(t, service, seenPayloadAttestationKey{100, 42}, 2)
+ close(coordinated.releaseFirst)
+ require.Error(t, <-restResult)
+ require.NoError(t, <-p2pResult)
+ require.Equal(t, int32(1), coordinated.calls.Load())
+ require.True(t, service.seenAttestationsCache.Contains(seenPayloadAttestationKey{100, 42}))
+}
+
+func TestPayloadAttestationServiceP2PValidatesAfterRESTValidationFailure(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, fcu, _ := setupPayloadAttestationService(t, ctrl)
+ root := common.HexToHash("0x1234")
+ msg := newTestPayloadAttestationMessage(100, 42, root)
+ fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100}
+ coordinated := &coordinatedPayloadAttestationForkchoice{
+ ForkChoiceStorage: fcu,
+ firstStarted: make(chan struct{}),
+ releaseFirst: make(chan struct{}),
+ firstErr: errors.New("invalid signature"),
+ }
+ service.forkchoiceStore = coordinated
+ restResult := make(chan error, 1)
+ var publishCalled atomic.Bool
+ go func() {
+ restResult <- service.ProcessRESTMessage(t.Context(), msg, func() error {
+ publishCalled.Store(true)
+ return nil
+ })
+ }()
+ <-coordinated.firstStarted
+ p2pResult := make(chan error, 1)
+ go func() { p2pResult <- service.ProcessMessage(t.Context(), nil, msg) }()
+ waitForValidatedRESTCoordinatorRefs(t, service, seenPayloadAttestationKey{100, 42}, 2)
+ close(coordinated.releaseFirst)
+ require.Error(t, <-restResult)
+ require.NoError(t, <-p2pResult)
+ require.Equal(t, int32(2), coordinated.calls.Load())
+ require.False(t, publishCalled.Load())
+}
+
+func waitForValidatedRESTCoordinatorRefs(t *testing.T, service *payloadAttestationService, key seenPayloadAttestationKey, refs int) {
+ t.Helper()
+ require.Eventually(t, func() bool {
+ service.validatedRESTMu.Lock()
+ defer service.validatedRESTMu.Unlock()
+ entry := service.validatedREST[key]
+ return entry != nil && entry.refs == refs
+ }, time.Second, time.Millisecond)
+}
+
+func TestPayloadAttestationServiceRESTPendingRejectsDifferentIdentity(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, fcu, _ := setupPayloadAttestationService(t, ctrl)
+ root := common.HexToHash("0x1234")
+ first := newTestPayloadAttestationMessage(100, 42, root)
+ second := newTestPayloadAttestationMessage(100, 42, root)
+ second.Signature[0] = 1
+ fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100}
+ require.Error(t, service.ProcessRESTMessage(t.Context(), first, func() error { return errors.New("publish failed") }))
+ err := service.ProcessRESTMessage(t.Context(), second, func() error { t.Fatal("different identity published"); return nil })
+ require.ErrorIs(t, err, ErrAttestationConflict)
+}
+
+func TestPayloadAttestationServiceConcurrentRESTRetryCoalescesValidation(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, fcu, _ := setupPayloadAttestationService(t, ctrl)
+ root := common.HexToHash("0x1234")
+ msg := newTestPayloadAttestationMessage(100, 42, root)
+ fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100}
+ consumeOnce := &consumeOncePayloadAttestationForkchoice{ForkChoiceStorage: fcu}
+ service.forkchoiceStore = consumeOnce
+ firstPublishing := make(chan struct{})
+ releaseFirst := make(chan struct{})
+ results := make(chan error, 2)
+ go func() {
+ results <- service.ProcessRESTMessage(t.Context(), msg, func() error {
+ close(firstPublishing)
+ <-releaseFirst
+ return errors.New("publish failed")
+ })
+ }()
+ <-firstPublishing
+ go func() {
+ results <- service.ProcessRESTMessage(t.Context(), msg, func() error { return nil })
+ }()
+ close(releaseFirst)
+
+ firstErr, secondErr := <-results, <-results
+ require.True(t, (firstErr == nil) != (secondErr == nil))
+ require.Equal(t, int32(1), consumeOnce.calls.Load())
+ require.True(t, service.seenAttestationsCache.Contains(seenPayloadAttestationKey{100, 42}))
+}
+
+func TestValidatedRESTPayloadAttestationCoordinatorExpiresAndBoundsEntries(t *testing.T) {
+ service := &payloadAttestationService{validatedREST: make(map[seenPayloadAttestationKey]*validatedRESTPayloadAttestation)}
+ service.validatedREST[seenPayloadAttestationKey{1, 1}] = &validatedRESTPayloadAttestation{
+ creationTime: time.Now().Add(-pendingPayloadAttestationExpiry - time.Second),
+ }
+ entry, err := service.acquireValidatedRESTAttestation(seenPayloadAttestationKey{2, 2}, common.Hash{2})
+ require.NoError(t, err)
+ require.Len(t, service.validatedREST, 1)
+ service.releaseValidatedRESTAttestation(seenPayloadAttestationKey{2, 2}, entry, true)
+ for i := range maxPendingAttestations - 1 {
+ service.validatedREST[seenPayloadAttestationKey{slot: uint64(i + 3), validatorIndex: uint64(i + 3)}] = &validatedRESTPayloadAttestation{creationTime: time.Now()}
+ }
+ _, err = service.acquireValidatedRESTAttestation(seenPayloadAttestationKey{9999, 9999}, common.Hash{9})
+ require.ErrorIs(t, err, ErrAttestationCapacity)
}
func TestPayloadAttestationServiceDifferentValidatorsSameBlock(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
- service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl)
+ service, fcu, _ := setupPayloadAttestationService(t, ctrl)
blockRoot := common.HexToHash("0x1234")
msg1 := newTestPayloadAttestationMessage(100, 1, blockRoot)
@@ -574,8 +872,6 @@ func TestPayloadAttestationServiceDifferentValidatorsSameBlock(t *testing.T) {
}
// Both should be processed (different validators)
- ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2)
-
err := service.ProcessMessage(context.Background(), nil, msg1)
require.NoError(t, err)
@@ -616,7 +912,7 @@ func TestPayloadAttestationServicePendingSlotMismatch(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
- service, _, ethClockMock := setupPayloadAttestationService(t, ctrl)
+ service, _, _ := setupPayloadAttestationService(t, ctrl)
blockRoot := common.HexToHash("0x1234")
msg := newTestPayloadAttestationMessage(100, 1, blockRoot)
@@ -629,8 +925,7 @@ func TestPayloadAttestationServicePendingSlotMismatch(t *testing.T) {
})
service.pendingCount.Store(1)
- // Mock: slot 100 is no longer current
- ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(false)
+ service.now = func() time.Time { return time.Unix(101*12, 0).Add(gloasMaximumClockDisparity + time.Millisecond) }
// Process pending - should remove due to slot mismatch
service.processPendingAttestations(context.Background())
@@ -644,7 +939,7 @@ func TestPayloadAttestationServicePendingProcessing(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
- service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl)
+ service, fcu, _ := setupPayloadAttestationService(t, ctrl)
blockRoot := common.HexToHash("0x1234")
msg := newTestPayloadAttestationMessage(100, 42, blockRoot)
@@ -658,7 +953,6 @@ func TestPayloadAttestationServicePendingProcessing(t *testing.T) {
service.pendingCount.Store(1)
// First process: slot ok, but block not available
- ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true)
service.processPendingAttestations(context.Background())
require.Equal(t, int32(1), service.pendingCount.Load()) // Still pending
@@ -668,8 +962,6 @@ func TestPayloadAttestationServicePendingProcessing(t *testing.T) {
}
// Second process: slot ok, block available -> should process
- // ProcessMessage will be called, which calls IsSlotCurrentSlotWithMaximumClockDisparity again
- ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(2)
service.processPendingAttestations(context.Background())
require.Equal(t, int32(0), service.pendingCount.Load())
@@ -684,7 +976,7 @@ func TestPayloadAttestationServiceMultiplePendingForSameBlock(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
- service, fcu, ethClockMock := setupPayloadAttestationService(t, ctrl)
+ service, fcu, _ := setupPayloadAttestationService(t, ctrl)
blockRoot := common.HexToHash("0x1234")
@@ -708,9 +1000,6 @@ func TestPayloadAttestationServiceMultiplePendingForSameBlock(t *testing.T) {
Slot: 100,
}
- // Expect IsSlotCurrentSlotWithMaximumClockDisparity to be called for each pending + each ProcessMessage
- ethClockMock.EXPECT().IsSlotCurrentSlotWithMaximumClockDisparity(uint64(100)).Return(true).Times(4)
-
// Process - both should be processed
service.processPendingAttestations(context.Background())
@@ -719,6 +1008,24 @@ func TestPayloadAttestationServiceMultiplePendingForSameBlock(t *testing.T) {
require.True(t, service.seenAttestationsCache.Contains(seenPayloadAttestationKey{100, 2}))
}
+func TestPayloadAttestationServiceRetainsPendingRetryableValidation(t *testing.T) {
+ for _, firstErr := range []error{forkchoice.ErrIgnore, context.Canceled} {
+ t.Run(firstErr.Error(), func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, fcu, _ := setupPayloadAttestationService(t, ctrl)
+ root := common.HexToHash("0x1234")
+ msg := newTestPayloadAttestationMessage(100, 42, root)
+ fcu.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 100}
+ service.forkchoiceStore = &retryablePayloadAttestationForkchoice{ForkChoiceStorage: fcu, first: firstErr}
+ service.queuePendingAttestation(root, msg)
+ service.processPendingAttestations(t.Context())
+ require.Equal(t, int32(1), service.pendingCount.Load())
+ service.processPendingAttestations(t.Context())
+ require.Zero(t, service.pendingCount.Load())
+ })
+ }
+}
+
func TestPayloadAttestationServicePendingQueueCap(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -811,3 +1118,19 @@ func TestPayloadAttestationServiceDecodeGossipMessageInvalid(t *testing.T) {
_, err := service.DecodeGossipMessage("peer123", []byte{0x00, 0x01, 0x02}, clparams.GloasVersion)
require.Error(t, err)
}
+
+func TestPayloadAttestationServiceDecodeGossipMessageStrict(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, _, _ := setupPayloadAttestationService(t, ctrl)
+ encoded, err := newTestPayloadAttestationMessage(100, 42, common.Hash{1}).EncodeSSZ(nil)
+ require.NoError(t, err)
+
+ _, err = service.DecodeGossipMessage("peer123", append(encoded, 0), clparams.GloasVersion)
+ require.Error(t, err)
+
+ nonCanonicalBool := append([]byte(nil), encoded...)
+ const payloadPresentOffset = 8 + 32 + 8
+ nonCanonicalBool[payloadPresentOffset] = 2
+ _, err = service.DecodeGossipMessage("peer123", nonCanonicalBool, clparams.GloasVersion)
+ require.Error(t, err)
+}
diff --git a/cl/phase1/network/services/proposer_preferences_service.go b/cl/phase1/network/services/proposer_preferences_service.go
index d767341b433..1d5014d4823 100644
--- a/cl/phase1/network/services/proposer_preferences_service.go
+++ b/cl/phase1/network/services/proposer_preferences_service.go
@@ -3,13 +3,15 @@ package services
import (
"context"
"fmt"
+ "sync"
+ "time"
+ "github.com/erigontech/erigon/cl/beacon/beaconevents"
"github.com/erigontech/erigon/cl/beacon/synced_data"
"github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/cltypes"
"github.com/erigontech/erigon/cl/gossip"
"github.com/erigontech/erigon/cl/phase1/core/state"
- "github.com/erigontech/erigon/cl/phase1/core/state/lru"
"github.com/erigontech/erigon/cl/phase1/forkchoice"
"github.com/erigontech/erigon/cl/pool"
"github.com/erigontech/erigon/cl/transition"
@@ -20,12 +22,13 @@ import (
)
type seenProposerPreferencesKey struct {
- validatorIndex uint64
- slot uint64
- dependentRoot common.Hash
+ slot uint64
+ dependentRoot common.Hash
}
-const seenProposerPreferencesCacheSize = 128 // ~2 epochs of slots * some buffer
+func newSeenProposerPreferencesKey(preferences *cltypes.ProposerPreferences) seenProposerPreferencesKey {
+ return seenProposerPreferencesKey{slot: preferences.ProposalSlot, dependentRoot: preferences.DependentRoot}
+}
type proposerPreferencesService struct {
syncedDataManager synced_data.SyncedData
@@ -33,8 +36,10 @@ type proposerPreferencesService struct {
ethClock eth_clock.EthereumClock
beaconCfg *clparams.BeaconChainConfig
epbsPool *pool.EpbsPool
+ now func() time.Time
+ emitters *beaconevents.EventEmitter
- seenCache *lru.Cache[seenProposerPreferencesKey, struct{}]
+ storeMu sync.Mutex
}
// NewProposerPreferencesService creates a new proposer preferences gossip service.
@@ -45,18 +50,16 @@ func NewProposerPreferencesService(
ethClock eth_clock.EthereumClock,
beaconCfg *clparams.BeaconChainConfig,
epbsPool *pool.EpbsPool,
+ emitters *beaconevents.EventEmitter,
) ProposerPreferencesService {
- seenCache, err := lru.New[seenProposerPreferencesKey, struct{}]("seen_proposer_preferences", seenProposerPreferencesCacheSize)
- if err != nil {
- panic(err)
- }
return &proposerPreferencesService{
syncedDataManager: syncedDataManager,
forkchoiceStore: forkchoiceStore,
ethClock: ethClock,
beaconCfg: beaconCfg,
epbsPool: epbsPool,
- seenCache: seenCache,
+ now: time.Now,
+ emitters: emitters,
}
}
@@ -66,7 +69,7 @@ func (s *proposerPreferencesService) Names() []string {
func (s *proposerPreferencesService) DecodeGossipMessage(_ peer.ID, data []byte, version clparams.StateVersion) (*cltypes.SignedProposerPreferences, error) {
msg := &cltypes.SignedProposerPreferences{}
- if err := msg.DecodeSSZ(data, int(version)); err != nil {
+ if err := msg.DecodeSSZStrict(data, int(version)); err != nil {
return nil, err
}
return msg, nil
@@ -85,36 +88,58 @@ func (s *proposerPreferencesService) ProcessMessage(ctx context.Context, _ *uint
"proposalSlot", proposalSlot,
"validatorIndex", validatorIndex)
- // [IGNORE] compute_epoch_at_slot(preferences.proposal_slot) in range(current_epoch, current_epoch + MIN_SEED_LOOKAHEAD + 1)
- currentEpoch := s.ethClock.GetCurrentEpoch()
+ now := s.now()
+ past, validTime := isPastSlot(s.ethClock, s.beaconCfg, now, proposalSlot, gloasMaximumClockDisparity)
+ if !validTime {
+ return fmt.Errorf("%w: proposal slot %d has no representable time", ErrIgnore, proposalSlot)
+ }
+ if past {
+ return fmt.Errorf("%w: proposal slot %d has already passed", ErrIgnore, proposalSlot)
+ }
+ if s.beaconCfg.SlotsPerEpoch == 0 {
+ return fmt.Errorf("%w: slots per epoch is zero", ErrIgnore)
+ }
proposalEpoch := state.GetEpochAtSlot(s.beaconCfg, proposalSlot)
- if proposalEpoch < currentEpoch || proposalEpoch > currentEpoch+s.beaconCfg.MinSeedLookahead {
- return fmt.Errorf("%w: proposal slot %d is in epoch %d, expected epoch in [%d, %d]",
- ErrIgnore, proposalSlot, proposalEpoch, currentEpoch, currentEpoch+s.beaconCfg.MinSeedLookahead)
+ if proposalEpoch < s.beaconCfg.MinSeedLookahead {
+ return fmt.Errorf("%w: proposal epoch %d before min seed lookahead %d", ErrIgnore, proposalEpoch, s.beaconCfg.MinSeedLookahead)
}
-
- // [IGNORE] The proposal slot has not already passed (proposal_slot > current_slot)
- currentSlot := s.ethClock.GetCurrentSlot()
- if proposalSlot <= currentSlot {
- return fmt.Errorf("%w: proposal slot %d has already passed (current slot %d)",
- ErrIgnore, proposalSlot, currentSlot)
+ lookaheadEpoch := proposalEpoch - s.beaconCfg.MinSeedLookahead
+ lookaheadEpochStartSlot, ok := safeMultiplyUint64(lookaheadEpoch, s.beaconCfg.SlotsPerEpoch)
+ if !ok {
+ return fmt.Errorf("%w: proposer lookahead slot is not representable", ErrIgnore)
}
-
- // [IGNORE] First valid message from this (validator_index, proposal_slot, dependent_root)
- seenKey := seenProposerPreferencesKey{
- validatorIndex: validatorIndex,
- slot: proposalSlot,
- dependentRoot: preferences.DependentRoot,
+ lookaheadEpochStartTime, ok := safeSlotTime(s.ethClock, s.beaconCfg, lookaheadEpochStartSlot)
+ if !ok {
+ return fmt.Errorf("%w: proposer lookahead slot %d has no representable time", ErrIgnore, lookaheadEpochStartSlot)
}
- if s.seenCache.Contains(seenKey) {
+ if now.Add(gloasMaximumClockDisparity).Before(lookaheadEpochStartTime) {
+ return fmt.Errorf("%w: proposer for proposal slot %d is not yet known", ErrIgnore, proposalSlot)
+ }
+ s.epbsPool.ProposerPreferences.PruneSlots(func(entrySlot uint64) bool {
+ return isPastBidWindow(s.ethClock, s.beaconCfg, now, entrySlot)
+ })
+
+ // [IGNORE] First valid message for this dependent root and proposal slot.
+ seenKey := newSeenProposerPreferencesKey(preferences)
+ if s.hasSeenPreference(seenKey) {
return fmt.Errorf("%w: already seen proposer preferences from validator %d for slot %d with dependent root %v",
ErrIgnore, validatorIndex, proposalSlot, preferences.DependentRoot)
}
-
- depState, err := s.forkchoiceStore.GetStateAtBlockRoot(preferences.DependentRoot, false)
+ dependentHeader, ok := s.forkchoiceStore.GetHeader(preferences.DependentRoot)
+ if !ok {
+ return fmt.Errorf("%w: dependent block %v has not been seen", ErrIgnore, preferences.DependentRoot)
+ }
+ depState, err := s.forkchoiceStore.GetStateAtBlockRoot(preferences.DependentRoot, true)
if err != nil || depState == nil {
return fmt.Errorf("%w: state for dependent_root %v not available", ErrIgnore, preferences.DependentRoot)
}
+ if dependentHeader.Slot >= lookaheadEpochStartSlot {
+ return fmt.Errorf("dependent root slot %d is not before proposer lookahead slot %d", dependentHeader.Slot, lookaheadEpochStartSlot)
+ }
+ if !s.isValidDependentRoot(preferences.DependentRoot, lookaheadEpochStartSlot) {
+ return fmt.Errorf("%w: dependent root is not a possible dependent block", ErrIgnore)
+ }
+
validationState, err := s.proposerPreferencesValidationState(depState, proposalEpoch)
if err != nil {
return fmt.Errorf("%w: failed to prepare dependent state: %w", ErrIgnore, err)
@@ -123,12 +148,20 @@ func (s *proposerPreferencesService) ProcessMessage(ctx context.Context, _ *uint
return fmt.Errorf("proposer preferences validation failed: %w", err)
}
- // All checks passed — mark as seen and store in pool
- s.seenCache.Add(seenKey, struct{}{})
+ s.storeMu.Lock()
+ if s.hasSeenPreference(seenKey) {
+ s.storeMu.Unlock()
+ return fmt.Errorf("%w: already seen proposer preferences from validator %d for slot %d with dependent root %v",
+ ErrIgnore, validatorIndex, proposalSlot, preferences.DependentRoot)
+ }
s.epbsPool.ProposerPreferences.Add(pool.ProposerPreferencesKey{
Slot: proposalSlot,
DependentRoot: preferences.DependentRoot,
}, msg)
+ s.storeMu.Unlock()
+ if s.emitters != nil {
+ s.emitters.Operation().SendProposerPreferences(&beaconevents.VersionedSignedProposerPreferences{Version: clparams.GloasVersion.String(), Data: msg})
+ }
log.Trace("Processed proposer preferences via gossip",
"proposalSlot", proposalSlot,
@@ -139,23 +172,51 @@ func (s *proposerPreferencesService) ProcessMessage(ctx context.Context, _ *uint
return nil
}
+func isPastSlot(clock eth_clock.EthereumClock, beaconCfg *clparams.BeaconChainConfig, now time.Time, slot uint64, disparity time.Duration) (bool, bool) {
+ slotTime, ok := safeSlotTime(clock, beaconCfg, slot)
+ if !ok {
+ return false, false
+ }
+ return now.After(slotTime.Add(disparity)), true
+}
+
+func (s *proposerPreferencesService) hasSeenPreference(key seenProposerPreferencesKey) bool {
+ _, ok := s.epbsPool.ProposerPreferences.Get(pool.ProposerPreferencesKey{Slot: key.slot, DependentRoot: key.dependentRoot})
+ return ok
+}
+
+func isPastBidWindow(clock eth_clock.EthereumClock, beaconCfg *clparams.BeaconChainConfig, now time.Time, slot uint64) bool {
+ if slot == ^uint64(0) {
+ return true
+ }
+ nextSlotTime, ok := safeSlotTime(clock, beaconCfg, slot+1)
+ return !ok || now.After(nextSlotTime.Add(gloasMaximumClockDisparity))
+}
+
+func (s *proposerPreferencesService) isValidDependentRoot(root common.Hash, epochStartSlot uint64) bool {
+ if s.forkchoiceStore.HasBlockChildAtOrAfter(root, epochStartSlot) {
+ return true
+ }
+ headRoot, _, err := s.forkchoiceStore.GetHead(nil)
+ return err == nil && root == headRoot
+}
+
func (s *proposerPreferencesService) proposerPreferencesValidationState(depState *state.CachingBeaconState, proposalEpoch uint64) (*state.CachingBeaconState, error) {
if proposalEpoch < s.beaconCfg.MinSeedLookahead {
return nil, fmt.Errorf("proposal epoch %d before min seed lookahead %d", proposalEpoch, s.beaconCfg.MinSeedLookahead)
}
dependentEpoch := proposalEpoch - s.beaconCfg.MinSeedLookahead
- validationSlot := dependentEpoch * s.beaconCfg.SlotsPerEpoch
+ validationSlot, ok := safeMultiplyUint64(dependentEpoch, s.beaconCfg.SlotsPerEpoch)
+ if !ok {
+ return nil, fmt.Errorf("dependent validation slot is not representable")
+ }
if depState.Slot() >= validationSlot {
return depState, nil
}
- validationState, err := depState.Copy()
- if err != nil {
- return nil, err
- }
- if err := transition.DefaultMachine.ProcessSlots(validationState, validationSlot); err != nil {
+ if err := transition.DefaultMachine.ProcessSlots(depState, validationSlot); err != nil {
return nil, err
}
- return validationState, nil
+ return depState, nil
}
func (s *proposerPreferencesService) validateProposerPreferencesWithState(msg *cltypes.SignedProposerPreferences, depState *state.CachingBeaconState) error {
diff --git a/cl/phase1/network/services/proposer_preferences_service_test.go b/cl/phase1/network/services/proposer_preferences_service_test.go
index 67a64b0e3c9..b26f854f5bc 100644
--- a/cl/phase1/network/services/proposer_preferences_service_test.go
+++ b/cl/phase1/network/services/proposer_preferences_service_test.go
@@ -3,17 +3,20 @@ package services
import (
"context"
"errors"
+ "math"
+ "sync"
"testing"
+ "time"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
+ "github.com/erigontech/erigon/cl/beacon/beaconevents"
synced_data_mock "github.com/erigontech/erigon/cl/beacon/synced_data/mock_services"
"github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/cltypes"
"github.com/erigontech/erigon/cl/cltypes/solid"
state2 "github.com/erigontech/erigon/cl/phase1/core/state"
- "github.com/erigontech/erigon/cl/phase1/core/state/lru"
forkchoice_mock "github.com/erigontech/erigon/cl/phase1/forkchoice/mock_services"
"github.com/erigontech/erigon/cl/pool"
"github.com/erigontech/erigon/cl/utils/eth_clock"
@@ -23,6 +26,10 @@ import (
func setupProposerPreferencesService(t *testing.T, ctrl *gomock.Controller) (*proposerPreferencesService, *synced_data_mock.MockSyncedData, *eth_clock.MockEthereumClock, *pool.EpbsPool, *forkchoice_mock.ForkChoiceStorageMock) {
mockSyncedData := synced_data_mock.NewMockSyncedData(ctrl)
ethClockMock := eth_clock.NewMockEthereumClock(ctrl)
+ ethClockMock.EXPECT().GenesisTime().Return(uint64(0)).AnyTimes()
+ ethClockMock.EXPECT().GetSlotTime(gomock.Any()).DoAndReturn(func(slot uint64) time.Time {
+ return time.Unix(int64(slot*12), 0)
+ }).AnyTimes()
epbsPool := pool.NewEpbsPool()
beaconCfg := clparams.MainnetBeaconConfig
beaconCfg.SlotsPerEpoch = 32
@@ -34,20 +41,21 @@ func setupProposerPreferencesService(t *testing.T, ctrl *gomock.Controller) (*pr
StateAtBlockRootVal: map[common.Hash]*state2.CachingBeaconState{},
}
forkChoiceMock.StateAtBlockRootVal[testDependentRoot] = newProposerPreferencesState(&beaconCfg, map[uint64]uint64{96: 42, 100: 42})
+ forkChoiceMock.Headers[testDependentRoot] = &cltypes.BeaconBlockHeader{Slot: 63}
+ forkChoiceMock.HeadVal = testDependentRoot
prevBlsVerify := blsVerify
blsVerify = func(_ []byte, _ []byte, _ []byte) (bool, error) { return true, nil }
t.Cleanup(func() { blsVerify = prevBlsVerify })
- seenCache, err := lru.New[seenProposerPreferencesKey, struct{}]("seen_proposer_preferences_test", seenProposerPreferencesCacheSize)
- require.NoError(t, err)
-
service := &proposerPreferencesService{
syncedDataManager: mockSyncedData,
forkchoiceStore: forkChoiceMock,
ethClock: ethClockMock,
beaconCfg: &beaconCfg,
epbsPool: epbsPool,
- seenCache: seenCache,
+ now: func() time.Time {
+ return ethClockMock.GetSlotTime(ethClockMock.GetCurrentSlot())
+ },
}
return service, mockSyncedData, ethClockMock, epbsPool, forkChoiceMock
@@ -58,6 +66,7 @@ var testDependentRoot = common.HexToHash("0xabcdef0123456789abcdef0123456789abcd
func newProposerPreferencesState(cfg *clparams.BeaconChainConfig, proposers map[uint64]uint64) *state2.CachingBeaconState {
s := state2.New(cfg)
+ s.SetVersion(clparams.GloasVersion)
if err := s.SetSlot(64); err != nil {
panic(err)
}
@@ -120,22 +129,58 @@ func TestProposerPreferencesServiceNilMessage(t *testing.T) {
require.Contains(t, err.Error(), "nil proposer preferences message")
}
-func TestProposerPreferencesServiceWrongEpoch(t *testing.T) {
+func TestProposerPreferencesServiceRejectsUnrepresentableSlotBeforeState(t *testing.T) {
+ for _, test := range []struct {
+ name string
+ proposalSlot uint64
+ secondsPerSlot uint64
+ }{
+ {name: "slot maximum", proposalSlot: math.MaxUint64, secondsPerSlot: 12},
+ {name: "unix second overflow", proposalSlot: math.MaxInt64/12 + 1, secondsPerSlot: 12},
+ {name: "zero seconds per slot", proposalSlot: 100, secondsPerSlot: 0},
+ } {
+ t.Run(test.name, func(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ clock := eth_clock.NewMockEthereumClock(ctrl)
+ clock.EXPECT().GenesisTime().Return(uint64(0)).AnyTimes()
+ cfg := clparams.MainnetBeaconConfig
+ cfg.SecondsPerSlot = test.secondsPerSlot
+ cfg.SlotsPerEpoch = 32
+ cfg.MinSeedLookahead = 1
+ stateCalls := 0
+ fc := forkchoice_mock.NewForkChoiceStorageMock(t)
+ fc.GetStateAtBlockRootFn = func(common.Hash, bool) (*state2.CachingBeaconState, error) {
+ stateCalls++
+ return nil, nil
+ }
+ service := &proposerPreferencesService{
+ forkchoiceStore: fc,
+ ethClock: clock,
+ beaconCfg: &cfg,
+ epbsPool: pool.NewEpbsPool(),
+ now: func() time.Time { return time.Unix(0, 0) },
+ }
+
+ err := service.ProcessMessage(context.Background(), nil, newTestSignedProposerPreferences(test.proposalSlot, 42))
+ require.ErrorIs(t, err, ErrIgnore)
+ require.Zero(t, stateCalls)
+ })
+ }
+}
+
+func TestProposerPreferencesServiceLookaheadNotKnown(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
service, _, ethClockMock, _, _ := setupProposerPreferencesService(t, ctrl)
- // proposal_slot=100, SlotsPerEpoch=32 → proposalEpoch = 100/32 = 3
- // currentEpoch = 5 → neither current (5) nor next (6) match proposalEpoch (3)
msg := newTestSignedProposerPreferences(100, 42)
-
- ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(5))
+ ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(50))
err := service.ProcessMessage(context.Background(), nil, msg)
require.Error(t, err)
require.True(t, errors.Is(err, ErrIgnore))
- require.Contains(t, err.Error(), "expected epoch in")
+ require.Contains(t, err.Error(), "not yet known")
}
func TestProposerPreferencesServiceCurrentEpoch(t *testing.T) {
@@ -147,7 +192,6 @@ func TestProposerPreferencesServiceCurrentEpoch(t *testing.T) {
// proposalEpoch == currentEpoch (same epoch) → should be accepted now
msg := newTestSignedProposerPreferences(100, 42)
- ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(3))
ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(90)) // slot not yet passed
err := service.ProcessMessage(context.Background(), nil, msg)
@@ -168,7 +212,6 @@ func TestProposerPreferencesServiceSlotAlreadyPassed(t *testing.T) {
// proposalSlot=100, currentSlot=105 → slot already passed → IGNORE
msg := newTestSignedProposerPreferences(100, 42)
- ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(2))
ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(105))
err := service.ProcessMessage(context.Background(), nil, msg)
@@ -177,22 +220,137 @@ func TestProposerPreferencesServiceSlotAlreadyPassed(t *testing.T) {
require.Contains(t, err.Error(), "already passed")
}
-func TestProposerPreferencesServiceCurrentSlotIgnored(t *testing.T) {
+func TestProposerPreferencesServiceCurrentSlotWithinDisparityAccepted(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
service, _, ethClockMock, _, _ := setupProposerPreferencesService(t, ctrl)
- // proposalSlot == currentSlot → spec says proposal_slot > current_slot, so this should be IGNORED
msg := newTestSignedProposerPreferences(100, 42)
- ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(2))
ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(100))
err := service.ProcessMessage(context.Background(), nil, msg)
- require.Error(t, err)
- require.True(t, errors.Is(err, ErrIgnore))
- require.Contains(t, err.Error(), "already passed")
+ require.NoError(t, err)
+}
+
+func TestIsPastSlotBoundaries(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, _, _, _, _ := setupProposerPreferencesService(t, ctrl)
+ start := service.ethClock.GetSlotTime(100)
+ tests := []struct {
+ name string
+ now time.Time
+ past bool
+ }{
+ {"before_start", start.Add(-time.Nanosecond), false},
+ {"at_start", start, false},
+ {"at_disparity", start.Add(gloasMaximumClockDisparity), false},
+ {"after_disparity", start.Add(gloasMaximumClockDisparity + time.Nanosecond), true},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ past, valid := isPastSlot(service.ethClock, service.beaconCfg, test.now, 100, gloasMaximumClockDisparity)
+ require.True(t, valid)
+ require.Equal(t, test.past, past)
+ })
+ }
+}
+
+func TestProposerPreferencesServiceAcceptsCurrentSlotAtDisparityEdge(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, _, _, epbsPool, _ := setupProposerPreferencesService(t, ctrl)
+ msg := newTestSignedProposerPreferences(96, 42)
+ service.now = func() time.Time {
+ return service.ethClock.GetSlotTime(96).Add(gloasMaximumClockDisparity)
+ }
+
+ require.NoError(t, service.ProcessMessage(context.Background(), nil, msg))
+ stored, ok := epbsPool.GetPreference(96, testDependentRoot)
+ require.True(t, ok)
+ require.Same(t, msg, stored)
+}
+
+func TestProposerPreferencesServiceEmitsEvent(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, _, _, _, _ := setupProposerPreferencesService(t, ctrl)
+ emitter := beaconevents.NewEventEmitter()
+ service.emitters = emitter
+ events := make(chan *beaconevents.EventStream, 1)
+ subscription := emitter.Operation().Subscribe(events)
+ defer subscription.Unsubscribe()
+ msg := newTestSignedProposerPreferences(100, 42)
+ service.now = func() time.Time { return service.ethClock.GetSlotTime(96).Add(gloasMaximumClockDisparity) }
+
+ require.NoError(t, service.ProcessMessage(context.Background(), nil, msg))
+ event := <-events
+ require.Equal(t, beaconevents.OpProposerPreferences, event.Event)
+ require.Equal(t, &beaconevents.VersionedSignedProposerPreferences{Version: "gloas", Data: msg}, event.Data)
+}
+
+func TestProposerPreferencesServiceProgressesWhileEventFeedIsBlocked(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, _, _, epbsPool, forkChoice := setupProposerPreferencesService(t, ctrl)
+ validationState := forkChoice.StateAtBlockRootVal[testDependentRoot]
+ forkChoice.GetStateAtBlockRootFn = func(common.Hash, bool) (*state2.CachingBeaconState, error) {
+ return validationState, nil
+ }
+ emitter := beaconevents.NewEventEmitter()
+ service.emitters = emitter
+ slow := make(chan *beaconevents.EventStream)
+ slowSubscription := emitter.Operation().Subscribe(slow)
+ defer slowSubscription.Unsubscribe()
+ ready := make(chan *beaconevents.EventStream)
+ readySubscription := emitter.Operation().Subscribe(ready)
+ defer readySubscription.Unsubscribe()
+ blockedSendDone := make(chan struct{})
+ go func() {
+ emitter.Operation().SendAttestation(&beaconevents.AttestationData{})
+ close(blockedSendDone)
+ }()
+ <-ready
+
+ msg := newTestSignedProposerPreferences(100, 42)
+ service.now = func() time.Time { return service.ethClock.GetSlotTime(96).Add(gloasMaximumClockDisparity) }
+ processDone := make(chan error, 1)
+ ctx := t.Context()
+ go func() { processDone <- service.ProcessMessage(ctx, nil, msg) }()
+ select {
+ case err := <-processDone:
+ require.NoError(t, err)
+ case <-time.After(5 * time.Second):
+ t.Fatal("proposer preferences processing blocked on the event feed")
+ }
+ stored, ok := epbsPool.GetPreference(100, testDependentRoot)
+ require.True(t, ok)
+ require.Same(t, msg, stored)
+
+ slowSubscription.Unsubscribe()
+ select {
+ case <-blockedSendDone:
+ case <-time.After(time.Second):
+ t.Fatal("legacy event send remained blocked after unsubscribe")
+ }
+}
+
+func TestProposerPreferencesServiceLookaheadClockDisparityBoundary(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, _, _, epbsPool, _ := setupProposerPreferencesService(t, ctrl)
+ msg := newTestSignedProposerPreferences(100, 42)
+ lookaheadStart := service.ethClock.GetSlotTime(64)
+ now := lookaheadStart.Add(-gloasMaximumClockDisparity - time.Millisecond)
+ service.now = func() time.Time { return now }
+
+ err := service.ProcessMessage(context.Background(), nil, msg)
+ require.ErrorIs(t, err, ErrIgnore)
+ require.Contains(t, err.Error(), "not yet known")
+ require.False(t, service.hasSeenPreference(newSeenProposerPreferencesKey(msg.Message)))
+
+ now = lookaheadStart.Add(-gloasMaximumClockDisparity)
+ require.NoError(t, service.ProcessMessage(context.Background(), nil, msg))
+ stored, ok := epbsPool.GetPreference(100, testDependentRoot)
+ require.True(t, ok)
+ require.Same(t, msg, stored)
}
func TestProposerPreferencesServiceDuplicate(t *testing.T) {
@@ -204,14 +362,12 @@ func TestProposerPreferencesServiceDuplicate(t *testing.T) {
msg := newTestSignedProposerPreferences(100, 42)
// First call: epoch OK and slot not passed.
- ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(2))
ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(90))
err := service.ProcessMessage(context.Background(), nil, msg)
require.NoError(t, err)
// Second call: same (validatorIndex, slot, dependentRoot) → IGNORE
- ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(2))
ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(90))
err = service.ProcessMessage(context.Background(), nil, msg)
@@ -220,6 +376,52 @@ func TestProposerPreferencesServiceDuplicate(t *testing.T) {
require.Contains(t, err.Error(), "already seen proposer preferences")
}
+func TestProposerPreferencesServiceDuplicateRetainedBeyondFormerCapacity(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ service, _, ethClock, epbsPool, _ := setupProposerPreferencesService(t, ctrl)
+ msg := newTestSignedProposerPreferences(100, 42)
+ for i := range uint64(256) {
+ root := common.Hash{byte(i), byte(i >> 8)}
+ epbsPool.ProposerPreferences.Add(pool.ProposerPreferencesKey{Slot: 100, DependentRoot: root}, newTestSignedProposerPreferences(100, 42))
+ }
+ epbsPool.ProposerPreferences.Add(pool.ProposerPreferencesKey{Slot: 100, DependentRoot: msg.Message.DependentRoot}, msg)
+ ethClock.EXPECT().GetCurrentSlot().Return(uint64(99))
+
+ err := service.ProcessMessage(context.Background(), nil, msg)
+ require.ErrorIs(t, err, ErrIgnore)
+ require.Contains(t, err.Error(), "already seen proposer preferences")
+}
+
+func TestProposerPreferencesServiceDoesNotPruneOnExpiredMessage(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ service, _, _, epbsPool, _ := setupProposerPreferencesService(t, ctrl)
+ msg := newTestSignedProposerPreferences(100, 42)
+ key := pool.ProposerPreferencesKey{Slot: 100, DependentRoot: msg.Message.DependentRoot}
+ epbsPool.ProposerPreferences.Add(key, msg)
+ preferenceBoundary := time.Unix(100*12, 0).Add(gloasMaximumClockDisparity)
+ service.now = func() time.Time { return preferenceBoundary.Add(time.Nanosecond) }
+
+ err := service.ProcessMessage(context.Background(), nil, msg)
+ require.ErrorIs(t, err, ErrIgnore)
+ _, found := epbsPool.ProposerPreferences.Get(key)
+ require.True(t, found)
+
+ bidBoundary := time.Unix(101*12, 0).Add(gloasMaximumClockDisparity)
+ service.now = func() time.Time { return bidBoundary }
+ err = service.ProcessMessage(context.Background(), nil, msg)
+ require.ErrorIs(t, err, ErrIgnore)
+ _, found = epbsPool.ProposerPreferences.Get(key)
+ require.True(t, found)
+
+ service.now = func() time.Time { return bidBoundary.Add(time.Nanosecond) }
+ err = service.ProcessMessage(context.Background(), nil, msg)
+ require.ErrorIs(t, err, ErrIgnore)
+ _, found = epbsPool.ProposerPreferences.Get(key)
+ require.True(t, found)
+}
+
func TestProposerPreferencesServiceDependentRootStateMissing(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -229,7 +431,6 @@ func TestProposerPreferencesServiceDependentRootStateMissing(t *testing.T) {
msg := newTestSignedProposerPreferences(100, 42)
delete(forkChoiceMock.StateAtBlockRootVal, testDependentRoot)
- ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(2))
ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(90))
err := service.ProcessMessage(context.Background(), nil, msg)
@@ -238,8 +439,81 @@ func TestProposerPreferencesServiceDependentRootStateMissing(t *testing.T) {
require.Contains(t, err.Error(), "state for dependent_root")
// Should NOT be marked as seen (validation failed)
- seenKey := seenProposerPreferencesKey{validatorIndex: 42, slot: 100, dependentRoot: testDependentRoot}
- require.False(t, service.seenCache.Contains(seenKey))
+ seenKey := newSeenProposerPreferencesKey(msg.Message)
+ require.False(t, service.hasSeenPreference(seenKey))
+}
+
+func TestProposerPreferencesServiceMissingStatePrecedesBoundaryReject(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ service, _, ethClock, _, fc := setupProposerPreferencesService(t, ctrl)
+ msg := newTestSignedProposerPreferences(100, 42)
+ fc.Headers[testDependentRoot] = &cltypes.BeaconBlockHeader{Slot: 64}
+ delete(fc.StateAtBlockRootVal, testDependentRoot)
+ ethClock.EXPECT().GetCurrentSlot().Return(uint64(90))
+
+ err := service.ProcessMessage(context.Background(), nil, msg)
+ require.ErrorIs(t, err, ErrIgnore)
+ require.Contains(t, err.Error(), "state for dependent_root")
+}
+
+func TestProposerPreferencesDependentRootValidity(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ service, _, _, _, fc := setupProposerPreferencesService(t, ctrl)
+ root := testDependentRoot
+ boundary := uint64(64)
+ fc.Headers[root] = &cltypes.BeaconBlockHeader{Slot: 63}
+
+ fc.HeadVal = root
+ require.True(t, service.isValidDependentRoot(root, boundary))
+ fc.HeadVal = common.HexToHash("0x99")
+ childRoot := common.HexToHash("0x88")
+ fc.Headers[childRoot] = &cltypes.BeaconBlockHeader{Slot: boundary, ParentRoot: root}
+ require.True(t, service.isValidDependentRoot(root, boundary))
+ delete(fc.Headers, childRoot)
+ fc.WeightsMock = nil
+ require.False(t, service.isValidDependentRoot(root, boundary))
+ fc.Headers[root] = &cltypes.BeaconBlockHeader{Slot: boundary}
+ require.False(t, service.isValidDependentRoot(root, boundary))
+}
+
+func TestProposerPreferencesServiceConcurrentFirstValidCommit(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ service, _, ethClock, epbsPool, _ := setupProposerPreferencesService(t, ctrl)
+ msg := newTestSignedProposerPreferences(100, 42)
+ ethClock.EXPECT().GetCurrentSlot().Return(uint64(90)).Times(2)
+
+ errs := make(chan error, 2)
+ var wg sync.WaitGroup
+ for range 2 {
+ wg.Go(func() { errs <- service.ProcessMessage(context.Background(), nil, msg) })
+ }
+ wg.Wait()
+ close(errs)
+ successes := 0
+ ignores := 0
+ for err := range errs {
+ if err == nil {
+ successes++
+ } else if errors.Is(err, ErrIgnore) {
+ ignores++
+ }
+ }
+ require.Equal(t, 1, successes)
+ require.Equal(t, 1, ignores)
+ stored, ok := epbsPool.GetPreference(100, testDependentRoot)
+ require.True(t, ok)
+ require.Same(t, msg, stored)
+}
+
+func TestSeenProposerPreferencesKeyUsesRootAndSlot(t *testing.T) {
+ root := common.HexToHash("0x11")
+ require.Equal(t,
+ seenProposerPreferencesKey{slot: 100, dependentRoot: root},
+ newSeenProposerPreferencesKey(&cltypes.ProposerPreferences{ProposalSlot: 100, ValidatorIndex: 42, DependentRoot: root}),
+ )
}
func TestProposerPreferencesServiceSuccess(t *testing.T) {
@@ -250,15 +524,14 @@ func TestProposerPreferencesServiceSuccess(t *testing.T) {
msg := newTestSignedProposerPreferences(100, 42)
- ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(2))
ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(90))
err := service.ProcessMessage(context.Background(), nil, msg)
require.NoError(t, err)
// Verify stored in seen cache
- seenKey := seenProposerPreferencesKey{validatorIndex: 42, slot: 100, dependentRoot: testDependentRoot}
- require.True(t, service.seenCache.Contains(seenKey))
+ seenKey := newSeenProposerPreferencesKey(msg.Message)
+ require.True(t, service.hasSeenPreference(seenKey))
// Verify stored in pool
stored, ok := epbsPool.ProposerPreferences.Get(pool.ProposerPreferencesKey{Slot: 100, DependentRoot: testDependentRoot})
@@ -266,30 +539,57 @@ func TestProposerPreferencesServiceSuccess(t *testing.T) {
require.Equal(t, msg, stored)
}
-func TestProposerPreferencesServiceAdvancesDependentRootStateToBoundary(t *testing.T) {
+func TestProposerPreferencesServiceRequestsIndependentDependentRootState(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
service, _, ethClockMock, epbsPool, forkChoiceMock := setupProposerPreferencesService(t, ctrl)
depState := newProposerPreferencesState(service.beaconCfg, map[uint64]uint64{100: 42})
- require.NoError(t, depState.SetSlot(63))
forkChoiceMock.StateAtBlockRootVal[testDependentRoot] = depState
+ var requestedCopy bool
+ var ownedState *state2.CachingBeaconState
+ forkChoiceMock.GetStateAtBlockRootFn = func(root common.Hash, alwaysCopy bool) (*state2.CachingBeaconState, error) {
+ requestedCopy = alwaysCopy
+ var err error
+ ownedState, err = forkChoiceMock.StateAtBlockRootVal[root].Copy()
+ return ownedState, err
+ }
- msg := newTestSignedProposerPreferences(100, 0)
+ msg := newTestSignedProposerPreferences(100, 42)
- ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(2))
ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(90))
err := service.ProcessMessage(context.Background(), nil, msg)
require.NoError(t, err)
- require.Equal(t, uint64(63), depState.Slot())
+ require.True(t, requestedCopy)
+ require.NotSame(t, depState, ownedState)
+ require.Equal(t, uint64(64), depState.Slot())
stored, ok := epbsPool.ProposerPreferences.Get(pool.ProposerPreferencesKey{Slot: 100, DependentRoot: testDependentRoot})
require.True(t, ok)
require.Equal(t, msg, stored)
}
+func TestProposerPreferencesValidationStateUsesOwnedStateWithoutSecondCopy(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ service, _, _, _, _ := setupProposerPreferencesService(t, ctrl)
+ ownedState := state2.New(service.beaconCfg)
+ ownedState.SetVersion(clparams.DenebVersion)
+ require.NoError(t, ownedState.AddValidator(solid.NewValidatorFromParameters(common.Bytes48{1}, common.Hash{}, service.beaconCfg.MaxEffectiveBalance, false, 0, 0, service.beaconCfg.FarFutureEpoch, service.beaconCfg.FarFutureEpoch), service.beaconCfg.MaxEffectiveBalance))
+ ownedState.SetPreviousEpochParticipationFlags([]cltypes.ParticipationFlags{0})
+ ownedState.SetCurrentEpochParticipationFlags([]cltypes.ParticipationFlags{0})
+ ownedState.SetInactivityScores([]uint64{0})
+ require.NoError(t, ownedState.SetSlot(63))
+
+ validationState, err := service.proposerPreferencesValidationState(ownedState, 3)
+ require.NoError(t, err)
+ require.Same(t, ownedState, validationState)
+ require.Equal(t, uint64(64), ownedState.Slot())
+}
+
func TestProposerPreferencesServiceDifferentValidatorsSameSlot(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -300,7 +600,6 @@ func TestProposerPreferencesServiceDifferentValidatorsSameSlot(t *testing.T) {
msg2 := newTestSignedProposerPreferences(101, 2)
forkChoiceMock.StateAtBlockRootVal[testDependentRoot] = newProposerPreferencesState(service.beaconCfg, map[uint64]uint64{100: 1, 101: 2})
- ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(2)).Times(2)
ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(90)).Times(2)
err := service.ProcessMessage(context.Background(), nil, msg1)
@@ -310,8 +609,8 @@ func TestProposerPreferencesServiceDifferentValidatorsSameSlot(t *testing.T) {
require.NoError(t, err)
// Both should be seen (different validators and slots)
- require.True(t, service.seenCache.Contains(seenProposerPreferencesKey{validatorIndex: 1, slot: 100, dependentRoot: testDependentRoot}))
- require.True(t, service.seenCache.Contains(seenProposerPreferencesKey{validatorIndex: 2, slot: 101, dependentRoot: testDependentRoot}))
+ require.True(t, service.hasSeenPreference(newSeenProposerPreferencesKey(msg1.Message)))
+ require.True(t, service.hasSeenPreference(newSeenProposerPreferencesKey(msg2.Message)))
stored, ok := epbsPool.ProposerPreferences.Get(pool.ProposerPreferencesKey{Slot: 100, DependentRoot: testDependentRoot})
require.True(t, ok)
@@ -330,7 +629,6 @@ func TestProposerPreferencesServiceSameValidatorDifferentSlots(t *testing.T) {
msg1 := newTestSignedProposerPreferences(96, 42) // slot 96, epoch 3
msg2 := newTestSignedProposerPreferences(100, 42) // slot 100, epoch 3
- ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(2)).Times(2)
ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(90)).Times(2)
err := service.ProcessMessage(context.Background(), nil, msg1)
@@ -340,8 +638,8 @@ func TestProposerPreferencesServiceSameValidatorDifferentSlots(t *testing.T) {
require.NoError(t, err)
// Both should be seen (different slots even though same validator)
- require.True(t, service.seenCache.Contains(seenProposerPreferencesKey{validatorIndex: 42, slot: 96, dependentRoot: testDependentRoot}))
- require.True(t, service.seenCache.Contains(seenProposerPreferencesKey{validatorIndex: 42, slot: 100, dependentRoot: testDependentRoot}))
+ require.True(t, service.hasSeenPreference(newSeenProposerPreferencesKey(msg1.Message)))
+ require.True(t, service.hasSeenPreference(newSeenProposerPreferencesKey(msg2.Message)))
// Both slots should be in pool
_, ok1 := epbsPool.ProposerPreferences.Get(pool.ProposerPreferencesKey{Slot: 96, DependentRoot: testDependentRoot})
@@ -380,6 +678,16 @@ func TestProposerPreferencesServiceDecodeGossipMessageInvalid(t *testing.T) {
require.Error(t, err)
}
+func TestProposerPreferencesServiceDecodeRejectsTrailingByte(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ service, _, _, _, _ := setupProposerPreferencesService(t, ctrl)
+ encoded, err := newTestSignedProposerPreferences(100, 42).EncodeSSZ(nil)
+ require.NoError(t, err)
+
+ _, err = service.DecodeGossipMessage("peer123", append(encoded, 0), clparams.GloasVersion)
+ require.Error(t, err)
+}
+
func TestProposerPreferencesServiceFailedValidationNotStored(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
@@ -390,15 +698,14 @@ func TestProposerPreferencesServiceFailedValidationNotStored(t *testing.T) {
forkChoiceMock.StateAtBlockRootVal[testDependentRoot] = newProposerPreferencesState(service.beaconCfg, map[uint64]uint64{100: 7})
- ethClockMock.EXPECT().GetCurrentEpoch().Return(uint64(2))
ethClockMock.EXPECT().GetCurrentSlot().Return(uint64(90))
err := service.ProcessMessage(context.Background(), nil, msg)
require.Error(t, err)
// Should NOT be in seen cache
- seenKey := seenProposerPreferencesKey{validatorIndex: 42, slot: 100, dependentRoot: testDependentRoot}
- require.False(t, service.seenCache.Contains(seenKey))
+ seenKey := newSeenProposerPreferencesKey(msg.Message)
+ require.False(t, service.hasSeenPreference(seenKey))
// Should NOT be in pool
_, ok := epbsPool.ProposerPreferences.Get(pool.ProposerPreferencesKey{Slot: 100, DependentRoot: testDependentRoot})
diff --git a/cl/phase1/network/services/types.go b/cl/phase1/network/services/types.go
index 1a83733d789..43b498cce59 100644
--- a/cl/phase1/network/services/types.go
+++ b/cl/phase1/network/services/types.go
@@ -1,12 +1,21 @@
package services
import (
+ "context"
+
"github.com/erigontech/erigon/cl/cltypes"
serviceinterface "github.com/erigontech/erigon/cl/phase1/network/services/service_interface"
)
//go:generate mockgen -typed=true -destination=./mock_services/block_service_mock.go -package=mock_services . BlockService
-type BlockService serviceinterface.Service[*cltypes.SignedBeaconBlock]
+type BlockService interface {
+ serviceinterface.Service[*cltypes.SignedBeaconBlock]
+ ValidateGossip(context.Context, *cltypes.SignedBeaconBlock) error
+ CommitGossipReservation(*cltypes.SignedBeaconBlock)
+ ReleaseGossipReservation(*cltypes.SignedBeaconBlock)
+ ScheduleBlockForLaterProcessing(*cltypes.SignedBeaconBlock)
+ SchedulePublishedBlockForLaterProcessing(*cltypes.SignedBeaconBlock, func(context.Context) error)
+}
//go:generate mockgen -typed=true -destination=./mock_services/blob_sidecars_service_mock.go -package=mock_services . BlobSidecarsService
type BlobSidecarsService serviceinterface.Service[*cltypes.BlobSidecar]
@@ -45,7 +54,10 @@ type ExecutionPayloadService serviceinterface.Service[*cltypes.SignedExecutionPa
type ExecutionPayloadBidService serviceinterface.Service[*cltypes.SignedExecutionPayloadBid]
//go:generate mockgen -typed=true -destination=./mock_services/payload_attestation_service_mock.go -package=mock_services . PayloadAttestationService
-type PayloadAttestationService serviceinterface.Service[*cltypes.PayloadAttestationMessage]
+type PayloadAttestationService interface {
+ serviceinterface.Service[*cltypes.PayloadAttestationMessage]
+ ProcessRESTMessage(context.Context, *cltypes.PayloadAttestationMessage, func() error) error
+}
//go:generate mockgen -typed=true -destination=./mock_services/proposer_preferences_service_mock.go -package=mock_services . ProposerPreferencesService
type ProposerPreferencesService serviceinterface.Service[*cltypes.SignedProposerPreferences]
diff --git a/cl/phase1/stages/chain_tip_sync.go b/cl/phase1/stages/chain_tip_sync.go
index 6eee814f9c8..6c2b6d24861 100644
--- a/cl/phase1/stages/chain_tip_sync.go
+++ b/cl/phase1/stages/chain_tip_sync.go
@@ -727,19 +727,24 @@ func drainPendingGloasPayloads(ctx context.Context, cfg *Cfg) {
if !validPendingGloasPayload(p) {
continue
}
+ beaconRoot := p.Envelope.Message.BeaconBlockRoot
+ if cfg.forkChoice.IsPayloadVerified(beaconRoot) {
+ continue
+ }
status, err := retryGloasPayloadWithEL(ctx, cfg, p.Block, p.Envelope)
if err != nil {
log.Warn("[chainTipSync] pending GLOAS NewPayload failed", "slot", p.Block.Block.Slot, "status", status, "err", err)
}
- beaconRoot := p.Envelope.Message.BeaconBlockRoot
execHash := p.Envelope.Message.Payload.BlockHash
+ var retained bool
+ status, retained = cfg.forkChoice.MarkPayloadStatusIfRetained(beaconRoot, execHash, status)
+ if !retained {
+ continue
+ }
switch status {
- case execution_client.PayloadStatusValidated:
- cfg.forkChoice.MarkPayloadVerified(beaconRoot, execHash)
case execution_client.PayloadStatusNone, execution_client.PayloadStatusNotValidated:
cfg.forkChoice.RequeuePendingELPayload(p)
case execution_client.PayloadStatusInvalidated:
- cfg.forkChoice.MarkPayloadInvalid(beaconRoot, execHash)
log.Warn("[chainTipSync] pending GLOAS payload invalidated by EL", "slot", p.Block.Block.Slot, "blockRoot", beaconRoot)
}
}
@@ -874,20 +879,22 @@ func verifyUnverifiedGloasPayloads(ctx context.Context, cfg *Cfg) {
return continueGloasVerificationAfterItemFailure(ctx, &completeBatch)
}
if isGloasPayloadKnownInvalid(cfg, envelope) {
- cfg.forkChoice.MarkPayloadInvalid(item.root, execHash)
+ cfg.forkChoice.MarkPayloadStatusIfRetained(item.root, execHash, execution_client.PayloadStatusInvalidated)
return true
}
status, err := retryGloasPayloadWithEL(ctx, cfg, item.block, envelope)
if err != nil {
log.Warn("[chainTipSync] GLOAS verification sweep NewPayload failed", "slot", item.block.Block.Slot, "blockRoot", item.root, "status", status, "err", err)
}
+ var retained bool
+ status, retained = cfg.forkChoice.MarkPayloadStatusIfRetained(item.root, execHash, status)
+ if !retained {
+ return true
+ }
switch status {
- case execution_client.PayloadStatusValidated:
- cfg.forkChoice.MarkPayloadVerified(item.root, execHash)
case execution_client.PayloadStatusNone, execution_client.PayloadStatusNotValidated:
cfg.forkChoice.RequeuePendingELPayload(forkchoice.PendingELPayload{Block: item.block, Envelope: envelope})
case execution_client.PayloadStatusInvalidated:
- cfg.forkChoice.MarkPayloadInvalid(item.root, execHash)
log.Warn("[chainTipSync] GLOAS verification sweep found invalid payload", "slot", item.block.Block.Slot, "blockRoot", item.root)
}
swept++
@@ -951,11 +958,12 @@ func retryUnverifiedAnchorPayload(ctx context.Context, cfg *Cfg) {
log.Warn("[chainTipSync] anchor payload NewPayload retry failed", "anchorRoot", anchorRoot, "status", status, "err", err)
}
execHash := envelope.Message.Payload.BlockHash
- switch status {
- case execution_client.PayloadStatusValidated:
- cfg.forkChoice.MarkPayloadVerified(anchorRoot, execHash)
- case execution_client.PayloadStatusInvalidated:
- cfg.forkChoice.MarkPayloadInvalid(anchorRoot, execHash)
+ var retained bool
+ status, retained = cfg.forkChoice.MarkPayloadStatusIfRetained(anchorRoot, execHash, status)
+ if !retained {
+ return
+ }
+ if status == execution_client.PayloadStatusInvalidated {
log.Warn("[chainTipSync] anchor payload invalidated by EL", "anchorRoot", anchorRoot)
}
}
diff --git a/cl/phase1/stages/forkchoice.go b/cl/phase1/stages/forkchoice.go
index f369bd81063..f2ea9cc9fa4 100644
--- a/cl/phase1/stages/forkchoice.go
+++ b/cl/phase1/stages/forkchoice.go
@@ -216,31 +216,60 @@ func updateCanonicalChainInTheDatabase(ctx context.Context, tx kv.RwTx, headSlot
// emitHeadEvent emits the head event with the given head slot, head root, and head state.
func emitHeadEvent(cfg *Cfg, headSlot uint64, headRoot common.Hash, headState *state.CachingBeaconState) error {
- headEpoch := headSlot / cfg.beaconCfg.SlotsPerEpoch
- previous_duty_dependent_root, err := headState.GetBlockRootAtSlot((headEpoch-1)*cfg.beaconCfg.SlotsPerEpoch - 1)
+ stateRoot, err := headState.HashSSZ()
if err != nil {
- return fmt.Errorf("failed to get block root at slot for previous_duty_dependent_root: %w", err)
+ return fmt.Errorf("failed to hash ssz: %w", err)
}
- current_duty_dependent_root, err := headState.GetBlockRootAtSlot(headEpoch*cfg.beaconCfg.SlotsPerEpoch - 1)
+ currentHeadRoot, currentHeadSlot, err := cfg.forkChoice.GetHead(nil)
if err != nil {
- return fmt.Errorf("failed to get block root at slot for current_duty_dependent_root: %w", err)
+ return fmt.Errorf("failed to revalidate head event: %w", err)
}
-
- stateRoot, err := headState.HashSSZ()
+ if currentHeadRoot != headRoot || currentHeadSlot != headSlot {
+ return nil
+ }
+ payloadStatus := beaconevents.PayloadStatusName(cfg.forkChoice.GetHeadPayloadStatus())
+ executionOptimistic := cfg.forkChoice.IsRootOptimistic(headRoot)
+ headEvent, err := beaconevents.BuildHeadV2Data(
+ cfg.beaconCfg,
+ headState,
+ headSlot,
+ headRoot,
+ stateRoot,
+ payloadStatus,
+ executionOptimistic,
+ )
if err != nil {
- return fmt.Errorf("failed to hash ssz: %w", err)
+ return err
}
- // emit the head event
- cfg.emitter.State().SendHead(&beaconevents.HeadData{
- Slot: headSlot,
- Block: headRoot,
- State: stateRoot,
- EpochTransition: true,
- PreviousDutyDependentRoot: previous_duty_dependent_root,
- CurrentDutyDependentRoot: current_duty_dependent_root,
- ExecutionOptimistic: false,
+ return emitHeadEventsIfCurrent(cfg.emitter, headEvent, headSlot, headRoot, stateRoot, func() (common.Hash, uint64, string, bool, error) {
+ root, slot, err := cfg.forkChoice.GetHead(nil)
+ return root, slot, beaconevents.PayloadStatusName(cfg.forkChoice.GetHeadPayloadStatus()), cfg.forkChoice.IsRootOptimistic(root), err
})
- return nil
+}
+
+func emitHeadEventsIfCurrent(emitter *beaconevents.EventEmitter, headEvent *beaconevents.HeadV2Data, headSlot uint64, headRoot, stateRoot common.Hash, getHead func() (common.Hash, uint64, string, bool, error)) error {
+ var validationErr error
+ emitter.WithHeadEventLock(func() {
+ currentRoot, currentSlot, payloadStatus, executionOptimistic, err := getHead()
+ if err != nil {
+ validationErr = fmt.Errorf("failed to revalidate head event: %w", err)
+ return
+ }
+ if currentRoot != headRoot || currentSlot != headSlot || payloadStatus != headEvent.Data.PayloadStatus || executionOptimistic != headEvent.Data.ExecutionOptimistic {
+ return
+ }
+ emitter.State().SendHead(&beaconevents.HeadData{
+ Slot: headSlot,
+ Block: headRoot,
+ State: stateRoot,
+ EpochTransition: headEvent.Data.EpochTransition,
+ PreviousDutyDependentRoot: headEvent.Data.CurrentEpochDependentRoot,
+ CurrentDutyDependentRoot: headEvent.Data.NextEpochDependentRoot,
+ ExecutionOptimistic: headEvent.Data.ExecutionOptimistic,
+ })
+ emitter.State().SendHeadV2(headEvent)
+ })
+ return validationErr
}
func emitNextPaylodAttributesEvent(cfg *Cfg, headSlot uint64, headRoot common.Hash, s *state.CachingBeaconState) error {
diff --git a/cl/phase1/stages/forkchoice_test.go b/cl/phase1/stages/forkchoice_test.go
index 960fe6004eb..b013b30e86d 100644
--- a/cl/phase1/stages/forkchoice_test.go
+++ b/cl/phase1/stages/forkchoice_test.go
@@ -256,3 +256,57 @@ func drainReorgEvent(t *testing.T, ctx context.Context, tx kv.RwTx, headSlot uin
}
}
}
+
+func TestEmitHeadEventsDropsStaleSnapshot(t *testing.T) {
+ emitter := beaconevents.NewEventEmitter()
+ ch := make(chan *beaconevents.EventStream, 2)
+ sub := emitter.State().Subscribe(ch)
+ defer sub.Unsubscribe()
+ headRoot := common.Hash{1}
+
+ err := emitHeadEventsIfCurrent(emitter, &beaconevents.HeadV2Data{}, 10, headRoot, common.Hash{2}, func() (common.Hash, uint64, string, bool, error) {
+ return common.Hash{3}, 11, "pending", false, nil
+ })
+ require.NoError(t, err)
+ require.Empty(t, ch)
+}
+
+func TestEmitHeadEventsDropsStalePayloadStatus(t *testing.T) {
+ emitter := beaconevents.NewEventEmitter()
+ ch := make(chan *beaconevents.EventStream, 2)
+ sub := emitter.State().Subscribe(ch)
+ defer sub.Unsubscribe()
+ headRoot := common.Hash{1}
+ headEvent := &beaconevents.HeadV2Data{Data: beaconevents.HeadV2Content{PayloadStatus: "pending"}}
+
+ err := emitHeadEventsIfCurrent(emitter, headEvent, 10, headRoot, common.Hash{2}, func() (common.Hash, uint64, string, bool, error) {
+ return headRoot, 10, "full", false, nil
+ })
+ require.NoError(t, err)
+ require.Empty(t, ch)
+}
+
+func TestEmitHeadEventsPreservesLegacyHeadFields(t *testing.T) {
+ emitter := beaconevents.NewEventEmitter()
+ ch := make(chan *beaconevents.EventStream, 2)
+ sub := emitter.State().Subscribe(ch)
+ defer sub.Unsubscribe()
+ headRoot := common.Hash{1}
+ headEvent := &beaconevents.HeadV2Data{Data: beaconevents.HeadV2Content{
+ PayloadStatus: "pending",
+ EpochTransition: false,
+ CurrentEpochDependentRoot: common.Hash{3},
+ NextEpochDependentRoot: common.Hash{4},
+ ExecutionOptimistic: true,
+ }}
+
+ err := emitHeadEventsIfCurrent(emitter, headEvent, 10, headRoot, common.Hash{2}, func() (common.Hash, uint64, string, bool, error) {
+ return headRoot, 10, "pending", true, nil
+ })
+ require.NoError(t, err)
+ legacy := (<-ch).Data.(*beaconevents.HeadData)
+ require.False(t, legacy.EpochTransition)
+ require.True(t, legacy.ExecutionOptimistic)
+ require.Equal(t, headEvent.Data.CurrentEpochDependentRoot, legacy.PreviousDutyDependentRoot)
+ require.Equal(t, headEvent.Data.NextEpochDependentRoot, legacy.CurrentDutyDependentRoot)
+}
diff --git a/cl/phase1/stages/forward_sync.go b/cl/phase1/stages/forward_sync.go
index c502a8cdefe..9c20529d662 100644
--- a/cl/phase1/stages/forward_sync.go
+++ b/cl/phase1/stages/forward_sync.go
@@ -118,14 +118,8 @@ func processDownloadedBlockBatches(ctx context.Context, logger log.Logger, cfg *
// the payload is delivered separately via a SignedExecutionPayloadEnvelope.
if block.Version() >= clparams.GloasVersion {
if env, ok := envelopes[blockRoot]; ok {
- // FULL block: update forkchoice with the envelope (updates eth2Roots, persists to disk).
- if fceErr := cfg.forkChoice.OnExecutionPayload(ctx, env, false, shouldValidateForwardSyncPayload(cfg, shouldInsert)); fceErr != nil {
- logger.Warn("[Caplin] forward sync: failed to process GLOAS envelope", "slot", block.Block.Slot, "err", fceErr)
- } else if shouldInsert {
- if err = cfg.blockCollector.AddGloasBlock(block.Block, env); err != nil {
- err = fmt.Errorf("failed to add gloas block to collector: %w", err)
- return
- }
+ if err = processDownloadedGloasEnvelope(ctx, logger, cfg.forkChoice, cfg.blockCollector, block.Block, blockRoot, env, shouldInsert, shouldValidateForwardSyncPayload(cfg, shouldInsert)); err != nil {
+ return highestBlockProcessed, fmt.Errorf("%w: %w", network2.ErrUnattributableProcess, err)
}
}
// Dump state periodically for restart checkpoints.
@@ -176,6 +170,37 @@ func processDownloadedBlockBatches(ctx context.Context, logger log.Logger, cfg *
return
}
+type gloasBlockCollector interface {
+ AddGloasBlock(*cltypes.BeaconBlock, *cltypes.SignedExecutionPayloadEnvelope) error
+}
+
+func processDownloadedGloasEnvelope(ctx context.Context, logger log.Logger, store forkchoice.ForkChoiceStorage, collector gloasBlockCollector, block *cltypes.BeaconBlock, blockRoot common.Hash, envelope *cltypes.SignedExecutionPayloadEnvelope, shouldInsert, validate bool) error {
+ err := store.OnExecutionPayload(ctx, envelope, false, validate)
+ if err != nil && !(errors.Is(err, forkchoice.ErrIgnore) && persistedEnvelopeMatches(store, blockRoot, envelope)) {
+ logger.Warn("[Caplin] forward sync: failed to process GLOAS envelope", "slot", block.Slot, "err", err)
+ return err
+ }
+ if shouldInsert {
+ if err := collector.AddGloasBlock(block, envelope); err != nil {
+ return fmt.Errorf("failed to add gloas block to collector: %w", err)
+ }
+ }
+ return nil
+}
+
+func persistedEnvelopeMatches(store forkchoice.ForkChoiceStorage, root common.Hash, envelope *cltypes.SignedExecutionPayloadEnvelope) bool {
+ persisted, err := store.ReadEnvelopeFromDisk(root)
+ if err != nil || persisted == nil || envelope == nil {
+ return false
+ }
+ persistedRoot, err := persisted.HashSSZ()
+ if err != nil {
+ return false
+ }
+ envelopeRoot, err := envelope.HashSSZ()
+ return err == nil && persistedRoot == envelopeRoot
+}
+
func shouldValidateForwardSyncPayload(cfg *Cfg, shouldInsert bool) bool {
return !shouldInsert && canValidateGloasPayloads(cfg)
}
@@ -445,11 +470,12 @@ func validateAnchorPayloadWithExecutionClient(ctx context.Context, cfg *Cfg, anc
if err != nil {
log.Warn("[Caplin] Anchor envelope EL validation failed", "anchorRoot", anchorRoot, "status", status, "err", err)
}
- switch status {
- case execution_client.PayloadStatusValidated:
- cfg.forkChoice.MarkPayloadVerified(anchorRoot, env.Message.Payload.BlockHash)
- case execution_client.PayloadStatusInvalidated:
- cfg.forkChoice.MarkPayloadInvalid(anchorRoot, env.Message.Payload.BlockHash)
+ var retained bool
+ status, retained = cfg.forkChoice.MarkPayloadStatusIfRetained(anchorRoot, env.Message.Payload.BlockHash, status)
+ if !retained {
+ return nil
+ }
+ if status == execution_client.PayloadStatusInvalidated {
return fmt.Errorf("anchor execution payload invalidated by EL")
}
return nil
diff --git a/cl/phase1/stages/gloas_payload_test.go b/cl/phase1/stages/gloas_payload_test.go
index 017b77f15ea..f553bab5084 100644
--- a/cl/phase1/stages/gloas_payload_test.go
+++ b/cl/phase1/stages/gloas_payload_test.go
@@ -9,8 +9,11 @@ import (
"time"
"github.com/holiman/uint256"
+ "github.com/spf13/afero"
"github.com/stretchr/testify/require"
+ "github.com/erigontech/erigon/cl/beacon/beacon_router_configuration"
+ "github.com/erigontech/erigon/cl/beacon/beaconevents"
"github.com/erigontech/erigon/cl/clparams"
"github.com/erigontech/erigon/cl/cltypes"
"github.com/erigontech/erigon/cl/cltypes/solid"
@@ -18,12 +21,20 @@ import (
state2 "github.com/erigontech/erigon/cl/phase1/core/state"
"github.com/erigontech/erigon/cl/phase1/execution_client"
"github.com/erigontech/erigon/cl/phase1/forkchoice"
+ "github.com/erigontech/erigon/cl/phase1/forkchoice/fork_graph"
"github.com/erigontech/erigon/cl/phase1/forkchoice/mock_services"
+ "github.com/erigontech/erigon/cl/phase1/forkchoice/public_keys_registry"
+ network2 "github.com/erigontech/erigon/cl/phase1/network"
+ "github.com/erigontech/erigon/cl/pool"
"github.com/erigontech/erigon/cl/utils"
"github.com/erigontech/erigon/cl/utils/bls"
+ "github.com/erigontech/erigon/cl/validator/validator_params"
"github.com/erigontech/erigon/common"
"github.com/erigontech/erigon/common/empty"
"github.com/erigontech/erigon/common/hexutil"
+ "github.com/erigontech/erigon/common/log/v3"
+ "github.com/erigontech/erigon/db/kv/dbcfg"
+ "github.com/erigontech/erigon/db/kv/mdbx/mdbxtest"
"github.com/erigontech/erigon/execution/engineapi/engine_types"
"github.com/erigontech/erigon/execution/protocol/rules/merge"
"github.com/erigontech/erigon/execution/types"
@@ -391,6 +402,166 @@ func TestGloasRecoveryCursorAdvancesAfterIncompleteFetch(t *testing.T) {
require.Equal(t, common.Hash{}, cfg.gloasEnvelopeRecoveryCursor)
}
+func TestPersistedEnvelopeMatchesExactIdentity(t *testing.T) {
+ root := common.HexToHash("0x1234")
+ envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(&clparams.MainnetBeaconConfig)}
+ envelope.Message.BeaconBlockRoot = root
+ store := mock_services.NewForkChoiceStorageMock(t)
+ store.Envelopes[root] = envelope
+
+ require.True(t, persistedEnvelopeMatches(store, root, envelope))
+ different := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(&clparams.MainnetBeaconConfig)}
+ different.Message.BeaconBlockRoot = root
+ different.Signature[0] = 1
+ require.False(t, persistedEnvelopeMatches(store, root, different))
+}
+
+type envelopeReadTestStore struct {
+ forkchoice.ForkChoiceStorage
+ onErr error
+ persisted *cltypes.SignedExecutionPayloadEnvelope
+ readErr error
+}
+
+func (s *envelopeReadTestStore) OnExecutionPayload(context.Context, *cltypes.SignedExecutionPayloadEnvelope, bool, bool) error {
+ return s.onErr
+}
+
+func (s *envelopeReadTestStore) ReadEnvelopeFromDisk(common.Hash) (*cltypes.SignedExecutionPayloadEnvelope, error) {
+ return s.persisted, s.readErr
+}
+
+type gloasCollectorTest struct {
+ calls int
+ err error
+}
+
+func (c *gloasCollectorTest) AddGloasBlock(*cltypes.BeaconBlock, *cltypes.SignedExecutionPayloadEnvelope) error {
+ c.calls++
+ return c.err
+}
+
+func (c *gloasCollectorTest) AddBlock(*cltypes.BeaconBlock) error {
+ c.calls++
+ return c.err
+}
+
+func (c *gloasCollectorTest) Flush(context.Context) error {
+ return nil
+}
+
+func (c *gloasCollectorTest) HasBlock(uint64) bool {
+ return false
+}
+
+func TestProcessDownloadedGloasEnvelopeCollectorReconciliation(t *testing.T) {
+ root := common.HexToHash("0x1234")
+ envelope := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(&clparams.MainnetBeaconConfig)}
+ envelope.Message.BeaconBlockRoot = root
+ block := &cltypes.BeaconBlock{Slot: 64}
+
+ t.Run("exact persisted duplicate", func(t *testing.T) {
+ store := &envelopeReadTestStore{onErr: forkchoice.ErrIgnore, persisted: envelope}
+ collector := &gloasCollectorTest{}
+ require.NoError(t, processDownloadedGloasEnvelope(t.Context(), log.Root(), store, collector, block, root, envelope, true, false))
+ require.Equal(t, 1, collector.calls)
+ })
+ t.Run("mismatch", func(t *testing.T) {
+ different := &cltypes.SignedExecutionPayloadEnvelope{Message: cltypes.NewExecutionPayloadEnvelope(&clparams.MainnetBeaconConfig)}
+ different.Message.BeaconBlockRoot = root
+ different.Signature[0] = 1
+ store := &envelopeReadTestStore{onErr: forkchoice.ErrIgnore, persisted: different}
+ collector := &gloasCollectorTest{}
+ require.ErrorIs(t, processDownloadedGloasEnvelope(t.Context(), log.Root(), store, collector, block, root, envelope, true, false), forkchoice.ErrIgnore)
+ require.Zero(t, collector.calls)
+ })
+ t.Run("read error", func(t *testing.T) {
+ store := &envelopeReadTestStore{onErr: forkchoice.ErrIgnore, readErr: errors.New("disk unavailable")}
+ collector := &gloasCollectorTest{}
+ require.ErrorIs(t, processDownloadedGloasEnvelope(t.Context(), log.Root(), store, collector, block, root, envelope, true, false), forkchoice.ErrIgnore)
+ require.Zero(t, collector.calls)
+ })
+ t.Run("validation failure", func(t *testing.T) {
+ validationErr := errors.New("invalid execution payload")
+ store := &envelopeReadTestStore{onErr: validationErr}
+ collector := &gloasCollectorTest{}
+ require.ErrorIs(t, processDownloadedGloasEnvelope(t.Context(), log.Root(), store, collector, block, root, envelope, true, false), validationErr)
+ require.Zero(t, collector.calls)
+ })
+ t.Run("collector error", func(t *testing.T) {
+ store := &envelopeReadTestStore{}
+ collector := &gloasCollectorTest{err: errors.New("collector failed")}
+ err := processDownloadedGloasEnvelope(t.Context(), log.Root(), store, collector, block, root, envelope, true, false)
+ require.ErrorContains(t, err, "collector failed")
+ })
+}
+
+func TestProcessDownloadedBlockBatchesRetainsFrontierForEnvelopeLocalFailure(t *testing.T) {
+ cfg := clparams.MainnetBeaconConfig
+ clparams.ApplyMinimalPreset(&cfg)
+ cfg.GloasForkEpoch = 0
+ cfg.GloasForkVersion = 0x80000038
+ cfg.InitializeForkSchedule()
+
+ block := cltypes.NewSignedBeaconBlock(&cfg, clparams.GloasVersion)
+ block.Block.Slot = 64
+ bodyRoot, err := block.Block.Body.HashSSZ()
+ require.NoError(t, err)
+ anchorState := state2.New(&cfg)
+ anchorState.SetVersion(clparams.GloasVersion)
+ require.NoError(t, anchorState.SetSlot(block.Block.Slot))
+ anchorState.SetGenesisValidatorsRoot(common.HexToHash("0x01"))
+ anchorState.SetLatestBlockHeader(&cltypes.BeaconBlockHeader{
+ Slot: block.Block.Slot,
+ ProposerIndex: block.Block.ProposerIndex,
+ ParentRoot: block.Block.ParentRoot,
+ Root: block.Block.StateRoot,
+ BodyRoot: bodyRoot,
+ })
+ forkGraph, err := fork_graph.NewForkGraphDisk(anchorState, nil, afero.NewMemMapFs(), beacon_router_configuration.RouterConfiguration{})
+ require.NoError(t, err)
+ store, err := forkchoice.NewForkChoiceStore(
+ nil,
+ anchorState,
+ nil,
+ pool.NewOperationsPool(&cfg),
+ forkGraph,
+ beaconevents.NewEventEmitter(),
+ nil,
+ nil,
+ public_keys_registry.NewInMemoryPublicKeysRegistry(),
+ validator_params.NewValidatorParams(),
+ false,
+ nil,
+ )
+ require.NoError(t, err)
+ blockRoot, err := block.Block.HashSSZ()
+ require.NoError(t, err)
+ collector := &gloasCollectorTest{}
+ stageCfg := &Cfg{
+ beaconCfg: &cfg,
+ forkChoice: store,
+ indiciesDB: mdbxtest.NewTestDB(t, dbcfg.ChainDB),
+ blockCollector: collector,
+ }
+
+ frontier, err := processDownloadedBlockBatches(
+ t.Context(),
+ log.Root(),
+ stageCfg,
+ 10,
+ true,
+ []*cltypes.SignedBeaconBlock{block},
+ map[common.Hash]*cltypes.SignedExecutionPayloadEnvelope{blockRoot: nil},
+ )
+
+ require.Error(t, err)
+ require.ErrorIs(t, err, network2.ErrUnattributableProcess)
+ require.ErrorContains(t, err, "nil execution payload envelope")
+ require.Equal(t, uint64(10), frontier)
+ require.Zero(t, collector.calls)
+}
+
func TestGloasVerificationItemFailureOnlyStopsOnCancellation(t *testing.T) {
completeBatch := true
require.True(t, continueGloasVerificationAfterItemFailure(context.Background(), &completeBatch))
diff --git a/cl/pool/epbs_pool.go b/cl/pool/epbs_pool.go
index c85a3d24708..c899077eddd 100644
--- a/cl/pool/epbs_pool.go
+++ b/cl/pool/epbs_pool.go
@@ -1,17 +1,80 @@
package pool
import (
+ "sync"
+
"github.com/erigontech/erigon/cl/cltypes"
"github.com/erigontech/erigon/cl/phase1/core/state/lru"
"github.com/erigontech/erigon/common"
)
const (
- epbsPreferencesPoolSize = 64 // ~2 epochs of slots
- epbsHighestBidsPoolSize = 128 // multiple builders × parent hashes × a few slots
epbsPayloadAttestationsPoolSize = 512 // one slot's worth of PTC votes
)
+type slotMap[K comparable, V any] struct {
+ mu sync.RWMutex
+ values map[K]V
+ bySlot map[uint64]map[K]struct{}
+ slotFor func(K) uint64
+}
+
+func newSlotMap[K comparable, V any](slotFor func(K) uint64) *slotMap[K, V] {
+ return &slotMap[K, V]{values: make(map[K]V), bySlot: make(map[uint64]map[K]struct{}), slotFor: slotFor}
+}
+
+func (m *slotMap[K, V]) Add(key K, value V) bool {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.values[key] = value
+ slot := m.slotFor(key)
+ if m.bySlot[slot] == nil {
+ m.bySlot[slot] = make(map[K]struct{})
+ }
+ m.bySlot[slot][key] = struct{}{}
+ return false
+}
+
+func (m *slotMap[K, V]) ValuesForSlot(slot uint64) []V {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ values := make([]V, 0, len(m.bySlot[slot]))
+ for key := range m.bySlot[slot] {
+ values = append(values, m.values[key])
+ }
+ return values
+}
+
+func (m *slotMap[K, V]) Get(key K) (V, bool) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ value, ok := m.values[key]
+ return value, ok
+}
+
+func (m *slotMap[K, V]) Keys() []K {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ keys := make([]K, 0, len(m.values))
+ for key := range m.values {
+ keys = append(keys, key)
+ }
+ return keys
+}
+
+func (m *slotMap[K, V]) PruneSlots(remove func(uint64) bool) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ for slot, keys := range m.bySlot {
+ if remove(slot) {
+ for key := range keys {
+ delete(m.values, key)
+ }
+ delete(m.bySlot, slot)
+ }
+ }
+}
+
// ProposerPreferencesKey identifies a proposer preferences entry by slot and dependent root.
// Different dependent roots (different forks) must not overwrite each other.
type ProposerPreferencesKey struct {
@@ -39,11 +102,11 @@ type HighestBidKey struct {
type EpbsPool struct {
// ProposerPreferences stores validated SignedProposerPreferences keyed by (slot, dependent_root).
// Written by the proposer_preferences gossip service, read by the execution_payload_bid service.
- ProposerPreferences *lru.Cache[ProposerPreferencesKey, *cltypes.SignedProposerPreferences]
+ ProposerPreferences *slotMap[ProposerPreferencesKey, *cltypes.SignedProposerPreferences]
// HighestBids stores the highest bid seen per (slot, parent_block_hash, parent_block_root).
// Written and read by the execution_payload_bid gossip service.
- HighestBids *lru.Cache[HighestBidKey, *cltypes.SignedExecutionPayloadBid]
+ HighestBids *slotMap[HighestBidKey, *cltypes.SignedExecutionPayloadBid]
// PayloadAttestations stores recently validated PayloadAttestationMessages for beacon API serving.
// Short-lived cache (~1 slot), keyed by (slot, validatorIndex).
@@ -51,14 +114,8 @@ type EpbsPool struct {
}
func NewEpbsPool() *EpbsPool {
- preferencesCache, err := lru.New[ProposerPreferencesKey, *cltypes.SignedProposerPreferences]("proposerPreferencesPool", epbsPreferencesPoolSize)
- if err != nil {
- panic(err)
- }
- highestBidsCache, err := lru.New[HighestBidKey, *cltypes.SignedExecutionPayloadBid]("highestBidsPool", epbsHighestBidsPoolSize)
- if err != nil {
- panic(err)
- }
+ preferencesCache := newSlotMap[ProposerPreferencesKey, *cltypes.SignedProposerPreferences](func(key ProposerPreferencesKey) uint64 { return key.Slot })
+ highestBidsCache := newSlotMap[HighestBidKey, *cltypes.SignedExecutionPayloadBid](func(key HighestBidKey) uint64 { return key.Slot })
payloadAttestationsCache, err := lru.New[PayloadAttestationKey, *cltypes.PayloadAttestationMessage]("payloadAttestationsPool", epbsPayloadAttestationsPoolSize)
if err != nil {
panic(err)
@@ -74,12 +131,10 @@ func NewEpbsPool() *EpbsPool {
// regardless of dependent_root. This is used by the bid service which needs to find any
// valid preferences for a slot across different fork views.
func (p *EpbsPool) GetPreferencesForSlot(slot uint64) []*cltypes.SignedProposerPreferences {
- var results []*cltypes.SignedProposerPreferences
- for _, key := range p.ProposerPreferences.Keys() {
- if key.Slot != slot {
- continue
- }
- if msg, ok := p.ProposerPreferences.Get(key); ok && msg != nil {
+ values := p.ProposerPreferences.ValuesForSlot(slot)
+ results := make([]*cltypes.SignedProposerPreferences, 0, len(values))
+ for _, msg := range values {
+ if msg != nil {
results = append(results, msg)
}
}
diff --git a/cl/pool/operations_pool_test.go b/cl/pool/operations_pool_test.go
index 0ec08e5eb89..edd927d01c5 100644
--- a/cl/pool/operations_pool_test.go
+++ b/cl/pool/operations_pool_test.go
@@ -415,3 +415,54 @@ func TestEpbsPoolGetPreferenceExactLookup(t *testing.T) {
_, ok = p.GetPreference(slot, otherRoot)
require.False(t, ok)
}
+
+func TestEpbsPoolRetainsLiveEntriesBeyondFormerCapacity(t *testing.T) {
+ p := NewEpbsPool()
+ for i := range uint64(256) {
+ root := common.Hash{byte(i), byte(i >> 8)}
+ p.ProposerPreferences.Add(ProposerPreferencesKey{Slot: 100, DependentRoot: root}, &cltypes.SignedProposerPreferences{})
+ p.HighestBids.Add(HighestBidKey{Slot: 100, ParentBlockRoot: root}, &cltypes.SignedExecutionPayloadBid{})
+ }
+
+ _, preferencesFound := p.ProposerPreferences.Get(ProposerPreferencesKey{Slot: 100})
+ _, bidFound := p.HighestBids.Get(HighestBidKey{Slot: 100})
+ require.True(t, preferencesFound)
+ require.True(t, bidFound)
+}
+
+func TestEpbsPoolPrunesEntriesBeforeSlot(t *testing.T) {
+ p := NewEpbsPool()
+ p.ProposerPreferences.Add(ProposerPreferencesKey{Slot: 99}, &cltypes.SignedProposerPreferences{})
+ p.ProposerPreferences.Add(ProposerPreferencesKey{Slot: 100}, &cltypes.SignedProposerPreferences{})
+ p.HighestBids.Add(HighestBidKey{Slot: 99}, &cltypes.SignedExecutionPayloadBid{})
+ p.HighestBids.Add(HighestBidKey{Slot: 100}, &cltypes.SignedExecutionPayloadBid{})
+
+ p.ProposerPreferences.PruneSlots(func(slot uint64) bool { return slot < 100 })
+ p.HighestBids.PruneSlots(func(slot uint64) bool { return slot < 100 })
+
+ _, oldPreferencesFound := p.ProposerPreferences.Get(ProposerPreferencesKey{Slot: 99})
+ _, livePreferencesFound := p.ProposerPreferences.Get(ProposerPreferencesKey{Slot: 100})
+ _, oldBidFound := p.HighestBids.Get(HighestBidKey{Slot: 99})
+ _, liveBidFound := p.HighestBids.Get(HighestBidKey{Slot: 100})
+ require.False(t, oldPreferencesFound)
+ require.True(t, livePreferencesFound)
+ require.False(t, oldBidFound)
+ require.True(t, liveBidFound)
+}
+
+func TestSlotMapPrunesSlotBucketsInsteadOfEntries(t *testing.T) {
+ const slot = uint64(100)
+ m := newSlotMap[ProposerPreferencesKey, int](func(key ProposerPreferencesKey) uint64 { return key.Slot })
+ for i := range 2048 {
+ m.Add(ProposerPreferencesKey{Slot: slot, DependentRoot: common.Hash{byte(i), byte(i >> 8)}}, i)
+ }
+ require.Len(t, m.ValuesForSlot(slot), 2048)
+
+ visited := 0
+ m.PruneSlots(func(entrySlot uint64) bool {
+ visited++
+ return entrySlot < slot
+ })
+ require.Equal(t, 1, visited)
+ require.Len(t, m.ValuesForSlot(slot), 2048)
+}
diff --git a/cl/spectest/consensus_tests/appendix.go b/cl/spectest/consensus_tests/appendix.go
index 50de64e97fe..3b77ddcfd67 100644
--- a/cl/spectest/consensus_tests/appendix.go
+++ b/cl/spectest/consensus_tests/appendix.go
@@ -242,9 +242,7 @@ func addSszTests() {
cltypes.NewPartialDataColumnSidecar, runAfterVersion(clparams.FuluVersion),
)).
With("PartialDataColumnPartsMetadata", sszStaticTestNewObjectByFunc(
- func(v clparams.StateVersion) *cltypes.PartialDataColumnPartsMetadata {
- return cltypes.NewPartialDataColumnPartsMetadata()
- }, runAfterVersion(clparams.FuluVersion),
+ cltypes.NewPartialDataColumnPartsMetadata, runAfterVersion(clparams.FuluVersion),
)).
// [New in Gloas:EIP7732] GLOAS SSZ types
With("Builder", sszStaticTestByEmptyObject(&cltypes.Builder{}, runAfterVersion(clparams.GloasVersion))).
diff --git a/cl/spectest/consensus_tests/operations.go b/cl/spectest/consensus_tests/operations.go
index 05bed3b5bc9..10aac25d60c 100644
--- a/cl/spectest/consensus_tests/operations.go
+++ b/cl/spectest/consensus_tests/operations.go
@@ -62,7 +62,11 @@ func operationAttestationHandler(t *testing.T, root fs.FS, c spectest.TestCase)
if err := spectest.ReadSszOld(root, att, c.Version(), attestationFileName); err != nil {
return err
}
- if err := c.Machine.ProcessAttestations(preState, solid.NewDynamicListSSZFromList([]*solid.Attestation{att}, 128)); err != nil {
+ var parentSlot uint64
+ if preState.Version() >= clparams.GloasVersion && preState.GetLatestExecutionPayloadBid() != nil {
+ parentSlot = preState.GetLatestExecutionPayloadBid().Slot
+ }
+ if err := c.Machine.ProcessAttestations(preState, solid.NewDynamicListSSZFromList([]*solid.Attestation{att}, 128), parentSlot); err != nil {
if expectedError {
return nil
}
@@ -636,7 +640,7 @@ func operationExecutionPayloadBidHandler(t *testing.T, root fs.FS, c spectest.Te
block.Slot = signedBid.Message.Slot
block.ParentRoot = signedBid.Message.ParentBlockRoot
block.Body.SignedExecutionPayloadBid = signedBid
- if err := c.Machine.ProcessExecutionPayloadBid(preState, block); err != nil {
+ if _, err := c.Machine.ProcessExecutionPayloadBid(preState, block); err != nil {
if expectedError {
return nil
}
diff --git a/cl/transition/impl/eth2/operations.go b/cl/transition/impl/eth2/operations.go
index 13913d0181c..f0bc2373f31 100644
--- a/cl/transition/impl/eth2/operations.go
+++ b/cl/transition/impl/eth2/operations.go
@@ -513,10 +513,10 @@ func updateNextWithdrawalBuilderIndex(s abstract.BeaconState, processedBuildersS
// ProcessExecutionPayloadBid processes the execution payload bid from the block.
// [New in Gloas:EIP7732]
-func (imp *impl) ProcessExecutionPayloadBid(s abstract.BeaconState, block cltypes.GenericBeaconBlock) error {
+func (imp *impl) ProcessExecutionPayloadBid(s abstract.BeaconState, block cltypes.GenericBeaconBlock) (uint64, error) {
signedBid := block.GetBody().GetSignedExecutionPayloadBid()
if signedBid == nil || signedBid.Message == nil {
- return errors.New("processExecutionPayloadBid: signed bid or bid message is nil")
+ return 0, errors.New("processExecutionPayloadBid: signed bid or bid message is nil")
}
bid := signedBid.Message
builderIndex := bid.BuilderIndex
@@ -525,41 +525,41 @@ func (imp *impl) ProcessExecutionPayloadBid(s abstract.BeaconState, block cltype
// For self-builds, amount must be zero regardless of withdrawal credential prefix
if builderIndex == clparams.BuilderIndexSelfBuild {
if amount != 0 {
- return errors.New("processExecutionPayloadBid: self-build bid must have zero value")
+ return 0, errors.New("processExecutionPayloadBid: self-build bid must have zero value")
}
if signedBid.Signature != common.Bytes96(bls.InfiniteSignature) {
- return errors.New("processExecutionPayloadBid: self-build bid must have infinite signature")
+ return 0, errors.New("processExecutionPayloadBid: self-build bid must have infinite signature")
}
} else {
// Verify that the builder is active
if !state.IsActiveBuilder(s, builderIndex) {
- return errors.New("processExecutionPayloadBid: builder is not active")
+ return 0, errors.New("processExecutionPayloadBid: builder is not active")
}
builders := s.GetBuilders()
if builders == nil || int(builderIndex) >= builders.Len() || builders.Get(int(builderIndex)) == nil {
- return errors.New("processExecutionPayloadBid: invalid builder index")
+ return 0, errors.New("processExecutionPayloadBid: invalid builder index")
}
if builders.Get(int(builderIndex)).Version != s.BeaconConfig().PayloadBuilderVersion {
- return errors.New("processExecutionPayloadBid: builder is not a payload builder")
+ return 0, errors.New("processExecutionPayloadBid: builder is not a payload builder")
}
// Verify that the builder has funds to cover the bid
if !state.CanBuilderCoverBid(s, builderIndex, amount) {
- return errors.New("processExecutionPayloadBid: builder cannot cover bid")
+ return 0, errors.New("processExecutionPayloadBid: builder cannot cover bid")
}
// Verify that the bid signature is valid
valid, err := verifyExecutionPayloadBidSignature(s, signedBid)
if err != nil {
- return fmt.Errorf("processExecutionPayloadBid: failed to verify bid signature: %w", err)
+ return 0, fmt.Errorf("processExecutionPayloadBid: failed to verify bid signature: %w", err)
}
if !valid {
- return errors.New("processExecutionPayloadBid: invalid bid signature")
+ return 0, errors.New("processExecutionPayloadBid: invalid bid signature")
}
}
// Verify commitments are under limit
epoch := state.Epoch(s)
if bid.BlobKzgCommitments.Len() > int(s.BeaconConfig().GetBlobParameters(epoch).MaxBlobsPerBlock) {
- return fmt.Errorf(
+ return 0, fmt.Errorf(
"processExecutionPayloadBid: too many blob kzg commitments: %d > %d",
bid.BlobKzgCommitments.Len(),
s.BeaconConfig().GetBlobParameters(epoch).MaxBlobsPerBlock,
@@ -567,35 +567,35 @@ func (imp *impl) ProcessExecutionPayloadBid(s abstract.BeaconState, block cltype
}
if bid.Slot != s.Slot() {
- return fmt.Errorf("processExecutionPayloadBid: bid slot %d does not match state slot %d", bid.Slot, s.Slot())
+ return 0, fmt.Errorf("processExecutionPayloadBid: bid slot %d does not match state slot %d", bid.Slot, s.Slot())
}
if s.Slot() <= s.BeaconConfig().GenesisSlot {
- return errors.New("processExecutionPayloadBid: bid at genesis slot")
+ return 0, errors.New("processExecutionPayloadBid: bid at genesis slot")
}
parentBid := s.GetLatestExecutionPayloadBid()
if parentBid == nil {
- return errors.New("processExecutionPayloadBid: state has no latest execution payload bid")
+ return 0, errors.New("processExecutionPayloadBid: state has no latest execution payload bid")
}
// Verify that the bid is for the right parent block
if bid.ParentBlockHash != s.GetLatestBlockHash() {
- return errors.New("processExecutionPayloadBid: parent block hash mismatch")
+ return 0, errors.New("processExecutionPayloadBid: parent block hash mismatch")
}
parentBlockRoot, err := s.GetBlockRootAtSlot(s.Slot() - 1)
if err != nil {
- return fmt.Errorf("processExecutionPayloadBid: failed to get parent block root: %w", err)
+ return 0, fmt.Errorf("processExecutionPayloadBid: failed to get parent block root: %w", err)
}
if bid.ParentBlockRoot != parentBlockRoot {
- return errors.New("processExecutionPayloadBid: parent block root mismatch")
+ return 0, errors.New("processExecutionPayloadBid: parent block root mismatch")
}
if bid.PrevRandao != s.GetRandaoMixes(state.Epoch(s)) {
- return errors.New("processExecutionPayloadBid: prev randao mismatch")
+ return 0, errors.New("processExecutionPayloadBid: prev randao mismatch")
}
// Record the pending payment if there is some payment
if amount > 0 {
proposerIndex, err := s.GetBeaconProposerIndex()
if err != nil {
- return fmt.Errorf("processExecutionPayloadBid: failed to get beacon proposer index: %w", err)
+ return 0, fmt.Errorf("processExecutionPayloadBid: failed to get beacon proposer index: %w", err)
}
pendingPayment := &cltypes.BuilderPendingPayment{
Weight: 0,
@@ -613,10 +613,10 @@ func (imp *impl) ProcessExecutionPayloadBid(s abstract.BeaconState, block cltype
s.SetBuilderPendingPayments(payments)
}
- // Cache the execution payload bid
+ parentSlot := parentBid.Slot
s.SetLatestExecutionPayloadBid(bid)
- return nil
+ return parentSlot, nil
}
// ApplyParentExecutionPayload applies the effects of a full parent execution payload to state.
@@ -1157,13 +1157,14 @@ func (imp *impl) ProcessBlsToExecutionChange(
func (imp *impl) ProcessAttestations(
s abstract.BeaconState,
attestations *solid.ListSSZ[*solid.Attestation],
+ parentSlot uint64,
) error {
attestingIndiciesSet := make([][]uint64, attestations.Len())
baseRewardPerIncrement := s.BaseRewardPerIncrement()
var err error
if err := solid.RangeErr[*solid.Attestation](attestations, func(i int, a *solid.Attestation, _ int) error {
- if attestingIndiciesSet[i], err = imp.processAttestation(s, a, baseRewardPerIncrement); err != nil {
+ if attestingIndiciesSet[i], err = imp.processAttestation(s, a, baseRewardPerIncrement, parentSlot); err != nil {
return err
}
return nil
@@ -1193,6 +1194,7 @@ func (imp *impl) processAttestationPostAltair(
s abstract.BeaconState,
attestation *solid.Attestation,
baseRewardPerIncrement uint64,
+ parentSlot uint64,
) ([]uint64, error) {
data := attestation.Data
currentEpoch := state.Epoch(s)
@@ -1244,6 +1246,7 @@ func (imp *impl) processAttestationPostAltair(
participationFlagsIndicies, err := s.GetAttestationParticipationFlagIndicies(
data,
stateSlot-data.Slot,
+ parentSlot,
false,
)
if err != nil {
@@ -1286,6 +1289,7 @@ func (imp *impl) processAttestationPostAltair(
}
baseReward := (val / beaconConfig.EffectiveBalanceIncrement) * baseRewardPerIncrement
+ hadNoParticipation := s.EpochParticipationForValidatorIndex(isCurrentEpoch, int(attesterIndex)) == 0
willSetNewFlag := false // [New in Gloas:EIP7732]
for flagIndex, weight := range beaconConfig.ParticipationWeights() {
flagParticipation := s.EpochParticipationForValidatorIndex(
@@ -1307,6 +1311,7 @@ func (imp *impl) processAttestationPostAltair(
if s.Version() >= clparams.GloasVersion &&
willSetNewFlag &&
+ hadNoParticipation &&
isSameSlot &&
payment != nil && payment.Withdrawal != nil && payment.Withdrawal.Amount > 0 {
paymentWeightDelta += val
@@ -1494,6 +1499,7 @@ func (imp *impl) processAttestation(
s abstract.BeaconState,
attestation *solid.Attestation,
baseRewardPerIncrement uint64,
+ parentSlot uint64,
) ([]uint64, error) {
// Prelimary checks.
if err := IsAttestationApplicable(s, attestation); err != nil {
@@ -1503,7 +1509,7 @@ func (imp *impl) processAttestation(
if s.Version() == clparams.Phase0Version {
return imp.processAttestationPhase0(s, attestation)
}
- return imp.processAttestationPostAltair(s, attestation, baseRewardPerIncrement)
+ return imp.processAttestationPostAltair(s, attestation, baseRewardPerIncrement, parentSlot)
}
func verifyAttestations(
diff --git a/cl/transition/machine/block.go b/cl/transition/machine/block.go
index 853819a2375..3fad38eabb1 100644
--- a/cl/transition/machine/block.go
+++ b/cl/transition/machine/block.go
@@ -47,6 +47,7 @@ func ProcessBlock(impl BlockProcessor, s abstract.BeaconState, block cltypes.Gen
}
// [New in Gloas:EIP7732] process_parent_execution_payload BEFORE process_block_header
+ var parentSlot uint64
if version >= clparams.GloasVersion {
if err := impl.ProcessParentExecutionPayload(s, block); err != nil {
return fmt.Errorf("processBlock: failed to process parent execution payload: %w", err)
@@ -68,7 +69,8 @@ func ProcessBlock(impl BlockProcessor, s abstract.BeaconState, block cltypes.Gen
return fmt.Errorf("processBlock: failed to process withdrawals: %w", err)
}
// 3. [New in Gloas:EIP7732] process_execution_payload_bid(state, block)
- if err := impl.ProcessExecutionPayloadBid(s, block); err != nil {
+ parentSlot, err = impl.ProcessExecutionPayloadBid(s, block)
+ if err != nil {
return fmt.Errorf("processBlock: failed to process execution payload bid: %w", err)
}
} else if version >= clparams.BellatrixVersion {
@@ -111,7 +113,7 @@ func ProcessBlock(impl BlockProcessor, s abstract.BeaconState, block cltypes.Gen
}
// 6. process_operations
- sigs, msgs, pubKeys, err = ProcessOperations(impl, s, body)
+ sigs, msgs, pubKeys, err = ProcessOperations(impl, s, body, parentSlot)
if err != nil {
return fmt.Errorf("processBlock: failed to process block body operations: %w", err)
}
@@ -139,7 +141,7 @@ func ProcessBlock(impl BlockProcessor, s abstract.BeaconState, block cltypes.Gen
}
// ProcessOperations is called by ProcessBlock and processes the block body operations
-func ProcessOperations(impl BlockOperationProcessor, s abstract.BeaconState, blockBody cltypes.GenericBeaconBody) (signatures [][]byte, messages [][]byte, publicKeys [][]byte, err error) {
+func ProcessOperations(impl BlockOperationProcessor, s abstract.BeaconState, blockBody cltypes.GenericBeaconBody, parentSlot uint64) (signatures [][]byte, messages [][]byte, publicKeys [][]byte, err error) {
switch {
case s.Version() <= clparams.DenebVersion:
maxDepositsAllowed := int(min(s.BeaconConfig().MaxDeposits, s.Eth1Data().DepositCount-s.Eth1DepositIndex()))
@@ -179,7 +181,7 @@ func ProcessOperations(impl BlockOperationProcessor, s abstract.BeaconState, blo
}
// Process each attestations
- if err := impl.ProcessAttestations(s, blockBody.GetAttestations()); err != nil {
+ if err := impl.ProcessAttestations(s, blockBody.GetAttestations(), parentSlot); err != nil {
return nil, nil, nil, fmt.Errorf("ProcessAttestation: %w", err)
}
diff --git a/cl/transition/machine/block_gloas_test.go b/cl/transition/machine/block_gloas_test.go
index 1dd448337af..51d312582b2 100644
--- a/cl/transition/machine/block_gloas_test.go
+++ b/cl/transition/machine/block_gloas_test.go
@@ -20,7 +20,7 @@ func (noopBlockOperationProcessor) ProcessProposerSlashing(abstract.BeaconState,
func (noopBlockOperationProcessor) ProcessAttesterSlashing(abstract.BeaconState, *cltypes.AttesterSlashing) error {
return nil
}
-func (noopBlockOperationProcessor) ProcessAttestations(abstract.BeaconState, *solid.ListSSZ[*solid.Attestation]) error {
+func (noopBlockOperationProcessor) ProcessAttestations(abstract.BeaconState, *solid.ListSSZ[*solid.Attestation], uint64) error {
return nil
}
func (noopBlockOperationProcessor) ProcessDeposit(abstract.BeaconState, *cltypes.Deposit) error {
@@ -90,7 +90,7 @@ func TestProcessOperationsRejectsOversizedGloasLists(t *testing.T) {
require.NoError(t, validateGloasOperationCounts(body, &cfg))
test.append(body)
- _, _, _, err := ProcessOperations(noopBlockOperationProcessor{}, s, body)
+ _, _, _, err := ProcessOperations(noopBlockOperationProcessor{}, s, body, 0)
require.ErrorContains(t, err, "too many "+test.name)
})
}
diff --git a/cl/transition/machine/machine.go b/cl/transition/machine/machine.go
index 99d57315f78..e1fef0792f3 100644
--- a/cl/transition/machine/machine.go
+++ b/cl/transition/machine/machine.go
@@ -48,7 +48,7 @@ type BlockHeaderProcessor interface {
ProcessBlockHeader(s abstract.BeaconState, slot, proposerIndex uint64, parentRoot common.Hash, bodyRoot [32]byte) error
ProcessWithdrawals(s abstract.BeaconState, withdrawals *solid.ListSSZ[*cltypes.Withdrawal]) error
ProcessExecutionPayload(s abstract.BeaconState, body cltypes.GenericBeaconBody) error
- ProcessExecutionPayloadBid(s abstract.BeaconState, block cltypes.GenericBeaconBlock) error
+ ProcessExecutionPayloadBid(s abstract.BeaconState, block cltypes.GenericBeaconBlock) (uint64, error)
ProcessParentExecutionPayload(s abstract.BeaconState, block cltypes.GenericBeaconBlock) error
ApplyParentExecutionPayload(s abstract.BeaconState, requests *cltypes.ExecutionRequests) error
ProcessRandao(s abstract.BeaconState, randao [96]byte, proposerIndex uint64) error
@@ -61,7 +61,7 @@ type BlockHeaderProcessor interface {
type BlockOperationProcessor interface {
ProcessProposerSlashing(s abstract.BeaconState, propSlashing *cltypes.ProposerSlashing) error
ProcessAttesterSlashing(s abstract.BeaconState, attSlashing *cltypes.AttesterSlashing) error
- ProcessAttestations(s abstract.BeaconState, attestations *solid.ListSSZ[*solid.Attestation]) error
+ ProcessAttestations(s abstract.BeaconState, attestations *solid.ListSSZ[*solid.Attestation], parentSlot uint64) error
ProcessDeposit(s abstract.BeaconState, deposit *cltypes.Deposit) error
ProcessVoluntaryExit(s abstract.BeaconState, signedVoluntaryExit *cltypes.SignedVoluntaryExit) error
ProcessBlsToExecutionChange(state abstract.BeaconState, signedChange *cltypes.SignedBLSToExecutionChange) error
diff --git a/cmd/caplin/caplin1/option.go b/cmd/caplin/caplin1/option.go
index c01ecf55afc..7137e47414b 100644
--- a/cmd/caplin/caplin1/option.go
+++ b/cmd/caplin/caplin1/option.go
@@ -27,8 +27,33 @@ type option struct {
type CaplinOption func(*option)
-func WithBuilder(mevRelayUrl string, beaconConfig *clparams.BeaconChainConfig) CaplinOption {
+func WithBuilder(mevRelayURL string, beaconConfig *clparams.BeaconChainConfig, policy builder.BuilderTargetPolicy) CaplinOption {
return func(o *option) {
- o.builderClient = builder.NewBlockBuilderClient(mevRelayUrl, beaconConfig)
+ if mevRelayURL == "" {
+ o.builderClient = builder.NewDynamicBuilderClient(beaconConfig, policy)
+ return
+ }
+ o.builderClient = builder.NewBlockBuilderClientWithPolicy(mevRelayURL, beaconConfig, policy)
}
}
+
+func builderOptionForConfig(config *clparams.CaplinConfig, beaconConfig *clparams.BeaconChainConfig) (CaplinOption, bool) {
+ legacyRelayURL := ""
+ skippedLegacy := false
+ if config.BeaconAPIRouter.Builder {
+ if config.RelayUrlExist() {
+ legacyRelayURL = config.MevRelayUrl
+ } else {
+ config.BeaconAPIRouter.Builder = false
+ skippedLegacy = true
+ }
+ }
+ if !config.BeaconAPIRouter.Validator && !config.BeaconAPIRouter.Builder {
+ return nil, skippedLegacy
+ }
+ return WithBuilder(legacyRelayURL, beaconConfig, builderTargetPolicyForConfig(config)), skippedLegacy
+}
+
+func builderTargetPolicyForConfig(config *clparams.CaplinConfig) builder.BuilderTargetPolicy {
+ return builder.BuilderTargetPolicy{AllowPrivate: config != nil && config.AllowPrivateBuilderURLs}
+}
diff --git a/cmd/caplin/caplin1/option_test.go b/cmd/caplin/caplin1/option_test.go
new file mode 100644
index 00000000000..c83cb0f4d0f
--- /dev/null
+++ b/cmd/caplin/caplin1/option_test.go
@@ -0,0 +1,53 @@
+// Copyright 2026 The Erigon Authors
+// This file is part of Erigon.
+//
+// Erigon is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+
+package caplin1
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/erigontech/erigon/cl/beacon/builder"
+ "github.com/erigontech/erigon/cl/clparams"
+)
+
+func TestWithBuilderSupportsDynamicValidatorAPIWithoutRelay(t *testing.T) {
+ var got option
+ WithBuilder("", &clparams.MainnetBeaconConfig, builder.BuilderTargetPolicy{})(&got)
+ require.NotNil(t, got.builderClient)
+}
+
+func TestBuilderTargetPolicyForConfig(t *testing.T) {
+ require.False(t, builderTargetPolicyForConfig(nil).AllowPrivate)
+ require.False(t, builderTargetPolicyForConfig(&clparams.CaplinConfig{}).AllowPrivate)
+ require.False(t, builderTargetPolicyForConfig(&clparams.CaplinConfig{CustomConfigPath: "config.yaml"}).AllowPrivate)
+ require.True(t, builderTargetPolicyForConfig(&clparams.CaplinConfig{AllowPrivateBuilderURLs: true}).AllowPrivate)
+}
+
+func TestBuilderOptionKeepsDynamicAndLegacyGatingIndependent(t *testing.T) {
+ t.Run("validator API without relay", func(t *testing.T) {
+ config := &clparams.CaplinConfig{}
+ config.BeaconAPIRouter.Validator = true
+ builderOption, skippedLegacy := builderOptionForConfig(config, &clparams.MainnetBeaconConfig)
+ require.NotNil(t, builderOption)
+ require.False(t, skippedLegacy)
+ var got option
+ builderOption(&got)
+ require.NotNil(t, got.builderClient)
+ })
+
+ t.Run("legacy builder API without relay", func(t *testing.T) {
+ config := &clparams.CaplinConfig{}
+ config.BeaconAPIRouter.Builder = true
+ builderOption, skippedLegacy := builderOptionForConfig(config, &clparams.MainnetBeaconConfig)
+ require.Nil(t, builderOption)
+ require.True(t, skippedLegacy)
+ require.False(t, config.BeaconAPIRouter.Builder)
+ })
+}
diff --git a/cmd/caplin/caplin1/run.go b/cmd/caplin/caplin1/run.go
index ea71009a3ed..6521bd47d9f 100644
--- a/cmd/caplin/caplin1/run.go
+++ b/cmd/caplin/caplin1/run.go
@@ -303,13 +303,13 @@ func RunCaplinService(ctx context.Context, engine execution_client.ExecutionEngi
}
caplinOptions := []CaplinOption{}
- if config.BeaconAPIRouter.Builder {
- if config.RelayUrlExist() {
- caplinOptions = append(caplinOptions, WithBuilder(config.MevRelayUrl, beaconConfig))
- } else {
+ if builderOption, skippedLegacy := builderOptionForConfig(&config, beaconConfig); builderOption != nil {
+ caplinOptions = append(caplinOptions, builderOption)
+ if skippedLegacy {
log.Warn("builder api enable but relay url not set. Skipping builder mode")
- config.BeaconAPIRouter.Builder = false
}
+ } else if skippedLegacy {
+ log.Warn("builder api enable but relay url not set. Skipping builder mode")
}
log.Info("Starting caplin")
@@ -481,8 +481,8 @@ func RunCaplinService(ctx context.Context, engine execution_client.ExecutionEngi
proposerSlashingService := services.NewProposerSlashingService(pool, syncedDataManager, beaconConfig, ethClock, emitters)
attesterSlashingService := services.NewAttesterSlashingService(forkChoice)
executionPayloadService := services.NewExecutionPayloadService(ctx, forkChoice, beaconConfig, emitters)
- payloadAttestationService := services.NewPayloadAttestationService(ctx, forkChoice, ethClock, networkConfig, emitters)
- proposerPreferencesService := services.NewProposerPreferencesService(syncedDataManager, forkChoice, ethClock, beaconConfig, epbsPool)
+ payloadAttestationService := services.NewPayloadAttestationService(ctx, forkChoice, ethClock, networkConfig, epbsPool, emitters)
+ proposerPreferencesService := services.NewProposerPreferencesService(syncedDataManager, forkChoice, ethClock, beaconConfig, epbsPool, emitters)
executionPayloadBidService := services.NewExecutionPayloadBidService(ctx, syncedDataManager, forkChoice, ethClock, beaconConfig, epbsPool, emitters)
registry.RegisterGossipServices(
gossipManager,
@@ -617,6 +617,7 @@ func RunCaplinService(ctx context.Context, engine execution_client.ExecutionEngi
voluntaryExitService,
blsToExecutionChangeService,
proposerSlashingService,
+ blockService,
option.builderClient,
stateSnapshots,
gossipManager,
diff --git a/cmd/caplin/caplincli/config.go b/cmd/caplin/caplincli/config.go
index 2194876fc55..df72710a5a1 100644
--- a/cmd/caplin/caplincli/config.go
+++ b/cmd/caplin/caplincli/config.go
@@ -37,22 +37,23 @@ import (
type CaplinCliCfg struct {
*sentinelcli.SentinelCliCfg
- Chaindata string `json:"chaindata"`
- ErigonPrivateApi string `json:"erigon_private_api"`
- AllowedEndpoints []string `json:"endpoints"`
- BeaconApiReadTimeout time.Duration `json:"beacon_api_read_timeout"`
- BeaconApiWriteTimeout time.Duration `json:"beacon_api_write_timeout"`
- BeaconAddr string `json:"beacon_addr"`
- BeaconProtocol string `json:"beacon_protocol"`
- DataDir string `json:"data_dir"`
- RunEngineAPI bool `json:"run_engine_api"`
- EngineAPIAddr string `json:"engine_api_addr"`
- EngineAPIPort int `json:"engine_api_port"`
- MevRelayUrl string `json:"mev_relay_url"`
- CustomConfig string `json:"custom_config"`
- CustomGenesisState string `json:"custom_genesis_state"`
- MaxPeerCount uint64 `json:"max_peer_count"`
- JwtSecret []byte
+ Chaindata string `json:"chaindata"`
+ ErigonPrivateApi string `json:"erigon_private_api"`
+ AllowedEndpoints []string `json:"endpoints"`
+ BeaconApiReadTimeout time.Duration `json:"beacon_api_read_timeout"`
+ BeaconApiWriteTimeout time.Duration `json:"beacon_api_write_timeout"`
+ BeaconAddr string `json:"beacon_addr"`
+ BeaconProtocol string `json:"beacon_protocol"`
+ DataDir string `json:"data_dir"`
+ RunEngineAPI bool `json:"run_engine_api"`
+ EngineAPIAddr string `json:"engine_api_addr"`
+ EngineAPIPort int `json:"engine_api_port"`
+ MevRelayUrl string `json:"mev_relay_url"`
+ AllowPrivateBuilderURLs bool `json:"allow_private_builder_urls"`
+ CustomConfig string `json:"custom_config"`
+ CustomGenesisState string `json:"custom_genesis_state"`
+ MaxPeerCount uint64 `json:"max_peer_count"`
+ JwtSecret []byte
AllowedMethods []string `json:"allowed_methods"`
AllowedOrigins []string `json:"allowed_origins"`
@@ -106,6 +107,7 @@ func SetupCaplinCli(ctx *cli.Command) (cfg *CaplinCliCfg, err error) {
cfg.Chaindata = ctx.String(caplinflags.ChaindataFlag.Name)
cfg.MevRelayUrl = ctx.String(caplinflags.MevRelayUrl.Name)
+ cfg.AllowPrivateBuilderURLs = ctx.Bool(utils.CaplinAllowPrivateBuilderURLs.Name)
// Custom Chain
cfg.CustomConfig = ctx.String(caplinflags.CustomConfig.Name)
diff --git a/cmd/caplin/caplinflags/flags.go b/cmd/caplin/caplinflags/flags.go
index 261bddc2eb9..26787846c53 100644
--- a/cmd/caplin/caplinflags/flags.go
+++ b/cmd/caplin/caplinflags/flags.go
@@ -33,6 +33,7 @@ var CliFlags = []cli.Flag{
&EngineApiHostFlag,
&EngineApiPortFlag,
&MevRelayUrl,
+ &utils.CaplinAllowPrivateBuilderURLs,
&JwtSecret,
&CustomConfig,
&CustomGenesisState,
diff --git a/cmd/caplin/caplinflags/flags_test.go b/cmd/caplin/caplinflags/flags_test.go
new file mode 100644
index 00000000000..7e935087ee1
--- /dev/null
+++ b/cmd/caplin/caplinflags/flags_test.go
@@ -0,0 +1,34 @@
+// Copyright 2026 The Erigon Authors
+// This file is part of Erigon.
+//
+// Erigon is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Lesser General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Erigon is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Lesser General Public License for more details.
+//
+// You should have received a copy of the GNU Lesser General Public License
+// along with Erigon. If not, see .
+
+package caplinflags
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/erigontech/erigon/cmd/utils"
+)
+
+func TestPrivateBuilderURLFlagMatchesIntegratedCaplin(t *testing.T) {
+ for _, flag := range CliFlags {
+ if flag.Names()[0] == utils.CaplinAllowPrivateBuilderURLs.Name {
+ return
+ }
+ }
+ require.Fail(t, "private builder URL flag is not registered")
+}
diff --git a/cmd/caplin/main.go b/cmd/caplin/main.go
index 408a28b6a82..375e2b80ace 100644
--- a/cmd/caplin/main.go
+++ b/cmd/caplin/main.go
@@ -115,6 +115,7 @@ func runCaplinNode(ctx context.Context, cliCtx *cli.Command) error {
NetworkId: networkId,
LocalDiscovery: cfg.LocalDiscovery,
MevRelayUrl: cfg.MevRelayUrl,
+ AllowPrivateBuilderURLs: cfg.AllowPrivateBuilderURLs,
CustomConfigPath: cfg.CustomConfig,
CustomGenesisStatePath: cfg.CustomGenesisState,
MaxPeerCount: cfg.MaxPeerCount,
diff --git a/cmd/utils/flags.go b/cmd/utils/flags.go
index e3e889630c5..5c595b75865 100644
--- a/cmd/utils/flags.go
+++ b/cmd/utils/flags.go
@@ -952,6 +952,11 @@ var (
Usage: "MEV relay endpoint. Caplin runs in builder mode if this is set",
Value: "",
}
+ CaplinAllowPrivateBuilderURLs = cli.BoolFlag{
+ Name: "caplin.builder.allow-private-urls",
+ Usage: "Allow validator-configured builder URLs to resolve to private or loopback addresses",
+ Value: false,
+ }
CaplinValidatorMonitorFlag = cli.BoolFlag{
Name: "caplin.validator-monitor",
Usage: "Enable caplin validator monitoring metrics",
@@ -1847,6 +1852,7 @@ func setCaplin(ctx *cli.Command, cfg *ethconfig.Config) {
cfg.CaplinConfig.ColumnKeepSlots = ctx.Uint64(CaplinColumnKeepSlotsFlag.Name)
// bunch of extra stuff
cfg.CaplinConfig.MevRelayUrl = ctx.String(CaplinMevRelayUrl.Name)
+ cfg.CaplinConfig.AllowPrivateBuilderURLs = ctx.Bool(CaplinAllowPrivateBuilderURLs.Name)
cfg.CaplinConfig.EnableValidatorMonitor = ctx.Bool(CaplinValidatorMonitorFlag.Name)
if checkpointUrls := ctx.StringSlice(CaplinCheckpointSyncUrlFlag.Name); len(checkpointUrls) > 0 {
clparams.ConfigurableCheckpointsURLs = checkpointUrls
diff --git a/common/event/feed.go b/common/event/feed.go
index 3de8f053e41..ad4e6305adf 100644
--- a/common/event/feed.go
+++ b/common/event/feed.go
@@ -182,6 +182,37 @@ func (f *Feed) Send(value any) (nsent int) {
return nsent
}
+// TrySend delivers to subscribers that can receive immediately and drops the value for the rest.
+func (f *Feed) TrySend(value any) (nsent int) {
+ rvalue := reflect.ValueOf(value)
+
+ f.once.Do(func() { f.init(rvalue.Type()) })
+ if f.etype != rvalue.Type() {
+ panic(feedTypeError{op: "TrySend", got: rvalue.Type(), want: f.etype})
+ }
+
+ select {
+ case <-f.sendLock:
+ default:
+ return 0
+ }
+ defer func() { f.sendLock <- struct{}{} }()
+
+ if !f.mu.TryLock() {
+ return 0
+ }
+ f.sendCases = append(f.sendCases, f.inbox...)
+ f.inbox = nil
+ f.mu.Unlock()
+
+ for i := firstSubSendCase; i < len(f.sendCases); i++ {
+ if f.sendCases[i].Chan.TrySend(rvalue) {
+ nsent++
+ }
+ }
+ return nsent
+}
+
type feedSub struct {
feed *Feed
channel reflect.Value
diff --git a/common/event/feed_test.go b/common/event/feed_test.go
index 92a4bfa3cbb..c0668c738e9 100644
--- a/common/event/feed_test.go
+++ b/common/event/feed_test.go
@@ -66,6 +66,14 @@ func TestFeedPanics(t *testing.T) {
t.Error(err)
}
}
+ {
+ var f Feed
+ f.TrySend(2)
+ want := feedTypeError{op: "TrySend", got: reflect.TypeFor[uint64](), want: reflect.TypeFor[int]()}
+ if err := checkPanic(want, func() { f.TrySend(uint64(2)) }); err != nil {
+ t.Error(err)
+ }
+ }
}
func checkPanic(want error, fn func()) (err error) {
@@ -129,6 +137,79 @@ func TestFeed(t *testing.T) {
done.Wait()
}
+func TestFeedTrySendSkipsSlowSubscribers(t *testing.T) {
+ var feed Feed
+ slow := make(chan int)
+ slowSub := feed.Subscribe(slow)
+ defer slowSub.Unsubscribe()
+ ready := make(chan int, 1)
+ readySub := feed.Subscribe(ready)
+ defer readySub.Unsubscribe()
+
+ if delivered := feed.TrySend(1); delivered != 1 {
+ t.Fatalf("TrySend delivered %d times, want 1", delivered)
+ }
+ if value := <-ready; value != 1 {
+ t.Fatalf("received %d, want 1", value)
+ }
+}
+
+func TestFeedTrySendDoesNotWaitForBlockedSend(t *testing.T) {
+ var feed Feed
+ slow := make(chan int)
+ slowSub := feed.Subscribe(slow)
+ defer slowSub.Unsubscribe()
+ ready := make(chan int)
+ readySub := feed.Subscribe(ready)
+ defer readySub.Unsubscribe()
+
+ sendDone := make(chan struct{})
+ go func() {
+ feed.Send(1)
+ close(sendDone)
+ }()
+ if value := <-ready; value != 1 {
+ t.Fatalf("received %d, want 1", value)
+ }
+
+ completed := make(chan int, 1)
+ go func() { completed <- feed.TrySend(2) }()
+ select {
+ case delivered := <-completed:
+ if delivered != 0 {
+ t.Fatalf("TrySend delivered %d times, want 0", delivered)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("TrySend waited for a blocked Send")
+ }
+
+ slowSub.Unsubscribe()
+ select {
+ case <-sendDone:
+ case <-time.After(time.Second):
+ t.Fatal("Send remained blocked after unsubscribe")
+ }
+}
+
+func TestFeedTrySendDoesNotWaitForSubscriptionLock(t *testing.T) {
+ var feed Feed
+ sub := feed.Subscribe(make(chan int, 1))
+ defer sub.Unsubscribe()
+ feed.mu.Lock()
+ defer feed.mu.Unlock()
+
+ completed := make(chan int, 1)
+ go func() { completed <- feed.TrySend(1) }()
+ select {
+ case delivered := <-completed:
+ if delivered != 0 {
+ t.Fatalf("TrySend delivered %d times, want 0", delivered)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("TrySend waited for the subscription lock")
+ }
+}
+
func TestFeedSubscribeSameChannel(t *testing.T) {
var (
feed Feed
diff --git a/node/cli/default_flags.go b/node/cli/default_flags.go
index f36f90885b0..c29cb4901b1 100644
--- a/node/cli/default_flags.go
+++ b/node/cli/default_flags.go
@@ -246,6 +246,7 @@ var DefaultFlags = []cli.Flag{
&utils.CaplinResumeMaxStalenessEpochsFlag,
&utils.CaplinEnableSnapshotGeneration,
&utils.CaplinMevRelayUrl,
+ &utils.CaplinAllowPrivateBuilderURLs,
&utils.CaplinValidatorMonitorFlag,
&utils.CaplinCustomConfigFlag,
&utils.CaplinCustomGenesisFlag,
diff --git a/test-fixtures.json b/test-fixtures.json
index 5f2115fa1a5..2b2c301d3be 100644
--- a/test-fixtures.json
+++ b/test-fixtures.json
@@ -21,9 +21,9 @@
"size": 497764775
},
"cl_mainnet": {
- "url": "https://github.com/ethereum/consensus-specs/releases/download/v1.7.0-alpha.12/mainnet.tar.gz",
- "sha256": "f0057d2acdea2730ec68cac2c796aad89174e10e9b38ac68ad7d8aa1c0cbd85c",
- "size": 858531022
+ "url": "https://github.com/ethereum/consensus-specs/releases/download/v1.7.0-alpha.14/mainnet.tar.gz",
+ "sha256": "f98bd32f4d68867691b910ba1c62e3d55dd4e8b6a57a785b3dfd9297bf9800f7",
+ "size": 898796310
},
"legacy_cancun": {
"url": "https://github.com/ethereum/legacytests/archive/1f581b8ccdc4c63acf5f2c5c1b155c690c32a8eb.tar.gz",