@lodestar/validator 1.47.0-dev.9d19aee2e7 → 1.47.0-dev.9ecc10f386

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lodestar/validator",
3
- "version": "1.47.0-dev.9d19aee2e7",
3
+ "version": "1.47.0-dev.9ecc10f386",
4
4
  "description": "A Typescript implementation of the validator client",
5
5
  "author": "ChainSafe Systems",
6
6
  "license": "Apache-2.0",
@@ -48,23 +48,23 @@
48
48
  "blockchain"
49
49
  ],
50
50
  "dependencies": {
51
- "@chainsafe/blst": "^2.2.0",
52
- "@chainsafe/ssz": "^1.6.2",
53
- "@lodestar/api": "^1.47.0-dev.9d19aee2e7",
54
- "@lodestar/config": "^1.47.0-dev.9d19aee2e7",
55
- "@lodestar/db": "^1.47.0-dev.9d19aee2e7",
56
- "@lodestar/params": "^1.47.0-dev.9d19aee2e7",
57
- "@lodestar/state-transition": "^1.47.0-dev.9d19aee2e7",
58
- "@lodestar/types": "^1.47.0-dev.9d19aee2e7",
59
- "@lodestar/utils": "^1.47.0-dev.9d19aee2e7",
51
+ "@chainsafe/lodestar-z": "catalog:",
52
+ "@chainsafe/ssz": "^1.6.3",
53
+ "@lodestar/api": "^1.47.0-dev.9ecc10f386",
54
+ "@lodestar/config": "^1.47.0-dev.9ecc10f386",
55
+ "@lodestar/db": "^1.47.0-dev.9ecc10f386",
56
+ "@lodestar/params": "^1.47.0-dev.9ecc10f386",
57
+ "@lodestar/state-transition": "^1.47.0-dev.9ecc10f386",
58
+ "@lodestar/types": "^1.47.0-dev.9ecc10f386",
59
+ "@lodestar/utils": "^1.47.0-dev.9ecc10f386",
60
60
  "strict-event-emitter-types": "^2.0.0"
61
61
  },
62
62
  "devDependencies": {
63
- "@lodestar/logger": "^1.47.0-dev.9d19aee2e7",
64
- "@lodestar/spec-test-util": "^1.47.0-dev.9d19aee2e7",
65
- "@lodestar/test-utils": "^1.47.0-dev.9d19aee2e7",
63
+ "@lodestar/logger": "^1.47.0-dev.9ecc10f386",
64
+ "@lodestar/spec-test-util": "^1.47.0-dev.9ecc10f386",
65
+ "@lodestar/test-utils": "^1.47.0-dev.9ecc10f386",
66
66
  "@vekexasia/bigint-buffer2": "^1.1.1",
67
67
  "rimraf": "^4.4.1"
68
68
  },
69
- "gitHead": "7bcb13bb1874a0d514f22c13a8629eabc69eabe8"
69
+ "gitHead": "d9f5a350881754b896bbbc33887c8c22ad93acff"
70
70
  }
