Skip to main content

Proof of Work Example

Published

November 11, 2026

Overview

This notebook develops a minimal Proof of Work (PoW) blockchain in Python.

You will implement the core mechanics step by step:

  • define SHA-256 hashing helpers,
  • mine a block by searching over nonce values,
  • verify block validity and detect tampering,
  • link multiple blocks into a chain and verify chain integrity.

The objective is to make PoW mechanics explicit and testable, not abstract. The notes on hashing and proof of work explain the ideas behind each step.

Core building blocks

A hash function maps any input (text, numbers, files) to a fixed-size output called a digest. Cryptographic hashes are deterministic (same input, same output), highly sensitive to input changes, and hard to reverse.

In PoW systems like Bitcoin, the hash function is SHA-256. It always outputs 256 bits, written as 64 hexadecimal characters. For example, sha256_hex("hello") equals 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.

SHA-256 repeatedly mixes bits through many rounds and outputs a value that looks random. That is why PoW mining is brute-force search over nonce values.

Why 64 hexadecimal characters? Each hex character represents 4 bits, so 64 \times 4 = 256 \text{ bits.} The helper function below computes that SHA-256 digest for any input string.

import hashlib
import time
import pandas as pd

def sha256_hex(s: str) -> str:
    return hashlib.sha256(s.encode("utf-8")).hexdigest()

A block has two parts only:

  • header: metadata used for PoW (prev_hash, merkle_root, timestamp, nonce, difficulty)1
  • body: transaction payload (a single transaction string in this toy model)2

1 In Bitcoin, the header stores nBits, a compact encoding of the target threshold. This notebook uses an integer difficulty (leading zero hex characters) as a pedagogical simplification.

2 In this notebook, the block body is exactly one transaction string, so merkle_root is just sha256(transaction). In real blockchains, the body contains many transactions and merkle_root is computed from a full Merkle tree.

PoW hashes only the header. The helper below takes a header object and returns its SHA-256 digest.

During mining, prev_hash, merkle_root, and timestamp are fixed for one attempt, and miners vary only the nonce to produce new candidate hashes. Conceptually, h(\text{nonce}) = \text{SHA-256}(\text{prev\_hash} \,\|\, \text{merkle\_root} \,\|\, \text{timestamp} \,\|\, \text{nonce} \,\|\, \text{difficulty}), where nonce is an integer and h() returns a 64-character hexadecimal string. PoW is a search for a nonce such that h(\text{nonce}) satisfies the difficulty rule.

def header_hash(header: dict) -> str:
    serialized = (
        f"{header['prev_hash']}|"
        f"{header['merkle_root']}|"
        f"{header['timestamp']}|"
        f"{header['nonce']}|"
        f"{header['difficulty']}"
    )
    return sha256_hex(serialized)

This function checks difficulty. It returns True only if the hash starts with the required number of leading zero hex characters.

def meets_difficulty(hash_hex: str, difficulty: int) -> bool:
    return hash_hex.startswith("0" * difficulty)

The mining function now builds header and body explicitly. It brute-forces header["nonce"] until the header hash meets difficulty.

def mine_block(
    prev_hash: str,
    tx_summary: str,
    difficulty: int,
    max_tries: int = 5_000_000,
) -> dict:
    body = tx_summary
    merkle_root = sha256_hex(body)
    timestamp = int(time.time())
    for nonce in range(max_tries):
        header = {
            "prev_hash": prev_hash,
            "merkle_root": merkle_root,
            "timestamp": timestamp,
            "nonce": nonce,
            "difficulty": difficulty,
        }
        hash_hex = header_hash(header)
        if meets_difficulty(hash_hex, difficulty):
            return {
                "header": header,
                "body": body,
            }
    raise RuntimeError("Increase max_tries or lower difficulty.")

Mine and verify a block

This block initializes a genesis-like previous hash, sets transaction content for the body, mines the first block, and prints the mined block contents.3

