A didactic blockchain written in Rust, ported from an original TypeScript implementation.
The goal is learning Rust — ownership, Result, enums, traits, modules, workspaces — by building something concrete.
⚠️ Study project: a single node with no networking and no distributed consensus. See Known limitations before reading the code as a reference implementation.
- Blocks chained together and made tamper-evident through SHA-256 hashing.
- Proof of Work: mining against a fixed difficulty (4 leading zeros).
- Account model: balances live in a
Wallet(HashMap<address, balance>), Ethereum-style rather than UTXO. - Transactions with a dynamically calculated fee (flat base + percentage of the amount + congestion factor).
- Mempool: pending transactions are sealed into a block every 10.
- Chain validation: tamper detection via
is_chain_valid. - Typed error handling with
thiserror:InsufficientBalance,DoubleSpending,InvalidSignature,AddressMismatch. - ed25519 signatures, enforced: a transaction is rejected unless it carries a valid signature and its sender address is the public key that signed it.
- CLI built with
clap.
A Cargo workspace with two crates:
rustchain/
├── Cargo.toml # workspace manifest (virtual — no root package)
└── crates/
├── core/ # library: domain logic (rustchain-core)
│ └── src/
│ ├── lib.rs # module declarations
│ ├── transaction.rs # Transaction + signing/verification
│ ├── block.rs # Block + hashing + mining
│ ├── wallet.rs # balances + transaction processing
│ ├── error.rs # ChainError enum
│ └── blockchain.rs # chain, mempool, fees, validation
└── cli/ # binary: terminal interface (rustchain-cli)
└── src/main.rs
Why two crates? core holds pure domain logic with no I/O and no CLI concerns, so it can be reused
(by an HTTP API, another binary, or tests) without dragging in clap. cli is just one consumer of it.
Transaction::new_signed → Blockchain::add_transaction
├─ signature verified → Err(InvalidSignature)
├─ address == public key? → Err(AddressMismatch)
├─ duplicate signature in pool? → Err(DoubleSpending)
├─ fee assigned by the chain (calculate_fee)
├─ Wallet::process_transaction → Err(InsufficientBalance)
├─ pushed onto pending_transactions
└─ at 10 pending: mine_pending_transactions → block mined → chain grows
An address is an ed25519 public key, hex-encoded. Transaction::new_signed derives the sender
address from the signing key, so the address cannot be forged: a valid signature over
from + to + amount + timestamp proves both that the data is untampered and that the sender controls
the private key behind that address. The fee is deliberately outside the signed payload, because the
chain — not the sender — decides it.
- Rust stable, edition 2024 — install via rustup.
Build:
cargo buildRun the test suite:
cargo test14 unit tests live next to the code they cover, in #[cfg(test)] mod tests blocks. They exercise
signing and verification round-trips, rejection of unsigned transactions, address spoofing, replayed
transactions, insufficient balance, block sealing at 10 pending transactions, and tamper detection on a
mined block. The sealing and tampering tests actually mine at difficulty 4, so the suite takes a few
seconds.
Lint (style and correctness suggestions beyond the compiler):
cargo clippy --all-targetsRun the CLI. Note the --, which separates cargo's own arguments from the program's:
cargo run -p rustchain-cli -- demo
cargo run -p rustchain-cli -- fee 30
cargo run -p rustchain-cli -- load
cargo run -p rustchain-cli -- --help| Command | Description |
|---|---|
demo |
Runs a scripted transaction (alice → bob) and saves the chain to chain.json |
fee <amount> |
Prints the fee that would be charged for a given amount |
load |
Loads chain.json and reports block count and chain validity |
State is serialized to chain.json via serde_json. Loading degrades gracefully: a missing file starts
a fresh chain silently, a corrupted one prints a warning and then starts a fresh chain — neither case
panics. The file is gitignored: it is runtime state, not source.
JSON is deliberately simple rather than efficient — a production chain would use an embedded key-value store such as RocksDB or LMDB.
These are understood gaps, kept explicit because this is a learning project:
- Keys are not persisted. The demo uses a hardcoded key so the address stays stable across runs; a real wallet would generate a keypair once and store it securely. Nothing on disk survives as an identity.
update_balanceis public, so balances can be credited from nothing. This is convenient for the demo and tests, but it is an unlimited faucet.- Recipient addresses are not validated. Senders are bound to a public key, but
tois an arbitrary string, so funds can be sent to an address no key can ever unlock. - Block timestamps are always 0 — real clock values are not wired in yet.
- No mining reward. Fees are debited from senders but never credited to a miner, so value leaves circulation on every transaction.
- Balances use
f64. Floating point is the wrong representation for money (rounding drift); an integer of minor units would be correct. - Fixed difficulty. Real chains retarget difficulty against observed hash rate.
- No P2P layer, so no block propagation, fork resolution, or consensus.
- Core structures:
Transaction,Block,Wallet,Blockchain - Proof-of-work mining and chain validation
- Dynamic fees
- CLI with
clap - JSON file persistence
- ed25519 signing and verification, enforced in
add_transaction - Address bound to public key, replay detection via signature
- Persistent keypairs (a real wallet stored on disk)
- Mining rewards, so collected fees go somewhere
- Real block timestamps
- HTTP API
Personal project, for educational purposes.