@lodestar/beacon-node 1.45.0 → 1.46.0-dev.1cd9a671ea

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 (151) hide show
  1. package/lib/api/impl/beacon/blocks/index.d.ts.map +1 -1
  2. package/lib/api/impl/beacon/blocks/index.js +186 -57
  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 +172 -88
  6. package/lib/api/impl/validator/index.js.map +1 -1
  7. package/lib/api/rest/base.d.ts.map +1 -1
  8. package/lib/api/rest/base.js +6 -3
  9. package/lib/api/rest/base.js.map +1 -1
  10. package/lib/chain/blocks/importBlock.d.ts.map +1 -1
  11. package/lib/chain/blocks/importBlock.js +2 -17
  12. package/lib/chain/blocks/importBlock.js.map +1 -1
  13. package/lib/chain/blocks/utils/chainSegment.d.ts.map +1 -1
  14. package/lib/chain/blocks/utils/chainSegment.js +13 -5
  15. package/lib/chain/blocks/utils/chainSegment.js.map +1 -1
  16. package/lib/chain/blocks/verifyBlocksExecutionPayloads.js +1 -1
  17. package/lib/chain/blocks/verifyBlocksExecutionPayloads.js.map +1 -1
  18. package/lib/chain/blocks/verifyBlocksSanityChecks.js +1 -1
  19. package/lib/chain/blocks/verifyBlocksSanityChecks.js.map +1 -1
  20. package/lib/chain/blocks/verifyExecutionPayloadEnvelope.js +2 -2
  21. package/lib/chain/blocks/verifyExecutionPayloadEnvelope.js.map +1 -1
  22. package/lib/chain/chain.d.ts.map +1 -1
  23. package/lib/chain/chain.js +44 -2
  24. package/lib/chain/chain.js.map +1 -1
  25. package/lib/chain/errors/blockError.d.ts +48 -6
  26. package/lib/chain/errors/blockError.d.ts.map +1 -1
  27. package/lib/chain/errors/blockError.js +25 -4
  28. package/lib/chain/errors/blockError.js.map +1 -1
  29. package/lib/chain/errors/executionPayloadEnvelope.d.ts +11 -0
  30. package/lib/chain/errors/executionPayloadEnvelope.d.ts.map +1 -1
  31. package/lib/chain/errors/executionPayloadEnvelope.js +2 -0
  32. package/lib/chain/errors/executionPayloadEnvelope.js.map +1 -1
  33. package/lib/chain/errors/proposerPreferences.d.ts +14 -1
  34. package/lib/chain/errors/proposerPreferences.d.ts.map +1 -1
  35. package/lib/chain/errors/proposerPreferences.js +2 -0
  36. package/lib/chain/errors/proposerPreferences.js.map +1 -1
  37. package/lib/chain/lightClient/index.d.ts.map +1 -1
  38. package/lib/chain/lightClient/index.js +11 -6
  39. package/lib/chain/lightClient/index.js.map +1 -1
  40. package/lib/chain/lightClient/proofs.d.ts.map +1 -1
  41. package/lib/chain/lightClient/proofs.js +28 -2
  42. package/lib/chain/lightClient/proofs.js.map +1 -1
  43. package/lib/chain/lightClient/types.d.ts +3 -1
  44. package/lib/chain/lightClient/types.d.ts.map +1 -1
  45. package/lib/chain/opPools/aggregatedAttestationPool.js +1 -1
  46. package/lib/chain/opPools/aggregatedAttestationPool.js.map +1 -1
  47. package/lib/chain/opPools/proposerPreferencesPool.d.ts +1 -1
  48. package/lib/chain/opPools/proposerPreferencesPool.d.ts.map +1 -1
  49. package/lib/chain/opPools/proposerPreferencesPool.js +1 -1
  50. package/lib/chain/opPools/proposerPreferencesPool.js.map +1 -1
  51. package/lib/chain/validation/aggregateAndProof.js +10 -6
  52. package/lib/chain/validation/aggregateAndProof.js.map +1 -1
  53. package/lib/chain/validation/attestation.d.ts.map +1 -1
  54. package/lib/chain/validation/attestation.js +10 -6
  55. package/lib/chain/validation/attestation.js.map +1 -1
  56. package/lib/chain/validation/block.d.ts.map +1 -1
  57. package/lib/chain/validation/block.js +53 -4
  58. package/lib/chain/validation/block.js.map +1 -1
  59. package/lib/chain/validation/executionPayloadBid.d.ts +5 -0
  60. package/lib/chain/validation/executionPayloadBid.d.ts.map +1 -1
  61. package/lib/chain/validation/executionPayloadBid.js +31 -3
  62. package/lib/chain/validation/executionPayloadBid.js.map +1 -1
  63. package/lib/chain/validation/executionPayloadEnvelope.d.ts.map +1 -1
  64. package/lib/chain/validation/executionPayloadEnvelope.js +29 -0
  65. package/lib/chain/validation/executionPayloadEnvelope.js.map +1 -1
  66. package/lib/chain/validation/proposerPreferences.d.ts +9 -2
  67. package/lib/chain/validation/proposerPreferences.d.ts.map +1 -1
  68. package/lib/chain/validation/proposerPreferences.js +61 -9
  69. package/lib/chain/validation/proposerPreferences.js.map +1 -1
  70. package/lib/chain/validation/signatureSets/aggregateAndProof.d.ts.map +1 -1
  71. package/lib/chain/validation/signatureSets/aggregateAndProof.js +2 -3
  72. package/lib/chain/validation/signatureSets/aggregateAndProof.js.map +1 -1
  73. package/lib/db/repositories/lightclientSyncCommitteeWitness.d.ts.map +1 -1
  74. package/lib/db/repositories/lightclientSyncCommitteeWitness.js +42 -7
  75. package/lib/db/repositories/lightclientSyncCommitteeWitness.js.map +1 -1
  76. package/lib/network/gossip/encoding.d.ts +2 -2
  77. package/lib/network/gossip/encoding.d.ts.map +1 -1
  78. package/lib/network/gossip/encoding.js +15 -9
  79. package/lib/network/gossip/encoding.js.map +1 -1
  80. package/lib/network/gossip/gossipsub.d.ts.map +1 -1
  81. package/lib/network/gossip/gossipsub.js +2 -2
  82. package/lib/network/gossip/gossipsub.js.map +1 -1
  83. package/lib/network/gossip/topic.d.ts +74 -231
  84. package/lib/network/gossip/topic.d.ts.map +1 -1
  85. package/lib/network/gossip/topic.js +24 -1
  86. package/lib/network/gossip/topic.js.map +1 -1
  87. package/lib/network/interface.d.ts +4 -1
  88. package/lib/network/interface.d.ts.map +1 -1
  89. package/lib/network/network.d.ts +4 -1
  90. package/lib/network/network.d.ts.map +1 -1
  91. package/lib/network/network.js +19 -11
  92. package/lib/network/network.js.map +1 -1
  93. package/lib/network/processor/gossipHandlers.d.ts.map +1 -1
  94. package/lib/network/processor/gossipHandlers.js +12 -6
  95. package/lib/network/processor/gossipHandlers.js.map +1 -1
  96. package/lib/network/reqresp/handlers/executionPayloadEnvelopesByRange.d.ts.map +1 -1
  97. package/lib/network/reqresp/handlers/executionPayloadEnvelopesByRange.js +4 -2
  98. package/lib/network/reqresp/handlers/executionPayloadEnvelopesByRange.js.map +1 -1
  99. package/lib/network/reqresp/handlers/index.d.ts.map +1 -1
  100. package/lib/network/reqresp/handlers/index.js +20 -11
  101. package/lib/network/reqresp/handlers/index.js.map +1 -1
  102. package/lib/network/util.d.ts +1 -0
  103. package/lib/network/util.d.ts.map +1 -1
  104. package/lib/network/util.js +4 -0
  105. package/lib/network/util.js.map +1 -1
  106. package/lib/sync/sync.d.ts.map +1 -1
  107. package/lib/sync/sync.js +7 -0
  108. package/lib/sync/sync.js.map +1 -1
  109. package/lib/sync/unknownBlock.d.ts.map +1 -1
  110. package/lib/sync/unknownBlock.js +7 -2
  111. package/lib/sync/unknownBlock.js.map +1 -1
  112. package/lib/util/execution.js +1 -1
  113. package/lib/util/execution.js.map +1 -1
  114. package/package.json +18 -16
  115. package/src/api/impl/beacon/blocks/index.ts +221 -63
  116. package/src/api/impl/validator/index.ts +206 -98
  117. package/src/api/rest/base.ts +8 -3
  118. package/src/chain/blocks/importBlock.ts +0 -17
  119. package/src/chain/blocks/utils/chainSegment.ts +16 -5
  120. package/src/chain/blocks/verifyBlocksExecutionPayloads.ts +1 -1
  121. package/src/chain/blocks/verifyBlocksSanityChecks.ts +1 -1
  122. package/src/chain/blocks/verifyExecutionPayloadEnvelope.ts +2 -2
  123. package/src/chain/chain.ts +61 -2
  124. package/src/chain/errors/blockError.ts +44 -7
  125. package/src/chain/errors/executionPayloadEnvelope.ts +9 -0
  126. package/src/chain/errors/proposerPreferences.ts +16 -1
  127. package/src/chain/lightClient/index.ts +12 -7
  128. package/src/chain/lightClient/proofs.ts +36 -1
  129. package/src/chain/lightClient/types.ts +3 -1
  130. package/src/chain/opPools/aggregatedAttestationPool.ts +1 -1
  131. package/src/chain/opPools/proposerPreferencesPool.ts +1 -1
  132. package/src/chain/validation/aggregateAndProof.ts +12 -6
  133. package/src/chain/validation/attestation.ts +12 -6
  134. package/src/chain/validation/block.ts +69 -4
  135. package/src/chain/validation/executionPayloadBid.ts +33 -3
  136. package/src/chain/validation/executionPayloadEnvelope.ts +37 -0
  137. package/src/chain/validation/proposerPreferences.ts +71 -9
  138. package/src/chain/validation/signatureSets/aggregateAndProof.ts +3 -3
  139. package/src/db/repositories/lightclientSyncCommitteeWitness.ts +57 -9
  140. package/src/network/gossip/encoding.ts +13 -7
  141. package/src/network/gossip/gossipsub.ts +7 -2
  142. package/src/network/gossip/topic.ts +30 -0
  143. package/src/network/interface.ts +3 -1
  144. package/src/network/network.ts +24 -15
  145. package/src/network/processor/gossipHandlers.ts +12 -6
  146. package/src/network/reqresp/handlers/executionPayloadEnvelopesByRange.ts +4 -2
  147. package/src/network/reqresp/handlers/index.ts +21 -12
  148. package/src/network/util.ts +5 -0
  149. package/src/sync/sync.ts +10 -0
  150. package/src/sync/unknownBlock.ts +8 -2
  151. package/src/util/execution.ts +1 -1
