From d817b4893ff3bcd83f2845040fabc28682651851 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 24 Apr 2026 22:53:54 +0100 Subject: [PATCH 1/5] feat: wire up proposer_preferences gossip topic --- packages/beacon-node/src/chain/chain.ts | 3 + .../beacon-node/src/chain/errors/index.ts | 1 + .../src/chain/errors/proposerPreferences.ts | 39 +++++++ packages/beacon-node/src/chain/interface.ts | 2 + .../beacon-node/src/chain/regen/interface.ts | 1 + .../beacon-node/src/chain/seenCache/index.ts | 1 + .../seenCache/seenProposerPreferences.ts | 41 +++++++ .../chain/validation/proposerPreferences.ts | 109 ++++++++++++++++++ .../src/network/gossip/interface.ts | 6 + .../src/network/gossip/scoringParameters.ts | 15 ++- .../beacon-node/src/network/gossip/topic.ts | 6 + .../network/processor/extractSlotRootFns.ts | 10 ++ .../src/network/processor/gossipHandlers.ts | 9 ++ .../network/processor/gossipQueues/index.ts | 7 ++ .../src/network/processor/index.ts | 1 + packages/beacon-node/src/util/sszBytes.ts | 20 ++++ .../test/unit/network/gossip/topic.test.ts | 10 ++ .../src/signatureSets/index.ts | 1 + .../src/signatureSets/proposerPreferences.ts | 12 ++ 19 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 packages/beacon-node/src/chain/errors/proposerPreferences.ts create mode 100644 packages/beacon-node/src/chain/seenCache/seenProposerPreferences.ts create mode 100644 packages/beacon-node/src/chain/validation/proposerPreferences.ts create mode 100644 packages/state-transition/src/signatureSets/proposerPreferences.ts diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 35b79c711485..731e6b615d81 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -106,6 +106,7 @@ import { SeenExecutionPayloadBids, SeenPayloadAttesters, SeenPayloadEnvelopeInput, + SeenProposerPreferences, SeenSyncCommitteeMessages, } from "./seenCache/index.js"; import {SeenAggregatedAttestations} from "./seenCache/seenAggregateAndProof.js"; @@ -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; @@ -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); diff --git a/packages/beacon-node/src/chain/errors/index.ts b/packages/beacon-node/src/chain/errors/index.ts index 106d61021213..3725af0cb421 100644 --- a/packages/beacon-node/src/chain/errors/index.ts +++ b/packages/beacon-node/src/chain/errors/index.ts @@ -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"; diff --git a/packages/beacon-node/src/chain/errors/proposerPreferences.ts b/packages/beacon-node/src/chain/errors/proposerPreferences.ts new file mode 100644 index 000000000000..17b8bf6e6799 --- /dev/null +++ b/packages/beacon-node/src/chain/errors/proposerPreferences.ts @@ -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; + stateSlot: 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 {} diff --git a/packages/beacon-node/src/chain/interface.ts b/packages/beacon-node/src/chain/interface.ts index 722461949e56..b13304a8a08f 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -61,6 +61,7 @@ import { SeenContributionAndProof, SeenExecutionPayloadBids, SeenPayloadAttesters, + SeenProposerPreferences, SeenSyncCommitteeMessages, } from "./seenCache/index.js"; import {SeenAggregatedAttestations} from "./seenCache/seenAggregateAndProof.js"; @@ -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; diff --git a/packages/beacon-node/src/chain/regen/interface.ts b/packages/beacon-node/src/chain/regen/interface.ts index e2f91819de62..b70844f0fb59 100644 --- a/packages/beacon-node/src/chain/regen/interface.ts +++ b/packages/beacon-node/src/chain/regen/interface.ts @@ -21,6 +21,7 @@ export enum RegenCaller { validateGossipAttestation = "validateGossipAttestation", validateGossipVoluntaryExit = "validateGossipVoluntaryExit", validateGossipExecutionPayloadBid = "validateGossipExecutionPayloadBid", + validateGossipProposerPreferences = "validateGossipProposerPreferences", onForkChoiceFinalized = "onForkChoiceFinalized", restApi = "restApi", } diff --git a/packages/beacon-node/src/chain/seenCache/index.ts b/packages/beacon-node/src/chain/seenCache/index.ts index 23782e14385b..08d750090dba 100644 --- a/packages/beacon-node/src/chain/seenCache/index.ts +++ b/packages/beacon-node/src/chain/seenCache/index.ts @@ -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"; diff --git a/packages/beacon-node/src/chain/seenCache/seenProposerPreferences.ts b/packages/beacon-node/src/chain/seenCache/seenProposerPreferences.ts new file mode 100644 index 000000000000..c28ef09021e5 --- /dev/null +++ b/packages/beacon-node/src/chain/seenCache/seenProposerPreferences.ts @@ -0,0 +1,41 @@ +import {Slot, ValidatorIndex} from "@lodestar/types"; +import {MapDef} from "@lodestar/utils"; + +/** + * Proposer preferences are valid for the current or next epoch, so keep a small + * buffer of past slots to protect against clock skew around slot boundaries. + */ +const SLOTS_RETAINED = 2; + +/** + * Tracks signed proposer preferences we've already seen per (proposal_slot, validator_index). + * + * Enforces the gossip rule: + * [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`. + */ +export class SeenProposerPreferences { + private readonly validatorIndexesBySlot = new MapDef>(() => new Set()); + private lowestPermissibleSlot: Slot = 0; + + isKnown(proposalSlot: Slot, validatorIndex: ValidatorIndex): boolean { + return this.validatorIndexesBySlot.get(proposalSlot)?.has(validatorIndex) === true; + } + + add(proposalSlot: Slot, validatorIndex: ValidatorIndex): void { + if (proposalSlot < this.lowestPermissibleSlot) { + throw Error(`proposalSlot ${proposalSlot} < lowestPermissibleSlot ${this.lowestPermissibleSlot}`); + } + this.validatorIndexesBySlot.getOrDefault(proposalSlot).add(validatorIndex); + } + + prune(currentSlot: Slot): void { + this.lowestPermissibleSlot = Math.max(currentSlot - SLOTS_RETAINED, 0); + for (const slot of this.validatorIndexesBySlot.keys()) { + if (slot < this.lowestPermissibleSlot) { + this.validatorIndexesBySlot.delete(slot); + } + } + } +} diff --git a/packages/beacon-node/src/chain/validation/proposerPreferences.ts b/packages/beacon-node/src/chain/validation/proposerPreferences.ts new file mode 100644 index 000000000000..0c1810d93678 --- /dev/null +++ b/packages/beacon-node/src/chain/validation/proposerPreferences.ts @@ -0,0 +1,109 @@ +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"; + +export async function validateApiProposerPreferences( + chain: IBeaconChain, + signedProposerPreferences: gloas.SignedProposerPreferences +): Promise { + const prioritizeBls = true; + return validateProposerPreferences(chain, signedProposerPreferences, prioritizeBls); +} + +export async function validateGossipProposerPreferences( + chain: IBeaconChain, + signedProposerPreferences: gloas.SignedProposerPreferences +): Promise { + return validateProposerPreferences(chain, signedProposerPreferences); +} + +async function validateProposerPreferences( + chain: IBeaconChain, + signedProposerPreferences: gloas.SignedProposerPreferences, + prioritizeBls = false +): Promise { + const preferences = signedProposerPreferences.message; + const {proposalSlot, validatorIndex} = preferences; + const proposalEpoch = computeEpochAtSlot(proposalSlot); + + const state = await chain.getHeadStateAtCurrentEpoch(RegenCaller.validateGossipProposerPreferences); + if (!isStatePostGloas(state)) { + throw new Error(`Expected gloas+ state for proposer preferences validation, got fork=${state.forkName}`); + } + const currentEpoch = state.epoch; + + // [IGNORE] `preferences.proposal_slot` is in the current or next epoch. + 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. + if (proposalSlot <= state.slot) { + throw new ProposerPreferencesError(GossipAction.IGNORE, { + code: ProposerPreferencesErrorCode.PROPOSAL_SLOT_PASSED, + proposalSlot, + stateSlot: state.slot, + }); + } + + // [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 proposers = proposalEpoch === currentEpoch ? state.currentProposers : state.nextProposers; + const expectedProposer = proposers[proposalSlot % SLOTS_PER_EPOCH]; + if (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 validatorPubkey = chain.pubkeyCache.get(validatorIndex); + if (!validatorPubkey) { + throw new ProposerPreferencesError(GossipAction.REJECT, { + code: ProposerPreferencesErrorCode.INVALID_SIGNATURE, + proposalSlot, + validatorIndex, + }); + } + + const signatureSet = createSingleSignatureSetFromComponents( + validatorPubkey, + getProposerPreferencesSigningRoot(chain.config, preferences), + signedProposerPreferences.signature + ); + + if (!(await chain.bls.verifySignatureSets([signatureSet], {batchable: true, priority: prioritizeBls}))) { + throw new ProposerPreferencesError(GossipAction.REJECT, { + code: ProposerPreferencesErrorCode.INVALID_SIGNATURE, + proposalSlot, + validatorIndex, + }); + } + + // Valid + chain.seenProposerPreferences.add(proposalSlot, validatorIndex); +} diff --git a/packages/beacon-node/src/network/gossip/interface.ts b/packages/beacon-node/src/network/gossip/interface.ts index 76584a0ebc51..f981df5e7f6e 100644 --- a/packages/beacon-node/src/network/gossip/interface.ts +++ b/packages/beacon-node/src/network/gossip/interface.ts @@ -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; @@ -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 = { @@ -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 = { @@ -141,6 +144,9 @@ export type GossipFnByType = { payloadAttestationMessage: gloas.PayloadAttestationMessage ) => Promise | void; [GossipType.execution_payload_bid]: (executionPayloadBid: gloas.SignedExecutionPayloadBid) => Promise | void; + [GossipType.proposer_preferences]: ( + signedProposerPreferences: gloas.SignedProposerPreferences + ) => Promise | void; }; export type GossipFn = GossipFnByType[keyof GossipFnByType]; diff --git a/packages/beacon-node/src/network/gossip/scoringParameters.ts b/packages/beacon-node/src/network/gossip/scoringParameters.ts index 669d188ae8c9..44a6c06f30a1 100644 --- a/packages/beacon-node/src/network/gossip/scoringParameters.ts +++ b/packages/beacon-node/src/network/gossip/scoringParameters.ts @@ -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 = @@ -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 @@ -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, + // One signed preferences message per proposer slot (current + next epoch) + expectedMessageRate: 2, + firstMessageDecayTime: epochDurationMs * 100, + }); // other topics topicsParams[ diff --git a/packages/beacon-node/src/network/gossip/topic.ts b/packages/beacon-node/src/network/gossip/topic.ts index e01343d6bbf5..2ad9b5173a65 100644 --- a/packages/beacon-node/src/network/gossip/topic.ts +++ b/packages/beacon-node/src/network/gossip/topic.ts @@ -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: @@ -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; } } @@ -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}; } @@ -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} @@ -351,4 +356,5 @@ export const gossipTopicIgnoreDuplicatePublishError: Record [GossipType.execution_payload]: true, [GossipType.payload_attestation_message]: true, [GossipType.execution_payload_bid]: true, + [GossipType.proposer_preferences]: true, }; diff --git a/packages/beacon-node/src/network/processor/extractSlotRootFns.ts b/packages/beacon-node/src/network/processor/extractSlotRootFns.ts index 9ebc959e07e6..c3e4d183abfa 100644 --- a/packages/beacon-node/src/network/processor/extractSlotRootFns.ts +++ b/packages/beacon-node/src/network/processor/extractSlotRootFns.ts @@ -13,6 +13,7 @@ import { getSlotFromSignedAggregateAndProofSerialized, getSlotFromSignedBeaconBlockSerialized, getSlotFromSignedExecutionPayloadBidSerialized, + getSlotFromSignedProposerPreferencesSerialized, } from "../../util/sszBytes.js"; import {GossipType} from "../gossip/index.js"; import {ExtractSlotRootFns} from "./types.js"; @@ -97,6 +98,15 @@ export function createExtractBlockSlotRootFns(): ExtractSlotRootFns { return null; } + return {slot}; + }, + [GossipType.proposer_preferences]: (data: Uint8Array): SlotOptionalRoot | null => { + const slot = getSlotFromSignedProposerPreferencesSerialized(data); + + if (slot === null) { + return null; + } + return {slot}; }, }; diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index b9607e77f9d5..28a8b0388f81 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -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"; @@ -1170,6 +1171,14 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand data: executionPayloadBid, }); }, + [GossipType.proposer_preferences]: async ({ + gossipData, + topic, + }: GossipHandlerParamGeneric) => { + const {serializedData} = gossipData; + const signedProposerPreferences = sszDeserialize(topic, serializedData); + await validateGossipProposerPreferences(chain, signedProposerPreferences); + }, }; } diff --git a/packages/beacon-node/src/network/processor/gossipQueues/index.ts b/packages/beacon-node/src/network/processor/gossipQueues/index.ts index 5f4200b9eab9..14248876f386 100644 --- a/packages/beacon-node/src/network/processor/gossipQueues/index.ts +++ b/packages/beacon-node/src/network/processor/gossipQueues/index.ts @@ -83,6 +83,13 @@ const linearGossipQueueOpts: { type: QueueType.FIFO, dropOpts: {type: DropType.count, count: 1}, }, + // Upper bound: one message per proposer per (current + next) epoch, so 2 * SLOTS_PER_EPOCH = 64. + // Keep some slack for retransmits and late arrivals. + [GossipType.proposer_preferences]: { + maxLength: 1024, + type: QueueType.FIFO, + dropOpts: {type: DropType.count, count: 1}, + }, }; const indexedGossipQueueOpts: { diff --git a/packages/beacon-node/src/network/processor/index.ts b/packages/beacon-node/src/network/processor/index.ts index 8e51e35d95d2..d67890cf647d 100644 --- a/packages/beacon-node/src/network/processor/index.ts +++ b/packages/beacon-node/src/network/processor/index.ts @@ -91,6 +91,7 @@ const executeGossipWorkOrderObj: Record = { [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)[]; diff --git a/packages/beacon-node/src/util/sszBytes.ts b/packages/beacon-node/src/util/sszBytes.ts index fa9bf569577a..2a1703f73b59 100644 --- a/packages/beacon-node/src/util/sszBytes.ts +++ b/packages/beacon-node/src/util/sszBytes.ts @@ -747,6 +747,26 @@ export function getParentBlockRootFromSignedExecutionPayloadBidSerialized(data: return `0x${blockRootBuf.toString("hex")}`; } +/** + * SignedProposerPreferences: {message: ProposerPreferences (fixed), signature: BLSSignature (96 bytes)} + * + * ProposerPreferences fixed fields (in order): + * proposalSlot: Slot (8 bytes) ← absolute offset 0 + * validatorIndex: ValidatorIndex (8 bytes) + * feeRecipient: ExecutionAddress (20 bytes) + * gasLimit: uint64 (8 bytes) + * Since all fields are fixed-size, the message is embedded inline without a variable offset. + */ +const SIGNED_PROPOSER_PREFERENCES_SLOT_OFFSET = 0; +const SIGNED_PROPOSER_PREFERENCES_MIN_SIZE = 8 + 8 + 20 + 8 + SIGNATURE_SIZE; + +export function getSlotFromSignedProposerPreferencesSerialized(data: Uint8Array): Slot | null { + if (data.length < SIGNED_PROPOSER_PREFERENCES_MIN_SIZE) { + return null; + } + return getSlotFromOffset(data, SIGNED_PROPOSER_PREFERENCES_SLOT_OFFSET); +} + /** * Read only the first 4 bytes of Slot, max value is 4,294,967,295 will be reached 1634 years after genesis * diff --git a/packages/beacon-node/test/unit/network/gossip/topic.test.ts b/packages/beacon-node/test/unit/network/gossip/topic.test.ts index 7e9b3f6ddd46..5caa4be1802e 100644 --- a/packages/beacon-node/test/unit/network/gossip/topic.test.ts +++ b/packages/beacon-node/test/unit/network/gossip/topic.test.ts @@ -167,6 +167,16 @@ describe("network / gossip / topic", () => { topicStr: "/eth2/a41d57bd/execution_payload_bid/ssz_snappy", }, ], + [GossipType.proposer_preferences]: [ + { + topic: { + type: GossipType.proposer_preferences, + boundary: {fork: ForkName.gloas, epoch: config.GLOAS_FORK_EPOCH}, + encoding, + }, + topicStr: "/eth2/a41d57bd/proposer_preferences/ssz_snappy", + }, + ], }; for (const topics of Object.values(testCases)) { diff --git a/packages/state-transition/src/signatureSets/index.ts b/packages/state-transition/src/signatureSets/index.ts index d76ac2f10f07..67ffbc6ef3f3 100644 --- a/packages/state-transition/src/signatureSets/index.ts +++ b/packages/state-transition/src/signatureSets/index.ts @@ -20,6 +20,7 @@ export * from "./executionPayloadEnvelope.js"; export * from "./indexedAttestation.js"; export * from "./indexedPayloadAttestation.js"; export * from "./proposer.js"; +export * from "./proposerPreferences.js"; export * from "./proposerSlashings.js"; export * from "./randao.js"; export * from "./voluntaryExits.js"; diff --git a/packages/state-transition/src/signatureSets/proposerPreferences.ts b/packages/state-transition/src/signatureSets/proposerPreferences.ts new file mode 100644 index 000000000000..667abbd67dfc --- /dev/null +++ b/packages/state-transition/src/signatureSets/proposerPreferences.ts @@ -0,0 +1,12 @@ +import {BeaconConfig} from "@lodestar/config"; +import {DOMAIN_PROPOSER_PREFERENCES} from "@lodestar/params"; +import {gloas, ssz} from "@lodestar/types"; +import {computeSigningRoot} from "../util/index.js"; + +export function getProposerPreferencesSigningRoot( + config: BeaconConfig, + preferences: gloas.ProposerPreferences +): Uint8Array { + const domain = config.getDomain(preferences.proposalSlot, DOMAIN_PROPOSER_PREFERENCES); + return computeSigningRoot(ssz.gloas.ProposerPreferences, preferences, domain); +} From b37696ff973c2c8887b5aa8bde0eccd9a1c7752e Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 24 Apr 2026 23:18:52 +0100 Subject: [PATCH 2/5] review --- .../src/chain/errors/proposerPreferences.ts | 2 +- .../seenCache/seenProposerPreferences.ts | 20 ++-- .../chain/validation/proposerPreferences.ts | 101 +++++++++--------- .../src/network/gossip/scoringParameters.ts | 6 +- .../network/processor/extractSlotRootFns.ts | 10 -- .../network/processor/gossipQueues/index.ts | 2 - packages/beacon-node/src/util/sszBytes.ts | 20 ---- 7 files changed, 63 insertions(+), 98 deletions(-) diff --git a/packages/beacon-node/src/chain/errors/proposerPreferences.ts b/packages/beacon-node/src/chain/errors/proposerPreferences.ts index 17b8bf6e6799..3e63966da850 100644 --- a/packages/beacon-node/src/chain/errors/proposerPreferences.ts +++ b/packages/beacon-node/src/chain/errors/proposerPreferences.ts @@ -18,7 +18,7 @@ export type ProposerPreferencesErrorType = | { code: ProposerPreferencesErrorCode.PROPOSAL_SLOT_PASSED; proposalSlot: Slot; - stateSlot: Slot; + currentSlot: Slot; } | { code: ProposerPreferencesErrorCode.INVALID_PROPOSER; diff --git a/packages/beacon-node/src/chain/seenCache/seenProposerPreferences.ts b/packages/beacon-node/src/chain/seenCache/seenProposerPreferences.ts index c28ef09021e5..00ff72bc1bdb 100644 --- a/packages/beacon-node/src/chain/seenCache/seenProposerPreferences.ts +++ b/packages/beacon-node/src/chain/seenCache/seenProposerPreferences.ts @@ -1,12 +1,6 @@ import {Slot, ValidatorIndex} from "@lodestar/types"; import {MapDef} from "@lodestar/utils"; -/** - * Proposer preferences are valid for the current or next epoch, so keep a small - * buffer of past slots to protect against clock skew around slot boundaries. - */ -const SLOTS_RETAINED = 2; - /** * Tracks signed proposer preferences we've already seen per (proposal_slot, validator_index). * @@ -14,26 +8,28 @@ const SLOTS_RETAINED = 2; * [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`. + * + * Entries are only load-bearing while `proposal_slot > state.slot`. Once the slot + * has passed, the `[IGNORE] preferences.proposal_slot > state.slot` rule takes over + * and this cache is no longer consulted for that slot. */ export class SeenProposerPreferences { private readonly validatorIndexesBySlot = new MapDef>(() => new Set()); - private lowestPermissibleSlot: Slot = 0; isKnown(proposalSlot: Slot, validatorIndex: ValidatorIndex): boolean { return this.validatorIndexesBySlot.get(proposalSlot)?.has(validatorIndex) === true; } add(proposalSlot: Slot, validatorIndex: ValidatorIndex): void { - if (proposalSlot < this.lowestPermissibleSlot) { - throw Error(`proposalSlot ${proposalSlot} < lowestPermissibleSlot ${this.lowestPermissibleSlot}`); - } this.validatorIndexesBySlot.getOrDefault(proposalSlot).add(validatorIndex); } + /** + * Drop entries for slots that have already passed. Called on clock slot tick. + */ prune(currentSlot: Slot): void { - this.lowestPermissibleSlot = Math.max(currentSlot - SLOTS_RETAINED, 0); for (const slot of this.validatorIndexesBySlot.keys()) { - if (slot < this.lowestPermissibleSlot) { + if (slot < currentSlot) { this.validatorIndexesBySlot.delete(slot); } } diff --git a/packages/beacon-node/src/chain/validation/proposerPreferences.ts b/packages/beacon-node/src/chain/validation/proposerPreferences.ts index 0c1810d93678..f7d459b2a36c 100644 --- a/packages/beacon-node/src/chain/validation/proposerPreferences.ts +++ b/packages/beacon-node/src/chain/validation/proposerPreferences.ts @@ -10,93 +10,92 @@ import {GossipAction, ProposerPreferencesError, ProposerPreferencesErrorCode} fr import {IBeaconChain} from "../index.js"; import {RegenCaller} from "../regen/index.js"; -export async function validateApiProposerPreferences( - chain: IBeaconChain, - signedProposerPreferences: gloas.SignedProposerPreferences -): Promise { - const prioritizeBls = true; - return validateProposerPreferences(chain, signedProposerPreferences, prioritizeBls); -} - +/** + * Validates a gossiped `SignedProposerPreferences` per + * https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/p2p-interface.md#proposer_preferences + * + * Spec rules (checked below in a slightly different order for performance): + * [IGNORE] proposal_slot epoch in [current, current+1] + * [IGNORE] proposal_slot > state.slot + * [REJECT] is_valid_proposal_slot(state, preferences) + * [IGNORE] first valid message for (validator_index, proposal_slot) + * [REJECT] signature valid w.r.t. validator pubkey + */ export async function validateGossipProposerPreferences( chain: IBeaconChain, signedProposerPreferences: gloas.SignedProposerPreferences -): Promise { - return validateProposerPreferences(chain, signedProposerPreferences); -} - -async function validateProposerPreferences( - chain: IBeaconChain, - signedProposerPreferences: gloas.SignedProposerPreferences, - prioritizeBls = false ): Promise { const preferences = signedProposerPreferences.message; const {proposalSlot, validatorIndex} = preferences; - const proposalEpoch = computeEpochAtSlot(proposalSlot); - const state = await chain.getHeadStateAtCurrentEpoch(RegenCaller.validateGossipProposerPreferences); - if (!isStatePostGloas(state)) { - throw new Error(`Expected gloas+ state for proposer preferences validation, got fork=${state.forkName}`); - } - const currentEpoch = state.epoch; - - // [IGNORE] `preferences.proposal_slot` is in the current or next epoch. - if (proposalEpoch < currentEpoch || proposalEpoch > currentEpoch + 1) { + // [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`. + // Spec lists this fourth, but checking it first lets us early-exit on duplicate gossip without + // paying for the state fetch and signature verification below. + if (chain.seenProposerPreferences.isKnown(proposalSlot, validatorIndex)) { throw new ProposerPreferencesError(GossipAction.IGNORE, { - code: ProposerPreferencesErrorCode.INVALID_EPOCH, + code: ProposerPreferencesErrorCode.ALREADY_KNOWN, proposalSlot, - currentEpoch, + validatorIndex, }); } - // [IGNORE] `preferences.proposal_slot` has not already passed. - if (proposalSlot <= state.slot) { + // [IGNORE] `preferences.proposal_slot` has not already passed — i.e. `proposal_slot > state.slot`. + // Spec uses `state.slot`, but `state.slot` can lag the wall clock during skipped slots or while + // catching up. Use the node clock for gossip freshness (matches the pattern used by other gossip + // validators in this repo, e.g. executionPayloadBid). + const currentSlot = chain.clock.currentSlot; + if (proposalSlot <= currentSlot) { throw new ProposerPreferencesError(GossipAction.IGNORE, { code: ProposerPreferencesErrorCode.PROPOSAL_SLOT_PASSED, proposalSlot, - stateSlot: state.slot, + currentSlot, }); } - // [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 proposers = proposalEpoch === currentEpoch ? state.currentProposers : state.nextProposers; - const expectedProposer = proposers[proposalSlot % SLOTS_PER_EPOCH]; - if (expectedProposer !== validatorIndex) { - throw new ProposerPreferencesError(GossipAction.REJECT, { - code: ProposerPreferencesErrorCode.INVALID_PROPOSER, - proposalSlot, - validatorIndex, - }); + // [IGNORE] `preferences.proposal_slot` is in the current or next epoch — i.e. + // `compute_epoch_at_slot(proposal_slot)` is in `[current_epoch, current_epoch + 1]`. + // The lower bound is already enforced by the slot-passed check above (proposalSlot > currentSlot + // implies proposalEpoch >= currentEpoch). The upper bound is checked against state.epoch after the + // state fetch below — state is the authoritative source for "current_epoch" in the spec rule. + const proposalEpoch = computeEpochAtSlot(proposalSlot); + + const state = await chain.getHeadStateAtCurrentEpoch(RegenCaller.validateGossipProposerPreferences); + if (!isStatePostGloas(state)) { + throw new Error(`Expected gloas+ state for proposer preferences validation, got fork=${state.forkName}`); } - // [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)) { + const epochOffset = proposalEpoch - state.epoch; + if (epochOffset < 0 || epochOffset > 1) { throw new ProposerPreferencesError(GossipAction.IGNORE, { - code: ProposerPreferencesErrorCode.ALREADY_KNOWN, + code: ProposerPreferencesErrorCode.INVALID_EPOCH, proposalSlot, - validatorIndex, + currentEpoch: state.epoch, }); } - // [REJECT] `signed_proposer_preferences.signature` is valid with respect to the validator's public key. - const validatorPubkey = chain.pubkeyCache.get(validatorIndex); - if (!validatorPubkey) { + // [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 proposers = epochOffset === 0 ? state.currentProposers : state.nextProposers; + const expectedProposer = proposers[proposalSlot % SLOTS_PER_EPOCH]; + if (expectedProposer !== validatorIndex) { throw new ProposerPreferencesError(GossipAction.REJECT, { - code: ProposerPreferencesErrorCode.INVALID_SIGNATURE, + code: ProposerPreferencesErrorCode.INVALID_PROPOSER, proposalSlot, validatorIndex, }); } + // [REJECT] `signed_proposer_preferences.signature` is valid with respect to the validator's public key. + // The validator is guaranteed to exist: it appears in `state.proposer_lookahead` (checked above). const signatureSet = createSingleSignatureSetFromComponents( - validatorPubkey, + chain.pubkeyCache.getOrThrow(validatorIndex), getProposerPreferencesSigningRoot(chain.config, preferences), signedProposerPreferences.signature ); - if (!(await chain.bls.verifySignatureSets([signatureSet], {batchable: true, priority: prioritizeBls}))) { + if (!(await chain.bls.verifySignatureSets([signatureSet], {batchable: true}))) { throw new ProposerPreferencesError(GossipAction.REJECT, { code: ProposerPreferencesErrorCode.INVALID_SIGNATURE, proposalSlot, diff --git a/packages/beacon-node/src/network/gossip/scoringParameters.ts b/packages/beacon-node/src/network/gossip/scoringParameters.ts index 44a6c06f30a1..893678bad66e 100644 --- a/packages/beacon-node/src/network/gossip/scoringParameters.ts +++ b/packages/beacon-node/src/network/gossip/scoringParameters.ts @@ -207,8 +207,10 @@ function getAllTopicsScoreParams( }) ] = getTopicScoreParams(config, precomputedParams, { topicWeight: PROPOSER_PREFERENCES_WEIGHT, - // One signed preferences message per proposer slot (current + next epoch) - expectedMessageRate: 2, + // One message per proposer per proposal slot (first-valid dedup), with proposers visible for + // current + next epoch: upper bound ~64 messages per epoch. expectedMessageRate is a per-slot + // rate (cf. PTC_SIZE used by payload_attestation_message). + expectedMessageRate: 64 / SLOTS_PER_EPOCH, firstMessageDecayTime: epochDurationMs * 100, }); diff --git a/packages/beacon-node/src/network/processor/extractSlotRootFns.ts b/packages/beacon-node/src/network/processor/extractSlotRootFns.ts index c3e4d183abfa..9ebc959e07e6 100644 --- a/packages/beacon-node/src/network/processor/extractSlotRootFns.ts +++ b/packages/beacon-node/src/network/processor/extractSlotRootFns.ts @@ -13,7 +13,6 @@ import { getSlotFromSignedAggregateAndProofSerialized, getSlotFromSignedBeaconBlockSerialized, getSlotFromSignedExecutionPayloadBidSerialized, - getSlotFromSignedProposerPreferencesSerialized, } from "../../util/sszBytes.js"; import {GossipType} from "../gossip/index.js"; import {ExtractSlotRootFns} from "./types.js"; @@ -98,15 +97,6 @@ export function createExtractBlockSlotRootFns(): ExtractSlotRootFns { return null; } - return {slot}; - }, - [GossipType.proposer_preferences]: (data: Uint8Array): SlotOptionalRoot | null => { - const slot = getSlotFromSignedProposerPreferencesSerialized(data); - - if (slot === null) { - return null; - } - return {slot}; }, }; diff --git a/packages/beacon-node/src/network/processor/gossipQueues/index.ts b/packages/beacon-node/src/network/processor/gossipQueues/index.ts index 14248876f386..0c05c2ac9b52 100644 --- a/packages/beacon-node/src/network/processor/gossipQueues/index.ts +++ b/packages/beacon-node/src/network/processor/gossipQueues/index.ts @@ -83,8 +83,6 @@ const linearGossipQueueOpts: { type: QueueType.FIFO, dropOpts: {type: DropType.count, count: 1}, }, - // Upper bound: one message per proposer per (current + next) epoch, so 2 * SLOTS_PER_EPOCH = 64. - // Keep some slack for retransmits and late arrivals. [GossipType.proposer_preferences]: { maxLength: 1024, type: QueueType.FIFO, diff --git a/packages/beacon-node/src/util/sszBytes.ts b/packages/beacon-node/src/util/sszBytes.ts index 2a1703f73b59..fa9bf569577a 100644 --- a/packages/beacon-node/src/util/sszBytes.ts +++ b/packages/beacon-node/src/util/sszBytes.ts @@ -747,26 +747,6 @@ export function getParentBlockRootFromSignedExecutionPayloadBidSerialized(data: return `0x${blockRootBuf.toString("hex")}`; } -/** - * SignedProposerPreferences: {message: ProposerPreferences (fixed), signature: BLSSignature (96 bytes)} - * - * ProposerPreferences fixed fields (in order): - * proposalSlot: Slot (8 bytes) ← absolute offset 0 - * validatorIndex: ValidatorIndex (8 bytes) - * feeRecipient: ExecutionAddress (20 bytes) - * gasLimit: uint64 (8 bytes) - * Since all fields are fixed-size, the message is embedded inline without a variable offset. - */ -const SIGNED_PROPOSER_PREFERENCES_SLOT_OFFSET = 0; -const SIGNED_PROPOSER_PREFERENCES_MIN_SIZE = 8 + 8 + 20 + 8 + SIGNATURE_SIZE; - -export function getSlotFromSignedProposerPreferencesSerialized(data: Uint8Array): Slot | null { - if (data.length < SIGNED_PROPOSER_PREFERENCES_MIN_SIZE) { - return null; - } - return getSlotFromOffset(data, SIGNED_PROPOSER_PREFERENCES_SLOT_OFFSET); -} - /** * Read only the first 4 bytes of Slot, max value is 4,294,967,295 will be reached 1634 years after genesis * From f5a3f03a3085c61510db56f6c63999daa54f4ed9 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 24 Apr 2026 23:33:43 +0100 Subject: [PATCH 3/5] keep spec ordering --- .../chain/validation/proposerPreferences.ts | 57 +++++++------------ 1 file changed, 22 insertions(+), 35 deletions(-) diff --git a/packages/beacon-node/src/chain/validation/proposerPreferences.ts b/packages/beacon-node/src/chain/validation/proposerPreferences.ts index f7d459b2a36c..862dbfe20260 100644 --- a/packages/beacon-node/src/chain/validation/proposerPreferences.ts +++ b/packages/beacon-node/src/chain/validation/proposerPreferences.ts @@ -13,13 +13,6 @@ 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 - * - * Spec rules (checked below in a slightly different order for performance): - * [IGNORE] proposal_slot epoch in [current, current+1] - * [IGNORE] proposal_slot > state.slot - * [REJECT] is_valid_proposal_slot(state, preferences) - * [IGNORE] first valid message for (validator_index, proposal_slot) - * [REJECT] signature valid w.r.t. validator pubkey */ export async function validateGossipProposerPreferences( chain: IBeaconChain, @@ -27,16 +20,21 @@ export async function validateGossipProposerPreferences( ): Promise { const preferences = signedProposerPreferences.message; const {proposalSlot, validatorIndex} = preferences; + const proposalEpoch = computeEpochAtSlot(proposalSlot); - // [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`. - // Spec lists this fourth, but checking it first lets us early-exit on duplicate gossip without - // paying for the state fetch and signature verification below. - if (chain.seenProposerPreferences.isKnown(proposalSlot, validatorIndex)) { + const state = await chain.getHeadStateAtCurrentEpoch(RegenCaller.validateGossipProposerPreferences); + if (!isStatePostGloas(state)) { + throw new Error(`Expected gloas+ state for proposer preferences validation, got fork=${state.forkName}`); + } + + // [IGNORE] `preferences.proposal_slot` is in the current or next epoch — i.e. + // `compute_epoch_at_slot(preferences.proposal_slot)` is in `[current_epoch, current_epoch + 1]`. + const epochOffset = proposalEpoch - state.epoch; + if (epochOffset < 0 || epochOffset > 1) { throw new ProposerPreferencesError(GossipAction.IGNORE, { - code: ProposerPreferencesErrorCode.ALREADY_KNOWN, + code: ProposerPreferencesErrorCode.INVALID_EPOCH, proposalSlot, - validatorIndex, + currentEpoch: state.epoch, }); } @@ -53,27 +51,6 @@ export async function validateGossipProposerPreferences( }); } - // [IGNORE] `preferences.proposal_slot` is in the current or next epoch — i.e. - // `compute_epoch_at_slot(proposal_slot)` is in `[current_epoch, current_epoch + 1]`. - // The lower bound is already enforced by the slot-passed check above (proposalSlot > currentSlot - // implies proposalEpoch >= currentEpoch). The upper bound is checked against state.epoch after the - // state fetch below — state is the authoritative source for "current_epoch" in the spec rule. - const proposalEpoch = computeEpochAtSlot(proposalSlot); - - const state = await chain.getHeadStateAtCurrentEpoch(RegenCaller.validateGossipProposerPreferences); - if (!isStatePostGloas(state)) { - throw new Error(`Expected gloas+ state for proposer preferences validation, got fork=${state.forkName}`); - } - - const epochOffset = proposalEpoch - state.epoch; - if (epochOffset < 0 || epochOffset > 1) { - throw new ProposerPreferencesError(GossipAction.IGNORE, { - code: ProposerPreferencesErrorCode.INVALID_EPOCH, - proposalSlot, - currentEpoch: state.epoch, - }); - } - // [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. @@ -87,6 +64,16 @@ export async function validateGossipProposerPreferences( }); } + // [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. // The validator is guaranteed to exist: it appears in `state.proposer_lookahead` (checked above). const signatureSet = createSingleSignatureSetFromComponents( From c1e5809e224babf1bc92a8ae9030f03cd2bcfa72 Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Fri, 24 Apr 2026 23:57:13 +0100 Subject: [PATCH 4/5] review --- .../chain/validation/proposerPreferences.ts | 32 +++++++++---------- .../network/gossip/scoringParameters.test.ts | 22 ++++++------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/beacon-node/src/chain/validation/proposerPreferences.ts b/packages/beacon-node/src/chain/validation/proposerPreferences.ts index 862dbfe20260..e1a07fc4439c 100644 --- a/packages/beacon-node/src/chain/validation/proposerPreferences.ts +++ b/packages/beacon-node/src/chain/validation/proposerPreferences.ts @@ -13,6 +13,10 @@ 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 + * + * Per repo convention, freshness checks (epoch and slot) use `chain.clock` rather than the spec-literal + * `state.epoch` / `state.slot` — same as `executionPayloadBid` and `payloadAttestationMessage`. State is + * only fetched once, after the cheap clock-based gates, for the proposer-lookahead and signature checks. */ export async function validateGossipProposerPreferences( chain: IBeaconChain, @@ -22,26 +26,17 @@ export async function validateGossipProposerPreferences( const {proposalSlot, validatorIndex} = preferences; const proposalEpoch = computeEpochAtSlot(proposalSlot); - const state = await chain.getHeadStateAtCurrentEpoch(RegenCaller.validateGossipProposerPreferences); - if (!isStatePostGloas(state)) { - throw new Error(`Expected gloas+ state for proposer preferences validation, got fork=${state.forkName}`); - } - - // [IGNORE] `preferences.proposal_slot` is in the current or next epoch — i.e. - // `compute_epoch_at_slot(preferences.proposal_slot)` is in `[current_epoch, current_epoch + 1]`. - const epochOffset = proposalEpoch - state.epoch; - if (epochOffset < 0 || epochOffset > 1) { + // [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: state.epoch, + currentEpoch, }); } - // [IGNORE] `preferences.proposal_slot` has not already passed — i.e. `proposal_slot > state.slot`. - // Spec uses `state.slot`, but `state.slot` can lag the wall clock during skipped slots or while - // catching up. Use the node clock for gossip freshness (matches the pattern used by other gossip - // validators in this repo, e.g. executionPayloadBid). + // [IGNORE] `preferences.proposal_slot` has not already passed. const currentSlot = chain.clock.currentSlot; if (proposalSlot <= currentSlot) { throw new ProposerPreferencesError(GossipAction.IGNORE, { @@ -51,12 +46,18 @@ export async function validateGossipProposerPreferences( }); } + 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 (expectedProposer !== validatorIndex) { + if (epochOffset < 0 || epochOffset > 1 || expectedProposer !== validatorIndex) { throw new ProposerPreferencesError(GossipAction.REJECT, { code: ProposerPreferencesErrorCode.INVALID_PROPOSER, proposalSlot, @@ -75,7 +76,6 @@ export async function validateGossipProposerPreferences( } // [REJECT] `signed_proposer_preferences.signature` is valid with respect to the validator's public key. - // The validator is guaranteed to exist: it appears in `state.proposer_lookahead` (checked above). const signatureSet = createSingleSignatureSetFromComponents( chain.pubkeyCache.getOrThrow(validatorIndex), getProposerPreferencesSigningRoot(chain.config, preferences), diff --git a/packages/beacon-node/test/unit/network/gossip/scoringParameters.test.ts b/packages/beacon-node/test/unit/network/gossip/scoringParameters.test.ts index aa05f51a96c4..a3934a68f09e 100644 --- a/packages/beacon-node/test/unit/network/gossip/scoringParameters.test.ts +++ b/packages/beacon-node/test/unit/network/gossip/scoringParameters.test.ts @@ -80,7 +80,7 @@ describe("computeGossipPeerScoreParams", () => { expect(params.firstMessageDeliveriesWeight).closeTo(1.8407, TOLERANCE); expect(params.firstMessageDeliveriesDecay).closeTo(0.99856, TOLERANCE); expect(params.firstMessageDeliveriesCap).closeTo(21.73035, TOLERANCE); - expect(params.invalidMessageDeliveriesWeight).closeTo(-2800, TOLERANCE); + expect(params.invalidMessageDeliveriesWeight).closeTo(-2850, TOLERANCE); expect(params.invalidMessageDeliveriesDecay).closeTo(0.99713, TOLERANCE); } @@ -106,7 +106,7 @@ describe("computeGossipPeerScoreParams", () => { expect(params.firstMessageDeliveriesWeight).closeTo(36.81486, TOLERANCE); expect(params.firstMessageDeliveriesDecay).closeTo(0.998561, TOLERANCE); expect(params.firstMessageDeliveriesCap).closeTo(1.08652, TOLERANCE); - expect(params.invalidMessageDeliveriesWeight).closeTo(-2800.0, TOLERANCE); + expect(params.invalidMessageDeliveriesWeight).closeTo(-2850.0, TOLERANCE); expect(params.invalidMessageDeliveriesDecay).closeTo(0.99713, TOLERANCE); } @@ -124,7 +124,7 @@ describe("computeGossipPeerScoreParams", () => { expect(params.firstMessageDeliveriesWeight).closeTo(0.33509, TOLERANCE); expect(params.firstMessageDeliveriesDecay).closeTo(0.86596, TOLERANCE); expect(params.firstMessageDeliveriesCap).closeTo(119.3712, TOLERANCE); - expect(params.invalidMessageDeliveriesWeight).closeTo(-280.0, TOLERANCE); + expect(params.invalidMessageDeliveriesWeight).closeTo(-285.0, TOLERANCE); expect(params.invalidMessageDeliveriesDecay).closeTo(0.99713, TOLERANCE); // Check message rate penalty params @@ -132,11 +132,11 @@ describe("computeGossipPeerScoreParams", () => { expect(params.meshMessageDeliveriesCap).closeTo(68.6255, TOLERANCE); expect(params.meshMessageDeliveriesActivation).toEqual(384 * 1000); expect(params.meshMessageDeliveriesWindow).toEqual(12 * 1000); - expect(params.meshFailurePenaltyWeight).closeTo(-0.95127, TOLERANCE); + expect(params.meshFailurePenaltyWeight).closeTo(-0.96826, TOLERANCE); expect(params.meshFailurePenaltyDecay).closeTo(0.93057, TOLERANCE); if (penaltiesActive) { - expect(params.meshMessageDeliveriesWeight).closeTo(-0.95127, TOLERANCE); + expect(params.meshMessageDeliveriesWeight).closeTo(-0.96826, TOLERANCE); expect(params.meshMessageDeliveriesThreshold).closeTo(17.15638, TOLERANCE); } else { expect(params.meshMessageDeliveriesWeight).toEqual(0.0); @@ -158,7 +158,7 @@ describe("computeGossipPeerScoreParams", () => { expect(params.firstMessageDeliveriesWeight).closeTo(1.14716, TOLERANCE); expect(params.firstMessageDeliveriesDecay).closeTo(0.99283, TOLERANCE); expect(params.firstMessageDeliveriesCap).closeTo(34.8687, TOLERANCE); - expect(params.invalidMessageDeliveriesWeight).closeTo(-280.0, TOLERANCE); + expect(params.invalidMessageDeliveriesWeight).closeTo(-285.0, TOLERANCE); expect(params.invalidMessageDeliveriesDecay).closeTo(0.99713, TOLERANCE); // Check message rate penalty params @@ -166,11 +166,11 @@ describe("computeGossipPeerScoreParams", () => { expect(params.meshMessageDeliveriesCap).closeTo(2.0547574, TOLERANCE); expect(params.meshMessageDeliveriesActivation).toEqual(384 * 1000); expect(params.meshMessageDeliveriesWindow).toEqual(12 * 1000); - expect(params.meshFailurePenaltyWeight).closeTo(-596.8696, TOLERANCE); + expect(params.meshFailurePenaltyWeight).closeTo(-607.52794, TOLERANCE); expect(params.meshFailurePenaltyDecay).closeTo(0.97163, TOLERANCE); if (penaltiesActive) { - expect(params.meshMessageDeliveriesWeight).closeTo(-596.8696, TOLERANCE); + expect(params.meshMessageDeliveriesWeight).closeTo(-607.52794, TOLERANCE); expect(params.meshMessageDeliveriesThreshold).closeTo(0.68491, TOLERANCE); } else { expect(params.meshMessageDeliveriesWeight).toEqual(0.0); @@ -200,7 +200,7 @@ describe("computeGossipPeerScoreParams", () => { expect(params.firstMessageDeliveriesWeight).closeTo(2.6807, TOLERANCE); expect(params.firstMessageDeliveriesDecay).closeTo(0.86596, TOLERANCE); expect(params.firstMessageDeliveriesCap).closeTo(14.9214, TOLERANCE); - expect(params.invalidMessageDeliveriesWeight).closeTo(-8960.0, TOLERANCE); + expect(params.invalidMessageDeliveriesWeight).closeTo(-9120.0, TOLERANCE); expect(params.invalidMessageDeliveriesDecay).closeTo(0.99713, TOLERANCE); // Check message rate penalty params @@ -208,11 +208,11 @@ describe("computeGossipPeerScoreParams", () => { expect(params.meshMessageDeliveriesCap).closeTo(69.88248, TOLERANCE); expect(params.meshMessageDeliveriesActivation).toEqual(204 * 1000); expect(params.meshMessageDeliveriesWindow).toEqual(12 * 1000); - expect(params.meshFailurePenaltyWeight).closeTo(-469.69001, TOLERANCE); + expect(params.meshFailurePenaltyWeight).closeTo(-478.07737, TOLERANCE); expect(params.meshFailurePenaltyDecay).closeTo(0.96466, TOLERANCE); if (penaltiesActive) { - expect(params.meshMessageDeliveriesWeight).closeTo(-469.69001, TOLERANCE); + expect(params.meshMessageDeliveriesWeight).closeTo(-478.07737, TOLERANCE); expect(params.meshMessageDeliveriesThreshold).closeTo(4.367655, TOLERANCE); } else { expect(params.meshMessageDeliveriesWeight).toEqual(0.0); From e5b606cea9bb63c87b3a8e1d135fc6b177116aae Mon Sep 17 00:00:00 2001 From: Nico Flaig Date: Sat, 25 Apr 2026 00:09:58 +0100 Subject: [PATCH 5/5] more review --- .../src/chain/seenCache/seenProposerPreferences.ts | 12 ++---------- .../src/chain/validation/proposerPreferences.ts | 4 ---- .../src/network/gossip/scoringParameters.ts | 4 +--- 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/packages/beacon-node/src/chain/seenCache/seenProposerPreferences.ts b/packages/beacon-node/src/chain/seenCache/seenProposerPreferences.ts index 00ff72bc1bdb..3fbbdfe1d1e0 100644 --- a/packages/beacon-node/src/chain/seenCache/seenProposerPreferences.ts +++ b/packages/beacon-node/src/chain/seenCache/seenProposerPreferences.ts @@ -3,15 +3,6 @@ import {MapDef} from "@lodestar/utils"; /** * Tracks signed proposer preferences we've already seen per (proposal_slot, validator_index). - * - * Enforces the gossip rule: - * [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`. - * - * Entries are only load-bearing while `proposal_slot > state.slot`. Once the slot - * has passed, the `[IGNORE] preferences.proposal_slot > state.slot` rule takes over - * and this cache is no longer consulted for that slot. */ export class SeenProposerPreferences { private readonly validatorIndexesBySlot = new MapDef>(() => new Set()); @@ -25,7 +16,8 @@ export class SeenProposerPreferences { } /** - * Drop entries for slots that have already passed. Called on clock slot tick. + * 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()) { diff --git a/packages/beacon-node/src/chain/validation/proposerPreferences.ts b/packages/beacon-node/src/chain/validation/proposerPreferences.ts index e1a07fc4439c..11ba27618cb9 100644 --- a/packages/beacon-node/src/chain/validation/proposerPreferences.ts +++ b/packages/beacon-node/src/chain/validation/proposerPreferences.ts @@ -13,10 +13,6 @@ 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 - * - * Per repo convention, freshness checks (epoch and slot) use `chain.clock` rather than the spec-literal - * `state.epoch` / `state.slot` — same as `executionPayloadBid` and `payloadAttestationMessage`. State is - * only fetched once, after the cheap clock-based gates, for the proposer-lookahead and signature checks. */ export async function validateGossipProposerPreferences( chain: IBeaconChain, diff --git a/packages/beacon-node/src/network/gossip/scoringParameters.ts b/packages/beacon-node/src/network/gossip/scoringParameters.ts index 893678bad66e..3ad8ee2a673d 100644 --- a/packages/beacon-node/src/network/gossip/scoringParameters.ts +++ b/packages/beacon-node/src/network/gossip/scoringParameters.ts @@ -207,9 +207,7 @@ function getAllTopicsScoreParams( }) ] = getTopicScoreParams(config, precomputedParams, { topicWeight: PROPOSER_PREFERENCES_WEIGHT, - // One message per proposer per proposal slot (first-valid dedup), with proposers visible for - // current + next epoch: upper bound ~64 messages per epoch. expectedMessageRate is a per-slot - // rate (cf. PTC_SIZE used by payload_attestation_message). + // Upper bound ~64 messages per epoch: one per proposer across the current + next epoch. expectedMessageRate: 64 / SLOTS_PER_EPOCH, firstMessageDecayTime: epochDurationMs * 100, });