@lodestar/beacon-node 1.43.0-dev.be5f70c0c8 → 1.43.0-dev.c98da75ec7

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 (97) hide show
  1. package/lib/api/impl/beacon/pool/index.d.ts.map +1 -1
  2. package/lib/api/impl/beacon/pool/index.js +46 -5
  3. package/lib/api/impl/beacon/pool/index.js.map +1 -1
  4. package/lib/chain/blocks/utils/chainSegment.d.ts +1 -3
  5. package/lib/chain/blocks/utils/chainSegment.d.ts.map +1 -1
  6. package/lib/chain/blocks/utils/chainSegment.js +29 -26
  7. package/lib/chain/blocks/utils/chainSegment.js.map +1 -1
  8. package/lib/chain/chain.d.ts +2 -1
  9. package/lib/chain/chain.d.ts.map +1 -1
  10. package/lib/chain/chain.js +3 -1
  11. package/lib/chain/chain.js.map +1 -1
  12. package/lib/chain/errors/executionPayloadBid.d.ts +19 -1
  13. package/lib/chain/errors/executionPayloadBid.d.ts.map +1 -1
  14. package/lib/chain/errors/executionPayloadBid.js +3 -0
  15. package/lib/chain/errors/executionPayloadBid.js.map +1 -1
  16. package/lib/chain/interface.d.ts +2 -1
  17. package/lib/chain/interface.d.ts.map +1 -1
  18. package/lib/chain/interface.js.map +1 -1
  19. package/lib/chain/lightClient/index.d.ts.map +1 -1
  20. package/lib/chain/lightClient/index.js +1 -1
  21. package/lib/chain/lightClient/index.js.map +1 -1
  22. package/lib/chain/opPools/index.d.ts +1 -0
  23. package/lib/chain/opPools/index.d.ts.map +1 -1
  24. package/lib/chain/opPools/index.js +1 -0
  25. package/lib/chain/opPools/index.js.map +1 -1
  26. package/lib/chain/opPools/payloadAttestationPool.d.ts +1 -1
  27. package/lib/chain/opPools/payloadAttestationPool.d.ts.map +1 -1
  28. package/lib/chain/opPools/payloadAttestationPool.js +30 -10
  29. package/lib/chain/opPools/payloadAttestationPool.js.map +1 -1
  30. package/lib/chain/opPools/proposerPreferencesPool.d.ts +29 -0
  31. package/lib/chain/opPools/proposerPreferencesPool.d.ts.map +1 -0
  32. package/lib/chain/opPools/proposerPreferencesPool.js +56 -0
  33. package/lib/chain/opPools/proposerPreferencesPool.js.map +1 -0
  34. package/lib/chain/validation/executionPayloadBid.d.ts.map +1 -1
  35. package/lib/chain/validation/executionPayloadBid.js +64 -16
  36. package/lib/chain/validation/executionPayloadBid.js.map +1 -1
  37. package/lib/chain/validation/payloadAttestationMessage.d.ts +1 -1
  38. package/lib/chain/validation/payloadAttestationMessage.d.ts.map +1 -1
  39. package/lib/chain/validation/payloadAttestationMessage.js +5 -3
  40. package/lib/chain/validation/payloadAttestationMessage.js.map +1 -1
  41. package/lib/metrics/metrics/lodestar.d.ts +4 -0
  42. package/lib/metrics/metrics/lodestar.d.ts.map +1 -1
  43. package/lib/metrics/metrics/lodestar.js +5 -0
  44. package/lib/metrics/metrics/lodestar.js.map +1 -1
  45. package/lib/network/interface.d.ts +1 -0
  46. package/lib/network/interface.d.ts.map +1 -1
  47. package/lib/network/network.d.ts +1 -0
  48. package/lib/network/network.d.ts.map +1 -1
  49. package/lib/network/network.js +5 -0
  50. package/lib/network/network.js.map +1 -1
  51. package/lib/network/processor/gossipHandlers.d.ts.map +1 -1
  52. package/lib/network/processor/gossipHandlers.js +39 -4
  53. package/lib/network/processor/gossipHandlers.js.map +1 -1
  54. package/lib/sync/range/batch.d.ts +20 -3
  55. package/lib/sync/range/batch.d.ts.map +1 -1
  56. package/lib/sync/range/batch.js +54 -10
  57. package/lib/sync/range/batch.js.map +1 -1
  58. package/lib/sync/range/chain.d.ts.map +1 -1
  59. package/lib/sync/range/chain.js +3 -1
  60. package/lib/sync/range/chain.js.map +1 -1
  61. package/lib/sync/range/range.d.ts.map +1 -1
  62. package/lib/sync/range/range.js +40 -2
  63. package/lib/sync/range/range.js.map +1 -1
  64. package/lib/sync/range/utils/peerBalancer.d.ts +2 -1
  65. package/lib/sync/range/utils/peerBalancer.d.ts.map +1 -1
  66. package/lib/sync/range/utils/peerBalancer.js +8 -4
  67. package/lib/sync/range/utils/peerBalancer.js.map +1 -1
  68. package/lib/sync/utils/downloadByRange.d.ts +7 -1
  69. package/lib/sync/utils/downloadByRange.d.ts.map +1 -1
  70. package/lib/sync/utils/downloadByRange.js +2 -0
  71. package/lib/sync/utils/downloadByRange.js.map +1 -1
  72. package/lib/util/dependentRoot.d.ts +6 -2
  73. package/lib/util/dependentRoot.d.ts.map +1 -1
  74. package/lib/util/dependentRoot.js +20 -16
  75. package/lib/util/dependentRoot.js.map +1 -1
  76. package/package.json +14 -15
  77. package/src/api/impl/beacon/pool/index.ts +56 -3
  78. package/src/chain/blocks/utils/chainSegment.ts +30 -27
  79. package/src/chain/chain.ts +3 -0
  80. package/src/chain/errors/executionPayloadBid.ts +22 -1
  81. package/src/chain/interface.ts +2 -0
  82. package/src/chain/lightClient/index.ts +6 -6
  83. package/src/chain/opPools/index.ts +1 -0
  84. package/src/chain/opPools/payloadAttestationPool.ts +34 -10
  85. package/src/chain/opPools/proposerPreferencesPool.ts +59 -0
  86. package/src/chain/validation/executionPayloadBid.ts +67 -17
  87. package/src/chain/validation/payloadAttestationMessage.ts +6 -4
  88. package/src/metrics/metrics/lodestar.ts +6 -0
  89. package/src/network/interface.ts +1 -0
  90. package/src/network/network.ts +11 -0
  91. package/src/network/processor/gossipHandlers.ts +43 -3
  92. package/src/sync/range/batch.ts +71 -11
  93. package/src/sync/range/chain.ts +3 -1
  94. package/src/sync/range/range.ts +52 -2
  95. package/src/sync/range/utils/peerBalancer.ts +9 -3
  96. package/src/sync/utils/downloadByRange.ts +8 -0
  97. package/src/util/dependentRoot.ts +22 -18
