Skip to content

refactor: select experimental native state transition during setup - #10014

Draft
wemeetagain wants to merge 18 commits into
bing/stf-bytesfrom
cayman/stf-foundation
Draft

refactor: select experimental native state transition during setup#10014
wemeetagain wants to merge 18 commits into
bing/stf-bytesfrom
cayman/stf-foundation

Conversation

@wemeetagain

@wemeetagain wemeetagain commented Sep 5, 2026

Copy link
Copy Markdown
Member

This follows up on the squashed Zig integration in #9632 and selects the state-transition implementation once during node or historical-worker setup. TypeScript remains the default; --chain.nativeStateView=true enables the experimental native implementation and requires a restart to change. Factories, descendants, reloads, and archived-block replay retain that choice.

The native adapter handles full and blinded block serialization, while the TypeScript path keeps using block objects without additional serialization. Startup checks reject a mismatched anchor backend, native transitions reject Gloas and later forks, and native/historical metrics have distinct names. Native rewards APIs remain unsupported and are documented as an experimental limitation.

Depends on lodestar-z #648, pinned to d7e17aa0eb05f0b6e5ad71b9a5cf8b0d6ffce29d. That PR provides immutable setup configuration, worker resource isolation, input validation, and native memory accounting. Tree pools allocate on first state construction so default TypeScript/BLS workers avoid roughly 440 MB of unused memory per environment. It also honors canonical milliseconds over deprecated slot-duration defaults, fixing an actual six-second devnet startup failure. Sync committee lookups return the complete cache promised by their declarations, fixing a devnet failure that prevented native validators from obtaining sync duties. This branch merges current unstable into the #9632 squash; the foundation changes follow that merge in separate commits.

Validation:

  • All CI checks pass on the final commit, including full unit/E2E/spec tests, simulations, build, type checks, lint, and documentation checks. Local full build and relevant checks also pass.
  • 52 targeted tests pass against the installed immutable native dependency, including real historical replay with both implementations, setup ownership, production/import, metrics, startup checks, and canonical slot-duration configuration.
  • Native sanity, finality, fork-transition, and fork-choice vectors pass on both presets: 1,083 mainnet and 1,356 minimal. Unsupported forks are skipped. CI now runs those actual native paths for both presets and verifies the installed native preset.
  • 191 default TypeScript Fulu comparison vectors pass.
  • Frozen-lockfile install and the Node 24 devnet Docker build pass.
  • A three-node Fulu Kurtosis devnet with 64 validators and six-second slots passed Assertoor stability and proposal checks. The final native image loaded the saved validator database, returned sync duties identical to TypeScript, published sync-committee contributions, and returned matching historical state at slot 64. Its observer recovered through checkpoint sync. Final agreement was at slot 777 and finalized epoch 22, with all three nodes healthy and metrics scraping without duplicate families. The two native CLs ran the final image; the TS CL and VCs retained the earlier validated image.
  • Historical replay assertions compare complete serialized bytes with the existing byte helper, avoiding the CI timeout caused by deep object comparison of multi-megabyte arrays.

Repeated observer replay in this two-peer topology hit range-request rate limits; checkpoint recovery restored agreement. Both backends also logged publication failures where no peer subscribed to an attestation subnet. These test-environment limitations are recorded without changing peer scoring.

This is a pre-Gloas foundation for controlled fleet testing, with no automatic implementation fallback or full API parity claim.

This PR was written primarily by Codex.

nflaig and others added 14 commits September 4, 2026 13:59
…10002)

lodestar-geth-2 on glamsterdam-devnet-9 was stuck at head 16192 for 20+
minutes after a restart, every range sync batch starting at 16192 failed
with `BLOCK_ERROR_PARENT_PAYLOAD_UNKNOWN` at 16193 (~1000 errors, 190+
sync chain restarts) until a Prysm peer happened to serve the batch
without the 16192 envelope.

the payload of 16192 was revealed late and orphaned, 16193 builds on the
EMPTY variant of 16192 (its `bid.parent_block_hash` is the 16191 payload
hash). block 16192 was already imported as target of the previous
finalized sync chain, so the batch is processed as `[16193, ...]` with
parent 16192 EMPTY from fork choice, which is correct. but peers still
serve the orphaned 16192 envelope in
`execution_payload_envelopes_by_range` and `assertLinearChainSegment`
unconditionally takes an envelope for the parent slot as the parent EL
head, so the first block check fails against the orphaned payload hash
and the batch is retried forever.