@@ -194,6 +194,8 @@ export class BlockProposingService {
194
194
  const {broadcastValidation, payloadLocal} = this.opts;
195
195
  const {selection: builderSelection, boostFactor: builderBoostFactor} =
196
196
  this.validatorStore.getBuilderSelectionParams(pubkeyHex, slot);
197
+ const builderMinBid = this.validatorStore.getBuilderMinBid(pubkeyHex);
198
+ const builderEntries = this.validatorStore.getResolvedBuilderEntries(pubkeyHex, builderBoostFactor);
197
199
 
198
200
  this.logger.debug("Producing block", {
199
201
  ...debugLogCtx,
@@ -202,9 +204,35 @@ export class BlockProposingService {
202
204
  payloadLocal,
203
205
  builderSelection,
204
206
  builderBoostFactor,
207
+ builderMinBid,
208
+ builderUrls: builderEntries.map((entry) => entry.url).join(","),
205
209
  });
206
210
  this.metrics?.proposerStepCallProduceBlock.observe(this.clock.secFromSlot(slot));
207
211
 
212
+ // One entry per resolved builder, authenticated by a request auth signed over the entry's
213
+ // auth data. The auth is usually pre-signed when preferences are submitted ahead of the
214
+ // proposal. An entry whose auth cannot be signed is skipped so it never fails the proposal.
215
+ const builders: routes.validator.BuilderEntry[] = (
216
+ await Promise.all(
217
+ builderEntries.map(async (entry) => {
218
+ try {
219
+ const auth = await this.validatorStore.getBuilderRequestAuth(pubkey, entry.authData, slot, slot);
220
+ return {
221
+ url: new TextEncoder().encode(entry.url),
222
+ auth,
223
+ builderPubkeys: entry.builderPubkeys,
224
+ maxExecutionPayment: entry.maxExecutionPayment,
225
+ minBid: entry.minBid,
226
+ builderBoostFactor: entry.builderBoostFactor,
227
+ };
228
+ } catch (e) {
229
+ this.logger.warn("Failed to sign builder request auth", {...logCtx, builderUrl: entry.url}, e as Error);
230
+ return null;
231
+ }
232
+ })
233
+ )
234
+ ).filter((entry) => entry !== null);
235
+
208
236
  // Step 1: Produce beacon block with execution payload bid
209
237
  const blockRes = await this.api.validator
210
238
  .produceBlockV4({
@@ -214,7 +242,11 @@ export class BlockProposingService {
214
242
  feeRecipient,
215
243
  strictFeeRecipientCheck,
216
244
  includePayload: !payloadLocal,
217
- builderBoostFactor,
245
+ builderConfig: {
246
+ minBid: builderMinBid,
247
+ builderBoostFactor,
248
+ builders,
249
+ },
218
250
  })
219
251
  .catch((e: Error) => {
220
252
  this.metrics?.blockProposingErrors.inc({error: "produce"});
@@ -250,6 +282,8 @@ export class BlockProposingService {
250
282
  .publishBlockV2({
251
283
  signedBlockContents: {signedBlock},
252
284
  broadcastValidation,
285
+ // Echo the winning builder url so any beacon node can forward the block to the builder
286
+ builderUrl: blockMeta.builderUrl,
253
287
  })
254
288
  .catch((e: Error) => {
255
289
  this.metrics?.blockProposingErrors.inc({error: "publish"});
@@ -0,0 +1,142 @@
1
+ import {ApiClient, routes} from "@lodestar/api";
2
+ import {ChainForkConfig} from "@lodestar/config";
3
+ import {SLOTS_PER_EPOCH, isForkPostGloas} from "@lodestar/params";
4
+ import {IClock, computeEpochAtSlot} from "@lodestar/state-transition";
5
+ import {Epoch, RootHex, Slot} from "@lodestar/types";
6
+ import {toPubkeyHex} from "@lodestar/utils";
7
+ import {Metrics} from "../metrics.js";
8
+ import {LoggerVc} from "../util/index.js";
9
+ import {BlockDutiesService} from "./blockDuties.js";
10
+ import {ValidatorStore} from "./validatorStore.js";
11
+
12
+ /**
13
+ * Submit a proposer's builder preferences this many slots before the proposal slot.
14
+ * Same pacing as `ProposerPreferencesService`, builders must have the preferences
15
+ * before the bid request arrives at proposal time.
16
+ */
17
+ const SUBMIT_BEFORE_PROPOSAL_SLOTS = Math.floor(SLOTS_PER_EPOCH / 4);
18
+
19
+ /** Per-epoch tracking of preferences already submitted under the current dependent_root. */
20
+ type SubmittedAtEpoch = {dependentRoot: RootHex; slots: Set<Slot>};
21
+
22
+ /**
23
+ * Signs and submits builder preferences for any local validator that will propose within the
24
+ * next `SUBMIT_BEFORE_PROPOSAL_SLOTS` and has external builders configured. Signing the request
25
+ * auths here also pre-fills the auth cache used at proposal time.
26
+ *
27
+ * The beacon node submits each entry to the builder at the entry's url. Re-submits automatically
28
+ * when the proposer dependent root for an epoch shifts (e.g. after a reorg) since the proposer
29
+ * for a slot may have changed.
30
+ */
31
+ export class BuilderPreferencesService {
32
+ private readonly submitted = new Map<Epoch, SubmittedAtEpoch>();
33
+
34
+ constructor(
35
+ private readonly config: ChainForkConfig,
36
+ private readonly logger: LoggerVc,
37
+ private readonly api: ApiClient,
38
+ clock: IClock,
39
+ private readonly validatorStore: ValidatorStore,
40
+ private readonly blockDutiesService: BlockDutiesService,
41
+ _metrics: Metrics | null
42
+ ) {
43
+ clock.runEverySlot(this.runBuilderPreferencesTask);
44
+ }
45
+
46
+ private runBuilderPreferencesTask = async (slot: Slot): Promise<void> => {
47
+ // Start running once the submission window (`slot + SUBMIT_BEFORE_PROPOSAL_SLOTS`) reaches
48
+ // Gloas, i.e. already in the epoch before the fork. This allows builders to prepare and
49
+ // submit bids for the first Gloas slots.
50
+ if (!isForkPostGloas(this.config.getForkName(slot + SUBMIT_BEFORE_PROPOSAL_SLOTS))) {
51
+ return;
52
+ }
53
+
54
+ const currentEpoch = computeEpochAtSlot(slot);
55
+ const entries: routes.validator.BuilderPreferencesEntry[] = [];
56
+ // Track which `(submission, slot)` pairs are pending an API submission so we can mark
57
+ // them only after the network call succeeds. Marking before would silently drop a
58
+ // preference on transient API failure (no retry until dependent_root shifts).
59
+ const pending: {submission: SubmittedAtEpoch; slot: Slot}[] = [];
60
+
61
+ for (const epoch of [currentEpoch, currentEpoch + 1]) {
62
+ const dutiesAtEpoch = this.blockDutiesService.getProposersAtEpoch(epoch);
63
+ if (!dutiesAtEpoch) continue;
64
+
65
+ // Reset submission tracking if the dependent root for this epoch has shifted
66
+ // (e.g. due to a reorg). The proposer for a slot may have changed.
67
+ let submission = this.submitted.get(epoch);
68
+ if (submission === undefined || submission.dependentRoot !== dutiesAtEpoch.dependentRoot) {
69
+ if (submission !== undefined) {
70
+ this.logger.info("Proposer-shuffling dependent root shifted; resubmitting builder preferences", {
71
+ epoch,
72
+ priorDependentRoot: submission.dependentRoot,
73
+ dependentRoot: dutiesAtEpoch.dependentRoot,
74
+ });
75
+ }
76
+ submission = {dependentRoot: dutiesAtEpoch.dependentRoot, slots: new Set()};
77
+ this.submitted.set(epoch, submission);
78
+ }
79
+
80
+ for (const duty of dutiesAtEpoch.data) {
81
+ if (duty.slot <= slot) continue;
82
+ if (duty.slot > slot + SUBMIT_BEFORE_PROPOSAL_SLOTS) continue;
83
+ if (!isForkPostGloas(this.config.getForkName(duty.slot))) continue;
84
+ if (submission.slots.has(duty.slot)) continue;
85
+
86
+ const pubkeyHex = toPubkeyHex(duty.pubkey);
87
+ const {selection} = this.validatorStore.getBuilderSelectionParams(pubkeyHex, duty.slot);
88
+ if (selection === routes.validator.BuilderSelection.ExecutionOnly) continue;
89
+
90
+ const builderEntries = this.validatorStore.getResolvedBuilderEntries(pubkeyHex);
91
+ if (builderEntries.length === 0) continue;
92
+
93
+ try {
94
+ // Collect entries per duty and only add them to the batch if signing
95
+ // succeeded for all builders, else the duty is retried on the next tick
96
+ const dutyEntries: routes.validator.BuilderPreferencesEntry[] = [];
97
+ for (const entry of builderEntries) {
98
+ const auth = await this.validatorStore.getBuilderRequestAuth(duty.pubkey, entry.authData, duty.slot, slot);
99
+ dutyEntries.push({
100
+ proposerPubkey: duty.pubkey,
101
+ url: new TextEncoder().encode(entry.url),
102
+ auth,
103
+ maxExecutionPayment: entry.maxExecutionPayment,
104
+ });
105
+ }
106
+ entries.push(...dutyEntries);
107
+ pending.push({submission, slot: duty.slot});
108
+ } catch (e) {
109
+ this.logger.error(
110
+ "Error signing builder preferences",
111
+ {slot: duty.slot, validatorIndex: duty.validatorIndex},
112
+ e as Error
113
+ );
114
+ }
115
+ }
116
+ }
117
+
118
+ // Prune tracking for past epochs
119
+ for (const epoch of this.submitted.keys()) {
120
+ if (epoch < currentEpoch) {
121
+ this.submitted.delete(epoch);
122
+ }
123
+ }
124
+
125
+ if (entries.length === 0) {
126
+ return;
127
+ }
128
+
129
+ try {
130
+ (await this.api.validator.submitBuilderPreferences({builderPreferences: entries})).assertOk();
131
+ // Only mark as submitted after the API call succeeds; a thrown error, including per-entry
132
+ // failures reported by index, leaves all slots eligible for retry on the next tick.
133
+ // Re-submitting preferences a builder already accepted is harmless.
134
+ for (const {submission, slot: submittedSlot} of pending) {
135
+ submission.slots.add(submittedSlot);
136
+ }
137
+ this.logger.debug("Submitted builder preferences", {count: entries.length});
138
+ } catch (e) {
139
+ this.logger.error("Error submitting builder preferences", {count: entries.length}, e as Error);
140
+ }
141
+ };
142
+ }
@@ -1,4 +1,4 @@
1
- import {PublicKey} from "@chainsafe/blst";
1
+ import {PublicKey} from "@chainsafe/lodestar-z/blst";
2
2
  import {ChainForkConfig} from "@lodestar/config";
3
3
  import {SLOTS_PER_EPOCH} from "@lodestar/params";
4
4
  import {fromHex, toPrintableUrl} from "@lodestar/utils";
@@ -1,6 +1,7 @@
1
- import {SecretKey} from "@chainsafe/blst";
1
+ import {SecretKey} from "@chainsafe/lodestar-z/blst";
2
2
  import {BitArray} from "@chainsafe/ssz";
3
3
  import {routes} from "@lodestar/api";
4
+ import {BuilderConfigData, BuilderEntryConfig} from "@lodestar/api/keymanager";
4
5
  import {BeaconConfig} from "@lodestar/config";
5
6
  import {
6
7
  DOMAIN_AGGREGATE_AND_PROOF,
@@ -8,6 +9,7 @@ import {
8
9
  DOMAIN_BEACON_ATTESTER,
9
10
  DOMAIN_BEACON_BUILDER,
10
11
  DOMAIN_BEACON_PROPOSER,
12
+ DOMAIN_BUILDER_REQUEST_AUTH,
11
13
  DOMAIN_CONTRIBUTION_AND_PROOF,
12
14
  DOMAIN_PROPOSER_PREFERENCES,
13
15
  DOMAIN_PTC_ATTESTER,
@@ -16,6 +18,7 @@ import {
16
18
  DOMAIN_SYNC_COMMITTEE,
17
19
  DOMAIN_SYNC_COMMITTEE_SELECTION_PROOF,
18
20
  ForkSeq,
21
+ MAX_BUILDER_AUTH_DATA_SIZE,
19
22
  } from "@lodestar/params";
20
23
  import {
21
24
  ZERO_HASH,
@@ -46,7 +49,7 @@ import {
46
49
  phase0,
47
50
  ssz,
48
51
  } from "@lodestar/types";
49
- import {fromHex, toPubkeyHex, toRootHex} from "@lodestar/utils";
52
+ import {fromHex, isValidAsciiHttpUrl, toHex, toPubkeyHex, toRootHex} from "@lodestar/utils";
50
53
  import {Metrics} from "../metrics.js";
51
54
  import {ISlashingProtection} from "../slashingProtection/index.js";
52
55
  import {PubkeyHex} from "../types.js";
@@ -84,6 +87,9 @@ type DefaultProposerConfig = {
84
87
  gasLimit?: number;
85
88
  selection?: routes.validator.BuilderSelection;
86
89
  boostFactor: bigint;
90
+ minBid: bigint;
91
+ maxExecutionPayment: bigint;
92
+ builders?: BuilderEntryConfig[];
87
93
  };
88
94
  };
89
95
 
@@ -95,9 +101,23 @@ export type ProposerConfig = {
95
101
  gasLimit?: number;
96
102
  selection?: routes.validator.BuilderSelection;
97
103
  boostFactor?: bigint;
104
+ minBid?: bigint;
105
+ maxExecutionPayment?: bigint;
106
+ /** Per-key builder entries, replacing the validator client's builders */
107
+ builders?: BuilderEntryConfig[];
98
108
  };
99
109
  };
100
110
 
111
+ /** A builder entry with every omitted value resolved against the key and validator client defaults */
112
+ export type ResolvedBuilderEntry = {
113
+ url: string;
114
+ authData: Uint8Array;
115
+ builderPubkeys: Uint8Array[];
116
+ maxExecutionPayment: bigint;
117
+ minBid: bigint;
118
+ builderBoostFactor: bigint;
119
+ };
120
+
101
121
  export type ValidatorProposerConfig = {
102
122
  proposerConfig: {[index: PubkeyHex]: ProposerConfig};
103
123
  defaultConfig: ProposerConfig;
@@ -132,6 +152,8 @@ export type Signer = SignerLocal | SignerRemote;
132
152
  type ValidatorData = ProposerConfig & {
133
153
  signer: Signer;
134
154
  builderData?: BuilderData;
155
+ /** Pre-signed builder request auths keyed by proposal slot and auth data, pruned by proposal slot */
156
+ builderRequestAuths?: Map<string, gloas.SignedBuilderRequestAuth>;
135
157
  };
136
158
 
137
159
  export const defaultOptions = {
@@ -139,7 +161,10 @@ export const defaultOptions = {
139
161
  defaultGasLimit: 60_000_000,
140
162
  builderSelection: routes.validator.BuilderSelection.ExecutionOnly,
141
163
  builderAliasSelection: routes.validator.BuilderSelection.Default,
142
- builderBoostFactor: BigInt(100),
164
+ builderBoostFactor: 100n,
165
+ builderMinBid: 0n,
166
+ // Only trustless payments via the builder's staked collateral are counted by default
167
+ builderMaxExecutionPayment: 0n,
143
168
  // spec asks for gossip validation by default
144
169
  broadcastValidation: routes.beacon.BroadcastValidation.gossip,
145
170
  // should request fetching the locally produced block in blinded format
@@ -185,6 +210,9 @@ export class ValidatorStore {
185
210
  gasLimit: defaultConfig.builder?.gasLimit,
186
211
  selection: defaultConfig.builder?.selection,
187
212
  boostFactor: builderBoostFactor,
213
+ minBid: defaultConfig.builder?.minBid ?? defaultOptions.builderMinBid,
214
+ maxExecutionPayment: defaultConfig.builder?.maxExecutionPayment ?? defaultOptions.builderMaxExecutionPayment,
215
+ builders: defaultConfig.builder?.builders,
188
216
  },
189
217
  };
190
218
 
@@ -287,11 +315,22 @@ export class ValidatorStore {
287
315
  // whether they are received over p2p or through a builder API. Pre-gloas there is no
288
316
  // in-protocol builder, so the default remains local-only (executiononly).
289
317
  const isPostGloas = slot !== undefined && this.config.getForkSeq(slot) >= ForkSeq.gloas;
318
+ return this.resolveBuilderSelectionParams(pubkeyHex, isPostGloas);
319
+ }
320
+
321
+ private resolveBuilderSelectionParams(
322
+ pubkeyHex: PubkeyHex,
323
+ isPostGloas: boolean
324
+ ): {selection: routes.validator.BuilderSelection; boostFactor: bigint} {
325
+ const validatorBuilder = this.validators.get(pubkeyHex)?.builder;
290
326
  const defaultSelection = isPostGloas ? defaultOptions.builderAliasSelection : defaultOptions.builderSelection;
291
- let selection =
292
- this.validators.get(pubkeyHex)?.builder?.selection ??
293
- this.defaultProposerConfig.builder.selection ??
294
- defaultSelection;
327
+ let selection = validatorBuilder?.selection ?? this.defaultProposerConfig.builder.selection ?? defaultSelection;
328
+
329
+ // The standard per-key builder config directly controls the post-Gloas boost. It takes
330
+ // precedence over the legacy selection aliases when explicitly configured.
331
+ if (isPostGloas && validatorBuilder?.boostFactor !== undefined) {
332
+ return {selection: routes.validator.BuilderSelection.MaxProfit, boostFactor: validatorBuilder.boostFactor};
333
+ }
295
334
 
296
335
  // Post-Gloas block production uses standard builder boost factor. Need to normalize the
297
336
  // gloas-deprecated "builderonly" and "executiononly" to the gloas fallback "builderalways"
@@ -313,8 +352,7 @@ export class ValidatorStore {
313
352
  break;
314
353
 
315
354
  case routes.validator.BuilderSelection.MaxProfit:
316
- boostFactor =
317
- this.validators.get(pubkeyHex)?.builder?.boostFactor ?? this.defaultProposerConfig.builder.boostFactor;
355
+ boostFactor = validatorBuilder?.boostFactor ?? this.defaultProposerConfig.builder.boostFactor;
318
356
  break;
319
357
 
320
358
  case routes.validator.BuilderSelection.BuilderAlways:
@@ -409,6 +447,126 @@ export class ValidatorStore {
409
447
  delete validatorData.builder?.boostFactor;
410
448
  }
411
449
 
450
+ getBuilderMinBid(pubkeyHex: PubkeyHex): bigint {
451
+ const validatorData = this.validators.get(pubkeyHex);
452
+ if (validatorData === undefined) {
453
+ throw Error(`Validator pubkey ${pubkeyHex} not known`);
454
+ }
455
+ return validatorData?.builder?.minBid ?? this.defaultProposerConfig.builder.minBid;
456
+ }
457
+
458
+ getBuilderMaxExecutionPayment(pubkeyHex: PubkeyHex): bigint {
459
+ const validatorData = this.validators.get(pubkeyHex);
460
+ if (validatorData === undefined) {
461
+ throw Error(`Validator pubkey ${pubkeyHex} not known`);
462
+ }
463
+ return validatorData?.builder?.maxExecutionPayment ?? this.defaultProposerConfig.builder.maxExecutionPayment;
464
+ }
465
+
466
+ /**
467
+ * Resolve the builder entries for this key. Per-key entries replace the validator client's
468
+ * builders. A value omitted on an entry takes this key's default, then the validator
469
+ * client's configuration, while omitted auth data is derived from the entry url instead.
470
+ */
471
+ getResolvedBuilderEntries(pubkeyHex: PubkeyHex, boostFactor?: bigint): ResolvedBuilderEntry[] {
472
+ const validatorData = this.validators.get(pubkeyHex);
473
+ if (validatorData === undefined) {
474
+ throw Error(`Validator pubkey ${pubkeyHex} not known`);
475
+ }
476
+
477
+ const keyMinBid = validatorData.builder?.minBid ?? this.defaultProposerConfig.builder.minBid;
478
+ const keyBoostFactor =
479
+ boostFactor ?? validatorData.builder?.boostFactor ?? this.defaultProposerConfig.builder.boostFactor;
480
+ const keyMaxExecutionPayment =
481
+ validatorData.builder?.maxExecutionPayment ?? this.defaultProposerConfig.builder.maxExecutionPayment;
482
+
483
+ // The key's defaults apply to the validator client's own builders all the same
484
+ const builders = validatorData.builder?.builders ?? this.defaultProposerConfig.builder.builders ?? [];
485
+ return builders.map((entry) => ({
486
+ url: entry.url,
487
+ authData: entry.authData !== undefined ? fromHex(entry.authData) : new TextEncoder().encode(entry.url),
488
+ builderPubkeys: (entry.builderPubkeys ?? []).map(fromHex),
489
+ maxExecutionPayment: entry.maxExecutionPayment ?? keyMaxExecutionPayment,
490
+ minBid: entry.minBid ?? keyMinBid,
491
+ builderBoostFactor: entry.builderBoostFactor ?? keyBoostFactor,
492
+ }));
493
+ }
494
+
495
+ /** Return the builder configuration in effect for this key, with omitted values resolved */
496
+ getBuilderConfig(pubkeyHex: PubkeyHex): BuilderConfigData {
497
+ const validatorData = this.validators.get(pubkeyHex);
498
+ if (validatorData === undefined) {
499
+ throw Error(`Validator pubkey ${pubkeyHex} not known`);
500
+ }
501
+ const {boostFactor} = this.resolveBuilderSelectionParams(pubkeyHex, true);
502
+
503
+ return {
504
+ minBid: validatorData.builder?.minBid ?? this.defaultProposerConfig.builder.minBid,
505
+ builderBoostFactor: boostFactor,
506
+ builders: this.getResolvedBuilderEntries(pubkeyHex, boostFactor).map((entry) => ({
507
+ url: entry.url,
508
+ authData: toHex(entry.authData),
509
+ builderPubkeys: entry.builderPubkeys.map(toPubkeyHex),
510
+ maxExecutionPayment: entry.maxExecutionPayment,
511
+ minBid: entry.minBid,
512
+ builderBoostFactor: entry.builderBoostFactor,
513
+ })),
514
+ };
515
+ }
516
+
517
+ /** Set the builder configuration for this key, replacing any stored configuration in full */
518
+ setBuilderConfig(pubkeyHex: PubkeyHex, config: BuilderConfigData): void {
519
+ const validatorData = this.validators.get(pubkeyHex);
520
+ if (validatorData === undefined) {
521
+ throw Error(`Validator pubkey ${pubkeyHex} not known`);
522
+ }
523
+
524
+ for (const value of [config.minBid, config.builderBoostFactor]) {
525
+ if (value !== undefined && value > MAX_BUILDER_BOOST_FACTOR) {
526
+ throw Error(`Invalid builder config value=${value} exceeds uint64`);
527
+ }
528
+ }
529
+
530
+ // No two entries may share both their url and their auth data, an omitted auth data is
531
+ // compared as the value derived from the entry url
532
+ const seenEntries = new Set<string>();
533
+ for (const entry of config.builders ?? []) {
534
+ if (!isValidAsciiHttpUrl(entry.url)) {
535
+ throw Error(`Invalid builder url: ${entry.url}`);
536
+ }
537
+ const authData =
538
+ entry.authData !== undefined ? toHex(fromHex(entry.authData)) : toHex(new TextEncoder().encode(entry.url));
539
+ const entryKey = `${entry.url}|${authData}`;
540
+ if (seenEntries.has(entryKey)) {
541
+ throw Error(`Duplicate builder entry url=${entry.url} authData=${authData}`);
542
+ }
543
+ seenEntries.add(entryKey);
544
+ for (const value of [entry.maxExecutionPayment, entry.minBid, entry.builderBoostFactor]) {
545
+ if (value !== undefined && value > MAX_BUILDER_BOOST_FACTOR) {
546
+ throw Error(`Invalid builder entry value=${value} exceeds uint64`);
547
+ }
548
+ }
549
+ }
550
+
551
+ validatorData.builder = {
552
+ ...validatorData.builder,
553
+ minBid: config.minBid,
554
+ boostFactor: config.builderBoostFactor,
555
+ builders: config.builders,
556
+ };
557
+ }
558
+
559
+ /** Remove the builder configuration for this key, and revert to the validator client configuration */
560
+ deleteBuilderConfig(pubkeyHex: PubkeyHex): void {
561
+ const validatorData = this.validators.get(pubkeyHex);
562
+ if (validatorData === undefined) {
563
+ throw Error(`Validator pubkey ${pubkeyHex} not known`);
564
+ }
565
+ delete validatorData.builder?.minBid;
566
+ delete validatorData.builder?.boostFactor;
567
+ delete validatorData.builder?.builders;
568
+ }
569
+
412
570
  /** Return true if `index` is active part of this validator client */
413
571
  hasValidatorIndex(index: ValidatorIndex): boolean {
414
572
  return this.indicesService.index2pubkey.has(index);
@@ -430,7 +588,10 @@ export class ValidatorStore {
430
588
  feeRecipient !== undefined ||
431
589
  builder?.gasLimit !== undefined ||
432
590
  builder?.selection !== undefined ||
433
- builder?.boostFactor !== undefined
591
+ builder?.boostFactor !== undefined ||
592
+ builder?.minBid !== undefined ||
593
+ builder?.maxExecutionPayment !== undefined ||
594
+ builder?.builders !== undefined
434
595
  ) {
435
596
  proposerConfig = {graffiti, strictFeeRecipientCheck, feeRecipient, builder};
436
597
  }
@@ -879,6 +1040,71 @@ export class ValidatorStore {
879
1040
  };
880
1041
  }
881
1042
 
1043
+ async signBuilderRequestAuth(
1044
+ pubkeyMaybeHex: BLSPubkeyMaybeHex,
1045
+ data: Uint8Array,
1046
+ proposalSlot: Slot
1047
+ ): Promise<gloas.SignedBuilderRequestAuth> {
1048
+ if (data.length === 0 || data.length > MAX_BUILDER_AUTH_DATA_SIZE) {
1049
+ throw Error(
1050
+ `Invalid builder request auth data length=${data.length}, must be within 1 and ${MAX_BUILDER_AUTH_DATA_SIZE} bytes`
1051
+ );
1052
+ }
1053
+
1054
+ const message: gloas.BuilderRequestAuth = {data, slot: proposalSlot};
1055
+
1056
+ const signingSlot = 0;
1057
+ const domain = computeDomain(DOMAIN_BUILDER_REQUEST_AUTH, this.config.GENESIS_FORK_VERSION, ZERO_HASH);
1058
+ const signingRoot = computeSigningRoot(ssz.gloas.BuilderRequestAuth, message, domain);
1059
+
1060
+ const signableMessage: SignableMessage = {
1061
+ type: SignableMessageType.BUILDER_REQUEST_AUTH,
1062
+ data: message,
1063
+ };
1064
+
1065
+ return {
1066
+ message,
1067
+ signature: await this.getSignature(pubkeyMaybeHex, signingRoot, signingSlot, signableMessage),
1068
+ };
1069
+ }
1070
+
1071
+ /**
1072
+ * Return a pre-signed builder request auth for the auth data and proposal slot, or sign and cache a new
1073
+ * one. Signing happens off the block proposal hot path when preferences are submitted ahead of
1074
+ * time, cached auths are then used just-in-time when requesting bids at proposal time.
1075
+ */
1076
+ async getBuilderRequestAuth(
1077
+ pubkeyMaybeHex: BLSPubkeyMaybeHex,
1078
+ data: Uint8Array,
1079
+ proposalSlot: Slot,
1080
+ currentSlot: Slot
1081
+ ): Promise<gloas.SignedBuilderRequestAuth> {
1082
+ const pubkeyHex = typeof pubkeyMaybeHex === "string" ? pubkeyMaybeHex : toPubkeyHex(pubkeyMaybeHex);
1083
+ const authKey = `${proposalSlot}-${toHex(data)}`;
1084
+ const validatorData = this.validators.get(pubkeyHex);
1085
+ const cached = validatorData?.builderRequestAuths?.get(authKey);
1086
+ if (cached !== undefined) {
1087
+ return cached;
1088
+ }
1089
+
1090
+ const signedRequestAuth = await this.signBuilderRequestAuth(pubkeyMaybeHex, data, proposalSlot);
1091
+
1092
+ if (validatorData !== undefined) {
1093
+ const builderRequestAuths =
1094
+ validatorData.builderRequestAuths ?? new Map<string, gloas.SignedBuilderRequestAuth>();
1095
+ // Prune auths for proposal slots that are already in the past
1096
+ for (const key of builderRequestAuths.keys()) {
1097
+ if (Number(key.slice(0, key.indexOf("-"))) < currentSlot) {
1098
+ builderRequestAuths.delete(key);
1099
+ }
1100
+ }
1101
+ builderRequestAuths.set(authKey, signedRequestAuth);
1102
+ validatorData.builderRequestAuths = builderRequestAuths;
1103
+ }
1104
+
1105
+ return signedRequestAuth;
1106
+ }
1107
+
882
1108
  async getValidatorRegistration(
883
1109
  pubkeyMaybeHex: BLSPubkeyMaybeHex,
884
1110
  regAttributes: {feeRecipient: ExecutionAddress; gasLimit: number},
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import {SecretKey} from "@chainsafe/blst";
1
+ import {SecretKey} from "@chainsafe/lodestar-z/blst";
2
2
  import {DatabaseController} from "@lodestar/db";
3
3
  import {BLSPubkey} from "@lodestar/types";
4
4
 
@@ -36,6 +36,7 @@ export enum SignableMessageType {
36
36
  EXECUTION_PAYLOAD_ENVELOPE = "EXECUTION_PAYLOAD_ENVELOPE",
37
37
  PAYLOAD_ATTESTATION = "PAYLOAD_ATTESTATION",
38
38
  PROPOSER_PREFERENCES = "PROPOSER_PREFERENCES",
39
+ BUILDER_REQUEST_AUTH = "BUILDER_REQUEST_AUTH",
39
40
  }
40
41
 
41
42
  const AggregationSlotType = new ContainerType({
@@ -87,7 +88,8 @@ export type SignableMessage =
87
88
  | {type: SignableMessageType.VALIDATOR_REGISTRATION; data: ValidatorRegistrationV1}
88
89
  | {type: SignableMessageType.EXECUTION_PAYLOAD_ENVELOPE; data: gloas.ExecutionPayloadEnvelope}
89
90
  | {type: SignableMessageType.PAYLOAD_ATTESTATION; data: gloas.PayloadAttestationData}
90
- | {type: SignableMessageType.PROPOSER_PREFERENCES; data: gloas.ProposerPreferences};
91
+ | {type: SignableMessageType.PROPOSER_PREFERENCES; data: gloas.ProposerPreferences}
92
+ | {type: SignableMessageType.BUILDER_REQUEST_AUTH; data: gloas.BuilderRequestAuth};
91
93
 
92
94
  const requiresForkInfo: Record<SignableMessageType, boolean> = {
93
95
  [SignableMessageType.AGGREGATION_SLOT]: true,
@@ -105,6 +107,7 @@ const requiresForkInfo: Record<SignableMessageType, boolean> = {
105
107
  [SignableMessageType.EXECUTION_PAYLOAD_ENVELOPE]: true,
106
108
  [SignableMessageType.PAYLOAD_ATTESTATION]: true,
107
109
  [SignableMessageType.PROPOSER_PREFERENCES]: true,
110
+ [SignableMessageType.BUILDER_REQUEST_AUTH]: false,
108
111
  };
109
112
 
110
113
  type Web3SignerSerializedRequest = {
@@ -285,6 +288,9 @@ function serializerSignableMessagePayload(config: BeaconConfig, payload: Signabl
285
288
 
286
289
  case SignableMessageType.PROPOSER_PREFERENCES:
287
290
  return {proposer_preferences: ssz.gloas.ProposerPreferences.toJson(payload.data)};
291
+
292
+ case SignableMessageType.BUILDER_REQUEST_AUTH:
293
+ return {builder_request_auth: ssz.gloas.BuilderRequestAuth.toJson(payload.data)};
288
294
  }
289
295
  }
290
296
 
package/src/validator.ts CHANGED
@@ -16,6 +16,7 @@ import {MetaDataRepository} from "./repositories/metaDataRepository.js";
16
16
  import {AttestationService} from "./services/attestation.js";
17
17
  import {BlockProposingService} from "./services/block.js";
18
18
  import {BlockDutiesService} from "./services/blockDuties.js";
19
+ import {BuilderPreferencesService} from "./services/builderPreferences.js";
19
20
  import {ChainHeaderTracker} from "./services/chainHeaderTracker.js";
20
21
  import {DoppelgangerService} from "./services/doppelgangerService.js";
21
22
  import {ValidatorEventEmitter} from "./services/emitter.js";
@@ -313,6 +314,7 @@ export class Validator {
313
314
  );
314
315
 
315
316
  new ProposerPreferencesService(config, loggerVc, api, clock, validatorStore, blockDutiesService, metrics);
317
+ new BuilderPreferencesService(config, loggerVc, api, clock, validatorStore, blockDutiesService, metrics);
316
318
 
317
319
  return new Validator({
318
320
  opts,