Skip to content

ci: add linter and tests check - #33

Merged
MegaRedHand merged 4 commits into
mainfrom
add-ci
Jan 17, 2026
Merged

MegaRedHand merged 4 commits into
mainfrom
add-ci

Conversation

@MegaRedHand

Copy link
Copy Markdown
Collaborator

No description provided.

Checkout leanSpec repository and generate test fixtures before running
Rust tests. The stf_spectests require fixtures that are generated by
running `uv run fill --clean --fork=devnet` in leanSpec.
@MegaRedHand
MegaRedHand marked this pull request as ready for review January 17, 2026 03:29
@MegaRedHand
MegaRedHand merged commit 40458dc into main Jan 17, 2026
2 checks passed
@MegaRedHand
MegaRedHand deleted the add-ci branch January 17, 2026 03:29
MegaRedHand added a commit that referenced this pull request Aug 13, 2026
The Beacon Chain state transition mutates SSZ collections in place: the
validator registry, the randao mixes, the historical roots. No published libssz
release exposes `DerefMut`/`IndexMut` on `SszList`/`SszVector`, 0.3.0 included,
so 18 call sites across `stf/`, `helpers/`, `upgrade.rs` and `containers/mod.rs`
fail to compile. `ethlambda-beacon` has therefore never built from a clean
checkout of this branch, and the fixture counts recorded in `docs/beacon_stf.md`
were only ever reproducible against an uncommitted local override.

Pin all four libssz crates to 36802dd, which is v0.3.0 plus lambdaclass/libssz#33
(merged 2026-08-12), the commit adding those impls. All four together because
`libssz-types` depends on `libssz` and `libssz-merkle` by path inside that
repository, so patching one alone would put two incompatible copies of `libssz`
in the dependency graph.

The rev has to report 0.3.0 for the patch to apply at all. Cargo silently ignores
a `[patch]` whose version does not satisfy the requirement it replaces, and the
symptom is the original 18 errors with nothing pointing at the cause. That is
what the preceding merge of main buys: it carries the 0.3.0 bump, and the older
pin used against the 0.2.2 requirement is dead anyway, since `add-deref` was
deleted when #33 merged and the rev is no longer fetchable.

Nothing else moves as a result. The pinned commit is purely additive, 127
insertions confined to `list.rs` and `vector.rs`, which matters because the range
from 0.2.2 also crosses a declared breaking change in libssz. Both beacon presets
and the lean suite reproduce their existing counts: mainnet 5705 cases and 244
lib tests, minimal 40009 and 245, lean 564.

`shadow/cargo-patch.toml` needs a change to survive this. It is appended verbatim
to the manifest by two sites, `shadow/build.sh` and the Dockerfile's SHADOW path,
and it declared its own `[patch.crates-io]` header, which was safe only while no
such table was committed. Against one that is, the append is a duplicate table
definition and `make shadow-build` dies at manifest parse. Naming the `quinn-udp`
sub-table directly is valid whether or not the parent table exists, and does not
depend on what the manifest it is appended to ends with, so neither append site
needs touching.
MegaRedHand added a commit that referenced this pull request Aug 13, 2026
The Beacon Chain containers landing in the next commit hand out `&mut` into an
`SszList` (the validator registry, through `altair_validator_lists_mut`), which
published libssz-types 0.3.0 cannot do: it exposes only `Deref` and `Index`.
`DerefMut` and `IndexMut` landed after that release, in lambdaclass/libssz#33,
merged 2026-08-12.

Pinned to that merge commit rather than tracking `main`, so the dependency does
not move underneath us. The rev reports 0.3.0, which is what makes the patch
apply at all: Cargo silently ignores a `[patch]` whose version does not satisfy
the requirement it replaces, and the result is a compile error with nothing
saying why. All four crates are patched together because libssz-types depends on
libssz and libssz-merkle by path inside that repository, so patching one alone
would put two incompatible copies of libssz in the graph.

Remove once libssz publishes a release containing #33.

This makes `[patch.crates-io]` a committed table for the first time, which
collides with `shadow/cargo-patch.toml`: that fragment is appended verbatim to
this manifest by both `shadow/build.sh` and the Dockerfile, and declared the same
table, which is a TOML error. It now names the `quinn-udp` sub-table directly,
valid whether or not the parent table exists, so neither append site changes.