only take the parent envelope as EL head if the first block actually
builds on it (the checkpoint sync anchor case this was added for),
otherwise keep the fork choice parent and report the envelope as
orphaned, same as in-segment orphans.

replayed the real 16192/16193 blocks and 16192 envelope from the devnet
through `assertLinearChainSegment`, unstable throws
`PARENT_PAYLOAD_UNKNOWN`, with this change it passes and reports slot
16192 as orphaned.
**Motivation**

- gossip handler functions contains 2 parts: validation part and handler
part
- right now, the handler part was not awaited, however it's still in the
same event loop. As we can see in #9988, we run state transition before
forwarding an ACCEPT block

**Description**

- use `callInNextEventLoop()` util to forward ACCEPT gossip message
asap. The more forwarding messages, the more peer score/mesh peers we
can gain
- remove the `callInNextEventLoop()` in NetworkProcessor, otherwise we
still forward block after we run state transition

Closes #9988
Empty array values break older VCs (e.g. Nimbus) that only allowlist
blob_schedule as non-string in getSpec.

**Motivation**

Lodestar `v1.47.0` always returns `GAS_LIMIT_SCHEDULE` from `GET
/eth/v1/config/spec`, including as `[]` on mainnet. That breaks Nimbus
VC `v26.8.0` (and similarly strict clients), which fail to decode the
array and mark the BN Offline in a ~2s Online/Offline flap. Emitting the
field before Gloas is scheduled has no operational value under EIP-8261.

**Description**

