cld-toys › Toys › toy-blockchain

Commentary: toy-blockchain

A nine-block chain mined with 21 hashes that rewrites the history of an eight-block chain costing 2,638,572 — and validate() is right to return True. A study guide for chain.py.

toy-blockchain/ on GitHub·the source with line numbers, for reading rather than downloading
How to read this This is the only documentation the toy has — read it with chain.py open beside you. chain.py is the toy itself (182 lines, raw wc -l); demo.py (113 lines) runs the four acts; test_chain.py (164 lines) pins the failures and the headline numbers so this page can't rot. Every transcript below was captured from a real run on macOS 26.5.2 (Darwin 25.5.0), arm64 Apple Silicon, Python 3.15.0a8 (main, Apr 14 2026) [Clang 22.1.3], stdlib only (hashlib). There is no clock and no randomness anywhere in chain.py, and the nonce search starts at 0, so every hash count here is byte-identical on every run — I ran demo.py twice and diffed: no output difference. Exactly two numbers on this page are not produced by that code, and both are labelled where they appear: the hash rate in §7.3, which is wall-clock and machine-dependent, and Bitcoin's difficulty in §7.3, which was fetched from a live API on 2026-08-04 and is cited rather than run.
cd toy-blockchain
python3 demo.py       # the aha (§6), ~7 s
python3 test_chain.py # pins every number on this page, ~9 s
Contents
  1. Orientation
  2. The problem this mechanism exists to solve
  3. Background you need
  4. The mental model
  5. Reading the source
  6. The demo, and what it proves
  7. Design decisions and roads not taken
  8. What's simplified vs. the real thing
  9. Check yourself
  10. Further reading

1. Orientation

This toy is a chain of blocks linked by hash pointers, each stamped with a proof-of-work nonce found by counted search — plus the part that usually gets left out of the diagram: the rule a second node uses to decide which of two valid chains is history.

The mechanism is fork choice. Everything else here (SHA-256, hash pointers, a nonce loop) exists to give fork choice something to choose between.

The aha is an accounting error, not a cryptographic one. The demo builds an 8-block chain that cost 2,638,572 hashes, then rewrites its history with a 9-block chain that cost 21. Nothing is forged: SHA-256 is unbroken, every hash pointer links, every block genuinely meets the difficulty it declares, and validate() returns True on the rewritten chain — correctly. A node running the rule everyone repeats, longest chain wins, adopts it.

By the end you should be able to:


2. The problem this mechanism exists to solve

Two nodes hold two different histories. Both are internally consistent. There is no authority to ask. Which one is real?

A hash chain alone cannot answer that. Hash pointers make a history tamper-evident — change a byte and the link into the next block breaks — but tamper-evidence is a property of one chain in isolation. Hand a node two chains that are each perfectly linked and it has no basis for preferring either. That is the gap proof-of-work fills: it makes producing a history expensive, so the two candidates can be compared on something other than their own say-so.

Which turns consensus into an accounting problem, and gives it competing goals:


3. Background you need

None of it is deep, but the result below leans on it hard.

ConceptWhere it's used hereOne source
Hash pointer Block.prev, chain.py:52, checked in validate, chain.py:135-136 Bitcoin whitepaper §2
Proof-of-work as leading zero bits meets, chain.py:76-78 Bitcoin whitepaper §4
Self-declared difficulty (nBits) bits inside the hashed header, chain.py:57-64 Bitcoin wiki: Difficulty
Expected work is 2**bits, geometrically distributed work, chain.py:81-83; measured over 16 difficulties in §6.3 Bitcoin wiki: Difficulty
Cumulative chain work (nChainWork) total_work, chain.py:148-150 Bitcoin Core src/chain.h
Fork choice choose_longest / choose_heaviest, chain.py:153-160 Bitcoin Core CBlockIndexWorkComparator
Gambler's ruin the catch-up race in §6.5 Bitcoin whitepaper §11

The two that carry the result are the third and the sixth. If the header declares its own difficulty and the fork rule counts blocks, then the price of a block is set by the block. Everything else on this page is arithmetic.

If you internalise one line, make it this one, from Bitcoin Core's pow.cpp:

// Check proof of work matches claimed amount if (UintToArith256(hash) > bnTarget) return false;

Claimed. Even in production, the proof-of-work check is a check against the number in the header. What stops the claim being a lie is somewhere else entirely — two different somewhere elses, in fact, and §7.7 counts them.


4. The mental model

Before any code. Two chains that fork at block 3. Every number below is from demo.py act 3.

honest — eight blocks, each one declaring 18 bits ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ │ b0 │←──│ b1 │←──│ b2 │←──│ b3 │←──│ b4 │← ... ←─│ b7 │ height 8 └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ work 8 × 2^18 18 18 18 18 18 18 = 2,097,152 ╲ mined 2,638,572 ╲ fork: keep b0..b2, rewrite b3 ╲ ┌────┐ ┌────┐ ┌────┐ │ b3'│←──│ b4'│← ... ←───────│ b8'│ height 9 └────┘ └────┘ └────┘ work 3 × 2^18 1 1 1 + 6 × 2^1 = 786,444 mined 21 every pointer links. every block meets the target it declares. validate() returns True on both. they are both, in every checkable sense, chains. len(attack) = 9 > 8 = len(honest) → "longest chain wins" 786,444 < 2,097,152 → "most work wins"

