@lodestar/beacon-node 1.47.0 → 1.48.0-dev.04d463c59a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/lib/api/impl/beacon/blocks/index.d.ts.map +1 -1
  2. package/lib/api/impl/beacon/blocks/index.js +4 -0
  3. package/lib/api/impl/beacon/blocks/index.js.map +1 -1
  4. package/lib/api/impl/validator/index.d.ts.map +1 -1
  5. package/lib/api/impl/validator/index.js +5 -0
  6. package/lib/api/impl/validator/index.js.map +1 -1
  7. package/lib/chain/blocks/index.js +7 -7
  8. package/lib/chain/blocks/index.js.map +1 -1
  9. package/lib/chain/blocks/verifyBlock.d.ts.map +1 -1
  10. package/lib/chain/blocks/verifyBlock.js +5 -2
  11. package/lib/chain/blocks/verifyBlock.js.map +1 -1
  12. package/lib/chain/chain.d.ts +1 -1
  13. package/lib/chain/chain.d.ts.map +1 -1
  14. package/lib/chain/chain.js +2 -1
  15. package/lib/chain/chain.js.map +1 -1
  16. package/lib/chain/errors/executionPayloadBid.d.ts +5 -0
  17. package/lib/chain/errors/executionPayloadBid.d.ts.map +1 -1
  18. package/lib/chain/errors/executionPayloadBid.js +1 -0
  19. package/lib/chain/errors/executionPayloadBid.js.map +1 -1
  20. package/lib/chain/interface.d.ts +2 -2
  21. package/lib/chain/interface.d.ts.map +1 -1
  22. package/lib/chain/prepareNextSlot.d.ts +5 -0
  23. package/lib/chain/prepareNextSlot.d.ts.map +1 -1
  24. package/lib/chain/prepareNextSlot.js +24 -1
  25. package/lib/chain/prepareNextSlot.js.map +1 -1
  26. package/lib/chain/produceBlock/computeNewStateRoot.d.ts.map +1 -1
  27. package/lib/chain/produceBlock/computeNewStateRoot.js +3 -25
  28. package/lib/chain/produceBlock/computeNewStateRoot.js.map +1 -1
  29. package/lib/chain/regen/interface.d.ts +1 -0
  30. package/lib/chain/regen/interface.d.ts.map +1 -1
  31. package/lib/chain/regen/interface.js +1 -0
  32. package/lib/chain/regen/interface.js.map +1 -1
  33. package/lib/chain/validation/block.d.ts.map +1 -1
  34. package/lib/chain/validation/block.js +40 -10
  35. package/lib/chain/validation/block.js.map +1 -1
  36. package/lib/chain/validation/executionPayloadBid.d.ts +15 -3
  37. package/lib/chain/validation/executionPayloadBid.d.ts.map +1 -1
  38. package/lib/chain/validation/executionPayloadBid.js +183 -28
  39. package/lib/chain/validation/executionPayloadBid.js.map +1 -1
  40. package/lib/execution/builder/apiClient.d.ts +6 -0
  41. package/lib/execution/builder/apiClient.d.ts.map +1 -1
  42. package/lib/execution/builder/apiClient.js +19 -1
  43. package/lib/execution/builder/apiClient.js.map +1 -1
  44. package/lib/execution/builder/validateBid.d.ts +1 -0
  45. package/lib/execution/builder/validateBid.d.ts.map +1 -1
  46. package/lib/execution/builder/validateBid.js +14 -6
  47. package/lib/execution/builder/validateBid.js.map +1 -1
  48. package/lib/metrics/metrics/lodestar.d.ts +10 -1
  49. package/lib/metrics/metrics/lodestar.d.ts.map +1 -1
  50. package/lib/metrics/metrics/lodestar.js +19 -3
  51. package/lib/metrics/metrics/lodestar.js.map +1 -1
  52. package/lib/network/network.d.ts.map +1 -1
  53. package/lib/network/network.js +5 -4
  54. package/lib/network/network.js.map +1 -1
  55. package/lib/network/processor/gossipQueues/indexed.d.ts.map +1 -1
  56. package/lib/network/processor/gossipQueues/indexed.js +0 -1
  57. package/lib/network/processor/gossipQueues/indexed.js.map +1 -1
  58. package/lib/network/processor/index.d.ts.map +1 -1
  59. package/lib/network/processor/index.js +16 -8
  60. package/lib/network/processor/index.js.map +1 -1
  61. package/lib/sync/range/chain.d.ts.map +1 -1
  62. package/lib/sync/range/chain.js +9 -2
  63. package/lib/sync/range/chain.js.map +1 -1
  64. package/lib/sync/range/range.js +1 -1
  65. package/lib/sync/range/range.js.map +1 -1
  66. package/package.json +15 -15
  67. package/src/api/impl/beacon/blocks/index.ts +4 -0
  68. package/src/api/impl/validator/index.ts +5 -0
  69. package/src/chain/blocks/index.ts +7 -7
  70. package/src/chain/blocks/verifyBlock.ts +5 -2
  71. package/src/chain/chain.ts +2 -1
  72. package/src/chain/errors/executionPayloadBid.ts +6 -0
  73. package/src/chain/interface.ts +2 -2
  74. package/src/chain/prepareNextSlot.ts +26 -1
  75. package/src/chain/produceBlock/computeNewStateRoot.ts +3 -38
  76. package/src/chain/regen/interface.ts +1 -0
  77. package/src/chain/validation/block.ts +43 -10
  78. package/src/chain/validation/executionPayloadBid.ts +212 -32
  79. package/src/execution/builder/apiClient.ts +21 -1
  80. package/src/execution/builder/validateBid.ts +22 -7
  81. package/src/metrics/metrics/lodestar.ts +21 -4
  82. package/src/network/network.ts +7 -4
  83. package/src/network/processor/gossipQueues/indexed.ts +0 -1
  84. package/src/network/processor/index.ts +25 -11
  85. package/src/sync/range/chain.ts +9 -2
  86. package/src/sync/range/range.ts +1 -1