`ethereum-types` and `sha2` join the workspace dependencies for the same
incoming containers; `sha2`'s `asm` feature selects the CPU's SHA-256
instructions, which merkleizing a beacon state feels directly.
MegaRedHand added a commit that referenced this pull request Aug 14, 2026
The Beacon Chain containers landing in the next commit hand out `&mut` into an
`SszList` (the validator registry, through `altair_validator_lists_mut`), which
published libssz-types 0.3.0 cannot do: it exposes only `Deref` and `Index`.
`DerefMut` and `IndexMut` landed after that release, in lambdaclass/libssz#33.

A git dependency rather than a `[patch.crates-io]` override: nothing outside
this workspace depends on libssz, so there are no two copies to unify, which is
the job a patch table would be doing. Pointing the four dependencies at the
repository says the same thing with less machinery, and avoids committing a
`[patch]` table that `shadow/cargo-patch.toml` would then collide with, since
that fragment is appended verbatim to this manifest and declares one itself.

All four move together because libssz-types depends on libssz and libssz-merkle
by path inside that repository, so a mixed set would put two incompatible
copies of libssz in the graph.

`Cargo.lock` pins the exact commit, so tracking a branch does not make the build
drift on its own; it moves when someone runs `cargo update`. Return these to a
crates.io version once a release carrying #33 exists.

`ethereum-types` and `sha2` join the workspace dependencies for the same
incoming containers. `sha2`'s `asm` feature selects the CPU's SHA-256
instructions, which merkleizing a beacon state feels directly, since a state's
`hash_tree_root` is almost entirely SHA-256 compressions.
MegaRedHand added a commit that referenced this pull request Aug 14, 2026
The Beacon Chain containers landing in the next commit hand out `&mut` into an
`SszList` (the validator registry, through `altair_validator_lists_mut`), which
published libssz-types 0.3.0 cannot do: it exposes only `Deref` and `Index`.
`DerefMut` and `IndexMut` landed after that release, in lambdaclass/libssz#33.

A git dependency rather than a `[patch.crates-io]` override: nothing outside
this workspace depends on libssz, so there are no two copies to unify, which is
the job a patch table would be doing. Pointing the four dependencies at the
repository says the same thing with less machinery, and avoids committing a
`[patch]` table that `shadow/cargo-patch.toml` would then collide with, since
that fragment is appended verbatim to this manifest and declares one itself.

All four move together because libssz-types depends on libssz and libssz-merkle
by path inside that repository, so a mixed set would put two incompatible
copies of libssz in the graph.

`Cargo.lock` pins the exact commit, so tracking a branch does not make the build
drift on its own; it moves when someone runs `cargo update`. Return these to a
crates.io version once a release carrying #33 exists.

`ethereum-types` and `sha2` join the workspace dependencies for the same
incoming containers. `sha2`'s `asm` feature selects the CPU's SHA-256
instructions, which merkleizing a beacon state feels directly, since a state's
`hash_tree_root` is almost entirely SHA-256 compressions.
MegaRedHand added a commit that referenced this pull request Aug 31, 2026
The Beacon Chain containers landing in the next commit hand out `&mut` into an
`SszList` (the validator registry, through `altair_validator_lists_mut`), which
published libssz-types 0.3.0 cannot do: it exposes only `Deref` and `Index`.
`DerefMut` and `IndexMut` landed after that release, in lambdaclass/libssz#33.

A git dependency rather than a `[patch.crates-io]` override: nothing outside
this workspace depends on libssz, so there are no two copies to unify, which is
the job a patch table would be doing. Pointing the four dependencies at the
repository says the same thing with less machinery, and avoids committing a
`[patch]` table that `shadow/cargo-patch.toml` would then collide with, since
that fragment is appended verbatim to this manifest and declares one itself.

All four move together because libssz-types depends on libssz and libssz-merkle
by path inside that repository, so a mixed set would put two incompatible
copies of libssz in the graph.

`Cargo.lock` pins the exact commit, so tracking a branch does not make the build
drift on its own; it moves when someone runs `cargo update`. Return these to a
crates.io version once a release carrying #33 exists.

