Build a Blockchain Engine in Rust

Build a Proof-of-Work blockchain engine with mining and tamper detection.

Introduction

30 Second Summary

Every time you transfer money online, a bank sits in the middle verifying the transaction. Blockchains remove that middleman by making every participant prove their work cryptographically. But how does that actually work at the code level?

In this project, you will build a fully functional blockchain engine from scratch in Rust. You will implement transactions, mine blocks with a Proof-of-Work algorithm, and prove that tampering with any historical block breaks the entire chain.

What You'll Build

A modular blockchain engine running in your terminal where you create transactions, mine blocks by watching the nonce iterate until it finds a valid hash, and then attempt to tamper with the chain to watch it reject the forgery.

By the end of this project, you'll have:

  • A working Proof-of-Work mining loop that iterates through nonces until it finds a hash with the required leading zeros, giving you a visceral feel for the computational cost that secures real blockchains.
  • A trait-based architecture where you can swap between SHA-256 and double-SHA-256 hashing by changing a single enum variant, with zero changes to block or chain logic.
  • A tamper-detection demo where you mutate a past transaction and watch the validation engine pinpoint exactly which block was forged and why.
  • Secret Mission: a HashCash-style difficulty adjustment that auto-tunes mining time, just like Bitcoin adapts to keep block intervals stable.

Are there any prerequisites?

This project assumes solid Rust experience with ownership, traits, enums, and generics. Familiarity with basic cryptographic concepts like hashing is helpful but not required.

Before We Start

Before We Start...

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up the Project Scaffold

Every blockchain component you'll build needs cryptographic hashing and serialization. Before writing any blockchain logic, you need a Rust project configured with those capabilities.

In this step, you'll create a Rust binary project, add the required crate dependencies, lay out a modular file structure, and confirm everything compiles with a quick SHA-256 test.

In this step, get ready to:
  • Create a Rust binary project with crypto and serialization dependencies.
  • Set up the modular file structure for the blockchain engine.
  • Verify the SHA-256 crate works with a hello-world hash.
Create the project and add dependencies

You need a dedicated project folder on your Desktop and a Rust binary crate to work in.

  • Open your terminal.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create a new Rust binary project and move into it by running these commands:
cargo new blockchain-engine
cd blockchain-engine

What does this command do?

cargo new blockchain-engine creates a new directory with a Cargo.toml manifest and a src/main.rs file containing a hello-world program. It also initializes a Git repository in the folder.

  • Confirm the project was created by running:
ls

You should see Cargo.toml and a src directory listed.

  • Open the project in Zed by running:
zed .

Command not found for zed?

If zed is not recognized, you need to install the Zed CLI first. Open Zed manually, press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows/Linux) to open the command palette, then run cli: install cli binary.

help me install the Zed CLI so I can open projects from the terminal

Zed opens with your blockchain-engine folder loaded in the file explorer sidebar.

  • In the Zed sidebar, click Cargo.toml to open it.
  • Replace the entire file contents with the following:
[package]
name = "blockchain-engine"
version = "0.1.0"
edition = "2021"

[dependencies]
sha2 = "0.11.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
hex = "0.4"

What are these dependencies?

  • sha2 provides the SHA-256 hashing algorithm your blockchain uses to fingerprint every block.
  • serde with the derive feature lets you automatically serialize Rust structs to bytes for hashing.
  • serde_json converts structs to JSON byte arrays that feed into the hasher.
  • hex encodes raw hash bytes into human-readable hex strings for display.
  • Save Cargo.toml.
  • In your terminal, download and compile the dependencies by running:
cargo build

You should see output ending with a line like Finished and zero errors. The first build downloads the crates, so it may take 20-30 seconds.

Seeing a compilation error?

Double-check that the [dependencies] section in your Cargo.toml matches the code above exactly. A missing quote or bracket will cause the build to fail.

help me fix this cargo build error in my blockchain-engine project

Define the module structure

A well-structured Rust project separates concerns into modules. Each blockchain component gets its own file so you can work on hashing, transactions, blocks, and chain logic independently.

  • Create the empty module files by running this command in your terminal:

macOS

touch src/types.rs src/hasher.rs src/transaction.rs src/block.rs src/consensus.rs src/chain.rs

Windows

New-Item src/types.rs, src/hasher.rs, src/transaction.rs, src/block.rs, src/consensus.rs, src/chain.rs
  • Confirm the files were created by running:
ls src/

You should see seven files listed: block.rs, chain.rs, consensus.rs, hasher.rs, main.rs, transaction.rs, and types.rs.

Why so many empty files?

Rust's module system requires a file to exist for each mod declaration in main.rs. You'll fill each file in the upcoming steps as you build each blockchain component.

Verify with a hello-world hash

Before building any blockchain logic, you should prove the sha2 crate works end-to-end by hashing a test string and printing the result.

  • In Zed, click src/main.rs in the sidebar to open it.
  • Replace the entire file contents with the following code:
mod types;
mod hasher;
mod transaction;
mod block;
mod consensus;
mod chain;

fn main() {
    use sha2::{Sha256, Digest};
    let hash = Sha256::digest(b"hello blockchain");
    println!("SHA-256: {:x}", hash);
}

What does this code do?

  • The six mod declarations tell Rust to include each module file you just created.
  • Sha256::digest(b"hello blockchain") hashes the byte string in one call and returns 32 raw bytes.
  • {:x} formats those bytes as a lowercase hexadecimal string for readable output.
  • Save src/main.rs.

Before you run this, what do you expect to see printed?

  • Run the program by executing:
cargo run

You should see a line that reads SHA-256: followed by a 64-character hexadecimal string. That confirms the sha2 crate is working and your module structure compiles cleanly.

Seeing a module not found error?

If you see an error like file not found for module, one of the module files is missing from the src/ directory. Run ls src/ and confirm all seven files exist, including consensus.rs.

help me fix the module not found error in my Rust blockchain project

✔️ Awesome, I've got everything!

