Skip to main content

Command Palette

Search for a command to run...

From Bitcoin Whitepaper to Working Blockchain in Rust

UTXO transactions, proof-of-work mining, Merkle trees, and hash-linked blocks — translating Satoshi's 9-page paper into working Rust code

Published
18 min read
From Bitcoin Whitepaper to Working Blockchain in Rust

From Bitcoin Whitepaper to Working Blockchain in Rust

In 2008, Satoshi Nakamoto published a nine-page paper that described a peer-to-peer electronic cash system. The paper defined what the system should do — digital signatures for ownership, proof-of-work for consensus, a chain of hashes for immutability — but left how to turn those ideas into working code as an exercise for the reader.

We took that exercise seriously. We implemented the entire Bitcoin whitepaper in Rust — every core concept, from UTXO-based transactions to proof-of-work mining to Merkle tree commitments — as a working, runnable blockchain. This post walks through that translation: whitepaper concept → Rust data structure → working code.

If you've read the whitepaper and thought "okay, but how do I actually build this?", this is the post for you.

The Big Picture: How the Pieces Fit Together

Before diving into code, here's the full architecture. Every component maps to a specific whitepaper section:

Full node architecture: Wallet, Mempool, Miner, Blockchain, UTXO Set, and P2P Network — each mapping to a whitepaper section

The mapping from whitepaper concepts to Rust types:

Whitepaper Concept Rust Type Whitepaper Section
"chain of digital signatures" Transaction, TXInput, TXOutput §2 Transactions
"timestamp server" / "chain of blocks" Block, BlockHeader §3 Timestamp Server
"proof-of-work" ProofOfWork §4 Proof-of-Work
"not already spent" UTXOSet (sled database) §5 Network
"coinbase transaction" Transaction::new_coinbase_tx() §6 Incentive
"Merkle tree" hash_transactions() §7 Reclaiming Disk Space

The key insight: a blockchain is not a database of accounts and balances. It's a log of state transitions — each transaction consumes previously created outputs and creates new ones. Everything else exists to make that rule globally auditable.

Let's build each piece.

1. Transactions: The Chain of Digital Signatures

The Concept

The whitepaper defines an electronic coin as "a chain of digital signatures." Each owner transfers value by signing a hash of the previous transaction and the public key of the next owner. But what does "transfer value" actually mean in code?

Think of it like cash, not like a bank account. You don't have a "balance" — you have a collection of unspent outputs (like bills in your wallet). To pay someone, you hand over specific bills and get change back. A transaction is that exchange: it destroys the bills you're spending and creates new ones.

UTXO transaction flow: Alice's two unspent outputs are consumed as inputs, creating a 9-coin output for Bob and 3-coin change back to Alice

The Rust Types

This model translates into three types working together:

/// A spendable output: value locked to a public key hash.
/// Think of it as a "bill" in someone's wallet.
#[derive(Clone, Serialize, Deserialize)]
pub struct TXOutput {
    value: i32,
    pub_key_hash: Vec<u8>,   // the "lock" — who can spend this?
}

/// A claim on a previous output: "I'm spending this specific bill."
/// The signature proves you own the private key that matches the lock.
#[derive(Clone, Default, Serialize, Deserialize)]
pub struct TXInput {
    txid: Vec<u8>,            // which transaction created the output
    vout: usize,              // which output index in that transaction
    signature: Vec<u8>,       // proof of authorization (Schnorr signature)
    pub_key: Vec<u8>,         // the spender's public key
}

/// The atomic ledger transition: spend old outputs, create new ones.
/// A transaction is only valid if ALL inputs are unspent and ALL
/// signatures check out.
#[derive(Clone, Default, Serialize, Deserialize)]
pub struct Transaction {
    id: Vec<u8>,              // SHA-256 hash of the transaction data
    vin: Vec<TXInput>,        // inputs: which existing outputs we consume
    vout: Vec<TXOutput>,      // outputs: new spendable value we create
}

The relationship between these types:

TXInput refers to a TXOutput from a prior transaction — the validator checks that the output exists in the UTXO set, the public key matches the lock, and the signature is valid

Creating a Transaction

Here's what it looks like in practice — Alice sending coins to Bob:

pub async fn new_utxo_transaction(
    from: &WalletAddress,
    to: &WalletAddress,
    amount: i32,
    utxo_set: &UTXOSet,
) -> Result<Transaction> {
    let wallet = WalletService::new()?.get_wallet(from)?;
    let pub_key_hash = hash_pub_key(wallet.get_public_key());

    // Step 1: Find enough unspent outputs to cover the amount.
    // Like rifling through your wallet for enough bills.
    let (available, valid_outputs) = utxo_set
        .find_spendable_outputs(&pub_key_hash, amount)
        .await?;

    if available < amount {
        return Err(BtcError::NotEnoughFunds);
    }

    // Step 2: Build inputs — one per unspent output we're consuming.
    // Each input says "I'm spending output #vout from transaction txid."
    let mut inputs = vec![];
    for (txid_hex, out_indexes) in valid_outputs {
        let txid = HEXLOWER.decode(txid_hex.as_bytes())?;
        for index in out_indexes {
            inputs.push(TXInput {
                txid: txid.clone(),
                vout: index,
                signature: vec![],
                pub_key: wallet.get_public_key().to_vec(),
            });
        }
    }

    // Step 3: Build outputs — payment to recipient + change back to sender.
    // If we're spending 12 coins but only need to send 9, we create a
    // 3-coin "change" output back to ourselves.
    let mut outputs = vec![TXOutput::new(amount, to)?];
    if available > amount {
        outputs.push(TXOutput::new(available - amount, from)?);
    }

    // Step 4: Hash the transaction to produce its ID, then sign each input
    // with our private key to prove we're authorized to spend these outputs.
    let mut tx = Transaction { id: vec![], vin: inputs, vout: outputs };
    tx.id = tx.hash()?;
    tx.sign(utxo_set.get_blockchain(), wallet.get_pkcs8()).await?;
    Ok(tx)
}

Every spend references a specific prior output, proves authorization with a cryptographic signature, and creates new outputs that can be spent in the future. This is the whitepaper's "chain of digital signatures" made concrete.

2. Blocks: The Timestamp Server

The Concept

Transactions on their own aren't enough — we need a way to order them and agree on which transactions happened first (especially when someone tries to spend the same coins twice). The whitepaper solves this with a timestamp server that batches transactions into blocks and chains them together.

Block chaining: three blocks linked by prev_hash — modifying Block 1 cascades through the entire chain, invalidating every subsequent block

The Rust Types

#[derive(Clone, Serialize, Deserialize)]
pub struct BlockHeader {
    timestamp: i64,
    pre_block_hash: String,   // hash of the previous block (THE chain link)
    hash: String,             // this block's hash (computed via proof-of-work)
    nonce: i64,               // the value that makes the hash valid
    height: usize,            // position in the chain (block number)
}

#[derive(Clone, Serialize, Deserialize)]
pub struct Block {
    header: BlockHeader,
    transactions: Vec<Transaction>,
}

The pre_block_hash field is what makes this a chain. It's just one String — but that single field is what makes the entire history tamper-evident. Every block commits to the one before it, and through that, transitively commits to every block all the way back to genesis.

Building a Block

Block creation ties the header, transactions, and proof-of-work together:

impl Block {
    pub fn new_block(
        pre_block_hash: String,
        transactions: &[Transaction],
        height: usize,
    ) -> Block {
        let header = BlockHeader {
            timestamp: crate::current_timestamp(),
            pre_block_hash,
            hash: String::new(),      // empty — PoW will fill this in
            nonce: 0,                 // zero — PoW will find the right value
            height,
        };
        let mut block = Block {
            header,
            transactions: transactions.to_vec(),
        };

        // Mine the block — find a nonce that produces a valid hash.
        // This is the expensive part. Everything above is instant.
        let pow = ProofOfWork::new_proof_of_work(block.clone());
        let (nonce, hash) = pow.run();
        block.header.nonce = nonce;
        block.header.hash = hash;
        block
    }
}

Notice that hash starts empty and nonce starts at zero. The proof-of-work algorithm tries millions of nonce values until it finds one that produces a hash meeting the difficulty requirement. Until mining completes, the block isn't valid.

3. Proof-of-Work: Making History Immutable

The Concept

Here's the problem: if anyone can create blocks cheaply, an attacker could rewrite history by generating an alternative chain. Proof-of-work solves this by making block creation expensive — you have to burn CPU cycles finding a hash below a target threshold.

Proof-of-work: the mining target threshold, brute-force nonce search, instant verification, and difficulty comparison from TARGET_BITS=8 to Bitcoin's 2^75

The Rust Implementation

pub struct ProofOfWork {
    block: Block,
    target: BigInt,       // the threshold — hash must be below this
}

