blockchaindev
☰ Lessons

Build a blockchain in Rust · Lesson 06 of 07

P2P networking and gossip

TL;DR — Blockchains have no server — every node is a peer. We build a tokio TCP node that connects to peers, broadcasts messages, and dedups by content hash so gossip floods the network without looping forever. libp2p is the production path.

Why a blockchain needs P2P at all

A blockchain has no central server. There is no “the database” that everyone queries. Instead, every node holds a full copy of state and talks directly to a handful of other nodes — its peers. When you submit a transaction, it doesn’t go to a server; it gets handed to one node, which tells its peers, who tell their peers, until the whole network has seen it. That flooding pattern is called gossip, and it’s the substrate every other consensus mechanism sits on top of.

The properties we need from this layer:

  • No single point of failure. Any node can disappear and the network keeps working.
  • Eventual delivery. A message injected anywhere should reach everywhere.
  • Bounded amplification. Naive flooding re-sends the same message forever. We must stop loops.

We’ll build a minimal version of this with tokio, Rust’s async runtime, and then point at libp2p as where you go in production.

The gossip protocol, conceptually

The simplest useful gossip is flooding: when a node receives a message it hasn’t seen before, it forwards that message to all of its peers. The key word is “hasn’t seen before.” Without that check, a message bounces between two peers infinitely — each one thinks the other needs it.

The fix is a seen set: hash the message content, and keep a set of hashes you’ve already processed. First time you see a hash, you act on it and forward it. Second time, you drop it silently. This single rule turns an infinite storm into a clean wavefront that washes over the network exactly once per node.

Real systems (gossipsub) refine this heavily — they maintain a mesh of a few full-message peers per topic and gossip only lightweight “I have message X” announcements (IHAVE/IWANT) to everyone else, which keeps bandwidth amplification bounded. But the dedup-by-hash core is identical, so it’s the right thing to build by hand first.

Project setup

Here are the dependencies. We need tokio with the multi-threaded runtime, TCP networking, I/O utilities, and sync primitives; blake3 for a fast content hash.

Cargo.toml
[package]
name = "gossip-node"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] }
blake3 = "1"

Modeling the node

A node needs three things: a TCP listener so peers can dial in, a set of outbound connections it can broadcast to, and the seen set for dedup. We’ll represent the broadcast fan-out with a tokio broadcast channel: every connected peer’s writer task subscribes to it, so calling send once delivers to all of them.

Shared mutable state (the seen set) lives behind a Mutex inside an Arc so every async task can reach it. Before reacting to any message, a task calls mark_seen, which returns false if the hash was already present — that one boolean is our loop-breaker.

node.rs
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::{broadcast, Mutex};

/// A 32-byte content hash identifying a gossip message.
type MsgHash = [u8; 32];

/// Shared node state, cheap to clone (Arc inside).
#[derive(Clone)]
struct Node {
    /// Hashes of messages we've already processed.
    seen: Arc<Mutex<HashSet<MsgHash>>>,
    /// Fan-out channel: send once, every peer-writer task receives.
    outbound: broadcast::Sender<Vec<u8>>,
}

impl Node {
    /// Records a message hash. Returns `true` if this is the FIRST time
    /// we've seen it (caller should act + forward), `false` if duplicate.
    async fn mark_seen(&self, hash: MsgHash) -> bool {
        let mut seen = self.seen.lock().await;
        seen.insert(hash) // HashSet::insert returns false if already present
    }

    fn hash(payload: &[u8]) -> MsgHash {
        *blake3::hash(payload).as_bytes()
    }
}

The mark_seen method leans on a subtle detail of HashSet::insert: it returns true only when the value was newly added. We get our dedup logic for free from the standard library.

Wire framing

TCP is a byte stream, not a message stream — read can hand you half a message or two-and-a-half messages. So we frame every message with a 4-byte big-endian length prefix. The writer sends len ++ payload; the reader first reads exactly 4 bytes to learn the length, then reads exactly that many bytes for the body. read_exact and write_all from tokio’s AsyncReadExt/AsyncWriteExt handle the partial-read bookkeeping.

node.rs
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf};

/// Read one length-prefixed frame. Returns None on clean EOF.
async fn read_frame(r: &mut OwnedReadHalf) -> std::io::Result<Option<Vec<u8>>> {
    let mut len_buf = [0u8; 4];
    match r.read_exact(&mut len_buf).await {
        Ok(_) => {}
        Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None),
        Err(e) => return Err(e),
    }
    let len = u32::from_be_bytes(len_buf) as usize;
    let mut payload = vec![0u8; len];
    r.read_exact(&mut payload).await?;
    Ok(Some(payload))
}

/// Write one length-prefixed frame.
async fn write_frame(w: &mut OwnedWriteHalf, payload: &[u8]) -> std::io::Result<()> {
    w.write_all(&(payload.len() as u32).to_be_bytes()).await?;
    w.write_all(payload).await?;
    w.flush().await
}

Wiring up a connection