Great. Double check that you've saved both files.

ⓧ I'd like to double check the full code

Here is the full Cargo.toml:

[package]
name = "blockchain-engine"
version = "0.1.0"
edition = "2021"

[dependencies]
sha2 = "0.11.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
hex = "0.4"

Here is the full src/main.rs:

mod types;
mod hasher;
mod transaction;
mod block;
mod consensus;
mod chain;

fn main() {
    use sha2::{Sha256, Digest};
    let hash = Sha256::digest(b"hello blockchain");
    println!("SHA-256: {:x}", hash);
}

The remaining files (types.rs, hasher.rs, transaction.rs, block.rs, consensus.rs, chain.rs) should all be empty.

Your project scaffold is locked in. Next up, you'll define the zero-copy core types and build the pluggable Hasher trait that powers all cryptographic operations in the engine.

Build Core Types and the Hasher Trait

Your project compiles and the cryptographic crates are locked in. Now you need the foundation that every other module will build on: hash types and a pluggable hashing interface.

Without this foundation, every component would be coupled to a single algorithm. You would also pay for heap allocations on every hash. In this step, you will define zero-copy types that live on the stack, then wrap them in a trait-based architecture that lets you swap algorithms freely.

In this step, get ready to:
  • Define stack-allocated hash and address types with helper functions.
  • Build a Hasher trait with SHA-256 and double-SHA-256 implementations.
  • Hash a genesis payload and print the result to confirm the system works end-to-end.
Define zero-copy core types

A SHA-256 hash is always exactly 32 bytes. Instead of allocating a Vec<u8> on the heap every time you compute a hash, you can represent it as a fixed-size array on the stack. In a hot mining loop, this avoids thousands of unnecessary allocations.

  • Open src/types.rs in Zed and replace its contents with the following code:
pub type Hash = [u8; 32];
pub type Address = [u8; 20];

pub const ZERO_HASH: Hash = [0u8; 32];

pub fn hash_to_hex(hash: &Hash) -> String {
    hex::encode(hash)
}

pub fn current_timestamp() -> u64 {
    use std::time::{SystemTime, UNIX_EPOCH};
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs()
}

What does this code do?

  • Hash = [u8; 32] is a 32-byte array that lives entirely on the stack. No heap allocation, no reference counting.
  • Address = [u8; 20] simulates a 20-byte address like Ethereum uses.
  • ZERO_HASH is a constant of all zeros. The genesis block will use it as its "previous hash" since nothing comes before it.
  • hash_to_hex converts a raw byte array into a human-readable hex string for display.
  • current_timestamp returns the current Unix epoch in seconds. Blocks will use this to record when they were created.
  • Save src/types.rs.
Build the Hasher trait and SHA-256 implementation

The Hasher trait defines a single contract: given bytes in, produce a 32-byte hash out. Any struct that implements this trait becomes a valid hashing backend. This is what makes the architecture pluggable.

  • Open src/hasher.rs and add the trait definition and the SHA-256 hasher:
use sha2::{Sha256, Digest};
use crate::types::Hash;

pub trait Hasher {
    fn hash(&self, data: &[u8]) -> Hash;
}

pub struct Sha256Hasher;

impl Hasher for Sha256Hasher {
    fn hash(&self, data: &[u8]) -> Hash {
        let result = Sha256::digest(data);
        let mut hash = [0u8; 32];
        hash.copy_from_slice(&result);
        hash
    }
}

What does this code do?

  • The Hasher trait declares the interface: any type that implements it must provide a hash method that takes a byte slice and returns a Hash.
  • Sha256Hasher is a zero-sized struct (no fields). Its hash method uses the sha2 crate to digest data and copies the result into a fixed-size array.
  • Save src/hasher.rs.
  • Confirm everything compiles by running:
cargo build

You should see a successful compilation with no errors. You may see warnings about unused imports or dead code. Those are fine for now.

Seeing a compilation error?

  • If you see "unresolved import crate::types::Hash", make sure src/types.rs is saved and contains the pub type Hash line.
  • If you see "cannot find crate sha2", confirm your Cargo.toml has sha2 = "0.11.0" under [dependencies].

Help me fix my cargo build error in hasher.rs

Add double-SHA-256 and enum dispatch

Bitcoin hashes each block twice (hash-of-hash) to mitigate length-extension attacks. You will implement this as a second hasher, then wrap both behind an enum so the rest of your code never needs to know which algorithm is active.

  • In src/hasher.rs, add the following code below the Sha256Hasher implementation:
pub struct DoubleSha256Hasher;

impl Hasher for DoubleSha256Hasher {
    fn hash(&self, data: &[u8]) -> Hash {
        let first = Sha256::digest(data);
        let second = Sha256::digest(&first);
        let mut hash = [0u8; 32];
        hash.copy_from_slice(&second);
        hash
    }
}

#[derive(Clone)]
pub enum HashAlgorithm {
    Sha256,
    DoubleSha256,
}

impl HashAlgorithm {
    pub fn hash(&self, data: &[u8]) -> Hash {
        match self {
            HashAlgorithm::Sha256 => Sha256Hasher.hash(data),
            HashAlgorithm::DoubleSha256 => DoubleSha256Hasher.hash(data),
        }
    }
}

What does this code do?

  • DoubleSha256Hasher hashes the data once, then hashes that result again. This mirrors Bitcoin's actual hashing scheme.
  • HashAlgorithm is an enum that dispatches to the correct hasher via a match statement. This avoids vtable indirection from dyn Trait and keeps everything on the stack.
  • To swap algorithms later, you just change the enum variant at the call site. The block and chain modules never need to know which algorithm runs under the hood.

Why enum dispatch instead of dyn Trait?

Using dyn Hasher would require a heap-allocated trait object and virtual function calls. In a mining loop that hashes millions of times, that overhead adds up. Enum dispatch compiles to a simple branch instruction with zero indirection.

  • Save src/hasher.rs.

✔️ Awesome, I've got everything!