const TARGET_BITS: i32 = 8;      // difficulty: 8 leading zero bits
const MAX_NONCE: i64 = i64::MAX;

impl ProofOfWork {
    pub fn new_proof_of_work(block: Block) -> ProofOfWork {
        // BigInt from the `num` crate — Rust's native integers can't hold
        // 256-bit numbers, so we use arbitrary-precision arithmetic.
        let mut target = BigInt::from(1);
        // Shift left (<<= 248) to create the target threshold.
        // 256 - 8 = 248, so target = 2^248.
        // Any hash < 2^248 starts with at least 8 zero bits.
        target.shl_assign(256 - TARGET_BITS);
        ProofOfWork { block, target }
    }

    /// Concatenate all block data + nonce into the bytes we hash.
    fn prepare_data(&self, nonce: i64) -> Vec<u8> {
        let pre_block_hash = self.block.get_pre_block_hash();
        let transactions_hash = self.block.hash_transactions();
        let timestamp = self.block.get_timestamp();

        let mut data_bytes = vec![];
        data_bytes.extend(pre_block_hash.as_bytes());  // chain link
        data_bytes.extend(transactions_hash);           // tx commitment
        data_bytes.extend(timestamp.to_be_bytes());     // when
        data_bytes.extend(TARGET_BITS.to_be_bytes());   // difficulty
        data_bytes.extend(nonce.to_be_bytes());          // the search variable
        data_bytes
    }

    /// The mining loop: try nonces until we find a valid hash.
    pub fn run(&self) -> (i64, String) {
        let mut nonce = 0;
        let mut hash = Vec::new();

        while nonce < MAX_NONCE {
            let data = self.prepare_data(nonce);
            hash = sha256_digest(data.as_slice());
            // Interpret the 32-byte hash as a positive 256-bit integer
            // so we can compare it numerically against the target.
            let hash_int = BigInt::from_bytes_be(Sign::Plus, hash.as_slice());

            if hash_int.lt(&self.target) {
                break;  // Found it! This hash is below the target.
            } else {
                nonce += 1;  // Nope. Try the next one.
            }
        }

        (nonce, HEXLOWER.encode(hash.as_slice()))
    }
}

The algorithm is a brute-force search. prepare_data() packs the block's identity — previous hash, transaction commitment, timestamp, difficulty, and the current nonce guess — into a byte vector. run() hashes that data, checks whether the result falls below the target, and increments the nonce if it doesn't.

Why this matters: to rewrite a historical block, an attacker would need to redo the proof-of-work for that block and every block after it, then outpace the honest network. Each additional block makes the attack exponentially harder.

Verification is the elegant part. Mining takes millions of attempts, but any node can verify the result with a single hash:

Mining:      try nonce 0, 1, 2, ... 891 → hash < target ✓  (expensive)
Verification: hash(block_data + 891)     → hash < target ✓  (one operation)

4. The UTXO Set: Preventing Double-Spending

The Concept

The whitepaper states that "the only way to confirm the absence of a transaction is to be aware of all transactions." In practice, we don't need the full transaction history — we just need to know which outputs haven't been spent yet. That's the UTXO set (Unspent Transaction Output set).

Think of it as a ledger of "bills that still exist." After processing a few blocks, the UTXO set might contain four entries: Alice owns a 5-coin output and a 7-coin output, Bob owns a 3-coin output, and a miner owns a 10-coin output. Alice's "balance" is just the sum of her entries (12 coins) — there's no balance field anywhere.

When Alice spends her 5-coin output, that entry is removed from the set and the new outputs her transaction creates are added. If anyone tries to reference that 5-coin output again — whether Alice herself or an attacker replaying the transaction — the lookup fails because it's gone from the set. One set membership check is the entire double-spending prevention mechanism.

The Rust Implementation

pub struct UTXOSet {
    blockchain: BlockchainService,
}

