Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
eb1068a
feat: add bid circuit breaker for gloas block production
nflaig Jul 5, 2026
1b1430e
fix: exclude slots at or before anchor from bid circuit breaker window
nflaig Jul 5, 2026
b66bc86
chore: add gloas to docs wordlist
nflaig Jul 5, 2026
15b1f96
revert: exclude slots at or before anchor from bid circuit breaker wi…
nflaig Jul 6, 2026
e9946fd
chore: always include bid circuit breaker status in block production …
nflaig Jul 6, 2026
91d6805
refactor: rename bid circuit breaker to builder circuit breaker
nflaig Jul 6, 2026
335bacd
refactor: reuse --builder flags for post-gloas circuit breaker
nflaig Jul 6, 2026
ca36433
Merge branch 'unstable' into nflaig/gloas-circuit-breaker
nflaig Jul 8, 2026
96e2fc1
refactor: iterate protoArray backward to count payload reveals
nflaig Jul 17, 2026
9d02e97
feat: add builder circuit breaker window metrics
nflaig Jul 17, 2026
ae10b68
Merge branch 'unstable' into nflaig/gloas-circuit-breaker
nflaig Jul 17, 2026
43c7a9b
log circuit breaker activation as warning
nflaig Jul 17, 2026
a2aa07f
shorten circuit breaker log key
nflaig Jul 17, 2026
d82fc4c
reword builder bid test name
nflaig Jul 17, 2026
6004e8e
document rationale for randomized circuit breaker params
nflaig Jul 17, 2026
069269a
configure circuit breaker params on the cli layer
nflaig Jul 17, 2026
a01bca7
add more payload reveal count tests
nflaig Jul 17, 2026
8a3df06
add standalone circuit breaker args type
nflaig Jul 17, 2026
34e3cd4
reword branch counting rationale
nflaig Jul 17, 2026
91f691e
Update packages/fork-choice/test/unit/protoArray/gloas.test.ts
nflaig Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ flamegraphs
floodsub
fsSL
getNetworkIdentity
gloas
gnosis
gpg
heapdump
Expand Down
6 changes: 5 additions & 1 deletion packages/beacon-node/src/api/impl/validator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -938,14 +938,18 @@ export function getValidatorApi(
// support when it is implemented.
const isBuildingOnFull = chain.forkChoice.shouldBuildOnFull(parentBlock, slot);
const bidParentBlockHash = isBuildingOnFull ? parentBlock.executionPayloadBlockHash : parentBlock.parentBlockHash;
const builderBid = chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex);
const circuitBreakerActive = chain.builderCircuitBreaker.isActive(slot);
const builderBid = circuitBreakerActive
? null
: chain.executionPayloadBidPool.getBestBid(slot, bidParentBlockHash, parentBlockRootHex);

