diff --git a/packages/api/src/beacon/routes/beacon/pool.ts b/packages/api/src/beacon/routes/beacon/pool.ts index aa15390c2d97..2e46ff065d3d 100644 --- a/packages/api/src/beacon/routes/beacon/pool.ts +++ b/packages/api/src/beacon/routes/beacon/pool.ts @@ -34,6 +34,7 @@ const AttestationListTypeElectra = ArrayOf(ssz.electra.Attestation); const AttesterSlashingListTypePhase0 = ArrayOf(ssz.phase0.AttesterSlashing); const AttesterSlashingListTypeElectra = ArrayOf(ssz.electra.AttesterSlashing); const ProposerSlashingListType = ArrayOf(ssz.phase0.ProposerSlashing); +const SignedProposerPreferencesListType = ArrayOf(ssz.gloas.SignedProposerPreferences); const SignedVoluntaryExitListType = ArrayOf(ssz.phase0.SignedVoluntaryExit); const SignedBLSToExecutionChangeListType = ArrayOf(ssz.capella.SignedBLSToExecutionChange); const SyncCommitteeMessageListType = ArrayOf(ssz.altair.SyncCommitteeMessage); @@ -47,6 +48,7 @@ type AttesterSlashingListElectra = ValueOf; +type SignedProposerPreferencesList = ValueOf; type SignedVoluntaryExitList = ValueOf; type SignedBLSToExecutionChangeList = ValueOf; type SyncCommitteeMessageList = ValueOf; @@ -115,6 +117,18 @@ export type Endpoints = { EmptyMeta >; + /** + * Get ProposerPreferences from operations pool + * Retrieves proposer preferences known by the node but not necessarily incorporated into gossip handling logic yet. + */ + getPoolProposerPreferences: Endpoint< + "GET", + {slot?: Slot}, + {query: {slot?: number}}, + SignedProposerPreferencesList, + EmptyMeta + >; + /** * Get SignedVoluntaryExit from operations pool * Retrieves voluntary exits known by the node but not necessarily incorporated into any block @@ -303,6 +317,19 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions ({query: {slot}}), + parseReq: ({query}) => ({slot: query.slot}), + schema: {query: {slot: Schema.Uint}}, + }, + resp: { + data: SignedProposerPreferencesListType, + meta: EmptyMetaCodec, + }, + }, getPoolVoluntaryExits: { url: "/eth/v1/beacon/pool/voluntary_exits", method: "GET", diff --git a/packages/api/src/beacon/routes/events.ts b/packages/api/src/beacon/routes/events.ts index 4b1dfbe33d60..c186ccf703a3 100644 --- a/packages/api/src/beacon/routes/events.ts +++ b/packages/api/src/beacon/routes/events.ts @@ -15,6 +15,7 @@ import { altair, capella, electra, + gloas, phase0, ssz, sszTypesFor, @@ -88,6 +89,8 @@ export enum EventType { blobSidecar = "blob_sidecar", /** The node has received a valid DataColumnSidecar (from P2P or API) */ dataColumnSidecar = "data_column_sidecar", + /** The node has received valid SignedProposerPreferences (from P2P or API) */ + proposerPreferences = "proposer_preferences", } export const eventTypes: {[K in EventType]: K} = { @@ -108,6 +111,7 @@ export const eventTypes: {[K in EventType]: K} = { [EventType.payloadAttributes]: EventType.payloadAttributes, [EventType.blobSidecar]: EventType.blobSidecar, [EventType.dataColumnSidecar]: EventType.dataColumnSidecar, + [EventType.proposerPreferences]: EventType.proposerPreferences, }; export type EventData = { @@ -157,6 +161,7 @@ export type EventData = { [EventType.payloadAttributes]: {version: ForkName; data: SSEPayloadAttributes}; [EventType.blobSidecar]: BlobSidecarSSE; [EventType.dataColumnSidecar]: DataColumnSidecarSSE; + [EventType.proposerPreferences]: gloas.SignedProposerPreferences; }; export type BeaconEvent = {[K in EventType]: {type: K; message: EventData[K]}}[EventType]; @@ -311,6 +316,7 @@ export function getTypeByEvent(config: ChainForkConfig): {[K in EventType]: Type [EventType.payloadAttributes]: WithVersion((fork) => getPostBellatrixForkTypes(fork).SSEPayloadAttributes), [EventType.blobSidecar]: blobSidecarSSE, [EventType.dataColumnSidecar]: dataColumnSidecarSSE, + [EventType.proposerPreferences]: ssz.gloas.SignedProposerPreferences, [EventType.lightClientOptimisticUpdate]: WithVersion( (fork) => getPostAltairForkTypes(fork).LightClientOptimisticUpdate diff --git a/packages/api/src/beacon/routes/validator.ts b/packages/api/src/beacon/routes/validator.ts index 32a94959a71d..38ba1a60eefb 100644 --- a/packages/api/src/beacon/routes/validator.ts +++ b/packages/api/src/beacon/routes/validator.ts @@ -220,6 +220,7 @@ export const SignedValidatorRegistrationV1ListType = ArrayOf( ssz.bellatrix.SignedValidatorRegistrationV1, VALIDATOR_REGISTRY_LIMIT ); +export const SignedProposerPreferencesListType = ArrayOf(ssz.gloas.SignedProposerPreferences); export type ValidatorIndices = ValueOf; export type AttesterDuty = ValueOf; @@ -245,6 +246,7 @@ export type SyncCommitteeSelectionList = ValueOf; export type LivenessResponseDataList = ValueOf; export type SignedValidatorRegistrationV1List = ValueOf; +export type SignedProposerPreferencesList = ValueOf; export type Endpoints = { /** @@ -490,6 +492,14 @@ export type Endpoints = { EmptyMeta >; + submitProposerPreferences: Endpoint< + "POST", + {proposerPreferences: SignedProposerPreferencesList}, + {body: unknown}, + EmptyResponseData, + EmptyMeta + >; + /** * Determine if a distributed validator has been selected to aggregate attestations * @@ -978,6 +988,18 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions ({ + body: SignedProposerPreferencesListType.toJson(proposerPreferences), + }), + parseReqJson: ({body}) => ({proposerPreferences: SignedProposerPreferencesListType.fromJson(body)}), + schema: {body: Schema.ObjectArray}, + }), + resp: EmptyResponseCodec, + }, submitBeaconCommitteeSelections: { url: "/eth/v1/validator/beacon_committee_selections", method: "POST", diff --git a/packages/api/test/unit/beacon/testData/beacon.ts b/packages/api/test/unit/beacon/testData/beacon.ts index 49fa3e0d8d0b..f830a7c3101f 100644 --- a/packages/api/test/unit/beacon/testData/beacon.ts +++ b/packages/api/test/unit/beacon/testData/beacon.ts @@ -128,6 +128,10 @@ export const testData: GenericServerTestCases = { args: undefined, res: {data: [ssz.phase0.ProposerSlashing.defaultValue()]}, }, + getPoolProposerPreferences: { + args: {slot: 1}, + res: {data: [ssz.gloas.SignedProposerPreferences.defaultValue()]}, + }, getPoolVoluntaryExits: { args: undefined, res: {data: [ssz.phase0.SignedVoluntaryExit.defaultValue()]}, diff --git a/packages/api/test/unit/beacon/testData/events.ts b/packages/api/test/unit/beacon/testData/events.ts index d9da1503107c..6de71b304c6b 100644 --- a/packages/api/test/unit/beacon/testData/events.ts +++ b/packages/api/test/unit/beacon/testData/events.ts @@ -274,4 +274,5 @@ export const eventTestData: EventData = { "0x1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505cc411d61252fb6cb3fa0017b679f8bb2305b26a285fa2737f175668d0dff91cc1b66ac1fb663c9bc59509846d6ec05345bd908eda73e670af888da41af171505", ], }), + [EventType.proposerPreferences]: ssz.gloas.SignedProposerPreferences.defaultValue(), }; diff --git a/packages/api/test/unit/beacon/testData/validator.ts b/packages/api/test/unit/beacon/testData/validator.ts index bba342cb3f56..7348821322b5 100644 --- a/packages/api/test/unit/beacon/testData/validator.ts +++ b/packages/api/test/unit/beacon/testData/validator.ts @@ -112,6 +112,10 @@ export const testData: GenericServerTestCases = { args: {proposers: [{validatorIndex: 1, feeRecipient: "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b"}]}, res: undefined, }, + submitProposerPreferences: { + args: {proposerPreferences: [ssz.gloas.SignedProposerPreferences.defaultValue()]}, + res: undefined, + }, submitBeaconCommitteeSelections: { args: {selections: []}, res: {data: [{validatorIndex: 1, slot: 2, selectionProof}]}, diff --git a/packages/beacon-node/src/api/impl/beacon/pool/index.ts b/packages/beacon-node/src/api/impl/beacon/pool/index.ts index 80a5895afb54..2d48f061a2d2 100644 --- a/packages/beacon-node/src/api/impl/beacon/pool/index.ts +++ b/packages/beacon-node/src/api/impl/beacon/pool/index.ts @@ -83,6 +83,10 @@ export function getBeaconPoolApi({ return {data: chain.opPool.getAllProposerSlashings()}; }, + async getPoolProposerPreferences({slot}) { + return {data: chain.proposerPreferencesPool.getAll({slot})}; + }, + async getPoolVoluntaryExits() { return {data: chain.opPool.getAllVoluntaryExits()}; }, diff --git a/packages/beacon-node/src/api/impl/validator/index.ts b/packages/beacon-node/src/api/impl/validator/index.ts index 18c52450c17e..d7c148ed30f0 100644 --- a/packages/beacon-node/src/api/impl/validator/index.ts +++ b/packages/beacon-node/src/api/impl/validator/index.ts @@ -73,6 +73,7 @@ import {BlockType, ProduceFullDeneb} from "../../../chain/produceBlock/index.js" import {RegenCaller} from "../../../chain/regen/index.js"; import {CheckpointHex} from "../../../chain/stateCache/types.js"; import {validateApiAggregateAndProof} from "../../../chain/validation/index.js"; +import {validateApiProposerPreferences} from "../../../chain/validation/proposerPreferences.js"; import {validateSyncCommitteeGossipContributionAndProof} from "../../../chain/validation/syncCommitteeContributionAndProof.js"; import {ZERO_HASH} from "../../../constants/index.js"; import {BuilderStatus, NoBidReceived} from "../../../execution/builder/http.js"; @@ -1469,6 +1470,34 @@ export function getValidatorApi( await chain.updateBeaconProposerData(chain.clock.currentEpoch, proposers); }, + async submitProposerPreferences({proposerPreferences}) { + const failures: FailureList = []; + + await Promise.all( + proposerPreferences.map(async (signedProposerPreferences, i) => { + try { + await validateApiProposerPreferences(chain, signedProposerPreferences); + + const insertOutcome = chain.proposerPreferencesPool.add(signedProposerPreferences); + metrics?.opPool.proposerPreferencesPool.apiInsertOutcome.inc({insertOutcome}); + + const {proposalSlot, validatorIndex} = signedProposerPreferences.message; + chain.seenProposerPreferences.add(proposalSlot, validatorIndex); + chain.emitter.emit(routes.events.EventType.proposerPreferences, signedProposerPreferences); + + await network.publishProposerPreferences(signedProposerPreferences); + } catch (e) { + failures.push({index: i, message: (e as Error).message}); + logger.verbose(`Error on submitProposerPreferences [${i}]`, {}, e as Error); + } + }) + ); + + if (failures.length > 0) { + throw new IndexedError("Error processing proposer preferences", failures); + } + }, + async submitBeaconCommitteeSelections() { throw new OnlySupportedByDVT(); }, diff --git a/packages/beacon-node/src/chain/chain.ts b/packages/beacon-node/src/chain/chain.ts index 9c10aab69fc8..9eb45c8e0b1a 100644 --- a/packages/beacon-node/src/chain/chain.ts +++ b/packages/beacon-node/src/chain/chain.ts @@ -82,6 +82,7 @@ import { ExecutionPayloadBidPool, OpPool, PayloadAttestationPool, + ProposerPreferencesPool, SyncCommitteeMessagePool, SyncContributionAndProofPool, } from "./opPools/index.js"; @@ -100,6 +101,7 @@ import { SeenExecutionPayloadBids, SeenExecutionPayloadEnvelopes, SeenPayloadAttesters, + SeenProposerPreferences, SeenSyncCommitteeMessages, } from "./seenCache/index.js"; import {SeenAggregatedAttestations} from "./seenCache/seenAggregateAndProof.js"; @@ -164,6 +166,7 @@ export class BeaconChain implements IBeaconChain { readonly syncContributionAndProofPool; readonly executionPayloadBidPool: ExecutionPayloadBidPool; readonly payloadAttestationPool: PayloadAttestationPool; + readonly proposerPreferencesPool: ProposerPreferencesPool; readonly opPool: OpPool; // Gossip seen cache @@ -173,6 +176,7 @@ export class BeaconChain implements IBeaconChain { readonly seenAggregatedAttestations: SeenAggregatedAttestations; readonly seenExecutionPayloadEnvelopes = new SeenExecutionPayloadEnvelopes(); readonly seenExecutionPayloadBids = new SeenExecutionPayloadBids(); + readonly seenProposerPreferences = new SeenProposerPreferences(); readonly seenBlockProposers = new SeenBlockProposers(); readonly seenSyncCommitteeMessages = new SeenSyncCommitteeMessages(); readonly seenContributionAndProof: SeenContributionAndProof; @@ -291,6 +295,7 @@ export class BeaconChain implements IBeaconChain { this.syncContributionAndProofPool = new SyncContributionAndProofPool(config, clock, metrics, logger); this.executionPayloadBidPool = new ExecutionPayloadBidPool(); this.payloadAttestationPool = new PayloadAttestationPool(config, clock, metrics); + this.proposerPreferencesPool = new ProposerPreferencesPool(); this.opPool = new OpPool(config); this.seenAggregatedAttestations = new SeenAggregatedAttestations(metrics); @@ -1270,6 +1275,7 @@ export class BeaconChain implements IBeaconChain { metrics.opPool.syncCommitteeMessagePoolSize.set(this.syncCommitteeMessagePool.size); metrics.opPool.payloadAttestationPool.size.set(this.payloadAttestationPool.size); metrics.opPool.executionPayloadBidPool.size.set(this.executionPayloadBidPool.size); + metrics.opPool.proposerPreferencesPool.size.set(this.proposerPreferencesPool.size); // syncContributionAndProofPool tracks metrics on its own metrics.opPool.blsToExecutionChangePoolSize.set(this.opPool.blsToExecutionChangeSize); metrics.chain.blacklistedBlocks.set(this.blacklistedBlocks.size); @@ -1302,7 +1308,9 @@ export class BeaconChain implements IBeaconChain { this.seenSyncCommitteeMessages.prune(slot); this.payloadAttestationPool.prune(slot); this.executionPayloadBidPool.prune(slot); + this.proposerPreferencesPool.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/executionPayloadBid.ts b/packages/beacon-node/src/chain/errors/executionPayloadBid.ts index 5770d5efc045..2e4a68e3aef0 100644 --- a/packages/beacon-node/src/chain/errors/executionPayloadBid.ts +++ b/packages/beacon-node/src/chain/errors/executionPayloadBid.ts @@ -10,6 +10,8 @@ export enum ExecutionPayloadBidErrorCode { UNKNOWN_BLOCK_ROOT = "EXECUTION_PAYLOAD_BID_ERROR_UNKNOWN_BLOCK_ROOT", INVALID_SLOT = "EXECUTION_PAYLOAD_BID_ERROR_INVALID_SLOT", INVALID_SIGNATURE = "EXECUTION_PAYLOAD_BID_ERROR_INVALID_SIGNATURE", + PREFERENCES_NOT_SEEN = "EXECUTION_PAYLOAD_BID_ERROR_PREFERENCES_NOT_SEEN", + PREFERENCES_MISMATCH = "EXECUTION_PAYLOAD_BID_ERROR_PREFERENCES_MISMATCH", } export type ExecutionPayloadBidErrorType = @@ -30,6 +32,15 @@ export type ExecutionPayloadBidErrorType = | {code: ExecutionPayloadBidErrorCode.BID_TOO_HIGH; bidValue: number; builderBalance: number} | {code: ExecutionPayloadBidErrorCode.UNKNOWN_BLOCK_ROOT; parentBlockRoot: RootHex} | {code: ExecutionPayloadBidErrorCode.INVALID_SLOT; builderIndex: BuilderIndex; slot: Slot} - | {code: ExecutionPayloadBidErrorCode.INVALID_SIGNATURE; builderIndex: BuilderIndex; slot: Slot}; + | {code: ExecutionPayloadBidErrorCode.INVALID_SIGNATURE; builderIndex: BuilderIndex; slot: Slot} + | {code: ExecutionPayloadBidErrorCode.PREFERENCES_NOT_SEEN; slot: Slot} + | { + code: ExecutionPayloadBidErrorCode.PREFERENCES_MISMATCH; + slot: Slot; + bidFeeRecipient: string; + expectedFeeRecipient: string; + bidGasLimit: bigint; + expectedGasLimit: number; + }; export class ExecutionPayloadBidError extends GossipActionError {} 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..33c84689b69c --- /dev/null +++ b/packages/beacon-node/src/chain/errors/proposerPreferences.ts @@ -0,0 +1,34 @@ +import {Slot, ValidatorIndex} from "@lodestar/types"; +import {GossipActionError} from "./gossipValidation.js"; + +export enum ProposerPreferencesErrorCode { + INVALID_EPOCH = "PROPOSER_PREFERENCES_ERROR_INVALID_EPOCH", + INVALID_PROPOSAL_SLOT = "PROPOSER_PREFERENCES_ERROR_INVALID_PROPOSAL_SLOT", + PREFERENCES_ALREADY_KNOWN = "PROPOSER_PREFERENCES_ERROR_PREFERENCES_ALREADY_KNOWN", + INVALID_SIGNATURE = "PROPOSER_PREFERENCES_ERROR_INVALID_SIGNATURE", +} + +export type ProposerPreferencesErrorType = + | { + code: ProposerPreferencesErrorCode.INVALID_EPOCH; + currentEpoch: number; + proposalSlot: Slot; + } + | { + code: ProposerPreferencesErrorCode.INVALID_PROPOSAL_SLOT; + proposalSlot: Slot; + validatorIndex: ValidatorIndex; + expectedValidatorIndex: ValidatorIndex | null; + } + | { + code: ProposerPreferencesErrorCode.PREFERENCES_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 5f5525716f68..56d94f7ecd0c 100644 --- a/packages/beacon-node/src/chain/interface.ts +++ b/packages/beacon-node/src/chain/interface.ts @@ -51,6 +51,7 @@ import { ExecutionPayloadBidPool, OpPool, PayloadAttestationPool, + ProposerPreferencesPool, SyncCommitteeMessagePool, SyncContributionAndProofPool, } from "./opPools/index.js"; @@ -66,6 +67,7 @@ import { SeenExecutionPayloadBids, SeenExecutionPayloadEnvelopes, SeenPayloadAttesters, + SeenProposerPreferences, SeenSyncCommitteeMessages, } from "./seenCache/index.js"; import {SeenAggregatedAttestations} from "./seenCache/seenAggregateAndProof.js"; @@ -128,6 +130,7 @@ export interface IBeaconChain { readonly syncContributionAndProofPool: SyncContributionAndProofPool; readonly executionPayloadBidPool: ExecutionPayloadBidPool; readonly payloadAttestationPool: PayloadAttestationPool; + readonly proposerPreferencesPool: ProposerPreferencesPool; readonly opPool: OpPool; // Gossip seen cache @@ -137,6 +140,7 @@ export interface IBeaconChain { readonly seenAggregatedAttestations: SeenAggregatedAttestations; readonly seenExecutionPayloadEnvelopes: SeenExecutionPayloadEnvelopes; readonly seenExecutionPayloadBids: SeenExecutionPayloadBids; + readonly seenProposerPreferences: SeenProposerPreferences; readonly seenBlockProposers: SeenBlockProposers; readonly seenSyncCommitteeMessages: SeenSyncCommitteeMessages; readonly seenContributionAndProof: SeenContributionAndProof; diff --git a/packages/beacon-node/src/chain/opPools/index.ts b/packages/beacon-node/src/chain/opPools/index.ts index 262fb9419856..c64cdb3de674 100644 --- a/packages/beacon-node/src/chain/opPools/index.ts +++ b/packages/beacon-node/src/chain/opPools/index.ts @@ -3,5 +3,6 @@ export {AttestationPool} from "./attestationPool.js"; export {ExecutionPayloadBidPool} from "./executionPayloadBidPool.js"; export {OpPool} from "./opPool.js"; export {PayloadAttestationPool} from "./payloadAttestationPool.js"; +export {ProposerPreferencesPool} from "./proposerPreferencesPool.js"; export {SyncCommitteeMessagePool} from "./syncCommitteeMessagePool.js"; export {SyncContributionAndProofPool} from "./syncContributionAndProofPool.js"; diff --git a/packages/beacon-node/src/chain/opPools/proposerPreferencesPool.ts b/packages/beacon-node/src/chain/opPools/proposerPreferencesPool.ts new file mode 100644 index 000000000000..974cdd8512e4 --- /dev/null +++ b/packages/beacon-node/src/chain/opPools/proposerPreferencesPool.ts @@ -0,0 +1,53 @@ +import {SLOTS_PER_EPOCH} from "@lodestar/params"; +import {Slot, gloas} from "@lodestar/types"; +import {InsertOutcome} from "./types.js"; +import {pruneBySlot} from "./utils.js"; + +const SLOTS_RETAINED = SLOTS_PER_EPOCH * 2; + +type GetAllOpts = { + slot?: Slot; +}; + +/** + * Store proposer preferences by proposal slot. + */ +export class ProposerPreferencesPool { + private readonly signedPreferencesBySlot = new Map(); + private lowestPermissibleSlot = 0; + + get size(): number { + return this.signedPreferencesBySlot.size; + } + + add(signedPreferences: gloas.SignedProposerPreferences): InsertOutcome { + const proposalSlot = signedPreferences.message.proposalSlot; + if (proposalSlot < this.lowestPermissibleSlot) { + return InsertOutcome.Old; + } + + if (this.signedPreferencesBySlot.has(proposalSlot)) { + return InsertOutcome.AlreadyKnown; + } + + this.signedPreferencesBySlot.set(proposalSlot, signedPreferences); + return InsertOutcome.NewData; + } + + get(slot: Slot): gloas.SignedProposerPreferences | null { + return this.signedPreferencesBySlot.get(slot) ?? null; + } + + getAll(opts?: GetAllOpts): gloas.SignedProposerPreferences[] { + if (opts?.slot !== undefined) { + const preference = this.get(opts.slot); + return preference ? [preference] : []; + } + + return Array.from(this.signedPreferencesBySlot.values()); + } + + prune(clockSlot: Slot): void { + this.lowestPermissibleSlot = pruneBySlot(this.signedPreferencesBySlot, clockSlot, SLOTS_RETAINED); + } +} diff --git a/packages/beacon-node/src/chain/seenCache/index.ts b/packages/beacon-node/src/chain/seenCache/index.ts index f16ae79f7f2e..adbe83348eb6 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 {SeenExecutionPayloadEnvelopes} from "./seenExecutionPayloadEnvelope.js"; export {SeenBlockInput} from "./seenGossipBlockInput.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..fc74f617c5ff --- /dev/null +++ b/packages/beacon-node/src/chain/seenCache/seenProposerPreferences.ts @@ -0,0 +1,33 @@ +import {SLOTS_PER_EPOCH} from "@lodestar/params"; +import {Slot, ValidatorIndex} from "@lodestar/types"; +import {MapDef} from "@lodestar/utils"; + +const SLOTS_RETAINED = SLOTS_PER_EPOCH * 2; + +/** + * Tracks proposer preferences seen per (proposal slot, validator index). + */ +export class SeenProposerPreferences { + private readonly validatorIndexesBySlot = new MapDef>(() => new Set()); + private lowestPermissibleSlot: Slot = 0; + + isKnown(slot: Slot, validatorIndex: ValidatorIndex): boolean { + return this.validatorIndexesBySlot.get(slot)?.has(validatorIndex) === true; + } + + add(slot: Slot, validatorIndex: ValidatorIndex): void { + if (slot < this.lowestPermissibleSlot) { + throw Error(`slot ${slot} < lowestPermissibleSlot ${this.lowestPermissibleSlot}`); + } + this.validatorIndexesBySlot.getOrDefault(slot).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/executionPayloadBid.ts b/packages/beacon-node/src/chain/validation/executionPayloadBid.ts index 771f9d1e2842..708adb20650e 100644 --- a/packages/beacon-node/src/chain/validation/executionPayloadBid.ts +++ b/packages/beacon-node/src/chain/validation/executionPayloadBid.ts @@ -7,7 +7,7 @@ import { isActiveBuilder, } from "@lodestar/state-transition"; import {gloas} from "@lodestar/types"; -import {toRootHex} from "@lodestar/utils"; +import {toHex, toRootHex} from "@lodestar/utils"; import {ExecutionPayloadBidError, ExecutionPayloadBidErrorCode, GossipAction} from "../errors/index.js"; import {IBeaconChain} from "../index.js"; import {RegenCaller} from "../regen/index.js"; @@ -47,9 +47,14 @@ async function validateExecutionPayloadBid( }); } - // [IGNORE] the `SignedProposerPreferences` where `preferences.proposal_slot` - // is equal to `bid.slot` has been seen. - // TODO GLOAS: Implement this along with proposer preference + // [IGNORE] proposer preferences for this slot has been seen. + const signedPreferences = chain.proposerPreferencesPool.get(bid.slot); + if (signedPreferences === null) { + throw new ExecutionPayloadBidError(GossipAction.IGNORE, { + code: ExecutionPayloadBidErrorCode.PREFERENCES_NOT_SEEN, + slot: bid.slot, + }); + } // [REJECT] `bid.builder_index` is a valid/active builder index -- i.e. // `is_active_builder(state, bid.builder_index)` returns `True`. @@ -70,11 +75,18 @@ async function validateExecutionPayloadBid( }); } - // [REJECT] `bid.fee_recipient` matches the `fee_recipient` from the proposer's - // `SignedProposerPreferences` associated with `bid.slot`. - // [REJECT] `bid.gas_limit` matches the `gas_limit` from the proposer's - // `SignedProposerPreferences` associated with `bid.slot`. - // TODO GLOAS: Implement this along with proposer preference + // [REJECT] bid fee recipient and gas limit must match proposer preferences. + const preferences = signedPreferences.message; + if (toHex(bid.feeRecipient) !== toHex(preferences.feeRecipient) || bid.gasLimit !== BigInt(preferences.gasLimit)) { + throw new ExecutionPayloadBidError(GossipAction.REJECT, { + code: ExecutionPayloadBidErrorCode.PREFERENCES_MISMATCH, + slot: bid.slot, + bidFeeRecipient: toHex(bid.feeRecipient), + expectedFeeRecipient: toHex(preferences.feeRecipient), + bidGasLimit: bid.gasLimit, + expectedGasLimit: preferences.gasLimit, + }); + } // [IGNORE] this is the first signed bid seen with a valid signature from the given builder for this slot. if (chain.seenExecutionPayloadBids.isKnown(bid.slot, bid.builderIndex)) { diff --git a/packages/beacon-node/src/chain/validation/index.ts b/packages/beacon-node/src/chain/validation/index.ts index bb4f7868436a..e33cf82f3755 100644 --- a/packages/beacon-node/src/chain/validation/index.ts +++ b/packages/beacon-node/src/chain/validation/index.ts @@ -3,6 +3,7 @@ export * from "./attestation.js"; export * from "./attesterSlashing.js"; export * from "./block.js"; export * from "./blsToExecutionChange.js"; +export * from "./proposerPreferences.js"; export * from "./proposerSlashing.js"; export * from "./syncCommittee.js"; export * from "./syncCommitteeContributionAndProof.js"; 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..8bacadb65e26 --- /dev/null +++ b/packages/beacon-node/src/chain/validation/proposerPreferences.ts @@ -0,0 +1,87 @@ +import {SLOTS_PER_EPOCH} from "@lodestar/params"; +import { + CachedBeaconStateGloas, + computeEpochAtSlot, + createSingleSignatureSetFromComponents, + getProposerPreferencesSigningRoot, +} from "@lodestar/state-transition"; +import {ValidatorIndex, 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 { + return validateProposerPreferences(chain, signedProposerPreferences); +} + +export async function validateGossipProposerPreferences( + chain: IBeaconChain, + signedProposerPreferences: gloas.SignedProposerPreferences +): Promise { + return validateProposerPreferences(chain, signedProposerPreferences); +} + +async function validateProposerPreferences( + chain: IBeaconChain, + signedProposerPreferences: gloas.SignedProposerPreferences +): Promise { + const preferences = signedProposerPreferences.message; + const state = (await chain.getHeadStateAtCurrentEpoch( + RegenCaller.validateGossipExecutionPayloadBid + )) as CachedBeaconStateGloas; + const currentEpoch = computeEpochAtSlot(state.slot); + const proposalEpoch = computeEpochAtSlot(preferences.proposalSlot); + + // [IGNORE] preference must be for next epoch. + if (proposalEpoch !== currentEpoch + 1) { + throw new ProposerPreferencesError(GossipAction.IGNORE, { + code: ProposerPreferencesErrorCode.INVALID_EPOCH, + currentEpoch, + proposalSlot: preferences.proposalSlot, + }); + } + + // [REJECT] valid proposal slot according to proposer lookahead. + const expectedValidatorIndex = getExpectedValidatorIndex(state, preferences.proposalSlot); + if (expectedValidatorIndex !== preferences.validatorIndex) { + throw new ProposerPreferencesError(GossipAction.REJECT, { + code: ProposerPreferencesErrorCode.INVALID_PROPOSAL_SLOT, + proposalSlot: preferences.proposalSlot, + validatorIndex: preferences.validatorIndex, + expectedValidatorIndex, + }); + } + + // [IGNORE] first valid message per (proposal_slot, validator_index). + if (chain.seenProposerPreferences.isKnown(preferences.proposalSlot, preferences.validatorIndex)) { + throw new ProposerPreferencesError(GossipAction.IGNORE, { + code: ProposerPreferencesErrorCode.PREFERENCES_ALREADY_KNOWN, + proposalSlot: preferences.proposalSlot, + validatorIndex: preferences.validatorIndex, + }); + } + + // [REJECT] valid signature under DOMAIN_PROPOSER_PREFERENCES. + const signatureSet = createSingleSignatureSetFromComponents( + chain.index2pubkey[preferences.validatorIndex], + getProposerPreferencesSigningRoot(chain.config, state.slot, preferences), + signedProposerPreferences.signature + ); + + if (!(await chain.bls.verifySignatureSets([signatureSet]))) { + throw new ProposerPreferencesError(GossipAction.REJECT, { + code: ProposerPreferencesErrorCode.INVALID_SIGNATURE, + proposalSlot: preferences.proposalSlot, + validatorIndex: preferences.validatorIndex, + }); + } +} + +function getExpectedValidatorIndex(state: CachedBeaconStateGloas, proposalSlot: number): ValidatorIndex | null { + const index = (proposalSlot % SLOTS_PER_EPOCH) + SLOTS_PER_EPOCH; + const proposerLookahead = state.proposerLookahead.getAll(); + return proposerLookahead[index] ?? null; +} diff --git a/packages/beacon-node/src/metrics/metrics/lodestar.ts b/packages/beacon-node/src/metrics/metrics/lodestar.ts index b4c3eab0efef..c4577795bf4c 100644 --- a/packages/beacon-node/src/metrics/metrics/lodestar.ts +++ b/packages/beacon-node/src/metrics/metrics/lodestar.ts @@ -1181,6 +1181,22 @@ export function createLodestarMetrics( labelNames: ["insertOutcome"], }), }, + proposerPreferencesPool: { + size: register.gauge({ + name: "lodestar_oppool_proposer_preferences_pool_size", + help: "Current size of the ProposerPreferencesPool = total number of preferences", + }), + gossipInsertOutcome: register.counter<{insertOutcome: InsertOutcome}>({ + name: "lodestar_oppool_proposer_preferences_pool_gossip_insert_outcome_total", + help: "Total number of InsertOutcome as a result of adding proposer preferences from gossip to the pool", + labelNames: ["insertOutcome"], + }), + apiInsertOutcome: register.counter<{insertOutcome: InsertOutcome}>({ + name: "lodestar_oppool_proposer_preferences_pool_api_insert_outcome_total", + help: "Total number of InsertOutcome as a result of adding proposer preferences from api to the pool", + labelNames: ["insertOutcome"], + }), + }, }, chain: { diff --git a/packages/beacon-node/src/network/gossip/interface.ts b/packages/beacon-node/src/network/gossip/interface.ts index 54904cd28e26..c73bdbf581af 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,7 @@ export type GossipFnByType = { payloadAttestationMessage: gloas.PayloadAttestationMessage ) => Promise | void; [GossipType.execution_payload_bid]: (executionPayloadBid: gloas.SignedExecutionPayloadBid) => Promise | void; + [GossipType.proposer_preferences]: (proposerPreferences: 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 b9f2f24adf03..7e1c106cd602 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,16 @@ 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, + expectedMessageRate: 1024, // TODO GLOAS: Need an estimate for this + 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 e81087288ada..f20663936a25 100644 --- a/packages/beacon-node/src/network/gossip/topic.ts +++ b/packages/beacon-node/src/network/gossip/topic.ts @@ -72,6 +72,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: @@ -123,6 +124,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; } } @@ -202,6 +205,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}; } @@ -256,6 +260,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} @@ -350,4 +355,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/interface.ts b/packages/beacon-node/src/network/interface.ts index 347f94f04156..8d214c224f60 100644 --- a/packages/beacon-node/src/network/interface.ts +++ b/packages/beacon-node/src/network/interface.ts @@ -31,6 +31,7 @@ import { capella, deneb, fulu, + gloas, phase0, } from "@lodestar/types"; import {BlockInputSource} from "../chain/blocks/blockInput/types.js"; @@ -90,6 +91,7 @@ export interface INetwork extends INetworkCorePublic { publishVoluntaryExit(voluntaryExit: phase0.SignedVoluntaryExit): Promise; publishBlsToExecutionChange(blsToExecutionChange: capella.SignedBLSToExecutionChange): Promise; publishProposerSlashing(proposerSlashing: phase0.ProposerSlashing): Promise; + publishProposerPreferences(signedPreferences: gloas.SignedProposerPreferences): Promise; publishAttesterSlashing(attesterSlashing: AttesterSlashing): Promise; publishSyncCommitteeSignature(signature: altair.SyncCommitteeMessage, subnet: SubnetID): Promise; publishContributionAndProof(contributionAndProof: altair.SignedContributionAndProof): Promise; diff --git a/packages/beacon-node/src/network/network.ts b/packages/beacon-node/src/network/network.ts index 94e5b000909c..f9c8f8d5bd96 100644 --- a/packages/beacon-node/src/network/network.ts +++ b/packages/beacon-node/src/network/network.ts @@ -24,6 +24,7 @@ import { capella, deneb, fulu, + gloas, phase0, } from "@lodestar/types"; import {prettyPrintIndices, sleep} from "@lodestar/utils"; @@ -435,6 +436,16 @@ export class Network implements INetwork { ); } + async publishProposerPreferences(signedPreferences: gloas.SignedProposerPreferences): Promise { + const epoch = computeEpochAtSlot(signedPreferences.message.proposalSlot); + const boundary = this.config.getForkBoundaryAtEpoch(epoch); + + return this.publishGossip( + {type: GossipType.proposer_preferences, boundary}, + signedPreferences + ); + } + async publishAttesterSlashing(attesterSlashing: AttesterSlashing): Promise { const epoch = computeEpochAtSlot(Number(attesterSlashing.attestation1.data.slot as bigint)); const boundary = this.config.getForkBoundaryAtEpoch(epoch); diff --git a/packages/beacon-node/src/network/processor/gossipHandlers.ts b/packages/beacon-node/src/network/processor/gossipHandlers.ts index 996a26148785..ac8143193574 100644 --- a/packages/beacon-node/src/network/processor/gossipHandlers.ts +++ b/packages/beacon-node/src/network/processor/gossipHandlers.ts @@ -67,6 +67,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"; @@ -863,6 +864,25 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand logger.error("Error adding to executionPayloadBid pool", {}, e as Error); } }, + [GossipType.proposer_preferences]: async ({ + gossipData, + topic, + }: GossipHandlerParamGeneric) => { + const {serializedData} = gossipData; + const proposerPreferences = sszDeserialize(topic, serializedData); + await validateGossipProposerPreferences(chain, proposerPreferences); + + try { + const insertOutcome = chain.proposerPreferencesPool.add(proposerPreferences); + metrics?.opPool.proposerPreferencesPool.gossipInsertOutcome.inc({insertOutcome}); + const {proposalSlot, validatorIndex} = proposerPreferences.message; + chain.seenProposerPreferences.add(proposalSlot, validatorIndex); + } catch (e) { + logger.error("Error adding to proposerPreferences pool", {}, e as Error); + } + + chain.emitter.emit(routes.events.EventType.proposerPreferences, proposerPreferences); + }, }; } diff --git a/packages/beacon-node/src/network/processor/gossipQueues/index.ts b/packages/beacon-node/src/network/processor/gossipQueues/index.ts index 5f4200b9eab9..0c05c2ac9b52 100644 --- a/packages/beacon-node/src/network/processor/gossipQueues/index.ts +++ b/packages/beacon-node/src/network/processor/gossipQueues/index.ts @@ -83,6 +83,11 @@ const linearGossipQueueOpts: { type: QueueType.FIFO, dropOpts: {type: DropType.count, count: 1}, }, + [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 cf20ab63d2e3..bb6ed5b5d39b 100644 --- a/packages/beacon-node/src/network/processor/index.ts +++ b/packages/beacon-node/src/network/processor/index.ts @@ -81,6 +81,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/test/unit/chain/opPools/proposerPreferencesPool.test.ts b/packages/beacon-node/test/unit/chain/opPools/proposerPreferencesPool.test.ts new file mode 100644 index 000000000000..9f59c7932540 --- /dev/null +++ b/packages/beacon-node/test/unit/chain/opPools/proposerPreferencesPool.test.ts @@ -0,0 +1,35 @@ +import {describe, expect, it} from "vitest"; +import {SLOTS_PER_EPOCH} from "@lodestar/params"; +import {ssz} from "@lodestar/types"; +import {ProposerPreferencesPool} from "../../../../src/chain/opPools/proposerPreferencesPool.js"; +import {InsertOutcome} from "../../../../src/chain/opPools/types.js"; + +describe("chain / opPools / ProposerPreferencesPool", () => { + it("stores first preference for a slot", () => { + const pool = new ProposerPreferencesPool(); + const signed = ssz.gloas.SignedProposerPreferences.defaultValue(); + signed.message.proposalSlot = 64; + signed.message.validatorIndex = 5; + + expect(pool.add(signed)).toBe(InsertOutcome.NewData); + expect(pool.add(signed)).toBe(InsertOutcome.AlreadyKnown); + expect(pool.get(64)).toEqual(signed); + expect(pool.getAll({slot: 64})).toEqual([signed]); + }); + + it("prunes old slots", () => { + const pool = new ProposerPreferencesPool(); + const currentSlot = SLOTS_PER_EPOCH * 3; + for (let i = 0; i <= currentSlot; i++) { + const signed = ssz.gloas.SignedProposerPreferences.defaultValue(); + signed.message.proposalSlot = i; + signed.message.validatorIndex = i; + pool.add(signed); + } + + pool.prune(currentSlot); + + expect(pool.get(0)).toBeNull(); + expect(pool.get(currentSlot)).not.toBeNull(); + }); +}); diff --git a/packages/beacon-node/test/unit/chain/validation/executionPayloadBid.test.ts b/packages/beacon-node/test/unit/chain/validation/executionPayloadBid.test.ts new file mode 100644 index 000000000000..755cdf2a0ad9 --- /dev/null +++ b/packages/beacon-node/test/unit/chain/validation/executionPayloadBid.test.ts @@ -0,0 +1,48 @@ +import {describe, expect, it, vi} from "vitest"; +import {ssz} from "@lodestar/types"; +import {ExecutionPayloadBidErrorCode, GossipAction} from "../../../../src/chain/errors/index.js"; +import {validateGossipExecutionPayloadBid} from "../../../../src/chain/validation/executionPayloadBid.js"; +import {getMockedBeaconChain} from "../../../mocks/mockedBeaconChain.js"; + +describe("chain / validation / executionPayloadBid", () => { + it("ignores bids when proposer preferences are not seen", async () => { + const chain = getMockedBeaconChain(); + const signedBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + signedBid.message.slot = 100; + + chain.clock.currentSlot = 100; + chain.getHeadStateAtCurrentEpoch = vi.fn().mockResolvedValue({slot: 100} as any); + chain.proposerPreferencesPool = {get: vi.fn().mockReturnValue(null)} as any; + + await expect(validateGossipExecutionPayloadBid(chain, signedBid)).rejects.toEqual( + expect.objectContaining({ + action: GossipAction.IGNORE, + type: expect.objectContaining({code: ExecutionPayloadBidErrorCode.PREFERENCES_NOT_SEEN}), + }) + ); + }); + + it("rejects bids with fee recipient or gas limit mismatch", async () => { + const chain = getMockedBeaconChain(); + const signedBid = ssz.gloas.SignedExecutionPayloadBid.defaultValue(); + signedBid.message.slot = 200; + signedBid.message.feeRecipient = new Uint8Array(20).fill(1); + signedBid.message.gasLimit = 35n; + + const signedPreferences = ssz.gloas.SignedProposerPreferences.defaultValue(); + signedPreferences.message.proposalSlot = 200; + signedPreferences.message.feeRecipient = new Uint8Array(20).fill(2); + signedPreferences.message.gasLimit = 36; + + chain.clock.currentSlot = 200; + chain.getHeadStateAtCurrentEpoch = vi.fn().mockResolvedValue({slot: 200} as any); + chain.proposerPreferencesPool = {get: vi.fn().mockReturnValue(signedPreferences)} as any; + + await expect(validateGossipExecutionPayloadBid(chain, signedBid)).rejects.toEqual( + expect.objectContaining({ + action: GossipAction.REJECT, + type: expect.objectContaining({code: ExecutionPayloadBidErrorCode.PREFERENCES_MISMATCH}), + }) + ); + }); +}); diff --git a/packages/beacon-node/test/unit/chain/validation/proposerPreferences.test.ts b/packages/beacon-node/test/unit/chain/validation/proposerPreferences.test.ts new file mode 100644 index 000000000000..121dc4758873 --- /dev/null +++ b/packages/beacon-node/test/unit/chain/validation/proposerPreferences.test.ts @@ -0,0 +1,120 @@ +import {describe, expect, it, vi} from "vitest"; +import {SLOTS_PER_EPOCH} from "@lodestar/params"; +import {CachedBeaconStateGloas, computeStartSlotAtEpoch} from "@lodestar/state-transition"; +import {ssz} from "@lodestar/types"; +import { + GossipAction, + ProposerPreferencesError, + ProposerPreferencesErrorCode, +} from "../../../../src/chain/errors/index.js"; +import {SeenProposerPreferences} from "../../../../src/chain/seenCache/seenProposerPreferences.js"; +import {validateGossipProposerPreferences} from "../../../../src/chain/validation/proposerPreferences.js"; +import {getMockedBeaconChain} from "../../../mocks/mockedBeaconChain.js"; + +describe("chain / validation / proposerPreferences", () => { + it("accepts valid proposer preferences", async () => { + const chain = getMockedBeaconChain(); + const currentEpoch = 10; + const nextEpoch = currentEpoch + 1; + const proposalSlot = computeStartSlotAtEpoch(nextEpoch); + const validatorIndex = 123; + + const proposerLookahead = new Array(SLOTS_PER_EPOCH * 2).fill(0); + proposerLookahead[SLOTS_PER_EPOCH + (proposalSlot % SLOTS_PER_EPOCH)] = validatorIndex; + + chain.getHeadStateAtCurrentEpoch = vi.fn().mockResolvedValue({ + slot: computeStartSlotAtEpoch(currentEpoch), + proposerLookahead: {getAll: () => proposerLookahead}, + } as unknown as CachedBeaconStateGloas); + chain.seenProposerPreferences = new SeenProposerPreferences() as any; + chain.index2pubkey[validatorIndex] = new Uint8Array(48).fill(1); + chain.bls.verifySignatureSets = vi.fn().mockResolvedValue(true); + + const signed = ssz.gloas.SignedProposerPreferences.defaultValue(); + signed.message.proposalSlot = proposalSlot; + signed.message.validatorIndex = validatorIndex; + + await expect(validateGossipProposerPreferences(chain, signed)).resolves.toBeUndefined(); + }); + + it("rejects invalid proposer lookahead slot mapping", async () => { + const chain = getMockedBeaconChain(); + const currentEpoch = 3; + const proposalSlot = computeStartSlotAtEpoch(currentEpoch + 1); + + chain.getHeadStateAtCurrentEpoch = vi.fn().mockResolvedValue({ + slot: computeStartSlotAtEpoch(currentEpoch), + proposerLookahead: {getAll: () => new Array(SLOTS_PER_EPOCH * 2).fill(1)}, + } as unknown as CachedBeaconStateGloas); + chain.seenProposerPreferences = new SeenProposerPreferences() as any; + + const signed = ssz.gloas.SignedProposerPreferences.defaultValue(); + signed.message.proposalSlot = proposalSlot; + signed.message.validatorIndex = 2; + + await expect(validateGossipProposerPreferences(chain, signed)).rejects.toEqual( + expect.objectContaining({ + action: GossipAction.REJECT, + type: expect.objectContaining({code: ProposerPreferencesErrorCode.INVALID_PROPOSAL_SLOT}), + }) + ); + }); + + it("ignores duplicate proposer preferences per slot/validator", async () => { + const chain = getMockedBeaconChain(); + const currentEpoch = 5; + const proposalSlot = computeStartSlotAtEpoch(currentEpoch + 1); + const validatorIndex = 8; + + const proposerLookahead = new Array(SLOTS_PER_EPOCH * 2).fill(0); + proposerLookahead[SLOTS_PER_EPOCH + (proposalSlot % SLOTS_PER_EPOCH)] = validatorIndex; + + chain.getHeadStateAtCurrentEpoch = vi.fn().mockResolvedValue({ + slot: computeStartSlotAtEpoch(currentEpoch), + proposerLookahead: {getAll: () => proposerLookahead}, + } as unknown as CachedBeaconStateGloas); + chain.seenProposerPreferences = new SeenProposerPreferences() as any; + chain.seenProposerPreferences.add(proposalSlot, validatorIndex); + + const signed = ssz.gloas.SignedProposerPreferences.defaultValue(); + signed.message.proposalSlot = proposalSlot; + signed.message.validatorIndex = validatorIndex; + + try { + await validateGossipProposerPreferences(chain, signed); + throw Error("expected validation to fail"); + } catch (e) { + expect(e).toBeInstanceOf(ProposerPreferencesError); + expect((e as ProposerPreferencesError).action).toBe(GossipAction.IGNORE); + expect((e as ProposerPreferencesError).type.code).toBe(ProposerPreferencesErrorCode.PREFERENCES_ALREADY_KNOWN); + } + }); + + it("rejects invalid signature", async () => { + const chain = getMockedBeaconChain(); + const currentEpoch = 7; + const proposalSlot = computeStartSlotAtEpoch(currentEpoch + 1); + const validatorIndex = 45; + const proposerLookahead = new Array(SLOTS_PER_EPOCH * 2).fill(0); + proposerLookahead[SLOTS_PER_EPOCH + (proposalSlot % SLOTS_PER_EPOCH)] = validatorIndex; + + chain.getHeadStateAtCurrentEpoch = vi.fn().mockResolvedValue({ + slot: computeStartSlotAtEpoch(currentEpoch), + proposerLookahead: {getAll: () => proposerLookahead}, + } as unknown as CachedBeaconStateGloas); + chain.seenProposerPreferences = new SeenProposerPreferences() as any; + chain.index2pubkey[validatorIndex] = new Uint8Array(48).fill(2); + chain.bls.verifySignatureSets = vi.fn().mockResolvedValue(false); + + const signed = ssz.gloas.SignedProposerPreferences.defaultValue(); + signed.message.proposalSlot = proposalSlot; + signed.message.validatorIndex = validatorIndex; + + await expect(validateGossipProposerPreferences(chain, signed)).rejects.toEqual( + expect.objectContaining({ + action: GossipAction.REJECT, + type: expect.objectContaining({code: ProposerPreferencesErrorCode.INVALID_SIGNATURE}), + }) + ); + }); +}); 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 8c797e1adb8e..c5455c8b84e3 100644 --- a/packages/state-transition/src/signatureSets/index.ts +++ b/packages/state-transition/src/signatureSets/index.ts @@ -19,6 +19,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..0e92521d4ea0 --- /dev/null +++ b/packages/state-transition/src/signatureSets/proposerPreferences.ts @@ -0,0 +1,20 @@ +import {BeaconConfig} from "@lodestar/config"; +import {DOMAIN_PROPOSER_PREFERENCES} from "@lodestar/params"; +import {Slot, gloas, ssz} from "@lodestar/types"; +import {computeSigningRoot} from "../util/index.js"; + +export function getProposerPreferencesSigningRoot( + config: BeaconConfig, + _stateSlot: Slot, + proposerPreferences: gloas.ProposerPreferences +): Uint8Array { + // Use proposalSlot for both domain slot and message slot to ensure correct fork version + // at the Gloas fork boundary (when validating at epoch N-1 for proposals in epoch N) + const domain = config.getDomain( + proposerPreferences.proposalSlot, + DOMAIN_PROPOSER_PREFERENCES, + proposerPreferences.proposalSlot + ); + + return computeSigningRoot(ssz.gloas.ProposerPreferences, proposerPreferences, domain); +} diff --git a/packages/validator/src/services/proposerPreferences.ts b/packages/validator/src/services/proposerPreferences.ts new file mode 100644 index 000000000000..a3c3a0bf3e4f --- /dev/null +++ b/packages/validator/src/services/proposerPreferences.ts @@ -0,0 +1,58 @@ +import {ApiClient, routes} from "@lodestar/api"; +import {BeaconConfig} from "@lodestar/config"; +import {Epoch} from "@lodestar/types"; +import {fromHex, toPubkeyHex} from "@lodestar/utils"; +import {Metrics} from "../metrics.js"; +import {IClock, LoggerVc, batchItems} from "../util/index.js"; +import {ValidatorStore} from "./validatorStore.js"; + +const PROPOSER_PREFERENCES_CHUNK_SIZE = 512; + +export function pollProposerPreferences( + config: BeaconConfig, + logger: LoggerVc, + api: ApiClient, + clock: IClock, + validatorStore: ValidatorStore, + _metrics: Metrics | null +): void { + async function publishProposerPreferences(epoch: Epoch): Promise { + if (epoch < config.GLOAS_FORK_EPOCH - 1) return; + + await validatorStore.pollValidatorIndices().catch((e: Error) => { + logger.error("Error on pollValidatorIndices for proposer preferences", {epoch}, e); + }); + + const nextEpoch = epoch + 1; + const res = await api.validator.getProposerDuties({epoch: nextEpoch}); + const proposerDuties = res.value(); + + const signedPreferences = await Promise.all( + proposerDuties + .filter((duty) => validatorStore.hasVotingPubkey(toPubkeyHex(duty.pubkey))) + .map(async (duty): Promise => { + const pubkeyHex = toPubkeyHex(duty.pubkey); + const proposerPreferences: routes.validator.SignedProposerPreferencesList[number]["message"] = { + proposalSlot: duty.slot, + validatorIndex: duty.validatorIndex, + feeRecipient: fromHex(validatorStore.getFeeRecipient(pubkeyHex)), + gasLimit: validatorStore.getGasLimit(pubkeyHex), + }; + return validatorStore.signProposerPreferences(pubkeyHex, proposerPreferences); + }) + ); + + const chunks = batchItems(signedPreferences, {batchSize: PROPOSER_PREFERENCES_CHUNK_SIZE}); + + for (const proposerPreferences of chunks) { + try { + await api.validator.submitProposerPreferences({proposerPreferences}); + logger.debug("Published proposer preferences to beacon node", {epoch, count: proposerPreferences.length}); + } catch (e) { + logger.error("Failed to publish proposer preferences to beacon node", {epoch}, e as Error); + } + } + } + + clock.runEveryEpoch(publishProposerPreferences); +} diff --git a/packages/validator/src/services/validatorStore.ts b/packages/validator/src/services/validatorStore.ts index 6f24ef6738a0..bbd4849edc11 100644 --- a/packages/validator/src/services/validatorStore.ts +++ b/packages/validator/src/services/validatorStore.ts @@ -8,6 +8,7 @@ import { DOMAIN_BEACON_ATTESTER, DOMAIN_BEACON_PROPOSER, DOMAIN_CONTRIBUTION_AND_PROOF, + DOMAIN_PROPOSER_PREFERENCES, DOMAIN_RANDAO, DOMAIN_SELECTION_PROOF, DOMAIN_SYNC_COMMITTEE, @@ -39,6 +40,7 @@ import { ValidatorIndex, altair, bellatrix, + gloas, phase0, ssz, } from "@lodestar/types"; @@ -725,6 +727,25 @@ export class ValidatorStore { }; } + async signProposerPreferences( + pubkey: BLSPubkeyMaybeHex, + proposerPreferences: gloas.ProposerPreferences + ): Promise { + const signingSlot = proposerPreferences.proposalSlot; + const domain = this.config.getDomain(signingSlot, DOMAIN_PROPOSER_PREFERENCES, signingSlot); + const signingRoot = computeSigningRoot(ssz.gloas.ProposerPreferences, proposerPreferences, domain); + + const signableMessage: SignableMessage = { + type: SignableMessageType.PROPOSER_PREFERENCES, + data: proposerPreferences, + }; + + return { + message: proposerPreferences, + signature: await this.getSignature(pubkey, signingRoot, signingSlot, signableMessage), + }; + } + async getValidatorRegistration( pubkeyMaybeHex: BLSPubkeyMaybeHex, regAttributes: {feeRecipient: ExecutionAddress; gasLimit: number}, diff --git a/packages/validator/src/util/externalSignerClient.ts b/packages/validator/src/util/externalSignerClient.ts index 9a74f47740fd..9d6dfcc1b414 100644 --- a/packages/validator/src/util/externalSignerClient.ts +++ b/packages/validator/src/util/externalSignerClient.ts @@ -11,6 +11,7 @@ import { RootHex, Slot, altair, + gloas, phase0, ssz, sszTypesFor, @@ -32,6 +33,7 @@ export enum SignableMessageType { SYNC_COMMITTEE_SELECTION_PROOF = "SYNC_COMMITTEE_SELECTION_PROOF", SYNC_COMMITTEE_CONTRIBUTION_AND_PROOF = "SYNC_COMMITTEE_CONTRIBUTION_AND_PROOF", VALIDATOR_REGISTRATION = "VALIDATOR_REGISTRATION", + PROPOSER_PREFERENCES = "PROPOSER_PREFERENCES", } const AggregationSlotType = new ContainerType({ @@ -80,7 +82,8 @@ export type SignableMessage = | {type: SignableMessageType.SYNC_COMMITTEE_MESSAGE; data: ValueOf} | {type: SignableMessageType.SYNC_COMMITTEE_SELECTION_PROOF; data: ValueOf} | {type: SignableMessageType.SYNC_COMMITTEE_CONTRIBUTION_AND_PROOF; data: altair.ContributionAndProof} - | {type: SignableMessageType.VALIDATOR_REGISTRATION; data: ValidatorRegistrationV1}; + | {type: SignableMessageType.VALIDATOR_REGISTRATION; data: ValidatorRegistrationV1} + | {type: SignableMessageType.PROPOSER_PREFERENCES; data: gloas.ProposerPreferences}; const requiresForkInfo: Record = { [SignableMessageType.AGGREGATION_SLOT]: true, @@ -95,6 +98,7 @@ const requiresForkInfo: Record = { [SignableMessageType.SYNC_COMMITTEE_SELECTION_PROOF]: true, [SignableMessageType.SYNC_COMMITTEE_CONTRIBUTION_AND_PROOF]: true, [SignableMessageType.VALIDATOR_REGISTRATION]: false, + [SignableMessageType.PROPOSER_PREFERENCES]: true, }; type Web3SignerSerializedRequest = { @@ -266,6 +270,9 @@ function serializerSignableMessagePayload(config: BeaconConfig, payload: Signabl case SignableMessageType.VALIDATOR_REGISTRATION: return {validator_registration: ssz.bellatrix.ValidatorRegistrationV1.toJson(payload.data)}; + + case SignableMessageType.PROPOSER_PREFERENCES: + return {proposer_preferences: ssz.gloas.ProposerPreferences.toJson(payload.data)}; } } diff --git a/packages/validator/src/validator.ts b/packages/validator/src/validator.ts index 02b5de07c5f7..9b88bcf65845 100644 --- a/packages/validator/src/validator.ts +++ b/packages/validator/src/validator.ts @@ -15,6 +15,7 @@ import {ValidatorEventEmitter} from "./services/emitter.js"; import {ExternalSignerOptions, pollExternalSignerPubkeys} from "./services/externalSignerSync.js"; import {IndicesService} from "./services/indices.js"; import {pollBuilderValidatorRegistration, pollPrepareBeaconProposer} from "./services/prepareBeaconProposer.js"; +import {pollProposerPreferences} from "./services/proposerPreferences.js"; import {SyncCommitteeService} from "./services/syncCommittee.js"; import {SyncingStatusTracker} from "./services/syncingStatusTracker.js"; import {Signer, ValidatorProposerConfig, ValidatorStore, defaultOptions} from "./services/validatorStore.js"; @@ -214,6 +215,7 @@ export class Validator { ); pollPrepareBeaconProposer(config, loggerVc, api, clock, validatorStore, metrics); pollBuilderValidatorRegistration(config, loggerVc, api, clock, validatorStore, metrics); + pollProposerPreferences(config, loggerVc, api, clock, validatorStore, metrics); pollExternalSignerPubkeys(config, loggerVc, controller.signal, validatorStore, opts.externalSigner); const emitter = new ValidatorEventEmitter();