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",