Day 20 · A P2P Node
Day 19 built the rule that lets strangers converge — most accumulated
work wins. But a rule for converging is useless if the strangers can’t talk. So far the whole chain has
lived inside one program. Today we put it on the network: a minimal peer-to-peer node over std::net
that lets two separate processes exchange blocks and transactions and end up on the exact same chain. This
is where the abstraction becomes real — not “a node believes the most-work chain,” but “two computers,
given only a socket, agree on who owns what.”
The same concurrency model as kvlite
Section titled “The same concurrency model as kvlite”We’ve built a networked server in this playbook before — kvlite’s TCP server in Project
2. The node reuses that exact shape on purpose: thread-per-connection,
no async runtime. A listener accepts connections; each one gets its own OS thread; all threads share one
guarded state. From rust/btcmini/src/node.rs:
#[derive(Clone)]pub struct Node { state: Arc<Mutex<NodeState>>, // chain + mempool, shared across threads}
struct NodeState { chain: Blockchain, mempool: Mempool,}Node is Clone, and cloning it is an Arc bump — every connection thread holds a handle to the same
NodeState. The Mutex enforces “one writer at a time” so two peers delivering blocks at once can’t
corrupt the chain. This is the fearless-concurrency bargain again: the
compiler won’t let you share the chain across threads without a lock, because thread::spawn demands the
closure be Send, and a bare Blockchain shared mutably isn’t. What is the compiler protecting you from?
A data race in the one place it would be most catastrophic — concurrent writes to a ledger.
The mempool
Section titled “The mempool”Transactions that have been broadcast but not yet mined live in the mempool — a holding area the miner
draws from when building a block. It’s just a HashMap keyed by txid (rust/btcmini/src/mempool.rs), so
the same transaction announced twice is stored once, and confirmed transactions are dropped once a block
includes them:
pub struct Mempool { txs: HashMap<Hash256, Transaction> }
pub fn add(&mut self, tx: Transaction) -> bool { /* true if new */ }pub fn remove_confirmed(&mut self, block: &Block) { /* drop mined txs */ }The protocol: two moves, newline JSON
Section titled “The protocol: two moves, newline JSON”The wire format is deliberately tiny — one JSON Message per line, one reply per
line, then the connection closes. Debuggable with nc, and enough to demonstrate the two moves that make a
network converge:
pull: B ── GetChain ──▶ A B ◀── Chain ───── A B adopts A's chain if it has more work
push: A ── NewBlock ──▶ B B appends it if it extends B's tipThe message enum carries everything serde-serialized:
#[serde(tag = "type")]pub enum Message { GetChain, Chain { blocks: Vec<Block>, bits: u32, subsidy: u64 }, NewBlock { block: Block }, NewTx { tx: Transaction }, Ok, Err(String),}Pull is how a fresh node catches up: it asks a peer for the whole chain and runs maybe_adopt, which
rebuilds the chain from scratch (re-validating every block through connect) and switches to it only if
it’s valid and carries strictly more work. Push is how a miner announces a new block: it sends
NewBlock, and the receiver tries to connect it to its tip. Both routes funnel through the same
validation — a peer can say anything, but a block becomes part of your chain only if it survives every
rule from Days 16–19.
Under the hood — a shared genesis, and why “trust” never enters the picture
Section titled “Under the hood — a shared genesis, and why “trust” never enters the picture”Two nodes that have never communicated still agree on their first block, because the genesis is
deterministic (rust/btcmini/src/chain.rs): a fixed timestamp, the zero address, the agreed difficulty,
mined from constants. Every node computes an identical genesis and hash with no messages exchanged — the
shared anchor the rest of the chain hangs from. So when A pushes a block built on that genesis, it links
cleanly onto B’s tip, and the two converge. The integration test two_nodes_converge_over_tcp does the
whole dance over real loopback TCP: A mines a lead, B pulls and adopts it, A mines once more and pushes,
and the assertion is simply that the two nodes’ tip hashes are equal.
Notice what’s absent: at no point does B trust A. B doesn’t accept A’s chain because A is A; it accepts it because it re-checked every signature, every double-spend, every Proof-of-Work, and found more work than its own. Swap “A” for a malicious peer and nothing changes — a lie that fails validation is discarded; a lie that passes validation isn’t a lie, it’s a valid block. Replacing trust-in-a-peer with verify-every-rule is the entire idea of the project, now running across a socket.
Build it
Section titled “Build it”In rust/btcmini:
- Read
src/node.rs. Runcargo test nodeand the end-to-endcargo test --test chain— watchtwo_nodes_converge_over_tcpspin up two servers on port 0 and converge. - Run
cargo run -- p2pto see two in-process nodes converge over real loopback, printing each step. - Run
cargo run -- serve 127.0.0.1:9001in one terminal andserve 127.0.0.1:9002in another, then write a tiny client (or extendmain.rs) topull_chainone from the other and confirm the heights match. You’ve just run a two-node network.
You built a blockchain
Section titled “You built a blockchain”Step back. Over five days you built, from nothing, the four inventions that let strangers agree on money with no trusted third party:
double-SHA-256 ids → a history nobody can quietly rewrite (Day 16) the UTXO set → a ledger where double-spend = a gone row (Day 17) secp256k1 signatures → ownership = a signature, not an identity (Day 18) Proof-of-Work + work → one agreed history, costly to forge (Day 19) a P2P node → two computers converging over a socket (Day 20)And every line of it leaned on the Rust you trained in Phase 1: enums and pattern-matching for
transactions and messages, HashMap and hashing for the ledger, traits and generics underneath, Result
and thiserror for rules that fail loudly, and Arc/Mutex/threads for a node that’s race-free by
construction. The double thread held the whole way: what does building this force you to understand? —
that Bitcoin trades trust-in-a-person for rules-that-are-costly-to-break — and what is the compiler
protecting you from? — the memory and concurrency bugs that, in a ledger, are indistinguishable from
theft.
The bridge to Project 6
Section titled “The bridge to Project 6”A Bitcoin-style chain moves coins. The obvious next question — the one that created Ethereum — is: what if the ledger could run programs, not just move coins? Project 6 · Ethereum swaps the UTXO model for an account model and adds a tiny virtual machine, so the chain stores not just balances but code and state that anyone can call. After that, Project 7 · Solana asks the performance question — what if we could order and execute transactions far faster? — with a Proof-of-History clock and parallel execution, the runway toward your Solana/Anchor goal. You now have the foundation all three build on: you’ve implemented the ideas the next two will abstract.
→ Next: Project 6 · Ethereum → · Prev: Day 19 · Proof-of-Work · Back to the Project 5 overview
Check your understanding
Section titled “Check your understanding”- The node reuses kvlite’s concurrency model. Describe it, and explain what
Arc<Mutex<NodeState>>and theSendrequirement onthread::spawntogether prevent. - What is the mempool for, and why is it keyed by txid? What happens to its transactions when a block confirms them?
- Contrast the pull and push moves. What single thing do both routes have in common that makes a lying peer harmless (as long as you’re not eclipsed)?
- Two nodes that have never communicated still agree on their genesis block. How, and why does that matter for a pushed block to link?
- What is an eclipse attack, and why is it a threat even though the consensus rules are sound? What kind of fix addressed it?
Show answers
- Thread-per-connection: a
TcpListeneraccepts connections and hands each to its own OS thread; all threads share oneNodeStatebehindArc<Mutex<…>>. TheArcgives shared ownership; theMutexenforces one-writer-at-a-time. Becausethread::spawnrequires its closure to beSend, the compiler refuses to share the chain across threads without the lock — preventing a data race on the ledger at compile time. - The mempool holds broadcast-but-unmined transactions for the miner to include in the next block. It’s
keyed by txid so the same transaction announced twice is stored once (dedup). When a block confirms
transactions,
remove_confirmeddrops them so they aren’t mined again. - Pull asks a peer for its whole chain and adopts it via
maybe_adoptif it’s valid and has more work; push sends a single new block the receiver tries toconnectto its tip. Both funnel through the same validation (every rule from Days 16–19), so a peer’s claims are accepted only if they survive the rules — a lie that fails validation is discarded, and a “lie” that passes is just a valid block. - The genesis is deterministic — fixed timestamp, zero address, agreed difficulty, mined from
constants — so every node computes an identical genesis block and hash with no communication. That shared
anchor means a block A mines on top of the genesis has a
prev_hashmatching B’s tip, so it links cleanly when pushed. - An eclipse attack fills a victim node’s limited peer slots with attacker-controlled peers, monopolizing its view of the network and feeding it a false partial reality (enabling double-spends against it). The consensus rules can be perfectly sound yet useless if you only ever see the attacker’s data; the fix was in peer management (how nodes select and remember peers), not in the consensus rules — hardening the network’s edges rather than its math.