Skip to content

chore: v1.45.0 release#9674

Open
matthewkeil wants to merge 48 commits into
stablefrom
rc/v1.45.0
Open

chore: v1.45.0 release#9674
matthewkeil wants to merge 48 commits into
stablefrom
rc/v1.45.0

Conversation

@matthewkeil

Copy link
Copy Markdown
Member

Release v1.45.0

matthewkeil and others added 30 commits July 1, 2026 18:12
Relevant spec ethereum/consensus-specs#5306

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This avoids having to memoize expensive shufflings across bindings

See:
#9516 and #9569 for usage/motivation
## Motivation

`PeerDiscovery` reads `this.transports` inside `handleDiscoveredPeer()`
(to check transport compatibility), but the constructor assigns
`this.transports` only at the **end** — after it registers the discovery
listeners and, when `network.connectToDiscv5Bootnodes` is enabled,
synchronously processes every bootENR via `onDiscoveredENR()` →
`handleDiscoveredPeer()`.

So with `--network.connectToDiscv5Bootnodes` set, each bootENR is
handled while `this.transports` is still `undefined`, throwing:

```
error: Error onDiscovered - Cannot read properties of undefined (reading 'includes')
    at PeerDiscovery.onDiscoveredENR (.../network/peers/discover.js)
```

(from `this.transports.includes("tcp")`). The error is caught and
returns `DiscoveredPeerStatus.error`, so it is **not fatal** — but it
means **none of the bootnodes are dialed at startup**, which hurts peer
bootstrapping exactly when a freshly (re)started node needs peers most.

### Observed in the wild
On ethpandaops `glamsterdam-devnet-6`, restarted Lodestar supernodes
(run with `--network.connectToDiscv5Bootnodes`) repeatedly failed to
re-establish peers after a restart (stuck at 0–2 peers), and the log
spammed this TypeError on every startup.

## Description
Move the `this.transports` initialization above the listener
registration and the bootENR loop, with a comment documenting the
ordering requirement to avoid regression. Pure reordering — no other
behavior change.

## Testing
`discover.test.ts` has no constructor harness today (a regression test
would need to mock `libp2p.services.components.transportManager`, the
discv5 worker, etc.) — happy to add one if preferred. Verified the fix
removes the startup TypeError and restores bootnode dialing on the
affected devnet nodes.
`ProtoArray.onBlock` can potentially reject the block to be imported. So
we should update our fork choice store only after the import is
complete.

See
#9565 (review)
for more context

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…9476)

**Motivation**

- as found in #9475 we did not add PayloadEnvelopeInput to the seen
cache when we receive "unknown parent" block
- this caused some lodestar nodes to stay on "EMPTY" branch of the
common ancestor block 23956 forever

**Description**

issue on `lodestar-erigon-1` `lodestar-nethermind-2`

- add it to the cache right after we have it
- do the same thing for range sync

issue on `lodestar-nethermind-1`
- don't remove EMPTY PayloadEnvelopeInput because we may need it to
download FULL variant later

part of #9475

**AI Assistance Disclosure**
created with the help of Claude

---------