The whole toy is that those last two lines point in opposite directions, and that one of them is what the Bitcoin whitepaper's most-quoted phrase says literally:

Nodes always consider the longest chain to be the correct one and will keep working on extending it. — §5

while the sentence a page earlier says what it means:

The majority decision is represented by the longest chain, which has the greatest proof-of-work effort invested in it. — §4

A reader who implements the first sentence builds a node that accepts a history rewrite for 21 hashes.


5. Reading the source

182 lines, no classes but one, no dependencies. Read it in this order.

5.1 The counter is the clock

chain.py · lines 1–26
"""Blocks, hash pointers, proof-of-work, and the rule two nodes use to decide
which of two perfectly valid chains is history.

Everything is counted in SHA-256 evaluations. `HASHES` is the only clock in
this toy: there is no wall clock and no randomness anywhere in this file, and
the nonce search starts at 0 and increments, so every number the module
produces is byte-identical on every run.

Deliberately absent: a Merkle root in the block header. Committing a whole set
of transactions to one 32-byte field is the neighbouring toy (`merkle-tree/`),
and putting it here would blur two mechanisms together. `data` is opaque bytes,
so nothing distracts from the accounting.
"""

import hashlib

# ---------------------------------------------------------------- primitives

HASHES = [0]          # every SHA-256 in this module goes through H(), so the
                      # counter *is* the work, and no number on the page is a
                      # wall-clock measurement.


def H(b):
    HASHES[0] += 1
    return hashlib.sha256(b).digest()

A module-level list holding one integer is not elegant, and it is the single most useful decision in the file. Proof-of-work is a cost, and the honest unit of cost is hash evaluations, not seconds. Counting them makes every headline on this page reproducible (§7.3 shows what happens to a page built on seconds instead), and it makes the ratio 2,638,572 : 21 a fact about the algorithm rather than about my laptop.

Routing every SHA-256 through H is what makes the count trustworthy: there is no second path to hashlib anywhere in the file, so counted() is a total.

5.2 The block, and the field that sets its own price

chain.py · lines 39–67
class Block:
    """A header and nothing else. `data` stands in for the body.

    `bits` -- the number of leading zero bits this block *claims* to have
    mined against -- lives inside the hashed header, exactly like Bitcoin's
    `nBits`. It is self-declared: the block says how hard it was, and the
    proof-of-work check takes it at its word. That is the whole toy.
    """

    __slots__ = ("index", "prev", "data", "bits", "nonce")

    def __init__(self, index, prev, data, bits, nonce=0):
        self.index = index
        self.prev = prev          # bytes: hash of the previous block's header
        self.data = data          # bytes: the payload
        self.bits = bits          # int: leading zero bits this block claims
        self.nonce = nonce

    def header(self):
        return b"|".join([
            str(self.index).encode(),
            self.prev,
            self.data,
            str(self.bits).encode(),
            str(self.nonce).encode(),
        ])

    def hash(self):
        return H(self.header())

Five fields, and bits is the interesting one. It has to be in the header — if the difficulty label sat outside the hash, anyone could relabel a mined block as trivially easy afterwards, for free, and the attack in §6.4 would need no mining at all. §7.5 has that ablation: with bits dropped from the header, a relabel is caught 0 times out of 20.

But being in the header only binds the label to the block. It does not make the label true in any sense the validator can check, because there is nothing to check it against. That is the gap the whole page walks through.

b"|".join with a delimiter rather than bare concatenation is the one nod to merkle-tree's lesson, and I checked what it buys by monkeypatching the header to concatenate instead. data, bits and nonce are adjacent and variable-length, so two different blocks produce the same bytes:

as shipped, b'|'.join: headers equal = False, hashes equal = False a = data=b'pay bob x' bits=1 nonce=23 b = data=b'pay bob x1' bits=2 nonce=3 concatenated: headers equal = True, hashes equal = True a = data=b'pay bob x' bits=1 nonce=23 b = data=b'pay bob x1' bits=2 nonce=3

Cashed in, that is free work: mine one block under a concatenating header and it can be reread as a block declaring twice the difficulty, with both readings passing validate:

mined a block under the concatenating header, then reread it: a: data=b'pay bob xxxxxx' bits=1 nonce=23 valid chain: True b: data=b'pay bob xxxxxx1' bits=2 nonce=3 valid chain: True same header bytes: True; b claims 2 bits having done 1-bit work

The pipe is cheap; ambiguity is not.

5.3 The target, and what a "difficulty" is worth

chain.py · lines 76–83
def meets(digest, bits):
    """Does this digest clear a target of `bits` leading zero bits?"""
    return int.from_bytes(digest, "big") < (1 << (256 - bits))


def work(bits):
    """Expected hashes to find one block at this difficulty: 2**bits."""
    return 1 << bits

meets is the whole of proof-of-work: read the digest as a 256-bit integer and ask whether it is below a threshold. bits leading zeros means the digest must land in the bottom 1/2**bits of the space, so each attempt succeeds with probability 2**-bits, and the number of attempts is geometric with mean 2**bits. That is work, and §6.3 measures it across 16 doublings.

