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.
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
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:
validate() == True on a chain entitles you to conclude, which is much less than "this is what happened";len(a) >= len(b) versus total_work(a) >= total_work(b) — and point at the line in Bitcoin Core that gets it right;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:
None of it is deep, but the result below leans on it hard.
| Concept | Where it's used here | One 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:
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.
Before any code. Two chains that fork at block 3. Every number below is from demo.py act 3.
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.
182 lines, no classes but one, no dependencies. Read it in this order.
"""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.
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:
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:
The pipe is cheap; ambiguity is not.
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.
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.
validate — and the check it deliberately doesn't makedef 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 —
— 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.
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:
with nChainWork documented in src/chain.h as
"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.
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.
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.
python3 demo.py
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.
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:
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.
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()):
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%).
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:
The work. Here is why the attack chain is lighter despite being taller:
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:
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.
"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):
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):
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.
python3 test_chain.py
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.
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):
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:
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:
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:
validate() with no pinned difficulty returns True on 20 of 20 rows. There is no difficulty at which the chain detects its own rewrite. The validator is not a weak defence here; it is not a defence at all.choose_longest says ATTACK on 20 of 20 rows, including 1 bit. Height is never a defence, at any difficulty.expected_bits=18 pinned, validate returns False on every row except 18 — where the attacker is declaring the truth.And one boundary in the other direction, from a second sweep over where the attacker forks:
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.
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:
bits sits in the header and the validator takes it at its word. The attack costs 21 hashes and the lesson is the fork rule.(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.
merkle-tree doesn't already ownmerkle-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:
merkle-tree has no notion of cost. Nothing in it is counted; its attack is a hash-domain confusion costing two concatenations. The attack here contains no cryptographic flaw at all — SHA-256 is unbroken, every pointer is honest, every declared target is genuinely met. It is an accounting error in how two valid chains are ranked.merkle-tree has one structure. No second tree, no peer, no choice. The result here requires two chains and a rule for picking between them, which does not exist in merkle-tree's problem statement.merkle-tree teaches that a verifier returning True entitles you to less than you think. This toy teaches that the verifier is not the security boundary at all: validate() returns True on the rewritten chain at every difficulty tried (20 of 20, §6.7), and is correct each time. Immutability is not a property the data structure has. It is a property of other people holding a heavier chain.merkle-tree: two independent bugs, two independent fixes, neither covering the other. Here: one bug, two fixes, either sufficient — and §7.7 shows they still are not interchangeable.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:
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:
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.
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.
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:
index is inert. Removing it from the header changes nothing in any of the four probes — position is already pinned by prev, and validate checks the plaintext index separately. Bitcoin's header indeed has no height field; height lives in the coinbase (BIP 34) instead. Keeping index here is legibility, not security.prev is what makes it a chain. Without it, a block mined in a different chain grafts straight in (P4 False): same index, same body, its own valid proof-of-work, and nothing left to contradict it. The relabel probe also drops to 7 of 20 — with no successor pointing at the tampered block, the only remaining catch is the block's own re-checked proof-of-work against a 1-bit target, which is a coin flip.data is the payload commitment. Without it the whole exercise is pointless: edit any transaction, remine nothing, 0 of 20 caught.bits must be inside the hash. Without it a mined block can be relabelled as trivially difficult after the fact, 0 of 20 caught — and then the §6.4 attack needs no mining at all, just an edit.validate returns True, and that is not a bug to fixThe 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.
§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.
work() is 2**bits, not Bitcoin's 2**256 / (target+1). Same idea, and exact for the toy because the target is always a power of two. Bitcoin's compact nBits encoding is a floating-point-ish mantissa/exponent format whose decoding (DeriveTarget in pow.cpp) has its own overflow and negative-value checks; none of that changes the accounting.validate returns (bool, reason). A bare bool would make act 1's transcript unreadable — you would see False and not know which of four checks fired.HashCounter object threaded through every call would be tidier and would add a parameter to every signature in the file. For a toy whose measurements are all global totals, the global is honest about what it is.>= in both rules. §6.2 depends on it, and it is the toy's stand-in for "keep what you already have," which is what real nodes do to avoid flapping.nBits disagree. This toy pins nothing by default — expected_bits is the one-line stand-in, and it is off unless you pass it.data is opaque bytes, so the toy cannot tell a rewrite that steals money from one that changes a comment. Real nodes reject invalid transactions before fork choice ever runs — the whitepaper is explicit that an attacker "can only try to change one of his own transactions."merkle-tree/.assumevalid. Production systems add non-consensus brakes on deep reorgs. They are policy, not mechanism, and they exist precisely because the mechanism on this page permits what §6.4 shows.Answer before expanding. Each answer is derivable from the source, and each was verified by running it.
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?
Neither. >= in both rules means a tie goes to the first argument, which is the chain the node already holds:
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.
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?
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.
Why does the crossover sit at 18 declared bits, and not 17?
Because the attacker inherits three honest blocks and has to make up the difference with six of its own:
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.
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?
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.
Your service treats 12 confirmations as final because "the attacker would need 4096× the work." Where does that reasoning go wrong?
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.
You hold a chain, you run validate(), and it returns (True, 'valid'). Write down everything you are entitled to conclude.
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:
valid;expected_bits is off by default);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.
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).
src/pow.cpp — CheckProofOfWorkImpl is this toy's meets, with the comment "Check proof of work matches claimed amount" saying out loud that it grades against the header's claim. GetNextWorkRequired/CalculateNextWorkRequired is the retargeting the toy leaves out.src/validation.cpp — find ContextualCheckBlockHeader and the bad-diffbits rejection. This is the production version of expected_bits, and worth reading for why it needs pindexPrev: difficulty is a property of a chain, not of a block.src/chain.h — nChainWork, commented "Total amount of work (expected number of hashes) in the chain up to and including this block." That is total_work, in production, to the word.src/node/blockstorage.cpp — CBlockIndexWorkComparator::operator(), which begins "First sort by most total work" and never mentions height. Fifteen lines, and the answer to the whole page.D × 2**32 comes from, the exact D × 2**256 / (0xffff × 2**208) behind it, and the 2016-block retarget interval.Block.header.