Great. Make sure you have saved src/hasher.rs.

ⓧ I'd like to double check the full code

Scroll up and confirm your src/hasher.rs contains these sections in order:

  • The use sha2::{Sha256, Digest} and use crate::types::Hash imports at the top.
  • The Hasher trait with one method.
  • Sha256Hasher struct and its impl Hasher block.
  • DoubleSha256Hasher struct and its impl Hasher block.
  • The HashAlgorithm enum with Sha256 and DoubleSha256 variants, plus its impl block with the hash method.
Create the genesis block stub and print its hash

Time to wire everything together. You will update main.rs to use your new HashAlgorithm enum to hash a genesis payload and print the hex result.

  • Open src/main.rs and replace its entire contents with the following code:
mod types;
mod hasher;
mod transaction;
mod block;
mod consensus;
mod chain;

use hasher::HashAlgorithm;

fn main() {
    let algo = HashAlgorithm::Sha256;
    let hash = algo.hash(b"genesis");
    println!("Genesis hash: {}", crate::types::hash_to_hex(&hash));
}

What does this code do?

  • HashAlgorithm::Sha256 selects the single-pass SHA-256 algorithm. Changing this to DoubleSha256 would switch to Bitcoin-style double hashing with no other code changes.
  • algo.hash(b"genesis") hashes the byte string genesis and returns a [u8; 32].
  • hash_to_hex converts those 32 bytes into a 64-character hexadecimal string for human-readable output.
  • Save src/main.rs.

Before you run this, what do you expect to see? Think about what a SHA-256 hash of the word "genesis" would look like in hexadecimal.

  • Run your program:
cargo run

You should see output like this (your hash will be identical since SHA-256 is deterministic):

Genesis hash: efa136a5b5e8c... (64 hex characters total)

The hash is exactly 64 characters long. That confirms your types, trait, and enum dispatch all work correctly end-to-end.

Seeing a compilation error?

  • If you see "cannot find value Sha256Hasher in this scope", make sure both Sha256Hasher and DoubleSha256Hasher are defined above the HashAlgorithm enum in src/hasher.rs.
  • If you see "function hash is private", check that the impl HashAlgorithm block has pub fn hash (not just fn hash).
  • If you see "unresolved import hasher::HashAlgorithm", make sure the enum has pub enum HashAlgorithm (not just enum HashAlgorithm).

Help me debug my Hasher trait compilation error

✔️ Awesome, I've got everything!

Great. Double check you have saved both src/types.rs and src/main.rs.

ⓧ I'd like to double check the full code

Here is the full src/types.rs:

pub type Hash = [u8; 32];
pub type Address = [u8; 20];

pub const ZERO_HASH: Hash = [0u8; 32];

pub fn hash_to_hex(hash: &Hash) -> String {
    hex::encode(hash)
}

pub fn current_timestamp() -> u64 {
    use std::time::{SystemTime, UNIX_EPOCH};
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs()
}

Here is the full src/main.rs:

mod types;
mod hasher;
mod transaction;
mod block;
mod consensus;
mod chain;

use hasher::HashAlgorithm;

fn main() {
    let algo = HashAlgorithm::Sha256;
    let hash = algo.hash(b"genesis");
    println!("Genesis hash: {}", crate::types::hash_to_hex(&hash));
}

Your type system and hashing interface are locked in. Every module you build from here will rely on these exact types. Next up, you will give the chain a reason to exist by adding transactions and constructing your first block.

Define Transactions and Block Construction

Your hasher and core types are working. But right now, the blockchain is just a glorified linked list of hashes with no payload.

Transactions give the chain its purpose: they record value transfers between addresses. In this step, you will define a Transaction struct and a Block struct that bundles transactions together with a hash linking back to the previous block. The blocks you build here will have no security yet. Anyone could forge one instantly. The next step fixes that.

In this step, get ready to:
  • Define a Transaction struct that records sender, recipient, and amount.
  • Define a Block struct that bundles transactions and links to the previous block via its hash.
  • Assemble a block in main and print its details to prove the system works end-to-end.
Define the Transaction struct

A transaction is the smallest unit of meaning in a blockchain. It records who sent value, who received it, and how much was transferred.

  • Open src/transaction.rs in Zed and add this code:
use serde::Serialize;
use crate::types::Address;
use crate::types::current_timestamp;

// Derive Serialize for hashing, Clone for block inclusion, Debug for printing
#[derive(Serialize, Clone, Debug)]
pub struct Transaction {
    pub sender: Address,
    pub recipient: Address,
    pub amount: u64,
    pub timestamp: u64,
}

impl Transaction {
    // Create a new transaction stamped with the current time
    pub fn new(sender: Address, recipient: Address, amount: u64) -> Self {
        Self {
            sender,
            recipient,
            amount,
            timestamp: current_timestamp(),
        }
    }

    // Serialize to JSON bytes for inclusion in block hash input
    pub fn to_bytes(&self) -> Vec<u8> {
        serde_json::to_vec(self).unwrap()
    }
}

What does this code do?

  • The Transaction struct holds a sender address, recipient address, amount, and timestamp. The Address type is the 20-byte array you defined in types.rs.
  • Deriving Serialize lets serde_json convert the struct to bytes. That byte representation becomes the hash input when computing a block's fingerprint.
  • to_bytes() serializes the entire transaction to JSON bytes. The block's hash function will call this on every transaction it contains.
  • Save src/transaction.rs.
  • Confirm it compiles by running:
cargo build

You should see a clean compile with no errors. You may see unused import warnings for the other empty module files. Those are expected and disappear once we fill them in.

Seeing a compile error?

  • If you see "cannot find type Address", check that your use crate::types::Address; import is at the top of the file.
  • If you see "unresolved import serde", double-check that your Cargo.toml includes serde = { version = "1.0", features = ["derive"] } under [dependencies].

Why won't my transaction.rs compile?

Define the Block struct

A block is the container that groups transactions together and chains them to the previous block via a cryptographic hash. Each block carries its own hash, which is computed from all of its contents.

  • Open src/block.rs and add the imports and struct definition:
use crate::hasher::HashAlgorithm;
use crate::transaction::Transaction;
use crate::types::{Hash, hash_to_hex, current_timestamp};

#[derive(Clone, Debug)]
pub struct Block {
    pub index: u64,          // Position in the chain
    pub timestamp: u64,      // When the block was created
    pub transactions: Vec<Transaction>,  // Payload
    pub previous_hash: Hash, // Link to prior block
    pub hash: Hash,          // This block's identity
    pub nonce: u64,          // Mining counter (unused for now)
}

What does this struct represent?

Each field maps to a core blockchain concept. The previous_hash field is what makes this a chain: changing any earlier block invalidates every block that follows it. The nonce field is zeroed for now. Proof-of-Work mining will use it in the next step.

  • Below the struct, add the impl Block block with the constructor:
impl Block {
    pub fn new(
        index: u64,
        transactions: Vec<Transaction>,
        previous_hash: Hash,
        hasher: &HashAlgorithm,
    ) -> Self {
        let timestamp = current_timestamp();
        // Initialize with zeroed hash, then compute it immediately
        let mut block = Self {
            index,
            timestamp,
            transactions,
            previous_hash,
            hash: [0u8; 32],
            nonce: 0,
        };
        block.hash = block.compute_hash(hasher);
        block
    }

Why compute the hash inside the constructor?

The constructor creates the block with a temporary zeroed hash, then immediately fills it in by calling compute_hash. This guarantees every block has a valid hash the moment it exists. No block can accidentally float around without its identity set.

  • Still inside the impl Block block, add the hash computation and display methods, then close the impl with }:
    // Concatenate all block fields into bytes, then hash the result
    pub fn compute_hash(&self, hasher: &HashAlgorithm) -> Hash {
        let mut data = Vec::new();
        data.extend_from_slice(&self.index.to_le_bytes());
        data.extend_from_slice(&self.timestamp.to_le_bytes());
        data.extend_from_slice(&self.previous_hash);
        for tx in &self.transactions {
            data.extend_from_slice(&tx.to_bytes());
        }
        data.extend_from_slice(&self.nonce.to_le_bytes());
        hasher.hash(&data)
    }

    // Pretty-print block details to the terminal
    pub fn display(&self) {
        println!("--- Block {} ---", self.index);
        println!("  Timestamp:     {}", self.timestamp);
        println!("  Transactions:  {}", self.transactions.len());
        println!("  Nonce:         {}", self.nonce);
        println!("  Previous Hash: {}", hash_to_hex(&self.previous_hash));
        println!("  Hash:          {}", hash_to_hex(&self.hash));
        println!();
    }
}

What does compute_hash include?

The hash input is a concatenation of every field: index, timestamp, previous hash, all transaction bytes, and the nonce. Changing any single byte in any of these fields produces a completely different hash. That is why the chain is tamper-evident.

  • Save src/block.rs.
  • Verify the new file compiles by running:
cargo build

You should see a clean compile with zero errors.

Seeing "cannot find function compute_hash"?

  • Make sure all three code sections (struct, constructor, and methods) are in the same file and that the closing } for impl Block appears after both methods.
  • If you see "mismatched types" on the hash field, confirm that hash: [0u8; 32] uses exactly 32 bytes to match the Hash type alias.

Help me fix a compile error in my block.rs file.

Assemble and print a block in main

The structs compile, but you have not actually created a block yet. Time to wire everything together in main.rs and see the output.

  • Replace the entire contents of src/main.rs with the following:
mod types;
mod hasher;
mod transaction;
mod block;
mod consensus;
mod chain;

use hasher::HashAlgorithm;
use transaction::Transaction;
use block::Block;
use types::ZERO_HASH;

fn main() {
    // Create simplified addresses (20-byte arrays)
    let alice: [u8; 20] = [1u8; 20];
    let bob: [u8; 20] = [2u8; 20];
    let charlie: [u8; 20] = [3u8; 20];

    // Build two sample transactions
    let tx1 = Transaction::new(alice, bob, 50);
    let tx2 = Transaction::new(bob, charlie, 25);

    // Construct a block and display it
    let hasher = HashAlgorithm::Sha256;
    let block = Block::new(0, vec![tx1, tx2], ZERO_HASH, &hasher);
    block.display();
}

What is happening here?

  • The addresses are simple byte arrays filled with repeating values. A real blockchain would derive addresses from public keys, but for now these are identifiable stand-ins.
  • ZERO_HASH as previous_hash means this block has no predecessor. It is the genesis block.
  • The block's hash is computed instantly with nonce: 0. No mining is happening. Anyone could forge this block in microseconds.
  • Save src/main.rs.

Before you run this, what do you expect to see? Think about what fields the display() method prints.

  • Run the program:
cargo run

You should see output showing Block 0 with a timestamp, 2 transactions, nonce 0, a previous hash of all zeros, and a 64-character hex hash.

What should I see?

The output will look something like:

--- Block 0 ---
  Timestamp:     1752975600
  Transactions:  2
  Nonce:         0
  Previous Hash: 0000000000000000000000000000000000000000000000000000000000000000
  Hash:          a3f7c1... (your value will differ)

Your timestamp and hash will be different. The previous hash is all zeros because this is the genesis block. The nonce is 0 because no mining occurred.

Not seeing the block output?

  • If you get "unresolved import" errors, check that your use statements at the top of main.rs include transaction::Transaction, block::Block, and types::ZERO_HASH.
  • If you see "field is private", confirm that all struct fields in transaction.rs and block.rs use the pub keyword.

Why is my blockchain project not compiling?

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files: src/transaction.rs, src/block.rs, and src/main.rs.

ⓧ I'd like to double check the full code

Here is the full src/transaction.rs:

use serde::Serialize;
use crate::types::Address;
use crate::types::current_timestamp;