The important thing about work(bits) is that it is a pure function of the label. It does not look at the block, the nonce, or anything that was actually computed. It is what the block claims to have cost. total_work sums those claims, and a claim is exactly as good as the process that checks it.

5.4 Mining, and why the nonce starts at 0

chain.py · lines 88–99
def mine(index, prev, data, bits, start=0):
    """Search nonces from `start` upward. Returns (block, hashes spent).

    Starting at 0 rather than at a random nonce is what makes every mining
    cost on the commentary page reproducible: the search is a pure function of
    the header.
    """
    before = counted()
    b = Block(index, prev, data, bits, start)
    while not meets(b.hash(), bits):
        b.nonce += 1
    return b, counted() - before

A real miner randomises its starting nonce so that two miners working on similar headers don't retrace each other's search. Here that would make every number on this page a different number on your machine, which would make the page uncheckable. Starting at 0 costs nothing in a single-miner toy and buys byte-identical output — test_chain.py asserts the exact literals 2638572 and 21, which is only possible because of this line.

Returning (block, hashes) rather than just the block is the second half of the same decision. The cost is the subject of this toy, so the constructor of a block hands it back rather than making the caller instrument the loop.

5.5 validate — and the check it deliberately doesn't make

chain.py · lines 115–143
def validate(chain, expected_bits=None):
    """Is this a well-formed chain? Returns (bool, reason).

    Checks, in order:
      - block 0 points at GENESIS_PREV
      - every block's `prev` equals the previous block's hash (hash pointers)
      - every block's hash meets the difficulty *the block itself declares*
      - indices are consecutive

    Note what the third check does NOT do: compare the declared difficulty
    against anything the network expects. `expected_bits`, if given, adds that
    -- and it is a counterfactual, not the default. Leaving it off is what the
    demo's act 3 walks through.
    """
    if not chain:
        return False, "empty"
    prev = GENESIS_PREV
    for i, b in enumerate(chain):
        if b.index != i:
            return False, f"block {i} index {b.index}"
        if b.prev != prev:
            return False, f"block {i} broken hash pointer"
        h = b.hash()
        if not meets(h, b.bits):
            return False, f"block {i} fails its own difficulty {b.bits}"
        if expected_bits is not None and b.bits != expected_bits:
            return False, f"block {i} declares {b.bits}, expected {expected_bits}"
        prev = h
    return True, "valid"

This is the function the whole page argues with, so read it slowly.

meets(h, b.bits) — the block's own bits. Not a constant, not a value derived from the chain, not a parameter. Every block is graded against the difficulty it nominated for itself, and every block passes, because it was mined to. The function is not buggy; it is answering a narrower question than the one readers assume it answers. Its postcondition is: these blocks form a linked sequence, and each one clears the target printed on it. It says nothing about what the sequence cost.

expected_bits is the counterfactual, wired in as a default-off argument rather than a separate function so that the 2×2 in §6.4 varies one thing at a time. Real consensus goes further than this: Bitcoin Core recomputes the required nBits from the previous headers and rejects any block whose header disagrees —

if (block.nBits != GetNextWorkRequired(pindexPrev, &block, consensusParams)) return state.Invalid(BlockValidationResult::BLOCK_INVALID_HEADER, "bad-diffbits", "incorrect proof of work");

src/validation.cpp, ContextualCheckBlockHeader, fetched 2026-08-04. Note that this is a contextual check: it needs pindexPrev, the chain behind the block. A block cannot be graded on its own, which is the deeper reason the toy's validate() cannot catch this by looking harder at a single header.

Returning (bool, reason) rather than a bare bool is what makes act 1 legible: the demo prints block 3 fails its own difficulty 16 and you can see which check failed.

5.6 The load-bearing line, twice

chain.py · lines 148–160
def total_work(chain):
    """Cumulative expected hashes behind this chain -- Bitcoin's nChainWork."""
    return sum(work(b.bits) for b in chain)


def choose_longest(a, b):
    """The rule everyone repeats: 'longest chain wins'. Ties go to `a`."""
    return a if len(a) >= len(b) else b


def choose_heaviest(a, b):
    """The rule Bitcoin implements: most cumulative work wins. Ties go to `a`."""
    return a if total_work(a) >= total_work(b) else b

Two functions, identical but for one expression: len(a) >= len(b) against total_work(a) >= total_work(b). §6.4 runs both against the same pair of chains; one adopts a rewritten history for 21 hashes and the other doesn't. That is the entire toy, and it is one line.

Bitcoin Core's version, for comparison — src/node/blockstorage.cpp, fetched 2026-08-04:

bool CBlockIndexWorkComparator::operator()(const CBlockIndex* pa, const CBlockIndex* pb) const { // First sort by most total work, ...

with nChainWork documented in src/chain.h as

//! (memory only) Total amount of work (expected number of hashes) in the chain up to and including this block arith_uint256 nChainWork{};

"Expected number of hashes" is total_work, to the word. The height of a chain appears nowhere in that comparator.

>= rather than > in both rules means ties go to the first argument — the chain the node already holds. That is not decoration: it is the rule that stops a node flapping between two equal chains, and §6.2 is a case where both rules tie and the tie is the whole story.

5.7 Rewriting history

chain.py · lines 165–182
def tamper_and_remine(chain, k, new_data, bits=None):
    """Rewrite block k's data and re-mine blocks k..end so the chain relinks.

    `bits=None` keeps each block's original difficulty (an honest rewrite,
    priced at the real rate). Passing `bits` lets the rewriter declare its own
    difficulty, which is the attack in act 3.

    Returns (new chain, hashes spent).
    """
    before = counted()
    out = list(chain[:k])
    prev = chain[k - 1].hash() if k else GENESIS_PREV
    for i in range(k, len(chain)):
        d = new_data if i == k else chain[i].data
        b, _ = mine(i, prev, d, chain[i].bits if bits is None else bits)
        out.append(b)
        prev = b.hash()
    return out, counted() - before

out = list(chain[:k]) is the sentence "the attacker inherits everything before the fork" — and inheriting it means inheriting its work, which is what makes the arithmetic in §6.4 come out the way it does. The deeper the fork, the more honest work the attacker gets to count as their own.

The loop re-mines every block from k on, because each one's prev changed. That, and only that, is the "cascade": not that later blocks became invalid (§6.1 shows they didn't) but that relinking them requires new nonces.

5.8 The attack, in the demo

demo.py · lines 55–67
HONEST_BITS, ATTACK_BITS, FORK_AT = 18, 1, 3
counter_reset()
honest, hon_cost = build_chain(DATAS, HONEST_BITS)

before = counted()
attack = list(honest[:FORK_AT])
prev = honest[FORK_AT - 1].hash()
for i in range(FORK_AT, len(honest) + 1):          # ONE block longer
    d = MALLORY if i == FORK_AT else f"tx{i}: filler".encode()
    b, _ = mine(i, prev, d, ATTACK_BITS)           # ...at a difficulty it picks
    attack.append(b)
    prev = b.hash()
att_cost = counted() - before

Thirteen lines, no cryptography. len(honest) + 1 is the attacker's only insight: to beat a length comparison you need one more block, and one more block at one declared bit costs about two hashes. ATTACK_BITS = 1 is where the 125,646× comes from.


6. The demo, and what it proves

python3 demo.py
=== [1] tamper block 3, remine NOTHING (8 blocks @ 16 bits) === honest chain mined in 355,124 hashes (expected 8 * 2**16 = 524,288) validate(honest) = (True, 'valid') validate(tampered)= (False, 'block 3 fails its own difficulty 16') per-block damage report: block 0: link=True pow=True block 1: link=True pow=True block 2: link=True pow=True block 3: link=True pow=False block 4: link=False pow=True block 5: link=True pow=True block 6: link=True pow=True block 7: link=True pow=True broken links = 1, broken proofs-of-work = 1, blocks after 3 = 4 === [2] tamper block 3 and REMINE blocks 3..7 === remine cost = 161,369 hashes for 5 blocks validate = (True, 'valid') block 3 says = b'tx3: alice pays MALLORY 1000000' honest said = b'tx3: alice pays bob 3' same length = True same work = True (524,288 vs 524,288) tips differ = 00009303b5087fe2 vs 00007012818ccf40 === [3] a second node arrives holding a different chain === honest 8 blocks @ 18 bits mined 2,638,572 hashes work 2,097,152 attack 9 blocks @ 1 bit mined 21 hashes work 786,444 attacker paid 0.00080% of the honest work (125,646x cheaper) validator fork rule | accepts? picks history rewritten? -------------------------------------------------------------- unpinned length | True ATTACK TRUE unpinned work | True honest FALSE pinned length | False n/a FALSE pinned work | False n/a FALSE === [4] what burial depth buys (14 blocks @ 10 bits, 100 rewrites averaged) === k buried mean rewrite mean/block vs 2**bits vs k=1 linear? all valid -------------------------------------------------------------------------- 1 1,026 1,026 1.00x 1.00x 1x True 2 1,800 900 0.88x 1.75x 2x True 3 3,352 1,117 1.09x 3.27x 3x True 6 5,898 983 0.96x 5.75x 6x True 9 9,492 1,055 1.03x 9.25x 9x True 12 12,328 1,027 1.00x 12.02x 12x True total SHA-256 evaluations in this demo: 6,569,164

6.1 Act 1: the cascade is one link, not n

The story everyone tells about hash chains is that changing one historical block invalidates every block after it. Measured, that is false as a statement about blocks. Tampering with block 3 of an 8-block chain breaks:

Blocks 5, 6 and 7 are individually pristine: each one's prev matches its predecessor's hash, and each one's nonce still clears its target. Four blocks sit after the tamper; three of them are untouched. test_chain.py pins this at every chain length in (6, 8, 12, 16) and every tamper point in (1, n // 2, n - 2) — 12 rows, always exactly (1, 1).

"Every block after it is invalidated" is true only as a statement about the work to repair: you must re-mine from block 3 onward, because each relink changes the next block's input. That is a claim about cost, and act 2 prices it.

6.2 Act 2: the honest rewrite, which nothing can detect

Re-mine blocks 3 through 7 at the same 16 bits and validate returns (True, 'valid'). Same height, same declared work, different history. The only thing that differs is the tip hash: 00009303b5087fe2… against 00007012818ccf40….

