Skip to content

The CLI & Where to Go Next

You’ve built every piece: hashing, blocks, the chain, Proof of Work, transactions and the UTXO set, keys and signatures, Merkle trees, a mempool, full validation, persistence, and a P2P node. But so far each piece has lived behind cargo test. In this final chapter we give the whole thing a front door — a command-line interface — so you can actually run your blockchain from a terminal, watch it mine, and start two nodes that gossip blocks to each other.

Then we step back and answer the question the whole book has been chasing — and point you at what to build next.

The entire driver is one file: src/main.rs. It’s short. Let’s read it from the top.

//! Command-line driver for the blockchain.
//!
//! Usage:
//! cargo run -- demo # in-process end-to-end demo
//! cargo run -- serve <listen> [peer] # run a networked node
use std::env;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use blockchain_from_scratch::blockchain::{build_transaction, Blockchain, COIN};
use blockchain_from_scratch::hashing::to_hex;
use blockchain_from_scratch::message::Message;
use blockchain_from_scratch::net;
use blockchain_from_scratch::node::Node;
use blockchain_from_scratch::storage;
use blockchain_from_scratch::wallet::Wallet;

The first block (std::...) pulls in pieces of Rust’s standard library — things like reading command-line arguments and spawning threads. The second block pulls in our own crate by name, blockchain_from_scratch. Notice that from main.rs’s point of view, the library we spent ten chapters writing is just another dependency, addressed by its crate name. Every module you built — blockchain, hashing, net, node, storage, wallet, message — is reachable here.

fn main() {
let args: Vec<String> = env::args().collect();
match args.get(1).map(String::as_str) {
Some("demo") => demo(),
Some("serve") => {
let listen = args.get(2).cloned().unwrap_or_else(|| "127.0.0.1:9000".to_string());
let peer = args.get(3).cloned();
serve(listen, peer);
}
_ => {
eprintln!("usage:");
eprintln!(" cargo run -- demo");
eprintln!(" cargo run -- serve <listen_addr> [peer_addr]");
}
}
}

This is the heart of the CLI, and it teaches a handful of very common Rust idioms. Let’s walk it line by line.

Getting the arguments.

let args: Vec<String> = env::args().collect();

env::args() is the standard-library function that hands you the program’s command-line arguments — one at a time, as an iterator. We .collect() that iterator into a Vec<String> (a growable list of strings) so we can index into it. By convention, args[0] is the program’s own name, args[1] is the first real argument, and so on. So when you run:

Terminal window
cargo run -- demo

the -- tells Cargo “everything after this goes to my program, not to Cargo,” and your program sees args = ["...path-to-binary...", "demo"].

Why .get(1) instead of args[1]?