3 After a miner finds a valid block, it broadcasts the block to peers. Other nodes independently verify PoW, transaction validity, and consensus rules; if valid, they append it to their best chain and relay it further. A mempool is a node’s local queue of unconfirmed transactions, so transactions included in the new block are removed from mempools. Users usually wait for additional blocks (confirmations) before treating a payment as final.

prev_hash = "0" * 64
tx_summary = "Alice pays Bob 1 coin"
required_difficulty = 4
block1 = mine_block(prev_hash, tx_summary, difficulty=required_difficulty)
block1
{'header': {'prev_hash': '0000000000000000000000000000000000000000000000000000000000000000',
  'merkle_root': '0a77bba4e4457e5f6301818876eeb11b1b19830a0fc6d014b63107c75337e974',
  'timestamp': 1794439679,
  'nonce': 67425,
  'difficulty': 4},
 'body': 'Alice pays Bob 1 coin'}

The next function verifies one block by recomputing the header hash, checking difficulty, and checking that the body matches the header’s merkle_root. In real networks, difficulty comes from consensus rules, not from untrusted block fields, so we pass it in explicitly.

def verify_block(block: dict, required_difficulty: int) -> bool:
    header = block["header"]
    body = block["body"]
    recomputed_hash = header_hash(header)
    body_matches_root = sha256_hex(body) == header["merkle_root"]
    difficulty_matches = header["difficulty"] == required_difficulty
    return (
        difficulty_matches
        and meets_difficulty(recomputed_hash, required_difficulty)
        and body_matches_root
    )

verify_block(block1, required_difficulty)
True

This block prints the mined hash and whether verification succeeds.

print("Hash:", header_hash(block1["header"]))
print("Valid:", verify_block(block1, required_difficulty))
Hash: 00005a5f1d3fe39f8e7d91625dff6c763ec87ef198ac1b0580f740c6f5b1f52a
Valid: True

The final block in this section simulates tampering by changing the body (tx_summary) without re-mining, then checks validity to show it fails.

tampered = {
    "header": dict(block1["header"]),
    "body": block1["body"],
}
tampered["body"] = "Alice pays Bob 100 coins"

print("Tampered valid?", verify_block(tampered, required_difficulty))
Tampered valid? False

Build and verify a chain

This code extends the single-block example into a true chain structure. Each new block stores the previous block’s hash in header.prev_hash, so blocks are connected by cryptographic pointers rather than by position in a list.

That linkage is the key security idea: if someone changes an older block, its hash changes, which breaks the header.prev_hash reference in the next block. In practice, an attacker would have to re-mine that block and every block after it to restore consistency.

The helper function link_block takes the previous block and a new transaction summary string, then mines the next block.

def link_block(prev_block: dict, tx_summary: str, difficulty: int) -> dict:
    prev_hash = header_hash(prev_block["header"])
    return mine_block(prev_hash, tx_summary, difficulty=difficulty)

block0 = {
    "header": {
        "prev_hash": "0" * 64,
        "merkle_root": sha256_hex("genesis"),
        "timestamp": 0,
        "nonce": 0,
        "difficulty": 0,
    },
    "body": "genesis",
}

tx_stream = [
    "Alice pays Bob 1.0 coin",
    "Bob pays Carol 0.2 coins",
    "Carol pays Dave 0.1 coins",
    "Dave pays Erin 0.05 coins",
    "Erin pays Frank 0.03 coins",
    "Frank pays Grace 0.02 coins",
    "Grace pays Heidi 0.01 coins",
    "Heidi pays Ivan 0.01 coins",
]

chain = []
prev = block0
for tx in tx_stream:
    b = link_block(prev, tx, difficulty=required_difficulty)
    chain.append(b)
    prev = b

{"chain_length": len(chain)}
{'chain_length': 8}