Co-authored-by: twoeths <twoeths@users.noreply.github.com>
The bid commits to blob KZG commitments even when its payload is
orphaned, whose columns are pruned and unservable, so the batch must not
be truncated and re-requested (which stalls range sync). Skip such
blocks instead; data availability is enforced on envelope processing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…spin loop (#9501)

### Motivation

While testing `glamsterdam-devnet-5` via Kurtosis, `lodestar` reliably
crashed with a JS heap OOM (exit 134) ~60-100s after startup, whenever
the CL was following the chain while its EL was still syncing:

```
<--- Last few GCs --->

[7:0xffffac790000]    65680 ms: Scavenge (interleaved) 8124.2 (8168.0) -> 8101.1 (8172.3) MB, pooled: 0 MB, 14.92 / 0.00 ms  (average mu = 0.254, current mu = 0.204) allocation failure;
[7:0xffffac790000]    66835 ms: Mark-Compact (reduce) 8114.3 (8173.3) -> 8103.7 (8116.8) MB, pooled: 0 MB, 45.22 / 0.09 ms  (+ 1044.7 ms in 209 steps since start of marking, biggest step 6.3 ms, walltime since start of marking 1140 ms) (average mu = 0.257

FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory
----- Native stack trace -----

 1: 0x73f17c node::OOMErrorHandler(char const*, v8::OOMDetails const&) [node]
 2: 0xbaa8dc  [node]
 3: 0xbaa9b4  [node]
 4: 0xe26c4c  [node]
 5: 0xe26c7c  [node]
 6: 0xe26fd4  [node]
 7: 0xe36784  [node]
 8: 0xe3a3e4  [node]
 9: 0x18226c4  [node]
```

Debug logs captured the cause: an infinite spin loop in `BlockInputSync`
payload sync, re-processing the same payload root at ~25 iterations
**per millisecond** until the 8GB heap (`--max-old-space-size=8192`) was
exhausted, while starving the event loop (no status logs, dead REST API
for the final ~90s):

```
BlockInputSync.downloadPayload() slot=unknown, root=0x2869…afd0, pendingPayloads=2
BlockInputSync.fetchPayloadInput: successful download … hasPayload=true, hasAllData=true
Processed payload from unknown sync slot=43213, root=0x2869…afd0
Added new payload rootHex to BlockInputSync.pendingPayloads root=0x2869…afd0
No unknown block, process ancestor downloaded blocks pendingBlocks=1, ancestorBlocks=1, processedBlocks=0
(repeats ~25x per ms, cycling through every connected peer, until OOM)
```

The loop:

1. A pending block's missing dependency is its parent payload; the
payload is not in fork choice yet because its real import job is still
in flight (e.g. blocked on regen/EL while the EL syncs).
2. `advancePendingBlock()` re-adds the payload root to
`pendingPayloads`.
3. The scheduler "downloads" it instantly from
`seenPayloadEnvelopeInputCache`.
4. `processPayload()` →
`PayloadEnvelopeProcessor.processPayloadEnvelopeJob()` sees the input
already has an `importStatus` and **returns immediately — silent
success** — while the actual import has not completed.
5. `BlockInputSync` logs "Processed payload", deletes the
`pendingPayloads` entry, re-triggers the search → back to step 1. No
backoff, no progress check.

### Description

Replace the `importStatus` enum `WeakMap` in `PayloadEnvelopeProcessor`
with a `WeakMap` of in-flight import promises. Duplicate callers now
await the real import outcome instead of resolving early:

- While the import is in flight, deduped callers wait — `BlockInputSync`
no longer sees a fake success, so the spin loop cannot form.
- On import failure, the rejection propagates to all callers —
`BlockInputSync.processPayload` already has correct bounded handling for
`PayloadError`s (`BLOCK_NOT_IN_FORK_CHOICE` → re-enqueue block, engine
errors → retry on next scheduler pass), it just never saw the errors
before. The failed entry is dropped from the map so a later attempt can
retry.
- On success, subsequent calls still resolve immediately without
re-running the import (unchanged behavior).

Added unit tests for the processor; the first two fail against the
previous implementation.

Note: the bug is currently only reachable on gloas networks (devnets),
but the code is live on `unstable`.

### AI disclosure

This PR was diagnosed and authored with AI assistance (Claude Code),
reviewed by me.
As per ethereum/beacon-APIs#608
> Validator clients SHOULD begin submitting signed proposer preferences
one epoch before the
Gloas fork activates, so the proposer preference caches kept by both
beacon nodes and
builders are warm at the fork boundary, letting builders prepare valid
bids for the first
    Gloas slots.


Also related to ethereum/consensus-specs#4947
but we already do this since we subscribe to new gossip topics 2 epochs
before the fork (`SUBSCRIPTIONS_LOOKAHEAD_EPOCHS = 2`).
## Motivation

Lodestar's `--rcConfig` file currently requires nested options to be
written as **dotted keys**:

```yaml
rest.address: "0.0.0.0"
rest.port: 9596
```

This surprised a user who expected the idiomatic nested YAML form
(`rest:` → `address:`). The nested form is nicer, but we don't want to
break existing dotted configs — so this PR supports **both**.

## Description

### Nested maps → dotted keys

Nested maps in the config file are flattened to dotted keys before being
handed to yargs, so both of these produce identical results:

```yaml
# dotted (still works)
rest.address: "0.0.0.0"
rest.port: 9596

# nested (now works too)
rest:
  address: "0.0.0.0"
  port: 9596
```

**Why flattening:** `cli.ts` sets `parserConfiguration({"dot-notation":
false})` (needed so `.strict()` keeps working with dotted option names),
so options are registered as literal dotted keys (`"rest.address"`) and
yargs `.config()` only matches literal dotted keys from the file —
nested maps are silently ignored today. Flattening the parsed file in
the `rcConfig` read callback bridges the gap without touching parser
config or the strictness guarantees.

- **Arrays are preserved** as values (not flattened by index) so array
options (`rest.namespace`, `bootnodes`, …) keep working.
- **Already-dotted keys pass through unchanged** → fully backward
compatible.
- **Prototype-pollution safe:** `__proto__` is skipped and non-plain
input returns `{}`.
- If an option is given in both nested and dotted form, the value
appearing last in the file wins.
- Applies to every command that shares the global `--rcConfig` option
(beacon / validator / bootnode).

### `.enabled` → bare on/off flag

On/off options are registered as a bare boolean flag (e.g. `--metrics`),
which reads awkwardly as a nested map. After flattening,
`{prefix}.enabled` is rewritten to the bare `{prefix}` flag so the
natural nested form works:

```yaml
metrics:
  enabled: true   # → the --metrics flag
  port: 8008
```

`metrics.enabled: true` → `metrics: true`. If both `{prefix}` and
`{prefix}.enabled` are present, `.enabled` is left as-is so the conflict
surfaces at yargs' strict check instead of being silently swallowed.

## Testing

- **`flattenObject`** — 13 unit tests: nested → dotted, dotted
pass-through, mixed nested+dotted, array preservation,
arrays-of-objects, primitive/null values, deep nesting, top-level
scalars, empty-map elision, nested/dotted collision precedence,
`__proto__` guard, non-plain input, and non-plain object leaves
(`Date`).
- **`translateEnabledKeys`** — 7 unit tests: `.enabled` → bare flag, the
nested `metrics: {enabled, port}` form, multiple prefixes, the
already-set conflict case, no-op when absent, a top-level `enabled` is
not translated, and deeply-prefixed keys.
- `check-types`, `biome lint`, full build, and the existing cli `util` /
`options` / `beacon` unit suites pass — no regressions.

## Docs

Adds a compact example to the `--rcConfig` option showing both forms (a
nested map and a dotted key), so it renders in the generated CLI
reference
([beacon-cli#--rcconfig](https://chainsafe.github.io/lodestar/run/beacon-management/beacon-cli#--rcconfig)).
Because `--rcConfig` is a **global** option, its example renders in
every command's docs, so it uses options common to all of them
(`network` / `logLevel` / `metrics`) rather than a beacon-only option.

Rendering an option `example` that's a config snippet (no shell command)
needed a small docsgen change: `renderOption` renders the `example`
description directly, and `CliOptionDefinition.example` becomes
description-only (`Omit<CliExample, "title" | "command">`). The
`example` is docs-only (yargs ignores it, so `--help` is unchanged) and
the generated `*-cli.md` files are gitignored.

This supersedes the dedicated docs page in #9574 (now closed) — a whole
page was overkill; an inline example is enough.

---
🤖 Generated with AI assistance

---------

Co-authored-by: lodekeeper <lodekeeper@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Nico Flaig <nflaig@protonmail.com>
)

**Motivation**

`PersistentCheckpointStateCache.add()` can orphan a persisted
checkpoint-state file on disk.

**Description**

`add()` only carried `persistedKey` forward when the existing cache item
was *persisted*. But an in-memory item can also hold a `persistedKey` —
`getStateOrReload()` sets `{type: inMemory, state, persistedKey}` when
it faults a persisted state back into memory. When `add()` was then
called for that same checkpoint, it fell into the `else` branch and
dropped the `persistedKey`.

Since persisted files are deleted by the *stored* key only
(`isPersistedCacheItem(cacheItem) ? cacheItem.value :
cacheItem.persistedKey`), dropping it leaves the on-disk copy untracked
— so a later prune/finalize never removes it and it stays orphaned on
disk until the next restart (`init()` wipes all on-disk keys).

Trigger sequence:

1. A checkpoint state is persisted → `{persisted, value: key}`, file
written.
2. `getStateOrReload()` reloads it → `{inMemory, state, persistedKey}`.
3. `add()` is called for the same checkpoint → `else` branch →
`{inMemory, state}` (key dropped).
4. The epoch is pruned/finalized → cleanup finds no `persistedKey` → the
file is left behind.

The fix preserves an existing in-memory item's `persistedKey` too:

```ts
const persistedKey = cacheItem && (isPersistedCacheItem(cacheItem) ? cacheItem.value : cacheItem.persistedKey);
```

**Testing**

Added a regression test in `persistentCheckpointsCache.test.ts`
(`persist → reload → re-add → finalize` asserts the on-disk file is
removed, not orphaned). Confirmed it **fails on the pre-fix code**
(`expected true to be false` — file orphaned) and **passes with the
fix**. Full `PersistentCheckpointStateCache` suite (24 tests) green;
`biome check` and `tsgo` type-check clean.

**AI Assistance Disclosure**

- [x] I have read the [contributor
guidelines](https://github.com/ChainSafe/lodestar/blob/unstable/CONTRIBUTING.md#ai-assistance-notice)
and disclosed my usage of AI below.

The root-cause analysis, the fix, and the regression test were developed
with AI assistance (Claude Code), then reviewed and verified by me —
red→green confirmed, full suite green, lint + type-check clean.
**Motivation**

- we still collect metrics of removed sync chains, which may cause OOM,
see #9567

**Description**

- only collect metrics of active synced chains

Closes #9567

**AI Assistance Disclosure**

- do this with Claude

---------

Co-authored-by: Tuyen Nguyen <twoeths@users.noreply.github.com>
### Motivation

Follow-up to #9571. The `submitted` map in `ProposerPreferencesService`
tracks which preferences were sent per epoch but was never pruned, so it
grows by one entry each epoch for the lifetime of the process. Raised in
review by @twoeths.

### Description

Drop tracking for past epochs at the start of each task run; only
`currentEpoch` and `currentEpoch + 1` are ever processed.
- Update `on_execution_payload` anchor to
`on_execution_payload_envelope`
  (renamed in consensus-specs#5249).
- Replace the dangling `is_supporting_vote` reference with
`get_supported_node`
**Motivation**

Remove `SeenProposerPreferences` cache as `ProposerPreferencesPool` can
be used instead.

**Description**

Removes `SeenProposerPreferences` and uses `ProposerPreferencesPool`
instead.

Inside `validateGossipProposerPreferences`, preference addition to
`SeenProposerPreferences` is completely removed, as if we replaced it
with `ProposerPreferencesPool` addition that would make a duplicate.

Part of #9379
**Motivation**

We want produced blocks to carry Lodestar / EL client info when
validators leave enough room in graffiti, without taking over user
supplied graffiti. This keeps the default useful for client
diversity/debugging while still allowing node operators to opt out.

**Description**

When a validator supplies graffiti, the beacon node appends the richest
client info suffix that fits, always separated by a space:

- full EL+CL watermark, e.g. ` BU9b0eLS80c2`
- EL+CL client codes, e.g. ` BULS`
- CL client code, e.g. ` LS`

If none of these fit, the user graffiti is kept unchanged. The feature
is enabled by default, use `--graffitiAppend false` to disable it.
`--private` suppresses client info as before.

---------

Co-authored-by: Lodekeeper <258435968+lodekeeper@users.noreply.github.com>
Co-authored-by: matthewkeil <me@matthewkeil.com>
**Motivation**

There is a possibility of a race condition occurring between 2
preferences submissions. This can be fixed by adding another check of
preference presence for a validator in question after the bls signature
verification.

**Description**

This PR introduces a second preference presence check to prevent the
race condition.
Also, it moves the preference addition to the ProposerPreferencePool
inside the `validateGossipProposerPreferences` - this tie seems cleaner,
and it is also a pattern already applied in
`validateGossipAggregateAndProof`.

Closes #9612
`protoArray.isDescendant` was missing the gloas `is_ancestor PENDING`
short-circuit, causing the FCR to undercount attestation support.

Depends on #9541

Closes #9539

---------

Co-authored-by: Nazar Hussain <nazarhussain@gmail.com>
…vote (#9597)

- closes ethereum-bounty/lodestar#4

---------

Co-authored-by: Nazar Hussain <nazarhussain@gmail.com>
## Summary

The fast confirmation rule (FCR) recomputed the **total active balance**
on every `is_one_confirmed` evaluation. That value is invariant per
balance source within a single FCR run, but it was read ~3× per
evaluation (proposer score, committee weight, adversarial weight) across
every block of the epoch-boundary chain walk. For a state-backed balance
source each call also **allocates and scans the full validator set**
(`getEffectiveBalanceIncrementsZeroInactive`).

This memoizes it on the per-run cache, keyed by the balance source's
state/balances reference, so the full-set work runs **once per source
per run** instead of once per evaluation.

## Impact / how it was found

Observed on a mainnet node: at an epoch boundary the FCR blocked the
Node.js **event loop for ~11s** (zero gossip/attestation/block
processing during the window), seen in logs as ~56 serial `Fast
confirmation one-confirmed evaluation` lines ~150–300ms apart. The FCR
runs on the chain thread (`onClockSlot → forkChoice.updateTime →
runFastConfirmationRules`), so this delayed block import and attestation
processing.

Root cause: at an epoch boundary the FCR evaluates dozens of blocks (the
`isConfirmedChainSafe` chain walk **plus** the descendant search), and
each evaluation recomputed the full-validator-set total active balance
several times.

## Perf analysis

**Deterministic call-count (the regression guard)** — number of full-set
`getEffectiveBalanceIncrementsZeroInactive` computations for a 32-block
chain walk:

| | computations |
|---|---|
| before | **96** (≈3 × per evaluation × 32 blocks) |
| after | **1** (memoized per balance source per run) |

A 96× reduction for a single 32-block walk; an epoch-boundary run (~56
evaluations) drops from ~168 full-set computations to 1 per source.

**Benchmark** (`runFastConfirmationRules`, epoch-boundary path;
`test/perf/forkChoice/fastConfirmation.test.ts`):

| case | ms/op |
|---|---|
| vc 100k, bc 96 | 4.6 ms |
| vc 600k, bc 96 | 32 ms |
| vc 1M, bc 96 | 54 ms |
| vc 600k, bc 320 | 34 ms |
| vc 100k, bc 96, eq 1000 | 1.27 s (equivocation-path coverage) |

(The harness uses a flat-balances stub, so its absolute cost is far
below production — where each `getTotalActiveBalance` call also
allocates + tree-scans the validator set — but it captures the
per-evaluation redundancy the fix removes.)

## Changes

- `fastConfirmation/utils.ts`: `getTotalActiveBalance` reads/writes a
per-run memo; threaded `cache` through
`estimateCommitteeWeightBetweenSlots` / `computeProposerScore`; routed
the FFG-justification helpers
(`computeHonestFfgSupportForCurrentTarget`,
`willNoConflictingCheckpointBeJustified`,
`willCurrentTargetBeJustified`) through it too.
- `fastConfirmation/types.ts` + `data.ts`: `totalActiveBalanceByKey`
cache field + init.
- `test/unit/.../fastConfirmation.test.ts`: two deterministic
memoization regression tests (chain walk + current-target
justification).
- `test/perf/.../fastConfirmation.test.ts` + `util.ts`: benchmark
exercises the epoch-boundary path; lightweight stub state (reusing the
unit-test `makeState`) so the FFG path is reachable.

## Testing

- `check-types` ✅, biome lint ✅
- fork-choice unit suite ✅ 184/184 (incl. the 2 new memoization tests)
- perf benchmark ✅ 5/5

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
**Motivation**

Implement `POST /eth/v1/beacon/states/{state_id}/builders` added to the
beacon-api spec in ethereum/beacon-APIs#614.

**Description**

- add `getStateBuilders` route, filterable by builder ids (hex encoded
public key or builder index) and statuses (`pending`, `active`,
`exited`)
- add `getBuildersLength()` to the beacon state view to enumerate the
builder registry
- returns 400 if the requested state is prior to gloas, ids that do not
match a known builder are skipped without error as per spec
- builder pubkey lookups are a linear scan over the registry, can
consider a builder pubkey cache if this becomes a performance issue

---------

Co-authored-by: Lodekeeper <258435968+lodekeeper@users.noreply.github.com>
Co-authored-by: lodekeeper <lodekeeper@users.noreply.github.com>
**Motivation**

There has been some recent updates to the era spec in
e2store-format-specs , This PR make sure the lodestar era implementation
adheres to the recent changes. Pls see
eth-clients/e2store-format-specs@247cb54
for more details.


**AI Assistance Disclosure**

- [x] External Contributors: I have read the [contributor
guidelines](https://github.com/ChainSafe/lodestar/blob/unstable/CONTRIBUTING.md#ai-assistance-notice)
and disclosed my usage of AI below.

<!-- Insert any AI assistance disclosure here -->
I have not used any AI for the commit , as the commit is fairly straight
forward
…oas (#9592)

**Motivation**

Implements the spec change from
ethereum/beacon-APIs#621. Gloas blocks no longer
have block number info and there are edge cases where serving the parent
block number becomes unnecessarily complex. ELs can get the parent block
number from the parent block hash.

**Description**

- remove `parentBlockNumber` from the gloas `SSEPayloadAttributes` type
- only set `parent_block_number` on the `payload_attributes` event
pre-gloas, the event is unchanged for those forks
- removes the fork choice lookup on the gloas path which could throw if
the parent block was not found

The `payload_attributes` example in the oapi spec test data is kept
pre-gloas until we bump the pinned spec version to a release that
includes the updated example.
…ock (#9589)

As per
ethereum/beacon-APIs#612 (comment)
> I am beginning to feel more strongly about using 204 here, on
glamsterdam-devnet-6, there are a lot of missed blocks right now and
lodestar is emitting this as warnings (our standard request handler), I
don't wanna special case this api, and this is quite a frequent
error/warning being emitted. Today, this was flagged incorrectly by AI
as a potential CL issue to me but it's perfectly normal behavior, so
let's use a status code that indicates that, 404 does not.

the spec has not settled yet but to avoid noise in the logs we should
use 204 for now. Can always revert back to 404 if that's what we decide
to go with on the spec side.
Aligns our implementation with
ethereum/beacon-APIs#608
- endpoint moved `/eth/v1/beacon/pool/proposer_preferences` -->
`/eth/v1/validator/proposer_preferences`
- drop the `GET /eth/v1/beacon/pool/proposer_preferences` endpoint, we
can reconsider adding a `/lodestar` endpoint for this if it would be
useful for debugging or propose it to the spec, but for now I don't
think it's that useful to have
- list capped at `(MIN_SEED_LOOKAHEAD + 1) * SLOTS_PER_EPOCH` per spec
- derive `Eth-Consensus-Version` header and event `version` from
proposal slot
…oad (#9588)

We currently only cast payload attestations for canonical blocks at slot


https://github.com/ChainSafe/lodestar/blob/51a1c44b27069d392acdbd2c09a838da9b336eba/packages/beacon-node/src/api/impl/validator/index.ts#L1113-L1114

other approaches like #9453 do
not work currently as outlined
> this approach is incorrect, we cannot vote for any block (on any
branch), it has to match the head views shuffling/dependent root,
otherwise the PTC attestation we produce is invalid as we are not part
of the committee on that other branch

but our current code has a flaw


https://github.com/ChainSafe/lodestar/blob/51a1c44b27069d392acdbd2c09a838da9b336eba/packages/validator/src/services/ptc.ts#L62-L65

`waitForExecutionPayloadAvailableSlot` resolves for **any** payload we
receive for `slot` even if it's not on our canonical branch, this would
cause us to call `producePayloadAttestationData` too early and cast
`payload_present/blob_data_available = false` for the canonical block
before that block's own payload had arrived.


This PR makes sure that we **only** cast payload attestations early if
the `execution_payload_available` is emitted for the payload matching
our canonical head root.
**Motivation**

- client hardening

**Description**

- Add `getAllowedTopics` function which enumerates all valid topics ever
accepted by this node. This includes all known forks, all attestation
subnets, and all data column subnets.
- Configure gossipsub `allowedTopics` in gossipsub construction

**AI Assistance Disclosure**

- claude assistance
lodekeeper and others added 16 commits July 9, 2026 09:13
…p validation (#9624)

## Problem

`validateExecutionPayloadBid` (Gloas execution payload bid gossip
validation) does not implement the spec's `[REJECT] bid.builder_index <
len(state.builders)` bounds check. It looks up the builder inside a
`try/catch` meant to turn an out-of-range index into a `GossipReject`:

```ts
let builder: gloas.Builder;
try {
  builder = state.getBuilder(bid.builderIndex);
} catch {
  throw new ExecutionPayloadBidError(GossipAction.REJECT, {code: BUILDER_NOT_ELIGIBLE, ...});
}
if (!isActiveBuilder(builder, state.finalizedCheckpoint.epoch)) { ... }
```

But `state.getBuilder(i)` returns a **lazy** SSZ view
(`builders.getReadonly(i)`) that is not bounds-checked eagerly. An
out-of-range `builder_index` therefore does **not** throw at
`getBuilder` — it throws `LeafNode has no right node` later, on deferred
field access inside `isActiveBuilder` (`builder.depositEpoch`), which is
outside the `try/catch`. The net effect is an **uncaught error** on the
gossip validation path instead of a clean `REJECT`.

## Fix

Add an explicit `bid.builder_index < len(state.builders)` bounds check
up front using the existing `state.getBuildersLength()`, and drop the
now-ineffective `try/catch`.

## Testing

Found by running the consensus-specs
[#5294](ethereum/consensus-specs#5294) Gloas
networking reference tests (spec `v1.7.0-alpha.12`). The
`gossip_execution_payload_bid__reject_builder_index_out_of_range` case
now returns `REJECT` (previously an uncaught throw), with no regression
across the rest of the `gossip_execution_payload_bid` suite (minimal +
mainnet presets).

> Note: these Gloas gossip validators currently have no unit-test
coverage on `unstable`; the reftest suite (wired up in #9372) is their
canonical coverage.

🤖 Generated with AI assistance

Co-authored-by: lodekeeper <lodekeeper@users.noreply.github.com>
…ation (#9627)

## Problem

The Gloas execution payload bid gossip validation checked the slot with
an exact match and **no** `MAXIMUM_GOSSIP_CLOCK_DISPARITY` allowance:

```ts
const currentSlot = chain.clock.currentSlot;
if (bid.slot !== currentSlot && bid.slot !== currentSlot + 1) {
  throw new ExecutionPayloadBidError(GossipAction.IGNORE, {code: INVALID_SLOT, ...});
}
```

Every other gossip slot check in Lodestar applies the disparity
allowance (see `block.ts`, `blobSidecar.ts`, `attestation.ts`). Without
it, a bid that arrives within `MAXIMUM_GOSSIP_CLOCK_DISPARITY` of a slot
boundary is wrongly `IGNORE`d.

## Fix

Implement the spec's `is_within_slot_range(state, bid.slot, 1,
current_time_ms)` semantics: the current time must fall within
`[start(bid.slot - 1), start(bid.slot + 1)]`, extended by `±
MAXIMUM_GOSSIP_CLOCK_DISPARITY` on both ends — i.e. the clock is in slot
`bid.slot - 1` (bid.slot is the *next* slot) or `bid.slot` (the
*current* slot).

Implemented with `chain.clock.msFromSlot(...)` because the disparity
boundary is **sub-slot**: the passing/failing reftests are exactly **1
ms** apart at the edge (e.g. `95500` valid vs `95499` ignore; `108500`
valid vs `108501` ignore). The slot-granular helpers
(`currentSlotWithGossipDisparity` / `slotWithFutureTolerance`) floor to
integer slots and produce identical values on either side of that 1 ms
boundary, so they cannot express this check — a millisecond-precise
comparison is required.

## Testing

Found by running the consensus-specs
[#5294](ethereum/consensus-specs#5294) Gloas
networking reference tests (spec `v1.7.0-alpha.12`). All five
`gossip_execution_payload_bid` slot cases now pass (minimal + mainnet):

- `valid_slot_at_lower_disparity`, `valid_slot_at_upper_disparity` — now
`valid` (were `ignore`)
- `ignore_slot_outside_lower_disparity`,
`ignore_slot_outside_upper_disparity`, `ignore_slot_too_far_future` —
still `ignore` (no regression on the 1 ms-outside boundary)

> Note: these Gloas gossip validators currently have no unit-test
coverage on `unstable`; the reftest suite (wired up in #9372) is their
canonical coverage.

🤖 Generated with AI assistance

---------

Co-authored-by: lodekeeper <lodekeeper@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
**Motivation**

The
[spec](https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.10/specs/phase0/p2p-interface.md?plain=1#L486-L498)
requires the gossipsub `message-id` to be 20 bytes and clients must
reject other sizes.
In our previous testing, we found that lodestar already rejected `msgId`
longer than 20 bytes, which is good according to the spec. However, IDs
shorter than 20 bytes were accepted. Since `Buffer.set()` only
overwrites the prefix, the remaining bytes were reused from the previous
conversion.

This allowed malformed control-message IDs to alias to stale message
IDs. For example:

- peer A sends or triggers conversion of a valid 20-byte message ID
- peer B sends an empty `IWANT` message ID
- Lodestar converts the empty ID to the stale previous ID and returns
the cached gossip message

I run a PoC and confirmed this in the kurtosis testnet. I further
investigated its impact, but it seems that there is no security impact
since the gossip data is public. So this is just a correctness bug. This
PR adds length check in `msgIdToStrFn()` following similar style of
[`toRootHex`](https://github.com/ChainSafe/lodestar/blob/4ac6f45830a47b5e895e81b50753a5f3a417f5d3/packages/utils/src/bytes/nodejs.ts#L17-L20).


**Description**

- Reject non-20 bytes `msgId`.
- Add unit test for `msgId` `=`, `>`, `<` 20 bytes .

After the code changes, I run the tests and all passed:

- pnpm build
- pnpm lint
- pnpm check-types
- pnpm test:unit


**AI Assistance Disclosure**

- [x] External Contributors: I have read the [contributor
guidelines](https://github.com/ChainSafe/lodestar/blob/unstable/CONTRIBUTING.md#ai-assistance-notice)
and disclosed my usage of AI below.

I asked claude-opus-4-8 to generate the test and I reviewed the added
test, I think it suffice.

My first lodestar PR, would love some feedback :) Thanks for your
attention!

Co-authored-by: Nico Flaig <nflaig@protonmail.com>
## Motivation

The peer manager performs the network's most safety-critical
housekeeping —
scoring, pruning, discovery, subnet balancing — but much of it was
poorly
observable: the heaviest heartbeat phases were hidden inside a single
`heartbeat_duration` timer, and the algorithm's *decisions* (score-state
changes, prune reasons, relevance rejections) weren't recorded at all.

This PR adds metrics and a dashboard to monitor and debug the peer
manager in
production. The metrics fall into two dimensions:

- **Performance** — where heartbeat time goes, normalized per peer.
- **Behavioral / health** — the scoring state machine, prune reasons,
and the
  invariants the algorithm is supposed to maintain.

(These also make it possible to compare the peer manager against the
in-progress
native [Zig port](ChainSafe/lodestar-z#308), but
the
metrics stand on their own as long-lived observability.)

## Metrics added

**Performance**
| Metric | Notes |
|---|---|
| `lodestar_peer_manager_prioritize_peers_seconds` | Isolates the core
selection/pruning phase (was buried in `heartbeat_duration`) |
| `lodestar_peer_score_update_seconds` | Score decay + prune over all
peers |
| `lodestar_peer_manager_peers_evaluated_count` | Denominator for
time-per-peer on `prioritize_peers_seconds` |

**Behavioral / health**
| Metric | Notes |
|---|---|
| `lodestar_peer_score_state_transitions_total` `{from,to}` |
Healthy/Disconnected/Banned crossings — score-engine churn |
| `lodestar_peer_relevance_check_total` `{result}` |
`assertPeerRelevance` outcomes by reason (previously only logged) |
| `lodestar_peer_manager_peers_pruned_total` `{reason}` | Completes
prune accounting — includes top-of-heartbeat bad-score disconnects that
`peersRequestedToDisconnect` missed |
| `lodestar_peer_manager_peers_per_active_subnet` `{type}` |
Min-peers-per-subnet invariant (p10 reveals under-subscription) |
| `lodestar_peer_manager_outbound_peers_ratio` | Outbound floor
invariant (~10%, #2215) |
| `lodestar_peer_manager_score_map_size` | Score store entry count
(retention check) |
| `lodestar_peer_manager_connected_peers_map_size` | Connected-peers
entry count — leak signal vs `libp2p_peers` |

## Dashboard

Adds a **Peer manager internals** row to the networking dashboard (9
panels):

- **Duration panels** show **p95 per instance** with a table legend
(mean/max/last) — per-node latency is the point.
- **Breakdown/health panels** are **aggregated across the fleet** by
their
meaningful label (transition, reason, result, subnet type); drill into a
  single node via the `Filters` variable.
- Every panel carries help text (`ⓘ`) with the metric name and how to
read it.

Purely additive — no existing panels were modified.

<img width="1182" height="758" alt="image"
src="https://github.com/user-attachments/assets/ee762f96-0715-4bf2-b517-465f2c9bbaf1"
/>

## Notes / scope decisions

- **Entries, not bytes** for the map-size gauges: V8 `Map` overhead
isn't a
  meaningful byte comparison; total memory is left to process-level
  instrumentation.
- **Dial success ratio** is a dashboard/PromQL expression over existing
  `dial_time{status}` / `dial_attempts` — no new series.
- All metrics are guarded with optional chaining — no-op when metrics
are disabled.

## Testing

- `pnpm lint` — clean
- `pnpm check-types` (beacon-node) — 0 errors
- `pnpm vitest run --project unit test/unit/network/peers/` — 51/51
passing

## AI disclosure

This PR was authored with AI assistance (Claude Code). All changes were
reviewed by the author.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
**Motivation**

Hello developers,

In our testing, we found that a signature-valid ENR carrying a malformed
`ip` field reaches `enr.getLocationMultiaddr()`, which throws
`InvalidMultiaddrError`. On the discv5 request-timeout / partial-NODES
path this throw is not handled, so it escapes as an uncaught exception
inside the worker thread.

```
attacker answers lodestar FINDNODE with NODES(total=2, enrs=[badENR]) and withholds packet 2
  → discv5 buffers the partial response, request timer fires (rpcFailure)
  → "discovered" event emitted for the buffered ENR
  → onDiscovered(enr)                       worker.ts:74   (no try/catch)
  → enrRelevance(enr, config, clock)        utils.ts:15-21 (no try/catch)
  → enr.getLocationMultiaddr(ENRKey.tcp)    throws InvalidMultiaddrError
```

This is the logs of lodestar from local kurtosis testnet. Luckily, the
worker runtime (@chainsafe/threads) catches it, so it is not fatal and
chain health continues.

```
[network] UncaughtException: IPv4 address was incorrect length
InvalidMultiaddrError: IPv4 address was incorrect length
    at Object.ip4ToString [as bytesToValue] (file:///usr/app/node_modules/.pnpm/@multiformats+multiaddr@13.0.1/node_modules/@multiformats/multiaddr/dist/src/utils.js:138:15)
    at ENR.getLocationMultiaddr (file:///usr/app/node_modules/.pnpm/@ChainSafe+enr@6.0.1/node_modules/@chainsafe/enr/lib/enr.js:283:92)
    at ENR.getLocationMultiaddr (file:///usr/app/node_modules/.pnpm/@ChainSafe+enr@6.0.1/node_modules/@chainsafe/enr/lib/enr.js:264:25)
    at enrRelevance (file:///usr/app/packages/beacon-node/lib/network/discv5/utils.js:14:30)
    at Discv5.onDiscovered (file:///usr/app/packages/beacon-node/lib/network/discv5/worker.js:62:20)
    at Discv5.emit (node:events:509:28)
    at Discv5.discovered (file:///usr/app/node_modules/.pnpm/@ChainSafe+discv5@12.0.1/node_modules/@chainsafe/discv5/lib/service/service.js:582:18)
    at SessionService.rpcFailure (file:///usr/app/node_modules/.pnpm/@ChainSafe+discv5@12.0.1/node_modules/@chainsafe/discv5/lib/service/service.js:885:26)
    at SessionService.emit (node:events:509:28)
    at SessionService.failRequest (file:///usr/app/node_modules/.pnpm/@ChainSafe+discv5@12.0.1/node_modules/@chainsafe/discv5/lib/session/service.js:640:14) - IPv4 address was incorrect length
```

**Description**

- Wrap the `getLocationMultiaddr` calls in a try/catch so a
malformed/unconvertible ENR location returns `ENRRelevance.no_transport`
- Added a unit test to confirm the fix works

Notes
- I think use `no_transport` to explain can not be converted to a
multiaddr is enough
- Other call site (discover.ts onDiscoveredENR) of
`getLocationMultiaddr` is guarded.

**AI Assistance Disclosure**

- [x] External Contributors: I have read the [contributor
guidelines](https://github.com/ChainSafe/lodestar/blob/unstable/CONTRIBUTING.md#ai-assistance-notice)
and disclosed my usage of AI below.

I used claude-opus-4-7 to add the unit test and I checked it.
…9629)

**Motivation**

Aligns `producePayloadAttestationData` with
[beacon-APIs#626](ethereum/beacon-APIs#626)
(merged): `slot` moves from a path parameter to a query parameter,
consistent with the other "produce data to sign for a slot" validator
endpoints — `produceAttestationData` and
`produceSyncCommitteeContribution` — which all take `slot` as a required
query param.

**Description**

- `GET /eth/v1/validator/payload_attestation_data/{slot}` → `GET
/eth/v1/validator/payload_attestation_data?slot={slot}`
- Only the request wire encoding changes (path → query). The endpoint
args, the impl handler (`{slot}`), and the response are unchanged.
- The `204`-on-no-block response already landed in #9589.

Closes #9628

**oapiSpec conformance**

The pinned beacon-APIs spec (`v5.0.0-alpha.2`) predates beacon-APIs#626
and still defines `slot` as a path param, so
`producePayloadAttestationData` is temporarily added to
`ignoredOperations` in `oapiSpec.test.ts` to avoid a false mismatch.
**Remove that ignore once the pin is bumped to a release that includes
#626.**

🤖 Generated with AI assistance

---------

Co-authored-by: lodekeeper <lodekeeper@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…9633)

**Motivation**

The Gloas `proposer_preferences` gossip validation `[IGNORE]
preferences.proposal_slot has not already passed` compares
`proposal_slot` against the exact `current_slot`. A preference whose
`proposal_slot` falls within `MAXIMUM_GOSSIP_CLOCK_DISPARITY` of the
next-slot boundary is accepted as `valid` when it should be `ignore`d.
The boundary is sub-slot (a 1&nbsp;ms shift flips the outcome), so the
integer `current_slot` comparison cannot express it.

This is the same class of gap fixed for the `execution_payload_bid` slot
check in #9627.

**Description**

Use `slotWithFutureTolerance(MAXIMUM_GOSSIP_CLOCK_DISPARITY / 1000)` as
the `current_slot` reference, matching the existing convention in
`verifyPropagationSlotRange` (`attestation.ts`).

`MAXIMUM_GOSSIP_CLOCK_DISPARITY` is a global gossip allowance that
applies even where the
[spec](https://github.com/ethereum/consensus-specs/blob/master/specs/gloas/p2p-interface.md)
states the check as a plain `preferences.proposal_slot > current_slot`
(as confirmed for the analogous bid check).

**Verification**

Ran the Gloas networking gossip reference tests from [consensus-specs PR
#5294](ethereum/consensus-specs#5294):
- `gossip_proposer_preferences__ignore_slot_outside_disparity` → now
`ignore` (was `valid`)
- `gossip_proposer_preferences__valid_slot_at_disparity_edge` → still
`valid`
- `gossip_proposer_preferences__valid_at_lookahead_upper_edge` → still
`valid`

Full `gossip_proposer_preferences`: **13/13 minimal + 13/13 mainnet**,
no regressions in the rest of the Gloas networking suite.

🤖 Generated with AI assistance

---------

Co-authored-by: lodekeeper <lodekeeper@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Summary

Small, low-risk cleanup of the fork choice / fast confirmation
dashboard. No new
metrics or code changes — dashboard JSON only.

## Changes

- **Removed node-specific dead config**: the "updateHead() errors" panel
had a
color override matcher hardcoded to `{group="beta",
instance="contabo-13", …}`.
Reduced to the plain metric name so it is no longer tied to a specific
node.
- **Legend rename**: `Fast Confirmation Duration` → `Duration` on the
Fast
  Confirmation Duration avg panel.

## Testing

- Dashboard JSON validates (`python -m json.tool`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
we should follow the spec, thanks Sam for reporting
## Summary

Updates `@chainsafe/libp2p-quic` from 2.0.1 to 2.1.1, including the
platform-native package lock entries.

This consumes the automated 2.1.1 release published after the npm
provenance publishing regression was corrected upstream.

## Validation

- `pnpm lint`
- `pnpm test:unit`
- `pnpm vitest run --project unit
packages/beacon-node/test/unit/network/libp2p/createNodeJsLibp2p.test.ts`
- `pnpm check-types` (fails on existing validator errors outside this
diff: `proposerPreferences.ts` and `ptc.test.ts`)

> This PR was written with Codex assistance.
Previously in #9565 we added dependent root check, but didn't remove the
proposer index check.
## Summary

- update `@chainsafe/ssz` from `^1.4.0` to `^1.6.2` across workspace
dependencies and the `spec-test-util` peer dependency
- align `@chainsafe/persistent-merkle-tree` from `^1.2.5` to `^1.3.0`
- align `@chainsafe/as-sha256` from `^1.2.0` to `^1.2.4`
- refresh the pnpm lockfile and remove the duplicate older transitive
versions

## Impact

Lodestar now uses the latest published SSZ release with a single version
of its core merkle-tree and SHA-256 dependencies. This avoids duplicate
package instances and their associated bundle-size, memory, and
cross-version object compatibility risks.

This is a dependency-only update with no application code changes.

## Validation

- `pnpm lint`
- `pnpm check-types`
- `pnpm test:unit` (308 files passed, 1 skipped; 3,228 tests passed)

> This PR was written primarily by Codex.
**Motivation**

- discussion in discord

**Description**

- Add a line to AGENTS.md with some very basic communication style rules
to avoid verbosity.
## Problem

A far-behind node (e.g. resumed from a stale DB, hundreds of epochs
behind) that is polled for a beacon state via REST can trigger a `regen`
that needs to walk back further than `SLOTS_PER_HISTORICAL_ROOT` (8192)
slots. Regen can't get a block root more than 8192 slots in the past, so
the node **wedges**: the request grinds on the main thread, 0 blocks get
imported, and the only recovery is detaching the poller or
checkpoint-syncing fresh.

## Fix

Guard the shared `getStateResponseWithRegen` entry point with the
existing `notWhileSyncing` check, so REST state requests return **503
`NodeIsSyncing`** while the node is behind — before regen is ever
reached. This matches how the validator duties endpoints already behave
(and other clients).

- Extract `notWhileSyncing` + `SYNC_TOLERANCE_EPOCHS` from the validator
API into shared `api/impl/utils.ts` and reuse them (no behavior change
for validator endpoints).
- Thread `sync` into `getStateResponseWithRegen` and its callers (beacon
`state`, `proof`, `debug`, `lodestar`, validator duties). It's the
single choke point for all REST state-serving endpoints, so guarding it
there covers all of them.
- `head`/`finalized`/`justified`/`genesis` are exempt from the guard —
they resolve to already-available states (no regen), so they keep
serving during sync (observability, dashboards, VC checks).
- Added unit tests: `getStateResponseWithRegen` throws `NodeIsSyncing`
for a regen-capable slot while syncing, and still serves the four
always-available state ids.

## Relation to #9634

This is the `notWhileSyncing` alternative to #9634, per @nflaig's
review. It's simpler (drops the custom
`RegenError`/`SLOT_TOO_FAR_FROM_BLOCK` machinery) and covers all REST
state-serving endpoints, not just the far-slot regen case. If this is
preferred, #9634 can be closed.

## Behavior note

While the node is more than `SYNC_TOLERANCE_EPOCHS` behind, this returns
503 for regen-capable state lookups (arbitrary slots / state roots).
`head`/`finalized`/`justified`/`genesis` keep serving (they never
trigger regen). This is broader than the far-slot-only guard in #9634
but consistent with the duties endpoints and other clients.

🤖 Generated with AI assistance

---------

Co-authored-by: lodekeeper <lodekeeper@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
**Motivation**

The pre-gloas builder circuit breaker (`updateBuilderStatus`) counts
missed slots via fork choice and disables the external builder flow when
the chain is unhealthy. This mechanism does not apply to gloas:

- `produceBlockV4` always prefers a builder bid from the bid pool
without any chain health check
- post-gloas the beacon block is produced by the proposer regardless of
bid source, so missed blocks are no longer a useful builder health
signal. The failure mode to protect against is unrevealed payloads
- the missed slot counting via `getSlotsPresent` also breaks post-gloas
since each gloas block inserts multiple protoArray nodes
(PENDING/EMPTY/FULL variants)

**Description**

- add `BuilderCircuitBreaker` which counts blocks with unrevealed
payloads (no FULL variant in fork choice) within the fault inspection
window and ignores builder bids in `produceBlockV4` while the breaker is
active
- the fault budget is scaled by blocks present in the window so sparse
windows still trigger on high non-reveal rates
- the current slot is excluded from the window as its payload reveal may
still be in flight
- blocks on all branches are counted, this keeps the result independent
of which branch is head when the breaker is evaluated and errs toward
local building when forks are frequent
- the local block is always built, meaning fallback to the self-built
block requires no extra work
- the breaker will apply uniformly once bids are also accepted via the
builder api (#9594), bid value comparison vs. the local payload is
handled in #9595
- update breaker status during `prepareNextSlot` and lazily at block
production time, the pre-gloas builder status update is now gated to
pre-gloas forks
- the existing `--builder.faultInspectionWindow` and
`--builder.allowedFaults` flags configure the breaker on every fork,
defaults are randomized per boot as before
- add metrics
- `beacon_builder_circuit_breaker_active`: whether the breaker is
currently active (1) causing builder bids to be ignored
- `beacon_builder_circuit_breaker_faults`: count of blocks with
unrevealed payloads in the fault inspection window
- `beacon_builder_circuit_breaker_blocks_present`: count of blocks
present in the fault inspection window
- `beacon_builder_circuit_breaker_payloads_revealed`: count of blocks
with revealed payloads in the fault inspection window
@matthewkeil
matthewkeil requested a review from a team as a code owner July 17, 2026 17:21
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c4d9d78d33

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +686 to +690
if (node.slot < fromSlot) {
if (isGloasBlock(node) && node.payloadStatus === PayloadStatus.FULL) {
continue;
}
break;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Scan all proto-array nodes before stopping

When an old Gloas block is imported after newer blocks, its PENDING node is appended at the end of nodes, so this break exits before examining earlier in-window nodes. This occurs when resolving a current fork whose ancestors are imported after the existing canonical chain. The resulting incomplete counts can incorrectly activate or deactivate the builder circuit breaker, causing block production to select the wrong bid source. Skip below-window nodes instead of assuming PENDING nodes are ordered by slot.

Useful? React with 👍 / 👎.

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 comment might be valid, but it's not relevant for the release and related to gloas circuit breaker, but also there, doesn't seem that urgent to address, will look into it separately

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.

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Performance Report

✔️ no performance regression detected

Full benchmark results
Benchmark suite Current: 6c3d0ad Previous: null Ratio
getPubkeys - index2pubkey - req 1000 vs - 250000 vc 1.1748 ms/op
getPubkeys - validatorsArr - req 1000 vs - 250000 vc 39.402 us/op
BLS verify - blst 666.20 us/op
BLS verifyMultipleSignatures 3 - blst 1.3787 ms/op
BLS verifyMultipleSignatures 8 - blst 2.1931 ms/op
BLS verifyMultipleSignatures 32 - blst 6.9055 ms/op
BLS verifyMultipleSignatures 64 - blst 14.060 ms/op
BLS verifyMultipleSignatures 128 - blst 26.292 ms/op
BLS deserializing 10000 signatures 621.28 ms/op
BLS deserializing 100000 signatures 6.2104 s/op
BLS verifyMultipleSignatures - same message - 3 - blst 742.07 us/op
BLS verifyMultipleSignatures - same message - 8 - blst 867.98 us/op
BLS verifyMultipleSignatures - same message - 32 - blst 1.5053 ms/op
BLS verifyMultipleSignatures - same message - 64 - blst 2.3462 ms/op
BLS verifyMultipleSignatures - same message - 128 - blst 4.0207 ms/op
BLS aggregatePubkeys 32 - blst 17.505 us/op
BLS aggregatePubkeys 128 - blst 61.279 us/op
getSlashingsAndExits - default max 50.804 us/op
getSlashingsAndExits - 2k 380.03 us/op
proposeBlockBody type=full, size=empty 601.08 us/op
isKnown best case - 1 super set check 403.00 ns/op
isKnown normal case - 2 super set checks 408.00 ns/op
isKnown worse case - 16 super set checks 412.00 ns/op
validate api signedAggregateAndProof - struct 1.5523 ms/op
validate gossip signedAggregateAndProof - struct 1.5423 ms/op
batch validate gossip attestation - vc 640000 - chunk 32 109.92 us/op
batch validate gossip attestation - vc 640000 - chunk 64 95.995 us/op
batch validate gossip attestation - vc 640000 - chunk 128 89.295 us/op
batch validate gossip attestation - vc 640000 - chunk 256 82.534 us/op
bytes32 toHexString 531.00 ns/op
bytes32 Buffer.toString(hex) 402.00 ns/op
bytes32 Buffer.toString(hex) from Uint8Array 485.00 ns/op
bytes32 Buffer.toString(hex) + 0x 399.00 ns/op
Return object 10000 times 0.23360 ns/op
Throw Error 10000 times 3.4334 us/op
toHex 96.153 ns/op
Buffer.from 87.761 ns/op
shared Buffer 60.673 ns/op
fastMsgIdFn sha256 / 200 bytes 1.7360 us/op
fastMsgIdFn h32 xxhash / 200 bytes 386.00 ns/op
fastMsgIdFn h64 xxhash / 200 bytes 452.00 ns/op
fastMsgIdFn sha256 / 1000 bytes 5.0090 us/op
fastMsgIdFn h32 xxhash / 1000 bytes 474.00 ns/op
fastMsgIdFn h64 xxhash / 1000 bytes 495.00 ns/op
fastMsgIdFn sha256 / 10000 bytes 42.395 us/op
fastMsgIdFn h32 xxhash / 10000 bytes 1.4840 us/op
fastMsgIdFn h64 xxhash / 10000 bytes 1.0640 us/op
send data - 1000 256B messages 4.2951 ms/op
send data - 1000 512B messages 5.6569 ms/op
send data - 1000 1024B messages 5.8720 ms/op
send data - 1000 1200B messages 6.6539 ms/op
send data - 1000 2048B messages 10.770 ms/op
send data - 1000 4096B messages 70.843 ms/op
send data - 1000 16384B messages 311.93 ms/op
send data - 1000 65536B messages 1.6090 s/op
enrSubnets - fastDeserialize 64 bits 1.0830 us/op
enrSubnets - ssz BitVector 64 bits 501.00 ns/op
enrSubnets - fastDeserialize 4 bits 302.00 ns/op
enrSubnets - ssz BitVector 4 bits 506.00 ns/op
prioritizePeers score -10:0 att 32-0.1 sync 2-0 216.88 us/op
prioritizePeers score 0:0 att 32-0.25 sync 2-0.25 251.03 us/op
prioritizePeers score 0:0 att 32-0.5 sync 2-0.5 348.90 us/op
prioritizePeers score 0:0 att 64-0.75 sync 4-0.75 612.61 us/op
prioritizePeers score 0:0 att 64-1 sync 4-1 722.20 us/op
array of 16000 items push then shift 1.3207 us/op
LinkedList of 16000 items push then shift 7.4420 ns/op
array of 16000 items push then pop 67.312 ns/op
LinkedList of 16000 items push then pop 6.3000 ns/op
array of 24000 items push then shift 1.9579 us/op
LinkedList of 24000 items push then shift 7.2190 ns/op
array of 24000 items push then pop 92.351 ns/op
LinkedList of 24000 items push then pop 6.3480 ns/op
intersect bitArray bitLen 8 4.9480 ns/op
intersect array and set length 8 29.578 ns/op
intersect bitArray bitLen 128 24.351 ns/op
intersect array and set length 128 502.76 ns/op
bitArray.getTrueBitIndexes() bitLen 128 1.3500 us/op
bitArray.getTrueBitIndexes() bitLen 248 1.9800 us/op
bitArray.getTrueBitIndexes() bitLen 512 3.8080 us/op
Full columns - reconstruct all 6 blobs 108.14 us/op
Full columns - reconstruct half of the blobs out of 6 65.136 us/op
Full columns - reconstruct single blob out of 6 36.733 us/op
Half columns - reconstruct all 6 blobs 386.98 ms/op
Half columns - reconstruct half of the blobs out of 6 197.57 ms/op
Half columns - reconstruct single blob out of 6 70.630 ms/op
Set add up to 64 items then delete first 1.6744 us/op
OrderedSet add up to 64 items then delete first 2.5566 us/op
Set add up to 64 items then delete last 1.9302 us/op
OrderedSet add up to 64 items then delete last 2.8377 us/op
Set add up to 64 items then delete middle 1.9197 us/op
OrderedSet add up to 64 items then delete middle 4.3387 us/op
Set add up to 128 items then delete first 3.9253 us/op
OrderedSet add up to 128 items then delete first 5.8737 us/op
Set add up to 128 items then delete last 3.8070 us/op
OrderedSet add up to 128 items then delete last 5.4867 us/op
Set add up to 128 items then delete middle 3.8434 us/op
OrderedSet add up to 128 items then delete middle 11.437 us/op
Set add up to 256 items then delete first 7.8217 us/op
OrderedSet add up to 256 items then delete first 11.888 us/op
Set add up to 256 items then delete last 7.0749 us/op
OrderedSet add up to 256 items then delete last 10.886 us/op
Set add up to 256 items then delete middle 6.9494 us/op
OrderedSet add up to 256 items then delete middle 33.456 us/op
runFastConfirmationRules vc:100000 bc:96 eq:0 4.3901 ms/op
runFastConfirmationRules vc:600000 bc:96 eq:0 33.573 ms/op
runFastConfirmationRules vc:1000000 bc:96 eq:0 55.651 ms/op
runFastConfirmationRules vc:600000 bc:320 eq:0 33.452 ms/op
runFastConfirmationRules vc:100000 bc:96 eq:1000 1.2606 s/op
pass gossip attestations to forkchoice per slot 2.6434 ms/op
forkChoice updateHead vc 100000 bc 64 eq 0 419.88 us/op
forkChoice updateHead vc 600000 bc 64 eq 0 2.7888 ms/op
forkChoice updateHead vc 1000000 bc 64 eq 0 3.9651 ms/op
forkChoice updateHead vc 600000 bc 320 eq 0 2.4133 ms/op
forkChoice updateHead vc 600000 bc 1200 eq 0 2.4608 ms/op
forkChoice updateHead vc 600000 bc 7200 eq 0 2.7673 ms/op
forkChoice updateHead vc 600000 bc 64 eq 1000 2.3676 ms/op
forkChoice updateHead vc 600000 bc 64 eq 10000 2.4584 ms/op
forkChoice updateHead vc 600000 bc 64 eq 300000 6.2219 ms/op
computeDeltas 1400000 validators 0% inactive 11.722 ms/op
computeDeltas 1400000 validators 10% inactive 11.142 ms/op
computeDeltas 1400000 validators 20% inactive 10.442 ms/op
computeDeltas 1400000 validators 50% inactive 8.5310 ms/op
computeDeltas 2100000 validators 0% inactive 17.503 ms/op
computeDeltas 2100000 validators 10% inactive 16.634 ms/op
computeDeltas 2100000 validators 20% inactive 15.676 ms/op
computeDeltas 2100000 validators 50% inactive 10.558 ms/op
altair processAttestation - 250000 vs - 7PWei normalcase 1.9818 ms/op
altair processAttestation - 250000 vs - 7PWei worstcase 2.6691 ms/op
altair processAttestation - setStatus - 1/6 committees join 103.17 us/op
altair processAttestation - setStatus - 1/3 committees join 199.97 us/op
altair processAttestation - setStatus - 1/2 committees join 286.70 us/op
altair processAttestation - setStatus - 2/3 committees join 375.40 us/op
altair processAttestation - setStatus - 4/5 committees join 520.15 us/op
altair processAttestation - setStatus - 100% committees join 608.95 us/op
altair processBlock - 250000 vs - 7PWei normalcase 4.1771 ms/op
altair processBlock - 250000 vs - 7PWei normalcase hashState 19.745 ms/op
altair processBlock - 250000 vs - 7PWei worstcase 21.268 ms/op
altair processBlock - 250000 vs - 7PWei worstcase hashState 40.795 ms/op
phase0 processBlock - 250000 vs - 7PWei normalcase 1.5947 ms/op
phase0 processBlock - 250000 vs - 7PWei worstcase 16.884 ms/op
altair processEth1Data - 250000 vs - 7PWei normalcase 292.14 us/op
getExpectedWithdrawals 250000 eb:1,eth1:1,we:0,wn:0,smpl:16 8.3360 us/op
getExpectedWithdrawals 250000 eb:0.95,eth1:0.1,we:0.05,wn:0,smpl:220 20.347 us/op
getExpectedWithdrawals 250000 eb:0.95,eth1:0.3,we:0.05,wn:0,smpl:43 6.1250 us/op
getExpectedWithdrawals 250000 eb:0.95,eth1:0.7,we:0.05,wn:0,smpl:19 3.8840 us/op
getExpectedWithdrawals 250000 eb:0.1,eth1:0.1,we:0,wn:0,smpl:1021 89.899 us/op
getExpectedWithdrawals 250000 eb:0.03,eth1:0.03,we:0,wn:0,smpl:11778 1.4029 ms/op
getExpectedWithdrawals 250000 eb:0.01,eth1:0.01,we:0,wn:0,smpl:16384 1.8417 ms/op
getExpectedWithdrawals 250000 eb:0,eth1:0,we:0,wn:0,smpl:16384 1.7515 ms/op
getExpectedWithdrawals 250000 eb:0,eth1:0,we:0,wn:0,nocache,smpl:16384 3.5006 ms/op
getExpectedWithdrawals 250000 eb:0,eth1:1,we:0,wn:0,smpl:16384 2.0008 ms/op
getExpectedWithdrawals 250000 eb:0,eth1:1,we:0,wn:0,nocache,smpl:16384 3.8269 ms/op
Tree 40 250000 create 354.62 ms/op
Tree 40 250000 get(125000) 95.469 ns/op
Tree 40 250000 set(125000) 1.0046 us/op
Tree 40 250000 toArray() 14.330 ms/op
Tree 40 250000 iterate all - toArray() + loop 13.881 ms/op
Tree 40 250000 iterate all - get(i) 36.348 ms/op
Array 250000 create 2.2395 ms/op
Array 250000 clone - spread 689.75 us/op
Array 250000 get(125000) 0.46900 ns/op
Array 250000 set(125000) 0.49900 ns/op
Array 250000 iterate all - loop 56.935 us/op
phase0 afterProcessEpoch - 250000 vs - 7PWei 48.986 ms/op
Array.fill - length 1000000 2.2748 ms/op
Array push - length 1000000 7.9955 ms/op
Array.get 0.20726 ns/op
Uint8Array.get 0.24516 ns/op
phase0 beforeProcessEpoch - 250000 vs - 7PWei 14.241 ms/op
altair processEpoch - mainnet_e81889 317.92 ms/op
mainnet_e81889 - altair beforeProcessEpoch 22.170 ms/op
mainnet_e81889 - altair processJustificationAndFinalization 7.2290 us/op
mainnet_e81889 - altair processInactivityUpdates 3.5585 ms/op
mainnet_e81889 - altair processRewardsAndPenalties 17.797 ms/op
mainnet_e81889 - altair processRegistryUpdates 803.00 ns/op
mainnet_e81889 - altair processSlashings 363.00 ns/op
mainnet_e81889 - altair processEth1DataReset 368.00 ns/op
mainnet_e81889 - altair processEffectiveBalanceUpdates 1.6427 ms/op
mainnet_e81889 - altair processSlashingsReset 942.00 ns/op
mainnet_e81889 - altair processRandaoMixesReset 1.6090 us/op
mainnet_e81889 - altair processHistoricalRootsUpdate 376.00 ns/op
mainnet_e81889 - altair processParticipationFlagUpdates 658.00 ns/op
mainnet_e81889 - altair processSyncCommitteeUpdates 344.00 ns/op
mainnet_e81889 - altair afterProcessEpoch 42.148 ms/op
capella processEpoch - mainnet_e217614 984.63 ms/op
mainnet_e217614 - capella beforeProcessEpoch 64.380 ms/op
mainnet_e217614 - capella processJustificationAndFinalization 6.8950 us/op
mainnet_e217614 - capella processInactivityUpdates 12.921 ms/op
mainnet_e217614 - capella processRewardsAndPenalties 99.297 ms/op
mainnet_e217614 - capella processRegistryUpdates 4.8040 us/op
mainnet_e217614 - capella processSlashings 383.00 ns/op
mainnet_e217614 - capella processEth1DataReset 380.00 ns/op
mainnet_e217614 - capella processEffectiveBalanceUpdates 6.2201 ms/op
mainnet_e217614 - capella processSlashingsReset 956.00 ns/op
mainnet_e217614 - capella processRandaoMixesReset 1.6850 us/op
mainnet_e217614 - capella processHistoricalRootsUpdate 378.00 ns/op
mainnet_e217614 - capella processParticipationFlagUpdates 690.00 ns/op
mainnet_e217614 - capella afterProcessEpoch 115.42 ms/op
phase0 processEpoch - mainnet_e58758 381.44 ms/op
mainnet_e58758 - phase0 beforeProcessEpoch 68.608 ms/op
mainnet_e58758 - phase0 processJustificationAndFinalization 7.2760 us/op
mainnet_e58758 - phase0 processRewardsAndPenalties 16.889 ms/op
mainnet_e58758 - phase0 processRegistryUpdates 2.4600 us/op
mainnet_e58758 - phase0 processSlashings 375.00 ns/op
mainnet_e58758 - phase0 processEth1DataReset 363.00 ns/op
mainnet_e58758 - phase0 processEffectiveBalanceUpdates 2.1963 ms/op
mainnet_e58758 - phase0 processSlashingsReset 1.1280 us/op
mainnet_e58758 - phase0 processRandaoMixesReset 1.6440 us/op
mainnet_e58758 - phase0 processHistoricalRootsUpdate 501.00 ns/op
mainnet_e58758 - phase0 processParticipationRecordUpdates 1.4510 us/op
mainnet_e58758 - phase0 afterProcessEpoch 34.264 ms/op
phase0 processEffectiveBalanceUpdates - 250000 normalcase 976.59 us/op
phase0 processEffectiveBalanceUpdates - 250000 worstcase 0.5 1.6757 ms/op
altair processInactivityUpdates - 250000 normalcase 10.983 ms/op
altair processInactivityUpdates - 250000 worstcase 11.032 ms/op
phase0 processRegistryUpdates - 250000 normalcase 2.5410 us/op
phase0 processRegistryUpdates - 250000 badcase_full_deposits 140.01 us/op
phase0 processRegistryUpdates - 250000 worstcase 0.5 62.701 ms/op
altair processRewardsAndPenalties - 250000 normalcase 14.790 ms/op
altair processRewardsAndPenalties - 250000 worstcase 14.259 ms/op
phase0 getAttestationDeltas - 250000 normalcase 5.5332 ms/op
phase0 getAttestationDeltas - 250000 worstcase 18.643 ms/op
phase0 processSlashings - 250000 worstcase 58.100 us/op
altair processSyncCommitteeUpdates - 250000 9.9678 ms/op
BeaconState.hashTreeRoot - No change 405.00 ns/op
BeaconState.hashTreeRoot - 1 full validator 92.503 us/op
BeaconState.hashTreeRoot - 32 full validator 976.36 us/op
BeaconState.hashTreeRoot - 512 full validator 9.3892 ms/op
BeaconState.hashTreeRoot - 1 validator.effectiveBalance 130.35 us/op
BeaconState.hashTreeRoot - 32 validator.effectiveBalance 2.6039 ms/op
BeaconState.hashTreeRoot - 512 validator.effectiveBalance 18.294 ms/op
BeaconState.hashTreeRoot - 1 balances 94.259 us/op
BeaconState.hashTreeRoot - 32 balances 998.32 us/op
BeaconState.hashTreeRoot - 512 balances 6.3554 ms/op
BeaconState.hashTreeRoot - 250000 balances 195.54 ms/op
aggregationBits - 2048 els - zipIndexesInBitList 20.071 us/op
regular array get 100000 times 22.610 us/op
wrappedArray get 100000 times 22.722 us/op
arrayWithProxy get 100000 times 12.362 ms/op
ssz.Root.equals 21.781 ns/op
byteArrayEquals 21.582 ns/op
Buffer.compare 9.0470 ns/op
processSlot - 1 slots 10.216 us/op
processSlot - 32 slots 2.2225 ms/op
getEffectiveBalanceIncrementsZeroInactive - 250000 vs - 7PWei 5.3469 ms/op
getCommitteeAssignments - req 1 vs - 250000 vc 1.6597 ms/op
getCommitteeAssignments - req 100 vs - 250000 vc 3.3739 ms/op
getCommitteeAssignments - req 1000 vs - 250000 vc 3.6305 ms/op
findModifiedValidators - 10000 modified validators 822.24 ms/op
findModifiedValidators - 1000 modified validators 423.87 ms/op
findModifiedValidators - 100 modified validators 258.53 ms/op
findModifiedValidators - 10 modified validators 147.67 ms/op
findModifiedValidators - 1 modified validators 144.03 ms/op
findModifiedValidators - no difference 151.27 ms/op
migrate state 1500000 validators, 3400 modified, 2000 new 3.5269 s/op
RootCache.getBlockRootAtSlot - 250000 vs - 7PWei 5.6000 ns/op
state getBlockRootAtSlot - 250000 vs - 7PWei 503.27 ns/op
computeProposerIndex 100000 validators 1.3199 ms/op
getNextSyncCommitteeIndices 1000 validators 2.8590 ms/op
getNextSyncCommitteeIndices 10000 validators 24.628 ms/op
getNextSyncCommitteeIndices 100000 validators 86.049 ms/op
computeProposers - vc 250000 588.53 us/op
computeEpochShuffling - vc 250000 39.215 ms/op
getNextSyncCommittee - vc 250000 9.4090 ms/op
nodejs block root to RootHex using toHex 99.158 ns/op
nodejs block root to RootHex using toRootHex 63.951 ns/op
nodejs fromHex(blob) 838.72 us/op
nodejs fromHexInto(blob) 611.28 us/op
nodejs block root to RootHex using the deprecated toHexString 478.95 ns/op
nodejs byteArrayEquals 32 bytes (block root) 25.115 ns/op
nodejs byteArrayEquals 48 bytes (pubkey) 36.054 ns/op
nodejs byteArrayEquals 96 bytes (signature) 32.665 ns/op
nodejs byteArrayEquals 1024 bytes 39.770 ns/op
nodejs byteArrayEquals 131072 bytes (blob) 1.6890 us/op
browser block root to RootHex using toHex 139.35 ns/op
browser block root to RootHex using toRootHex 126.94 ns/op
browser fromHex(blob) 1.6812 ms/op
browser fromHexInto(blob) 608.45 us/op
browser block root to RootHex using the deprecated toHexString 325.66 ns/op
browser byteArrayEquals 32 bytes (block root) 26.833 ns/op
browser byteArrayEquals 48 bytes (pubkey) 38.134 ns/op
browser byteArrayEquals 96 bytes (signature) 70.924 ns/op
browser byteArrayEquals 1024 bytes 733.40 ns/op
browser byteArrayEquals 131072 bytes (blob) 91.714 us/op

by benchmarkbot/action

lodekeeper and others added 2 commits July 17, 2026 17:59
## Summary

Fixes #9672.

`GET /eth/v1/beacon/states/{state_id}/validators` (and
`.../validator_balances`) returns `400 id must be array` when a
validator client sends more than 20 ids as a comma-separated list
(`?id=a,b,c,...`). This regressed in v1.44.0 and breaks interop with
clients that use comma-separated `id` encoding, notably the Nimbus
validator client.

## Root cause

The REST query string is parsed with `qs` using `comma: true` but no
explicit `arrayLimit`, so it defaulted to **20**. v1.44.0 bumped `qs`
`6.14.1 -> 6.15.2` (#9399), which enforces `arrayLimit` on comma-parsed
values: once a comma-separated (or repeated) array exceeds the limit,
`qs` converts it into an object (`{0: ..., 1: ...}`), which then fails
Ajv `type: "array"` validation and surfaces as `id must be array`. The
default cap also applies to the repeated form (`?id=1&id=2&...`).

## Fix

Raise `arrayLimit` to `NUMBER_OF_COLUMNS` (128), the largest array any
beacon-API query param can carry — a full data-column custody set on
`getDebugDataColumnSidecars` (`indices`); the validator `id` lists are
capped at 64 by the spec. The route schemas set no per-request
`maxItems`, so the qs `arrayLimit` is the only cap on query-array
length. This is not a DoS boundary (`parseArrays: false` already
disables index-based sparse arrays, and the URL/header size limits bound
total input), so a single generous global cap is safe.

The `@lodestar/api` test-server helper (`getTestServer`) mirrors this
`arrayLimit` so it stays in parity with the real server.

🤖 Generated with AI assistance

---------

Co-authored-by: lodekeeper <lodekeeper@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.