#[derive(Serialize, Clone, Debug)]
pub struct Transaction {
    pub sender: Address,
    pub recipient: Address,
    pub amount: u64,
    pub timestamp: u64,
}

impl Transaction {
    pub fn new(sender: Address, recipient: Address, amount: u64) -> Self {
        Self {
            sender,
            recipient,
            amount,
            timestamp: current_timestamp(),
        }
    }

    pub fn to_bytes(&self) -> Vec<u8> {
        serde_json::to_vec(self).unwrap()
    }
}

For src/block.rs, confirm you have the three sections you added above in order: imports and struct definition, then the impl Block opening with the new() constructor, then compute_hash() and display() methods, ending with a closing } for the impl block.

Here is the full src/main.rs:

mod types;
mod hasher;
mod transaction;
mod block;
mod consensus;
mod chain;

use hasher::HashAlgorithm;
use transaction::Transaction;
use block::Block;
use types::ZERO_HASH;

fn main() {
    let alice: [u8; 20] = [1u8; 20];
    let bob: [u8; 20] = [2u8; 20];
    let charlie: [u8; 20] = [3u8; 20];

    let tx1 = Transaction::new(alice, bob, 50);
    let tx2 = Transaction::new(bob, charlie, 25);

    let hasher = HashAlgorithm::Sha256;
    let block = Block::new(0, vec![tx1, tx2], ZERO_HASH, &hasher);
    block.display();
}

You now have transactions flowing into blocks and blocks computing their own hash. But notice how fast that hash was computed. There was no effort required. Any attacker could build a fake block in microseconds. Next up, you will add Proof-of-Work mining so that creating a valid block costs real computation.

Implement Proof-of-Work Mining

In the previous step, your block was computed instantly with nonce set to 0. That means anyone could forge a block in microseconds. There is zero computational cost to creating a fake chain.

Proof-of-Work fixes this by forcing the miner to find a nonce that produces a hash with a required number of leading zeros. This costs real CPU time and makes forging blocks economically impractical. You will implement this behind a ConsensusEngine trait so the algorithm can be swapped without touching block logic.

In this step, get ready to:
  • Define a ConsensusEngine trait with mine and validate methods.
  • Implement Proof-of-Work mining that loops through nonces until it finds a valid hash.
  • Mine a block and observe real computational cost in action.
Define the ConsensusEngine trait

A consensus algorithm decides who gets to add the next block. By defining it as a trait, your blockchain core stays decoupled from any specific algorithm.

  • Open src/consensus.rs (currently empty) and add the following code:
use crate::block::Block;
use crate::hasher::HashAlgorithm;
use crate::types::hash_to_hex;

// Trait any consensus algorithm must implement
pub trait ConsensusEngine {
    fn mine(&self, block: &mut Block, hasher: &HashAlgorithm);
    fn validate(&self, block: &Block, hasher: &HashAlgorithm) -> bool;
}

// Proof-of-Work: requires hash to start with N leading hex zeros
#[derive(Clone)]
pub struct ProofOfWork {
    pub difficulty: usize,
}

impl ProofOfWork {
    pub fn new(difficulty: usize) -> Self {
        Self { difficulty }
    }

    // Check if a hash meets the difficulty target
    fn meets_difficulty(&self, hash: &[u8; 32]) -> bool {
        let hex = hash_to_hex(hash);
        hex.starts_with(&"0".repeat(self.difficulty))
    }
}

What does this code do?

  • ConsensusEngine is a trait with two methods: mine (find a valid nonce) and validate (verify a block meets the rules).
  • ProofOfWork holds a difficulty value representing how many leading hex zeros a valid hash must have.
  • meets_difficulty converts the hash to hex and checks if it starts with the required number of zeros. A difficulty of 4 means the hash must start with 0000.
  • Save src/consensus.rs.
  • Verify the new file compiles by running:
cargo build

You should see a successful compilation with no errors. You may see unused warnings, which are fine for now.

Seeing a compilation error?

  • If you see "unresolved import", make sure the use statements at the top reference crate::block::Block, crate::hasher::HashAlgorithm, and crate::types::hash_to_hex exactly.
  • Check that mod consensus; is declared in src/main.rs (it should already be there from the previous step).

Why won't my consensus module compile?

Implement the mining loop

The mining loop is the core of Proof-of-Work. It increments the nonce repeatedly, recomputes the hash, and checks if it meets the difficulty target. This is intentionally brute-force.

  • In src/consensus.rs, add the following implementation block below the impl ProofOfWork block you just wrote:
impl ConsensusEngine for ProofOfWork {
    fn mine(&self, block: &mut Block, hasher: &HashAlgorithm) {
        // Brute-force: try nonces until we find a valid hash
        loop {
            let hash = block.compute_hash(hasher);
            if self.meets_difficulty(&hash) {
                block.hash = hash;
                break;
            }
            block.nonce += 1;
        }
    }

    fn validate(&self, block: &Block, hasher: &HashAlgorithm) -> bool {
        let computed = block.compute_hash(hasher);
        computed == block.hash && self.meets_difficulty(&computed)
    }
}

What does this code do?

  • mine takes a mutable reference to a block. It loops forever, computing the hash at the current nonce. If the hash meets the difficulty, it stores the hash and breaks. Otherwise it increments the nonce and tries again.
  • validate recomputes the hash from scratch and checks two things: that it matches the stored hash (no tampering), and that it meets the difficulty (real work was done).

Why is the loop a brute-force search?

Cryptographic hash functions are one-way. You cannot reverse-engineer which nonce produces a hash starting with zeros. The only option is to guess and check. This is exactly what makes Proof-of-Work expensive to produce but cheap to verify.

  • Save src/consensus.rs.
  • Confirm it still compiles by running:
cargo build

You should see a clean build with no errors.

Seeing a type mismatch error?

  • Make sure the impl ConsensusEngine for ProofOfWork block is outside the impl ProofOfWork block. They should be separate, consecutive blocks at the top level of the file.
  • Verify that the method signatures match the trait exactly: mine(&self, block: &mut Block, hasher: &HashAlgorithm) and validate(&self, block: &Block, hasher: &HashAlgorithm) -> bool.

