- Target branch:
main. - Pre-push: run
zig fmt --check .,pnpm lint, and the relevant binding or spec tests before every push. - Bound everything: avoid recursion, put explicit limits on loops and allocations, fail fast, and assert invariants.
- No generated-file edits: do not manually edit generated spec test files. Regenerate them with
the corresponding
zig build run:write_*step. - Relative imports: use the
.jsextension in TypeScript ESM imports. - No
any: avoidanyandas any; use proper types or a justified Biome suppression. - Follow existing patterns before introducing new abstractions.
- Security reviews: first surface candidate security-objective violations, then use
THREAT_MODEL.mdto classify them. The model is not an allowlist. Downgrade a candidate only after verifying the applicable trust assumption against the current or planned call path. If code and documentation conflict, report the code behavior and the documentation gap. - Security documentation: update
THREAT_MODEL.mdonly when a change alters a security objective, trust assumption, trust boundary, or supported caller obligation. Document enduring API preconditions beside the owning declaration or module. Implementation changes that preserve these contracts require no security-documentation update. - Test file layout: a module holds at most one
testblock. A single inline test is fine; a second one means the tests move to a sibling<module>_test.zig, wired from the module withtest { _ = @import("<module>_test.zig"); }. Never mark a declarationpubonly to relocate a test.zig build test:tidyenforces this. - Incremental commits: after review starts, do not force-push unless a maintainer requests it.
- Communication style: do not use em dashes. Keep communication succinct and human-friendly.
Lodestar-z is a Zig library that provides consensus modules for Lodestar, the TypeScript Ethereum consensus client. It implements performance-critical paths in Zig and exposes them to Node.js through NAPI bindings. Major areas include:
- SSZ serialization, Merkleization, and tree-backed views
- Ethereum consensus types and fork-aware state transition
- BLS cryptography and parallel verification
- Persistent Merkle trees and hashing
- Fork choice and beacon-node components
- ERA file handling
- Node.js bindings used by Lodestar
Ethereum consensus spec tests are the source of truth for consensus behavior.
src/
beacon_node/ # Beacon-node components
bls/ # BLS types, verification, and worker pool
clock/ # Slot and epoch clock
config/ # Runtime and network configuration
consensus_types/ # Fork-specific Ethereum consensus types
constants/ # Protocol constants
era/ # ERA file handling
fork_choice/ # Fork-choice implementation
fork_types/ # Type-erased, fork-aware wrappers
hashing/ # SHA-256 and Merkleization
persistent_merkle_tree/ # Structural sharing and lazy hashing
preset/ # Mainnet and minimal presets
ssz/ # SSZ types, serialization, and views
state_transition/ # Block, epoch, and slot processing
bindings/
napi/ # Zig NAPI implementation
src/ # JavaScript and TypeScript public API
test/ # Binding tests
bench/ # Zig and binding benchmarks
test/
fuzz/ # Fuzz harnesses and corpora
int/ # Integration tests
spec/ # Consensus, SSZ, and BLS spec tests
examples/ # Example programs
scripts/ # Zig maintenance and download tools
The repository uses Zig 0.16.0 and pnpm 10.x.
# Build all default artifacts
zig build
# Check Zig formatting
zig fmt --check .
# Format Zig files
zig fmt .
# Run repo-wide lint rules that `zig fmt` cannot express
zig build test:tidy
# Run all unit tests
zig build test
# Run one module's tests
zig build test:ssz
zig build test:bls
zig build test:state_transition
# Filter a module test
zig build test:ssz -Dtest:ssz.filters="test name"
# Filter the aggregate test step
zig build test -- --test-filter "test name"Prefer a targeted module test while iterating, do not run zig build test unless necessary.
# Download vectors pinned by build.zig.zon
zig build run:download_spec_tests
# Generate test sources
zig build run:write_spec_tests
zig build run:write_ssz_generic_spec_tests
zig build run:write_ssz_static_spec_tests
zig build run:write_bls_spec_tests
# Run generated suites
zig build test:spec_tests -Dpreset=minimal
zig build test:ssz_generic_spec_tests -Dpreset=minimal
zig build test:ssz_static_spec_tests -Dpreset=minimal
zig build test:bls_spec_tests -Dpreset=minimal
# Filter a suite
zig build test:spec_tests -Dtest:spec_tests.filters="pattern" -Dpreset=minimalUse the minimal preset for faster iteration, but run mainnet when behavior depends on preset values. Do not assume minimal and mainnet constants are interchangeable.
# Install dependencies
pnpm install
# Build bindings
zig build build-lib:bindings
zig build build-lib:bindings -Doptimize=ReleaseSafe
# Build for a specific preset through package scripts
pnpm prepare-mainnet
pnpm prepare-minimal
# Run binding tests
pnpm test
# Run Biome
pnpm lint
pnpm exec biome check --write .Binding tests require a compatible zig-out/lib/bindings.node; rebuild it after changing Zig NAPI
code or the selected preset.
# ERA-backed integration tests
zig build run:download_era_files
zig build test:int
# Representative benchmarks
zig build run:bench_ssz_attestation -Doptimize=ReleaseSafe
zig build run:bench_ssz_block -Doptimize=ReleaseSafe
zig build run:bench_ssz_state -Doptimize=ReleaseSafe
zig build run:bench_hashing -Doptimize=ReleaseSafe
zig build run:bench_merkle_node -Doptimize=ReleaseSafe
zig build run:bench_merkle_gindex -Doptimize=ReleaseSafe
zig build run:bench_process_block -Doptimize=ReleaseSafe
zig build run:bench_process_epoch -Doptimize=ReleaseSafeThe project style guide is .gemini/styleguide.md, a Lodestar-specific adaptation of TigerStyle.
Important requirements include:
- Safety, performance, and developer experience, in that order.
- No recursion. Put a fixed upper bound on work and memory.
- Assert preconditions, postconditions, and invariants. Test both valid and invalid boundaries.
- Handle every error and clean up partially initialized resources.
- Prefer static or startup allocation where appropriate, but do not avoid dynamic allocation blindly.
- Use explicitly sized integer types for persisted and protocol values.
- Pass values larger than 16 bytes by
*constwhen copying is not intended. - Construct large objects in place where practical.
- Keep allocation and matching cleanup together, separated from surrounding logic by blank lines.
- Explain why a non-obvious design or safety decision is correct.
- Run
zig fmton every Zig change.
Bindings use ES modules and Biome:
- Use double quotes and named exports.
- Use
.jsextensions for relative TypeScript imports. - Use
camelCasefor functions and variables,PascalCasefor types and classes, andUPPER_SNAKE_CASEfor constants. - Avoid
anyandas any. If unavoidable, add a suppression with the full rule and rationale. - Keep declarations in
bindings/src/*.d.tssynchronized with JavaScript wrappers and NAPI exports. - Do not edit native binaries under
zig-out/; they are build outputs.
The default number of comments in new code is zero.
Add a comment only when the code cannot carry important information, such as:
- A precondition not visible in the signature
- A non-obvious consensus or protocol rule
- Design, invariants, or non-obvious algorithms of a public or self-contained module
- An ordering or workaround that looks unnecessary but is correctness-critical
- Rationale needed to prevent a future regression
Do not comment to restate what the code does. If the reason is clear from the surrounding names and structure, omit the comment. Do not narrate a change or record what the code used to do. That belongs in the commit message. Keep enduring correctness constraints and non-obvious rationale next to the code they govern.
Prefer a clearer name, a smaller function, or a named constant over a comment explaining unclear
code. Use // for implementation comments and /** */ JSDoc for public API documentation.
Prefer the active voice over passive voice. For example, instead of:
Invalid cryptographic inputs return false; Malformed inputs throw an error.
Prefer:
/// Returns false on cryptographic failure. Throws for malformed inputs.
Fork progression is:
phase0 -> altair -> bellatrix -> capella -> deneb -> electra -> fulu ->
gloas -> heze
Keep fork-specific types and logic in the corresponding namespace or module. Use existing fork
guards and Any* wrappers instead of unchecked casts. Changes to a fork generally apply to later
forks unless the consensus specification explicitly overrides them.
- Compile-time protocol values live in
src/preset/andsrc/constants/. - Runtime chain configuration lives in
src/config/. - Both mainnet and minimal must compile and pass their relevant tests.
- Size stack buffers and fixed collections from protocol constants only when the input is guaranteed to have that protocol bound. Public binding inputs need explicit validation or a dynamic fallback.
SSZ definitions are compile-time namespaces that expose the value type, default value, serialization, deserialization, hash-tree-root, and view operations. Match the existing fixed versus variable type distinction and preserve canonical SSZ limits. For consensus changes, update types, fork wrappers, and spec test generation together as needed.
Zig code must make allocator and ownership boundaries explicit:
- Pair every allocation with
deferorerrdeferimmediately. - Clean up all successfully initialized elements when initialization fails partway through.
- Do not free through a different allocator than the one used to allocate.
- Treat NAPI values and slices as scoped to the environment and callback rules documented by zapi.
- Avoid retaining pointers into growable collections across operations that may reallocate them.
- Prefer bounded stack buffers only when their worst-case size is safe for every calling context.
- Zig exports are implemented under
bindings/napi/. - Public JavaScript wrappers and declarations live under
bindings/src/. - Keep errors at the boundary specific and stable.
- Validate untrusted JavaScript lengths, indexes, and byte encodings before native access.
- Account for worker teardown and Node.js worker isolation when changing global native state.
- Tests must be deterministic and bounded.
- Add regression tests for bug fixes where the failure can be reproduced reliably.
- Test positive and negative boundaries, especially lengths, indexes, serialization offsets, and partial initialization.
- Use
std.testing.allocatorin unit tests to detect leaks. - Prefer exact error assertions over accepting any failure.
- Run the narrowest relevant test during development and the complete affected module before push.
- Run consensus spec tests for consensus types, SSZ behavior, or state-transition changes.
- Run both minimal and mainnet tests when preset-dependent limits or behavior change.
- Build and run binding tests for changes under
bindings/or exported native APIs. - Fuzz parsers, deserializers, and cryptographic boundaries when introducing new input shapes.
Keep tests beside the code they cover without letting them crowd it out.
- A module holds at most one
testblock. One inline test is a usage example and stays put; a second one makes it a suite, and suites live in a sibling<module>_test.zig. A module is therefore either inline (one test) or extracted (only the wiring block), never both. - Name the test file after its module in snake_case.
slot_math.zigpairs withslot_math_test.zig, andNode.zigpairs withnode_test.zig. - Wire the import from the module under test, not from the package
root.zig:
// slot_math.zig
test {
_ = @import("slot_math_test.zig");
}Keeping the import local means the pairing survives moving or deleting the module, and no package root has to be updated.
- Move only the tests. Leave the test bodies unchanged and rebuild the prelude they relied on in the new file as imports and aliases.
- Private test helpers move with the tests they serve.
- Do not widen a declaration to
pubonly to relocate a test. Tests that exercise private internals stay inline. - Prefix memory safety regression test names with
memory_safety:and keep them in the sibling test file for the module they cover. - Tests covering a whole package belong in
root_test.zig, wired from the packageroot.zig.
Create branches from main. Use Conventional Commit messages:
feat:new functionalityfix:bug fixesrefactor:behavior-preserving code changesperf:performance improvementstest:test-only changesdocs:documentation changeschore:maintenance
Keep commits focused. After review begins, add incremental commits rather than rewriting history unless a maintainer asks otherwise.
Title: conventional-commit prefix, then an imperative subject of about four words, lowercase, no
period. refactor: extract tests to _test.zig.
Description: use the Lodestar template and scale the detail to the change.
**Motivation**
The problem, at whatever length it needs. Quote the error or log, link the issue or discussion.
**Description**
One sentence saying what this does.
- Change, named by identifier
- detail
TODO:
- follow-up, if any
**AI Assistance Disclosure**
One line.- Motivation is never empty. It states the problem at whatever length the problem needs, and links the issue, review comment, or discussion it came from.
- Quote the evidence: the error, the log, the command output, in a code block. Long output goes in
a
<details>block. - Description length tracks the diff. A one-line fix gets a sentence; a larger change can use bullets, tables, or short subsections to explain behavior and relevant evidence.
- Bullets name concrete changes by identifier in backticks.
- Use tables or short subsections when they make before/after behavior, tradeoffs, or validation easier to review. Use text or code blocks instead of screenshots of text.
- Put the main rationale in Motivation. Include design constraints and validation evidence in Description when they help reviewers assess the change; link CI instead of repeating routine output.
- Do not list example files to illustrate a point.
- Code blocks are for real output or a before/after of an interface, never for explaining a concept.
Disclose AI assistance in the PR description, following Lodestar's convention. State whether the implementation was primarily AI-authored or whether AI was used only for codebase exploration.
zig fmt --check .zig build test:tidy- Run relevant tests, never run slow
zig build testunless changes touch spec logic - Relevant spec tests for consensus, SSZ, or BLS changes
pnpm lintfor binding source changes- Rebuild bindings and run
pnpm testfor binding or NAPI changes - Confirm no generated test source or build output was edited manually
- Confirm both ownership cleanup and error paths are covered
- Add a failing regression test when practical.
- Identify the violated invariant or missing boundary check.
- Fix the bug and all partial-failure cleanup paths.
- Run the affected module tests.
- Run spec or binding tests when the changed boundary requires them.
- Update the type in the relevant
src/consensus_types/<fork>.zigfile. - Propagate it through later forks and
src/fork_types/where required. - Update state-transition logic that consumes the type.
- Regenerate the relevant spec tests.
- Run minimal and mainnet spec suites.
- Implement the native export under
bindings/napi/. - Register it through the existing zapi export pattern.
- Add or update the wrapper and declaration under
bindings/src/. - Validate all JavaScript-controlled inputs at the native boundary.
- Add tests under
bindings/test/. - Build ReleaseSafe bindings and run
pnpm testandpnpm lint.
- Locate the fixed, variable, serialized, and tree-view paths affected by the change.
- Preserve canonical limits and offset validation.
- Add unit tests for valid values and malformed boundary cases.
- Regenerate and run generic or static SSZ spec tests.
- Fuzz the changed parser or deserializer when applicable.
The primary reference is the
Ethereum consensus-specs repository. The test version
is pinned in build.zig.zon; reference that exact version when implementation details differ from
upstream master.
Typical mappings are:
| Consensus specification area | Lodestar-z location |
|---|---|
| SSZ containers and fork types | src/consensus_types/, src/fork_types/ |
| Block processing | src/state_transition/block/ |
| Epoch processing | src/state_transition/epoch/ |
| Slot processing | src/state_transition/slot/ |
| Fork choice | src/fork_choice/ |
| Hashing and Merkleization | src/hashing/, src/persistent_merkle_tree/, src/ssz/ |
| Node.js integration | bindings/napi/, bindings/src/ |
When porting Lodestar behavior, preserve consensus semantics rather than TypeScript implementation details. Use the spec vectors to verify equivalence and document intentional differences caused by Zig ownership, bounded memory, or NAPI constraints.