match args.get(1).map(String::as_str) {

If the user runs the program with no arguments, there is no args[1]. Writing args[1] directly would panic (crash) on an out-of-bounds index. Instead, .get(1) returns an Option<&String> — either Some(value) if that slot exists, or None if it doesn’t. Option is Rust’s way of saying “this might be missing,” and it forces you to handle the missing case rather than crash. This is one of the language’s quiet superpowers: a whole class of “index out of range” bugs simply can’t happen if you reach for .get().

Why .map(String::as_str)? We want to match against string literals like "demo". But .get(1) gives us an Option<&String>, and a &String doesn’t compare cleanly against a &str literal. .map(String::as_str) reaches inside the Option and, if there’s a value, converts the &String into a &str — leaving us with an Option<&str>. (If the Option was None, .map does nothing and you still get None.) Now we can match it against Some("demo") and friends.

Matching the subcommand. Now the match itself:

  • Some("demo") => demo() — the user typed demo, so run our self-contained demonstration.

  • Some("serve") => { ... } — the user wants a networked node. We read the next two arguments:

    let listen = args.get(2).cloned().unwrap_or_else(|| "127.0.0.1:9000".to_string());
    let peer = args.get(3).cloned();

    args.get(2) is the address to listen on. .cloned() turns the Option<&String> into an Option<String> we can own and keep. .unwrap_or_else(...) says: “if it’s Some, give me the value; if it’s None, run this closure to produce a default” — here, the default listen address 127.0.0.1:9000. The peer (an optional address to sync from and broadcast to) is just args.get(3).cloned() — we keep it as an Option<String>, because a node with no peer is perfectly valid (it’s the first node in the network).

  • _ => { ... } — the catch-all arm. Any other input (no argument, a typo, help) prints usage to standard error with eprintln!. We use eprintln! rather than println! because usage/error messages belong on stderr, leaving stdout clean for actual program output.

This is the function that produces the output you saw back in the overview. It uses nothing but the library you already built.

/// A self-contained demonstration: mine, spend, and print the chain.
fn demo() {
let mut chain = Blockchain::new();
let alice = Wallet::new();
let bob = Wallet::new();
println!("genesis hash: {}", to_hex(&chain.tip_hash()));

We create a fresh Blockchain (which builds the genesis block for us) and two wallets, Alice and Bob, each with its own secp256k1 keypair. chain is mut because mining will change it. We print the genesis block’s hash via to_hex, the hex-encoding helper from our hashing module.

let block1 = chain.mine_block(vec![], alice.address());
chain.add_block(block1).unwrap();
println!("\nmined block 1 → Alice (height {})", chain.height());
println!(" Alice: {} coins", chain.utxos.balance(&alice.address()) / COIN);

Here we mine the first real block. mine_block(vec![], alice.address()) mines a block with no ordinary transactions (vec![] is the empty list) and pays the coinbase reward to Alice’s address. add_block runs full validation and appends it. We .unwrap() the result because in this controlled demo we know our own block is valid — if it somehow weren’t, crashing loudly is the right move.

Then we print Alice’s balance. chain.utxos.balance(...) returns an amount in the smallest unit; we divide by COIN (our equivalent of “satoshis per coin”) to show whole coins. After one coinbase, Alice has 50 coins.

let tx = build_transaction(&alice, &chain.utxos, bob.address(), 10 * COIN, COIN).unwrap();
let block2 = chain.mine_block(vec![tx], alice.address());
chain.add_block(block2).unwrap();
println!("\nmined block 2 with Alice → Bob (height {})", chain.height());
println!(" Alice: {} coins", chain.utxos.balance(&alice.address()) / COIN);
println!(" Bob: {} coins", chain.utxos.balance(&bob.address()) / COIN);

Now Alice spends. build_transaction picks UTXOs from Alice’s coins to pay Bob 10 * COIN, with a fee of COIN, signs it, and hands back a ready transaction. We mine a second block — this time carrying vec![tx], with the coinbase again going to Alice. After this block, Bob has 10 coins, and Alice has her change plus the two coinbase rewards minus the fee she paid herself back as miner. Run it and read the numbers — they reconcile exactly, which is the whole point of the UTXO accounting you built.

println!("\nthe chain:");
for (i, block) in chain.blocks.iter().enumerate() {
println!(
" #{i} {} ({} tx)",
to_hex(&block.hash()),
block.transactions.len()
);
}
}

Finally we print the whole chain. chain.blocks.iter() walks the blocks; .enumerate() pairs each one with its index i so we can number them. For each, we print its index, its hash (hex), and how many transactions it holds. Every hash will start with the 0000 of our Proof of Work — visible proof that each block cost real work to produce.

serve() — a live, mining, gossiping node

Section titled “serve() — a live, mining, gossiping node”

This is where the CLI becomes a real participant in a network. serve loads a chain from disk, optionally catches up from a peer, spawns a background thread that mines forever, and then runs the network server. We’ll take it in four stages.

Stage 1 — load (or create) the chain.

/// Run a networked node: listen for peers, optionally sync from one, and mine a
/// block every few seconds, broadcasting it to the peer.
fn serve(listen: String, peer: Option<String>) {
let file = format!("chain-{}.json", listen.replace(':', "_").replace('.', "_"));
let path = PathBuf::from(file);
let chain = storage::load(&path).ok().flatten().unwrap_or_default();
let miner = Wallet::new();
println!("node {listen} starting at height {}", chain.height());

Each node keeps its chain in its own file. We derive the filename from the listen address — 127.0.0.1:9000 becomes chain-127_0_0_1_9000.json — by replacing the : and . characters (which are awkward in filenames) with _. This way two nodes on one machine never clobber each other’s data.

The load line packs a lot in:

let chain = storage::load(&path).ok().flatten().unwrap_or_default();
  • storage::load(&path) returns a Result<Option<Blockchain>, _> — it can error (disk problem), and even on success the file might not exist yet (None).
  • .ok() throws away the error detail and turns Result<Option<...>, E> into Option<Option<...>>Some(inner) on success, None on error.
  • .flatten() collapses that nested Option<Option<Blockchain>> into a single Option<Blockchain>.
  • .unwrap_or_default() gives us the loaded chain if there is one, or otherwise a default Blockchain (a fresh chain with just the genesis block).

So in one line: “load the chain if a valid file exists; otherwise start a brand-new chain.” Then we make a fresh miner wallet to receive this node’s mining rewards, and print our starting height.

Stage 2 — wrap the node so it can be shared.

let shared: net::Shared = Arc::new(Mutex::new(Node::new(chain)));
if let Some(peer) = &peer {
match net::sync_from(peer, &shared) {
Ok(n) => println!("synced {n} block(s) from {peer}"),
Err(e) => eprintln!("could not sync from {peer}: {e}"),
}
}

We build a Node around our chain and wrap it in Arc<Mutex<...>> — the exact pattern you met in the networking chapter. The Mutex guarantees only one thread touches the node at a time (no data races); the Arc (atomically reference-counted pointer) lets multiple threads each hold a handle to that one shared node. net::Shared is just a type alias for Arc<Mutex<Node>>, named so the intent reads clearly.

If the user gave us a peer, we try to catch up from it. if let Some(peer) = &peer borrows the address (note the & — we don’t want to consume our peer variable, we still need it later for the miner thread). net::sync_from returns a Result, so we match it: on success we report how many blocks we pulled; on failure we print the error to stderr but keep running — a node that can’t reach one peer is still a valid, useful node.

Stage 3 — the miner thread.

This is the most interesting block in the file, and it ties together threads, shared state, and a couple of standard-library helpers.

// Miner thread.
{
let shared = Arc::clone(&shared);
let peer = peer.clone();
let path = path.clone();
let miner_address = miner.address();
thread::spawn(move || loop {
thread::sleep(Duration::from_secs(5));
let mined = {
let mut node = shared.lock().unwrap();
let mempool = std::mem::take(&mut node.mempool);
let block = node.chain.mine_block(mempool, miner_address);
if node.chain.add_block(block.clone()).is_ok() {
println!("mined block; height={}", node.chain.height());
let _ = storage::save(&node.chain, &path);
Some(block)
} else {
None
}
};
if let (Some(block), Some(peer)) = (mined, peer.clone()) {
let _ = net::send(&peer, &Message::NewBlock(block));
}
});
}

There’s a lot here, so let’s slow down.

Why the extra { ... } around the whole thing? That outer brace is a plain block scope. We use it so the clones we make (shared, peer, path) are local to this setup and don’t shadow the originals for the rest of serve — the originals are still needed by net::serve afterward.

Why all the clones?

let shared = Arc::clone(&shared);
let peer = peer.clone();
let path = path.clone();
let miner_address = miner.address();

The thread we’re about to spawn will run concurrently with the rest of serve, possibly for the whole life of the program. Rust requires that anything a thread captures must be owned by — or safely shareable with — that thread. So before spawning, we give the thread its own copies:

  • Arc::clone(&shared) does not copy the node. It bumps the Arc’s reference count and hands back another pointer to the same shared node. Now both the main thread and the miner thread point at one node, and Rust knows the data lives until the last handle is dropped. (Writing shared.clone() would do the same thing; Arc::clone(&shared) is the conventional spelling because it makes “this is a cheap pointer clone, not a deep copy” obvious to the reader.)
  • peer.clone() and path.clone() make the thread its own independent Option<String> and PathBuf.
  • miner.address() derives the address once, up front, so the thread captures a small Address value rather than the whole wallet.

thread::spawn(move || loop { ... }). thread::spawn starts a new OS thread. The closure we pass it begins with move, which means “take ownership of everything you capture” — those clones we just made get moved into the thread, so it owns them for as long as it runs. Inside, loop { ... } is an infinite loop: this thread mines forever.

thread::sleep(Duration::from_secs(5)). Real mining is a continuous race; here we simulate a block interval by pausing five seconds between attempts. Duration is the standard type for spans of time, and Duration::from_secs(5) is a readable way to say “five seconds.” Sleeping also means the thread isn’t pegging a CPU core in this teaching version.

The critical section. Everything inside the inner let mined = { ... }; happens while we hold the lock:

let mut node = shared.lock().unwrap();
let mempool = std::mem::take(&mut node.mempool);
let block = node.chain.mine_block(mempool, miner_address);

shared.lock().unwrap() acquires the mutex, giving us exclusive &mut access to the Node. (.lock() returns a Result that only errors if another thread panicked while holding the lock — “poisoning” — so .unwrap() here is reasonable.)

std::mem::take(&mut node.mempool) is a neat trick worth knowing. We want to drain the mempool into this block — take all its pending transactions and leave the mempool empty for the next round. std::mem::take does exactly that: it moves the value out of node.mempool and leaves the default (an empty mempool) in its place, handing us the old contents. Without it, we couldn’t move a field out of a struct we only have a &mut to — take swaps in the default so the struct stays valid. Then we mine a block from those transactions, paying ourselves the reward.

if node.chain.add_block(block.clone()).is_ok() {
println!("mined block; height={}", node.chain.height());
let _ = storage::save(&node.chain, &path);
Some(block)
} else {
None
}

We try to add the freshly mined block. We pass block.clone() because add_block consumes the block, but we still want the original to broadcast afterward. If it was accepted (.is_ok()), we log the new height, save the chain to disk, and yield Some(block) out of the block expression. let _ = storage::save(...) deliberately ignores the save’s Result — if a save hiccups we’d rather keep mining than crash. If add_block failed (e.g. a peer’s block arrived first and ours now conflicts), we yield None. Either way, the lock is released the moment we leave this inner scope — we do not hold it while talking to the network.

Broadcasting, outside the lock.

if let (Some(block), Some(peer)) = (mined, peer.clone()) {
let _ = net::send(&peer, &Message::NewBlock(block));
}

This is a tidy use of pattern matching on a tuple. We only want to send if we both mined a block and have a peer to send to. By matching (mined, peer.clone()) against (Some(block), Some(peer)), we get both unwrapped together — the body runs only when both are present. We wrap the block in Message::NewBlock (a variant of the message enum you built) and net::send it to the peer. Again let _ = ignores the result: if the peer is briefly unreachable, gossip and re-sync will heal it later. And crucially, we’re doing this network I/O after dropping the lock, so a slow peer never blocks the rest of the node.

Stage 4 — run the server on the main thread.

if let Err(e) = net::serve(&listen, shared) {
eprintln!("server error: {e}");
}
}

With the miner humming in the background, the main thread hands the shared node to net::serve, which binds the listen address and accepts incoming peer connections forever — receiving blocks, answering sync requests, and keeping the node’s chain up to date. net::serve only returns if something goes wrong (like the port being in use), in which case we print the error and exit. Notice the division of labor: one thread mines, the main thread serves, and they coordinate solely through the Arc<Mutex<Node>>. That’s a complete, if minimal, blockchain node.

What you’ve built is a real blockchain — but a deliberately simplified one. Each simplification was a choice to keep the code teachable, and behind every one is a richer, battle-tested mechanism in real Bitcoin. The best way to deepen your understanding now is to pick one and implement it. Here are the most rewarding directions, each paired with the concept page that explains how the real thing works.

Our Proof of Work uses a fixed difficulty target. Bitcoin adjusts difficulty every 2016 blocks so that blocks keep arriving roughly every ten minutes no matter how much mining power joins or leaves. Try adding a retarget: track block timestamps, and every N blocks raise or lower the target based on how long the last window actually took.

The Difficulty Retargeting Algorithm

We keep a single chain and simply reject any block that doesn’t extend our tip. Real nodes accept competing branches, track the one with the most accumulated work, and reorganize — rolling back blocks and rolling forward a better branch — when a heavier chain appears. This is how the network heals after two miners find a block at the same time. Implementing it means storing branches and recomputing the UTXO set on a switch.

Orphans, Stale Blocks & Reorgs and The Most-Work Chain

Our address is just SHA256(pubkey) rendered as hex. Bitcoin encodes addresses with Base58Check or Bech32, which add a network prefix and a checksum so a single mistyped character is caught before any coins move. Swapping in a real encoding is a self-contained, very satisfying upgrade.

Addresses, WIF, Base58Check & Bech32

We sign with ECDSA over secp256k1. Modern Bitcoin also supports Schnorr signatures, which enable elegant multi-signature aggregation (MuSig2) and the privacy of Taproot — many spending conditions that look identical on-chain. Adding a Schnorr signing path next to your ECDSA one is a great way to feel the difference.

Taproot, Schnorr & MuSig2

Our miner takes the mempool wholesale with std::mem::take. A real node sorts pending transactions by fee rate to maximize reward, and lets a sender replace a stuck transaction with a higher-fee version (Replace-By-Fee). Try ordering the mempool by fee-per-byte and only filling a block up to a size limit.

RBF, CPFP & the Zero-Confirmation Myth

We keep wallets in memory and the UTXO set in a HashMap, persisting the whole chain as one JSON file. A serious node stores keys in a persistent wallet and the UTXO set in a proper key-value database (Bitcoin Core uses LevelDB) so it can handle millions of entries and restart instantly. Worth noting: our txids are not malleability-free — txid() hashes the full encoding, signatures included, just like pre-SegWit Bitcoin. Computing txids from the witness-free form (as SegWit did) is another satisfying fix to attempt.

Transaction Malleability

From the very first page, this book asked one question over and over:

How does this help untrusting strangers agree on one ledger?

For Parts 0–10 you answered it by reading how Bitcoin works. In Part 11 you answered it by building — and the answer is no longer a diagram in someone else’s article. It’s your code:

  • Strangers don’t have to trust each other because every block is hash-linked to the last — change one byte anywhere and every hash after it breaks. You wrote that link.
  • No one can fake history cheaply because each block costs Proof of Work — that 0000 prefix is real electricity. You wrote that nonce loop.
  • No one can spend coins they don’t own because every input carries a signature checked against the output it claims, and the validation rules reject double-spends and inflation. You wrote those checks.
  • And no central server hands out the truth because nodes gossip blocks and converge on a shared chain. You wrote that node.

Put together, those are the moving parts of trustless agreement — consensus without a referee. You didn’t just learn that strangers can agree on one ledger without trusting each other; you built a tiny network that does it. That’s the whole idea of Bitcoin, running on your own machine, in code you understand line by line.

Run the demo one last time and read every line of output knowing exactly what produced it:

Terminal window
cd rust/blockchain-from-scratch
cargo run -- demo

Then pick one extension from the list above and start it today. A great first choice is real difficulty retargeting or Bech32 addresses — both are self-contained, both have a clear “before and after,” and both will teach you something the reading alone couldn’t. Make the change, run cargo test, and watch your blockchain get one step closer to the real thing.

  1. In main, why do we write args.get(1).map(String::as_str) instead of just args[1]? What goes wrong with args[1] when the program is run with no subcommand?

  2. The miner thread calls Arc::clone(&shared) before thread::spawn. Explain what Arc::clone does (and doesn’t) copy, and why the closure needs move.

  3. What does std::mem::take(&mut node.mempool) accomplish, and why can’t we just move node.mempool out directly? What is left behind in the mempool afterward?

  4. Notice that net::send is called after the inner { ... } scope ends — i.e. after the mutex lock is released. Why is it important not to hold the lock while sending over the network?

  5. Open-ended: How would you add a fee-prioritized mempool to serve? Sketch what would change in the miner thread, and what new ordering or size limit you’d introduce. (Peek at RBF, CPFP & the Zero-Confirmation Myth if you want the real design.)

Show answers
  1. args.get(1) returns an Option<&String>None if there’s no such argument — and .map(String::as_str) turns it into an Option<&str> so it can match cleanly against Some("demo"). Plain args[1] indexes directly, so with no subcommand it’s out of bounds and panics (crashes); .get(1) forces you to handle the missing case instead.
  2. Arc::clone copies only the pointer, bumping the reference count — it does not copy the underlying Node; both threads then point at the one shared node. The closure needs move so it takes ownership of the captured clones (the Arc, peer, path, address), which it must own since it outlives the surrounding serve call.
  3. It moves the mempool’s contents out and leaves an empty (default) mempool in its place, handing you the old transactions to mine. You can’t just move node.mempool out directly because you only have a &mut to the struct — moving a field out would leave the struct invalid; take keeps it valid by swapping in the default. Afterward the mempool is empty, ready for the next round.
  4. Network I/O can be slow or block on an unreachable peer, and the lock gives exclusive access to the shared Node — holding it across a send would stall every other thread (the server, other miners) that needs the node. Releasing it first means a slow peer never freezes the rest of the node; gossip and re-sync heal any missed send later.
  5. Open-ended — e.g. keep the mempool as a structure you can sort by fee-per-vbyte (each transaction’s fee divided by its size), and in the miner thread, instead of std::mem::take-ing the whole mempool, select highest-fee-rate transactions first and stop once a running block size/weight limit (e.g. ~4M weight units) is reached, leaving the rest for the next block. You’d also add RBF: let a sender replace a stuck transaction with a higher-fee conflict before it’s mined — see RBF, CPFP & the Zero-Confirmation Myth. This is how strangers bid for scarce block space without a central scheduler.