impl UTXOSet {
    /// Find outputs owned by this public key hash that we can spend,
    /// accumulating until we have enough to cover the requested amount.
    pub async fn find_spendable_outputs(
        &self,
        pub_key_hash: &[u8],
        amount: i32,
    ) -> Result<(i32, HashMap<String, Vec<usize>>)> {
        let mut unspent_outputs: HashMap<String, Vec<usize>> = HashMap::new();
        let mut accumulated = 0;

        // Open the "chainstate" tree in our sled database.
        // This is where we persist the UTXO set between restarts.
        let db = self.blockchain.get_db().await?;
        let utxo_tree = db.open_tree("chainstate")?;

        for item in utxo_tree.iter() {
            let (k, v) = item?;
            let txid_hex = HEXLOWER.encode(k.to_vec().as_slice());
            let (outputs, _): (Vec<TXOutput>, usize) =
                bincode::serde::decode_from_slice(&v, bincode::config::standard())?;

            for (index, out) in outputs.iter().enumerate() {
                // Three checks: do we own it, is it worth something,
                // and do we still need more?
                if out.is_locked_with_key(pub_key_hash)
                    && out.get_value() > 0
                    && accumulated < amount
                {
                    accumulated += out.get_value();
                    unspent_outputs
                        .entry(txid_hex.clone())
                        .or_default()
                        .push(index);
                }
            }
        }

        Ok((accumulated, unspent_outputs))
    }
}

The UTXO set is stored in a sled embedded database under the "chainstate" tree. When a block is accepted, we remove spent outputs and add newly created ones. When a transaction input references an output that isn't in the set, we reject it — that output was either already spent or never existed.

5. Merkle Trees: Committing to Transactions

The Concept

A block header needs to commit to its transactions without storing all of them. The whitepaper solves this with a Merkle tree — a binary tree of hashes where only the root is stored in the header. Change any transaction and the root changes, which changes the block hash, which invalidates the proof-of-work.

Merkle tree: transaction hashes pair up recursively to a single root stored in the block header — tampering with any transaction cascades up and invalidates the proof-of-work

The Rust Implementation

Our implementation takes a simplified approach that preserves the essential commitment property:

impl Block {
    /// Compute a single hash that commits to all transactions in this block.
    /// If any transaction changes, this hash changes, which changes the
    /// block header hash, which invalidates the proof-of-work.
    pub fn hash_transactions(&self) -> Vec<u8> {
        let mut tx_hashes = vec![];
        for transaction in &self.transactions {
            tx_hashes.extend(transaction.get_id());
        }
        sha256_digest(tx_hashes.as_slice())
    }
}

We concatenate all transaction IDs and hash the result. A full binary Merkle tree (as shown in the diagram above) would hash pairs recursively, enabling compact inclusion proofs for lightweight (SPV) clients. Our simplified version doesn't support SPV proofs, but it preserves the critical property: the block header hash depends on every transaction in the block. Tamper with any transaction and the chain breaks.

6. The Genesis Block: Where It All Starts

Every blockchain needs a starting point — a block with no predecessor. This is the genesis block, and it's hardcoded into the software:

pub const GENESIS_BLOCK_PRE_BLOCK_HASH: &str = "None";

pub fn generate_genesis_block(transaction: &Transaction) -> Block {
    Block::new_block(
        GENESIS_BLOCK_PRE_BLOCK_HASH.to_string(),
        &[transaction.clone()],
        1,  // height 1 — the first block
    )
}

The genesis block's pre_block_hash is "None" — there's nothing before it. It contains a single coinbase transaction that creates the first coins in the system. From this point forward, every block links to the one before it, and every coin in circulation traces back through the transaction graph to a coinbase reward in some block's first transaction.

7. The Coinbase Transaction: Minting New Coins

The whitepaper describes the incentive mechanism: "the first transaction in a block is a special transaction that starts a new coin owned by the creator of the block." This is the coinbase transaction — the only way new coins enter circulation.

const SUBSIDY: i32 = 10;  // mining reward: 10 coins per block

impl Transaction {
    pub fn new_coinbase_tx(to: &WalletAddress) -> Result<Transaction> {
        // The output: pay SUBSIDY coins to the miner's address
        let txout = TXOutput::new(SUBSIDY, to)?;

        // Coinbase input is special — it creates coins from nothing.
        // No previous transaction to reference. The signature field
        // carries a unique ID instead (Bitcoin uses arbitrary data here;
        // we use a UUID).
        let tx_input = TXInput {
            signature: Uuid::new_v4().as_bytes().to_vec(),
            ..Default::default()  // txid and vout are zeroed
        };

        let mut tx = Transaction {
            id: vec![],
            vin: vec![tx_input],
            vout: vec![txout],
        };
        tx.id = tx.hash()?;
        Ok(tx)
    }
}

The coinbase transaction is unique in two ways: it has no real inputs (it creates value rather than transferring it), and it's always the first transaction in a block. Every miner includes one in the block they're trying to mine, paying the reward to their own address. This is how the whitepaper aligns miner incentives with network security — you get paid for doing the work that secures the chain.