`ethereum-types` and `sha2` join the workspace dependencies for the same
incoming containers. `sha2`'s `asm` feature selects the CPU's SHA-256
instructions, which merkleizing a beacon state feels directly, since a state's
`hash_tree_root` is almost entirely SHA-256 compressions.
github-merge-queue Bot pushed a commit that referenced this pull request Oct 22, 2026
## 🗒️ Description / Motivation

Hoodi's Glamsterdam fork is unscheduled:
[eth-clients/hoodi#33](eth-clients/hoodi#33)
calls off the Gloas activation planned for epoch **132352** (2026-10-26
17:42:48 UTC), which #677 picked up. Our built-in Hoodi config is
eth-clients' file byte for byte, so it follows.

## What Changed

`bin/ethlambda/assets/hoodi/config.yaml` is copied again from
`eth-clients/hoodi` at `987ca9a` (#33's head):

| Key | Before | After |
|---|---|---|
| `GLOAS_FORK_EPOCH` | `132352` | `18446744073709551615` (far future) |
| `GAS_LIMIT_SCHEDULE` | 200M gas at `132352` | removed |

`GLOAS_FORK_VERSION` and the Gloas preset/timing keys stay, since
upstream keeps them so the fork can be rescheduled with one epoch
change. `bootstrap_nodes.yaml` already matches upstream.

## Correctness / Behavior Guarantees

| Build | Effect |
|---|---|
| `main` | Gloas keys are ignored here, so only `GAS_LIMIT_SCHEDULE`
drops out of the ignored-keys warning |
| A gloas build | No longer activates Gloas on 2026-10-26, so it stays
on the same chain as the rest of Hoodi. Existing directories resume:
both the old and the new epoch are ahead of anything processed, which
`first_config_difference` accepts since #677 |

> [!NOTE]
> eth-clients/hoodi#33 is still open at the time of writing. If its
final version differs, this file should be re-copied before merging.

## Tests Added / Run

No new tests: nothing pins Hoodi's Gloas epoch.

```bash
cargo test -p ethlambda --bins --profile release-fast
git clone https://github.com/eth-clients/hoodi && git -C hoodi fetch origin pull/33/head && git -C hoodi checkout FETCH_HEAD
cmp bin/ethlambda/assets/hoodi/config.yaml hoodi/metadata/config.yaml
```

## Related Issues / PRs

- Reverts the config half of #677
- Upstream: eth-clients/hoodi#33

## ✅ Verification Checklist

- [ ] Ran `make fmt` — n/a, no Rust changes
- [ ] Ran `make lint` (clippy with `-D warnings`) — n/a, no Rust changes
- [ ] Ran `make test` (`test-consensus` plus `test-node`, at
`release-fast`) — ran `cargo test -p ethlambda --bins` only, the crate
embedding the file
tcoratger pushed a commit to tcoratger/ethlambda that referenced this pull request Oct 30, 2026
…daclass#1)

* build(deps): track libssz from git, and add ethereum-types and sha2

The Beacon Chain containers landing in the next commit hand out `&mut` into an
`SszList` (the validator registry, through `altair_validator_lists_mut`), which
published libssz-types 0.3.0 cannot do: it exposes only `Deref` and `Index`.
`DerefMut` and `IndexMut` landed after that release, in lambdaclass/libssz#33.

A git dependency rather than a `[patch.crates-io]` override: nothing outside
this workspace depends on libssz, so there are no two copies to unify, which is
the job a patch table would be doing. Pointing the four dependencies at the
repository says the same thing with less machinery, and avoids committing a
`[patch]` table that `shadow/cargo-patch.toml` would then collide with, since
that fragment is appended verbatim to this manifest and declares one itself.

All four move together because libssz-types depends on libssz and libssz-merkle
by path inside that repository, so a mixed set would put two incompatible
copies of libssz in the graph.

`Cargo.lock` pins the exact commit, so tracking a branch does not make the build
drift on its own; it moves when someone runs `cargo update`. Return these to a
crates.io version once a release carrying lambdaclass#33 exists.

`ethereum-types` and `sha2` join the workspace dependencies for the same
incoming containers. `sha2`'s `asm` feature selects the CPU's SHA-256
instructions, which merkleizing a beacon state feels directly, since a state's
`hash_tree_root` is almost entirely SHA-256 compressions.

* feat(types): add the Beacon Chain containers under a beacon namespace

The Beacon Chain is a different protocol from the Lean consensus this
repository implements, but the two need to meet at one state type: a single
BlockChainServer should be able to dispatch on the state variant rather than
existing twice. That means the containers cannot live in a crate that also
carries the state transition, because everything reaching for a container would
then also pull in blst and c-kzg. They live here instead.

Seven forks, phase0 through fulu. Containers that change between forks are
defined once per fork as plain structs deriving their SSZ encoding and
merkleization, wrapped in an enum. Deriving is the reason for that shape: the
per-fork field lists are not a growing tail. Two of phase0's fields are
*replaced* in altair, one field changes type in five separate forks, and the
state's merkle tree gains a level at electra, so a single container with
fork-conditional serialization would have to reproduce all of that by hand.

The preset is a compile-time choice, `preset-minimal` against an implicit
mainnet default, because SSZ list and vector bounds are const-generic arguments
and cannot be selected at run time. Fork *scheduling* is runtime configuration
in `config` instead, since fork epochs move per case in the upstream test
suites.

`constants` holds what the spec fixes outright, `primitives` the scalar aliases
and fixed-length byte strings, `error` what the containers return, and
`fork` the `ForkName` ordering that later gating reads.

This is a port of code verified against the consensus-specs v1.6.1 fixture
suites, where it passes 5705 mainnet and 40009 minimal cases across both
presets. Those suites exercise the state transition that consumes these
containers and are not part of this crate, so what runs here is the containers'
own round-trip and shape tests.

* feat(types): give ForkName and BeaconState a Lean variant

This is the point of moving the containers here. One BlockChainServer should
dispatch on the state variant rather than existing once per chain, and that
needs a single state type spanning both. `BeaconState::Lean` holds lean's
`State`, which gains `PartialEq` because `BeaconState` derives it.

`ForkName::Lean` is declared last, so the derived `Ord` puts it after every
beacon fork. That is what `fork >= ForkName::X` gating reads, so such a gate
reads as true for a lean state rather than accidentally selecting phase0 rules.

It is deliberately absent from `ForkName::ALL`. `ALL` is what `parse`,
`previous`, `next`, and the upstream fixture harness search, so its absence is
what makes `parse("lean")` return `None`, keeps `Fulu.next()` at `None` so a
fork upgrade cannot walk off the end into lean, and stops a fixture directory
from ever resolving to a lean case. Lean is not a point on the Beacon Chain's
fork timeline and must not be reachable by traversing it.

Only `fork_name()` and `from_ssz()` are real arms. Every other exhaustive match
on `BeaconState` answers `unreachable!()` naming itself, and the accessors on
`ForkName` do the same, except those already returning `Result`, which answer
`UnsupportedForFork`.

That asymmetry is the design, not an oversight. The guarantee that a lean state
never reaches a beacon accessor is the single `match` at the top of each
handler, not the type system, so the failure mode when that guarantee is broken
should be a named panic pointing at the function that was reached, rather than
a silent wrong answer computed from a state of the wrong shape.

* feat(types): add what the storage and networking layers need

Four additions, each pulled here by a crate that must not depend on a Beacon
Chain state transition.

`fork_digest` computes `compute_fork_data_root` from the fork schedule. Gossip
topic names and the eth2 ENR entry both need it, and both belong to the
networking layer, which cannot reach for a crate carrying blst and c-kzg just
to name a topic. The fulu branch is EIP-7892's: a blob-parameter-only fork
perturbs the digest, so mainnet's is 8c9f62fe rather than fulu's bare 82fae541.

`fork_choice` holds `LatestMessage` and `PowBlock`. Both are data a fork choice
store holds rather than behavior it runs, and the storage layer cannot depend
on the consensus layer because the dependency runs the other way.

`ForkName` gains a stable one-byte storage selector. A stored state has to
record which fork's shape it has, and the derived discriminant is not a stable
encoding: it shifts whenever a variant is added, which silently reinterprets
every state already on disk. An explicit byte survives that.

Lean's `primitives` gains conversions between the beacon `Root` and lean's
`H256`. Same 32 bytes in two types, and the boundary is crossed by every store
lookup on the beacon path, so the conversion belongs here rather than
open-coded at each call site.

* docs(types): resolve the beacon namespace's doc links

Ten rustdoc warnings, inherited from the source these containers were ported
from rather than introduced here, but there is no reason to carry them onto
main.

Four kinds:

- `shared_state_accessors` is a `macro_rules!` with no `#[macro_export]`, so
  rustdoc has no item to point at and the link never resolved. It becomes a
  plain code span, which is what it always meant.
- `SYNC_COMMITTEE_SUBNET_COUNT` is a private const. altair.rs already refers to
  it as a plain code span thirteen lines further down, so this follows the
  convention the file had already settled on.
- capella's three links spelled out a target identical to what the label
  already resolved to.
- preset.rs's two sat in an outer doc comment on `pub mod retuned`, which
  resolves in the parent scope rather than inside the module, so they pointed
  at nothing. Qualifying them with `retuned::` makes them mean what they say.

`cargo doc -p ethlambda-types --no-deps` is now warning-free.

* docs: describe the beacon namespace in ethlambda-types

Records what a reader needs before touching these types: that the Beacon Chain
is a separate protocol sharing the crate only so one state type spans both,
that ForkName::Lean sits outside ForkName::ALL on purpose, why the beacon
accessors panic rather than error on the Lean variant, and that the preset is a
compile-time feature every lean crate inherits.

Also records where the verification lives, since it is not in this repo: the
state transition that exercises these containers against the consensus-specs
fixtures is on feat/beacon-chain-stf.

* build(deps): pin the libssz crates by rev, not branch

`branch = "main"` leaves a routine `cargo update` free to move the SSZ encoder
and merkleizer that decides every `hash_tree_root`. The requirement is a commit
carrying lambdaclass/libssz#33, not whatever `main` holds today, so pin the rev
`Cargo.lock` already resolved to and make moving it an explicit edit.

Also correct what sha2's `asm` feature actually buys: on x86/x86_64 the SHA-NI
dispatch happens through `cpufeatures` with or without it, and `asm` only swaps
that dispatch's fallback. aarch64 is where the feature is the whole hardware
path.

* refactor(types): dispatch the beacon fork ladders from one place

Every beacon accessor spelled out its own seven-arm match plus a lean arm, so a
new fork meant editing twenty ladders, and the boundary panic's wording had
already drifted between them. Route the arms through `dispatch_state!`,
`dispatch_state_from!` and `dispatch_block!`, and write the panic once as
`lean_unreachable!`, in the two forms the two boundaries are crossed by.

Also, from the same reviewing pass:

- Make the fork matches in `preset::retuned` exhaustive, so a fork added later
  is a build error instead of falling silently into bellatrix's value.
- Keep `get_blob_parameters` on `Config`, where `max_blobs_per_block` and
  `compute_fork_digest` both reach it; the digest hashes the pair's epoch as
  well as its limit, so the search cannot be written once per caller.
- Convert both presets' `TERMINAL_TOTAL_DIFFICULTY` at compile time, with a test
  pinning the limbs against the decimal strings the config files carry.
- Move the `Root` <-> `H256` conversion to the beacon side of the namespace:
  beacon is the half that knows about lean, so lean's `primitives` names nothing
  beacon.

* ci: check the minimal preset

The beacon containers' SSZ bounds are const-generic arguments, so the minimal
preset is a whole second compilation of ethlambda-types that no other step
reaches: everything else builds the default mainnet preset only. The fixture
suites that select the feature live on another branch, so without this step it
would rot between merges.

* refactor(types): turn lean_unreachable! into two functions

The macro's two arms were already two unrelated diagnostics: one for a
beacon accessor handed lean's state, one for a beacon-only function handed
ForkName::Lean. Nothing about either needed macro expansion, so they become
lean_state_unreachable and lean_fork_unreachable.

Both return `!` so they still type-check in every arm position the macro
occupied: value arms, the Ok/Err arms of dispatch_state_from!, and the unit
arm of Config::with_fork_epoch. #[track_caller] is what a plain function
would otherwise have cost us, since without it every panic would report
beacon/mod.rs instead of the arm that was actually reached.

* refactor(types): replace byte_vector! with plain newtypes

Four expansions did not earn a macro, and the $(#[$doc:meta])* passthrough
it needed to carry each type's documentation was the least readable part of
the module.

Written out rather than made newtypes over a generic ByteVector<N>, which
would have deduplicated Default and AsRef but turned every construction site
into BlsPubkey(ByteVector([9; 48])) and every field read into .0.0. Type
aliases are not an option at all: BLS_PUBKEY_SIZE and KZG_POINT_SIZE are
both 48, so aliasing would make BlsPubkey and KzgCommitment the same type
and give up exactly the confusion the newtypes exist to catch.

Default stays hand-written because the standard library implements it for
arrays only up to length 32, so a derive has nothing to call for [u8; 48] or
[u8; 96]. The Debug bodies share a debug_byte_vector helper so the four
cannot drift into printing the same kind of value four ways.

* test(types): drop a_state_equals_its_own_clone

The assertion was that a derived PartialEq agrees with a derived Clone,
which holds by construction and would not fail short of a compiler bug. It
was the only test in partial_eq_tests, so the module goes with it.

* ci: scope the minimal-preset check to --lib, so the lint job can pass

The step runs in the `lint` job, which downloads no fixtures. `cargo test -p
ethlambda-types --features preset-minimal` builds every test target of the crate,
including `ssz_spectests`, whose datatest-stable harness walks
`leanSpec/fixtures/consensus/ssz` and panics with "error reading directory" when
that tree is absent. Only the `test` job fetches it, so the step exits 101 on
every run, on pull requests and on main alike, without ever reaching the thing it
exists to check.

`--lib` runs the 102 unit tests instead, which read nothing from disk. Those are
what the preset actually changes: it selects the const-generic bounds the beacon
containers are declared with, so the whole point is that the crate compiles under
it and its own assertions still hold. The fixture suite is preset-independent and
already runs, at the default preset, in the `test` job.

* feat(types): let BeaconState encode and merkleize its lean variant

`BeaconState::from_ssz(ForkName::Lean, ..)` builds `BeaconState::Lean`, while
`to_ssz` and `hash_tree_root` dispatched through `dispatch_state!` and so panicked
on it. Decoding a state and re-encoding it therefore panicked: the enum accepted a
value on the way in that it refused on the way out.

Nothing reachable today hits it, since no production caller constructs the variant
and `ForkName::parse` cannot return `Lean`. What makes it worth closing anyway is
who the first caller will be. The variant exists so one `BlockChainServer` can
dispatch on a single state type; that server will decode a state whose fork came
from configuration, where `Lean` is a legal value, and re-encoding it to store it
is the next thing it does.

The other `Lean` arms panic because there is genuinely no beacon answer to give:
asking a lean state for its `validators` or its `slot` has no meaning, and a named
panic beats a wrong number. These two are not like that. Every variant is an SSZ
container, so encoding and merkleizing mean the same thing whichever chain the
state belongs to, and the answer was already sitting in the variant.
`dispatch_state_including_lean!` is the arm list for exactly those cases, kept
separate from `dispatch_state!` so that a beacon accessor cannot reach it by
accident: it takes no `$function`, because it has no arm that panics.

One subtlety worth naming, since it is what makes a single macro body typecheck
for all eight arms. This crate has two `HashTreeRoot` traits, both blanket
implemented over `libssz_merkle::HashTreeRoot`, differing only in the wrapper type
around the digest. `containers/mod.rs` imports the beacon one alone, so lean's
state answers it too, with the same bytes lean's own trait would produce. The new
test asserts that equality rather than merely asserting the call does not panic,
because a block's `state_root` is checked against this digest. It names lean's
trait through its full path: importing it would make the call ambiguous.

`SignedBeaconBlock` is left as it is. Its `from_ssz` returns
`Error::UnsupportedForFork` for `Lean` and it has no `Lean` variant to encode, so
it has no asymmetry to fix.

* refactor(types): declare the H-types instead of depending on ethereum-types

The beacon namespace pulled `H160`, `H256` and `U256` from `ethereum-types`,
which meant the crate carried two 32-byte hashes: that one and lean's own
`primitives::H256`. Every beacon block root reaching a store lookup had to be
converted between them, and the two `HashTreeRoot` convenience traits over the
two hashes were otherwise identical.

`beacon::primitives::Root` is now `primitives::H256` itself and
`beacon::primitives::HashTreeRoot` re-exports lean's trait, so both conversions
and the duplicate trait are gone. What an external crate was actually supplying
beyond the hash is two newtypes, `H160` and `U256`, which this module now
declares.

`U256` holds the 32 little-endian bytes SSZ encodes it as, so encoding and
merkleizing are the inner array's. That puts the stored byte order opposite the
numeric one, so `Ord` is written out: derived, it would compare the least
significant byte first and invert the `terminal_total_difficulty` comparisons
that are the only place the specification orders a `uint256`. Mainnet's value
fits 128 bits and is now spelled as its decimal digits rather than as limbs;
both values stay pinned against the configuration files' decimal strings.

`H160` writes out `libssz_merkle::HashTreeRoot` because the derive drops
`is_basic_type`, and at 20 bytes wide that answer decides whether a collection
of addresses packs or pads. No container holds one today, so both answers agree
everywhere it is currently asked; writing it out is what keeps the first
container that does hold one from silently getting the other tree.

`ethereum-types` stays in `Cargo.lock` as an indirect dependency of
`ethrex-common`, which `ethlambda-p2p` needs for discv5.

* refactor(types): print an H256 as hex under Debug, not as 32 numbers

The derived `Debug` printed the inner array, so a hash came out as
`H256([171, 171, ...])`: unreadable alone, and unreadable in bulk inside the
`Debug` of a container holding thousands of roots. `ethereum-types` printed hex,
so with the beacon `Root` now being this type, keeping the derive would have
made every spec-suite assertion failure that prints a state harder to read than
before.

Same output as `Display`. Nothing asserted on the old form. `ShortRoot` is still
the way to truncate at a call site that wants it short.
tcoratger pushed a commit to tcoratger/ethlambda that referenced this pull request Oct 30, 2026
…nned eviction (lambdaclass#33)

* fix(beacon): harden the committee cache and share it where it was missing

Review of lambdaclass#22 found gaps between what the cache's documentation promised
and what the code did.

- Committee numbers use checked arithmetic. Fork choice's gossip path never
  bounds `data.index`, so a release build wrapped a hostile index into a
  real committee where the specification raises.
- `EpochCommittees::committee` rejects a slot outside the epoch it was
  built for. Callers now name that epoch separately and share the result
  through the cache, so a mismatch would have sliced the wrong shuffle.
- Epochs 0 and 1 are keyed on the genesis block, as lighthouse does, instead
  of rebuilding a whole-epoch shuffle on every lookup.
- `get_beacon_committee` is back to the per-member derivation, so a
  single-committee caller pays what it did before this PR, and the tests
  holding the sliced form to it compare two independent derivations.
- Phase0 epoch processing shares one shuffling per call rather than one
  whole-epoch shuffle per attestation, and the inclusion-delay deltas derive
  each attestation's attesters once rather than once per attester.
- Pre-Electra `process_attestation` reads the committee count off the cached
  shuffling instead of scanning the registry per attestation, and reuses the
  indexed attestation's indices as Electra already did.
- The fork-choice and sync fixture runners hold one cache per case, as the
  chain actor does across imports, so the fixtures' sibling branches test
  the cache key instead of passing whatever it does.

* perf(beacon): shuffle in place and pin the head's shufflings in the committee cache

The whole-list shuffle visited every position in every round, each visit a
random lookup into that round's window hashes plus a 64-bit `% n`.
Lighthouse's `shuffle_list` (protolambda's algorithm, which Prysm and Teku
also use) walks each round's swap pairs instead: one decision per pair, in
order, rehashing only on crossing into a new window. It shuffles the active
set in place, so a build holds one validator-sized list instead of three.
Benchmarked standalone on an M3 Pro at 2.4M validators: 1387 ms to 355 ms,
peak heap 57.6 MB to 19.2 MB.

The cache held two shufflings, exactly one block's worth, so two branches
imported in turn evicted each other's entry on every import and rebuilt it on
the next. It now holds `COMMITTEE_CACHE_CAPACITY` entries and evicts the way
lighthouse does: oldest epoch first, never the canonical head's previous,
current, or next shuffling. The chain actor pins those whenever fork choice
moves the head, reading the head's post-state from the store's state cache
only, so pinning never reconstructs a state on the import thread.

`lean_beacon_committee_cache_lookups_total{result}` counts hits, misses, and
unkeyable lookups, so the capacity can be checked against live forks.
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.

1 participant