Skip to content

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.”

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.

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 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 tip

The 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.

In rust/btcmini:

  1. Read src/node.rs. Run cargo test node and the end-to-end cargo test --test chain — watch two_nodes_converge_over_tcp spin up two servers on port 0 and converge.
  2. Run cargo run -- p2p to see two in-process nodes converge over real loopback, printing each step.
  3. Run cargo run -- serve 127.0.0.1:9001 in one terminal and serve 127.0.0.1:9002 in another, then write a tiny client (or extend main.rs) to pull_chain one from the other and confirm the heights match. You’ve just run a two-node network.

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.

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

  1. The node reuses kvlite’s concurrency model. Describe it, and explain what Arc<Mutex<NodeState>> and the Send requirement on thread::spawn together prevent.
  2. What is the mempool for, and why is it keyed by txid? What happens to its transactions when a block confirms them?
  3. 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)?
  4. Two nodes that have never communicated still agree on their genesis block. How, and why does that matter for a pushed block to link?
  5. 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
  1. Thread-per-connection: a TcpListener accepts connections and hands each to its own OS thread; all threads share one NodeState behind Arc<Mutex<…>>. The Arc gives shared ownership; the Mutex enforces one-writer-at-a-time. Because thread::spawn requires its closure to be Send, the compiler refuses to share the chain across threads without the lock — preventing a data race on the ledger at compile time.
  2. 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_confirmed drops them so they aren’t mined again.
  3. Pull asks a peer for its whole chain and adopts it via maybe_adopt if it’s valid and has more work; push sends a single new block the receiver tries to connect to 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.
  4. 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_hash matching B’s tip, so it links cleanly when pushed.
  5. 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.