@@ -1,11 +1,5 @@
1
- import {
2
- DataAvailabilityStatus,
3
- ExecutionPayloadStatus,
4
- IBeaconStateView,
5
- StateHashTreeRootSource,
6
- } from "@lodestar/state-transition";
1
+ import {EMPTY_SIGNATURE, IBeaconStateView} from "@lodestar/state-transition";
7
2
  import {BeaconBlock, BlindedBeaconBlock, Gwei, Root} from "@lodestar/types";
8
- import {ZERO_HASH} from "../../constants/index.js";
9
3
  import {Metrics} from "../../metrics/index.js";
10
4
 
11
5
  /**
@@ -19,35 +13,6 @@ export function computeNewStateRoot(
19
13
  block: BeaconBlock | BlindedBeaconBlock
20
14
  ): {newStateRoot: Root; proposerReward: Gwei; postState: IBeaconStateView} {
21
15
  // Set signature to zero to re-use stateTransition() function which requires the SignedBeaconBlock type
22
- const blockEmptySig = {message: block, signature: ZERO_HASH};
23
-
24
- const postState = state.stateTransition(
25
- blockEmptySig,
26
- {
27
- // ExecutionPayloadStatus.valid: Assume payload valid, it has been produced by a trusted EL
28
- executionPayloadStatus: ExecutionPayloadStatus.valid,
29
- // DataAvailabilityStatus.available: Assume the blobs to be available, have just been produced by trusted EL
30
- dataAvailabilityStatus: DataAvailabilityStatus.Available,
31
- // verifyStateRoot: false | the root in the block is zero-ed, it's being computed here
32
- verifyStateRoot: false,
33
- // verifyProposer: false | as the block signature is zero-ed
34
- verifyProposer: false,
35
- // verifySignatures: false | since the data to assemble the block is trusted
36
- verifySignatures: false,
37
- // Preserve cache in source state, since the resulting state is not added to the state cache
38
- dontTransferCache: true,
39
- },
40
- {metrics}
41
- );
42
-
43
- const {attestations, syncAggregate, slashing} = postState.proposerRewards;
44
- const proposerReward = BigInt(attestations + syncAggregate + slashing);
45
-
46
- const hashTreeRootTimer = metrics?.stateHashTreeRootTime.startTimer({
47
- source: StateHashTreeRootSource.computeNewStateRoot,
48
- });
49
- const newStateRoot = postState.hashTreeRoot();
50
- hashTreeRootTimer?.();
51
-
52
- return {newStateRoot, proposerReward, postState};
16
+ const signedBlock = {message: block, signature: EMPTY_SIGNATURE};
17
+ return state.computeNewStateRoot({block: signedBlock}, {metrics});
53
18
  }
@@ -24,6 +24,7 @@ export enum RegenCaller {
24
24
  validateApiVoluntaryExit = "validateApiVoluntaryExit",
25
25
  publishDeferredVoluntaryExits = "publishDeferredVoluntaryExits",
26
26
  validateGossipExecutionPayloadBid = "validateGossipExecutionPayloadBid",
27
+ validateApiExecutionPayloadBid = "validateApiExecutionPayloadBid",
27
28
  validateGossipPayloadAttestationMessage = "validateGossipPayloadAttestationMessage",
28
29
  validateGossipProposerPreferences = "validateGossipProposerPreferences",
29
30
  onForkChoiceFinalized = "onForkChoiceFinalized",
@@ -12,6 +12,7 @@ import {
12
12
  MAX_PROPOSER_SLASHINGS,
13
13
  MAX_VOLUNTARY_EXITS,
14
14
  MAX_WITHDRAWAL_REQUESTS_PER_PAYLOAD,
15
+ MIN_SEED_LOOKAHEAD,
15
16
  isForkPostBellatrix,
16
17
  isForkPostDeneb,
17
18
  isForkPostGloas,
@@ -249,26 +250,58 @@ export async function validateGossipBlock(
249
250
  // This requires execution engine integration to verify the parent block hash
250
251
  }
251
252
 
252
- // use getPreState to reload state if needed. It also checks for whether the current finalized checkpoint is an ancestor of the block.
253
- // As a result, we throw an IGNORE (whereas the spec says we should REJECT for this scenario).
253
+ // For gossip forwarding we only need the state to check the block's proposer index.
254
+ // If the state cannot be regenerated we throw an IGNORE (whereas the spec says we should REJECT for the
255
+ // finalized-ancestor scenario, which is already guarded by the parentBlock lookup above).
254
256
  // this is something we should change this in the future to make the code airtight to the spec.
255
257
  // [IGNORE] The block's parent (defined by block.parent_root) has been seen (via both gossip and non-gossip sources) (a client MAY queue blocks for processing once the parent block is retrieved).
256
258
  // [REJECT] The block's parent (defined by block.parent_root) passes validation.
257
- const blockState = await chain.regen
258
- .getPreState(block, {dontTransferCache: true}, RegenCaller.validateGossipBlock)
259
- .catch(() => {
260
- throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.PARENT_BLOCK_UNKNOWN, parentRoot});
259
+ const canUseParentState = blockEpoch - computeEpochAtSlot(parentBlock.slot) <= MIN_SEED_LOOKAHEAD;
260
+
261
+ const getValidationState = async () => {
262
+ const getPreState = () =>
263
+ chain.regen.getPreState(block, {dontTransferCache: true}, RegenCaller.validateGossipBlock);
264
+ if (canUseParentState) {
265
+ try {
266
+ const parentState = await chain.regen.getState(parentBlock.stateRoot, RegenCaller.validateGossipBlock);
267
+ chain.metrics?.gossipBlock.preStateSource.inc({source: "parentState"});
268
+ return parentState;
269
+ } catch {
270
+ // if parent state is not in memory, we fall back to disk reload / dial-forward
271
+ chain.metrics?.gossipBlock.preStateSource.inc({source: "fallbackPreState"});
272
+ chain.logger.debug("Parent state not in memory, falling back to getPreState for gossip block validation", {
273
+ slot: blockSlot,
274
+ root: blockRoot,
275
+ parentSlot: parentBlock.slot,
276
+ parentRoot,
277
+ });
278
+ return getPreState();
279
+ }
280
+ }
281
+ // parent is >1 epoch behind (deep skip) → beyond proposer-lookahead range, must dial forward
282
+ chain.metrics?.gossipBlock.preStateSource.inc({source: "preState"});
283
+ chain.logger.debug("Cannot use parent state for gossip block validation, dialing forward via getPreState", {
284
+ slot: blockSlot,
285
+ root: blockRoot,
286
+ parentSlot: parentBlock.slot,
287
+ parentRoot,
261
288
  });
289
+ return getPreState();
290
+ };
291
+
292
+ const state = await getValidationState().catch(() => {
293
+ throw new BlockGossipError(GossipAction.IGNORE, {code: BlockErrorCode.PARENT_BLOCK_UNKNOWN, parentRoot});
294
+ });
262
295
 
263
296
  // in forky condition, make sure to populate ShufflingCache with regened state
264
- chain.shufflingCache.processState(blockState);
297
+ chain.shufflingCache.processState(state);
265
298
 
266
299
  // [REJECT] The block's execution payload timestamp is correct with respect to the slot
267
300
  // -- i.e. execution_payload.timestamp == compute_timestamp_at_slot(state, block.slot).
268
301
  if (isForkPostBellatrix(fork) && !isForkPostGloas(fork)) {
269
302
  if (!isExecutionBlockBodyType(block.body)) throw Error("Not execution block body type");
270
303
  const executionPayload = block.body.executionPayload;
271
- if (isStatePostBellatrix(blockState) && blockState.isExecutionStateType && blockState.isExecutionEnabled(block)) {
304
+ if (isStatePostBellatrix(state) && state.isExecutionStateType && state.isExecutionEnabled(block)) {
272
305
  const expectedTimestamp = computeTimeAtSlot(config, blockSlot, chain.genesisTime);
273
306
  if (executionPayload.timestamp !== computeTimeAtSlot(config, blockSlot, chain.genesisTime)) {
274
307
  throw new BlockGossipError(GossipAction.REJECT, {
@@ -281,7 +314,7 @@ export async function validateGossipBlock(
281
314
  }
282
315
 
283
316
  // [REJECT] The proposer index is a valid validator index
284
- if (proposerIndex >= blockState.validatorCount) {
317
+ if (proposerIndex >= state.validatorCount) {
285
318
  throw new BlockGossipError(GossipAction.REJECT, {code: BlockErrorCode.UNKNOWN_PROPOSER, proposerIndex});
286
319
  }
287
320
 
@@ -293,7 +326,7 @@ export async function validateGossipBlock(
293
326
  // shuffling (defined by parent_root/slot). If the proposer_index cannot immediately be verified against the expected
294
327
  // shuffling, the block MAY be queued for later processing while proposers for the block's branch are calculated --
295
328
  // in such a case do not REJECT, instead IGNORE this message.
296
- if (blockState.getBeaconProposer(blockSlot) !== proposerIndex) {
329
+ if (state.getBeaconProposer(blockSlot) !== proposerIndex) {
297
330
  throw new BlockGossipError(GossipAction.REJECT, {code: BlockErrorCode.INCORRECT_PROPOSER, proposerIndex});
298
331
  }
299
332
 
@@ -1,4 +1,4 @@
1
- import {IForkChoice, ProtoBlock} from "@lodestar/fork-choice";
1
+ import {ProtoBlock} from "@lodestar/fork-choice";
2
2
  import {PAYLOAD_BUILDER_VERSION} from "@lodestar/params";
3
3
  import {
4
4
  computeEpochAtSlot,
@@ -46,11 +46,12 @@ function getMinBidValue(currentHighestBid: number): number {
46
46
  /**
47
47
  * Check whether a bid builds on one of the paths compatible with the local head branch.
48
48
  *
49
- * Building directly on the parent is allowed for proposer-boost reorgs outside epoch boundaries.
49
+ * Building directly on the parent is allowed for proposer-boost reorgs, including at epoch
50
+ * boundaries when the head is weak enough that a reorg onto the strong parent is predicted.
50
51
  * Otherwise the bid must build on the local head's full or empty payload variant, as selected for its slot.
51
52
  */