The cost was 161,369 hashes for 5 blocks, against an expectation of 5 × 2**16 = 327,680. That is a lucky sample, not a discount — mining cost is geometric, so a single 5-block rewrite has a coefficient of variation of 1/√5 ≈ 45%, and §6.3 pools 768 blocks to show the mean lands where it should.

Now the part that matters. Hand both chains to either fork rule and neither can separate them:

(a) rewrite at the SAME difficulty, 16 bits cost 161,369 hashes; valid True; heights 8==8; work 524,288==524,288 choose_longest (honest, forged) -> honest choose_longest (forged, honest) -> forged choose_heaviest(honest, forged) -> honest choose_heaviest(forged, honest) -> forged

Both rules tie, and a tie resolves to whichever chain the node already had (>=, §5.6). So at equal difficulty the defence is not a rule at all — it is the fact that somebody else got there first and paid the same price. That is the honest version of "immutability", and it is much weaker than the word suggests.

6.3 The 2**bits law, since everything downstream is arithmetic on it

Every "work" number on this page is a sum of 2**bits, so the claim deserves a measurement. Mining 48 independent blocks at each difficulty from 1 to 16 (scratch script, against the shipped mine()):

bits 2**bits mean hashes ratio vs previous -------------------------------------------------------- 1 2 1.9 0.97x - 2 4 3.7 0.92x 1.89x 3 8 7.2 0.90x 1.97x 4 16 11.8 0.74x 1.63x 5 32 25.9 0.81x 2.20x 6 64 68.3 1.07x 2.64x 7 128 132.0 1.03x 1.93x 8 256 198.0 0.77x 1.50x 9 512 560.3 1.09x 2.83x 10 1,024 1,000.7 0.98x 1.79x 11 2,048 1,839.5 0.90x 1.84x 12 4,096 3,429.4 0.84x 1.86x 13 8,192 6,884.8 0.84x 2.01x 14 16,384 17,252.5 1.05x 2.51x 15 32,768 31,599.6 0.96x 1.83x 16 65,536 72,008.9 1.10x 2.28x pooled over all 16 difficulties: 135,025 hashes measured against 131,070 expected (1.030x)

Individual rows wander by ±25% because the mean of 48 geometric samples has a 14% standard error; pooled, the law holds to 3%. One bit of difficulty is one doubling of expected cost — which is why the honest chain's 2,638,572 measured hashes and its 2,097,152 claimed work are the same quantity measured two ways (1.258× on one sample of 8 blocks; CV 1/√8 = 35%).

6.4 Act 3: nine blocks for 21 hashes — THE AHA

The attacker forks after block 2, rewrites block 3 from tx3: alice pays bob 3 to tx3: alice pays MALLORY 1000000, and publishes nine blocks, each declaring 1 bit of difficulty.

The cost. Six new blocks (indices 3 through 8) at 1 declared bit. Expected 6 × 2**1 = 12 hashes; this run spent 21. Against the honest chain's 2,638,572:

2,638,572 / 21 = 125,646.28… → 125,646× cheaper 21 / 2,638,572 = 0.0000080 → 0.00080% of the honest work

The work. Here is why the attack chain is lighter despite being taller:

attacker inherits blocks 0–2, at 18 bits each 3 × 2**18 = 786,432 attacker adds blocks 3–8, at 1 bit each 6 × 2**1 = 12 ───────────────────── total_work(attack) 786,444 total_work(honest) = 8 × 2**18 2,097,152 786,444 / 2,097,152 = 0.375 → the attack is 37.5% of the honest work

The verdict table. validate(attack) returns True, and it is right to. Every prev is an honest hash of the block before it; every block's digest genuinely clears the 1-bit target it declares. There is no forgery to detect. What decides the outcome is the comparison afterwards:

validator fork rule | accepts? picks history rewritten? -------------------------------------------------------------- unpinned length | True ATTACK TRUE unpinned work | True honest FALSE pinned length | False n/a FALSE pinned work | False n/a FALSE

len(attack) = 9 >= 8 = len(honest), so choose_longest returns the attack chain and Mallory is paid a million. total_work(attack) = 786,444 < 2,097,152, so choose_heaviest doesn't move. One expression, 125,646× of price difference, and a rewritten ledger.

Exactly one of the four cells is broken. Compare merkle-tree, whose 2×2 has two independent bugs needing two independent fixes: here there is one bug and two fixes, either of which alone closes it — see §7.7 for what that redundancy does and does not buy.

6.5 Act 4: burial depth is linear, and the exponential is elsewhere

"Wait for six confirmations" sounds like it buys exponential security. In redo cost it does not. Averaging 100 rewrites at each depth (14-block chain, 10 bits):

k buried mean rewrite mean/block vs 2**bits vs k=1 linear? all valid 1 1,026 1,026 1.00x 1.00x 1x True 6 5,898 983 0.96x 5.75x 6x True 12 12,328 1,027 1.00x 12.02x 12x True

Twelve confirmations cost 12.02× one confirmation, not 2**12 = 4096×. The mean/block column is flat at ~1.00× of 2**bits, which is the whole explanation: each buried block is one more block to re-mine, at the same price. Redo cost is linear in depth and exponential only in difficulty.

