Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ BackgroundEventSource createEventSource(final RemoteValidatorApiChannel beaconNo

final List<EventType> eventTypes = new ArrayList<>();
eventTypes.add(EventType.head);
eventTypes.add(EventType.head_v2);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dual head event subscription

Medium Severity

The event stream subscribes to both head and head_v2. Beacon nodes emit both for the same head update during the transition, so a working head_v2 parser would invoke onHeadUpdate and onAttestationCreationDue twice per slot. Other clients prefer head_v2 with a fallback to head rather than listening to both at once.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0369dfd. Configure here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

im not sure how hard a fallback would be - if you subscribe and head_v2 isnt present, im not sure we'd know (except never seeing it) - maybe worth digging into...

if (shutdownWhenValidatorSlashedEnabled) {
eventTypes.add(EventType.attester_slashing);
eventTypes.add(EventType.proposer_slashing);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ public void onMessage(final String event, final MessageEvent messageEvent) {
final EventType eventType = EventType.valueOf(event);
switch (eventType) {
case head -> handleHeadEvent(messageEvent.getData());
case head_v2 -> handleHeadV2Event(messageEvent.getData());
case attester_slashing -> handleAttesterSlashingEvent(messageEvent.getData());
case proposer_slashing -> handleProposerSlashingEvent(messageEvent.getData());
default -> LOG.warn("Received unexpected event type: " + event);
Expand All @@ -119,6 +120,18 @@ private void handleHeadEvent(final String data) throws JsonProcessingException {
}
}

private void handleHeadV2Event(final String data) throws JsonProcessingException {
final HeadV2Event headEvent = JsonUtil.parse(data, HeadV2Event.TYPE_DEFINITION);
validatorTimingChannel.onHeadUpdate(
headEvent.slot(),
headEvent.previousDutyDependentRoot(),
headEvent.currentDutyDependentRoot(),
headEvent.block());
if (generateEarlyAttestations) {
validatorTimingChannel.onAttestationCreationDue(headEvent.slot());
}
}

private void handleAttesterSlashingEvent(final String data) throws JsonProcessingException {
final DeserializableTypeDefinition<AttesterSlashing> attesterSlashingTypeDefinition =
spec.getGenesisSchemaDefinitions().getAttesterSlashingSchema().getJsonTypeDefinition();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
* Copyright Consensys Software Inc., 2026
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
* an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
* specific language governing permissions and limitations under the License.
*/

package tech.pegasys.teku.validator.remote.eventsource;

import static tech.pegasys.teku.infrastructure.json.types.CoreTypes.BOOLEAN_TYPE;
import static tech.pegasys.teku.infrastructure.json.types.CoreTypes.BYTES32_TYPE;
import static tech.pegasys.teku.infrastructure.json.types.CoreTypes.STRING_TYPE;
import static tech.pegasys.teku.infrastructure.json.types.CoreTypes.UINT64_TYPE;

import org.apache.tuweni.bytes.Bytes32;
import tech.pegasys.teku.infrastructure.json.types.DeserializableTypeDefinition;
import tech.pegasys.teku.infrastructure.unsigned.UInt64;

record HeadV2Event(
UInt64 slot,
Bytes32 block,
Bytes32 state,
boolean epochTransition,
Bytes32 previousDutyDependentRoot,
Bytes32 currentDutyDependentRoot,
Boolean executionOptimistic,
String payloadStatus) {

static final DeserializableTypeDefinition<HeadV2Event> TYPE_DEFINITION =
DeserializableTypeDefinition.object(HeadV2Event.class, Builder.class)
.initializer(Builder::new)
.finisher(Builder::build)
.withField("slot", UINT64_TYPE, HeadV2Event::slot, Builder::slot)
.withField("block", BYTES32_TYPE, HeadV2Event::block, Builder::block)
.withField("state", BYTES32_TYPE, HeadV2Event::state, Builder::state)
.withField(
"epoch_transition",
BOOLEAN_TYPE,
HeadV2Event::epochTransition,
Builder::epochTransition)
.withField(
"previous_duty_dependent_root",
BYTES32_TYPE,
HeadV2Event::previousDutyDependentRoot,
Builder::previousDutyDependentRoot)
.withField(
"current_duty_dependent_root",
BYTES32_TYPE,
HeadV2Event::currentDutyDependentRoot,
Builder::currentDutyDependentRoot)
.withField(
"execution_optimistic",
BOOLEAN_TYPE,
HeadV2Event::executionOptimistic,
Builder::executionOptimistic)
.withField(
"payload_status", STRING_TYPE, HeadV2Event::payloadStatus, Builder::payloadStatus)
.build();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Head_v2 schema mismatch

High Severity

HeadV2Event.TYPE_DEFINITION does not match the wire format Teku emits for head_v2. The beacon node sends a versioned envelope (version + data) with current_epoch_dependent_root and next_epoch_dependent_root, but the client expects a flat payload using the legacy previous_duty_dependent_root / current_duty_dependent_root names. Every head_v2 message fails deserialization and is counted as an invalid event, so the new subscription never drives duty updates.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0369dfd. Configure here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

our head event structure may not be consistent on both sides of the wire by the sounds of this


private static class Builder {
private UInt64 slot;
private Bytes32 block;
private Bytes32 state;
private boolean epochTransition;
private Bytes32 previousDutyDependentRoot;
private Bytes32 currentDutyDependentRoot;
private boolean executionOptimistic;
private String payloadStatus;

Builder slot(final UInt64 slot) {
this.slot = slot;
return this;
}

Builder block(final Bytes32 block) {
this.block = block;
return this;
}

Builder state(final Bytes32 state) {
this.state = state;
return this;
}

Builder epochTransition(final boolean epochTransition) {
this.epochTransition = epochTransition;
return this;
}

Builder previousDutyDependentRoot(final Bytes32 previousDutyDependentRoot) {
this.previousDutyDependentRoot = previousDutyDependentRoot;
return this;
}

Builder currentDutyDependentRoot(final Bytes32 currentDutyDependentRoot) {
this.currentDutyDependentRoot = currentDutyDependentRoot;
return this;
}

Builder executionOptimistic(final boolean executionOptimistic) {
this.executionOptimistic = executionOptimistic;
return this;
}

Builder payloadStatus(final String payloadStatus) {
this.payloadStatus = payloadStatus;
return this;
}

HeadV2Event build() {
return new HeadV2Event(
slot,
block,
state,
epochTransition,
previousDutyDependentRoot,
currentDutyDependentRoot,
executionOptimistic,
payloadStatus);
}
}
}
Loading