Help me fix my ConsensusEngine implementation

Mine the genesis block and observe computational cost

Now you will update main.rs to actually mine the block using your new PoW engine. Instead of computing the hash instantly, the program will search for a valid nonce.

  • In src/main.rs, find this line in the imports:
use block::Block;
  • Add this new import directly below it:
use consensus::{ConsensusEngine, ProofOfWork};
  • Now replace the entire fn main() function body with the following:
fn main() {
    println!("=== Blockchain Engine ===\n");

    let alice: [u8; 20] = [1u8; 20];
    let bob: [u8; 20] = [2u8; 20];
    let charlie: [u8; 20] = [3u8; 20];

    let tx1 = Transaction::new(alice, bob, 50);
    let tx2 = Transaction::new(bob, charlie, 25);

    let hasher = HashAlgorithm::Sha256;
    // Create PoW with difficulty 4 (hash must start with 0000)
    let pow = ProofOfWork::new(4);

    let mut block = Block::new(0, vec![tx1, tx2], ZERO_HASH, &hasher);
    println!("Mining block 0...");
    pow.mine(&mut block, &hasher);
    println!("Block mined! Nonce: {}", block.nonce);
    block.display();
}

What changed from the previous version?

  • ProofOfWork::new(4) creates a miner that requires 4 leading hex zeros. This means approximately 1 in 65,536 hashes will be valid.
  • The block is now mut because mining modifies its nonce and hash fields.
  • pow.mine(&mut block, &hasher) runs the brute-force loop until a valid hash is found. This takes real time.
  • Save src/main.rs.

Before you run this, think: will the output appear instantly like before, or will there be a noticeable pause? What will the nonce value be?

  • Run the program:
cargo run

You will notice a pause before output appears. The program is iterating through thousands of nonces behind the scenes. When it finds a valid hash, you will see output like:

=== Blockchain Engine ===

Mining block 0...
Block mined! Nonce: 54837
--- Block 0 ---
  Timestamp:     1752997200
  Transactions:  2
  Nonce:         54837
  Previous Hash: 0000000000000000000000000000000000000000000000000000000000000000
  Hash:          0000a3f7...  (starts with four zeros!)

Your nonce and hash will differ, but the hash will start with 0000. That is the proof that real work was done. The pause you felt is the security mechanism.

Program seems stuck or takes too long?

  • With difficulty 4, mining typically takes a few hundred milliseconds to a few seconds. If it runs longer than 30 seconds, press Ctrl+C to stop and check your meets_difficulty function. Make sure it checks hex.starts_with(&"0".repeat(self.difficulty)) and not something stricter.
  • If the hash in the output does not start with zeros, verify that mine sets block.hash = hash inside the if-branch before breaking.

Help me debug my Proof-of-Work mining loop

✔️ Awesome, I've got everything!

Great. Double check you have saved both files.

ⓧ I'd like to double check the full code

Your src/consensus.rs should contain both chunks in order: the trait, struct, and helper from substep 4.1, then the impl ConsensusEngine for ProofOfWork block from substep 4.2. Scroll up and confirm each is present.

Your src/main.rs should have the updated imports (including use consensus::{ConsensusEngine, ProofOfWork};) and the new fn main() body that creates a ProofOfWork::new(4), mines a block, and prints the result.

Your blocks are now secured by real computational work. Next up, you will assemble a full blockchain that links these blocks together and proves that tampering with any block breaks the entire chain.

Build the Blockchain and Tamper Detection

Individual blocks are meaningless without a chain that validates their linkage. Right now you can mine a single block, but nothing connects it to other blocks or detects if someone changes its contents after the fact.

In this step, you will assemble the full Blockchain struct that manages block creation, transaction pooling, and chain validation. Then you will deliberately tamper with a mined block and watch the chain reject it. This is the moment where immutability becomes tangible.

In this step, get ready to:
  • Build the Blockchain struct with genesis block creation and mining.
  • Implement chain validation that catches tampering.
  • Run the full demo and prove that modifying a historical block breaks the entire chain.
Build the Blockchain struct

The Blockchain struct owns the full chain of blocks, a pool of pending transactions, and the hashing and consensus configuration. Its constructor automatically creates and mines the genesis block.

  • Open src/chain.rs and add the struct definition and constructor by pasting this code:
use crate::block::Block;
use crate::consensus::{ConsensusEngine, ProofOfWork};
use crate::hasher::HashAlgorithm;
use crate::transaction::Transaction;
use crate::types::ZERO_HASH;

pub struct Blockchain {
    pub chain: Vec<Block>,
    pub pending_transactions: Vec<Transaction>,
    pub hasher: HashAlgorithm,
    pub consensus: ProofOfWork,
}

impl Blockchain {
    pub fn new(difficulty: usize, hasher: HashAlgorithm) -> Self {
        let consensus = ProofOfWork::new(difficulty);
        let mut genesis = Block::new(0, vec![], ZERO_HASH, &hasher);
        consensus.mine(&mut genesis, &hasher);

        println!("Genesis block mined! Nonce: {}", genesis.nonce);

        Self {
            chain: vec![genesis],
            pending_transactions: vec![],
            hasher,
            consensus,
        }
    }

What does this code do?

The Blockchain struct stores the ordered chain of blocks, a pool of transactions waiting to be mined, and the hasher and consensus engine configuration.

The new() constructor creates the genesis block with an empty transaction list and ZERO_HASH as its previous hash, then mines it immediately. Every blockchain starts with this foundational block.

  • Add the transaction pooling and mining methods directly below the new() method (still inside the impl Blockchain block):
    pub fn add_transaction(&mut self, tx: Transaction) {
        self.pending_transactions.push(tx);
    }