const logCtx = {
slot,
parentSlot,
parentBlockRoot: parentBlockRootHex,
parentBlockHash: parentBlock.executionPayloadBlockHash,
fork,
circuitBreakerActive,
...(builderBid !== null
? {
bidValue: builderBid.message.value,
Expand Down
84 changes: 84 additions & 0 deletions packages/beacon-node/src/chain/builderCircuitBreaker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {IForkChoice} from "@lodestar/fork-choice";
import {Slot} from "@lodestar/types";
import {Logger} from "@lodestar/utils";
import {getFaultInspectionParams} from "../execution/builder/http.js";
import {Metrics} from "../metrics/index.js";

export type BuilderCircuitBreakerOpts = {
faultInspectionWindow?: number;
allowedFaults?: number;
};

export type BuilderCircuitBreakerModules = {
forkChoice: IForkChoice;
logger: Logger;
metrics: Metrics | null;
};

/**
* Post-gloas circuit breaker for builder bids. The beacon block is produced by the proposer
* regardless of bid source, so missed blocks are not a useful builder health signal. Instead
* count blocks whose payload was never revealed and stop selecting builder bids while the
* non-reveal rate in the fault inspection window is too high.
*/
export class BuilderCircuitBreaker {
readonly faultInspectionWindow: number;
readonly allowedFaults: number;

private active = false;
private lastUpdatedSlot = -1;

constructor(
opts: BuilderCircuitBreakerOpts,
private readonly modules: BuilderCircuitBreakerModules
) {
const {faultInspectionWindow, allowedFaults} = getFaultInspectionParams(opts);
this.faultInspectionWindow = faultInspectionWindow;
this.allowedFaults = allowedFaults;
}

/** Whether builder bids must be ignored for a block produced at clockSlot */
isActive(clockSlot: Slot): boolean {
this.update(clockSlot);
return this.active;
}

update(clockSlot: Slot): void {
if (clockSlot <= this.lastUpdatedSlot) {
return;
}
this.lastUpdatedSlot = clockSlot;

// Exclude clockSlot itself, its payload reveal may still be in flight
const {blocksPresent, payloadsRevealed} = this.modules.forkChoice.getPayloadRevealCounts(
Math.max(clockSlot - this.faultInspectionWindow, 0),
clockSlot - 1
);
Comment thread
nflaig marked this conversation as resolved.
const faults = blocksPresent - payloadsRevealed;

const wasActive = this.active;
// Scale the fault budget by blocks present so sparse windows still trigger on high non-reveal rates
this.active = faults * this.faultInspectionWindow > this.allowedFaults * blocksPresent;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be great to also have minObservedBlocks or minObservedBlocksPercentage.

If we have only observed say 4 blocks in the past 32 slots, we would probably want to activate this because chain is unhealthy.
Only if it's >= 4 blocks, that we start the calculation of blocksPresent and payloadRevealed.

Numbers here are just arbitrary, just to illustrate an example

@nflaig nflaig Jul 18, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we have only observed say 4 blocks in the past 32 slots, we would probably want to activate this because chain is unhealthy.

to be clear, the circuit breaker has nothing to do with blocks at all in gloas, I wouldn't activate the circuit breaker if there are <4 blocks in the epoch, but I think it could set a min threshold of blocks that are needed for the circuit breaker changes it's state, eg. in that case above, if it's <4, then the circuit breaker just keeps it's previous state, either activated or deactived


this.modules.metrics?.builderCircuitBreaker.active.set(this.active ? 1 : 0);
this.modules.metrics?.builderCircuitBreaker.faults.set(faults);
this.modules.metrics?.builderCircuitBreaker.blocksPresent.set(blocksPresent);
this.modules.metrics?.builderCircuitBreaker.payloadsRevealed.set(payloadsRevealed);

const logCtx = {
blocksPresent,
faults,
faultInspectionWindow: this.faultInspectionWindow,
allowedFaults: this.allowedFaults,
};
if (this.active !== wasActive) {
if (this.active) {
this.modules.logger.warn("Builder circuit breaker activated, ignoring builder bids", logCtx);
} else {
this.modules.logger.info("Builder circuit breaker deactivated", logCtx);
}
} else {
this.modules.logger.verbose("Builder circuit breaker status", {active: this.active, ...logCtx});
}
}
}
7 changes: 7 additions & 0 deletions packages/beacon-node/src/chain/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import {ImportPayloadOpts} from "./blocks/types.js";
import {persistBlockInput} from "./blocks/writeBlockInputToDb.js";
import {persistPayloadEnvelopeInput} from "./blocks/writePayloadEnvelopeInputToDb.js";
import {BlsMultiThreadWorkerPool, BlsSingleThreadVerifier, IBlsVerifier} from "./bls/index.js";
import {BuilderCircuitBreaker} from "./builderCircuitBreaker.js";
import {ColumnReconstructionTracker} from "./ColumnReconstructionTracker.js";
import {ChainEvent, ChainEventEmitter} from "./emitter.js";
import {ForkchoiceCaller, initializeForkChoice} from "./forkChoice/index.js";
Expand Down Expand Up @@ -151,6 +152,7 @@ export class BeaconChain implements IBeaconChain {
readonly genesisValidatorsRoot: Root;
readonly executionEngine: IExecutionEngine;
readonly executionBuilder?: IExecutionBuilder;
readonly builderCircuitBreaker: BuilderCircuitBreaker;
// Expose config for convenience in modularized functions
readonly config: BeaconConfig;
readonly custodyConfig: CustodyConfig;
Expand Down Expand Up @@ -425,6 +427,11 @@ export class BeaconChain implements IBeaconChain {

this.forkChoice = forkChoice;

this.builderCircuitBreaker = new BuilderCircuitBreaker(
{faultInspectionWindow: opts.faultInspectionWindow, allowedFaults: opts.allowedFaults},
{forkChoice, logger, metrics}
);

this.seenPayloadEnvelopeInputCache = new SeenPayloadEnvelopeInput({
config,
clock,
Expand Down
2 changes: 2 additions & 0 deletions packages/beacon-node/src/chain/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {BeaconProposerCache, ProposerPreparationData} from "./beaconProposerCach
import {IBlockInput} from "./blocks/blockInput/index.js";
import {ImportBlockOpts, ImportPayloadOpts} from "./blocks/types.js";
import {IBlsVerifier} from "./bls/index.js";
import {BuilderCircuitBreaker} from "./builderCircuitBreaker.js";
import {ColumnReconstructionTracker} from "./ColumnReconstructionTracker.js";
import {ChainEventEmitter} from "./emitter.js";
import {ForkchoiceCaller} from "./forkChoice/index.js";
Expand Down Expand Up @@ -95,6 +96,7 @@ export interface IBeaconChain {
readonly earliestAvailableSlot: Slot;
readonly executionEngine: IExecutionEngine;
readonly executionBuilder?: IExecutionBuilder;
readonly builderCircuitBreaker: BuilderCircuitBreaker;
// Expose config for convenience in modularized functions
readonly config: BeaconConfig;
readonly custodyConfig: CustodyConfig;
Expand Down
4 changes: 4 additions & 0 deletions packages/beacon-node/src/chain/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export type IChainOptions = BlockProcessOpts &
archiveDateEpochs?: number;
nHistoricalStatesFileDataStore?: boolean;
nativeStateView?: boolean;
/** Builder circuit breaker fault inspection window in slots */
faultInspectionWindow?: number;
/** Allowed unrevealed payloads within the fault inspection window */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This description is wrong though. I originally thought this means the absolute count of unrevealed payloads that triggers the circuit breaker but apparently not.

What it means is if fault rate is greater than allowedFaults / window, circuit breaker will trigger.

If window = 32, allowedFaults = 8, observedBlocks = 16, then circuit breaker activates at 5 unrevealed payloads

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's better to define allowedFaultsPercentage = 0.25 to allow up to 25% fault

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah it's not 100% accurate, I also think for gloas we can even re-design the inputs a bit, I don't really like faultInspectionWindow and allowedFaults as input parameters, just kept to not overcomplicate this PR, I think we can do better here if we have more time to think how we wanna design this. Especially now that we have to consider blocks as well, it makes sense to use different semantics

allowedFaults?: number;
};

export type BlockProcessOpts = {
Expand Down
18 changes: 11 additions & 7 deletions packages/beacon-node/src/chain/prepareNextSlot.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {routes} from "@lodestar/api";
import {ChainForkConfig} from "@lodestar/config";
import {getSafeExecutionBlockHash} from "@lodestar/fork-choice";
import {ForkPostBellatrix, ForkSeq, SLOTS_PER_EPOCH, isForkPostBellatrix} from "@lodestar/params";
import {ForkPostBellatrix, ForkSeq, SLOTS_PER_EPOCH, isForkPostBellatrix, isForkPostGloas} from "@lodestar/params";
import {
IBeaconStateView,
IBeaconStateViewBellatrix,
Expand Down Expand Up @@ -152,12 +152,16 @@ export class PrepareNextSlotScheduler {
updatedHead = proposerHead;
}

// Update the builder status, if enabled shoot an api call to check status
this.chain.updateBuilderStatus(clockSlot);
if (this.chain.executionBuilder?.status === BuilderStatus.enabled) {
this.chain.executionBuilder.checkStatus().catch((e) => {
this.logger.error("Builder disabled as the check status api failed", {prepareSlot}, e as Error);
});
if (isForkPostGloas(fork)) {
this.chain.builderCircuitBreaker.update(clockSlot);
} else {
// Update the builder status, if enabled shoot an api call to check status
this.chain.updateBuilderStatus(clockSlot);
if (this.chain.executionBuilder?.status === BuilderStatus.enabled) {
this.chain.executionBuilder.checkStatus().catch((e) => {
this.logger.error("Builder disabled as the check status api failed", {prepareSlot}, e as Error);
});
}
}
}

Expand Down
47 changes: 30 additions & 17 deletions packages/beacon-node/src/execution/builder/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,33 @@ export class NoBidReceived extends Error {
}
}

/**
* Beacon clients select randomized values from the following ranges when initializing
* the circuit breaker (so at boot time and once for each unique boot).
Comment on lines +74 to +75

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this is just moved (existing code). But why are we randomizing these values?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so that you can't game the circuit breaker, if you know the exact algorithm the builders could abuse that. this isn't that relevant since each client has it's own implementation but assuming we had 100% network share this would be needed so each proposer behaves slightly different and builders are not able to predict when local blocks will be forced by the circuit breaker

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a comment here 6004e8e

*
* ALLOWED_FAULTS: between 1 and SLOTS_PER_EPOCH // 4

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allowed faults is not between 1 and 8 (mainnet), but L93 calculation says between 8 and 15

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah good catch, this comment is wrong, should have read more carefully, would be concerning if allowed faults could be 1, that would be too aggressive behavior

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed by #9780

* FAULT_INSPECTION_WINDOW: between SLOTS_PER_EPOCH and 2 * SLOTS_PER_EPOCH
*
* The values are randomized per node so builders cannot predict when a given proposer will
* fall back to local blocks. With fixed thresholds a builder could withhold payloads right up
* to the limit without ever tripping the breaker.
*/
export function getFaultInspectionParams(opts: {faultInspectionWindow?: number; allowedFaults?: number}): {
faultInspectionWindow: number;
allowedFaults: number;
} {
const faultInspectionWindow = Math.max(
opts.faultInspectionWindow ?? SLOTS_PER_EPOCH + Math.floor(Math.random() * SLOTS_PER_EPOCH),
SLOTS_PER_EPOCH
);
// allowedFaults should be < faultInspectionWindow, limiting them to faultInspectionWindow/4
const allowedFaults = Math.min(
opts.allowedFaults ?? Math.floor(faultInspectionWindow / 4),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer ?? infinity.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't get what you mean here so you are saying if opts.allowedFaults is not configured the is should be infinity?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah I think I got what you mean, sure can do that

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed by #9780

Math.floor(faultInspectionWindow / 4)
);
return {faultInspectionWindow, allowedFaults};
}

/**
* Additional duration to account for potential event loop lag which causes
* builder blocks to be rejected even though the response was sent in time.
Expand Down Expand Up @@ -122,23 +149,9 @@ export class ExecutionBuilderHttp implements IExecutionBuilder {
this.registrations = new ValidatorRegistrationCache();
this.issueLocalFcUWithFeeRecipient = opts.issueLocalFcUWithFeeRecipient;

/**
* Beacon clients select randomized values from the following ranges when initializing
* the circuit breaker (so at boot time and once for each unique boot).
*
* ALLOWED_FAULTS: between 1 and SLOTS_PER_EPOCH // 4
* FAULT_INSPECTION_WINDOW: between SLOTS_PER_EPOCH and 2 * SLOTS_PER_EPOCH
*
*/
this.faultInspectionWindow = Math.max(
opts.faultInspectionWindow ?? SLOTS_PER_EPOCH + Math.floor(Math.random() * SLOTS_PER_EPOCH),
SLOTS_PER_EPOCH
);
// allowedFaults should be < faultInspectionWindow, limiting them to faultInspectionWindow/4
this.allowedFaults = Math.min(
opts.allowedFaults ?? Math.floor(this.faultInspectionWindow / 4),
Math.floor(this.faultInspectionWindow / 4)
);
const {faultInspectionWindow, allowedFaults} = getFaultInspectionParams(opts);
this.faultInspectionWindow = faultInspectionWindow;
this.allowedFaults = allowedFaults;
}

updateStatus(status: BuilderStatus): void {
Expand Down
19 changes: 19 additions & 0 deletions packages/beacon-node/src/metrics/metrics/beacon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,25 @@ export function createBeaconMetrics(register: RegistryMetricCreator) {
help: "Count of cached produced results",
}),

builderCircuitBreaker: {
Comment thread
nflaig marked this conversation as resolved.
active: register.gauge({
name: "beacon_builder_circuit_breaker_active",
help: "Whether the builder circuit breaker is active (1) causing builder bids to be ignored",
}),
faults: register.gauge({
name: "beacon_builder_circuit_breaker_faults",
help: "Count of blocks with unrevealed payloads in the fault inspection window",
}),
blocksPresent: register.gauge({
name: "beacon_builder_circuit_breaker_blocks_present",
help: "Count of blocks present in the fault inspection window",
}),
payloadsRevealed: register.gauge({
name: "beacon_builder_circuit_breaker_payloads_revealed",
help: "Count of blocks with revealed payloads in the fault inspection window",
}),
},

blockPayload: {
payloadAdvancePrepTime: register.histogram({
name: "beacon_block_payload_prepare_time",
Expand Down
14 changes: 14 additions & 0 deletions packages/beacon-node/test/mocks/mockedBeaconChain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import {EpochDifference, ForkChoice, ProtoBlock} from "@lodestar/fork-choice";
import {createPubkeyCache} from "@lodestar/state-transition";
import {Logger} from "@lodestar/utils";
import {BeaconProposerCache} from "../../src/chain/beaconProposerCache.js";
import {BuilderCircuitBreaker} from "../../src/chain/builderCircuitBreaker.js";
import {BeaconChain} from "../../src/chain/chain.js";
import {ChainEventEmitter} from "../../src/chain/emitter.js";
import {LightClientServer} from "../../src/chain/lightClient/index.js";
import {ExecutionPayloadBidPool} from "../../src/chain/opPools/executionPayloadBidPool.js";
import {AggregatedAttestationPool, OpPool, SyncContributionAndProofPool} from "../../src/chain/opPools/index.js";
import {QueuedStateRegenerator} from "../../src/chain/regen/index.js";
import {SeenBlockInput} from "../../src/chain/seenCache/seenGossipBlockInput.js";
Expand All @@ -23,6 +25,8 @@ export type MockedBeaconChain = Mocked<BeaconChain> & {
forkChoice: MockedForkChoice;
executionEngine: Mocked<ExecutionEngineHttp>;
executionBuilder: Mocked<ExecutionBuilderHttp>;
builderCircuitBreaker: Mocked<BuilderCircuitBreaker>;
executionPayloadBidPool: Mocked<ExecutionPayloadBidPool>;
opPool: Mocked<OpPool>;
aggregatedAttestationPool: Mocked<AggregatedAttestationPool>;
syncContributionAndProofPool: Mocked<SyncContributionAndProofPool>;
Expand Down Expand Up @@ -71,6 +75,8 @@ vi.mock("@lodestar/fork-choice", async (importActual) => {
hasBlockHex: vi.fn(),
getBlockSummariesAtSlot: vi.fn(),
notifyPtcMessages: vi.fn(),
shouldBuildOnFull: vi.fn(),
getPayloadRevealCounts: vi.fn(),
};
});

Expand Down Expand Up @@ -143,6 +149,14 @@ vi.mock("../../src/chain/chain.js", async (importActual) => {
getClientVersion: vi.fn(),
},
executionBuilder: {},
builderCircuitBreaker: {
isActive: vi.fn(),
update: vi.fn(),
},
executionPayloadBidPool: {
add: vi.fn(),
getBestBid: vi.fn(),
},
opPool: new OpPool(config as BeaconConfig),
aggregatedAttestationPool: new AggregatedAttestationPool(config as BeaconConfig),
syncContributionAndProofPool: new SyncContributionAndProofPool(config, clock),
Expand Down
Loading
Loading