@@ -57,7 +57,7 @@ export class PayloadAttestationPool {
57
57
  add(
58
58
  message: gloas.PayloadAttestationMessage,
59
59
  payloadAttDataRootHex: RootHex,
60
- validatorCommitteeIndex: number
60
+ validatorCommitteeIndices: number[]
61
61
  ): InsertOutcome {
62
62
  const slot = message.data.slot;
63
63
  const lowestPermissibleSlot = this.lowestPermissibleSlot;
@@ -85,10 +85,10 @@ export class PayloadAttestationPool {
85
85
  const aggregate = aggregateByDataRoot.get(payloadAttDataRootHex);
86
86
  if (aggregate) {
87
87
  // Aggregate msg into aggregate
88
- return aggregateMessageInto(message, validatorCommitteeIndex, aggregate);
88
+ return aggregateMessageInto(message, validatorCommitteeIndices, aggregate);
89
89
  }
90
90
  // Create a new aggregate with data
91
- aggregateByDataRoot.set(payloadAttDataRootHex, messageToAggregate(message, validatorCommitteeIndex));
91
+ aggregateByDataRoot.set(payloadAttDataRootHex, messageToAggregate(message, validatorCommitteeIndices));
92
92
 
93
93
  return InsertOutcome.NewData;
94
94
  }
@@ -150,25 +150,49 @@ export class PayloadAttestationPool {
150
150
  }
151
151
  }
152
152
 
153
- function messageToAggregate(message: gloas.PayloadAttestationMessage, validatorCommitteeIndex: number): AggregateFast {
153
+ function messageToAggregate(
154
+ message: gloas.PayloadAttestationMessage,
155
+ validatorCommitteeIndices: number[]
156
+ ): AggregateFast {
157
+ const aggregationBits = BitArray.fromBitLen(PTC_SIZE);
158
+ for (const index of validatorCommitteeIndices) {
159
+ aggregationBits.set(index, true);
160
+ }
161
+ const sig = signatureFromBytesNoCheck(message.signature);
162
+ // The validator signed once but occupies `validatorCommitteeIndices.length` PTC positions.
163
+ // Verification aggregates the pubkey once per set bit, so the signature must be aggregated
164
+ // the same number of times for the BLS check to balance — same pattern as sync committee.
165
+ const signature =
166
+ validatorCommitteeIndices.length === 1
167
+ ? sig
168
+ : aggregateSignatures(new Array(validatorCommitteeIndices.length).fill(sig));
154
169
  return {
155
- aggregationBits: BitArray.fromSingleBit(PTC_SIZE, validatorCommitteeIndex),
170
+ aggregationBits,
156
171
  data: message.data,
157
- signature: signatureFromBytesNoCheck(message.signature),
172
+ signature,
158
173
  };
159
174
  }
160
175
 
161
176
  function aggregateMessageInto(
162
177
  message: gloas.PayloadAttestationMessage,
163
- validatorCommitteeIndex: number,
178
+ validatorCommitteeIndices: number[],
164
179
  aggregate: AggregateFast
165
180
  ): InsertOutcome {
166
- if (aggregate.aggregationBits.get(validatorCommitteeIndex) === true) {
181
+ // Gossip dedup via `seenPayloadAttesters` is keyed by (epoch, validatorIndex), so the same
182
+ // validator's message is never processed twice — all of its bits are set together or none.
183
+ // Checking the first index is sufficient.
184
+ if (aggregate.aggregationBits.get(validatorCommitteeIndices[0]) === true) {
167
185
  return InsertOutcome.AlreadyKnown;
168
186
  }
169
187
 
170
- aggregate.aggregationBits.set(validatorCommitteeIndex, true);
171
- aggregate.signature = aggregateSignatures([aggregate.signature, signatureFromBytesNoCheck(message.signature)]);
188
+ for (const index of validatorCommitteeIndices) {
189
+ aggregate.aggregationBits.set(index, true);
190
+ }
191
+ const sig = signatureFromBytesNoCheck(message.signature);
192
+ aggregate.signature = aggregateSignatures([
193
+ aggregate.signature,
194
+ ...new Array(validatorCommitteeIndices.length).fill(sig),
195
+ ]);
172
196
 
173
197
  return InsertOutcome.Aggregated;
174
198
  }
@@ -0,0 +1,59 @@
1
+ import {RootHex, Slot, gloas} from "@lodestar/types";
2
+ import {toRootHex} from "@lodestar/utils";
3
+
4
+ /**
5
+ * Pool of validated `SignedProposerPreferences` indexed by `(slot, dependent_root)`.
6
+ *
7
+ * The primary consumer is `validateExecutionPayloadBid`, which looks up the matching
8
+ * preferences via `get(bid.slot, dependent_root)` to enforce the IGNORE-existence and
9
+ * REJECT-equality rules from the gloas spec. The beacon API `/pool/proposer_preferences`
10
+ * GET endpoint reads from the same pool via `getAll`.
11
+ *
12
+ * `validator_index` is intentionally not part of the key: gossip validation enforces
13
+ * `proposers[proposalSlot % SLOTS_PER_EPOCH] === validatorIndex` against the shuffling
14
+ * implied by `dependent_root`, so once a preference has been validated `(slot, dependent_root)`
15
+ * already pins down the validator.
16
+ */
17
+ export class ProposerPreferencesPool {
18
+ private readonly bySlot = new Map<Slot, Map<RootHex, gloas.SignedProposerPreferences>>();
19
+
20
+ /** Lookup for bid validation: matches `(bid.slot, get_proposer_dependent_root(parent_state, ...))`. */
21
+ get(slot: Slot, dependentRootHex: RootHex): gloas.SignedProposerPreferences | null {
22
+ return this.bySlot.get(slot)?.get(dependentRootHex) ?? null;
23
+ }
24
+
25
+ add(signed: gloas.SignedProposerPreferences): void {
26
+ const {proposalSlot, dependentRoot} = signed.message;
27
+ const rootHex = toRootHex(dependentRoot);
28
+ let byRoot = this.bySlot.get(proposalSlot);
29
+ if (!byRoot) {
30
+ byRoot = new Map();
31
+ this.bySlot.set(proposalSlot, byRoot);
32
+ }
33
+ byRoot.set(rootHex, signed);
34
+ }
35
+
36
+ /** API read-out: flatten across branches, optionally filtered by slot. */
37
+ getAll(slot?: Slot): gloas.SignedProposerPreferences[] {
38
+ if (slot !== undefined) {
39
+ const byRoot = this.bySlot.get(slot);
40
+ return byRoot ? Array.from(byRoot.values()) : [];
41
+ }
42
+ const out: gloas.SignedProposerPreferences[] = [];
43
+ for (const byRoot of this.bySlot.values()) {
44
+ for (const v of byRoot.values()) out.push(v);
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * Entries are only load-bearing while `proposal_slot >= current_slot`. Once the slot has
51
+ * passed the `[IGNORE] proposal_slot > current_slot` gossip rule takes over, so drop them
52
+ * on each slot tick.
53
+ */
54
+ prune(currentSlot: Slot): void {
55
+ for (const slot of this.bySlot.keys()) {
56
+ if (slot < currentSlot) this.bySlot.delete(slot);
57
+ }
58
+ }
59
+ }
@@ -7,7 +7,8 @@ import {
7
7
  isStatePostGloas,
8
8
  } from "@lodestar/state-transition";
9
9
  import {gloas} from "@lodestar/types";
10
- import {toRootHex} from "@lodestar/utils";
10
+ import {byteArrayEquals, toHex, toRootHex} from "@lodestar/utils";
11
+ import {getShufflingDependentRoot} from "../../util/dependentRoot.js";
11
12
  import {ExecutionPayloadBidError, ExecutionPayloadBidErrorCode, GossipAction} from "../errors/index.js";
12
13
  import {IBeaconChain} from "../index.js";
13
14
  import {RegenCaller} from "../regen/index.js";
@@ -48,12 +49,55 @@ async function validateExecutionPayloadBid(
48
49
  });
49
50
  }
50
51
 
52
+ // [IGNORE] `bid.parent_block_root` is the hash tree root of a known beacon block in fork choice.
53
+ // Moved earlier than the spec ordering so we can derive the proposer dependent root for the
54
+ // proposer-preferences lookup below from a known fork-choice block.
55
+ const parentBlock = chain.forkChoice.getBlockHexDefaultStatus(parentBlockRootHex);
56
+ if (parentBlock === null) {
57
+ throw new ExecutionPayloadBidError(GossipAction.IGNORE, {
58
+ code: ExecutionPayloadBidErrorCode.UNKNOWN_BLOCK_ROOT,
59
+ parentBlockRoot: parentBlockRootHex,
60
+ });
61
+ }
62
+
51
63
  // [IGNORE] A `SignedProposerPreferences` matching `bid.slot` and the bid's branch has been
52
64
  // seen — i.e. `proposal_slot == bid.slot` AND `dependent_root ==
53
- // get_proposer_dependent_root(parent_state, compute_epoch_at_slot(bid.slot))`,
54
- // where `parent_state` is the post-state of `bid.parent_block_root`.
55
- // This is the message referenced as `proposer_preferences` in the following REJECT rules.
56
- // TODO GLOAS: Implement once a ProposerPreferencesPool exists.
65
+ // get_proposer_dependent_root(parent_state, compute_epoch_at_slot(bid.slot))`.
66
+ const bidEpoch = computeEpochAtSlot(bid.slot);
67
+ // gloas is always post-Fulu, so `get_proposer_dependent_root` is the post-Fulu (deterministic
68
+ // proposer lookahead) form `block_root_at(start_slot(epoch - MIN_SEED_LOOKAHEAD) - 1)` with
69
+ // `MIN_SEED_LOOKAHEAD == 1` — identical to the attester-shuffling dependent root for the same
70
+ // epoch (both 1-epoch lookahead), hence `getShufflingDependentRoot`. `null` on a
71
+ // unknown/finalized-pruned ancestor or genesis edge → degrade to IGNORE below instead of
72
+ // letting a raw `ForkChoiceError` escape the `GossipActionError` contract.
73
+ const dependentRootHex = (() => {
74
+ try {
75
+ return getShufflingDependentRoot(chain.forkChoice, bidEpoch, computeEpochAtSlot(parentBlock.slot), parentBlock);
76
+ } catch {
77
+ return null;
78
+ }
79
+ })();
80
+
81
+ if (dependentRootHex === null) {
82
+ // Could not derive the dependent root for this branch (unknown/finalized-pruned ancestor,
83
+ // genesis edge, etc.) → definitionally no matching `SignedProposerPreferences`.
84
+ throw new ExecutionPayloadBidError(GossipAction.IGNORE, {
85
+ code: ExecutionPayloadBidErrorCode.NO_MATCHING_PROPOSER_PREFERENCES,
86
+ slot: bid.slot,
87
+ parentBlockRoot: parentBlockRootHex,
88
+ dependentRoot: "unknown",
89
+ });
90
+ }
91
+
92
+ const proposerPreferences = chain.proposerPreferencesPool.get(bid.slot, dependentRootHex);
93
+ if (proposerPreferences === null) {
94
+ throw new ExecutionPayloadBidError(GossipAction.IGNORE, {
95
+ code: ExecutionPayloadBidErrorCode.NO_MATCHING_PROPOSER_PREFERENCES,
96
+ slot: bid.slot,
97
+ parentBlockRoot: parentBlockRootHex,
98
+ dependentRoot: dependentRootHex,
99
+ });
100
+ }
57
101
 
58
102
  // [REJECT] `bid.builder_index` is a valid/active builder index -- i.e.
59
103
  // `is_active_builder(state, bid.builder_index)` returns `True`.
@@ -75,10 +119,25 @@ async function validateExecutionPayloadBid(
75
119
  }
76
120
 
77
121
  // [REJECT] `bid.fee_recipient == proposer_preferences.fee_recipient`.
122
+ if (!byteArrayEquals(bid.feeRecipient, proposerPreferences.message.feeRecipient)) {
123
+ throw new ExecutionPayloadBidError(GossipAction.REJECT, {
124
+ code: ExecutionPayloadBidErrorCode.PROPOSER_PREFERENCES_FEE_RECIPIENT_MISMATCH,
125
+ builderIndex: bid.builderIndex,
126
+ bidFeeRecipient: toHex(bid.feeRecipient),
127
+ expectedFeeRecipient: toHex(proposerPreferences.message.feeRecipient),
128
+ });
129
+ }
130
+
78
131
  // [REJECT] `bid.gas_limit == proposer_preferences.gas_limit`.
79
- // Both compared against the matching `proposer_preferences` defined above (same branch
80
- // via dependent_root, same proposal_slot).
81
- // TODO GLOAS: Implement once a ProposerPreferencesPool exists.
132
+ const bidGasLimit = Number(bid.gasLimit);
133
+ if (bidGasLimit !== proposerPreferences.message.gasLimit) {
134
+ throw new ExecutionPayloadBidError(GossipAction.REJECT, {
135
+ code: ExecutionPayloadBidErrorCode.PROPOSER_PREFERENCES_GAS_LIMIT_MISMATCH,
136
+ builderIndex: bid.builderIndex,
137
+ bidGasLimit,
138
+ expectedGasLimit: proposerPreferences.message.gasLimit,
139
+ });
140
+ }
82
141
 
83
142
  // [REJECT] The length of KZG commitments is less than or equal to the limitation defined in the
84
143
  // consensus layer -- i.e. validate that
@@ -128,15 +187,6 @@ async function validateExecutionPayloadBid(
128
187
  // payload in fork choice.
129
188
  // TODO GLOAS: implement this
130
189
 
131
- // [IGNORE] `bid.parent_block_root` is the hash tree root of a known beacon
132
- // block in fork choice.
133
- if (!chain.forkChoice.hasBlock(bid.parentBlockRoot)) {
134
- throw new ExecutionPayloadBidError(GossipAction.IGNORE, {
135
- code: ExecutionPayloadBidErrorCode.UNKNOWN_BLOCK_ROOT,
136
- parentBlockRoot: parentBlockRootHex,
137
- });
138
- }
139
-
140
190
  // [REJECT] `signed_execution_payload_bid.signature` is valid with respect to the `bid.builder_index`.
141
191
  const signatureSet = createSingleSignatureSetFromComponents(
142
192
  PublicKey.fromBytes(builder.pubkey),
@@ -11,7 +11,7 @@ import {IBeaconChain} from "../index.js";
11
11
 
12
12
  export type PayloadAttestationValidationResult = {
13
13
  attDataRootHex: RootHex;
14
- validatorCommitteeIndex: number;
14
+ validatorCommitteeIndices: number[];
15
15
  };
16
16
 
17
17
  export async function validateApiPayloadAttestationMessage(
@@ -80,9 +80,11 @@ async function validatePayloadAttestationMessage(
80
80
  // [REJECT] The message's validator index is within the payload committee in
81
81
  // `get_ptc(state, data.slot)`. The `state` is the head state corresponding to
82
82
  // processing the block up to the current slot as determined by the fork choice.
83
- const validatorCommitteeIndex = state.getIndexInPayloadTimelinessCommittee(validatorIndex, data.slot);
83
+ // The validator may occupy multiple PTC positions because `compute_ptc` samples
84
+ // by effective balance — collect all of them so duplicate votes are counted.
85
+ const validatorCommitteeIndices = state.getIndicesInPayloadTimelinessCommittee(validatorIndex, data.slot);
84
86
 
85
- if (validatorCommitteeIndex === -1) {
87
+ if (validatorCommitteeIndices.length === 0) {
86
88
  throw new PayloadAttestationError(GossipAction.REJECT, {
87
89
  code: PayloadAttestationErrorCode.INVALID_ATTESTER,
88
90
  attesterIndex: validatorIndex,
@@ -115,6 +117,6 @@ async function validatePayloadAttestationMessage(
115
117
 
116
118
  return {
117
119
  attDataRootHex: toRootHex(ssz.gloas.PayloadAttestationData.hashTreeRoot(data)),
118
- validatorCommitteeIndex,
120
+ validatorCommitteeIndices,
119
121
  };
120
122
  }
@@ -3,6 +3,7 @@ import {NotReorgedReason} from "@lodestar/fork-choice";
3
3
  import {ArchiveStoreTask} from "../../chain/archiveStore/archiveStore.js";
4
4
  import {FrequencyStateArchiveStep} from "../../chain/archiveStore/strategies/frequencyStateArchiveStrategy.js";
5
5
  import {BlockInputSource} from "../../chain/blocks/blockInput/index.js";
6
+ import {PayloadErrorCode} from "../../chain/blocks/importExecutionPayload.js";
6
7
  import {PayloadEnvelopeInputSource} from "../../chain/blocks/payloadEnvelopeInput/index.js";
7
8
  import {JobQueueItemType} from "../../chain/bls/index.js";
8
9
  import {AttestationErrorCode, BlockErrorCode} from "../../chain/errors/index.js";
@@ -890,6 +891,11 @@ export function createLodestarMetrics(
890
891
  labelNames: ["source"],
891
892
  buckets: [0.5, 1, 2, 4, 6, 12],
892
893
  }),
894
+ processPayloadErrors: register.gauge<{error: PayloadErrorCode | "NOT_PAYLOAD_ERROR"}>({
895
+ name: "lodestar_gossip_execution_payload_envelope_process_payload_errors",
896
+ help: "Count of errors, by error type, while processing execution payload envelopes",
897
+ labelNames: ["error"],
898
+ }),
893
899
  },
894
900
  // recovery in the case of specific blob rows required
895
901
  recoverBlobSidecars: {
@@ -114,6 +114,7 @@ export interface INetwork extends INetworkCorePublic {
114
114
  publishLightClientOptimisticUpdate(update: LightClientOptimisticUpdate): Promise<number>;
115
115
  publishSignedExecutionPayloadEnvelope(signedEnvelope: gloas.SignedExecutionPayloadEnvelope): Promise<number>;
116
116
  publishPayloadAttestationMessage(payloadAttestationMessage: gloas.PayloadAttestationMessage): Promise<number>;
117
+ publishProposerPreferences(signedProposerPreferences: gloas.SignedProposerPreferences): Promise<number>;
117
118
 
118
119
  // Debug
119
120
  dumpGossipQueue(gossipType: GossipType): Promise<PendingGossipsubMessage[]>;
@@ -526,6 +526,17 @@ export class Network implements INetwork {
526
526
  );
527
527
  }
528
528
 
529
+ async publishProposerPreferences(signedProposerPreferences: gloas.SignedProposerPreferences): Promise<number> {
530
+ const epoch = computeEpochAtSlot(signedProposerPreferences.message.proposalSlot);
531
+ const boundary = this.config.getForkBoundaryAtEpoch(epoch);
532
+
533
+ return this.publishGossip<GossipType.proposer_preferences>(
534
+ {type: GossipType.proposer_preferences, boundary},
535
+ signedProposerPreferences,
536
+ {ignoreDuplicatePublishError: true}
537
+ );
538
+ }
539
+
529
540
  private async publishGossip<K extends GossipType>(
530
541
  topic: GossipTopicMap[K],
531
542
  object: GossipTypeMap[K],
@@ -35,6 +35,7 @@ import {
35
35
  IBlockInput,
36
36
  isBlockInputColumns,
37
37
  } from "../../chain/blocks/blockInput/index.js";
38
+ import {PayloadError, PayloadErrorCode} from "../../chain/blocks/importExecutionPayload.js";
38
39
  import {PayloadEnvelopeInput, PayloadEnvelopeInputSource} from "../../chain/blocks/payloadEnvelopeInput/index.js";
39
40
  import {BlobSidecarValidation} from "../../chain/blocks/types.js";
40
41
  import {ChainEvent} from "../../chain/emitter.js";
@@ -1148,7 +1149,40 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand
1148
1149
  });
1149
1150
 
1150
1151
  chain.processExecutionPayload(payloadInput, {validSignature: true}).catch((e) => {
1151
- chain.logger.debug("Error processing execution payload from gossip", {slot, root: blockRootHex}, e as Error);
1152
+ // Adjust verbosity based on error type
1153
+ let logLevel: LogLevel;
1154
+
1155
+ if (e instanceof PayloadError) {
1156
+ switch (e.type.code) {
1157
+ // BLOCK_NOT_IN_FORK_CHOICE should not happen, validateGossipExecutionPayloadEnvelope above
1158
+ // already verified the block is in fork choice
1159
+ case PayloadErrorCode.BLOCK_NOT_IN_FORK_CHOICE:
1160
+ case PayloadErrorCode.MISS_BLOCK_STATE:
1161
+ case PayloadErrorCode.EXECUTION_ENGINE_ERROR:
1162
+ // Errors might indicate an issue with our node or the connected EL client
1163
+ logLevel = LogLevel.error;
1164
+ break;
1165
+ // INVALID_SIGNATURE should not happen, signature is verified during gossip validation
1166
+ case PayloadErrorCode.INVALID_SIGNATURE:
1167
+ case PayloadErrorCode.ENVELOPE_VERIFICATION_ERROR:
1168
+ case PayloadErrorCode.EXECUTION_ENGINE_INVALID:
1169
+ core.reportPeer(peerIdStr, PeerAction.LowToleranceError, "BadGossipPayload");
1170
+ // Misbehaving peer, but could highlight an issue in another client
1171
+ logLevel = LogLevel.warn;
1172
+ break;
1173
+ }
1174
+ } else {
1175
+ // Any unexpected error
1176
+ logLevel = LogLevel.error;
1177
+ }
1178
+ metrics?.gossipExecutionPayloadEnvelope.processPayloadErrors.inc({
1179
+ error: e instanceof PayloadError ? e.type.code : "NOT_PAYLOAD_ERROR",
1180
+ });
1181
+ chain.logger[logLevel](
1182
+ "Error processing execution payload from gossip",
1183
+ {slot, peer: peerIdStr, root: blockRootHex},
1184
+ e as Error
1185
+ );
1152
1186
  });
1153
1187
  },
1154
1188
  [GossipType.payload_attestation_message]: async ({
@@ -1163,7 +1197,7 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand
1163
1197
  const insertOutcome = chain.payloadAttestationPool.add(
1164
1198
  payloadAttestationMessage,
1165
1199
  validationResult.attDataRootHex,
1166
- validationResult.validatorCommitteeIndex
1200
+ validationResult.validatorCommitteeIndices
1167
1201
  );
1168
1202
  metrics?.opPool.payloadAttestationPool.gossipInsertOutcome.inc({insertOutcome});
1169
1203
  } catch (e) {
@@ -1171,7 +1205,7 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand
1171
1205
  }
1172
1206
  chain.forkChoice.notifyPtcMessages(
1173
1207
  toRootHex(payloadAttestationMessage.data.beaconBlockRoot),
1174
- [validationResult.validatorCommitteeIndex],
1208
+ validationResult.validatorCommitteeIndices,
1175
1209
  payloadAttestationMessage.data.payloadPresent
1176
1210
  );
1177
1211
  },
@@ -1203,6 +1237,12 @@ function getSequentialHandlers(modules: ValidatorFnsModules, options: GossipHand
1203
1237
  const {serializedData} = gossipData;
1204
1238
  const signedProposerPreferences = sszDeserialize(topic, serializedData);
1205
1239
  await validateGossipProposerPreferences(chain, signedProposerPreferences);
1240
+
1241
+ chain.proposerPreferencesPool.add(signedProposerPreferences);
1242
+ chain.emitter.emit(routes.events.EventType.proposerPreferences, {
1243
+ version: ForkName.gloas,
1244
+ data: signedProposerPreferences,
1245
+ });
1206
1246
  },
1207
1247
  };
1208
1248
  }
@@ -45,6 +45,15 @@ export type Attempt = {
45
45
  hash: RootHex;
46
46
  };
47
47
 
48
+ type TrackedRequest = {
49
+ /** only happen for the 1st batch in checkpoint sync */
50
+ parentPayload: boolean;
51
+ /**
52
+ * we always issue by_range before parent_payload, so we don't model this as null
53
+ */
54
+ byRangeColumns: Set<number>;
55
+ };
56
+
48
57
  export type AwaitingDownloadState = {
49
58
  status: BatchStatus.AwaitingDownload;
50
59
  blocks: IBlockInput[];
@@ -62,6 +71,7 @@ export type BatchState =
62
71
  | {
63
72
  status: BatchStatus.Downloading;
64
73
  peer: PeerIdStr;
74
+ request: TrackedRequest;
65
75
  blocks: IBlockInput[];
66
76
  payloadEnvelopes: Map<Slot, PayloadEnvelopeInput> | null;
67
77
  }
@@ -110,6 +120,13 @@ function formatColumnsReq(req: {startSlot: Slot; count: number; columns: number[
110
120
  return `startSlot=${req.startSlot},count=${req.count},cols=${prettyPrintIndices(req.columns)}`;
111
121
  }
112
122
 
123
+ function getTrackedRequest({parentPayloadRequest, columnsRequest}: DownloadByRangeRequests): TrackedRequest {
124
+ return {
125
+ parentPayload: parentPayloadRequest != null,
126
+ byRangeColumns: new Set(parentPayloadRequest == null ? (columnsRequest?.columns ?? []) : []),
127
+ };
128
+ }
129
+
113
130
  /**
114
131
  * Batches are downloaded at the first block of the epoch.
115
132
  *
@@ -131,8 +148,8 @@ export class Batch {
131
148
  requests: DownloadByRangeRequests;
132
149
  /** State of the batch. */
133
150
  state: BatchState = {status: BatchStatus.AwaitingDownload, blocks: [], payloadEnvelopes: null};
134
- /** Peers that provided good data */
135
- goodPeers: PeerIdStr[] = [];
151
+ /** Peers that provided good data, with column coverage for by_range requests */
152
+ private readonly successfulDownloads = new Map<PeerIdStr, TrackedRequest>();
136
153
  /** The `Attempts` that have been made and failed to send us this batch. */
137
154
  readonly failedProcessingAttempts: Attempt[] = [];
138
155
  /** The `Attempts` that have been made and failed because of execution malfunction. */
@@ -406,6 +423,35 @@ export class Batch {
406
423
  return [...this.failedDownloadAttempts, ...this.failedProcessingAttempts.flatMap((a) => a.peers)];
407
424
  }
408
425
 
426
+ /**
427
+ * True only if the peer has already returned a successful response for the current request.
428
+ * A by_range success may update `this.requests` to parent_payload, and the same peer is then
429
+ * still eligible for the newly discovered parent payload data.
430
+ * For by_range, a peer that previously succeeded with a superset of requested columns is skipped.
431
+ */
432
+ hasPeerSucceededCurrentRequest(peer: PeerSyncMeta): boolean {
433
+ const successfulDownload = this.successfulDownloads.get(peer.peerId);
434
+ if (successfulDownload == null) return false;
435
+
436
+ const request = getTrackedRequest(this.getRequestsForPeer(peer));
437
+ if (request.parentPayload) return successfulDownload.parentPayload;
438
+
439
+ const requestByRangeColumns = request.byRangeColumns;
440
+
441
+ if (requestByRangeColumns.size === 0) {
442
+ // this means a download blocks/envelops by_range only
443
+ // don't do that again if we already did it
444
+ // see https://github.com/ChainSafe/lodestar/issues/9357
445
+ return true;
446
+ }
447
+
448
+ return [...requestByRangeColumns].every((column) => successfulDownload.byRangeColumns.has(column));
449
+ }
450
+
451
+ private getSuccessfulPeers(): PeerIdStr[] {
452
+ return Array.from(this.successfulDownloads.keys());
453
+ }
454
+
409
455
  getMetadata(): BatchMetadata {
410
456
  const {blocksRequest, blobsRequest, columnsRequest, envelopesRequest} = this.requests;
411
457
  const failedProcessingPeerList = this.failedProcessingAttempts.flatMap((a) => a.peers);
@@ -440,14 +486,17 @@ export class Batch {
440
486
  /**
441
487
  * AwaitingDownload -> Downloading
442
488
  */
443
- startDownloading(peer: PeerIdStr): void {
489
+ startDownloading(peer: PeerSyncMeta): void {
444
490
  if (this.state.status !== BatchStatus.AwaitingDownload) {
445
491
  throw new BatchError(this.wrongStatusErrorType(BatchStatus.AwaitingDownload));
446
492
  }
447
493
 
494
+ const request = getTrackedRequest(this.getRequestsForPeer(peer));
495
+
448
496
  this.state = {
449
497
  status: BatchStatus.Downloading,
450
- peer,
498
+ peer: peer.peerId,
499
+ request,
451
500
  blocks: this.state.blocks,
452
501
  payloadEnvelopes: this.state.payloadEnvelopes,
453
502
  };
@@ -468,7 +517,17 @@ export class Batch {
468
517
  // ensure that blocks are always sorted before getting stored on the batch.state or being used to getRequests
469
518
  blocks.sort((a, b) => a.slot - b.slot);
470
519
 
471
- this.goodPeers.push(peer);
520
+ const successfulDownload = this.successfulDownloads.get(peer) ?? {
521
+ parentPayload: false,
522
+ byRangeColumns: new Set<number>(),
523
+ };
524
+ successfulDownload.parentPayload ||= this.state.request.parentPayload;
525
+ if (!this.state.request.parentPayload) {
526
+ for (const column of this.state.request.byRangeColumns) {
527
+ successfulDownload.byRangeColumns.add(column);
528
+ }
529
+ }
530
+ this.successfulDownloads.set(peer, successfulDownload);
472
531
 
473
532
  let allComplete = true;
474
533
  const slots = new Set<number>();
@@ -497,8 +556,9 @@ export class Batch {
497
556
  if (allComplete && isForkPostGloas(this.forkName)) {
498
557
  for (const block of blocks) {
499
558
  const payloadInput = newPayloadEnvelopes?.get(block.slot);
500
- // by_range needs every block's envelope and all sampled columns.
501
- if (!payloadInput?.hasPayloadEnvelope() || !payloadInput.hasComputedAllData()) {
559
+ // only need to make sure envelope has all columns, not all blocks have payload
560
+ // assertLinearChainSegment() was called before reaching this
561
+ if (payloadInput?.hasPayloadEnvelope() && !payloadInput.hasComputedAllData()) {
502
562
  allComplete = false;
503
563
  break;
504
564
  }
@@ -589,10 +649,10 @@ export class Batch {
589
649
  const blocks = this.state.blocks;
590
650
  const payloadEnvelopes = this.state.payloadEnvelopes;
591
651
  const hash = hashBlocks(blocks, this.config); // tracks blocks to report peer on processing error
592
- // Reset goodPeers in case another download attempt needs to be made. When Attempt is successful or not the peers
593
- // that the data came from will be handled by the Attempt that goes for processing
594
- const peers = this.goodPeers;
595
- this.goodPeers = [];
652
+ // Reset successfulDownloads in case another download attempt needs to be made. When Attempt is successful or not
653
+ // the peers that the data came from will be handled by the Attempt that goes for processing.
654
+ const peers = this.getSuccessfulPeers();
655
+ this.successfulDownloads.clear();
596
656
  this.state = {status: BatchStatus.Processing, blocks, payloadEnvelopes, attempt: {peers, hash}};
597
657
  return {blocks, payloadEnvelopes, peers};
598
658
  }
@@ -551,7 +551,7 @@ export class SyncChain {
551
551
  peer: prettyPrintPeerIdStr(peer.peerId),
552
552
  });
553
553
  try {
554
- batch.startDownloading(peer.peerId);
554
+ batch.startDownloading(peer);
555
555
 
556
556
  // wrapError ensures to never call both batch success() and batch error()
557
557
  const res = await wrapError(this.downloadByRange(peer, batch, this.syncType));
@@ -581,6 +581,8 @@ export class SyncChain {
581
581
  case DownloadByRangeErrorCode.OUT_OF_ORDER_BLOCKS:
582
582
  case DownloadByRangeErrorCode.OUT_OF_RANGE_BLOCKS:
583
583
  case DownloadByRangeErrorCode.PARENT_ROOT_MISMATCH:
584
+ case DownloadByRangeErrorCode.INVALID_ENVELOPE_BEACON_BLOCK_ROOT:
585
+ case DownloadByRangeErrorCode.INVALID_CHAIN_SEGMENT:
584
586
  case BlobSidecarErrorCode.INCLUSION_PROOF_INVALID:
585
587
  case BlobSidecarErrorCode.INVALID_KZG_PROOF_BATCH:
586
588
  case DataColumnSidecarErrorCode.INCORRECT_KZG_COMMITMENTS_COUNT:
@@ -3,14 +3,21 @@ import {StrictEventEmitter} from "strict-event-emitter-types";
3
3
  import {BeaconConfig} from "@lodestar/config";
4
4
  import {IBeaconStateViewGloas, computeStartSlotAtEpoch, isStatePostGloas} from "@lodestar/state-transition";
5
5
  import {Epoch, Status, fulu} from "@lodestar/types";
6
- import {Logger, toRootHex} from "@lodestar/utils";
6
+ import {Logger, prettyPrintIndices, toRootHex} from "@lodestar/utils";
7
7
  import {IBlockInput} from "../../chain/blocks/blockInput/types.js";
8
8
  import {AttestationImportOpt, ImportBlockOpts} from "../../chain/blocks/index.js";
9
+ import {assertLinearChainSegment} from "../../chain/blocks/utils/chainSegment.js";
10
+ import {BlockError} from "../../chain/errors/index.js";
9
11
  import {IBeaconChain} from "../../chain/index.js";
10
12
  import {Metrics} from "../../metrics/index.js";
11
13
  import {INetwork} from "../../network/index.js";
12
14
  import {PeerIdStr} from "../../util/peerId.js";
13
- import {cacheByRangeResponses, downloadByRange} from "../utils/downloadByRange.js";
15
+ import {
16
+ DownloadByRangeError,
17
+ DownloadByRangeErrorCode,
18
+ cacheByRangeResponses,
19
+ downloadByRange,
20
+ } from "../utils/downloadByRange.js";
14
21
  import {RangeSyncType, getRangeSyncTarget, rangeSyncTypes} from "../utils/remoteSyncType.js";
15
22
  import {ChainTarget, SyncChain, SyncChainDebugState, SyncChainFns} from "./chain.js";
16
23
  import {updateChains} from "./utils/index.js";
@@ -231,6 +238,49 @@ export class RangeSync extends (EventEmitter as {new (): RangeSyncEmitter}) {
231
238
  custodyConfig: this.chain.custodyConfig,
232
239
  seenTimestampSec: Date.now() / 1000,
233
240
  });
241
+
242
+ const segmentBlocks = blocks.filter((b) => b.hasBlock()).sort((a, b) => a.slot - b.slot);
243
+ const envelopeSlots = payloadEnvelopes
244
+ ? Array.from(payloadEnvelopes.entries())
245
+ .filter(([, pi]) => pi.hasPayloadEnvelope())
246
+ .map(([slot]) => slot)
247
+ .sort((a, b) => a - b)
248
+ : [];
249
+ this.logger.verbose("downloadByRange batch ready", {
250
+ peer: peer.peerId,
251
+ blockSlots: prettyPrintIndices(segmentBlocks.map((b) => b.slot)),
252
+ envelopeSlots: prettyPrintIndices(envelopeSlots),
253
+ ...batch.getMetadata(),
254
+ });
255
+
256
+ if (segmentBlocks.length > 1) {
257
+ try {
258
+ assertLinearChainSegment(this.config, segmentBlocks, payloadEnvelopes, null);
259
+ } catch (err) {
260
+ if (err instanceof BlockError) {
261
+ this.logger.debug(
262
+ "downloadByRange segment validation failed",
263
+ {
264
+ peer: peer.peerId,
265
+ reason: err.type.code,
266
+ slot: err.signedBlock.message.slot,
267
+ detail: JSON.stringify(err.type),
268
+ ...batch.getMetadata(),
269
+ },
270
+ err
271
+ );
272
+ // with this error, the peer will be penalized inside SyncChain
273
+ throw new DownloadByRangeError({
274
+ code: DownloadByRangeErrorCode.INVALID_CHAIN_SEGMENT,
275
+ slot: err.signedBlock.message.slot,
276
+ reason: err.type.code,
277
+ });
278
+ }
279
+
280
+ throw err;
281
+ }
282
+ }
283
+
234
284
  return {result: {blocks, payloadEnvelopes}, warnings};
235
285
  };
236
286