Omit `GAS_LIMIT_SCHEDULE` from `renderJsonSpec()` when `GLOAS_FORK_EPOCH
=== Infinity` or the schedule is empty, mirroring the existing
`BLOB_SCHEDULE` interop guard. Include it only when Gloas is scheduled
and the schedule has entries. Adds unit coverage for omit/include
behavior.
fix `writting` typo in a comment in `persistedKeys.ts`
…le (#10003)

after a restart from a db or checkpoint state at an epoch boundary whose
boundary slot was skipped, the head block's post-state does not exist
anywhere. the anchor state is the boundary state (slot > head block
slot) and is only cached under its own root, regen walks ancestors only
and the anchor has none. every `head` state request
(`getStateFinalityCheckpoints`, `getEpochCommittees`,
`postStateValidators`, ...) fails with `REGEN_ERROR_NO_SEED_STATE` until
the first new block is imported, seen on glamsterdam-devnet-9 after
restarts with anchor state slot 16000 and head block 15999.

serve the closest head state in that case, same as
`chain.getHeadState()` does internally. the cached head post-state is
still preferred when it exists.
…sync (#10005)

19 of 30 lodestar nodes on glamsterdam-devnet-9 had their head parked
for hours on a block whose payload was orphaned (e.g. 7264, 7584, 7745)
while range sync kept importing thousands of blocks behind it. the
validator client logs `Node is syncing` the whole time,
`notifyForkchoiceUpdate` goes out with the stale head and the EL answers
`Too deep reorg`.

range sync imports the orphaned envelope as an in-segment orphan, which
adds a FULL fork choice variant without descendants. during range sync
no attestations are imported (`AttestationImportOpt.Skip` on the
finalized chain, blocks older than `FORK_CHOICE_ATT_EPOCH_LIMIT`
otherwise) so the EMPTY variant that carries the whole chain has weight
0 as well, same root, and `getPayloadStatusTiebreaker` picks FULL for
any block older than the previous slot, exactly as in the spec
`get_head`. the spec never sees this tie because it feeds block
attestations into `get_weight`. the head only moves again once a block
with a newer justification is imported and the leaf fails
`nodeIsViableForHead`, on devnet-9 that was 270 epochs later.

the chain did not build on an orphaned payload, so there is nothing to
gain from importing it: fork choice does not need the variant, the EL
does not need `newPayload` for it and lighthouse/prysm do not even serve
those envelopes by range. skip DA verification and import for the
envelopes `assertLinearChainSegment` reports as orphaned, including the
parent one from #10002 which otherwise creates the same dead leaf at the
batch boundary, whenever the block attestations are not imported
(`importsBlockAttestations()`, same condition as `importBlock()`).
orphaned payloads of recent blocks are still imported, there weight
decides the variant and a reorg of the child could still build on the
payload. skipped envelopes were never validated, they are pruned from
`seenPayloadEnvelopeInputCache` so they are not served to peers, by-root
sync reloads the entry from db if the payload is ever needed. the
batch's own map is left untouched.

**verified on devnet-9**

- lodestar-erigon-1 (head parked at 7745 for 2.5h): restarted with this
change it skipped the orphaned envelopes at 7264 and 7392 and the head
followed the synced chain through 7264, 7392 and 7584, the slots other
unpatched nodes were parked at
- lodestar-reth-1 (head parked at 7264 for 3.5h, then looping on
`PARENT_PAYLOAD_UNKNOWN` at 16542 after unparking) restarted with #10002
+ this change: skipped the orphaned envelopes at
16541/16548/16839/16856/16944, head tracked the imported tip the whole
way and reached head
- 14 more nodes that had unparked and were looping at 16065 restarted
with both changes: 0 `PARENT_PAYLOAD_UNKNOWN` since, orphaned envelopes
at 16065/16096 skipped, heads followed the import and all reached head

the attestation gate and the cache prune were added in review and are
covered by unit tests only, the finalized sync chain behaves the same
with and without them

**known gap**: an orphaned payload of the last block in a batch (or of a
count=1 target batch) has no child in the segment and is still imported,
in a non-finalizing chain that FULL leaf parks the head the same way
until the justified checkpoint moves. deferring the last envelope of a
batch to the next one (where #10002 already classifies the parent
payload) or resolving parent payloads from the seen cache would close
it, left as follow-up
**Motivation**

- monitor the `PersistentCheckpointStateCache` persisted epochs/states

**Description**

- as monitored on `glamsterdam-devnet-9`:


<img width="1394" height="462" alt="Screenshot 2026-09-04 at 16 21 40"
src="https://github.com/user-attachments/assets/3d7a01ec-a866-4a5f-806f-4940495e8a2a"
/>

Co-authored-by: twoeths <twoeths@users.noreply.github.com>
…10006)

lodestar-geth-5 on glamsterdam-devnet-9 (running #10002 + #10005) got
stuck at head 17533 looping on `BLOCK_ERROR_PARENT_PAYLOAD_UNKNOWN` for
17547.

the first peer served block 17533 without its envelope, so it was
imported with a PENDING payload. the next peer served the same batch
including the envelope and the finalized sync chain re-downloaded a
1-slot batch `[17533]` with it as well, but with every block already
known `processBlocks` returns early on "no relevant blocks" and drops
the envelopes with them. 17547 builds on the FULL variant of 17533,
which never gets created, so `verifyBlocksSanityChecks` throws on every
retry and range sync never advances.

import the envelopes of already known blocks in that early return if the
block has no FULL variant yet. if our head descends from the block's
EMPTY variant the chain built on EMPTY and the payload is orphaned,
importing it would only add the dead FULL leaf #10005 removes, so it is
skipped and pruned from the seen cache under the same condition as
there: only while the block attestations are not imported
(`importsBlockAttestations()`), a recent envelope is imported anyway so
a reorg building on it does not have to fetch it through unknown block
sync. a fork child on EMPTY does not block the import, the chain being
synced may still build on the payload. `importExecutionPayload()`
verifies the envelope against the block state, its signature and the EL
as for any other envelope. the head is recomputed afterwards, the cached
one would not see the new FULL variants until the next block import.

lodestar-geth-5 restarted with this change synced through 17547 to head,
but the restart re-anchored it at 16960 so 17533 came in as a new block
with its envelope and the early-return path was not exercised live, it
is covered by the unit tests

same known gap as #10005: the envelope of the sync target block itself
(no children yet) is imported even if its payload later turns out to be
orphaned

builds on #10005 (merged)
**Motivation**

- log peer clients when it serves orphaned payloads, so that we can
monitor and work with them to avoid it
- in the future, we can consider to also penalize peers in this
situation

**Description**

- remove the log inside `BeaconChain.processBlocks()`
- new ProcessBlocksResult:
```typescript
export type ProcessBlocksResult = {
  orphaned: OrphanedPayloadEnvelope[];
  skipped: boolean;
};
``` 
and return it from `processChainSegments`
- then range sync pick it up and log
read the parent slot from `latest_block_header` instead of the bid as
per ethereum/consensus-specs#5554

also updates to
[v1.7.0-beta.0](https://github.com/ethereum/consensus-specs/releases/tag/v1.7.0-beta.0):
- update the spec test vectors and specrefs
- rename the payload timeliness committee SSZ types
- update `MAX_SIGNED_INCLUSION_LIST_SIZE`

the voluntary exit gossip changes are left for a follow-up
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

4 participants