52
53
  function isBidCompatibleWithHead(
53
- forkChoice: IForkChoice,
54
+ chain: IBeaconChain,
54
55
  head: ProtoBlock,
55
56
  bidSlot: Slot,
56
57
  bidParentBlockRoot: RootHex,
@@ -60,9 +61,13 @@ function isBidCompatibleWithHead(
60
61
  const buildsOnParentPayload = bidParentBlockHash === head.parentBlockHash;
61
62
 
62
63
  if (buildsOnParentBlock && buildsOnParentPayload) {
63
- // The spec allows this at epoch boundaries, but Lodestar does not propagate these bids because validating
64
- // them requires an epoch transition for a parent state that cannot be used for proposer-boost reorgs.
65
- return !isStartSlotOfEpoch(bidSlot);
64
+ if (!isStartSlotOfEpoch(bidSlot)) {
65
+ return true;
66
+ }
67
+ // Epoch-boundary reorgs are allowed, but only propagate the bid if its parent matches the block
68
+ // we predict a proposer would reorg onto — predictProposerHead returns the strong parent only
69
+ // when the head is weak.
70
+ return chain.predictProposerHead().blockRoot === bidParentBlockRoot;
66
71
  }
67
72
 
68
73
  if (bidParentBlockRoot !== head.blockRoot) {
@@ -71,18 +76,170 @@ function isBidCompatibleWithHead(
71
76
 
72
77
  const buildsOnHeadPayload = bidParentBlockHash === head.executionPayloadBlockHash;
73
78
 
74
- if (forkChoice.shouldBuildOnFull(head, bidSlot)) {
79
+ if (chain.forkChoice.shouldBuildOnFull(head, bidSlot)) {
75
80
  return buildsOnHeadPayload;
76
81
  }
77
82
 
78
83
  return buildsOnParentPayload;
79
84
  }
80
85
 
86
+ /**
87
+ * Validation for bids submitted via the API by the operator's own builder.
88
+ *
89
+ * Only non-transient checks are applied: slot later than parent, zero execution payment, blob
90
+ * commitment count, builder eligibility, version and balance coverage, prev_randao, and signature.
91
+ * Transient gossip rules (head compatibility, first bid per tuple, value increment, proposer
92
+ * preferences) are not applied, since those only limit forwarding of peers' messages and the
93
+ * builder may legitimately bid on a branch this node does not consider head.
94
+ * The parent-payload builder-exit rule is omitted because this endpoint only accepts bids from
95
+ * the operator's own builder.
96
+ *
97
+ * Throws on any failed check. Also throws if the bid's parent block is unknown or its state is
98
+ * unavailable, since the checks cannot be evaluated against the parent branch.
99
+ */
81
100
  export async function validateApiExecutionPayloadBid(
82
101
  chain: IBeaconChain,
83
102
  signedExecutionPayloadBid: gloas.SignedExecutionPayloadBid
84
- ): Promise<{proposerIndex: ValidatorIndex}> {
85
- return validateExecutionPayloadBid(chain, signedExecutionPayloadBid);
103
+ ): Promise<void> {
104
+ const bid = signedExecutionPayloadBid.message;
105
+ const bidParentBlockRoot = toRootHex(bid.parentBlockRoot);
106
+
107
+ // [REJECT] `bid.execution_payment` is zero.
108
+ if (bid.executionPayment !== 0n) {
109
+ throw new ExecutionPayloadBidError(GossipAction.REJECT, {
110
+ code: ExecutionPayloadBidErrorCode.NON_ZERO_EXECUTION_PAYMENT,
111
+ builderIndex: bid.builderIndex,
112
+ executionPayment: bid.executionPayment,
113
+ });
114
+ }
115
+
116
+ // [REJECT] The length of KZG commitments is less than or equal to the limitation defined in the
117
+ // consensus layer -- i.e. validate that
118
+ // `len(bid.blob_kzg_commitments) <= get_blob_parameters(compute_epoch_at_slot(bid.slot)).max_blobs_per_block`.
119
+ const blobKzgCommitmentsLen = bid.blobKzgCommitments.length;
120
+ const maxBlobsPerBlock = chain.config.getMaxBlobsPerBlock(computeEpochAtSlot(bid.slot));
121
+ if (blobKzgCommitmentsLen > maxBlobsPerBlock) {
122
+ throw new ExecutionPayloadBidError(GossipAction.REJECT, {
123
+ code: ExecutionPayloadBidErrorCode.TOO_MANY_KZG_COMMITMENTS,
124
+ blobKzgCommitmentsLen,
125
+ commitmentLimit: maxBlobsPerBlock,
126
+ });
127
+ }
128
+
129
+ // [IGNORE] `bid.slot` is the current slot, or the next slot (`bid.slot - 1` is current), allowing for `MAXIMUM_GOSSIP_CLOCK_DISPARITY`.
130
+ if (
131
+ !chain.clock.isCurrentSlotGivenGossipDisparity(bid.slot) &&
132
+ !chain.clock.isCurrentSlotGivenGossipDisparity(bid.slot - 1)
133
+ ) {
134
+ throw new ExecutionPayloadBidError(GossipAction.IGNORE, {
135
+ code: ExecutionPayloadBidErrorCode.INVALID_SLOT,
136
+ builderIndex: bid.builderIndex,
137
+ slot: bid.slot,
138
+ });
139
+ }
140
+
141
+ // [IGNORE] `bid.parent_block_root` is the hash tree root of a known beacon block in fork choice.
142
+ // Moved earlier than the spec ordering so we can derive the proposer dependent root for the
143
+ // proposer-preferences lookup below from a known fork-choice block.
144
+ const parentBlock = chain.forkChoice.getBlockHexDefaultStatus(bidParentBlockRoot);
145
+ if (parentBlock === null) {
146
+ throw new ExecutionPayloadBidError(GossipAction.IGNORE, {
147
+ code: ExecutionPayloadBidErrorCode.UNKNOWN_BLOCK_ROOT,
148
+ parentBlockRoot: bidParentBlockRoot,
149
+ });
150
+ }
151
+
152
+ // [REJECT] The bid is for a higher slot than its parent block -- i.e.
153
+ // validate that `bid.slot` is greater than the slot of the block with root
154
+ // `bid.parent_block_root`.
155
+ if (bid.slot <= parentBlock.slot) {
156
+ throw new ExecutionPayloadBidError(GossipAction.REJECT, {
157
+ code: ExecutionPayloadBidErrorCode.NOT_LATER_THAN_PARENT,
158
+ parentSlot: parentBlock.slot,
159
+ slot: bid.slot,
160
+ });
161
+ }
162
+
163
+ const state = await chain.regen
164
+ .getBlockSlotState(parentBlock, bid.slot, {dontTransferCache: true}, RegenCaller.validateApiExecutionPayloadBid)
165
+ .catch(() => {
166
+ throw new ExecutionPayloadBidError(GossipAction.IGNORE, {
167
+ code: ExecutionPayloadBidErrorCode.UNKNOWN_BLOCK_ROOT,
168
+ parentBlockRoot: bidParentBlockRoot,
169
+ });
170
+ });
171
+
172
+ if (!isStatePostGloas(state)) {
173
+ throw new Error(`Expected gloas+ state for execution payload bid validation, got fork=${state.forkName}`);
174
+ }
175
+
176
+ // [REJECT] `bid.builder_index` is within bounds -- i.e. `bid.builder_index < len(state.builders)`.
177
+ // `state.getBuilder` returns a lazy SSZ `getReadonly` view that is not bounds-checked eagerly; an
178
+ // out-of-range index only throws (`LeafNode has no right node`) on deferred field access (e.g. inside
179
+ // `isActiveBuilder`), escaping a try/catch around `getBuilder`. Check the length explicitly instead.
180
+ if (bid.builderIndex >= state.getBuildersLength()) {
181
+ throw new ExecutionPayloadBidError(GossipAction.REJECT, {
182
+ code: ExecutionPayloadBidErrorCode.BUILDER_NOT_ELIGIBLE,
183
+ builderIndex: bid.builderIndex,
184
+ });
185
+ }
186
+
187
+ // [REJECT] `bid.builder_index` is a valid/active builder index -- i.e.
188
+ // `is_active_builder(state, bid.builder_index)` returns `True`.
189
+ const builder = state.getBuilder(bid.builderIndex);
190
+ if (!isActiveBuilder(builder, state.finalizedCheckpoint.epoch)) {
191
+ throw new ExecutionPayloadBidError(GossipAction.REJECT, {
192
+ code: ExecutionPayloadBidErrorCode.BUILDER_NOT_ELIGIBLE,
193
+ builderIndex: bid.builderIndex,
194
+ });
195
+ }
196
+
197
+ // [REJECT] The builder version is `PAYLOAD_BUILDER_VERSION` -- i.e.
198
+ // `state.builders[bid.builder_index].version == PAYLOAD_BUILDER_VERSION`.
199
+ if (builder.version !== PAYLOAD_BUILDER_VERSION) {
200
+ throw new ExecutionPayloadBidError(GossipAction.REJECT, {
201
+ code: ExecutionPayloadBidErrorCode.INVALID_BUILDER_VERSION,
202
+ builderIndex: bid.builderIndex,
203
+ version: builder.version,
204
+ expectedVersion: PAYLOAD_BUILDER_VERSION,
205
+ });
206
+ }
207
+
208
+ // [IGNORE] `bid.value` is less or equal than the builder's excess balance --
209
+ // i.e. `can_builder_cover_bid(state, builder_index, amount)` returns `True`.
210
+ if (!state.canBuilderCoverBid(bid.builderIndex, bid.value)) {
211
+ throw new ExecutionPayloadBidError(GossipAction.IGNORE, {
212
+ code: ExecutionPayloadBidErrorCode.BID_TOO_HIGH,
213
+ bidValue: bid.value,
214
+ builderBalance: builder.balance,
215
+ });
216
+ }
217
+
218
+ // [REJECT] `bid.prev_randao` is the correct RANDAO mix -- i.e. validate that
219
+ // `bid.prev_randao == get_randao_mix(parent_state, get_current_epoch(parent_state))`.
220
+ const randaoMix = state.getRandaoMix(computeEpochAtSlot(state.slot));
221
+ if (!byteArrayEquals(bid.prevRandao, randaoMix)) {
222
+ throw new ExecutionPayloadBidError(GossipAction.REJECT, {
223
+ code: ExecutionPayloadBidErrorCode.INVALID_PREV_RANDAO,
224
+ builderIndex: bid.builderIndex,
225
+ bidPrevRandao: toHex(bid.prevRandao),
226
+ expectedPrevRandao: toHex(randaoMix),
227
+ });
228
+ }
229
+
230
+ // [REJECT] `signed_execution_payload_bid.signature` is valid with respect to the `bid.builder_index`.
231
+ const signatureSet = createSingleSignatureSetFromComponents(
232
+ builder.pubkey,
233
+ getExecutionPayloadBidSigningRoot(chain.config, bid),
234
+ signedExecutionPayloadBid.signature
235
+ );
236
+ if (!(await chain.bls.verifySignatureSets([signatureSet]))) {
237
+ throw new ExecutionPayloadBidError(GossipAction.REJECT, {
238
+ code: ExecutionPayloadBidErrorCode.INVALID_SIGNATURE,
239
+ builderIndex: bid.builderIndex,
240
+ slot: bid.slot,
241
+ });
242
+ }
86
243
  }
87
244
 
88
245
  export async function validateGossipExecutionPayloadBid(
@@ -100,6 +257,28 @@ async function validateExecutionPayloadBid(
100
257
  const bidParentBlockRoot = toRootHex(bid.parentBlockRoot);
101
258
  const bidParentBlockHash = toRootHex(bid.parentBlockHash);
102
259
 
260
+ // [REJECT] `bid.execution_payment` is zero.
261
+ if (bid.executionPayment !== 0n) {
262
+ throw new ExecutionPayloadBidError(GossipAction.REJECT, {
263
+ code: ExecutionPayloadBidErrorCode.NON_ZERO_EXECUTION_PAYMENT,
264
+ builderIndex: bid.builderIndex,
265
+ executionPayment: bid.executionPayment,
266
+ });
267
+ }
268
+
269
+ // [REJECT] The length of KZG commitments is less than or equal to the limitation defined in the
270
+ // consensus layer -- i.e. validate that
271
+ // `len(bid.blob_kzg_commitments) <= get_blob_parameters(compute_epoch_at_slot(bid.slot)).max_blobs_per_block`.
272
+ const blobKzgCommitmentsLen = bid.blobKzgCommitments.length;
273
+ const maxBlobsPerBlock = chain.config.getMaxBlobsPerBlock(computeEpochAtSlot(bid.slot));
274
+ if (blobKzgCommitmentsLen > maxBlobsPerBlock) {
275
+ throw new ExecutionPayloadBidError(GossipAction.REJECT, {
276
+ code: ExecutionPayloadBidErrorCode.TOO_MANY_KZG_COMMITMENTS,
277
+ blobKzgCommitmentsLen,
278
+ commitmentLimit: maxBlobsPerBlock,
279
+ });
280
+ }
281
+
103
282
  // [IGNORE] `bid.slot` is the current slot, or the next slot (`bid.slot - 1` is current), allowing for `MAXIMUM_GOSSIP_CLOCK_DISPARITY`.
104
283
  if (
105
284
  !chain.clock.isCurrentSlotGivenGossipDisparity(bid.slot) &&
@@ -114,7 +293,7 @@ async function validateExecutionPayloadBid(
114
293
 
115
294
  // [IGNORE] The bid is compatible with the current head branch.
116
295
  const head = chain.forkChoice.getHead();
117
- if (!isBidCompatibleWithHead(chain.forkChoice, head, bid.slot, bidParentBlockRoot, bidParentBlockHash)) {
296
+ if (!isBidCompatibleWithHead(chain, head, bid.slot, bidParentBlockRoot, bidParentBlockHash)) {
118
297
  throw new ExecutionPayloadBidError(GossipAction.IGNORE, {
119
298
  code: ExecutionPayloadBidErrorCode.INCOMPATIBLE_WITH_HEAD,
120
299
  slot: bid.slot,
@@ -245,15 +424,6 @@ async function validateExecutionPayloadBid(
245
424
  });
246
425
  }
247
426
 
248
- // [REJECT] `bid.execution_payment` is zero.
249
- if (bid.executionPayment !== 0n) {
250
- throw new ExecutionPayloadBidError(GossipAction.REJECT, {
251
- code: ExecutionPayloadBidErrorCode.NON_ZERO_EXECUTION_PAYMENT,
252
- builderIndex: bid.builderIndex,
253
- executionPayment: bid.executionPayment,
254
- });
255
- }
256
-
257
427
  // [IGNORE] `bid.fee_recipient == proposer_preferences.fee_recipient`.
258
428
  if (!byteArrayEquals(bid.feeRecipient, proposerPreferences.message.feeRecipient)) {
259
429
  throw new ExecutionPayloadBidError(GossipAction.IGNORE, {
@@ -294,19 +464,6 @@ async function validateExecutionPayloadBid(
294
464
  });
295
465
  }
296
466
 
297
- // [REJECT] The length of KZG commitments is less than or equal to the limitation defined in the
298
- // consensus layer -- i.e. validate that
299
- // `len(bid.blob_kzg_commitments) <= get_blob_parameters(compute_epoch_at_slot(bid.slot)).max_blobs_per_block`.
300
- const blobKzgCommitmentsLen = bid.blobKzgCommitments.length;
301
- const maxBlobsPerBlock = chain.config.getMaxBlobsPerBlock(computeEpochAtSlot(bid.slot));
302
- if (blobKzgCommitmentsLen > maxBlobsPerBlock) {
303
- throw new ExecutionPayloadBidError(GossipAction.REJECT, {
304
- code: ExecutionPayloadBidErrorCode.TOO_MANY_KZG_COMMITMENTS,
305
- blobKzgCommitmentsLen,
306
- commitmentLimit: maxBlobsPerBlock,
307
- });
308
- }
309
-
310
467
  // [IGNORE] this bid is the highest value bid seen for the tuple
311
468
  // `(bid.slot, bid.parent_block_hash, bid.parent_block_root)`.
312
469
  // As a DoS prevention measure, the bid must also exceed the current highest bid by a minimum
@@ -342,6 +499,29 @@ async function validateExecutionPayloadBid(
342
499
  });
343
500
  }
344
501
 
502
+ // [IGNORE] The parent's payload does not try to exit the builder
503
+ if (byteArrayEquals(bid.parentBlockHash, state.latestExecutionPayloadBid.blockHash)) {
504
+ const requests = await chain.getParentExecutionRequests(parentBlock.slot, parentBlock.blockRoot).catch(() => {
505
+ throw new ExecutionPayloadBidError(GossipAction.IGNORE, {
506
+ code: ExecutionPayloadBidErrorCode.UNKNOWN_PARENT_BLOCK_HASH,
507
+ parentBlockHash: bidParentBlockHash,
508
+ });
509
+ });
510
+ if (
511
+ requests.builderExits.some(
512
+ (request) =>
513
+ byteArrayEquals(request.pubkey, builder.pubkey) &&
514
+ byteArrayEquals(request.sourceAddress, builder.executionAddress)
515
+ )
516
+ ) {
517
+ throw new ExecutionPayloadBidError(GossipAction.IGNORE, {
518
+ code: ExecutionPayloadBidErrorCode.BUILDER_MAY_EXIT,
519
+ builderIndex: bid.builderIndex,
520
+ parentBlockRoot: bidParentBlockRoot,
521
+ });
522
+ }
523
+ }
524
+
345
525
  // [REJECT] `signed_execution_payload_bid.signature` is valid with respect to the `bid.builder_index`.
346
526
  const signatureSet = createSingleSignatureSetFromComponents(
347
527
  builder.pubkey,
@@ -201,6 +201,26 @@ export class BuilderApiClient {
201
201
  }
202
202
  }
203
203
 
204
+ /**
205
+ * Establish the connection to every known builder outside the bid deadline, a cold TCP/TLS handshake
206
+ * to a distant builder can exhaust `BUILDER_BID_DEADLINE_MS` on its own, and a long idle connection
207
+ * may be half-open and only fail once written to.
208
+ */
209
+ async checkStatus(): Promise<void> {
210
+ await Promise.all(
211
+ Array.from(this.clients.entries()).map(async ([url, client]) => {
212
+ try {
213
+ (await client.status()).assertOk();
214
+ this.metrics?.builderApi.statusChecks.inc({status: "success"});
215
+ this.logger?.debug("Builder is ready", {builder: toPrintableUrl(url)});
216
+ } catch (e) {
217
+ this.metrics?.builderApi.statusChecks.inc({status: "error"});
218
+ this.logger?.warn("Builder status check failed", {builder: toPrintableUrl(url)}, e as Error);
219
+ }
220
+ })
221
+ );
222
+ }
223
+
204
224
  private async getOrCreateClient(
205
225
  url: BuilderUrl,
206
226
  proposerPubkey: BLSPubkey,
@@ -237,7 +257,7 @@ export class BuilderApiClient {
237
257
  {config: this.config, metrics: this.metrics?.builderHttpClient, logger: this.logger}
238
258
  );
239
259
  this.clients.set(url, client);
240
- this.logger?.info("External builder registered", {url: toPrintableUrl(url)});
260
+ this.logger?.debug("External builder registered", {url: toPrintableUrl(url)});
241
261
  }
242
262
  return client;
243
263
  }
@@ -37,10 +37,11 @@ export async function validateBuilderApiExecutionPayloadBid(
37
37
  parentBlockHash: RootHex;
38
38
  parentBlockRoot: RootHex;
39
39
  entry: routes.validator.BuilderEntry;
40
+ getParentExecutionRequests: () => Promise<gloas.ExecutionRequests>;
40
41
  }
41
42
  ): Promise<void> {
42
43
  const bid = signedExecutionPayloadBid.message;
43
- const {slot, parentBlock, parentBlockHash, parentBlockRoot, entry} = request;
44
+ const {slot, parentBlock, parentBlockHash, parentBlockRoot, entry, getParentExecutionRequests} = request;
44
45
 
45
46
  if (bid.slot !== slot) {
46
47
  throw Error(`Bid slot=${bid.slot} does not match requested slot=${slot}`);
@@ -55,6 +56,12 @@ export async function validateBuilderApiExecutionPayloadBid(
55
56
  );
56
57
  }
57
58
 
59
+ const blobKzgCommitmentsLen = bid.blobKzgCommitments.length;
60
+ const maxBlobsPerBlock = chain.config.getMaxBlobsPerBlock(computeEpochAtSlot(bid.slot));
61
+ if (blobKzgCommitmentsLen > maxBlobsPerBlock) {
62
+ throw Error(`Bid has too many KZG commitments len=${blobKzgCommitmentsLen} limit=${maxBlobsPerBlock}`);
63
+ }
64
+
58
65
  const totalPayment = getBuilderBidTotalGwei(bid, entry.maxExecutionPayment);
59
66
  if (totalPayment < entry.minBid) {
60
67
  throw Error(
@@ -99,12 +106,6 @@ export async function validateBuilderApiExecutionPayloadBid(
99
106
  );
100
107
  }
101
108
 
102
- const blobKzgCommitmentsLen = bid.blobKzgCommitments.length;
103
- const maxBlobsPerBlock = chain.config.getMaxBlobsPerBlock(computeEpochAtSlot(bid.slot));
104
- if (blobKzgCommitmentsLen > maxBlobsPerBlock) {
105
- throw Error(`Bid has too many KZG commitments len=${blobKzgCommitmentsLen} limit=${maxBlobsPerBlock}`);
106
- }
107
-
108
109
  // The coverage check only applies to the staked collateral payment, a pure execution
109
110
  // layer payment bid has nothing to cover on-chain
110
111
  if (bid.value > 0 && !state.canBuilderCoverBid(bid.builderIndex, bid.value)) {
@@ -118,6 +119,20 @@ export async function validateBuilderApiExecutionPayloadBid(
118
119
  throw Error(`Invalid bid prevRandao=${toHex(bid.prevRandao)} expected=${toHex(randaoMix)}`);
119
120
  }
120
121
 
122
+ // The parent's payload does not try to exit the builder
123
+ if (byteArrayEquals(bid.parentBlockHash, state.latestExecutionPayloadBid.blockHash)) {
124
+ const requests = await getParentExecutionRequests();
125
+ if (
126
+ requests.builderExits.some(
127
+ (request) =>
128
+ byteArrayEquals(request.pubkey, builder.pubkey) &&
129
+ byteArrayEquals(request.sourceAddress, builder.executionAddress)
130
+ )
131
+ ) {
132
+ throw Error(`Bid builderIndex=${bid.builderIndex} may exit in the parent payload`);
133
+ }
134
+ }
135
+
121
136
  // The builder must honor the proposer preferences it learned over gossip
122
137
  const bidEpoch = computeEpochAtSlot(bid.slot);
123
138
  const dependentRootHex = (() => {
@@ -83,15 +83,15 @@ export function createLodestarMetrics(
83
83
  }),
84
84
  jobTime: register.histogram<{topic: GossipType}>({
85
85
  name: "lodestar_gossip_validation_queue_job_time_seconds",
86
- help: "Time to process gossip validation queue job in seconds",
86
+ help: "Time to process gossip validation queue job in seconds (accepted messages only)",
87
87
  labelNames: ["topic"],
88
- buckets: [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10],
88
+ buckets: [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2],
89
89
  }),
90
90
  jobWaitTime: register.histogram<{topic: GossipType}>({
91
91
  name: "lodestar_gossip_validation_queue_job_wait_time_seconds",
92
92
  help: "Time from job added to the queue to starting the job in seconds",
93
93
  labelNames: ["topic"],
94
- buckets: [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10],
94
+ buckets: [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 2],
95
95
  }),
96
96
  concurrency: register.gauge<{topic: GossipType}>({
97
97
  name: "lodestar_gossip_validation_queue_concurrency",
@@ -104,9 +104,10 @@ export function createLodestarMetrics(
104
104
  help: "Age of the first item of each key in the indexed queues in seconds",
105
105
  buckets: [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 5],
106
106
  }),
107
- queueTime: register.histogram({
107
+ queueTime: register.histogram<{topic: GossipType}>({
108
108
  name: "lodestar_gossip_validation_queue_time_seconds",
109
109
  help: "Total time an item stays in queue until it is processed in seconds",
110
+ labelNames: ["topic"],
110
111
  buckets: [0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1, 5],
111
112
  }),
112
113
  },
@@ -920,6 +921,12 @@ export function createLodestarMetrics(
920
921
  help: "Count of errors, by error type, while processing blocks",
921
922
  labelNames: ["error"],
922
923
  }),
924
+
925
+ preStateSource: register.counter<{source: "parentState" | "fallbackPreState" | "preState"}>({
926
+ name: "lodestar_gossip_block_validation_pre_state_source_total",
927
+ help: "Source of the pre-state used for gossip block validation",
928
+ labelNames: ["source"],
929
+ }),
923
930
  },
924
931
  gossipBlob: {
925
932
  recvToValidation: register.histogram({
@@ -1350,6 +1357,11 @@ export function createLodestarMetrics(
1350
1357
  help: "Total number of InsertOutcome as a result of adding an execution payload bid from api to the pool",
1351
1358
  labelNames: ["insertOutcome"],
1352
1359
  }),
1360
+ apiValidationTime: register.histogram({
1361
+ name: "lodestar_api_execution_payload_bid_validation_time_seconds",
1362
+ help: "Time elapsed for signed execution payload bid validation - api path",
1363
+ buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.5],
1364
+ }),
1353
1365
  },
1354
1366
  },
1355
1367
 
@@ -2030,6 +2042,11 @@ export function createLodestarMetrics(
2030
2042
  },
2031
2043
 
2032
2044
  builderApi: {
2045
+ statusChecks: register.counter<{status: "success" | "error"}>({
2046
+ name: "lodestar_builder_api_status_checks_total",
2047
+ help: "Total count of status checks sent to external builders ahead of a proposal",
2048
+ labelNames: ["status"],
2049
+ }),
2033
2050
  bidRequests: register.counter({
2034
2051
  name: "lodestar_builder_api_bid_requests_total",
2035
2052
  help: "Total count of execution payload bid requests sent to external builders",
@@ -516,7 +516,8 @@ export class Network implements INetwork {
516
516
 
517
517
  return this.publishGossip<GossipType.execution_payload_bid>(
518
518
  {type: GossipType.execution_payload_bid, boundary},
519
- signedBid
519
+ signedBid,
520
+ {floodPublish: true}
520
521
  );
521
522
  }
522
523
 
@@ -542,17 +543,19 @@ export class Network implements INetwork {
542
543
 
543
544
  private async publishGossip<K extends GossipType>(
544
545
  topic: GossipTopicMap[K],
545
- object: GossipTypeMap[K]
546
+ object: GossipTypeMap[K],
547
+ opts?: PublishOpts
546
548
  ): Promise<number> {
547
549
  const topicStr = stringifyGossipTopic(this.config, topic);
548
550
  const sszType = getGossipSSZType(topic);
549
551
  const messageData = (sszType.serialize as (object: GossipTypeMap[GossipType]) => Uint8Array)(object);
550
- const opts: PublishOpts = {
552
+ const publishOpts: PublishOpts = {
551
553
  ignoreDuplicatePublishError: gossipTopicIgnoreDuplicatePublishError[topic.type],
552
554
  // Leave undefined unless the topic opts out, so `--network.allowPublishToZeroPeers` still applies
553
555
  allowPublishToZeroTopicPeers: gossipTopicAllowPublishToZeroPeers[topic.type] ? true : undefined,
556
+ ...opts,
554
557
  };
555
- const sentPeers = await this.core.publishGossip(topicStr, messageData, opts);
558
+ const sentPeers = await this.core.publishGossip(topicStr, messageData, publishOpts);
556
559
 
557
560
  this.logger.verbose("Publish to topic", {topic: topicStr, sentPeers, currentSlot: this.clock.currentSlot});
558
561
  return sentPeers;
@@ -86,7 +86,6 @@ export class IndexedGossipQueueMinSize<T extends {indexed?: string; queueAddedMs
86
86
  const now = Date.now();
87
87
  // here we mutate item, which is used for gossip validation later
88
88
  item.indexed = key;
89
- item.queueAddedMs = now;
90
89
  let queueItem = this.indexedItems.get(key);
91
90
  if (queueItem == null) {
92
91
  queueItem = {firstSeenMs: now, listItems: new LinkedList<T>()};