    pub fn mine_pending(&mut self) {
        let previous_hash = self.chain.last().unwrap().hash;
        let index = self.chain.len() as u64;
        let transactions = std::mem::take(&mut self.pending_transactions);

        let mut block = Block::new(index, transactions, previous_hash, &self.hasher);
        self.consensus.mine(&mut block, &self.hasher);

        println!(
            "Block {} mined! Nonce: {}",
            block.index, block.nonce
        );

        self.chain.push(block);
    }

    pub fn print_chain(&self) {
        println!("\n========== BLOCKCHAIN ==========\n");
        for block in &self.chain {
            block.display();
        }
        println!("================================\n");
    }
}

What does this code do?

  • add_transaction() pushes a transaction into the pending pool. These sit waiting until a miner bundles them into a block.
  • mine_pending() grabs all pending transactions with std::mem::take (efficiently draining the pool), creates a new block linked to the last block's hash, mines it, and appends it to the chain.
  • print_chain() iterates the chain calling each block's display() method for a readable dump.
  • Save src/chain.rs.
  • Now replace the contents of src/main.rs with this updated version that uses the new Blockchain struct:
mod types;
mod hasher;
mod transaction;
mod block;
mod consensus;
mod chain;

use hasher::HashAlgorithm;
use transaction::Transaction;
use chain::Blockchain;

fn main() {
    println!("=== Blockchain Engine ===\n");

    // Create blockchain with difficulty 4 (4 leading hex zeros)
    let mut blockchain = Blockchain::new(4, HashAlgorithm::Sha256);

    // Create some addresses (simplified: just byte arrays)
    let alice: [u8; 20] = [1u8; 20];
    let bob: [u8; 20] = [2u8; 20];
    let charlie: [u8; 20] = [3u8; 20];

    // Add transactions and mine block 1
    blockchain.add_transaction(Transaction::new(alice, bob, 50));
    blockchain.add_transaction(Transaction::new(bob, charlie, 25));
    blockchain.mine_pending();

    // Add transactions and mine block 2
    blockchain.add_transaction(Transaction::new(charlie, alice, 10));
    blockchain.mine_pending();

    blockchain.print_chain();
}

What does this code do?

Instead of manually constructing blocks, you now create a Blockchain instance that handles everything. You add transactions to the pool, call mine_pending() to bundle and mine them, and the chain grows automatically. Three blocks will exist: the auto-mined genesis block, block 1 with Alice-to-Bob and Bob-to-Charlie transactions, and block 2 with Charlie-to-Alice.

  • Save src/main.rs.
  • Run the project by executing:
cargo run

You should see three blocks mined (genesis, block 1, block 2) followed by the full chain printout showing each block's index, nonce, previous hash, and hash.

Seeing a compilation error?

  • If you see "cannot find type Blockchain", make sure mod chain; is declared at the top of main.rs and that you have use chain::Blockchain; in the imports.
  • If you see "unused import" warnings for the old block::Block or types::ZERO_HASH imports, make sure you replaced the entire file contents. The new version no longer imports those directly.

Why isn't my blockchain compiling?

Implement chain validation

A chain that cannot detect tampering is no more trustworthy than a plain database. The validate() method walks the chain checking four properties on every block. If any check fails, the exact block index and reason are reported.

  • In src/chain.rs, find the closing brace of print_chain(). Add the first half of the validate() method above the final } that closes the impl Blockchain block:
    pub fn validate(&self) -> Result<(), String> {
        for i in 1..self.chain.len() {
            let block = &self.chain[i];
            let prev_block = &self.chain[i - 1];

            // Check index
            if block.index != prev_block.index + 1 {
                return Err(format!(
                    "Block {}: invalid index (expected {}, got {})",
                    i,
                    prev_block.index + 1,
                    block.index
                ));
            }

            // Check previous hash linkage
            if block.previous_hash != prev_block.hash {
                return Err(format!(
                    "Block {}: previous_hash does not match block {}'s hash",
                    i,
                    i - 1
                ));
            }
  • Continue the validate() method by adding the hash integrity and PoW checks directly below:
            // Check hash integrity
            let computed = block.compute_hash(&self.hasher);
            if computed != block.hash {
                return Err(format!(
                    "Block {}: stored hash does not match computed hash (tampered!)",
                    i
                ));
            }

            // Check PoW
            if !self.consensus.validate(block, &self.hasher) {
                return Err(format!(
                    "Block {}: does not meet PoW difficulty requirement",
                    i
                ));
            }
        }
        Ok(())
    }

What does validate() check?

  • Index sequence: each block's index is exactly one more than the previous block's.
  • Previous hash linkage: each block's previous_hash matches the prior block's stored hash. This is the "chain" in blockchain.
  • Hash integrity: recomputing the block's hash from its data matches the stored hash. If anyone changes even one byte of data, this check catches it.
  • PoW difficulty: the stored hash still meets the mining difficulty requirement, proving the block was legitimately mined.
  • Save src/chain.rs.
  • Confirm it compiles by running:
cargo build

You should see a successful compilation with no errors.

Seeing a mismatched type or brace error?

  • Make sure you have exactly one closing } for the for loop, one for the validate() function, and one for the impl Blockchain block at the very end of the file.
  • The validate() method must be placed between print_chain() and the final closing brace of the impl block.

Help me fix a brace-matching error in my chain.rs file.

Run the full demo with tamper detection

The chain now validates itself. Time to prove it works by running a clean validation first, then deliberately corrupting a transaction and watching the chain catch it.

  • In src/main.rs, find the line blockchain.print_chain(); at the bottom of main().
  • Add the validation and tamper detection code below it (still inside the main() function, before the closing }):
    // Validate the chain
    match blockchain.validate() {
        Ok(()) => println!("[OK] Chain is valid!\n"),
        Err(e) => println!("[ERROR] Chain invalid: {}\n", e),
    }

    // Tamper with block 1: change a transaction amount
    println!("--- Tampering with block 1... ---\n");
    blockchain.chain[1].transactions[0].amount = 999999;

    // Validate again
    match blockchain.validate() {
        Ok(()) => println!("[OK] Chain is valid!\n"),
        Err(e) => println!("[ERROR] Chain invalid: {}\n", e),
    }