So where is the exponential everyone remembers? In the race, and only when the attacker holds less than half the hash power. Simulating the whitepaper's §11 gambler's ruin — the attacker must gain k+1 blocks before a 200-round window closes — against its exact value (q/(1-q))**(k+1):

random.Random(20260804), 2,000 trials, 200-round window q k=1 k=2 k=6 k=12 sim / exact sim / exact sim / exact sim / exact -------------------------------------------------------------------------- 0.10 0.80%/ 1.235% 0.15%/ 0.137% 0.00%/ 0.000% 0.00%/ 0.000% 0.30 18.75%/ 18.367% 7.95%/ 7.872% 0.25%/ 0.266% 0.00%/ 0.002% 0.45 66.45%/ 66.942% 54.85%/ 54.771% 24.10%/ 24.544% 6.60%/ 7.363% 0.50 88.50%/100.000% 83.00%/100.000% 62.70%/100.000% 36.95%/100.000%

Each extra confirmation multiplies the attacker's odds by q/(1-q): one ninth per block at q = 0.10, and exactly 1 at q = 0.50, where depth buys nothing at all. (The q = 0.50 row undershoots because catch-up there is certain but unbounded in time, and the window is 200 rounds — an artifact of the cap, worth stating rather than hiding. It is the only randomised number on this page, and it is seeded.)

The distinction is worth carrying: cost is linear in depth, success probability is exponential in depth, and the exponent is the attacker's share of the hash rate.

6.6 The tests

python3 test_chain.py
PASS test_honest_chain_validates_and_every_block_meets_its_target PASS test_mining_is_deterministic PASS test_tampering_breaks_exactly_one_link_and_one_proof_of_work PASS test_tamper_and_remine_is_indistinguishable_from_honest PASS test_the_attack_costs_21_hashes_against_2638572 PASS test_the_two_by_two_has_exactly_one_broken_cell PASS test_total_work_arithmetic PASS test_the_boundary_is_the_honest_difficulty PASS test_validate_catches_what_it_is_meant_to_catch All 9 tests PASSED

Nine tests, stdlib asserts, no pytest. Three of them exist purely so this page cannot rot: test_the_attack_costs_21_hashes_against_2638572 asserts both literals, test_the_two_by_two_has_exactly_one_broken_cell asserts the shape of the table in §6.4, and test_the_boundary_is_the_honest_difficulty asserts the crossover derived below.

6.7 The boundary — where the attack stops being an attack

Sweep the difficulty the attacker declares from 1 to 20 against the same 8-block, 18-bit honest chain (scratch script against the shipped module; the rewrite hashes column is one geometric sample per row, so read it as an order of magnitude, and see the averaged table underneath):

att rewrite % of honest claimed work valid? pinned? longest heaviest bits hashes ---------------------------------------------------------------------------------- 1 21 0.0008% 786,444 True False ATTACK honest 8 1,213 0.0460% 787,968 True False ATTACK honest 15 275,493 10.4410% 983,040 True False ATTACK honest 16 329,568 12.4904% 1,179,648 True False ATTACK honest 17 1,156,610 43.8347% 1,572,864 True False ATTACK honest 18 1,254,286 47.5365% 2,359,296 True True ATTACK ATTACK 19 6,055,108 229.4843% 3,932,160 True False ATTACK ATTACK 20 2,732,063 103.5432% 7,077,888 True False ATTACK ATTACK

The crossover is at 18 declared bits, and it is derivable rather than observed. The attacker's work is 786,432 + 6 × 2**b; the honest chain's is 2,097,152. Set them equal:

6 × 2**b ≥ 2,097,152 − 786,432 = 1,310,720 2**b ≥ 218,453.3 b ≥ 17.74 → b = 18, since b is an integer

At 17 the attacker's chain is worth 786,432 + 786,432 = 1,572,864, which is 75% of the honest chain and loses. At 18 it is 786,432 + 1,572,864 = 2,359,296, which wins — and at that point the attacker has paid full price. Averaging 8 independent rewrites at each declared difficulty:

att mean rewrite % of honest mined % of honest work expected 6*2**ab -------------------------------------------------------------------------- 1 19 0.0007% 0.0009% 12 15 206,544 7.8279% 9.8488% 196,608 16 309,691 11.7371% 14.7672% 393,216 17 871,998 33.0481% 41.5801% 786,432 18 1,615,554 61.2283% 77.0356% 1,572,864

77% of the honest chain's entire work, spent to rewrite one transaction. That is not an attack any more, it is mining — the attacker is now buying the rewrite at the going rate, which is exactly what proof-of-work is supposed to charge. The gap between free and honest is precisely the gap between the difficulty you declare and the difficulty the network expects.

Three more boundary readings from the same sweep, each of them a "does it ever stop?" answer:

And one boundary in the other direction, from a second sweep over where the attacker forks:

fork new cost inherited + new total work valid? longest heaviest rewrite? 3 6 21 786,432 12 786,444 True ATTACK honest True 7 2 8 1,835,008 4 1,835,012 True ATTACK honest True 8 1 3 2,097,152 2 2,097,154 True ATTACK ATTACK False

At fork = 8 the attacker inherits the entire honest chain and appends one 1-bit block, for 3 hashes. Now choose_heaviest adopts it too — it is heavier by exactly 2 — but the rewrite? column says False: no history changed. The work rule stops rewrites; it does not stop garbage being appended cheaply. Only pinning the difficulty does that, which is the first crack in the "two redundant defences" story. §7.7.


