Repository navigation
perf(beacon): precompute the epoch transition off the import path - #652
Conversation
…636-638-gloas-live
Conflicted files:
- crates/blockchain/src/lib.rs
- crates/blockchain/state_transition/src/beacon/fork_choice.rs
- crates/blockchain/state_transition/src/metrics.rs
- docs/metrics.md
Gloas: the precompute stays enabled, because the base state cannot differ.
In this branch a gloas block's post-state never holds its own payload: the
child applies it (process_parent_execution_payload) inside process_block,
after process_slots, and on_execution_payload_envelope only records and
checks, it stores no second state. The store keeps one state per root and
both on_block (get_state(parent_root)) and checkpoint_state read it, and the
worker clones get_state(head_root) and caches the result under
CheckpointState { E+1, head_root }, so the entry is process_slots of the very
state an import of a child of that root clones. The fulu-to-gloas boundary
needs no case either: the upgrade runs inside process_slots on both paths.
A new unit test (a_gloas_precompute_starts_from_the_state_an_import_would)
checks, for a gloas parent, that the worker's result equals
checkpoint_state's for the key, that an import finds it, and that advancing
the rest of a skipped first slot from it equals plain process_slots.
Other adaptations:
- transition_block and stf::apply_block take the ActiveBalanceCache that
#648 added to state_transition; on_block passes the store's own cache.
- on_block's gloas-specific code between the parent read and the
transition does not use the cloned state, so removing that early clone
(as #652 does) needed nothing else.
- lib.rs: the NewBlock handler keeps the envelope settling that follows the
import and triggers the precompute after it, so a head moved by a settled
envelope's block is seen.
- metrics.rs and docs/metrics.md: both sides' metrics kept.
- Test fixtures of #652 follow this branch's BlockInputs (operations) and
the extra argument.
- docs/beacon_stf.md notes why gloas shares the key.
…36-638-gloas-live Brings gloas validator duties (produceBlockV4, envelope publication, PTC duties and payload attestations, gloas attestation data and aggregates, VC gloas support) onto the deployment branch, keeping every behavior of #626, #633, #636, #638, #646, #647-#652, #656, #658-#660 and the sync-committee and liveness endpoints. Conflict resolutions keep both sides: the attestation pool stays in Store (tmp) while the payload attestation pool is threaded through P2P and the RPC handles (feature); the aggregate endpoints keep tmp's liveness recording and attesting indices and add the feature's fork-header check and gloas pooling; the VC tests and fake execution client serve both fulu blobs and gloas V6. Semantic fixes: a. POST /eth/v2/beacon/blocks (gloas) calls publish_beacon_block(block, Vec::new()): gloas columns travel with the envelope. RecordingNetwork implements publish_beacon_block(block, sidecars) and both new methods. b. produceBlockV4 appends client versions to the graffiti exactly like produceBlockV3 (graffiti::execution_client_version run alongside the payload build, with_client_versions, Extension<OwnVersion>) and logs it. c. Attestation data, aggregate_attestation keep require_execution_client and require_validated for gloas slots; payload_attestation_data now applies the same two rules (503 without an execution client, or when the voted block's payload is unvalidated). d. Proposer duties v1 and v2 serve gloas epochs from a fulu or gloas state's proposer_lookahead; nothing refuses gloas any more; v2 keeps its dependent root. e. The VC's per-validator ProposerSettings apply to gloas proposals (graffiti in the BlockRequest, fee recipient compared with the bid's); a test pins the graffiti. VC tests updated to the ProposerSettings constructors. f. gloas production reads the attestation pool from Store and calls the stf with the ActiveBalanceCache the perf work added; fulu production and pack_operations are untouched (gloas blocks carry no pooled operations). g. Chain events are emitted by the chain actor only, so nothing on the RPC publish paths needed to move; gloas imports reach it unchanged. h. Cargo.lock unchanged; cargo check --locked passes.
4d896d2 to
2f83786
Compare
## 🗒️ Description / Motivation
Merges `beacon-chain-integration` into `main`. This adds an **Ethereum
Beacon Chain** client (phase0 through fulu) to the ethlambda binary,
alongside the Lean consensus client:
- **`ethlambda beacon`**: a beacon node. It checkpoint-syncs, follows
the chain over gossip and req/resp, validates execution payloads through
an execution client's Engine API, and serves the Beacon API, including
the validator endpoints.
- **`ethlambda validator`**: a beacon validator client (attest,
aggregate, propose) that talks to any beacon node over the standard REST
API.
- **`ethlambda node`** (lean) keeps its behavior, apart from the items
under "Behavior changes for lean" below.
Both chains run on the same storage layer, P2P actor, RPC crate and
chain actor. `BeaconState` has a `Lean` variant holding lean's `State`,
and each shared handler dispatches on it once, at the top.
Each piece was reviewed as its own PR into the integration branch
(listed under "Related Issues / PRs"). This PR merges that branch.
## What Changed
| Area | Change |
|---|---|
| `ethlambda-types` (`beacon/`) | Containers for phase0 to fulu, presets
(compile-time `preset-minimal` feature), runtime fork config, and a
`Lean` variant on `ForkName` and `BeaconState` |
| `ethlambda-state-transition` (`beacon/`) | State transition, epoch
processing, fork upgrades, fork choice, BLS (`blst`), KZG (`c-kzg`),
gossip validation rules, DAS custody math |
| `ethlambda-ssz-tree` (**new**) | Persistent Merkle `List`/`Vector` for
the validator registry and balances, so consecutive states share
unchanged subtrees |
| `ethlambda-storage` | One `Store` for both chains. Beacon states are
stored as snapshots plus VCDIFF deltas and written on a background
thread. Adds a committee cache, and `db_version`/`chain`/`preset`
metadata that resume checks |
| `ethlambda-blockchain` | Beacon path in the chain actor
(`BlockChain::spawn_beacon`): import, holding blocks until their parent
or columns arrive, applying gossip aggregates to fork choice, block
production and aggregation |
| `ethlambda-engine` (**new**) | Engine API client with JWT auth:
`newPayload`, `forkchoiceUpdated`, `getPayload`, `getBlobs` |
| `ethlambda-p2p` | Beacon wire: blocks, aggregates, subnet attestations
and data columns are validated before relay;
`beacon_blocks_by_{range,root}/2` served and requested; range sync;
column custody and sampling; attestation subnet backbone; discv5 always
on for `beacon` |
| `ethlambda-rpc` | Beacon API under `/eth/v1` and `/eth/v2`: JSON by
default, SSZ on request. The router is picked by `Store::chain()`, so
`/lean/v0` and the Beacon API are never served together |
| `ethlambda-validator` (**new**) | Validator client: duties,
attestations, aggregation selection, proposals, keymanager API. It has
**no slashing protection** (recorded in `docs/spec_deviations.md`) |
| `bin/ethlambda` | `beacon` and `validator` sub-commands; `--network`
takes a built-in name (`mainnet`, `sepolia`, `hoodi`) or a directory of
published network files; beacon checkpoint sync; `benchmark import`
replays a real block corpus offline; `run_node` is one startup path for
both chains |
| CI / Makefile | One beacon spec-test job per preset, minimal-preset
check, `make test` split into `test-consensus` and `test-node` (one
runner's disk no longer fits both), benchmark-smoke and tooling jobs,
disk-cleanup action |
| Docs | New: `beacon_stf.md`, `beacon_wire.md`, `beacon_engine.md`,
`cli.md`. Updated: `data_storage.md`, `metrics.md`, `rpc.md`,
`checkpoint_sync.md`, `discovery.md`, `benchmarking.md`,
`spec_deviations.md`, README (beacon quickstart with ethrex) |
## Correctness / Behavior Guarantees
- **Separation:** beacon behavior lives in `beacon` modules that lean
code never reads, and those modules never read lean's. The shared crates
dispatch on the chain once per handler.
- **Lean state at a beacon accessor:** this panics, naming the function
it reached (`lean_state_unreachable` / `lean_fork_unreachable`), so the
caller never gets a wrong answer back. `ForkName::Lean` is left out of
`ForkName::ALL`, so neither `parse` nor a fork upgrade can reach it.
- **Spec conformance:** every consensus-specs `v1.6.1` fixture case
passes on both presets: 5705 mainnet and 40009 minimal, including 150
mainnet `fork_choice` cases. Gossip fixtures are pinned to
`v1.7.0-beta.1`.
- **Resume safety:** a data directory written by another chain, preset
or format version is refused at startup, before any state is read.
### ⚠️ Behavior changes for lean (`ethlambda node`)
- **Existing data directories are refused on resume.** A directory
written by `main` has no `db_version` key, so `Store::from_db_state`
returns `DbVersionMismatch { found: 0, expected: 4 }`. There is no
migration: an upgraded node needs a fresh data directory (or checkpoint
sync).
- `blst` and `c-kzg` are now dependencies of the lean binary too, since
the state-transition crate holds both chains' rules. They are not
feature-gated.
- libssz is a git dependency pinned by `rev`: the beacon containers need
`DerefMut`/`IndexMut` on `SszList` (lambdaclass/libssz#33), which no
crates.io release has yet.
- The `release-fast` profile (used for tests) now sets `debug-assertions
= true`.
- `P2P::spawn` now runs before the chain actor is spawned, so gossip
arriving in that short window is dropped.
- `/lean/v0/blocks/finalized` answers JSON when asked for it by name.
SSZ stays the default.
## Tests Added / Run
- **Beacon spec tests:** `make test-beacon` (or `test-beacon-mainnet` /
`test-beacon-minimal`). Each fixture case is a separately named test,
e.g. `cargo test -p ethlambda-state-transition --test beacon_spec_tests
--features beacon-spec-tests -- electra/attester_slashing`. CI runs one
job per preset.
- **`make test`** still needs no consensus-spec download: the beacon
spec target is behind the `beacon-spec-tests` feature, and the BLS/KZG
vector tests report as ignored without it.
- **Minimal preset:** the `Test minimal preset` CI job builds and runs
the `ethlambda-types` unit tests with `preset-minimal` on.
- **Benchmark smoke:** a CI job runs the `benchmark` sub-command end to
end.
- Unit and integration tests in every new crate (`ssz-tree`, `engine`,
`validator`) and in the beacon paths of storage, p2p, rpc and the chain
actor.
## Related Issues / PRs
<details>
<summary>PRs merged into the integration branch (reviewed on
<code>lambdaclass/ethlambda_private</code>)</summary>
**Types and state transition**
- lambdaclass/ethlambda_private#1 Beacon Chain containers under a
`beacon` namespace
- lambdaclass/ethlambda_private#2 Beacon Chain state transition, phase0
through fulu
- lambdaclass/ethlambda_private#37 keep the registry and balances in
persistent Merkle trees
**Follower, sync and storage**
- lambdaclass/ethlambda_private#3 follow mainnet gossip behind an
`ethlambda beacon` sub-command
- lambdaclass/ethlambda_private#6 unify the lean and beacon storage
layer
- lambdaclass/ethlambda_private#7 checkpoint sync for the beacon chain
- lambdaclass/ethlambda_private#8 serve beacon blocks by range and by
root
- lambdaclass/ethlambda_private#9 give the follower a chain, from the
anchor to the tip
- lambdaclass/ethlambda_private#11 fulu data availability sampling,
custody through the availability gate
- lambdaclass/ethlambda_private#13 validate execution payloads over the
Engine API
- lambdaclass/ethlambda_private#15 follow a network described by files
on disk
- lambdaclass/ethlambda_private#26 add sepolia and hoodi as built-in
networks
- lambdaclass/ethlambda_private#48, lambdaclass/ethlambda_private#52,
lambdaclass/ethlambda_private#53, lambdaclass/ethlambda_private#54: held
blocks, their columns and range batches
**P2P**
- lambdaclass/ethlambda_private#18 stop outbound req/resp requests
crossing protocols
- lambdaclass/ethlambda_private#23 rank dial candidates by custody
redundancy
- lambdaclass/ethlambda_private#28 count why peers drop us
- lambdaclass/ethlambda_private#38 validate beacon gossip before
propagating it
- lambdaclass/ethlambda_private#43 run every data column check in p2p
- lambdaclass/ethlambda_private#47 validate beacon aggregates and subnet
attestations before relaying
- lambdaclass/ethlambda_private#19 apply gossip aggregates to fork
choice, backbone two attestation subnets
**API and validator**
- lambdaclass/ethlambda_private#21 serve the Beacon API from `ethlambda
beacon`
- lambdaclass/ethlambda_private#44 report the preset and constants from
`/eth/v1/config/spec`
- lambdaclass/ethlambda_private#16 beacon-chain validator client
- lambdaclass/ethlambda_private#34 serve the validator API
- lambdaclass/ethlambda_private#40 aggregate and propose through
`ethlambda beacon`
- lambdaclass/ethlambda_private#56 pack other nodes' attestations into
produced blocks
**Performance and metrics**
- lambdaclass/ethlambda_private#10, lambdaclass/ethlambda_private#20,
lambdaclass/ethlambda_private#22, lambdaclass/ethlambda_private#24,
lambdaclass/ethlambda_private#33, lambdaclass/ethlambda_private#49:
registry scans, parallel pubkey validation, committee cache, background
state writes, BLS key validation
- lambdaclass/ethlambda_private#17, lambdaclass/ethlambda_private#31:
import timing by phase
- lambdaclass/ethlambda_private#25 benchmark block import by replaying a
real corpus
**CI**
- lambdaclass/ethlambda_private#12, lambdaclass/ethlambda_private#30,
lambdaclass/ethlambda_private#45: split lint/test jobs, benchmark smoke
job, runner selection
</details>
Open PRs based on `beacon-chain-integration`, **not** included here:
lambdaclass#626, lambdaclass#627, lambdaclass#629, lambdaclass#630, lambdaclass#632, lambdaclass#634, lambdaclass#636, lambdaclass#638, lambdaclass#642, lambdaclass#644 to lambdaclass#652,
lambdaclass#656, lambdaclass#658.
## ✅ Verification Checklist
- [ ] Ran `make fmt` (clean)
- [ ] Ran `make lint` (clippy with `-D warnings`, clean)
- [ ] Ran `make test` (`test-consensus` plus `test-node`, at
`release-fast`), all passing
- [ ] Ran `make test-beacon` (both presets), all passing
---------
Co-authored-by: Pablo Deymonnaz <pdeymon@fi.uba.ar>
Co-authored-by: Klaus Lungwitz <klaus.lungwitz@lambdaclass.com>
Co-authored-by: Klaus @ LambdaClass <18153834+klaus993@users.noreply.github.com>
Co-authored-by: Chase Wright <chase.wright@ethereum.org>
🤖 Claude Code ReviewReview: PR 652, epoch-transition precomputeThe design is sound and the correctness argument holds up. The cache key I found no consensus-correctness bugs. A few robustness points:
The tests cover the happy path, Point 1 is the only one I'd want addressed before merge. The rest are optional. Automated review by Claude (Anthropic) · sonnet · custom prompt |
🤖 Codex Code ReviewLooks mostly solid. The precompute path is well-contained, keeps cache residency out of consensus decisions, and the
If you want, I can turn this into ready-to-post GitHub review comments. Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
🤖 Kimi Code ReviewI'll review this PR for epoch-transition precompute optimization in the beacon chain. Let me analyze the code carefully. Overall AssessmentThis is a well-structured optimization PR that precomputes epoch-boundary states to reduce import latency. The design is sound, but I've identified several issues ranging from minor correctness concerns to potential race conditions. Critical Issues1. Integer Overflow in
|
| Priority | Item | File | Line | Issue |
|---|---|---|---|---|
| High | 4 | fork_choice.rs |
888-901 | hash_tree_root() error silently ignored |
| Medium | 1 | epoch_precompute.rs |
147 | Potential overflow in three_quarter_slot |
| Medium | 10 | epoch_precompute.rs |
228 | spawn_blocking task not tracked |
| Medium | 5 | fork_choice.rs |
137-138 | Clone of precomputed state assumes hash memoization preserved |
| Low | 17 | fork_choice.rs |
888-895 | Doc comment overstates "at or past" behavior |
| Low | 16 | fork_choice.rs |
3959 | block_on name shadows tokio function |
| Low | 12 | epoch_precompute.rs |
220 | Store error silently ignored, consider debug log |
| Low | 9 | stf/mod.rs |
147 | apply_block is pub, consider pub(crate) |
The PR is well-designed and the core logic is correct. The most important fix is Item 4 (handling hash_tree_root failure), followed by the overflow check in Item 1.
Automated review by Kimi (Moonshot AI) · kimi-k2.6 · custom prompt
ilitteri
left a comment
There was a problem hiding this comment.
Medium: Timer-triggered precompute can replay an unbounded number of historical epochs.
The Timer arm at crates/blockchain/src/epoch_precompute.rs:105-112 checks only that the timer names an epoch's last slot and that head_slot <= slot. Unlike the Head arm, it never uses current_slot or otherwise bounds the head's lag.
During a network-wide stall, the sync tracker deliberately reports syncing = false once the freshest-known block is over eight slots old (crates/blockchain/src/sync_status.rs:98-100). A timer several epochs ahead of the head can therefore start advance_to_epoch_start, replaying every intervening slot and epoch on a blocking worker. If the stall continues, later epochs use new cache keys and repeat increasingly expensive work from the same old head. This also contradicts the general freshness gate documented in docs/beacon_stf.md:578.
Please bound Timer precompute to a head within the trigger's epoch, or another explicitly bounded lag that still supports skipped slots. Please also add a stale-Timer test; the existing stale-head test covers only Trigger::Head.
575d88b to
b79beac
Compare
…st from the snapshot (#665) ## Motivation On the eth-3 followers (48h of import timings, 10-03 to 10-05), the head recompute at an epoch-start tick sometimes took 0.5-4 s, and the epoch's first block queued behind it: | network | head computes > 0.5 s | slot-0 block queue wait | |---|---|---| | Hoodi | 8 (7 in 2-4 s, 1 in 4-12 s) | up to 2.3 s | | Plataberget | 22 | up to 1.1 s | At that tick the justified checkpoint moves to (E-1, R), and the first head walk builds the justified-balances snapshot from `checkpoint_state((E-1, R))`. When that state has left the 32-entry state cache, the chain actor rebuilds it inline: a full snapshot decode (~0.3 s on Hoodi), plus a whole epoch transition when R's block is before the epoch's first slot (an empty first slot, or an epoch-start block orphaned by a late block). A replay of the logs through a model of the state cache puts the rebuilds at ~100 of ~410 Hoodi epoch ticks. Lighthouse never reads a state at that tick: `BeaconForkChoiceStore::on_verified_block` builds the justified balances at block import, for the block's epoch-boundary checkpoint, into a small cache keyed by (boundary root, epoch), from a state import already has. The tick only looks them up. Prysm keeps a per-checkpoint balances vector too, but builds it from the checkpoint block's un-advanced post-state, which is not what the specification's `checkpoint_states` holds; this PR follows Lighthouse. ## Changes This branch carries two open PRs it builds on, merged in with the resolutions `tmp/bci-...-gloas-live` already uses for gloas (minus #648's active-balance cache and #646's block-production operations, which are not on this branch): - #649, the justified-balances snapshot (`86b07388`) - #652, the epoch precompute and its `transition_block` (`b3017e4b`) The change itself is `6badb4f3`: 1. **A balances cache instead of one snapshot.** The store keeps up to 8 `JustifiedBalances` keyed by checkpoint. When full it evicts the lowest epoch first (ties: oldest insert), and never the current justified checkpoint. Not Lighthouse's plain 4-entry FIFO: on a network with many forks (Plataberget), several blocks crossing one boundary from different parents could flush the canonical entry within an epoch. 2. **Built at import.** - A block at its epoch's first slot caches the balances from its own post-state, under (epoch, block_root): that post-state is what `checkpoint_state` returns for it. - A block crossing from an earlier-epoch parent (empty first slot) caches them from the boundary state, under (epoch, parent_root), inside `transition_block`: from the precomputed state on a precompute hit, or by advancing the cloned parent to the epoch start once on a miss (each slot is still processed once). The boundary state depends only on the parent, so building it before the block is validated cannot cache a wrong value. - `justified_balances()` looks the current checkpoint up and keeps the lazy `checkpoint_state` path for a miss (a restart, or a boundary this node never imported). 3. **The gloas boost reads the snapshot.** `compute_node_weights` fetched the justified checkpoint state on every head walk, only for the gloas boost gate. `is_head_weak`, `is_parent_strong` and `should_apply_proposer_boost` now use the snapshot: the total active balance and vote weights were already there; the equivocator term in `is_head_weak` needs the raw `effective_balance` at the justified state, which the vote weights zero for slashed validators, so the snapshot gains an exact copy as a `u16` count of `EFFECTIVE_BALANCE_INCREMENT` per validator (asserted at build). Committees still come from the head/parent block states. The spec-literal `get_weight`, `gloas_get_weight` and `get_attestation_score` stay state-based as references. On a snapshot hit, no head walk (pre-gloas or gloas) reads the justified state. From review: 4. **The committee total leaves slashed validators out.** The snapshot's total, and the spec-literal `calculate_committee_fraction`, now sum active, unslashed validators only, as consensus-specs #5679 does. Fork choice weighs no vote from a slashed validator, so a boost or reorg threshold sized against their stake outweighs a larger share of the votes that count. #5679 first shipped in v1.7.0-beta.4 and the fixtures are pinned to beta.2, so `docs/spec_deviations.md` records it until the pin catches up; no beta.2 fork-choice fixture tells the two apart. Lighthouse already sums the same way. The raw `effective_balance` copy is unchanged, since `is_head_weak` still needs it for slashed equivocators. 5. **The timer precompute is bounded to a head in its own epoch.** The timer trigger checked only that the head was not past its slot. In a network-wide stall the sync tracker reports synced, so a timer epochs past a stalled head replayed every epoch in between, and again under a new key at each later epoch. A worker now crosses one epoch boundary at most, on either trigger. ## Cost - One in-order registry pass (~25 ms on Hoodi, `lean_beacon_justified_balances_build_seconds`) per epoch-boundary checkpoint, at import, on the importer, as in Lighthouse. - 10 bytes per validator per cached snapshot (8 for the vote weight, 2 for the new raw effective balance): ~12 MB per entry on Hoodi, so ~96 MB with all 8 entries filled. Worth reviewing whether 8 entries (vs 4) earn their memory. ## Testing - New: the cache's eviction policy (bound, lowest epoch, ties, protected justified, replace); a crossing import caches the boundary balances on both precompute paths with the block's state root still verifying; a first-slot import caches from its post-state and other slots cache nothing; a snapshot hit reads no justified state on every head path under both fork rules; a slashed equivocator still counts toward head weakness, matching the state-based reference; a slashed-but-active validator is left out of the proposer score and the snapshot total but keeps its raw balance; a timer whose epoch holds no block, or a stall several epochs long, starts no precompute. - `cargo test --profile release-fast --lib --bins` for storage, state-transition, blockchain and types: 1083 passed, 0 failed. - Beacon `fork_choice` fixtures, consensus-specs v1.7.0-beta.2, mainnet: 206 passed, 0 failed (before items 4 and 5; not re-run locally since, so CI's `test-beacon` jobs are the check). - `cargo clippy --workspace --all-targets -- -D warnings`: clean. ## Not covered - Live effect on the epoch-tick head recompute: worth re-measuring on a follower after deploy. - An `on_block` unit test of the first-slot hook needs a BLS-signed block; it is tested through `transition_block` and the cache helper instead. --------- Co-authored-by: Pablo Deymonnaz <pdeymon@fi.uba.ar>
…-gloas-live The import-time justified balances hook into the precompute's `transition_block`. Taken from tmp/bci-...-gloas-live's resolution of the same merge (23986cf), which also enables the precompute for gloas blocks, minus the active-balance cache (lambdaclass#648) and the block-production operations (lambdaclass#646) that branch threads through `apply_block` and the precompute tests: neither is on this branch.
Motivation
The first block of every epoch runs
process_epochinline on the chain actor (fork_choice::on_block, thenstf::state_transition, thenprocess_slots), followed by the post-epoch rehash the state-root check needs. In the import replay such blocks take ~1.5 s against ~0.58 s for an ordinary block (measured on the replay).The transition depends only on the parent, which is known by the end of the previous epoch's last slot, so it can run ahead of time on a blocking worker, off the import path.
Design
Eas head: precompute(E+1, head_root). Covers a late last-slot block.E, derived from the configured slot duration. The once-per-slot tick arms a delayed self-message on last slots only (send_after). Starts a worker only if none is running and the key is not cached. Covers a skipped last slot, where the head is an earlier block.spawn_blocking: clone the head state,process_slotsto the first slot ofE+1, flush pending writes,hash_tree_rootso tree-node hashes are memoized, then message the state back. At most one worker in flight; deduped by(epoch, root)through the cache.CacheKey::CheckpointState { epoch: E+1, root: head_root }.checkpoint_statederives exactly that value for the checkpoint (the checkpoint block's post-state advanced to the epoch's first slot), so attestation targets forE+1hit it too. A unit test asserts equalhash_tree_root.on_block: when the parent's slot is before the block epoch's first slot and the block is at or after it, look upCheckpointState { block_epoch, parent_root }. A hit clones the entry, advances any remaining skipped slots, and applies the block with the newstf::apply_block(the part ofstate_transitionafterprocess_slots;process_slotsrejects a target equal to the current slot, so a boundary block cannot go throughstate_transition).Forks:
process_slotsperforms the fork upgrade at the boundary, so nothing here is fork-specific.New metrics (documented in
docs/metrics.md):lean_beacon_epoch_precompute_lookups_total{result}(epoch-crossing imports only),lean_beacon_epoch_precompute_seconds,lean_beacon_epoch_precompute_started_total{trigger}.Not in this PR
compute_pulled_up_tip).Testing
ethlambda-storagelib,ethlambda-state-transitionlib,ethlambda-blockchainlib, beacon spec suite (mainnet), clippy andcargo check -p ethlambda: see counts below.checkpoint_state's for the same key; trigger decisions as a pure function (head, timer, syncing, stale head, in-flight, already cached) and the three-quarter delay.Context, so the trigger decision is tested as a pure function instead.Results: storage lib 138 passed (1 ignored); state-transition lib 327 passed (16 ignored); blockchain lib 217 passed; beacon spec suite (mainnet) 5734 passed, 4 failed (the 4 expected gossip matched_fixture_cases on the empty gossip dir), 152 ignored; clippy (workspace, and state-transition with beacon-spec-tests) clean; cargo check -p ethlambda clean.