8. Putting It All Together: The Node Pipeline

With all the pieces in place, here's the end-to-end data flow through a running node:

Node pipeline: three phases — Receive Transaction (decode, verify, mempool), Mine Block (collect, coinbase, PoW), Accept Block (validate, update UTXO set, extend chain) — mapping to Whitepaper Section 5

This is exactly the six-step protocol the whitepaper describes in Section 5. Notice how every piece we built maps to a specific step: incoming transactions are verified against the UTXOSet (§4) and held in the mempool. When the miner triggers, it collects those transactions, prepends a coinbase (§7), constructs a Block (§2), and runs ProofOfWork (§3). When a valid block arrives — either from our own miner or from a peer over the network — the node verifies the pre_block_hash chain link, re-checks the proof-of-work, validates every transaction signature, and atomically updates the UTXO set before extending the chain.

The order matters: the UTXO set update is the last step because it's the point of no return. Once spent outputs are removed, they can never be spent again. Everything before that step is validation — everything after it is commitment.

What We Simplified (and Why)

This is a teaching implementation, not a production Bitcoin node. We made deliberate simplifications to keep the code focused on whitepaper concepts:

Simplification Real Bitcoin Our Implementation Why
Hash function SHA-256d (double hash) SHA-256 (single) Same concept, less confusion
Merkle tree Full binary tree Concatenate + hash Preserves commitment; skips SPV proofs
Difficulty Dynamic (adjusts every 2,016 blocks) Fixed (TARGET_BITS = 8) Keeps mining fast for testing
Signatures ECDSA (legacy) / Schnorr (Taproot) Schnorr More modern than Bitcoin's original
Storage LevelDB sled (embedded Rust DB) Pure Rust, no C dependencies
Script system Full Bitcoin Script VM Public key hash lock Same ownership model, simpler code

None of these change the architecture. The whitepaper's core design — UTXO model, proof-of-work consensus, hash-linked blocks, digital signatures for authorization — is implemented faithfully. If you understand this code, you understand how Bitcoin works.

Key Takeaways

  1. A blockchain is a log of state transitions, not a database of balances. Transactions consume outputs and create new ones. The UTXO set is derived state — like an index built from a log.

  2. The whitepaper is surprisingly implementable. The core types fit in about 100 lines of Rust structs. The mining algorithm is under 50 lines. The hard part isn't the crypto — it's getting the state management right.

  3. Proof-of-work is elegant. Expensive to produce, trivial to verify, and it makes history rewriting computationally impractical. One if hash_int < target check validates what may have taken millions of attempts to produce.

  4. Rust is a natural fit for blockchain. Ownership semantics map cleanly to UTXO ownership. The type system catches whole categories of bugs at compile time. serde + bincode make serialization painless, and sled gives us an embedded database without leaving the Rust ecosystem.

  5. The "chain" in blockchain is just one field. pre_block_hash in the block header links everything together. That single 32-byte hash is what makes the entire history tamper-evident.

Explore the Source Code

The complete implementation is open source:

Full repository: github.com/bkunyiha/rust-blockchain

Key files referenced in this post:

  • block.rs — Block and BlockHeader structs, genesis block creation
  • transaction.rs — Transaction, TXInput, TXOutput, coinbase creation
  • pow.rs — Proof-of-work implementation
  • utxo_set.rs — UTXO set management and spendable output queries
  • hash.rs — SHA-256 hashing utilities

This post is adapted from Rust Blockchain: A Full-Stack Implementation Guide — a 33-chapter, 716-page book covering Axum, Iced, Tauri 2, Tokio, SQLCipher, Docker, and Kubernetes through one cohesive project.

Coming mid April on Amazon (paperback + Kindle), Gumroad (PDF + source code), and Leanpub (pay what you want).

Free resources: Download the Rust+Blockchain Cheat Sheet | Clone the Starter Template

Follow buildwithrust.com for weekly posts on building production systems in Rust.

More from this blog

B

Build with Rust

4 posts

Production-grade Rust tutorials for backend engineers and systems programmers. Each post is a deep dive into real architecture patterns extracted from a full-stack project: REST APIs with Axum, desktop applications with Iced and Tauri 2, async concurrency with Tokio, encrypted storage with SQLCipher, containerization with Docker, and orchestration with Kubernetes. No toy examples — every code snippet compiles and runs. Companion blog to the book Rust Blockchain: A Full-Stack Implementation Guide