7. Design decisions and roads not taken

7.1 Difficulty is self-declared, and that is the point

The obvious objection to this whole page: real chains don't let a block pick its own difficulty. True — Bitcoin recomputes it (§5.5's bad-diffbits). Two designs were on the table:

(a) wins because the objection to it is itself the finding. The 2×2 in §6.4 shows that pinning the difficulty closes the hole and so does counting work, which raises the question of why a real system would ship both. Bitcoin does ship both: ContextualCheckBlockHeader recomputes nBits, and CBlockIndexWorkComparator sorts by nChainWork rather than height. Belt and braces for what looks like one hole — and §7.7 is about why that is not paranoia.

Design (b) is also the strictly less honest toy: it would hide the fork-choice bug behind a difficulty check, and a reader would come away believing the validator was what saved them.

7.2 What is new here that merkle-tree doesn't already own

merkle-tree/ owns hash-linked tamper-evidence completely. Its §6.2 forges an inclusion proof for a block that was never in the file and gets verify() → True. If this toy's payload were "hash pointers detect tampering," it would be that toy with the tree straightened into a line — strictly less interesting, because a chain is a degenerate Merkle tree.

What proof-of-work adds is a resource. A resource introduces a comparison. And the bug lives in the comparison:

7.3 Counted hashes, not seconds — and the claim that killed

The backlog entry for this toy promised that "remining is visibly slow." It isn't, and the measurement is the reason this page counts hashes instead of timing them. Measuring this machine's rate through the shipped mine() loop and converting:

measured through the shipped mine(): 927,931 hashes in 0.997 s hash rate = 930,852 hashes/sec (single core, CPython) bits hex 0s hashes/block time/block 8-block chain ---------------------------------------------------------------------- 10 2.50 1,024 0.00 s 0.01 s 13 3.25 8,192 0.01 s 0.07 s 16 4.00 65,536 0.07 s 0.56 s 18 4.50 262,144 0.28 s 2.25 s 20 5.00 1,048,576 1.13 s 9.01 s 24 6.00 16,777,216 18.02 s 2.40 min 32 8.00 4,294,967,296 1.28 hr 10.25 hr 48 12.00 281,474,976,710,656 9.58 yr 76.66 yr

This is the only wall-clock measurement in the toy, and it is machine-dependent by construction — four runs on this laptop gave 741,913, 776,361, 892,858 and 930,852 hashes/sec, while the counted 927,931 was identical every time. That is precisely why the rest of the page is denominated in hashes.

Every row of that table is the same code; one integer changes. A toy has to sit around 16–20 bits for its demo to finish, and at any difficulty a toy can demo, an 8-block chain is rewritten in seconds. "Remining is visibly slow" is not a property of proof-of-work; it is a property of a constant, and a toy structurally cannot pick one that makes it true. Building the page around it would have meant building it around the one claim the artifact contradicts.

For scale, and fetched rather than measured: Bitcoin's network difficulty was 1.26231507121868 × 10^14 when this was written (blockchain.info/q/getdifficulty, 2026-08-04). The Bitcoin wiki's conversion gives expected hashes per block as D × 2**32:

1.26231507121868e14 × 2**32 = 5.4216e23 = 2**78.84

Cross-checking against the same site's reported hash rate of 865,950,311,160 GH/s (865.95 EH/s, fetched the same minute): 5.4216e23 / 8.6595e20 = 626 s per block, against Bitcoin's 600-second target — close enough to confirm the two figures are consistent. On this laptop, one such block would take 1.846e10 years. That row is context, not a result: it was not produced by anything in this repo, and it is here with its source and its date so you can re-fetch it.

7.4 No Merkle root in the header — deliberately

A real block header commits to its transactions through a Merkle root, and the whitepaper's §7 shows exactly that picture. This one doesn't: data is opaque bytes. That is not a corner cut for the LOC budget, it is a boundary between toys. A Merkle root would import a whole second mechanism — with its own failure modes, its own 2×2, and its own commentary next door — into a page about fork choice. The chain here commits to a payload; whether that payload is one transaction or a root over a million is a question the neighbouring toy answers.

7.5 What each header field is buying — and the one that is inert

Dropping one field at a time from the hashed header (monkeypatching Block.header, then running the shipped mine, validate and build_chain against it). A field the header doesn't commit to is free to edit, so the probe repairs it before publishing:

22 blocks @ 12 bits. A cell is 'caught / tried': how often the SHIPPED validate() rejected the tamper. header fields | honest P1 data P2 bits P3 delete P4 graft -------------------------------------------------------------------- ALL (as shipped) | True 20/20 20/20 True True -index | True 20/20 20/20 True True -prev | True 20/20 7/20 True False -data | True 0/20 20/20 True False -bits | True 20/20 0/20 True True

7.6 validate returns True, and that is not a bug to fix

The tempting patch is to make validate smarter. It cannot be. To know that a block's declared difficulty is wrong, you need to know what the difficulty should be, which is a function of the chain's history — hence Bitcoin's check being contextual (ContextualCheckBlockHeader, taking pindexPrev). A single-chain validator is answering "is this a well-formed chain?" and the answer really is yes.

