@lodestar/builder 1.51.0-dev.a736f94f66 → 1.51.0-dev.a877514e67

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 (48) hide show
  1. package/README.md +9 -0
  2. package/lib/builder.d.ts +23 -1
  3. package/lib/builder.d.ts.map +1 -1
  4. package/lib/builder.js +76 -2
  5. package/lib/builder.js.map +1 -1
  6. package/lib/defaults.d.ts +11 -0
  7. package/lib/defaults.d.ts.map +1 -1
  8. package/lib/defaults.js +12 -0
  9. package/lib/defaults.js.map +1 -1
  10. package/lib/identity.js +3 -3
  11. package/lib/identity.js.map +1 -1
  12. package/lib/index.d.ts +1 -0
  13. package/lib/index.d.ts.map +1 -1
  14. package/lib/index.js +1 -0
  15. package/lib/index.js.map +1 -1
  16. package/lib/metrics.d.ts +25 -0
  17. package/lib/metrics.d.ts.map +1 -1
  18. package/lib/metrics.js +34 -0
  19. package/lib/metrics.js.map +1 -1
  20. package/lib/services/bidLedger.d.ts +2 -0
  21. package/lib/services/bidLedger.d.ts.map +1 -1
  22. package/lib/services/bidLedger.js +13 -0
  23. package/lib/services/bidLedger.js.map +1 -1
  24. package/lib/services/bidder.d.ts +56 -0
  25. package/lib/services/bidder.d.ts.map +1 -0
  26. package/lib/services/bidder.js +144 -0
  27. package/lib/services/bidder.js.map +1 -0
  28. package/lib/services/builderSigner.d.ts +1 -1
  29. package/lib/services/builderSigner.d.ts.map +1 -1
  30. package/lib/services/payloadOrchestrator.d.ts +7 -3
  31. package/lib/services/payloadOrchestrator.d.ts.map +1 -1
  32. package/lib/services/payloadOrchestrator.js +7 -3
  33. package/lib/services/payloadOrchestrator.js.map +1 -1
  34. package/lib/services/revealer.d.ts +35 -0
  35. package/lib/services/revealer.d.ts.map +1 -0
  36. package/lib/services/revealer.js +56 -0
  37. package/lib/services/revealer.js.map +1 -0
  38. package/package.json +12 -12
  39. package/src/builder.ts +107 -1
  40. package/src/defaults.ts +14 -0
  41. package/src/identity.ts +3 -3
  42. package/src/index.ts +6 -0
  43. package/src/metrics.ts +36 -0
  44. package/src/services/bidLedger.ts +15 -0
  45. package/src/services/bidder.ts +191 -0
  46. package/src/services/builderSigner.ts +1 -1
  47. package/src/services/payloadOrchestrator.ts +16 -4
  48. package/src/services/revealer.ts +88 -0
package/src/builder.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {ApiClient, routes} from "@lodestar/api";
2
2
  import {ChainForkConfig, assertEqualParams, createBeaconConfig} from "@lodestar/config";
3
+ import {SLOTS_PER_EPOCH} from "@lodestar/params";
3
4
  import {Clock, ClockOptions, IClock} from "@lodestar/state-transition";
4
5
  import {BuilderIndex, ExecutionAddress} from "@lodestar/types";
5
6
  import {Logger, isErrorAborted, toHex, toRootHex} from "@lodestar/utils";
@@ -7,11 +8,26 @@ import {waitForGenesis} from "./genesis.js";
7
8
  import {resolveBuilderIdentity} from "./identity.js";
8
9
  import {Metrics} from "./metrics.js";
9
10
  import {logNodeVersion, waitForNodeReady} from "./readiness.js";
11
+ import {Bidder, BidderOptions} from "./services/bidder.js";
12
+ import {BidLedger} from "./services/bidLedger.js";
13
+ import {ProportionalBidPolicy, ProportionalBidPolicyOpts} from "./services/bidPolicy.js";
14
+ import {BidPublisher} from "./services/bidPublisher.js";
15
+ import {BidSelector} from "./services/bidSelector.js";
10
16
  import {BlockObserver} from "./services/blockObserver.js";
11
17
  import {BuilderSigner, Keypair} from "./services/builderSigner.js";
12
18
  import {BuilderStatusTracker} from "./services/builderStatusTracker.js";
19
+ import {EnvelopePublisher} from "./services/envelopePublisher.js";
20
+ import {PayloadOrchestrator} from "./services/payloadOrchestrator.js";
21
+ import {PayloadSource} from "./services/payloadSource.js";
13
22
  import {PayloadStore} from "./services/payloadStore.js";