Every TCP connection — whether we dialed out or accepted it — gets split into a read half and a write half with into_split(). We spawn two tasks per connection:

  • A writer task that subscribes to the broadcast channel and forwards every outgoing message onto this socket.
  • A reader task that pulls frames off the socket, dedups them via mark_seen, and — on a genuinely new message — re-broadcasts it so it propagates to all other peers.

This is where the loop-breaking pays off: when peer A’s message arrives, we broadcast it, which sends it to B and C but also echoes back toward A’s own writer. A receives its own message, hashes it, finds it already in seen, and drops it. No storm.

node.rs
use tokio::net::TcpStream;

impl Node {
    /// Take ownership of a socket and run both pumps for it.
    fn handle_connection(&self, stream: TcpStream) {
        let (mut read_half, mut write_half) = stream.into_split();
        let mut rx = self.outbound.subscribe();
        let node = self.clone();

        // Writer pump: drain the broadcast channel onto this socket.
        tokio::spawn(async move {
            while let Ok(msg) = rx.recv().await {
                if write_frame(&mut write_half, &msg).await.is_err() {
                    break; // peer hung up
                }
            }
        });

        // Reader pump: ingest, dedup, re-gossip.
        tokio::spawn(async move {
            while let Ok(Some(payload)) = read_frame(&mut read_half).await {
                let h = Node::hash(&payload);
                if node.mark_seen(h).await {
                    // First time seen — this is where you'd hand it to the
                    // mempool / consensus layer. Then flood onward.
                    println!("[recv new] {}", String::from_utf8_lossy(&payload));
                    let _ = node.outbound.send(payload);
                }
                // else: duplicate, drop silently — this stops gossip loops.
            }
        });
    }
}

The main loop: listen, dial, broadcast

Finally we assemble a runnable node. It binds a TcpListener, dials any peer addresses passed on the command line, then accepts inbound connections forever. A background task injects a heartbeat message every few seconds so you can watch gossip propagate.

node.rs
use tokio::net::TcpListener;

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut args = std::env::args().skip(1);
    let listen_addr = args.next().unwrap_or_else(|| "127.0.0.1:7000".into());
    let peers: Vec<String> = args.collect();

    let (tx, _) = broadcast::channel(1024);
    let node = Node { seen: Arc::new(Mutex::new(HashSet::new())), outbound: tx };

    let listener = TcpListener::bind(&listen_addr).await?;
    println!("listening on {listen_addr}");

    // Dial out to known peers.
    for peer in peers {
        if let Ok(stream) = TcpStream::connect(&peer).await {
            println!("dialed {peer}");
            node.handle_connection(stream);
        }
    }

    // Periodically originate a message so we can watch it spread.
    let origin = node.clone();
    let me = listen_addr.clone();
    tokio::spawn(async move {
        let mut tick = 0u64;
        loop {
            tokio::time::sleep(std::time::Duration::from_secs(3)).await;
            let msg = format!("hello from {me} #{tick}").into_bytes();
            origin.mark_seen(Node::hash(&msg)).await; // don't re-ingest our own
            let _ = origin.outbound.send(msg);
            tick += 1;
        }
    });

    // Accept inbound peers forever.
    loop {
        let (stream, addr) = listener.accept().await?;
        println!("accepted {addr}");
        node.handle_connection(stream);
    }
}

Run three terminals to see gossip in action:

run.sh
cargo run -- 127.0.0.1:7000
cargo run -- 127.0.0.1:7001 127.0.0.1:7000
cargo run -- 127.0.0.1:7002 127.0.0.1:7001

Node 7002 connects only to 7001, yet it receives messages originated by 7000 — relayed through 7001. That is gossip: transitive delivery without full connectivity. Watch the logs and note you never see the same message printed twice on a node; the seen set guarantees it.

What we skipped (and where libp2p comes in)

This node is deliberately minimal. A production P2P stack adds: peer discovery (you can’t hardcode addresses), authenticated/encrypted channels (so peers can’t impersonate or eavesdrop), backpressure and per-peer scoring (to resist spam), NAT traversal, and bounded gossip amplification — flooding every message to every peer doesn’t scale to thousands of nodes.

That last point is the heart of gossipsub: instead of flooding full messages, each node keeps a small mesh of full-message peers per topic and sends only compact “I have message X” gossip (IHAVE) to the rest, who request the body (IWANT) only if they’re missing it. This caps bandwidth per node regardless of network size while keeping our dedup-by-hash core.

In Rust, you don’t reimplement this — you use the libp2p-gossipsub crate, which gives you the mesh, peer scoring, message signing, and transport security out of the box. The mental model you built here — hash, check seen, forward once — is exactly what’s running underneath. You now understand the layer instead of treating it as magic.

Takeaways

  • A blockchain network is peers gossiping, not clients hitting a server.
  • Flooding + a content-hash seen set gives correct, loop-free propagation.
  • TCP needs explicit framing (length prefix) because it’s a byte stream.
  • tokio’s broadcast channel makes “send once, deliver to all peers” trivial.
  • Reach for libp2p/gossipsub in production for discovery, security, and bounded amplification — but the dedup core is what you built here.

The final lesson builds the layer that executes the transactions you’ve now learned to order and propagate: a minimal VM.

Sources