The distinction is worth carrying into other systems: a checker that validates a structure against itself cannot tell you the structure is the right one. validate() here is exactly as strong as a signature check that confirms the signature matches the key it names.

7.7 Two fixes for one hole, and why they are not interchangeable

§6.4's 2×2 has exactly one broken cell, so either defence alone closes the rewrite. That is a genuinely different shape from merkle-tree's, and it makes the natural question "so why does Bitcoin ship both?" The fork-depth sweep in §6.7 answers it:

So the redundancy is real for the rewrite, and false for everything else. That is the shape of most defence-in-depth: two controls that overlap on the scenario you thought of, and diverge on the one you didn't.

7.8 Smaller decisions


8. What's simplified vs. the real thing


9. Check yourself

Answer before expanding. Each answer is derivable from the source, and each was verified by running it.

Question 1

The attacker mines 8 blocks at 1 bit instead of 9 — same height as the honest chain, and it still contains the rewritten block 3. Which rule adopts it?

Answer

Neither. >= in both rules means a tie goes to the first argument, which is the chain the node already holds:

(b) attacker publishes 8 blocks vs honest 8: cost 18 hashes, work 786,442 choose_longest -> honest choose_heaviest -> honest (b) attacker publishes 9 blocks vs honest 8: cost 21 hashes, work 786,444 choose_longest -> ATTACK choose_heaviest -> honest

The 9th block is what flips choose_longest, and it costs 3 hashes (21 − 18). That is the entire price of the attack: not 21 hashes, but the 3 that took it past the tie. §6.2, §6.4.

Question 2

You keep "longest chain wins" but pin the difficulty in the validator (expected_bits=18). Are you safe from the §6.4 attack? Are you safe from everything?

Answer

Safe from that attack, yes — it is the pinned / length row of the 2×2, and validate rejects the chain before any fork rule runs. Safe from everything, no. Under a pin, every candidate block costs the same, so height and work agree on all of them and the fork rule stops mattering — which means the moment difficulty changes (a retarget, a soft fork, a chain with variable difficulty), you are back to counting blocks with no reason to think that is right. The pin is what makes the wrong rule look correct. §7.7.

Question 3

Why does the crossover sit at 18 declared bits, and not 17?

Answer

Because the attacker inherits three honest blocks and has to make up the difference with six of its own:

6 × 2**b ≥ 2,097,152 − 786,432 = 1,310,720 2**b ≥ 218,453.3 b ≥ 17.74 → b = 18

At 17 the attack chain is worth 1,572,864 — 75% of the honest chain, and it loses. At 18 it is worth 2,359,296 and wins, at an averaged cost of 1,615,554 hashes, 77% of the honest chain's work. test_the_boundary_is_the_honest_difficulty asserts the crossover for every b from 1 to 20. §6.7.

Question 4

Someone "optimises" the header by moving bits out of it — it's only a label, and it's stored beside the block anyway. What breaks?

Answer

Mining becomes optional. With bits outside the hash, a block mined at 12 bits can be relabelled as 1-bit afterwards without changing its digest, so meets(h, b.bits) still passes: 0 of 20 relabels caught, against 20 of 20 as shipped (§7.5). Then the §6.4 attack needs no mine() call at all — take the honest chain, relabel every block as 1 bit, append a relabelled copy of anything, and you have a longer chain with no work behind it. Committing the difficulty is what makes the claim at least binding, even though it never makes it true.

Question 5

Your service treats 12 confirmations as final because "the attacker would need 4096× the work." Where does that reasoning go wrong?

Answer

Two places. First, the redo cost is linear in depth, not exponential: 12 confirmations measured 12.02× the cost of one, because each buried block is one more block to re-mine at the same price (§6.5). Second, what actually decays exponentially is the attacker's probability of winning the race, and its base is q/(1-q), the attacker's share of hash power. At q = 0.45 twelve confirmations still leave a 6.6% success rate in simulation (7.363% exact); at q = 0.50 depth buys nothing at all. Confirmations are a bet on the hash-rate split, not a cost multiplier.

Question 6

You hold a chain, you run validate(), and it returns (True, 'valid'). Write down everything you are entitled to conclude.

Answer

Precisely this: these blocks form a linked sequence with consecutive indices, and each block's digest clears the target printed on that block. That is the literal postcondition of chain.py:129-143, and it is all of it.

You may not conclude:

  • that this is what happened — §6.2 rewrites a transaction and the function still says valid;
  • that anyone spent any particular amount of work — §6.4's chain cost 21 hashes and declares 786,444;
  • that the difficulties are the ones the network expected — nothing is compared against anything (expected_bits is off by default);
  • that no shorter, heavier, or older chain exists — validate sees one chain and knows nothing of any other.

The security boundary is not in the function. It is the existence of other people holding a chain with more work in it, and a rule that counts that work. Which is why this toy's last word is that immutability is not a property of the data structure.


10. Further reading

Every link below was fetched and confirmed live on 2026-08-04. Three candidates were dropped as unreachable: hashcash.org/papers/hashcash.pdf (TLS handshake failure), learnmeabitcoin.com/technical/blockchain/chain-work/ (403), and bitcoinops.org/en/topics/mining/ (404).