Skip to content

docs: add initial roadmap - #1

Merged
MegaRedHand merged 3 commits into
mainfrom
add-roadmap
Dec 23, 2025
Merged

MegaRedHand merged 3 commits into
mainfrom
add-roadmap

Conversation

@MegaRedHand

@MegaRedHand MegaRedHand commented Dec 23, 2025 •

Copy link
Copy Markdown
Collaborator

This PR adds a roadmap to the readme.

@MegaRedHand
MegaRedHand marked this pull request as ready for review December 23, 2025 19:29
@MegaRedHand
MegaRedHand merged commit e2ad122 into main Dec 23, 2025
@MegaRedHand
MegaRedHand deleted the add-roadmap branch December 23, 2025 19:29
@claude claude Bot mentioned this pull request Jan 30, 2026
2 of 3 tasks
pablodeymo pushed a commit that referenced this pull request Feb 11, 2026
See lambdaclass/ethrex#6187 for more context.

Review agents make use of `#N` for numbering comments, which triggers
GitHub references, linking all reviewed PRs with links to the first PRs
of the repo (see #1 for an example).
MegaRedHand added a commit that referenced this pull request May 14, 2026
* lib.rs (#5): decode proposer proposal pubkey once and reuse it for the
  singleton Type-1 wrap and the merge inputs; was deserialized twice on
  the crypto hot path.
* store.rs (#4): block / attestation slot u64→u32 overflow now maps to
  a dedicated `SlotOutOfRange(u64)` variant instead of being misreported
  as `ProposerSignatureVerificationFailed`.
* store.rs (#1): explicitly document the security caveat of the empty-
  SNARK structural-only branch (proposer XMSS not crypto-verified) and
  note the two upstream mitigations (STF `process_block_header` rejects
  wrong proposer_index; per-attestation crypto still runs at gossip
  ingestion). The structural log line now flags it explicitly.
* crypto/src/lib.rs (#2): outer Type-2 decompression failure in
  `split_type_2_by_message` returns a new `DeserializationFailed`
  variant instead of `ChildDeserializationFailed(0)`, which had implied
  a child at index 0 had failed.
* types/src/block.rs (#3): annotate the intentional duplication of
  proof bytes between `TypeOneMultiSignature::info.proof` and the outer
  `proof` field — mirrors leanSpec PR #717's shape so a Type-1 embedded
  inside a Type-2's info[i] reads the same as a standalone Type-1.
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.
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