Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions packages/beacon-node/src/chain/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ import {
SeenExecutionPayloadBids,
SeenPayloadAttesters,
SeenPayloadEnvelopeInput,
SeenProposerPreferences,
SeenSyncCommitteeMessages,
} from "./seenCache/index.js";
import {SeenAggregatedAttestations} from "./seenCache/seenAggregateAndProof.js";
Expand Down Expand Up @@ -186,6 +187,7 @@ export class BeaconChain implements IBeaconChain {
readonly seenPayloadAttesters = new SeenPayloadAttesters();
readonly seenAggregatedAttestations: SeenAggregatedAttestations;
readonly seenExecutionPayloadBids = new SeenExecutionPayloadBids();
readonly seenProposerPreferences = new SeenProposerPreferences();
readonly seenBlockProposers = new SeenBlockProposers();
readonly seenSyncCommitteeMessages = new SeenSyncCommitteeMessages();
readonly seenContributionAndProof: SeenContributionAndProof;
Expand Down Expand Up @@ -1437,6 +1439,7 @@ export class BeaconChain implements IBeaconChain {
this.payloadAttestationPool.prune(slot);
this.executionPayloadBidPool.prune(slot);
this.seenExecutionPayloadBids.prune(slot);
this.seenProposerPreferences.prune(slot);
this.seenAttestationDatas.onSlot(slot);
this.reprocessController.onSlot(slot);

Expand Down
1 change: 1 addition & 0 deletions packages/beacon-node/src/chain/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export * from "./executionPayloadBid.js";
export * from "./executionPayloadEnvelope.js";
export * from "./gossipValidation.js";
export * from "./payloadAttestation.js";
export * from "./proposerPreferences.js";
export * from "./proposerSlashingError.js";
export * from "./syncCommitteeError.js";
export * from "./voluntaryExitError.js";
39 changes: 39 additions & 0 deletions packages/beacon-node/src/chain/errors/proposerPreferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import {Slot, ValidatorIndex} from "@lodestar/types";
import {GossipActionError} from "./gossipValidation.js";

export enum ProposerPreferencesErrorCode {
INVALID_EPOCH = "PROPOSER_PREFERENCES_ERROR_INVALID_EPOCH",
PROPOSAL_SLOT_PASSED = "PROPOSER_PREFERENCES_ERROR_PROPOSAL_SLOT_PASSED",
INVALID_PROPOSER = "PROPOSER_PREFERENCES_ERROR_INVALID_PROPOSER",
ALREADY_KNOWN = "PROPOSER_PREFERENCES_ERROR_ALREADY_KNOWN",
INVALID_SIGNATURE = "PROPOSER_PREFERENCES_ERROR_INVALID_SIGNATURE",
}

export type ProposerPreferencesErrorType =
| {
code: ProposerPreferencesErrorCode.INVALID_EPOCH;
proposalSlot: Slot;
currentEpoch: number;
}
| {
code: ProposerPreferencesErrorCode.PROPOSAL_SLOT_PASSED;
proposalSlot: Slot;
currentSlot: Slot;
}
| {
code: ProposerPreferencesErrorCode.INVALID_PROPOSER;
proposalSlot: Slot;
validatorIndex: ValidatorIndex;
}
| {
code: ProposerPreferencesErrorCode.ALREADY_KNOWN;
proposalSlot: Slot;
validatorIndex: ValidatorIndex;
}
| {
code: ProposerPreferencesErrorCode.INVALID_SIGNATURE;
proposalSlot: Slot;
validatorIndex: ValidatorIndex;
};

export class ProposerPreferencesError extends GossipActionError<ProposerPreferencesErrorType> {}
2 changes: 2 additions & 0 deletions packages/beacon-node/src/chain/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
SeenContributionAndProof,
SeenExecutionPayloadBids,
SeenPayloadAttesters,
SeenProposerPreferences,
SeenSyncCommitteeMessages,
} from "./seenCache/index.js";
import {SeenAggregatedAttestations} from "./seenCache/seenAggregateAndProof.js";
Expand Down Expand Up @@ -131,6 +132,7 @@ export interface IBeaconChain {
readonly seenPayloadAttesters: SeenPayloadAttesters;
readonly seenAggregatedAttestations: SeenAggregatedAttestations;
readonly seenExecutionPayloadBids: SeenExecutionPayloadBids;
readonly seenProposerPreferences: SeenProposerPreferences;
readonly seenBlockProposers: SeenBlockProposers;
readonly seenSyncCommitteeMessages: SeenSyncCommitteeMessages;
readonly seenContributionAndProof: SeenContributionAndProof;
Expand Down
1 change: 1 addition & 0 deletions packages/beacon-node/src/chain/regen/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export enum RegenCaller {
validateGossipAttestation = "validateGossipAttestation",
validateGossipVoluntaryExit = "validateGossipVoluntaryExit",
validateGossipExecutionPayloadBid = "validateGossipExecutionPayloadBid",
validateGossipProposerPreferences = "validateGossipProposerPreferences",
onForkChoiceFinalized = "onForkChoiceFinalized",
restApi = "restApi",
}
Expand Down
1 change: 1 addition & 0 deletions packages/beacon-node/src/chain/seenCache/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export {SeenContributionAndProof} from "./seenCommitteeContribution.js";
export {SeenExecutionPayloadBids} from "./seenExecutionPayloadBids.js";
export {SeenBlockInput} from "./seenGossipBlockInput.js";
export {PayloadEnvelopeInput, SeenPayloadEnvelopeInput} from "./seenPayloadEnvelopeInput.js";
export {SeenProposerPreferences} from "./seenProposerPreferences.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {Slot, ValidatorIndex} from "@lodestar/types";
import {MapDef} from "@lodestar/utils";

/**
* Tracks signed proposer preferences we've already seen per (proposal_slot, validator_index).
*/
export class SeenProposerPreferences {
private readonly validatorIndexesBySlot = new MapDef<Slot, Set<ValidatorIndex>>(() => new Set<ValidatorIndex>());

isKnown(proposalSlot: Slot, validatorIndex: ValidatorIndex): boolean {
return this.validatorIndexesBySlot.get(proposalSlot)?.has(validatorIndex) === true;
}

add(proposalSlot: Slot, validatorIndex: ValidatorIndex): void {
this.validatorIndexesBySlot.getOrDefault(proposalSlot).add(validatorIndex);
}

/**
* Entries are only load-bearing while `proposal_slot > state.slot`. Once the slot has passed the
* `[IGNORE] proposal_slot > state.slot` gossip rule takes over, so drop them on each slot tick.
*/
prune(currentSlot: Slot): void {
for (const slot of this.validatorIndexesBySlot.keys()) {
if (slot < currentSlot) {
this.validatorIndexesBySlot.delete(slot);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import {SLOTS_PER_EPOCH} from "@lodestar/params";
import {
computeEpochAtSlot,
createSingleSignatureSetFromComponents,
getProposerPreferencesSigningRoot,
isStatePostGloas,
} from "@lodestar/state-transition";
import {gloas} from "@lodestar/types";
import {GossipAction, ProposerPreferencesError, ProposerPreferencesErrorCode} from "../errors/index.js";
import {IBeaconChain} from "../index.js";
import {RegenCaller} from "../regen/index.js";

/**
* Validates a gossiped `SignedProposerPreferences` per
* https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/p2p-interface.md#proposer_preferences
*/
export async function validateGossipProposerPreferences(
chain: IBeaconChain,
signedProposerPreferences: gloas.SignedProposerPreferences
): Promise<void> {
const preferences = signedProposerPreferences.message;
const {proposalSlot, validatorIndex} = preferences;
const proposalEpoch = computeEpochAtSlot(proposalSlot);

// [IGNORE] `preferences.proposal_slot` is in the current or next epoch.
const currentEpoch = chain.clock.currentEpoch;
if (proposalEpoch < currentEpoch || proposalEpoch > currentEpoch + 1) {
throw new ProposerPreferencesError(GossipAction.IGNORE, {
code: ProposerPreferencesErrorCode.INVALID_EPOCH,
proposalSlot,
currentEpoch,
});
}

// [IGNORE] `preferences.proposal_slot` has not already passed.
const currentSlot = chain.clock.currentSlot;
if (proposalSlot <= currentSlot) {
throw new ProposerPreferencesError(GossipAction.IGNORE, {
code: ProposerPreferencesErrorCode.PROPOSAL_SLOT_PASSED,
proposalSlot,
currentSlot,
});
}

const state = await chain.getHeadStateAtCurrentEpoch(RegenCaller.validateGossipProposerPreferences);
if (!isStatePostGloas(state)) {
throw new Error(`Expected gloas+ state for proposer preferences validation, got fork=${state.forkName}`);
}

// [REJECT] `preferences.validator_index` is present at the correct slot in the current or next
// epoch's portion of `state.proposer_lookahead` — i.e. `is_valid_proposal_slot(state, preferences)`
// returns True.
const epochOffset = proposalEpoch - state.epoch;
const proposers = epochOffset === 0 ? state.currentProposers : state.nextProposers;
const expectedProposer = proposers[proposalSlot % SLOTS_PER_EPOCH];
if (epochOffset < 0 || epochOffset > 1 || expectedProposer !== validatorIndex) {
throw new ProposerPreferencesError(GossipAction.REJECT, {
code: ProposerPreferencesErrorCode.INVALID_PROPOSER,
proposalSlot,
validatorIndex,
});
}

// [IGNORE] The `signed_proposer_preferences` is the first valid message received from the validator
// with index `preferences.validator_index` and the given slot `preferences.proposal_slot`.
if (chain.seenProposerPreferences.isKnown(proposalSlot, validatorIndex)) {
throw new ProposerPreferencesError(GossipAction.IGNORE, {
code: ProposerPreferencesErrorCode.ALREADY_KNOWN,
proposalSlot,
validatorIndex,
});
}

// [REJECT] `signed_proposer_preferences.signature` is valid with respect to the validator's public key.
const signatureSet = createSingleSignatureSetFromComponents(
chain.pubkeyCache.getOrThrow(validatorIndex),
getProposerPreferencesSigningRoot(chain.config, preferences),
signedProposerPreferences.signature
);

if (!(await chain.bls.verifySignatureSets([signatureSet], {batchable: true}))) {
throw new ProposerPreferencesError(GossipAction.REJECT, {
code: ProposerPreferencesErrorCode.INVALID_SIGNATURE,
proposalSlot,
validatorIndex,
});
}

// Valid
chain.seenProposerPreferences.add(proposalSlot, validatorIndex);
}
6 changes: 6 additions & 0 deletions packages/beacon-node/src/network/gossip/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export enum GossipType {
execution_payload = "execution_payload",
payload_attestation_message = "payload_attestation_message",
execution_payload_bid = "execution_payload_bid",
proposer_preferences = "proposer_preferences",
}

export type SequentialGossipType = Exclude<GossipType, GossipType.beacon_attestation>;
Expand Down Expand Up @@ -78,6 +79,7 @@ export type GossipTopicTypeMap = {
[GossipType.execution_payload]: {type: GossipType.execution_payload};
[GossipType.payload_attestation_message]: {type: GossipType.payload_attestation_message};
[GossipType.execution_payload_bid]: {type: GossipType.execution_payload_bid};
[GossipType.proposer_preferences]: {type: GossipType.proposer_preferences};
};

export type GossipTopicMap = {
Expand Down Expand Up @@ -110,6 +112,7 @@ export type GossipTypeMap = {
[GossipType.execution_payload]: gloas.SignedExecutionPayloadEnvelope;
[GossipType.payload_attestation_message]: gloas.PayloadAttestationMessage;
[GossipType.execution_payload_bid]: gloas.SignedExecutionPayloadBid;
[GossipType.proposer_preferences]: gloas.SignedProposerPreferences;
};

export type GossipFnByType = {
Expand Down Expand Up @@ -141,6 +144,9 @@ export type GossipFnByType = {
payloadAttestationMessage: gloas.PayloadAttestationMessage
) => Promise<void> | void;
[GossipType.execution_payload_bid]: (executionPayloadBid: gloas.SignedExecutionPayloadBid) => Promise<void> | void;
[GossipType.proposer_preferences]: (
signedProposerPreferences: gloas.SignedProposerPreferences
) => Promise<void> | void;
};

export type GossipFn = GossipFnByType[keyof GossipFnByType];
Expand Down
15 changes: 14 additions & 1 deletion packages/beacon-node/src/network/gossip/scoringParameters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const BLS_TO_EXECUTION_CHANGE_WEIGHT = 0.05;
const EXECUTION_PAYLOAD_WEIGHT = 0.5;
const PAYLOAD_ATTESTATION_WEIGHT = 0.05;
const EXECUTION_PAYLOAD_BID_WEIGHT = 0.05;
const PROPOSER_PREFERENCES_WEIGHT = 0.05;

const beaconAttestationSubnetWeight = 1 / ATTESTATION_SUBNET_COUNT;
const maxPositiveScore =
Expand All @@ -40,7 +41,8 @@ const maxPositiveScore =
BLS_TO_EXECUTION_CHANGE_WEIGHT +
EXECUTION_PAYLOAD_WEIGHT +
PAYLOAD_ATTESTATION_WEIGHT +
EXECUTION_PAYLOAD_BID_WEIGHT);
EXECUTION_PAYLOAD_BID_WEIGHT +
PROPOSER_PREFERENCES_WEIGHT);

/**
* The following params is implemented by Lighthouse at
Expand Down Expand Up @@ -198,6 +200,17 @@ function getAllTopicsScoreParams(
expectedMessageRate: 1024, // TODO GLOAS: Need an estimate for this
firstMessageDecayTime: epochDurationMs * 100,
});
topicsParams[
stringifyGossipTopic(config, {
type: GossipType.proposer_preferences,
boundary,
})
] = getTopicScoreParams(config, precomputedParams, {
topicWeight: PROPOSER_PREFERENCES_WEIGHT,
// Upper bound ~64 messages per epoch: one per proposer across the current + next epoch.
expectedMessageRate: 64 / SLOTS_PER_EPOCH,

@nflaig nflaig Apr 25, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

could be more if there is a epoch boundary reorg which causes dependent root to change but this also depends on how clients will broadcast these, either way this seems fine

firstMessageDecayTime: epochDurationMs * 100,
});

// other topics
topicsParams[
Expand Down
6 changes: 6 additions & 0 deletions packages/beacon-node/src/network/gossip/topic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ function stringifyGossipTopicType(topic: GossipTopic): string {
case GossipType.execution_payload:
case GossipType.payload_attestation_message:
case GossipType.execution_payload_bid:
case GossipType.proposer_preferences:
return topic.type;
case GossipType.beacon_attestation:
case GossipType.sync_committee:
Expand Down Expand Up @@ -124,6 +125,8 @@ export function getGossipSSZType(topic: GossipTopic) {
return ssz.gloas.PayloadAttestationMessage;
case GossipType.execution_payload_bid:
return ssz.gloas.SignedExecutionPayloadBid;
case GossipType.proposer_preferences:
return ssz.gloas.SignedProposerPreferences;
}
}

Expand Down Expand Up @@ -203,6 +206,7 @@ export function parseGossipTopic(forkDigestContext: ForkDigestContext, topicStr:
case GossipType.execution_payload:
case GossipType.payload_attestation_message:
case GossipType.execution_payload_bid:
case GossipType.proposer_preferences:
return {type: gossipTypeStr, boundary, encoding};
}

Expand Down Expand Up @@ -257,6 +261,7 @@ export function getCoreTopicsAtFork(
topics.push({type: GossipType.execution_payload});
topics.push({type: GossipType.payload_attestation_message});
topics.push({type: GossipType.execution_payload_bid});
topics.push({type: GossipType.proposer_preferences});
}

// After fulu also track data_column_sidecar_{index}
Expand Down Expand Up @@ -351,4 +356,5 @@ export const gossipTopicIgnoreDuplicatePublishError: Record<GossipType, boolean>
[GossipType.execution_payload]: true,
[GossipType.payload_attestation_message]: true,
[GossipType.execution_payload_bid]: true,
[GossipType.proposer_preferences]: true,
};
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import {
import {validateLightClientFinalityUpdate} from "../../chain/validation/lightClientFinalityUpdate.js";
import {validateLightClientOptimisticUpdate} from "../../chain/validation/lightClientOptimisticUpdate.js";
import {validateGossipPayloadAttestationMessage} from "../../chain/validation/payloadAttestationMessage.js";
import {validateGossipProposerPreferences} from "../../chain/validation/proposerPreferences.js";
import {OpSource} from "../../chain/validatorMonitor.js";
import {Metrics} from "../../metrics/index.js";
import {kzgCommitmentToVersionedHash} from "../../util/blobs.js";
Expand Down Expand Up @@ -1170,6 +1171,14 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand
data: executionPayloadBid,
});
},
[GossipType.proposer_preferences]: async ({
gossipData,
topic,
}: GossipHandlerParamGeneric<GossipType.proposer_preferences>) => {
const {serializedData} = gossipData;
const signedProposerPreferences = sszDeserialize(topic, serializedData);
await validateGossipProposerPreferences(chain, signedProposerPreferences);
},
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ const linearGossipQueueOpts: {
type: QueueType.FIFO,
dropOpts: {type: DropType.count, count: 1},
},
[GossipType.proposer_preferences]: {
maxLength: 1024,

@nflaig nflaig Apr 25, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

this queue is likely way too long but not sure that really matters much

type: QueueType.FIFO,
dropOpts: {type: DropType.count, count: 1},
},
};

const indexedGossipQueueOpts: {
Expand Down
1 change: 1 addition & 0 deletions packages/beacon-node/src/network/processor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ const executeGossipWorkOrderObj: Record<GossipType, WorkOpts> = {
[GossipType.execution_payload]: {bypassQueue: true},
[GossipType.payload_attestation_message]: {},
[GossipType.execution_payload_bid]: {},
[GossipType.proposer_preferences]: {},
};
const executeGossipWorkOrder = Object.keys(executeGossipWorkOrderObj) as (keyof typeof executeGossipWorkOrderObj)[];

Expand Down
Loading
Loading