14
23
  import {ProposerPreferencesTracker} from "./services/proposerPreferencesTracker.js";
24
+ import {Revealer} from "./services/revealer.js";
25
+
26
+ // Payments of won bids are assumed to be reflected in the builder balance after this many slots.
27
+ // The payment is queued as a builder withdrawal at the epoch transition after the bid epoch and only debited once that
28
+ // withdrawal is processed, so with a backlog of withdrawals the ledger releases the reservation before the balance drops.
29
+ // Reconciling against the pending payments and pending withdrawals of the state is a follow-up.
30
+ const PAYMENT_SETTLEMENT_SLOTS = 3 * SLOTS_PER_EPOCH;
15
31
 
16
32
  export type BuilderModules = {
17
33
  opts: BuilderOptions;
@@ -22,6 +38,9 @@ export type BuilderModules = {
22
38
  clock: IClock;
23
39
  index: BuilderIndex;
24
40
  payloadStore: PayloadStore;
41
+ ledger: BidLedger;
42
+ bidder: Bidder;
43
+ revealer: Revealer;
25
44
  };
26
45
 
27
46
  export type BuilderOptions = {
@@ -31,8 +50,20 @@ export type BuilderOptions = {
31
50
  abortController: AbortController;
32
51
  api: ApiClient;
33
52
  clock?: ClockOptions;
53
+ /** Fee recipient of built payloads, receives priority fees and MEV */
34
54
  executionFeeRecipient: ExecutionAddress;
35
55
  metrics: Metrics | null;
56
+ /** Payload construction engine, an execution client is one implementation */
57
+ payloadSource: PayloadSource;
58
+ bidding: BidderOptions &
59
+ ProportionalBidPolicyOpts & {
60
+ /** Maximum time in milliseconds to wait for payload retrieval at the bid deadline */
61
+ getPayloadTimeout: number;
62
+ };
63
+ reveal: {
64
+ /** Defaults to PAYLOAD_ATTESTATION_DUE_BPS of the network */
65
+ cutoffBps?: number;
66
+ };
36
67
  };
37
68
 
38
69
  /**
@@ -49,6 +80,9 @@ export class Builder {
49
80
  private readonly logger: Logger;
50
81
  private readonly executionFeeRecipient: ExecutionAddress;
51
82
  private readonly payloadStore: PayloadStore;
83
+ private readonly ledger: BidLedger;
84
+ private readonly bidder: Bidder;
85
+ private readonly revealer: Revealer;
52
86
 
53
87
  constructor({
54
88
  opts,
@@ -59,6 +93,9 @@ export class Builder {
59
93
  clock,
60
94
  index,
61
95
  payloadStore,
96
+ ledger,
97
+ bidder,
98
+ revealer,
62
99
  }: BuilderModules) {
63
100
  this.builderSigner = builderSigner;
64
101
  this.blockObserver = blockObserver;
@@ -69,9 +106,13 @@ export class Builder {
69
106
  this.logger = opts.logger;
70
107
  this.index = index;
71
108
  this.payloadStore = payloadStore;
109
+ this.ledger = ledger;
110
+ this.bidder = bidder;
111
+ this.revealer = revealer;
72
112
 
73
113
  this.executionFeeRecipient = opts.executionFeeRecipient;
74
114
 
115
+ this.blockObserver.runOnBlock((block) => this.revealer.onBlock(block));
75
116
  this.clock.runEverySlot(async (slot) => this.onSlot(slot));
76
117
  this.clock.runEveryEpoch((epoch) => this.builderStatusTracker.poll(epoch));
77
118
  this.clock.start(this.controller.signal);
@@ -80,6 +121,9 @@ export class Builder {
80
121
  this.logger.info("Builder client initialized", {
81
122
  index: this.index,
82
123
  executionFeeRecipient: toHex(this.executionFeeRecipient),
124
+ payloadSource: opts.payloadSource.id,
125
+ shareBps: opts.bidding.shareBps,
126
+ deadlineBps: opts.bidding.deadlineBps,
83
127
  });
84
128
  }
85
129
 
@@ -112,10 +156,60 @@ export class Builder {
112
156
  );
113
157
 
114
158
  const builderStatusTracker = new BuilderStatusTracker(api, logger, index, opts.metrics);
159
+ await builderStatusTracker.poll(clock.getCurrentEpoch());
115
160
  const blockObserver = new BlockObserver(config, logger, api);
116
161
  const proposerPreferencesTracker = new ProposerPreferencesTracker();
117
162
 
118
163
  const payloadStore = new PayloadStore();
164
+ const ledger = new BidLedger();
165
+ const signal = opts.abortController.signal;
166
+
167
+ const bidder = new Bidder(
168
+ {
169
+ config,
170
+ logger,
171
+ clock,
172
+ api,
173
+ orchestrator: new PayloadOrchestrator(
174
+ opts.payloadSource,
175
+ {getPayloadTimeout: opts.bidding.getPayloadTimeout},
176
+ signal
177
+ ),
178
+ payloadStore,
179
+ ledger,
180
+ policy: new ProportionalBidPolicy(opts.bidding),
181
+ bidPublisher: new BidPublisher({
182
+ api,
183
+ config,
184
+ signer: builderSigner,
185
+ ledger,
186
+ builderIndex: index,
187
+ hasPayload: ({blockHash}) => payloadStore.has(blockHash),
188
+ }),
189
+ proposerPreferencesTracker,
190
+ getBuilderStatus: () => builderStatusTracker.getStatus(),
191
+ builderIndex: index,
192
+ executionFeeRecipient: opts.executionFeeRecipient,
193
+ metrics: opts.metrics,
194
+ signal,
195
+ },
196
+ opts.bidding
197
+ );
198
+
199
+ const revealer = new Revealer(
200
+ {
201
+ config,
202
+ logger,
203
+ clock,
204
+ bidSelector: new BidSelector({config, ledger, builderIndex: index}),
205
+ payloadStore,
206
+ envelopePublisher: new EnvelopePublisher({api, signer: builderSigner, ledger, builderIndex: index}),
207
+ builderIndex: index,
208
+ metrics: opts.metrics,
209
+ signal,
210
+ },
211
+ {cutoffBps: opts.reveal.cutoffBps ?? config.PAYLOAD_ATTESTATION_DUE_BPS}
212
+ );
119
213
 
120
214
  return new Builder({
121
215
  opts,
@@ -126,19 +220,28 @@ export class Builder {
126
220
  clock,
127
221
  index,
128
222
  payloadStore,
223
+ ledger,
224
+ bidder,
225
+ revealer,
129
226
  });
130
227
  }
131
228
 
132
229
  private async onSlot(slot: number): Promise<void> {
133
230
  this.payloadStore.prune(slot);
134
231
  this.proposerPreferencesTracker.prune(slot);
232
+ this.ledger.settlePaymentsBefore(slot - PAYMENT_SETTLEMENT_SLOTS);
233
+ this.ledger.prune(slot);
135
234
  }
136
235
 
137
236
  private subscribeToEvents(api: ApiClient): void {
138
237
  const signal = this.controller.signal;
139
238
  if (signal.aborted) return;
140
239
 
141
- const topics = [routes.events.EventType.block, routes.events.EventType.proposerPreferences];
240
+ const topics = [
241
+ routes.events.EventType.block,
242
+ routes.events.EventType.proposerPreferences,
243
+ routes.events.EventType.payloadAttributes,
244
+ ];
142
245
  this.logger.verbose("Subscribing to builder events", {topics: topics.join(",")});
143
246
  api.events
144
247
  .eventstream({
@@ -209,6 +312,9 @@ export class Builder {
209
312
  case routes.events.EventType.proposerPreferences:
210
313
  this.proposerPreferencesTracker.onProposerPreferences(event.message.data);
211
314
  break;
315
+ case routes.events.EventType.payloadAttributes:
316
+ await this.bidder.onPayloadAttributes(event.message);
317
+ break;
212
318
  }
213
319
  } catch (error) {
214
320
  if (!signal.aborted && !isErrorAborted(error)) {
package/src/defaults.ts CHANGED
@@ -1,5 +1,19 @@
1
+ import {MIN_DEPOSIT_AMOUNT} from "@lodestar/params";
2
+
1
3
  export const defaultOptions = {
2
4
  // Source beacon node the builder connects to
3
5
  beaconNodeUrl: "http://127.0.0.1:9596",
4
6
  requestTimeout: 10_000,
7
+
8
+ bidding: {
9
+ /** Share of the payload value offered to the proposer, the full value by default */
10
+ shareBps: 10_000,
11
+ fixedCostGwei: 0,
12
+ minValueGwei: 0,
13
+ /** Retrieve the payload and publish the bid at 95% of the slot before the proposal slot */
14
+ deadlineBps: 9500,
15
+ getPayloadTimeout: 1000,
16
+ /** Do not bid when the builder balance falls below this, bids are only covered by the balance above the deposit minimum */
17
+ minOperatingBalanceGwei: MIN_DEPOSIT_AMOUNT,
18
+ },
5
19
  };
