Lesson 1 — Workspace + Reth + Malachite (Stages 1-3)
Question
Turn an empty cargo init into openhl's 11-crate Rust workspace and pin Reth v2.2.0 and Malachite v0.5.0 by git SHA, ending with a clean cargo check --workspace.
Principle (minimum model)
- One workspace, 11 members. Ten empty library crates (
types,codec,clob,oracle,funding,liquidation,vault,evm,consensus,node) plus thebin/openhlbinary. Each member inherits version, edition, license and lints from the rootCargo.toml. - Declare every dependency once.
[workspace.dependencies]holds every version; member crates write{ workspace = true }, so a Reth bump is a one-line change. - Reth pinned by git rev.
reth-* = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7…" }— the commit of thev2.2.0tag. Reth's node crates are not on crates.io, so a git pin is the only option, and a rev (not a branch) keeps it reproducible. - Malachite pinned the same way.
informalsystems-malachitebft-* = { git = "https://github.com/informalsystems/malachite", rev = "9ef02b33…" }(v0.5.0). - Declared is not fetched. Cargo ignores
[workspace.dependencies]entries that no member uses, so this lesson'scargo checkfinishes in seconds. The big Reth download and compile arrives in Lesson 5, when the first crate actually depends on it. - Answer key = two commits.
75be9de(workspace skeleton) and5fc7ca1(Reth + Malachite pins, added together).
Worked example + steps
Lesson 1 — Workspace + Reth + Malachite (Stages 1-3)
Goal
Concepts you'll grasp in this lesson:
- Dependency-graph-first workflow — why getting Reth + Malachite to coexist before writing any application code prevents mid-course backtracking when transitive conflicts surface.
- Workspace-level dep declaration — declaring every external dep once at the root
Cargo.tomland inheriting via{ workspace = true }, so a Reth version bump is a one-line change instead of an 11-crate sweep. - Git-SHA pinning vs. crates.io — why this workspace pins Reth and Malachite to commit SHAs, not semver ranges; absolute reproducibility beats convenience when validators must agree byte-for-byte.
- The 10-crate + 1-bin layout — how OpenHL's 5 subsystems (types, codec, clob, consensus, evm, …) map onto a flat
crates/directory plus onebin/openhlentry point.
Verification:
cargo check --workspace
…run from your ~/code/my-openhl/ directory, returns Finished with no warnings. You will have written zero application logic — that's Lesson 2 onwards.
This lesson's checks are fast: Reth and Malachite are only declared here, and Cargo does not fetch or build a workspace dependency until some member crate uses it. The heavy part — openhl's full lock file is about 1,000 packages, almost all of them Reth's — arrives in Lesson 5. Expect that first build to take many minutes.
Specific changes:
- 10 empty library crates and one binary crate scaffolded under
crates/andbin/openhl/. - Root
Cargo.tomldeclaresmembers, workspace defaults, and[workspace.dependencies]. - Reth pinned as a git dependency at a specific SHA, declared at the workspace level.
- Malachite pinned the same way.
Recap
You ran the Lesson 0 setup. You have:
~/code/my-openhl/— your workspace, currently a defaultcargo init --libartifact~/code/openhl-reference/—psyto/openhlcloned,cargo checkpassing
This lesson edits files in ~/code/my-openhl/. Never touch openhl-reference/.
Plan
Dependency resolution is the most common source of friction in a Rust workspace. Reth and Malachite are big crates with deep transitive dep trees — getting them to compile cleanly together is non-trivial. If we deferred this to "later," we'd discover the conflicts in the middle of writing application code and have to backtrack. Getting the deps right first means every subsequent lesson focuses on the lesson's actual topic, not yak-shaving dependencies. That's why the stage order below front-loads dep setup before any application code.
So you'll do three things, in this order:
- Stage 1 — replace the default
cargo init --liboutput with a real workspace: 10 empty library crates, 1 binary crate, top-levelCargo.tomldeclaring all the workspace defaults. Test:cargo check --workspacesucceeds. - Stage 2 — pin Reth as a git dependency at a specific SHA, declared at the workspace level. Test:
cargo check --workspacestill succeeds (no crate uses Reth yet, so Cargo doesn't even fetch it — this only checks that the manifest is valid). - Stage 3 — pin Malachite the same way. Test:
cargo check --workspacestill succeeds.
In psyto/openhl, Stage 1 is commit 75be9de; Stages 2 and 3 landed together in 5fc7ca1.
Walk-through
Step 1: Reset ~/code/my-openhl/
The Lesson 0 setup left a default cargo project there. We need to wipe it and start fresh:
cd ~/code/my-openhl
rm Cargo.toml Cargo.lock src/lib.rs
rmdir src
You should now have only .git/ (from the initial cargo init) and nothing else visible:
ls -la
# . .. .git
Step 2: Write the top-level workspace Cargo.toml
Create Cargo.toml at the root with this content. Type it; don't copy from the reference. Pay attention to each section.
[workspace]
resolver = "3"
members = [
"bin/openhl",
"crates/types",
"crates/codec",
"crates/clob",
"crates/oracle",
"crates/funding",
"crates/liquidation",
"crates/vault",
"crates/evm",
"crates/consensus",
"crates/node",
]
[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.95"
license = "MIT OR Apache-2.0"
repository = "https://github.com/yourusername/my-openhl"
authors = ["Your Name <you@example.com>"]
[workspace.dependencies]
# --- Internal crates ---
openhl-types = { path = "crates/types" }
openhl-codec = { path = "crates/codec" }
openhl-clob = { path = "crates/clob" }
openhl-oracle = { path = "crates/oracle" }
openhl-funding = { path = "crates/funding" }
openhl-liquidation = { path = "crates/liquidation" }
openhl-vault = { path = "crates/vault" }
openhl-evm = { path = "crates/evm" }
openhl-consensus = { path = "crates/consensus" }
openhl-node = { path = "crates/node" }
# --- Reth and Malachite — added in Steps 8 and 9 below ---
# --- Shared utilities ---
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
eyre = "0.6"
tracing = "0.1"
proptest = "1"
[workspace.lints.rust]
unsafe_code = "forbid"
missing_debug_implementations = "warn"
unreachable_pub = "warn"
rust_2018_idioms = { level = "warn", priority = -1 }
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
module_name_repetitions = "allow"
must_use_candidate = "allow"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
[profile.release]
opt-level = 3
lto = "fat"
codegen-units = 1
strip = "symbols"
debug = false
panic = "abort"
[profile.dev]
opt-level = 1
debug = true
[profile.dev.package."*"]
opt-level = 3
[profile.bench]
inherits = "release"
debug = true
Three load-bearing choices in this file:
resolver = "3". The Cargo dependency resolver version — the default for edition 2024 (which a virtual workspace can't infer, so it is written out). Resolver 3 keeps resolver 2's feature handling and adds MSRV-aware version selection: it prefers dependency versions compatible with yourrust-version. You'll see it in the output as "Locking N packages to latest Rust 1.95 compatible versions".unsafe_code = "forbid"at the workspace level. This forbidsunsafein every member crate. Reth depends onunsafeinternally; we don't. If a pure state-machine crate ever wantsunsafe, that's a code review smell.pedantic = "warn"(clippy). Pedantic clippy lints catch a lot of subtle stuff. Some are noisy, hence themodule_name_repetitions/etc. allowances at the bottom. Setting pedantic-warn up front means every commit lands clippy-clean.
Step 3: Add rust-toolchain.toml at the root
Create rust-toolchain.toml:
[toolchain]
channel = "1.95.0"
components = ["clippy", "rustfmt"]
profile = "minimal"
This pins the Rust version. When the reader (or CI) runs cargo, the toolchain is fetched and used automatically. Without this, different machines could build with different rustc versions and produce different artifacts — a determinism risk we don't want.
Step 4: Create the first library crate (crates/types) as a template
We'll create one crate end-to-end, then replicate the pattern.
mkdir -p crates/types/src
Create crates/types/Cargo.toml:
[package]
name = "openhl-types"
version = { workspace = true }
edition = { workspace = true }
rust-version = { workspace = true }
license = { workspace = true }
repository = { workspace = true }
authors = { workspace = true }
[dependencies]
serde = { workspace = true }
[lints]
workspace = true
Create crates/types/src/lib.rs:
//! Shared primitives and CL/EL contract types.
That's it. The crate is empty other than a module doc comment. Subsequent lessons fill it in.
Why version = { workspace = true } etc.? This inherits from [workspace.package] in the root Cargo.toml. Every member crate has identical metadata — versioning, edition, license. Inheriting via workspace = true means a one-line bump to the workspace gets propagated. The alternative (per-crate version = "0.1.0") duplicates 6 lines into 11 crates and is easy to drift out of sync.
Step 5: Create the other 9 library crates
The pattern is the same as crates/types. For each, create:
crates/<name>/Cargo.toml(same shape, onlynamefield differs)crates/<name>/src/lib.rs(empty other than doc comment)
For example, for codec, first create the directory:
mkdir -p crates/codec/src
then drop in crates/codec/Cargo.toml and crates/codec/src/lib.rs using the name and doc comment from the table below. Repeat the same mkdir -p crates/<name>/src + two-file recipe for each of the remaining 8 crates.
The 9 remaining crates and their doc comments:
| Crate | name | lib.rs doc comment |
|---|---|---|
| codec | openhl-codec | //! Canonical encoding for consensus messages. |
| clob | openhl-clob | //! CLOB matching engine — pure state machine. |
| oracle | openhl-oracle | //! Mark price aggregation. |
| funding | openhl-funding | //! Funding-rate calculation and settlement. |
| liquidation | openhl-liquidation | //! Liquidation engine. |
| vault | openhl-vault | //! Protocol-native vault primitive. |
| evm | openhl-evm | //! EVM execution layer — Reth integration. |
| consensus | openhl-consensus | //! Consensus layer — Malachite BFT. |
| node | openhl-node | //! Node assembly: consensus + evm + clob. |
For clob, oracle, funding, liquidation, vault, node: the [dependencies] section can be empty ([dependencies] line followed by a blank [lints] block). For codec, evm, consensus: also empty initially — the actual dependencies land in later lessons when we write code that uses them.
Step 6: Create bin/openhl
The binary crate. It does nothing yet — just proves the workspace compiles.
mkdir -p bin/openhl/src
Create bin/openhl/Cargo.toml:
[package]
name = "openhl"
version = { workspace = true }
edition = { workspace = true }
rust-version = { workspace = true }
license = { workspace = true }
repository = { workspace = true }
authors = { workspace = true }
[[bin]]
name = "openhl"
path = "src/main.rs"
[dependencies]
[lints]
workspace = true
Create bin/openhl/src/main.rs:
fn main() {
println!("openhl v{}", env!("CARGO_PKG_VERSION"));
}
The [[bin]] section names the binary openhl and points it at src/main.rs. The env!("CARGO_PKG_VERSION") macro inlines the package version from Cargo.toml at compile time — useful for openhl --version later.
Step 7: First cargo check
cd ~/code/my-openhl
cargo check --workspace
Expected output:
Compiling serde v1.0.x
...
Checking openhl-types v0.1.0 (...)
Checking openhl-codec v0.1.0 (...)
...(all 10 crates plus the openhl bin)...
Finished `dev` profile [optimized + debuginfo] target(s) in 8s
There should be no warnings: openhl-types declares serde without using it yet, but the lint that would flag that (unused_crate_dependencies) is allow-by-default. Hard errors are NOT OK — if you see one, the most common causes are:
- Typo in a crate name in workspace.members or in a per-crate Cargo.toml. Cargo will name the missing crate; fix the typo.
- Missing
src/lib.rsfor a library crate. Each crate listed in workspace.members must have eithersrc/lib.rsorsrc/main.rs. [lints]block but noworkspace = trueinside. Each crate's[lints]must sayworkspace = trueto inherit.
Resolve any errors before moving to Step 8.
Step 8: Pin Reth as a workspace dependency
Edit the workspace Cargo.toml. Find the line:
# --- Reth and Malachite — added in Steps 8 and 9 below ---
Replace it with:
# --- Reth (pinned to v2.2.0 release tag) ---
# Bump in a dedicated PR. Always pin to a release-tag SHA, never main HEAD.
reth-node-builder = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-node-ethereum = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-node-core = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-tasks = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-chainspec = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-evm = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-ethereum-primitives = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-engine-primitives = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-payload-primitives = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-provider = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-storage-api = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-consensus = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-ethereum-consensus = { git = "https://github.com/paradigmxyz/reth", rev = "88505c7fcbfdebfd3b56d88c86b62e950043c6c4" }
reth-primitives-traits = "0.3"
alloy-primitives = { version = "1.5", default-features = false }
alloy-consensus = { version = "2.0", default-features = false }
alloy-genesis = { version = "2.0", default-features = false }
alloy-evm = { version = "0.34", default-features = false }
alloy-rlp = { version = "0.3", default-features = false }
Why this many Reth crates? Reth is a multi-crate codebase. Different parts (the node builder, the EVM, the storage API, the consensus hook) live in different crates. We declare all the ones our later lessons will use at the workspace level so each consuming crate just references reth-xxx = { workspace = true } later.
Why pin to a SHA? Reth has frequent breaking changes, and its node crates (reth-node-builder etc.) are not published on crates.io, so a git dependency is the only option. Pinning to a release tag's commit (here 88505c7f... = v2.2.0) gives us a stable target; a branch pin could break when Reth pushes an unrelated change. (reth-primitives-traits is the exception: it is published on crates.io from the separate paradigmxyz/reth-core repo, hence the plain "0.3".)
Why pin to a release-tag SHA, not main HEAD? Main HEAD can be broken at any moment. Release tags are tested and stable. The comment in the file (# Bump in a dedicated PR. Always pin to a release-tag SHA, never main HEAD.) is a process discipline note for future bumps.
What does cargo check do with these lines? Nothing yet. [workspace.dependencies] entries are templates: Cargo only resolves, fetches and builds one when a member crate references it with { workspace = true }, and no member does yet. (openhl's own commit 5fc7ca1 says the same: "git pins are not fetched until a crate references them".)
Run it:
cargo check --workspace
It finishes in seconds, without touching GitHub:
Finished `dev` profile [optimized + debuginfo] target(s) in 0.46s
If it errors, it's a manifest problem — a missing quote, a duplicated key, a misspelled table name. Cargo names the file and line. Version or toolchain problems (for example, Reth v2.2.0 declares rust-version = "1.93"; the rust-toolchain.toml above pins 1.95.0) can only show up once a crate actually depends on Reth, in Lesson 5.
Step 9: Pin Malachite as a workspace dependency
Append to the [workspace.dependencies] section:
# --- Malachite BFT (pinned to v0.5.0 release tag) ---
# Note: crate names in the malachite repo are prefixed `informalsystems-malachitebft-*`.
informalsystems-malachitebft-core-types = { git = "https://github.com/informalsystems/malachite", rev = "9ef02b33c4ded5fe3e072631d86448658680fe55" }
informalsystems-malachitebft-core-consensus = { git = "https://github.com/informalsystems/malachite", rev = "9ef02b33c4ded5fe3e072631d86448658680fe55" }
informalsystems-malachitebft-core-driver = { git = "https://github.com/informalsystems/malachite", rev = "9ef02b33c4ded5fe3e072631d86448658680fe55", features = ["std"] }
informalsystems-malachitebft-engine = { git = "https://github.com/informalsystems/malachite", rev = "9ef02b33c4ded5fe3e072631d86448658680fe55" }
informalsystems-malachitebft-app = { git = "https://github.com/informalsystems/malachite", rev = "9ef02b33c4ded5fe3e072631d86448658680fe55" }
informalsystems-malachitebft-app-channel = { git = "https://github.com/informalsystems/malachite", rev = "9ef02b33c4ded5fe3e072631d86448658680fe55" }
informalsystems-malachitebft-config = { git = "https://github.com/informalsystems/malachite", rev = "9ef02b33c4ded5fe3e072631d86448658680fe55" }
informalsystems-malachitebft-codec = { git = "https://github.com/informalsystems/malachite", rev = "9ef02b33c4ded5fe3e072631d86448658680fe55" }
informalsystems-malachitebft-signing-ed25519 = { git = "https://github.com/informalsystems/malachite", rev = "9ef02b33c4ded5fe3e072631d86448658680fe55" }
Crate-name oddity. Malachite's repo (informalsystems/malachite) publishes its crates under the prefix informalsystems-malachitebft-*. We use the full prefixed names in Cargo.toml. In Rust source code we'll use informalsystems_malachitebft_core_types::Context (snake_case rename). The comment in the file documents this.
features = ["std"] on core-driver. The driver crate has a std feature that is not on by default, so we enable it explicitly. The other Malachite crates listed here need no extra feature.
Run cargo check again:
cargo check --workspace
Again nothing is fetched — no crate uses Malachite yet (the first one does in Lesson 6) — so this also finishes in seconds.
Test
After Step 9 finishes successfully:
cargo check --workspace
Expected (nothing has changed since Step 9, so this is near-instant):
Finished `dev` profile [optimized + debuginfo] target(s) in 0.23s
⚠️ Don't pipe through
| tail -5. If something does fail, the actual error body streams out near the top of the compile pipeline, andtailwill throw it away — leaving you with a useless trailing summary. Even if warnings feel noisy, keep the full log visible while you're debugging.
You can also try:
cargo build --bin openhl
./target/debug/openhl
Expected:
openhl v0.1.0
That's Lesson 1 done.
Design reflection
Two load-bearing decisions you just encoded:
-
All external deps are declared at the workspace level, not per-crate. Per-crate Cargo.toml entries say
reth-storage-api = { workspace = true }, inheriting the version. This means a Reth version bump is a one-line change. The alternative (each crate declaring its own version) would cause every Cargo.toml in 11 crates to drift. -
Reth and Malachite are git deps, not crates.io deps. For Reth there's no choice — its node crates aren't on crates.io. Malachite does publish
informalsystems-malachitebft-*crates there, but openhl pins both to tag commits the same way. Pinning to a specific commit SHA is a deliberate trade-off: more friction for bumps, but absolute reproducibility — you don't want two validators built from different patch versions of the consensus engine.
These two decisions propagate: every later lesson assumes them. When you add reth-storage-api = { workspace = true } to a crate's [dependencies] in Lesson 11, Cargo finds the workspace-level pin and resolves correctly without you thinking about it.
Answer key
Compare your workspace state to psyto/openhl at the Stage 2+3 commit:
cd ~/code/openhl-reference
git checkout 5fc7ca1
diff -ru ~/code/my-openhl/Cargo.toml ./Cargo.toml
diff -ru ~/code/my-openhl/crates/types ./crates/types
diff -ru ~/code/my-openhl/bin/openhl ./bin/openhl
Differences in authors, repository, and comment wording are fine. Some differences are expected because this lesson writes the dependency list the reference only reached later: at 5fc7ca1 the reference has fewer reth-* crates (it gained the rest in c938321 and later), alloy-primitives = "0.8" (bumped to 1.5 in c938321), rust-version = "1.85" with a 1.85.0 toolchain (bumped to 1.95 in c938321), and empty lib.rs files with no doc comments. Differences in members, the Reth/Malachite pin SHAs, [workspace.lints], or profiles are not fine — re-read whichever step you skimmed.
Return to main when you're done diffing:
git checkout main
Common questions
Q: Should I commit my work to git? Yes. Initialize git in ~/code/my-openhl/ and commit after each step or each lesson. The commit log becomes your own personal Stage history.
cd ~/code/my-openhl
git init # if you haven't
git add .
git commit -m "Lesson 1 — workspace + Reth + Malachite pinned"
Q: Why does cargo check say nothing about all those unused workspace dependencies? Because unused [workspace.dependencies] entries are never resolved, and Cargo doesn't warn about them. (Rust's unused_crate_dependencies lint, which warns about deps a crate declares but never uses, is allow-by-default and not enabled in this workspace.)
Q: My machine ran out of disk space. Not in this lesson, but from Lesson 5 on, the Reth + Malachite source trees plus their target/ cache can easily reach 10-15 GB. Add disk or move target/ to an external drive via [build] target-dir = ... in .cargo/config.toml.
Q: Can I parallelize fetching deps? Cargo does this automatically. The "Updating git repository" steps run sequentially because each one writes to the same git cache. The "Compiling" steps fan out across cores. If yours is slow, check cargo build -j $(nproc).
Next lesson (Lesson 2)
You have a workspace that compiles. No application logic yet. In Lesson 2 we write the first application code — openhl-types's BlockHash, PayloadId, PayloadAttrs, ExecutedBlock, and PayloadStatus. These are the shared vocabulary of the consensus↔EVM contract. After Lesson 2, the contract types compile and have basic tests. Then Lesson 3 writes the trait that uses them.
Summary (3 lines)
- Workspace = 10 empty library crates +
bin/openhl, with Reth v2.2.0 and Malachite v0.5.0 pinned by git SHA in[workspace.dependencies].cargo check --workspaceis clean. - Declared deps aren't fetched until a crate uses them — Reth's heavy first build comes in Lesson 5. Answer key:
75be9de+5fc7ca1. - Foundation for everything after. Next: shared contract types in openhl-types.