What does this code do?

First you validate the untouched chain, which should pass all checks. Then you directly mutate a transaction amount inside block 1 from 50 to 999999. This simulates an attacker trying to change the historical record. When you validate again, the recomputed hash no longer matches the stored hash, and the chain reports exactly which block was tampered with.

  • Save src/main.rs.

Before you run this, what do you think will happen after the tamper? Will the chain still report valid, or will validation catch the change?

  • Run the full demo by executing:
cargo run

You should see the three blocks mined, the chain printed, then [OK] Chain is valid! followed by the tamper message, and finally [ERROR] Chain invalid: Block 1: stored hash does not match computed hash (tampered!).

Why does changing one number break the whole thing?

When the block was mined, its hash was computed from ALL its data (index, timestamp, transactions, previous hash, nonce). Changing even one bit of transaction data means recomputing the hash produces a completely different result. The stored hash no longer matches, so validation catches it immediately.

An attacker could try to recompute the hash, but then it would not start with the required leading zeros (failing the PoW check) unless they re-mine the block. And re-mining would change this block's hash, breaking the NEXT block's previous_hash linkage. This cascade is what makes blockchains tamper-resistant.

Not seeing the tamper error?

  • Make sure you are accessing blockchain.chain[1] (block at index 1, which is the second block). The genesis block is at index 0.
  • Verify the tamper line is blockchain.chain[1].transactions[0].amount = 999999; and comes AFTER the first blockchain.validate() call.

Help me debug my tamper detection output.

✔️ Awesome, I've got everything!

Great. Double check you have saved both src/chain.rs and src/main.rs.

ⓧ I'd like to double check the full code

Here is the complete src/chain.rs at this point. Scroll up and confirm the following sections are present in order: imports, struct definition, new(), add_transaction(), mine_pending(), print_chain(), and validate().

Here is the complete src/main.rs:

mod types;
mod hasher;
mod transaction;
mod block;
mod consensus;
mod chain;

use hasher::HashAlgorithm;
use transaction::Transaction;
use chain::Blockchain;

fn main() {
    println!("=== Blockchain Engine ===\n");

    let mut blockchain = Blockchain::new(4, HashAlgorithm::Sha256);

    let alice: [u8; 20] = [1u8; 20];
    let bob: [u8; 20] = [2u8; 20];
    let charlie: [u8; 20] = [3u8; 20];

    blockchain.add_transaction(Transaction::new(alice, bob, 50));
    blockchain.add_transaction(Transaction::new(bob, charlie, 25));
    blockchain.mine_pending();

    blockchain.add_transaction(Transaction::new(charlie, alice, 10));
    blockchain.mine_pending();

    blockchain.print_chain();
}
    match blockchain.validate() {
        Ok(()) => println!("[OK] Chain is valid!\n"),
        Err(e) => println!("[ERROR] Chain invalid: {}\n", e),
    }

    println!("--- Tampering with block 1... ---\n");
    blockchain.chain[1].transactions[0].amount = 999999;

    match blockchain.validate() {
        Ok(()) => println!("[OK] Chain is valid!\n"),
        Err(e) => println!("[ERROR] Chain invalid: {}\n", e),
    }
}

You now have a fully working blockchain with tamper detection. You can create transactions, mine blocks, and prove that modifying any historical data is caught instantly. Next up, the Secret Mission challenges you to make the difficulty adjust dynamically based on mining speed.

Secret mission

Implement Dynamic Difficulty Adjustment

Your blockchain mines every block at the same difficulty, regardless of how fast or slow mining actually takes. Real blockchains like Bitcoin adjust difficulty every N blocks to maintain a target block time. Can you build a feedback loop that auto-tunes difficulty based on actual mining speed?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The blockchain-engine project folder on your Desktop (all Rust source files and compiled artifacts).

Keep everything running

No action needed. Choose this if you're still actively building or plan to extend the engine with wallets, networking, or additional consensus algorithms.

  • Your blockchain-engine folder stays on your Desktop. It costs nothing to keep.
  • Compiled artifacts live inside blockchain-engine/target/. These can be large (hundreds of MB) but rebuild automatically if deleted.

Pause - I'll come back to this later

Free up disk space from compiled artifacts while keeping your source code intact.

  • Navigate to your project folder and remove the build cache by running:
  • macOS/Linux:
cd ~/Desktop/blockchain-engine
rm -rf target/
  • Windows:
cd ~\Desktop\blockchain-engine
Remove-Item -Recurse -Force target\
  • Your source files remain untouched. Run cargo build when you return to rebuild everything.

Delete - I don't want to use this again

Remove the entire project folder and start fresh if you ever want to rebuild.

  • Delete the blockchain-engine folder from your Desktop.
  • macOS/Linux:
rm -rf ~/Desktop/blockchain-engine
  • Windows:
Remove-Item -Recurse -Force ~\Desktop\blockchain-engine
  • Confirm the folder is gone by running ls ~/Desktop (macOS/Linux) or ls ~\Desktop (Windows). You should no longer see blockchain-engine in the output.

That's a Wrap!

That's a Wrap!

Nice work. You just built a fully functional blockchain engine from scratch in Rust, complete with cryptographic hashing, Proof-of-Work mining, and tamper detection.

Here's what you learned:

  • Built a trait-based hashing system with SHA-256 and Double-SHA-256 implementations that you can hot-swap by changing a single enum variant.
  • Implemented a Proof-of-Work mining algorithm that iterates through nonces until it finds a hash meeting the difficulty requirement, demonstrating the real computational cost that secures blockchains.
  • Assembled a full chain validation system that checks hash integrity, block linkage, sequential indices, and PoW compliance. Then proved it works by tampering with a block and watching it get caught.
  • Secret Mission: Added dynamic difficulty adjustment that measures block mining times and auto-tunes difficulty to maintain a stable target block time.

Ready to quiz yourself?