The chain verifier enforces three checks in order. First, each block must pass verify_block. Second, the first block must link to the expected parent hash (the prior block, here block0). Third, every later block must point to the previous block’s hash.

This is essentially what full nodes do when they receive blocks: verify PoW and data integrity block-by-block, then verify linkage across the chain. If either check fails at any point, the chain is rejected.

def verify_chain(chain, required_difficulty: int, first_prev_hash: str):
    if not chain:
        return True
    for idx, block in enumerate(chain):
        if not verify_block(block, required_difficulty):
            return False
        if idx == 0 and block["header"]["prev_hash"] != first_prev_hash:
            return False
        if idx > 0 and block["header"]["prev_hash"] != header_hash(chain[idx - 1]["header"]):
            return False
    return True

verify_chain(chain, required_difficulty, first_prev_hash=header_hash(block0["header"]))
True

Visualize the chain

This final block prints a compact view of the chain so you can see each block’s hash, its header.prev_hash pointer, and whether each pointer correctly links to the previous block.

rows = []
for i, b in enumerate(chain, start=1):
    linked_ok = True if i == 1 else (b["header"]["prev_hash"] == header_hash(chain[i-2]["header"]))
    rows.append({
        "block": i,
        "nonce": b["header"]["nonce"],
        "hash_prefix": header_hash(b["header"])[:12],
        "prev_hash_prefix": b["header"]["prev_hash"][:12],
        "linked_ok": linked_ok,
    })

pd.DataFrame(rows)
block nonce hash_prefix prev_hash_prefix linked_ok
0 1 50044 00005842e4a8 52cae89ca662 True
1 2 13734 0000432f0c7a 00005842e4a8 True
2 3 86898 0000ec779e25 0000432f0c7a True
3 4 10273 0000348ba2cb 0000ec779e25 True
4 5 28235 00004abd825d 0000348ba2cb True
5 6 227977 00009d010f10 00004abd825d True
6 7 4219 00008bb94e55 00009d010f10 True
7 8 14370 0000cb3fc539 00008bb94e55 True

In this notebook, nonce starts at 0 and increases by 1 on each mining attempt. That means nonce is an attempt index, while tries is an attempt count. Attempt 1 uses nonce 0, attempt 2 uses nonce 1, and in general success at nonce k means you have made k + 1 total tries.

So the relationship if nonce starts at 0 is: \text{tries} = \text{nonce} + 1.

How much work does mining take?

The notes argue that requiring N leading zeros makes the expected number of tries 16^N. We can check this by mining many times at each difficulty and averaging the number of tries. The function below hashes a fixed header with nonces 0, 1, 2, and so on, and returns the number of tries until the hash starts with N zeros. Each run uses a different random header, so the runs are independent.

import random
import statistics

def tries_until_valid(N):
    header = f"block-{random.getrandbits(64)}"
    nonce = 0
    while not sha256_hex(f"{header}|{nonce}").startswith("0" * N):
        nonce += 1
    return nonce + 1

random.seed(0)
rows = []
for N, runs in [(1, 1000), (2, 1000), (3, 400), (4, 150)]:
    tries = [tries_until_valid(N) for _ in range(runs)]
    rows.append({"N": N, "runs": runs, "theory 16^N": 16**N,
                 "mean tries": round(statistics.mean(tries)),
                 "standard error": round(statistics.stdev(tries) / runs**0.5, 1),
                 "median tries": round(statistics.median(tries))})
pd.DataFrame(rows).set_index("N")
runs theory 16^N mean tries standard error median tries
N
1 1000 16 16 0.5 11
2 1000 256 252 8.0 175
3 400 4096 3984 185.4 2918
4 150 65536 63119 5162.7 50122

The averages are close to 16^N: the differences are of the size of the standard error, which measures how much an average of this many random searches varies by chance. Each extra zero multiplies the work by about 16. The medians are below the means: most searches end sooner than average, but a few take much longer, which is why a miner’s income from solo mining is so uneven.