package/src/identity.ts CHANGED
@@ -99,11 +99,10 @@ async function waitForBuilder(
99
99
  }
100
100
  if (builder?.status === "pending") {
101
101
  logger.info("Waiting for builder deposit to be finalized", {id, slot: clock.getCurrentSlot()});
102
- await sleep(msToNextEpochPoll(clock), signal);
103
102
  } else {
104
103
  logger.info("Waiting for builder to be known to the beacon node", {id, slot: clock.getCurrentSlot()});
105
- await sleep(msToNextSlotPoll(clock), signal);
106
104
  }
105
+ await sleep(msToNextSlotPoll(clock), signal);
107
106
  }
108
107
  throw new ErrorAborted("waitForBuilder");
109
108
  }
@@ -145,8 +144,9 @@ async function fetchBuilder(
145
144
  }
146
145
  }
147
146
 
147
+ /** Polls at the second slot of the next epoch, the head state has gone through the epoch transition by then */
148
148
  function msToNextEpochPoll(clock: IClock): number {
149
- return clock.msToSlot(computeStartSlotAtEpoch(clock.getCurrentEpoch() + 1));
149
+ return clock.msToSlot(computeStartSlotAtEpoch(clock.getCurrentEpoch() + 1) + 1);
150
150
  }
151
151
 