@@ -50,6 +50,7 @@ import {
50
50
  ssz,
51
51
  } from "@lodestar/types";
52
52
  import {
53
+ GWEI_TO_WEI,
53
54
  TimeoutError,
54
55
  defer,
55
56
  formatWeiToEth,
@@ -101,7 +102,7 @@ import {computeSubnetForCommitteesAtSlot, getPubkeysForIndices, selectBlockProdu
101
102
  * A cutoff of 2 seconds gives enough time and if there are unexpected delays it ensures we publish
102
103
  * in time as proposals post 4 seconds into the slot will likely be orphaned due to proposer boost reorg.
103
104
  *
104
- * TODO GLOAS: re-evaluate cutoff timing
105
+ * TODO GLOAS: re-evaluate cutoff timing due to attestation deadline changes in gloas
105
106
  */
106
107
  const BLOCK_PRODUCTION_RACE_CUTOFF_MS = 2_000;
107
108
  /** Overall timeout for execution and block production apis */
@@ -528,6 +529,16 @@ export function getValidatorApi(
528
529
  builderBoostFactor?: bigint,
529
530
  {feeRecipient, builderSelection, strictFeeRecipientCheck}: routes.validator.ExtraProduceBlockOpts = {}
530
531
  ): Promise<ProduceBlindedBlockOrBlockContentsRes> {
532
+ builderSelection = builderSelection ?? routes.validator.BuilderSelection.MaxProfit;
533
+ if (builderSelection === routes.validator.BuilderSelection.BuilderOnly) {
534
+ logger.warn("Builder selection builderonly is no longer supported, treating as builderalways");
535
+ builderSelection = routes.validator.BuilderSelection.BuilderAlways;
536
+ }
537
+ builderBoostFactor = builderBoostFactor ?? BigInt(100);
538
+ if (builderBoostFactor > MAX_BUILDER_BOOST_FACTOR) {
539
+ throw new ApiError(400, `Invalid builderBoostFactor=${builderBoostFactor} > MAX_BUILDER_BOOST_FACTOR`);
540
+ }
541
+
531
542
  notWhileSyncing(chain, sync.state);
532
543
  await waitForSlot(slot); // Must never request for a future slot > currentSlot
533
544
 
@@ -538,36 +549,12 @@ export function getValidatorApi(
538
549
  metrics?.blockProductionSlotDelta.set(slot - parentSlot);
539
550
 
540
551
  const fork = config.getForkName(slot);
541
- // set some sensible opts
542
- // builderSelection will be deprecated and will run in mode MaxProfit if builder is enabled
543
- // and the actual selection will be determined using builderBoostFactor passed by the validator
544
- builderSelection = builderSelection ?? routes.validator.BuilderSelection.MaxProfit;
545
- builderBoostFactor = builderBoostFactor ?? BigInt(100);
546
- if (builderBoostFactor > MAX_BUILDER_BOOST_FACTOR) {
547
- throw new ApiError(400, `Invalid builderBoostFactor=${builderBoostFactor} > MAX_BUILDER_BOOST_FACTOR`);
548
- }
549
552
 
550
553
  const isBuilderEnabled =
551
554
  ForkSeq[fork] >= ForkSeq.bellatrix &&
552
555
  chain.executionBuilder !== undefined &&
553
556
  builderSelection !== routes.validator.BuilderSelection.ExecutionOnly;
554
557
 
555
- // At any point either the builder or execution or both flows should be active.
556
- //
557
- // Ideally such a scenario should be prevented on startup, but proposerSettingsFile or keymanager
558
- // configurations could cause a validator pubkey to have builder disabled with builder selection builder only
559
- // (TODO: independently make sure such an options update is not successful for a validator pubkey)
560
- //
561
- // So if builder is disabled ignore builder selection of builder only if caused by user mistake
562
- // https://github.com/ChainSafe/lodestar/issues/6338
563
- const isEngineEnabled = !isBuilderEnabled || builderSelection !== routes.validator.BuilderSelection.BuilderOnly;
564
-
565
- if (!isEngineEnabled && !isBuilderEnabled) {
566
- throw Error(
567
- `Internal Error: Neither builder nor execution proposal flow activated isBuilderEnabled=${isBuilderEnabled} builderSelection=${builderSelection}`
568
- );
569
- }
570
-
571
558
  const graffitiBytes = toGraffitiBytes(
572
559
  getBlockGraffiti(graffiti, getLodestarClientVersion(opts), chain.executionEngine.clientVersion, {
573
560
  private: opts.private,
@@ -582,7 +569,6 @@ export function getValidatorApi(
582
569
  fork,
583
570
  builderSelection,
584
571
  isBuilderEnabled,
585
- isEngineEnabled,
586
572
  strictFeeRecipientCheck,
587
573
  // winston logger doesn't like bigint
588
574
  builderBoostFactor: `${builderBoostFactor}`,
@@ -608,27 +594,25 @@ export function getValidatorApi(
608
594
  })
609
595
  : Promise.reject(new Error("Builder disabled"));
610
596
 
611
- const enginePromise = isEngineEnabled
612
- ? produceEngineBlockContents(slot, randaoReveal, graffitiBytes, {
613
- feeRecipient,
614
- strictFeeRecipientCheck,
615
- commonBlockBodyPromise,
616
- parentBlock,
617
- }).then((engineBlock) => {
618
- // Once the engine returns a block, in the event of either:
619
- // - suspected builder censorship
620
- // - builder boost factor set to 0 or builder selection `executionalways`
621
- // we don't need to wait for builder block as engine block will always be selected
622
- if (
623
- engineBlock.shouldOverrideBuilder ||
624
- builderBoostFactor === BigInt(0) ||
625
- builderSelection === routes.validator.BuilderSelection.ExecutionAlways
626
- ) {
627
- controller.abort();
628
- }
629
- return engineBlock;
630
- })
631
- : Promise.reject(new Error("Engine disabled"));
597
+ const enginePromise = produceEngineBlockContents(slot, randaoReveal, graffitiBytes, {
598
+ feeRecipient,
599
+ strictFeeRecipientCheck,
600
+ commonBlockBodyPromise,
601
+ parentBlock,
602
+ }).then((engineBlock) => {
603
+ // Once the engine returns a block, in the event of either:
604
+ // - suspected builder censorship
605
+ // - builder boost factor set to 0 or builder selection `executionalways`
606
+ // we don't need to wait for builder block as engine block will always be selected
607
+ if (
608
+ engineBlock.shouldOverrideBuilder ||
609
+ builderBoostFactor === BigInt(0) ||
610
+ builderSelection === routes.validator.BuilderSelection.ExecutionAlways
611
+ ) {
612
+ controller.abort();
613
+ }
614
+ return engineBlock;
615
+ });
632
616
 
633
617
  // Calculate cutoff time based on start of the slot
634
618
  const cutoffMs = Math.max(0, BLOCK_PRODUCTION_RACE_CUTOFF_MS - chain.clock.msFromSlot(slot));
@@ -675,30 +659,24 @@ export function getValidatorApi(
675
659
  throw Error("Builder and engine both failed to produce the block within timeout");
676
660
  }
677
661
 
678
- if (builder.status === "pending" && !isEngineEnabled) {
679
- throw Error("Builder failed to produce the block within timeout");
680
- }
681
-
682
662
  if (engine.status === "pending" && !isBuilderEnabled) {
683
663
  throw Error("Engine failed to produce the block within timeout");
684
664
  }
685
665
 
686
- if (isEngineEnabled) {
687
- if (engine.status === "rejected") {
688
- logger.warn(
689
- "Engine failed to produce the block",
690
- {
691
- ...loggerContext,
692
- durationMs: engine.durationMs,
693
- },
694
- engine.reason
695
- );
696
- } else if (engine.status === "pending") {
697
- logger.warn("Engine failed to produce the block within cutoff time", {
666
+ if (engine.status === "rejected") {
667
+ logger.warn(
668
+ "Engine failed to produce the block",
669
+ {
698
670
  ...loggerContext,
699
- cutoffMs,
700
- });
701
- }
671
+ durationMs: engine.durationMs,
672
+ },
673
+ engine.reason
674
+ );
675
+ } else if (engine.status === "pending") {
676
+ logger.warn("Engine failed to produce the block within cutoff time", {
677
+ ...loggerContext,
678
+ cutoffMs,
679
+ });
702
680
  }
703
681
 
704
682
  if (isBuilderEnabled) {
@@ -727,14 +705,12 @@ export function getValidatorApi(
727
705
  }
728
706
 
729
707
  if (builder.status === "rejected" && engine.status === "rejected") {
730
- throw Error(
731
- `${isBuilderEnabled && isEngineEnabled ? "Builder and engine both" : isBuilderEnabled ? "Builder" : "Engine"} failed to produce the block`
732
- );
708
+ throw Error(`${isBuilderEnabled ? "Builder and engine both" : "Engine"} failed to produce the block`);
733
709
  }
734
710
 
735
711
  // handle shouldOverrideBuilder separately
736
- if (engine.status === "fulfilled" && engine.value.shouldOverrideBuilder) {
737
- logger.info("Selected engine block: censorship suspected in builder blocks", {
712
+ if (engine.status === "fulfilled" && engine.value.shouldOverrideBuilder && isBuilderEnabled) {
713
+ logger.warn("Selected engine block: censorship suspected in builder blocks", {
738
714
  ...loggerContext,
739
715
  durationMs: engine.durationMs,
740
716
  shouldOverrideBuilder: engine.value.shouldOverrideBuilder,
@@ -751,11 +727,9 @@ export function getValidatorApi(
751
727
 
752
728
  if (builder.status === "fulfilled" && engine.status !== "fulfilled") {
753
729
  const reason =
754
- isEngineEnabled === false
755
- ? BuilderBlockSelectionReason.EngineDisabled
756
- : engine.status === "pending"
757
- ? BuilderBlockSelectionReason.EnginePending
758
- : BuilderBlockSelectionReason.EngineError;
730
+ engine.status === "pending"
731
+ ? BuilderBlockSelectionReason.EnginePending
732
+ : BuilderBlockSelectionReason.EngineError;
759
733
 
760
734
  logger.info("Selected builder block: no engine block produced", {
761
735
  reason,
@@ -865,13 +839,31 @@ export function getValidatorApi(
865
839
  return {data, meta};
866
840
  },
867
841
 
868
- async produceBlockV4({slot, randaoReveal, graffiti, feeRecipient}) {
842
+ async produceBlockV4({
843
+ slot,
844
+ randaoReveal,
845
+ graffiti,
846
+ feeRecipient,
847
+ includePayload,
848
+ builderSelection,
849
+ builderBoostFactor,
850
+ }) {
869
851
  const fork = config.getForkName(slot);
870
852
 
871
853
  if (!isForkPostGloas(fork)) {
872
854
  throw new ApiError(400, `produceBlockV4 not supported for pre-gloas fork=${fork}`);
873
855
  }
874
856
 
857
+ builderSelection = builderSelection ?? routes.validator.BuilderSelection.MaxProfit;
858
+ if (builderSelection === routes.validator.BuilderSelection.BuilderOnly) {
859
+ logger.warn("Builder selection builderonly is no longer supported, treating as builderalways");
860
+ builderSelection = routes.validator.BuilderSelection.BuilderAlways;
861
+ }
862
+ builderBoostFactor = builderBoostFactor ?? BigInt(100);
863
+ if (builderBoostFactor > MAX_BUILDER_BOOST_FACTOR) {
864
+ throw new ApiError(400, `Invalid builderBoostFactor=${builderBoostFactor} > MAX_BUILDER_BOOST_FACTOR`);
865
+ }
866
+
875
867
  notWhileSyncing(chain, sync.state);
876
868
  await waitForSlot(slot);
877
869
 
@@ -888,15 +880,17 @@ export function getValidatorApi(
888
880
  })
889
881
  );
890
882
 
891
- // TODO GLOAS: respect builderSelection (MaxProfit, BuilderAlways, ExecutionAlways, etc.) to let
892
- // the user control bid source preferences and value comparison. Also add external builder api
893
- // support when it is implemented.
883
+ // TODO GLOAS: add external builder api support when it is implemented
894
884
  const isBuildingOnFull = chain.forkChoice.shouldBuildOnFull(parentBlock, slot);
895
885
  const bidParentBlockHash = isBuildingOnFull ? parentBlock.executionPayloadBlockHash : parentBlock.parentBlockHash;
886
+ // Bids are only skipped entirely with executiononly or while the circuit breaker is active,
887
+ // other engine-preferring selections still build a block with the best bid as fallback in
888
+ // case local production fails
896
889
  const circuitBreakerActive = chain.builderCircuitBreaker.isActive(slot);
897
- const builderBid = circuitBreakerActive
898
- ? null
899
- : chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex);
890
+ const builderBid =
891
+ builderSelection === routes.validator.BuilderSelection.ExecutionOnly || circuitBreakerActive
892
+ ? null
893
+ : chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex);
900
894
 
901
895
  const logCtx = {
902
896
  slot,
@@ -904,6 +898,8 @@ export function getValidatorApi(
904
898
  parentBlockRoot: parentBlockRootHex,
905
899
  parentBlockHash: parentBlock.executionPayloadBlockHash,
906
900
  fork,
901
+ builderSelection,
902
+ builderBoostFactor,
907
903
  circuitBreakerActive,
908
904
  ...(builderBid !== null
909
905
  ? {
@@ -940,33 +936,101 @@ export function getValidatorApi(
940
936
  return fn().finally(() => t?.({source}));
941
937
  };
942
938
 
943
- // Always build local block. If builder bid available, also build with it in parallel and prefer it.
944
- const [engineResult, bidResult] = await Promise.allSettled([
945
- timed(ProducedBlockSource.engine, () => chain.produceBlock(baseAttrs)),
939
+ // Calculate cutoff time based on start of the slot, ensures a slow local payload build does
940
+ // not delay the proposal when a builder bid block is available (and vice versa)
941
+ const cutoffMs = Math.max(0, BLOCK_PRODUCTION_RACE_CUTOFF_MS - chain.clock.msFromSlot(slot));
942
+
943
+ // use abort controller to stop waiting for the bid block if the engine block will be selected
944
+ const controller = new AbortController();
945
+
946
+ const enginePromise: ReturnType<typeof chain.produceBlock> = timed(ProducedBlockSource.engine, () =>
947
+ chain.produceBlock(baseAttrs)
948
+ ).then((engineBlock) => {
949
+ // No need to wait for the bid block if the engine block will always be selected due to
950
+ // suspected builder censorship, a builder boost factor of 0 or executionalways selection
951
+ if (
952
+ engineBlock.shouldOverrideBuilder ||
953
+ builderBoostFactor === BigInt(0) ||
954
+ builderSelection === routes.validator.BuilderSelection.ExecutionAlways
955
+ ) {
956
+ controller.abort();
957
+ }
958
+ return engineBlock;
959
+ });
960
+ const bidPromise: ReturnType<typeof chain.produceBlock> =
946
961
  builderBid !== null
947
962
  ? timed(ProducedBlockSource.builder, () => chain.produceBlock({...baseAttrs, builderBid}))
948
- : Promise.reject(),
949
- ]);
963
+ : Promise.reject(new Error("No builder bid available"));
964
+
965
+ const [engineResult, bidResult] = await resolveOrRacePromises([enginePromise, bidPromise], {
966
+ resolveTimeoutMs: cutoffMs,
967
+ raceTimeoutMs: BLOCK_PRODUCTION_RACE_TIMEOUT_MS,
968
+ signal: controller.signal,
969
+ });
950
970
 
951
971
  let bestResult: typeof engineResult | null = null;
952
972
  let source: ProducedBlockSource = ProducedBlockSource.engine;
953
- if (builderBid !== null && bidResult.status === "fulfilled") {
973
+
974
+ // handle shouldOverrideBuilder separately
975
+ if (engineResult.status === "fulfilled" && engineResult.value.shouldOverrideBuilder && builderBid !== null) {
976
+ source = ProducedBlockSource.engine;
977
+ bestResult = engineResult;
978
+ metrics?.blockProductionSelectionResults.inc({
979
+ source: ProducedBlockSource.engine,
980
+ reason: EngineBlockSelectionReason.BuilderCensorship,
981
+ });
982
+ logger.warn("Selected local block: censorship suspected in builder bid", logCtx);
983
+ } else if (engineResult.status === "fulfilled" && bidResult.status === "fulfilled") {
984
+ const result = selectBlockProductionSource({
985
+ builderSelection,
986
+ builderBoostFactor,
987
+ engineExecutionPayloadValue: engineResult.value.executionPayloadValue,
988
+ // The bid value is the payment to the proposer, in Gwei
989
+ builderExecutionPayloadValue: BigInt(builderBid?.message.value ?? 0) * GWEI_TO_WEI,
990
+ });
991
+ source = result.source;
992
+ metrics?.blockProductionSelectionResults.inc(result);
993
+ logger.info(`Selected ${source} block`, {reason: result.reason, ...logCtx});
994
+ bestResult = source === ProducedBlockSource.builder ? bidResult : engineResult;
995
+ } else if (bidResult.status === "fulfilled") {
954
996
  source = ProducedBlockSource.builder;
955
997
  bestResult = bidResult;
956
- logger.info("Selected builder bid block", logCtx);
998
+ const reason =
999
+ engineResult.status === "pending"
1000
+ ? BuilderBlockSelectionReason.EnginePending
1001
+ : BuilderBlockSelectionReason.EngineError;
1002
+ metrics?.blockProductionSelectionResults.inc({source: ProducedBlockSource.builder, reason});
1003
+ logger.info("Selected builder bid block: no local block produced", {
1004
+ reason,
1005
+ ...logCtx,
1006
+ error: engineResult.status === "rejected" ? (engineResult.reason as Error).message : undefined,
1007
+ });
957
1008
  } else if (engineResult.status === "fulfilled") {
958
1009
  source = ProducedBlockSource.engine;
959
1010
  bestResult = engineResult;
960
- if (builderBid !== null) {
961
- logger.warn("Builder bid block production failed, using local block", logCtx);
962
- }
1011
+ const reason =
1012
+ builderBid === null
1013
+ ? EngineBlockSelectionReason.BuilderNoBid
1014
+ : bidResult.status === "pending"
1015
+ ? EngineBlockSelectionReason.BuilderPending
1016
+ : EngineBlockSelectionReason.BuilderError;
1017
+ metrics?.blockProductionSelectionResults.inc({source: ProducedBlockSource.engine, reason});
1018
+ logger.info("Selected local block: no builder bid block produced", {
1019
+ reason,
1020
+ ...logCtx,
1021
+ error: bidResult.status === "rejected" ? (bidResult.reason as Error).message : undefined,
1022
+ });
963
1023
  }
964
1024
 
965
1025
  if (bestResult === null || bestResult.status !== "fulfilled") {
966
- const engineReason = engineResult.status === "rejected" ? engineResult.reason : undefined;
967
- const bidReason = builderBid !== null && bidResult.status === "rejected" ? bidResult.reason : undefined;
968
- logger.error("Block production failed", {...logCtx, engineReason, bidReason});
969
- throw Error(`Block production failed: engine=${engineReason ?? "n/a"} builder=${bidReason ?? "n/a"}`);
1026
+ const engineReason = engineResult.status === "rejected" ? engineResult.reason : engineResult.status;
1027
+ const bidReason = bidResult.status === "rejected" ? bidResult.reason : bidResult.status;
1028
+ logger.error("Block production failed", {
1029
+ ...logCtx,
1030
+ engineReason: String(engineReason),
1031
+ bidReason: String(bidReason),
1032
+ });
1033
+ throw Error(`Block production failed: engine=${String(engineReason)} builder=${String(bidReason)}`);
970
1034
  }
971
1035
 
972
1036
  const {block, executionPayloadValue, consensusBlockValue} = bestResult.value;
@@ -987,9 +1051,41 @@ export function getValidatorApi(
987
1051
  void chain.persistBlock(block, "produced_engine_block");
988
1052
  }
989
1053
 
1054
+ // Include the payload for self-builds unless disabled (stateless flow)
1055
+ const isSelfBuild = source === ProducedBlockSource.engine;
1056
+ if (isSelfBuild && includePayload) {
1057
+ const produceResult = chain.blockProductionCache.get(blockRoot);
1058
+ if (
1059
+ produceResult === undefined ||
1060
+ !isForkPostGloas(produceResult.fork) ||
1061
+ produceResult.type !== BlockType.Full
1062
+ ) {
1063
+ throw Error(`Missing cached block production result for produced block slot=${slot} blockRoot=${blockRoot}`);
1064
+ }
1065
+ const {executionPayload, executionRequests, blobsBundle, parentBlockRoot} = produceResult as ProduceFullGloas;
1066
+
1067
+ const blockContents: gloas.BlockContents = {
1068
+ block: block as gloas.BeaconBlock,
1069
+ executionPayloadEnvelope: {
1070
+ payload: executionPayload,
1071
+ executionRequests,
1072
+ builderIndex: BUILDER_INDEX_SELF_BUILD,
1073
+ beaconBlockRoot: fromHex(blockRoot),
1074
+ parentBeaconBlockRoot: parentBlockRoot,
1075
+ },
1076
+ kzgProofs: blobsBundle.proofs,
1077
+ blobs: blobsBundle.blobs,
1078
+ };
1079
+
1080
+ return {
1081
+ data: blockContents,
1082
+ meta: {version: fork, consensusBlockValue, executionPayloadValue, executionPayloadIncluded: true},
1083
+ };
1084
+ }
1085
+
990
1086
  return {
991
1087
  data: block as gloas.BeaconBlock,
992
- meta: {version: fork, consensusBlockValue},
1088
+ meta: {version: fork, consensusBlockValue, executionPayloadValue, executionPayloadIncluded: false},
993
1089
  };
994
1090
  },
995
1091
 
@@ -1798,6 +1894,18 @@ export function getValidatorApi(
1798
1894
 
1799
1895
  const {executionPayload, executionRequests, parentBlockRoot} = produceResult as ProduceFullGloas;
1800
1896
 
1897
+ if (executionPayload === undefined) {
1898
+ // Blocks committing to a builder bid are cached as full but without payload data
1899
+ throw new ApiError(404, `No local execution payload cached for block root ${blockRootHex}`);
1900
+ }
1901
+
1902
+ if (executionPayload.slotNumber !== slot) {
1903
+ throw new ApiError(
1904
+ 404,
1905
+ `Cached execution payload is for slot=${executionPayload.slotNumber}, requested slot=${slot}`
1906
+ );
1907
+ }
1908
+
1801
1909
  const envelope: gloas.ExecutionPayloadEnvelope = {
1802
1910
  payload: executionPayload,
1803
1911
  executionRequests: executionRequests,
@@ -5,6 +5,7 @@ import {parse as parseQueryString} from "qs";
5
5
  import {addSszContentTypeParser} from "@lodestar/api/server";
6
6
  import {NUMBER_OF_COLUMNS} from "@lodestar/params";
7
7
  import {ErrorAborted, Gauge, Histogram, Logger} from "@lodestar/utils";
8
+ import {GossipActionError} from "../../chain/errors/gossipValidation.js";
8
9
  import {isLocalhostIP} from "../../util/ip.js";
9
10
  import {ApiError, FailureList, IndexedError, NodeIsSyncing} from "../impl/errors.js";
10
11
  import {HttpActiveSocketsTracker, SocketMetrics} from "./activeSockets.js";
@@ -121,8 +122,8 @@ export class RestApiServer {
121
122
  };
122
123
  void res.status(err.statusCode).send(payload);
123
124
  } else {
124
- // Convert our custom ApiError into status code
125
- const statusCode = err instanceof ApiError ? err.statusCode : 500;
125
+ // Convert known request errors into status codes
126
+ const statusCode = err instanceof ApiError ? err.statusCode : err instanceof GossipActionError ? 400 : 500;
126
127
  const payload: ErrorResponse = {code: statusCode, message: err.message, stacktraces};
127
128
  void res.status(statusCode).send(payload);
128
129
  }
@@ -170,7 +171,11 @@ export class RestApiServer {
170
171
 
171
172
  const operationId = getOperationId(req);
172
173
 
173
- if (err instanceof ApiError || [INVALID_MEDIA_TYPE_CODE, SCHEMA_VALIDATION_ERROR_CODE].includes(err.code)) {
174
+ if (
175
+ err instanceof ApiError ||
176
+ err instanceof GossipActionError ||
177
+ [INVALID_MEDIA_TYPE_CODE, SCHEMA_VALIDATION_ERROR_CODE].includes(err.code)
178
+ ) {
174
179
  this.logger.warn(`Req ${req.id} ${operationId} failed`, {reason: err.message});
175
180
  } else {
176
181
  this.logger.error(`Req ${req.id} ${operationId} error`, {}, err);
@@ -2,7 +2,6 @@ import {BitArray} from "@chainsafe/ssz";
2
2
  import {routes} from "@lodestar/api";
3
3
  import {
4
4
  AncestorStatus,
5
- EpochDifference,
6
5
  ExecutionStatus,
7
6
  ForkChoiceError,
8
7
  ForkChoiceErrorCode,
@@ -21,7 +20,6 @@ import {
21
20
  IBeaconStateView,
22
21
  RootCache,
23
22
  computeEpochAtSlot,
24
- computeStartSlotAtEpoch,
25
23
  computeTimeAtSlot,
26
24
  isStartSlotOfEpoch,
27
25
  isStatePostAltair,
@@ -301,21 +299,6 @@ export async function importBlock(
301
299
  // Set head state as strong reference
302
300
  this.regen.updateHeadState(newHead, postState);
303
301
 
304
- try {
305
- this.emitter.emit(routes.events.EventType.head, {
306
- block: newHead.blockRoot,
307
- epochTransition: computeStartSlotAtEpoch(computeEpochAtSlot(newHead.slot)) === newHead.slot,
308
- slot: newHead.slot,
309
- state: newHead.stateRoot,
310
- previousDutyDependentRoot: this.forkChoice.getDependentRoot(newHead, EpochDifference.previous),
311
- currentDutyDependentRoot: this.forkChoice.getDependentRoot(newHead, EpochDifference.current),
312
- executionOptimistic: isOptimisticBlock(newHead),
313
- });
314
- } catch (e) {
315
- // getDependentRoot() may fail with error: "No block for root" as we can see in holesky non-finality issue
316
- this.logger.debug("Error emitting head event", {slot: newHead.slot, root: newHead.blockRoot}, e as Error);
317
- }
318
-
319
302
  const delaySec = this.clock.secFromSlot(newHead.slot);
320
303
  this.logger.verbose("New chain head", {
321
304
  slot: newHead.slot,
@@ -86,11 +86,22 @@ export function assertLinearChainSegment(
86
86
  // Maybe the previous slot's FULL envelope was orphaned — try falling back.
87
87
  // If even prevExecHash doesn't match, the segment is non-linear.
88
88
  if (bidParentHash !== prevExecHash) {
89
- throw new BlockError(block, {
90
- code: BlockErrorCode.PARENT_PAYLOAD_UNKNOWN,
91
- parentRoot: toRootHex(block.message.parentRoot),
92
- parentBlockHash: bidParentHash,
93
- });
89
+ // i === 0 compares against the seeded fork-choice parent (segment boundary); i > 0
90
+ // compares against the in-segment predecessor's payload (broken link inside the segment).
91
+ throw new BlockError(
92
+ block,
93
+ i === 0
94
+ ? {
95
+ code: BlockErrorCode.PARENT_PAYLOAD_UNKNOWN,
96
+ parentRoot: toRootHex(block.message.parentRoot),
97
+ parentBlockHash: bidParentHash,
98
+ }
99
+ : {
100
+ code: BlockErrorCode.NON_LINEAR_PAYLOAD_ROOTS,
101
+ parentBlockHash: bidParentHash,
102
+ expectedBlockHash: currentExecHash,
103
+ }
104
+ );
94
105
  }
95
106
  if (lastFullSlot !== null && payloadEnvelopes !== null) {
96
107
  const orphanedInput = payloadEnvelopes.get(lastFullSlot);
@@ -201,7 +201,7 @@ export async function verifyBlockExecutionPayload(
201
201
  invalidateFromParentBlockHash: toRootHex(executionPayloadEnabled.parentHash),
202
202
  };
203
203
  const execError = new BlockError(block, {
204
- code: BlockErrorCode.EXECUTION_ENGINE_ERROR,
204
+ code: BlockErrorCode.EXECUTION_ENGINE_INVALID,
205
205
  execStatus: execResult.status,
206
206
  errorMessage: execResult.validationError ?? "",
207
207
  });
@@ -94,7 +94,7 @@ export function verifyBlocksSanityChecks(
94
94
  const parentRoot = toRootHex(block.message.parentRoot);
95
95
  const parentBlockDefaultStatus = chain.forkChoice.getBlockHexDefaultStatus(parentRoot);
96
96
  if (!parentBlockDefaultStatus) {
97
- throw new BlockError(block, {code: BlockErrorCode.PARENT_UNKNOWN, parentRoot});
97
+ throw new BlockError(block, {code: BlockErrorCode.PARENT_BLOCK_UNKNOWN, parentRoot});
98
98
  }
99
99
 
100
100
  parentBlock = parentBlockDefaultStatus;
@@ -100,8 +100,8 @@ export function verifyExecutionPayloadEnvelope(
100
100
  }
101
101
 
102
102
  // Verify consistency with expected withdrawals
103
- const payloadWithdrawalsRoot = ssz.capella.Withdrawals.hashTreeRoot(payload.withdrawals);
104
- const expectedWithdrawalsRoot = ssz.capella.Withdrawals.hashTreeRoot(state.payloadExpectedWithdrawals);
103
+ const payloadWithdrawalsRoot = ssz.gloas.Withdrawals.hashTreeRoot(payload.withdrawals);
104
+ const expectedWithdrawalsRoot = ssz.gloas.Withdrawals.hashTreeRoot(state.payloadExpectedWithdrawals);
105
105
  if (!byteArrayEquals(payloadWithdrawalsRoot, expectedWithdrawalsRoot)) {
106
106
  throw new Error(
107
107
  `Withdrawals mismatch between payload and expected payload=${toRootHex(payloadWithdrawalsRoot)} expected=${toRootHex(expectedWithdrawalsRoot)}`
@@ -1,8 +1,17 @@
1
1
  import path from "node:path";
2
2
  import {PrivateKey} from "@libp2p/interface";
3
3
  import {Type} from "@chainsafe/ssz";
4
+ import {routes} from "@lodestar/api";
4
5
  import {BeaconConfig} from "@lodestar/config";
5
- import {CheckpointWithHex, ForkChoiceStateGetter, IForkChoice, ProtoBlock, UpdateHeadOpt} from "@lodestar/fork-choice";
6
+ import {
7
+ CheckpointWithHex,
8
+ EpochDifference,
9
+ ForkChoiceStateGetter,
10
+ IForkChoice,
11
+ PayloadStatus,
12
+ ProtoBlock,
13
+ UpdateHeadOpt,
14
+ } from "@lodestar/fork-choice";
6
15
  import {LoggerNode} from "@lodestar/logger/node";
7
16
  import {
8
17
  EFFECTIVE_BALANCE_INCREMENT,
@@ -1179,7 +1188,56 @@ export class BeaconChain implements IBeaconChain {
1179
1188
  const timer = this.metrics?.forkChoice.findHead.startTimer({caller});
1180
1189
 
1181
1190
  try {
1182
- return this.forkChoice.updateAndGetHead({mode: UpdateHeadOpt.GetCanonicalHead}).head;
1191
+ const prevHead = this.forkChoice.getHead();
1192
+ const head = this.forkChoice.updateAndGetHead({mode: UpdateHeadOpt.GetCanonicalHead}).head;
1193
+
1194
+ const headRootChanged = head.blockRoot !== prevHead.blockRoot;
1195
+
1196
+ if (!headRootChanged && prevHead.payloadStatus === head.payloadStatus) {
1197
+ return head;
1198
+ }
1199
+
1200
+ try {
1201
+ const previousDutyDependentRoot = this.forkChoice.getDependentRoot(head, EpochDifference.previous);
1202
+ const currentDutyDependentRoot = this.forkChoice.getDependentRoot(head, EpochDifference.current);
1203
+ const epochTransition = computeStartSlotAtEpoch(computeEpochAtSlot(head.slot)) === head.slot;
1204
+ const executionOptimistic = isOptimisticBlock(head);
1205
+
1206
+ if (headRootChanged) {
1207
+ this.emitter.emit(routes.events.EventType.head, {
1208
+ block: head.blockRoot,
1209
+ epochTransition,
1210
+ slot: head.slot,
1211
+ state: head.stateRoot,
1212
+ previousDutyDependentRoot,
1213
+ currentDutyDependentRoot,
1214
+ executionOptimistic,
1215
+ });
1216
+ }
1217
+
1218
+ this.emitter.emit(routes.events.EventType.headV2, {
1219
+ version: this.config.getForkName(head.slot),
1220
+ data: {
1221
+ slot: head.slot,
1222
+ block: head.blockRoot,
1223
+ state: head.stateRoot,
1224
+ payloadStatus: head.payloadStatus === PayloadStatus.FULL ? "full" : "empty",
1225
+ epochTransition,
1226
+ currentEpochDependentRoot: previousDutyDependentRoot,
1227
+ nextEpochDependentRoot: currentDutyDependentRoot,
1228
+ executionOptimistic,
1229
+ },
1230
+ });
1231
+ } catch (e) {
1232
+ // getDependentRoot() may fail with error: "No block for root" as we can see in holesky non-finality issue
1233
+ this.logger.debug(
1234
+ "Error emitting head/head_v2 event",
1235
+ {slot: head.slot, root: head.blockRoot, headRootChanged},
1236
+ e as Error
1237
+ );
1238
+ }
1239
+
1240
+ return head;
1183
1241
  } catch (e) {
1184
1242
  this.metrics?.forkChoice.errors.inc({entrypoint: UpdateHeadOpt.GetCanonicalHead});
1185
1243
  throw e;
@@ -1209,6 +1267,7 @@ export class BeaconChain implements IBeaconChain {
1209
1267
  const secFromSlot = this.clock.secFromSlot(slot);
1210
1268
 
1211
1269
  try {
1270
+ // Do not emit head event here, when proposing we rely on the one emitted when importing our own block
1212
1271
  const {head, isHeadTimely, notReorgedReason} = this.forkChoice.updateAndGetHead({
1213
1272
  mode: UpdateHeadOpt.GetProposerHead,
1214
1273
  secFromSlot,