
Building TinyL1: A Minimal Layer 1 Blockchain in Rust
A few weeks ago I decided to stop reading about blockchains and start building one from scratch. The result is TinyL1, a minimal layer-1 blockchain written in Rust. It is not production-ready, but it implements the core ideas that make real chains work: accounts, state roots, Merkle trees, a stack VM, and Byzantine Fault Tolerant consensus.
This post explains what I built, why each piece matters, and what I learned along the way.
What TinyL1 includes
flowchart TB
subgraph cli["CLI"]
sim["simulate"]
transfer["transfer"]
node["node"]
end
subgraph consensus["BFT Consensus"]
propose["Propose"]
prevote["PreVote"]
precommit["PreCommit"]
commit["Commit"]
propose --> prevote --> precommit --> commit --> propose
end
subgraph exec["Execution"]
vm["Stack VM"]
gas["Gas Meter"]
end
subgraph data["State & Crypto"]
state[(Accounts)]
merkle[[Merkle Tree]]
sha3[[SHA3-256]]
end
cli --> consensus
consensus -->|transactions| exec
exec -->|state delta| data
merkle -.->|tx_root| consensus
state -.->|state_root| consensus
sha3 -.->|block_hash| consensus
- Account-based state with nonce replay protection
- Merkle tree for transaction roots and state commitment
- Stack-based VM with 16 opcodes and gas metering
- Tendermint-style consensus (Propose → PreVote → PreCommit → Commit)
- Deterministic CLI simulator for local testing
Accounts and state transitions
The simplest part of any chain is the ledger. TinyL1 stores accounts in a HashMap keyed by a 20-byte address. Each account has a balance and a nonce. The nonce prevents replay attacks: a transaction must match the sender's current nonce, and after execution the nonce is incremented.
pub struct Account {
pub address: [u8; 20],
pub balance: u64,
pub nonce: u64,
}
The State::apply method checks the nonce and balance, then transfers value from sender to receiver. This is the core state-transition function of the chain.
Merkle trees
Every block in TinyL1 has a tx_root: the Merkle root of all transactions in that block. A Merkle tree takes many items and compresses them into a single hash. If any transaction changes, the root changes, which makes tampering detectable.
flowchart BT
A["A"]
B["B"]
C["C"]
D["D"]
hA["h(A)"]
hB["h(B)"]
hC["h(C)"]
hD["h(D)"]
hash1["h(hA + hB)"]
hash2["h(hC + hD)"]
root["Merkle Root\nh(hash1 + hash2)"]
A --> hA
B --> hB
C --> hC
D --> hD
hA --> hash1
hB --> hash1
hC --> hash2
hD --> hash2
hash1 --> root
hash2 --> root
Merkle proofs let you prove that a single transaction is part of a block without sending the whole block. That efficiency is why almost every blockchain uses them.
A stack VM
I also built a tiny stack-based virtual machine. It supports opcodes like Push, Add, Sub, Load, Store, Jump, JumpIfZero, and Halt. The VM has a gas counter: each instruction costs one unit of gas, and execution stops if the limit is exceeded.
The most satisfying test was a Fibonacci contract written entirely in bytecode:
let code = vec![
Opcode::Push(0), Opcode::Store(0),
Opcode::Push(1), Opcode::Store(1),
Opcode::Push(10), Opcode::Store(2),
Opcode::Load(1),
Opcode::Load(0),
Opcode::Add,
Opcode::Store(1),
Opcode::Load(1),
Opcode::Load(0),
Opcode::Sub,
Opcode::Store(0),
Opcode::Load(2),
Opcode::Push(1),
Opcode::Sub,
Opcode::Dup(0),
Opcode::Store(2),
Opcode::JumpIfZero(23),
Opcode::Jump(7),
Opcode::Halt,
];
After ten iterations the VM holds mem[0] = 55 and mem[1] = 89. Writing bytecode by hand is tedious, but it made the VM design very concrete.
Consensus
Consensus is the protocol that lets a network agree on the next block. TinyL1 uses a simplified Tendermint-style round:
sequenceDiagram
participant P as Proposer
participant V1 as Validator 1
participant V2 as Validator 2
participant V3 as Validator 3
P->>V1: Propose block B
P->>V2: Propose block B
P->>V3: Propose block B
V1->>V1: PreVote(B)
V2->>V2: PreVote(B)
V3->>V3: PreVote(B)
note over V1,V3: threshold reached
V1->>V1: PreCommit(B)
V2->>V2: PreCommit(B)
V3->>V3: PreCommit(B)
note over V1,V3: threshold reached → Commit
V1->>V1: commit B
V2->>V2: commit B
V3->>V3: commit B
The two-phase vote prevents validators from accidentally committing different blocks at the same height. In TinyL1 a single ConsensusEngine tracks prevotes, precommits, and a threshold. Once the threshold is reached the block is finalized.
The CLI simulator
The project ships with a CLI built on clap. The most useful command is the transfer simulator:
RUST_LOG=info cargo run -- transfer --blocks 3 --transfers-per-block 5
It seeds an account with an initial balance, generates transfers, applies them to state, runs them through consensus, and prints committed blocks with live balance updates.
Committed block 1 hash=0x... tx_count=5 alice=950 bob=50
Committed block 2 hash=0x... tx_count=5 alice=900 bob=100
Committed block 3 hash=0x... tx_count=5 alice=850 bob=150
This made the whole pipeline feel real for the first time: transactions → state update → block proposal → consensus → committed chain.
What I learned
- Start small. A real blockchain has networking, P2P, mempool, signatures, and economic incentives. TinyL1 has none of that, but the core data structures and state machine are the same.
- Deterministic tests matter. Using real timestamps would make tests fragile. Passing timestamps explicitly kept everything reproducible.
- Rust borrow rules reveal design flaws. The first version of
State::applytried to hold two mutable references into the sameHashMap. Refactoring it made the logic clearer. - Mermaid diagrams are worth the effort. Drawing the architecture, consensus flow, and VM loop made the README much easier to understand.
What's next
- Add Ed25519 transaction signing
- Replace the serialized state hash with a real Merkle-Patricia trie
- Add a peer-to-peer simulator so consensus runs across multiple processes
- Build a tiny compiler or assembler for the VM so contracts are easier to write
The code
The full project is on GitHub at https://github.com/eyji-koike/rust-blockchain. If you want to see how a minimal layer-1 chain fits together, it is a good place to start.