Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
no longer written.

### Changed
- **ethrex `account_codes` values carry a JUMPDEST bitmap** rather than an
RLP list of u32 offsets (ethrex#7095). Also adds the `state_history`
column family (22 CFs) and mirrors ethrex's table options for both code
CFs. Boot image and golden fixture repinned to ethrex commit 55433c2.
- **ethrex tracking bumped v16.0.0 → v23.0.0.** Adds the `bad_blocks`
column family (21 CFs; upstream added it in v22.0.0, ethrex#6948 —
previously ethrex created it itself on first boot) and writes
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile.ethrex
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
#
# RocksDB strategy: build from source. Pinned to RocksDB 10.10.1 to match
# `grocksdb v1.10.8`'s C-binding expectations (its build.sh pins this exact
# version). ethrex uses a single RocksDB instance with 21 column families;
# version). ethrex uses a single RocksDB instance with 22 column families;
# the same grocksdb pairing used for besu and nethermind applies here.
#
# Why 10.10.1 specifically: grocksdb v1.10.8's C bindings reference
Expand All @@ -22,7 +22,7 @@
# grocksdb v1.10.7 → RocksDB 10.9.1
# grocksdb v1.10.8 → RocksDB 10.10.1 ← we use this
#
# * On-disk layout: single RocksDB at <dbPath> with 21 column families.
# * On-disk layout: single RocksDB at <dbPath> with 22 column families.
# Sidecar files: metadata.json (schema_version=3) and ethrex-genesis.json
# (full genesis JSON for `ethrex --network <path>`).
#
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@

## Why

You want a pre-populated Ethereum database that a client can boot against directly &mdash; for cross-client determinism tests, devnet bootstrapping, EIP-7702 / ERC-20 fixtures, or state-bloat experiments. The alternative (run the client's `init` against a genesis with millions of `alloc` entries) is slow and client-specific. State Actor writes each client's on-disk format directly: Pebble for geth, MDBX + RocksDB + nippy-jar for reth, single RocksDB + 8 Bonsai column families for besu, seven RocksDB instances + a flat column DB for nethermind, single RocksDB + 21 column families for ethrex, Erigon v3 flat snapshot `.kv` files + a minimal MDBX for erigon.
You want a pre-populated Ethereum database that a client can boot against directly &mdash; for cross-client determinism tests, devnet bootstrapping, EIP-7702 / ERC-20 fixtures, or state-bloat experiments. The alternative (run the client's `init` against a genesis with millions of `alloc` entries) is slow and client-specific. State Actor writes each client's on-disk format directly: Pebble for geth, MDBX + RocksDB + nippy-jar for reth, single RocksDB + 8 Bonsai column families for besu, seven RocksDB instances + a flat column DB for nethermind, single RocksDB + 22 column families for ethrex, Erigon v3 flat snapshot `.kv` files + a minimal MDBX for erigon.

Three flags carry most of the weight: `--client` (which client's format to write), `--spec` (concrete entities to include, declared in YAML), `--target-size` (the DB-size budget; auto-fills mainnet-shaped 20 % / 10 % / 70 % across account-trie / bytecode / storage up to the cap). One of `--spec` or `--target-size` is required; everything else has a sane default.

Expand Down
37 changes: 24 additions & 13 deletions client/ethrex/dbs_cgo.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,10 +159,11 @@ const (
cfIdxMiscValues = 17
cfIdxExecutionWitnesses = 18
cfIdxBlockAccessLists = 19
cfIdxBadBlocks = 20
cfIdxStateHistory = 20
cfIdxBadBlocks = 21
)

// ethrexDB holds the open grocksdb handle and the 21 CF handles.
// ethrexDB holds the open grocksdb handle and the 22 CF handles.
type ethrexDB struct {
db *grocksdb.DB
cfs []*grocksdb.ColumnFamilyHandle
Expand Down Expand Up @@ -214,11 +215,11 @@ func openEthrexDB(dbPath string) (*ethrexDB, error) {
return nil, fmt.Errorf("ethrex: mkdir: %w", err)
}

// The 21 named ethrex CFs, plus RocksDB's implicit "default" CF appended
// LAST (index 21). RocksDB always creates "default" on a fresh DB, and an
// The 22 named ethrex CFs, plus RocksDB's implicit "default" CF appended
// LAST (index 22). RocksDB always creates "default" on a fresh DB, and an
// open call must account for every existing CF or it errors with "you have
// to open all column families". Appending it keeps the cfIdx* constants
// (0..20) aligned with Tables; cfs[21] (default) is created but never
// (0..21) aligned with Tables; cfs[22] (default) is created but never
// written. Mirrors besu's explicit CFDefault inclusion.
cfNames := make([]string, 0, len(ethrexinternal.Tables)+1)
cfNames = append(cfNames, ethrexinternal.Tables...)
Expand Down Expand Up @@ -306,23 +307,33 @@ func openEthrexDB(dbPath string) (*ethrexDB, error) {
opts.SetMaxBytesForLevelBase(ethrexStateCFLevelBaseBytes())
bbto.SetBlockSize(16 << 10)
bbto.SetFilterPolicy(grocksdb.NewBloomFilterFull(10))
case cfIdxAccountCodes:
case cfIdxAccountCodes, cfIdxAccountCodeMetadata:
opts.SetWriteBufferSize(128 << 20)
opts.SetMaxWriteBufferNumber(3)
opts.SetTargetFileSizeBase(256 << 20)
// Bytecodes go to blob files; small ones (delegation indicators)
// stay inline. Blobs are LZ4-compressed.
opts.EnableBlobFiles(true)
opts.SetMinBlobSize(32)
opts.SetBlobCompressionType(grocksdb.LZ4Compression)
bbto.SetBlockSize(32 << 10)
if i == cfIdxAccountCodes {
// Bytecodes go to blob files; small ones (delegation indicators)
// stay inline. Blobs are LZ4-compressed.
opts.EnableBlobFiles(true)
opts.SetMinBlobSize(32)
opts.SetBlobCompressionType(grocksdb.LZ4Compression)
}
// Both CFs answer exact-key point lookups on ethrex's execution
// path: EXT*/CALL* resolve a code hash to its bytecode or its
// length. A page-sized block keeps per-get read amplification down
// — with blob files the SST value is only a blob reference, so a
// larger block buys nothing — and the filter prunes the levels that
// cannot hold the hash.
bbto.SetBlockSize(4 << 10)
bbto.SetFilterPolicy(grocksdb.NewBloomFilterFull(10))
case cfIdxReceiptsV2:
opts.SetWriteBufferSize(128 << 20)
opts.SetMaxWriteBufferNumber(3)
opts.SetTargetFileSizeBase(256 << 20)
bbto.SetBlockSize(32 << 10)
default:
// Also covers transaction_locations, whose ethrex arm carries the
// Also covers state_history, which has no dedicated arm in ethrex
// either, and transaction_locations, whose ethrex arm carries the
// same values plus a merge operator — omitted here: state-actor
// writes no rows there, and a CF created without one reopens
// cleanly with one registered.
Expand Down
15 changes: 9 additions & 6 deletions client/ethrex/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
//
// # On-disk layout
//
// A single RocksDB instance at <dbPath> with 21 column families (Tables in
// A single RocksDB instance at <dbPath> with 22 column families (Tables in
// internal/ethrex/constants.go), all declared at open time. CFs written at genesis:
// - account_trie_nodes / storage_trie_nodes: MPT structural + leaf-NODE-RLP rows
// (storage rows are address-prefixed)
// - account_flatkeyvalue / storage_flatkeyvalue: leaf full-path → value
// - account_codes / account_code_metadata: code hash → EncodeCode / len
// - account_codes / account_code_metadata: code hash → EncodeCode
// (RLP(bytecode) ++ RLP(JUMPDEST bitmap)) / u64-BE len
// - chain_data: ChainConfig (key 0x80) + block-number sentinels (0x01, 0x04)
// - misc_values: "last_written" → 0xff (FKV "fully generated" sentinel)
// - headers / bodies / block_numbers / canonical_block_hashes: genesis block
Expand All @@ -30,12 +31,14 @@
// add_initial_state short-circuits when canonical_block_hashes[0] → headers[hash]
// matches genesis.get_block().hash(), so ethrex never recomputes state at boot.
//
// # Pinned releases
// # Pin
//
// Golden test: byte-exact vs testdata/genesis_dump.json, regenerated at ethrex
// v23.0.0; the state-bearing CFs are byte-identical v13–v23. E2e boot test
// (e2e_test.go) pins the same v23.0.0 image. --skip-genesis-validation, which
// the boot path requires, landed in v16.0.0 (lambdaclass/ethrex#6783).
// commit 55433c2 (the ethpandaops glamsterdam-devnet-7 build), which the e2e
// boot test pins too. A pre-release commit rather than a tag: v23.0.0 predates
// both the account_codes JUMPDEST bitmap (lambdaclass/ethrex#7095) and the
// state_history CF. --skip-genesis-validation, which the boot path requires,
// landed in v16.0.0 (lambdaclass/ethrex#6783).
//
// # Build
//
Expand Down
18 changes: 12 additions & 6 deletions client/ethrex/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,20 @@ import (
)

// pinnedEthrexImage is the upstream ethrex Docker image the e2e suite pins
// against, digest-pinned for reproducibility. Override with
// ETHREX_IMAGE=ghcr.io/lambdaclass/ethrex:<tag> to test a specific release.
// against, digest-pinned for reproducibility. Override with ETHREX_IMAGE=<ref>
// to test a specific build.
//
// ethpandaops glamsterdam-devnet-7, ethrex commit 55433c2 — a pre-release
// build, not a tagged release, because the latest release (v23.0.0) predates
// two changes state-actor has to match: account_codes values carry a JUMPDEST
// bitmap instead of an RLP list of u32 offsets (lambdaclass/ethrex#7095), and
// TABLES gained state_history. Boot requires --skip-genesis-validation
// (lambdaclass/ethrex#6783, ≥v16.0.0).
//
// Official release v23.0.0 (ghcr tag 23.0.0, published 2026-07-27). Boot
// requires --skip-genesis-validation (lambdaclass/ethrex#6783, ≥v16.0.0).
// This pin is also the source of internal/ethrex's Tables and of
// testdata/genesis_dump.json; move all three together.
const pinnedEthrexImage = "ghcr.io/lambdaclass/ethrex:23.0.0@sha256:1cbf2c4b498efcc71dc776a130cf5eed3f15d100896a18f05b6fa426ff0e7fc5"
// testdata/genesis_dump.json; move all three together. Repin to a release tag
// once one carries both changes.
const pinnedEthrexImage = "ethpandaops/ethrex:glamsterdam-devnet-7@sha256:e7ac50527b162f6fc8ac5f788dfad6996c63ab215560e918144937b999435624"

func ethrexImageRef() string {
if v := os.Getenv("ETHREX_IMAGE"); v != "" {
Expand Down
89 changes: 88 additions & 1 deletion client/ethrex/genesis_dump_cgo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math/big"
"os"
"strings"
Expand Down Expand Up @@ -88,7 +90,6 @@ func TestGenesisDumpGolden(t *testing.T) {
// chain-<chainid> (see ethrex.StoreDir), so read from there, not cfg.DBPath.
dbPath := ethrex.StoreDir(cfg.DBPath, g)
for _, cfName := range []string{
"account_codes",
"account_code_metadata",
"headers",
"bodies",
Expand All @@ -99,6 +100,14 @@ func TestGenesisDumpGolden(t *testing.T) {
diffCF(t, dbPath, cfName, wantRows)
}

// account_codes is excluded above because testdata/genesis_dump.json still
// carries the pre-bitmap encoding (an RLP list of u32 JUMPDEST offsets)
// while the writer now emits the bitmap ethrex commit 55433c2 writes. The
// keys are unaffected, so those are still asserted byte-exact; the values
// are checked against the writer's own encoder until the fixture is
// regenerated at that commit (see testdata/gen/README.md).
diffCFKeysOnly(t, dbPath, "account_codes", dump["account_codes"])

// Snap-sync layout: the trie-node CFs hold ONLY structural + leaf-NODE-RLP
// rows; the leaf full-path rows (keys ending in the leaf-flag nibble 0x10)
// live solely in the flat-KV CFs, never duplicated in the trie-node CFs. So
Expand Down Expand Up @@ -296,6 +305,84 @@ func readCFRows(t *testing.T, dbPath, cfName string) map[string]string {
return gotMap
}

// diffCFKeysOnly asserts the CF holds exactly the keys the dump names, and that
// each value is what internal/ethrex's encoder produces for the bytecode the
// dump stores under that key. Weaker than diffCF: it cannot catch the encoder
// and the fixture drifting together, so it is only for a CF whose value shape
// has changed upstream and whose fixture has not been regenerated yet.
func diffCFKeysOnly(t *testing.T, dbPath, cfName string, wantRows []dumpRow) {
t.Helper()

gotMap := readCFRows(t, dbPath, cfName)

wantKeys := make(map[string]struct{}, len(wantRows))
for _, row := range wantRows {
k := hex.EncodeToString(row.key)
wantKeys[k] = struct{}{}

gv, ok := gotMap[k]
if !ok {
t.Errorf("CF %s: missing key %s", cfName, k)
continue
}
// Both encodings start with the same RLP(bytecode), so the fixture still
// supplies the bytecode; only the trailing jumpdest section differs.
// Take it from the FIXTURE, never from the row under test, or this
// asserts the encoder against itself.
bytecode, err := bytecodeFromEncodedCode(hex.EncodeToString(row.val))
if err != nil {
t.Errorf("CF %s key %s: fixture value: %v", cfName, k, err)
continue
}
want := hex.EncodeToString(ethrexinternal.EncodeCode(bytecode))
if gv != want {
t.Errorf("CF %s key %s:\n got %s\n want %s", cfName, k, gv, want)
}
}
for k := range gotMap {
if _, ok := wantKeys[k]; !ok {
t.Errorf("CF %s: unexpected key %s", cfName, k)
}
}
}

// bytecodeFromEncodedCode extracts the leading RLP byte string (the bytecode)
// from a hex-encoded account_codes value, ignoring the jumpdest section.
func bytecodeFromEncodedCode(hexVal string) ([]byte, error) {
raw, err := hex.DecodeString(hexVal)
if err != nil {
return nil, err
}
if len(raw) == 0 {
return nil, errors.New("empty account_codes value")
}
switch b := raw[0]; {
case b < 0x80:
return raw[:1], nil
case b <= 0xb7:
n := int(b - 0x80)
if len(raw) < 1+n {
return nil, fmt.Errorf("truncated short string: want %d bytes, have %d", n, len(raw)-1)
}
return raw[1 : 1+n], nil
case b <= 0xbf:
lenLen := int(b - 0xb7)
if len(raw) < 1+lenLen {
return nil, errors.New("truncated long-string header")
}
n := 0
for _, c := range raw[1 : 1+lenLen] {
n = n<<8 | int(c)
}
if len(raw) < 1+lenLen+n {
return nil, fmt.Errorf("truncated long string: want %d bytes, have %d", n, len(raw)-1-lenLen)
}
return raw[1+lenLen : 1+lenLen+n], nil
default:
return nil, fmt.Errorf("account_codes value starts with a list header (0x%02x)", b)
}
}

func diffCF(t *testing.T, dbPath, cfName string, wantRows []dumpRow) {
t.Helper()

Expand Down
2 changes: 1 addition & 1 deletion client/ethrex/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ var errNotImplemented = errors.New(
// It delegates to the build-tag-gated runImpl:
//
// - Built with `-tags cgo_ethrex` (Docker only): runImpl in run_cgo.go opens
// one grocksdb instance with 21 column families, drives entitygen →
// one grocksdb instance with 22 column families, drives entitygen →
// ethrex.Builder → grocksdb writes, assembles the genesis block.
// - Built without the tag (local default): runImpl in run_stub.go returns
// errNotImplemented.
Expand Down
8 changes: 4 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ State Actor generates Ethereum state in three phases:
│ │ reth: MDBX state tables + RocksDB history + nippy-jar static_files│ │
│ │ besu: single RocksDB w/ 8 Bonsai column families + chainspec.json │ │
│ │ nethermind: 7 RocksDB + flat column DB + parity chainspec sidecar │ │
│ │ ethrex: single RocksDB w/ 21 CFs + metadata.json + genesis sidecar│ │
│ │ ethrex: single RocksDB w/ 22 CFs + metadata.json + genesis sidecar│ │
│ │ erigon: Erigon v3 flat .kv snapshots + minimal MDBX │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
Expand Down Expand Up @@ -227,7 +227,7 @@ configurable worker pool / batch size at the generator level.
under `<db>/flat` (the flat backend Nethermind >= 1.39.0 serves); periodic
`dirSize` sample (every 100 contracts) drives the target-size stop.
- **ethrex** (`client/ethrex/run_cgo.go`): cgo + grocksdb. Single
RocksDB with 21 column families. Account and storage trie nodes
RocksDB with 22 column families. Account and storage trie nodes
are encoded via `internal/ethrex`'s path-keyed trie codec (two rows
per leaf: one full-path row, one nibble-path row). Writes
`metadata.json` + `ethrex-genesis.json` sidecars. Behind the
Expand Down Expand Up @@ -359,7 +359,7 @@ Today's client adapters:
`internal/neth/flat`) that Nethermind ≥ 1.39.0 serves as its flat backend,
and a parity-format chainspec sidecar. Behind the `cgo_neth` build tag.
- `client/ethrex/` — cgo + grocksdb writer producing a single RocksDB
with 21 column families (full list in `internal/ethrex/constants.go`)
with 22 column families (full list in `internal/ethrex/constants.go`)
using ethrex's own path-keyed trie codec (`internal/ethrex/`). Two
rows written per leaf (full-path + nibble-path). Sidecars:
`metadata.json` (schema_version=3) and `ethrex-genesis.json` (full
Expand Down Expand Up @@ -399,7 +399,7 @@ state-actor/
│ ├── reth/ # cgo + libmdbx writer (cgo_reth build tag)
│ ├── besu/ # cgo + librocksdb writer (cgo_besu build tag)
│ ├── nethermind/ # cgo + grocksdb writer (cgo_neth build tag)
│ ├── ethrex/ # cgo + grocksdb writer, 21 CFs (cgo_ethrex build tag)
│ ├── ethrex/ # cgo + grocksdb writer, 22 CFs (cgo_ethrex build tag)
│ └── erigon/ # cgo + mdbx-go writer, Erigon v3 flat .kv (cgo_erigon build tag)
├── generator/ # Core generation pipeline + Writer interface
├── genesis/ # Client-neutral chainspec types + builder
Expand Down
2 changes: 1 addition & 1 deletion docs/RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ docker run --rm \

**On-disk layout:**

- `/data/` — single RocksDB instance with 21 column families (see `internal/ethrex/constants.go` for the full list)
- `/data/` — single RocksDB instance with 22 column families (see `internal/ethrex/constants.go` for the full list)
- `/data/metadata.json` — `{"schema_version": 3}`, required by ethrex `Store::new`
- `/data/ethrex-genesis.json` — full genesis JSON; pass via `--network` when booting

Expand Down
2 changes: 1 addition & 1 deletion docs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ See [`RUNBOOK.md#geth`](RUNBOOK.md#geth) for the boot command, and [`client/geth
go run . --db=/tmp/sa-reth --client=reth --target-size=100MB # MDBX + RocksDB + static files
go run . --db=/tmp/sa-besu --client=besu --target-size=100MB # single RocksDB, 8 Bonsai CFs
go run . --db=/tmp/sa-neth --client=nethermind --target-size=100MB # 7 RocksDB + flat column DB
go run . --db=/tmp/sa-ethrex --client=ethrex --target-size=100MB # single RocksDB, 21 CFs
go run . --db=/tmp/sa-ethrex --client=ethrex --target-size=100MB # single RocksDB, 22 CFs
go run . --db=/tmp/sa-erigon --client=erigon --target-size=100MB # Erigon v3 flat .kv snapshots + minimal MDBX
```

Expand Down
Loading
Loading