152
152
  function msToNextSlotPoll(clock: IClock): number {
package/src/index.ts CHANGED
@@ -2,3 +2,9 @@ export {Builder, type BuilderOptions} from "./builder.js";
2
2
  export {defaultOptions} from "./defaults.js";
3
3
  export {type Metrics, getMetrics} from "./metrics.js";
4
4
  export {type Keypair} from "./services/builderSigner.js";
5
+ export {
6
+ type EnginePayloadResult,
7
+ EnginePayloadSource,
8
+ type PayloadSource,
9
+ type PayloadSourceEngine,
10
+ } from "./services/payloadSource.js";
package/src/metrics.ts CHANGED
@@ -14,6 +14,22 @@ export type LodestarGitData = {
14
14
 
15
15
  export const builderStatusValue: Record<BuilderStatus, number> = {pending: 0, active: 1, exited: 2};
16
16
 
17
+ export enum BidResult {
18
+ published = "published",
19
+ noProposerPreferences = "no_proposer_preferences",
20
+ inactive = "inactive",
21
+ lowBalance = "low_balance",
22
+ policyDeclined = "policy_declined",
23
+ error = "error",
24
+ }
25
+
26
+ export enum RevealResult {
27
+ published = "published",
28
+ unknownPayload = "unknown_payload",
29
+ late = "late",
30
+ error = "error",
31
+ }
32
+
17
33
  export function getMetrics(register: MetricsRegisterExtra, gitData: LodestarGitData) {
18
34
  register
19
35
  .gauge<LodestarGitData>({
@@ -34,6 +50,26 @@ export function getMetrics(register: MetricsRegisterExtra, gitData: LodestarGitD
34
50
  help: "Current builder balance in gwei",
35
51
  }),
36
52
 
53
+ bids: {
54
+ total: register.gauge<{result: BidResult}>({
55
+ name: "bc_builder_bids_total",
56
+ help: "Total count of bid attempts by result",
57
+ labelNames: ["result"],
58
+ }),
59
+ won: register.gauge({
60
+ name: "bc_builder_bids_won_total",
61
+ help: "Total count of imported blocks that selected one of our bids",
62
+ }),
63
+ },
64
+
65
+ reveals: {
66
+ total: register.gauge<{result: RevealResult}>({
67
+ name: "bc_builder_reveals_total",
68
+ help: "Total count of payload reveal attempts by result",
69
+ labelNames: ["result"],
70
+ }),
71
+ },
72
+
37
73
  // REST API client
38
74
 
39
75
  restApiClient: {
@@ -190,6 +190,21 @@ export class BidLedger {
190
190
  }
191
191
  }
192
192
 
193
+ /** Marks won bids before the given slot as settled, by then their payment is reflected in the builder balance. */
194
+ settlePaymentsBefore(slot: Slot): void {
195
+ for (const [bidSlot, bidsForSlot] of this.bidsBySlot) {
196
+ if (bidSlot >= slot) {
197
+ continue;
198
+ }
199
+
200
+ for (const record of bidsForSlot.values()) {
201
+ if (record.wonBlockRoots.size > 0) {
202
+ record.paymentSettled = true;
203
+ }
204
+ }
205
+ }
206
+ }
207
+
193
208
  getUnsettledValueGwei(currentEpoch: Epoch): number {
194
209
  let total = 0;
195
210
  for (const bidsForSlot of this.bidsBySlot.values()) {
@@ -0,0 +1,191 @@
1
+ import {ApiClient, routes} from "@lodestar/api";
2
+ import {ChainForkConfig} from "@lodestar/config";
3
+ import {ForkName, MIN_DEPOSIT_AMOUNT, isForkPostGloas} from "@lodestar/params";
4
+ import {IClock, computeEpochAtSlot} from "@lodestar/state-transition";
5
+ import {BuilderIndex, BuilderStatus, Epoch, ExecutionAddress, RootHex, gloas} from "@lodestar/types";
6
+ import {GWEI_TO_WEI, Logger, isErrorAborted, prettyGweiToEth, toHex, toRootHex} from "@lodestar/utils";
7
+ import {BidResult, Metrics} from "../metrics.js";
8
+ import {BidLedger} from "./bidLedger.js";
9
+ import {BidPolicy} from "./bidPolicy.js";
10
+ import {BidPublisher} from "./bidPublisher.js";
11
+ import {createExecutionPayloadBid} from "./executionPayloadBid.js";
12
+ import {PayloadOrchestrator} from "./payloadOrchestrator.js";
13
+ import {PayloadStore} from "./payloadStore.js";
14
+ import {ProposerPreferencesTracker} from "./proposerPreferencesTracker.js";
15
+
16
+ export type PayloadAttributesEvent = routes.events.EventData[routes.events.EventType.payloadAttributes];
17
+
18
+ export type BidderOptions = {
19
+ /** Point within the slot before the proposal slot at which the payload is retrieved and bid on, in basis points */
20
+ deadlineBps: number;
21
+ /** Do not bid while the builder balance is below this value */
22
+ minOperatingBalanceGwei: number;
23
+ };
24
+
25
+ export type BidderModules = {
26
+ config: ChainForkConfig;
27
+ logger: Logger;
28
+ clock: IClock;
29
+ api: ApiClient;
30
+ orchestrator: PayloadOrchestrator;
31
+ payloadStore: PayloadStore;
32
+ ledger: BidLedger;
33
+ policy: BidPolicy;
34
+ bidPublisher: BidPublisher;
35
+ proposerPreferencesTracker: ProposerPreferencesTracker;
36
+ getBuilderStatus: () => {status: BuilderStatus | undefined; balance: number | undefined};
37
+ builderIndex: BuilderIndex;
38
+ executionFeeRecipient: ExecutionAddress;
39
+ metrics: Metrics | null;
40
+ signal: AbortSignal;
41
+ };
42
+
43
+ /**
44
+ * Bids once per proposal slot and parent: starts a payload build when the beacon node emits payload attributes,
45
+ * retrieves the payload at the deadline, prices it and publishes the signed bid.
46
+ */
47
+ export class Bidder {
48
+ private readonly activeBuilds = new Set<string>();
49
+
50
+ constructor(
51
+ private readonly modules: BidderModules,
52
+ private readonly opts: BidderOptions
53
+ ) {}
54
+
55
+ async onPayloadAttributes(event: PayloadAttributesEvent): Promise<void> {
56
+ const {clock, config, ledger, logger, metrics, orchestrator, proposerPreferencesTracker} = this.modules;
57
+ if (!isForkPostGloas(event.version)) {
58
+ return;
59
+ }
60
+ const data = event.data as gloas.SSEPayloadAttributes;
61
+ const slot = data.proposalSlot;
62
+ const parentBlockRoot = toRootHex(data.parentBlockRoot);
63
+ const parentBlockHash = toRootHex(data.parentBlockHash);
64
+ const logCtx = {slot, parentBlockRoot, parentBlockHash};
65
+
66
+ if (slot <= clock.getCurrentSlot()) {
67
+ logger.debug("Ignoring payload attributes for past slot", logCtx);
68
+ return;
69
+ }
70
+
71
+ // TODO: the beacon node emits payload attributes once per slot, late in the slot and only for the parent
72
+ // payload variant it would build on, revisit to start the build earlier and to bid on both variants
73
+ const buildId = `${slot}:${parentBlockRoot}:${parentBlockHash}`;
74
+ if (this.activeBuilds.has(buildId) || ledger.hasSubmitted(slot, parentBlockHash, parentBlockRoot)) {
75
+ return;
76
+ }
77
+ this.activeBuilds.add(buildId);
78
+
79
+ try {
80
+ // Heze bids require inclusion list bits which are not tracked yet
81
+ if (event.version !== ForkName.gloas) {
82
+ logger.warn("Bidding is not supported for fork", {...logCtx, fork: event.version});
83
+ return;
84
+ }
85
+
86
+ const dependentRoot = await this.getDependentRoot(computeEpochAtSlot(slot), this.modules.signal);
87
+ const proposerPreferences = proposerPreferencesTracker.get(slot, dependentRoot);
88
+ if (proposerPreferences === null) {
89
+ logger.debug("No proposer preferences known for slot, not bidding", {...logCtx, dependentRoot});
90
+ metrics?.bids.total.inc({result: BidResult.noProposerPreferences});
91
+ return;
92
+ }
93
+ const {feeRecipient, targetGasLimit} = proposerPreferences.message;
94
+
95
+ // The payload is retrieved in the slot before the proposal slot, use the timing of that slot
96
+ const buildFork = config.getForkName(slot - 1);
97
+ const msToDeadline =
98
+ clock.msToSlot(slot) -
99
+ config.getSlotDurationMs(buildFork) +
100
+ config.getSlotComponentDurationMs(buildFork, this.opts.deadlineBps);
101
+ logger.verbose("Preparing payload build", {...logCtx, msToDeadline, targetGasLimit});
102
+
103
+ const payload = await orchestrator.run({
104
+ id: buildId,
105
+ request: {
106
+ fork: event.version,
107
+ forkchoiceState: {
108
+ headBlockHash: parentBlockHash,
109
+ safeBlockHash: toRootHex(data.safeBlockHash),
110
+ finalizedBlockHash: toRootHex(data.finalizedBlockHash),
111
+ },
112
+ payloadAttributes: {
113
+ ...data.payloadAttributes,
114
+ suggestedFeeRecipient: toHex(this.modules.executionFeeRecipient),
115
+ targetGasLimit,
116
+ },
117
+ },
118
+ getPayloadAt: Date.now() + msToDeadline,
119
+ });
120
+
121
+ const {status, balance} = this.modules.getBuilderStatus();
122
+ if (status !== "active" || balance === undefined) {
123
+ logger.warn("Builder is not active, not bidding", {...logCtx, status});
124
+ metrics?.bids.total.inc({result: BidResult.inactive});
125
+ return;
126
+ }
127
+ if (balance < this.opts.minOperatingBalanceGwei) {
128
+ logger.warn("Builder balance below operating minimum, not bidding", {
129
+ ...logCtx,
130
+ balance: prettyGweiToEth(balance),
131
+ minOperatingBalance: prettyGweiToEth(this.opts.minOperatingBalanceGwei),
132
+ });
133
+ metrics?.bids.total.inc({result: BidResult.lowBalance});
134
+ return;
135
+ }
136
+
137
+ const unsettledGwei = ledger.getUnsettledValueGwei(computeEpochAtSlot(slot));
138
+ const coverableGwei = Math.max(balance - MIN_DEPOSIT_AMOUNT - unsettledGwei, 0);
139
+ const payloadValueGwei = Number(payload.executionPayloadValue / GWEI_TO_WEI);
140
+ const value = this.modules.policy.computeValue({payloadValueGwei, coverableGwei});
141
+ if (value === null) {
142
+ logger.info("Bid policy declined to bid", {...logCtx, payloadValueGwei, coverableGwei, unsettledGwei});
143
+ metrics?.bids.total.inc({result: BidResult.policyDeclined});
144
+ return;
145
+ }
146
+
147
+ const {executionPayload} = payload;
148
+ const blockHash = toRootHex(executionPayload.blockHash);
149
+ this.modules.payloadStore.add({slot, parentBlockRoot: data.parentBlockRoot, blockHash, payload});
150
+
151
+ const bid = createExecutionPayloadBid({
152
+ slot,
153
+ parentBlockRoot: data.parentBlockRoot,
154
+ prevRandao: data.payloadAttributes.prevRandao,
155
+ builderIndex: this.modules.builderIndex,
156
+ feeRecipient,
157
+ value,
158
+ payload: {...payload, fork: ForkName.gloas},
159
+ });
160
+ await this.modules.bidPublisher.publish(bid, this.modules.signal);
161
+
162
+ metrics?.bids.total.inc({result: BidResult.published});
163
+ logger.info("Published execution payload bid", {
164
+ ...logCtx,
165
+ blockHash,
166
+ value: prettyGweiToEth(value),
167
+ payloadValue: prettyGweiToEth(payloadValueGwei),
168
+ feeRecipient: toHex(feeRecipient),
169
+ gasLimit: executionPayload.gasLimit,
170
+ transactions: executionPayload.transactions.length,
171
+ blobs: bid.blobKzgCommitments.length,
172
+ secFromSlot: clock.secFromSlot(slot),
173
+ });
174
+ } catch (e) {
175
+ if (!isErrorAborted(e)) {
176
+ metrics?.bids.total.inc({result: BidResult.error});
177
+ logger.error("Failed to bid", logCtx, e as Error);
178
+ }
179
+ } finally {
180
+ this.activeBuilds.delete(buildId);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Dependent root of the proposer duties in the given epoch, identifies the proposer preferences to bid against.
186
+ * Not cached, a reorg can change the dependent root within an epoch. Active builds already coalesce repeated attributes.
187
+ */
188
+ private async getDependentRoot(epoch: Epoch, signal: AbortSignal): Promise<RootHex> {
189
+ return (await this.modules.api.validator.getProposerDutiesV2({epoch}, {signal})).meta().dependentRoot;
190
+ }
191
+ }
@@ -1,5 +1,5 @@
1
- import {PublicKey, SecretKey} from "@chainsafe/lodestar-z/blst";
2
1
  import {BeaconConfig} from "@lodestar/config";
2
+ import {PublicKey, SecretKey} from "@lodestar/native/blst";
3
3
  import {getExecutionPayloadBidSigningRoot, getExecutionPayloadEnvelopeSigningRoot} from "@lodestar/state-transition";
4
4
  import {gloas, heze} from "@lodestar/types";
5
5
 
@@ -35,15 +35,20 @@ export type PayloadOrchestratorErrorType =
35
35
  getPayloadAt: number;
36
36
  }
37
37
  | {
38
- code: PayloadOrchestratorErrorCode.PREPARE_TIMEOUT | PayloadOrchestratorErrorCode.GET_PAYLOAD_TIMEOUT;
38
+ code: PayloadOrchestratorErrorCode.PREPARE_TIMEOUT;
39
+ jobId: string;
40
+ lastError: string | null;
41
+ }
42
+ | {
43
+ code: PayloadOrchestratorErrorCode.GET_PAYLOAD_TIMEOUT;
39
44
  jobId: string;
40
45
  };
41
46
 
42
47
  export class PayloadOrchestratorError extends LodestarError<PayloadOrchestratorErrorType> {}
43
48
 
44
49
  /**
45
- * Prepares a payload and retrieves it at the requested time. Preparation retries missing payload IDs until the
46
- * retrieval time. Transport-level retries remain owned by the source.
50
+ * Prepares a payload and retrieves it at the requested time. A missing payload ID is retried until the
51
+ * retrieval time, the execution client may not have imported the parent payload yet. Other errors fail the build.
47
52
  */
48
53
  export class PayloadOrchestrator {
49
54
  private readonly jobs = new Map<string, Promise<BuiltPayload>>();
@@ -73,6 +78,7 @@ export class PayloadOrchestrator {
73
78
  );
74
79
  }
75
80
 
81
+ let lastError: Error | null = null;
76
82
  const handle = await withTimeout(
77
83
  (signal = this.signal) =>
78
84
  retry(() => this.source.prepare(request, signal), {
@@ -80,13 +86,19 @@ export class PayloadOrchestrator {
80
86
  retryDelay: PREPARE_RETRY_DELAY,
81
87
  shouldRetry: (error) =>
82
88
  error instanceof PayloadSourceError && error.type.code === PayloadSourceErrorCode.NO_PAYLOAD_ID,
89
+ onRetry: (error) => {
90
+ lastError = error;
91
+ },
83
92
  signal,
84
93
  }),
85
94
  prepareTimeout,
86
95
  this.signal
87
96
  ).catch((error: unknown) => {
88
97
  if (error instanceof TimeoutError) {
89
- throw new PayloadOrchestratorError({code: PayloadOrchestratorErrorCode.PREPARE_TIMEOUT, jobId: id});
98
+ throw new PayloadOrchestratorError(
99
+ {code: PayloadOrchestratorErrorCode.PREPARE_TIMEOUT, jobId: id, lastError: lastError?.message ?? null},
100
+ `Payload preparation timed out jobId=${id} lastError=${lastError?.message}`
101
+ );
90
102
  }
91
103
  throw error;
92
104
  });
@@ -0,0 +1,88 @@
1
+ import {ChainForkConfig} from "@lodestar/config";
2
+ import {IClock} from "@lodestar/state-transition";
3
+ import {BuilderIndex} from "@lodestar/types";
4
+ import {Logger} from "@lodestar/utils";
5
+ import {Metrics, RevealResult} from "../metrics.js";
6
+ import {BidSelector} from "./bidSelector.js";
7
+ import {ObservedBlock} from "./blockObserver.js";
8
+ import {EnvelopePublisher} from "./envelopePublisher.js";
9
+ import {createExecutionPayloadEnvelopeContents} from "./executionPayloadEnvelope.js";
10
+ import {PayloadStore} from "./payloadStore.js";
11
+
12
+ export type RevealerOptions = {
13
+ /**
14
+ * Latest point within the block's slot at which a reveal may start, in basis points.
15
+ * Defaults to the network's PAYLOAD_ATTESTATION_DUE_BPS
16
+ */
17
+ cutoffBps: number;
18
+ };
19
+
20
+ export type RevealerModules = {
21
+ config: ChainForkConfig;
22
+ logger: Logger;
23
+ clock: IClock;
24
+ bidSelector: BidSelector;
25
+ payloadStore: PayloadStore;
26
+ envelopePublisher: EnvelopePublisher;
27
+ builderIndex: BuilderIndex;
28
+ metrics: Metrics | null;
29
+ signal: AbortSignal;
30
+ };
31
+
32
+ /** Reveals the payload once a block committing to one of our bids is imported by the beacon node. */
33
+ export class Revealer {
34
+ constructor(
35
+ private readonly modules: RevealerModules,
36
+ private readonly opts: RevealerOptions
37
+ ) {}
38
+
39
+ async onBlock(observed: ObservedBlock): Promise<void> {
40
+ const {bidSelector, clock, config, envelopePublisher, logger, metrics, payloadStore} = this.modules;
41
+ const selection = bidSelector.match(observed);
42
+ if (selection.status !== "selected") {
43
+ return;
44
+ }
45
+ metrics?.bids.won.inc();
46
+
47
+ const {blockRoot, slot} = observed;
48
+ const {blockHash} = selection.bid;
49
+ const logCtx = {slot, blockRoot, blockHash};
50
+
51
+ const storedPayload = payloadStore.get(blockHash);
52
+ if (storedPayload === null) {
53
+ logger.error("Payload of selected bid is not retained, cannot reveal", logCtx);
54
+ metrics?.reveals.total.inc({result: RevealResult.unknownPayload});
55
+ return;
56
+ }
57
+
58
+ const msFromSlot = clock.msFromSlot(slot);
59
+ const cutoffMs = config.getSlotComponentDurationMs(config.getForkName(slot), this.opts.cutoffBps);
60
+ if (msFromSlot > cutoffMs) {
61
+ logger.warn("Block with our bid arrived after reveal cutoff, not revealing", {...logCtx, msFromSlot, cutoffMs});
62
+ metrics?.reveals.total.inc({result: RevealResult.late});
63
+ return;
64
+ }
65
+
66
+ const contents = createExecutionPayloadEnvelopeContents({
67
+ blockRoot,
68
+ builderIndex: this.modules.builderIndex,
69
+ selectedBid: observed.signedBid.message,
70
+ storedPayload,
71
+ });
72
+
73
+ try {
74
+ await envelopePublisher.publish(contents, this.modules.signal);
75
+ } catch (e) {
76
+ metrics?.reveals.total.inc({result: RevealResult.error});
77
+ throw e;
78
+ }
79
+
80
+ metrics?.reveals.total.inc({result: RevealResult.published});
81
+ logger.info("Revealed execution payload", {
82
+ ...logCtx,
83
+ blobs: contents.blobs.length,
84
+ transactions: contents.envelope.payload.transactions.length,
85
+ secFromSlot: clock.secFromSlot(slot),
86
+ });
87
+ }
88
+ }