Part V · Toward real engineering · the final chapter Every “deficiency” in chapter 14’s virtual machine was a decision.
In chapter 14 you built a script VM that probably felt crude: no loops, no variables,
no functions, not even an if.
If it’s a VM, why not make it stronger?
The answer needs exactly what you started up last chapter:
Every full node has to validate every transaction. A script an attacker sends for a few cents must not be able to freeze the entire network.
18.1 What a validation machine has to guarantee¶
When Bitcoin nodes validate a script, they need four properties:
1. It will definitely stop.
2. Every node computes the same result.
3. The cost has an upper bound.
4. It does not read or write state that differs between machines.Each of those four takes away a capability that ordinary programming languages have. Here they are one at a time.
First, a VM with limits:
def run_limited(script, max_steps=50, max_stack_items=20, trace=False):
'''A safe toy VM with step and stack limits.'''
stack = []
pc = 0
steps = 0
while pc < len(script):
if steps >= max_steps:
return False, "rejected: script too slow, step limit exceeded"
if len(stack) > max_stack_items:
return False, "rejected: stack too large"
op = script[pc]
steps += 1
pc += 1
if isinstance(op, int):
stack.append(op)
elif op == "OP_ADD":
b = stack.pop(); a = stack.pop(); stack.append(a + b)
elif op == "OP_EQUAL":
b = stack.pop(); a = stack.pop(); stack.append(a == b)
elif op == "OP_DUP":
stack.append(stack[-1])
else:
return False, "unknown opcode: " + str(op)
if trace:
print(f"{steps:>2}. {op:<8} stack={stack}")
return bool(stack and stack[-1] is True), f"done, steps={steps}"
print(run_limited([2, 3, "OP_ADD", 5, "OP_EQUAL"], trace=True))
1. 2 stack=[2]
2. 3 stack=[2, 3]
3. OP_ADD stack=[5]
4. 5 stack=[5, 5]
5. OP_EQUAL stack=[True]
(True, 'done, steps=5')
Compared with chapter 14’s VM, two things are new: max_steps and max_stack_items.
Those parameters are not defensive-programming hygiene. They are part of the consensus rules — every node must use the same limits, or one accepts what another rejects and the chain splits (chapter 9).
18.2 Why no loops¶
If Script had loops, someone could write this:
while True:
do nothingIn an ordinary program that is merely a bad program, and it freezes your own machine.
In Bitcoin it is a bad program broadcast to every node on Earth.
def run_with_jump(script, max_steps=20):
'''A dangerous toy VM: add OP_JUMP and an infinite loop becomes trivial.'''
stack = []
pc = 0
steps = 0
while pc < len(script):
if steps >= max_steps:
return False, f"stopped for safety after {steps} steps -- possible infinite loop"
op = script[pc]
steps += 1
pc += 1
if isinstance(op, int):
stack.append(op)
elif op == "OP_JUMP":
pc = stack.pop() # jump to a position
elif op == "OP_DUP":
stack.append(stack[-1])
return True, "done"
# The script says: push 0, jump to instruction 0, repeat forever.
evil_loop = [0, "OP_JUMP"]
print(run_with_jump(evil_loop))
(False, 'stopped for safety after 20 steps -- possible infinite loop')
That infinite loop is two tokens long.
[0, "OP_JUMP"] — the attacker pays fees by the byte, so he pays for two bytes and every
node on the planet loops forever.
This is an extraordinarily asymmetric attack: cost to attack, O(1). Cost to defend, O(∞).
Bitcoin’s choice: there is no
OP_JUMP, nowhile, nofor. A script runs left to right and ends by construction.
Chapter 14’s for item in script: — it cannot go backwards and it cannot jump.
That was not laziness. That was the design.
18.3 Why Turing completeness is a defect here¶
Turing complete means, roughly: the language is powerful enough to simulate any computation.
That is a virtue in a general-purpose language. It is not a virtue when any stranger can hand you code to execute.
More power -> harder to predict runtime
More power -> harder to bound resources
More power -> easier to hide an attackAnd more fundamentally: deciding whether a Turing-complete program halts is undecidable (the halting problem). There is no checker you can write that rejects all looping scripts in advance.
Which leaves two roads:
Restrict the language so it halts by construction → Bitcoin
Charge for every step and stop when the money runs out → Ethereum’s gas
Bitcoin Script’s philosophy: not general computation. Predictable validation only.
18.4 Why no global mutable state¶
Suppose a script could read “globals” — how many transactions are in the mempool, what time it is, the price on some website.
The problem: different nodes do not see the same world. (Section 12.1, and you watched it directly last chapter.)
def script_reads_mempool(node_view):
'''Dangerous hypothetical: a script reading this node's mempool size.'''
return node_view["mempool_size"] < 100
node_a = {"name": "node A", "mempool_size": 20}
node_b = {"name": "node B", "mempool_size": 200}
for node in [node_a, node_b]:
print(node["name"], "validation result:", script_reads_mempool(node))
node A validation result: True
node B validation result: False
The same transaction is valid for node A and invalid for node B.
Neither node is malfunctioning — they simply see different worlds. But the result is a network split, and the kind from section 9.5 that does not heal by itself.
So script validation must depend on exactly two things: the transaction itself, and data already settled on the blockchain.
Determinism is not a code-quality preference. It is a precondition for consensus.
(This also explains something that confuses newcomers: Bitcoin’s timelocks use block height or the timestamp in the block header, never the node’s own system clock. The first two are written on the chain and identical for everyone; the last differs on every machine.)
18.5 Why limit opcodes¶
Verifying a signature costs tens of times more than an addition. An attacker can build a script stuffed with signature checks.
COST = {"OP_ADD": 1, "OP_EQUAL": 1, "OP_CHECKSIG": 80}
def script_cost(script):
return sum(COST.get(op, 0) for op in script if isinstance(op, str))
cheap_script = [1, 2, "OP_ADD", 3, "OP_EQUAL"]
expensive_script = ["OP_CHECKSIG"] * 30
print("cheap_script cost:", script_cost(cheap_script))
print("expensive_script cost:", script_cost(expensive_script))
print("1000 expensive scripts in one block:", script_cost(expensive_script) * 1000)
cheap_script cost: 2
expensive_script cost: 2400
1000 expensive scripts in one block: 2400000
This is the answer to section 14.4’s question: P2PKH does OP_EQUALVERIFY (a hash
comparison, cheap) before OP_CHECKSIG (a signature check, expensive) —
do the cheap check first and bad scripts are rejected before any real money is spent.
Real Bitcoin caps script size, operation count, and total signature checks. Not because those features are uncool, but because network-wide validation has to stay affordable.
Put a number on it¶
def estimate_block_validation(num_txs, script_cost_each, node_speed=100_000):
'''Estimate validation time: node_speed = cost units processed per second.'''
total_cost = num_txs * script_cost_each
seconds = total_cost / node_speed
return total_cost, seconds
for cost_each in [10, 1_000, 100_000, 10_000_000]:
total, sec = estimate_block_validation(num_txs=2000, script_cost_each=cost_each)
print(f"cost per tx {cost_each:>10,} -> block cost {total:>14,}, about {sec:>9.2f} seconds")
cost per tx 10 -> block cost 20,000, about 0.20 seconds
cost per tx 1,000 -> block cost 2,000,000, about 20.00 seconds
cost per tx 100,000 -> block cost 200,000,000, about 2000.00 seconds
cost per tx 10,000,000 -> block cost 20,000,000,000, about 200000.00 seconds
Look at the last row: 200,000 seconds to validate one block. More than two days.
If validating a block takes minutes, let alone hours, nodes cannot keep up. Nodes that fall behind drop out, and people who drop out stop running their own node and start trusting somebody else’s —
and “ordinary people can no longer afford to run a node” is precisely how decentralization dies.
That is the most important sentence in this chapter. Every one of these limits ultimately protects not CPU cycles but the property that one ordinary computer can independently verify the whole system.
Chapter 1’s “anyone can check the arithmetic” comes full circle here: if verification becomes expensive, that sentence becomes a slogan.
18.6 Why some opcodes were removed¶
An ordinary software bug gets patched. A consensus bug is a different animal:
If node versions disagree about one script -> the chain splits.
If one opcode can consume unbounded memory -> every node is crushed.
If an opcode's behaviour is loosely specified -> upgrading becomes dangerous.def op_cat_attack(rounds):
'''Dangerous hypothetical: OP_CAT doubling a string repeatedly.
We don't build the huge string -- just compute the length.'''
length = 1
for _ in range(rounds):
length *= 2
return length
for rounds in [10, 20, 30, 40]:
print(f"doubling {rounds:>2} times -> length about {op_cat_attack(rounds):,}")
doubling 10 times -> length about 1,024
doubling 20 times -> length about 1,048,576
doubling 30 times -> length about 1,073,741,824
doubling 40 times -> length about 1,099,511,627,776
Forty doublings is one terabyte. Under a hundred bytes of script demanding a terabyte of memory from every node on the network.
That is the flavour of resource explosion: an operation that looks completely harmless — joining two strings — amplifying exponentially in the absence of a hard cap.
Satoshi disabled a batch of opcodes in 2010, and OP_CAT was among them.
(Worth noting: OP_CAT has been seriously discussed for re-enablement in recent years,
because inside Tapscript it is already bounded by a 520-byte stack element limit,
which makes the doubling attack above impossible.
The same opcode is differently dangerous under different resource limits —
which shows that “dangerous” was never a property of the opcode itself, but of its
combination with the limit system around it.)
18.7 So why can Ethereum be more powerful?¶
Some systems choose Turing completeness. They generally must introduce resource pricing:
Every step costs gas.
Gas runs out, the program stops.
Complex computation costs more money.def run_with_gas(script, gas):
pc = 0
while pc < len(script):
op = script[pc]
gas -= COST.get(op, 1) if isinstance(op, str) else 1
if gas < 0:
return False, "out of gas, halted"
pc += 1
return True, "done, gas remaining = " + str(gas)
print(run_with_gas(["OP_CHECKSIG"] * 3, gas=300))
print(run_with_gas(["OP_CHECKSIG"] * 30, gas=300))
(True, 'done, gas remaining = 60')
(False, 'out of gas, halted')
Gas is a different solution, not a worse one.
It converts “will this halt,” which is undecidable, into “is there enough money,” which is computable. The price is that every operation must be priced — and mispricing is a vulnerability. (Ethereum has in fact suffered DoS attacks from underpriced opcodes and had to reprice them in a hard fork.)
Bitcoin Script : conservative, predictable, cheap to validate, anyone can run a node.
General contract VM: expressive, but requires careful metering and resource limits.This is not right versus wrong. The design goals differ. Bitcoin ranks “an old laptop can verify the entire network” above “you can write complicated contracts.” That is a value judgment, not a technical verdict.
YOUR TURN¶
# YOUR TURN:
# 1) Set evil_loop's max_steps to 1000. What happens? Why can't you just wait indefinitely?
# 2) Change expensive_script from 30 OP_CHECKSIGs to 300. How much does the cost grow?
# 3) Design a script that "reads the current time." Why would nodes disagree?
# 4) If code/toycoin_network/node.py's valid_tx actually ran scripts,
# which limits would you be forced to add?
Question 4 is the last exercise this book leaves you, and it is real: the network you
started in chapter 17 runs no scripts at all inside valid_tx(). Wire chapter 14’s VM into it
and you will have to answer every question in this chapter.
18.8 The ledger¶
| What you wrote | Field |
|---|---|
run_limited() — a VM with caps | Consensus safety |
run_with_jump() — a two-byte infinite loop | The attacker’s view |
script_reads_mempool() — non-determinism | Consensus safety |
script_cost() / estimate_block_validation() | Resource economics |
run_with_gas() — the other road | Comparative design |
Zero cryptography.
| Restriction | What it prevents |
|---|---|
| No loops | scripts that never stop |
| Not Turing complete | unpredictable validation time |
| No global mutable state | nodes computing different results |
| Capped opcodes, script size, signature checks | CPU and memory DoS |
| Dangerous opcodes disabled | bugs becoming consensus splits |
The sentence:
Bitcoin Script’s goal is not “you can write anything.” Its goal is: a spending condition sent by a stranger can be validated by every node in the world quickly, deterministically, and cheaply.
It is a pocket knife, not a general-purpose computer. And that was chosen.
Closing the books¶
Eighteen chapters. Here is the whole tally:
| Ch | What you built | Field |
|---|---|---|
| 1 | Hashing, commitments | Cryptography |
| 2 | Finite fields, elliptic curves, public keys | Cryptography |
| 3 | ECDSA signing and verification | Cryptography |
| 4 | The hash time-stamp chain | Systems |
| 5 | Proof-of-work | Systems + probability |
| 6 | UTXOs, transactions, double-spend prevention | Systems |
| 7 | Blocks, longest-chain consensus | Systems + game theory |
| 8 | Block rewards, halving, incentives | Economics |
| 9 | Forks and upgrades | Systems + consensus + social coordination |
| 10 | Fees, difficulty adjustment | Economics + control theory + statistics |
| 11 | Merkle trees | Cryptography |
| 12 | Mempool, fee market, confirmations | Node policy + economics + consensus |
| 13 | secp256k1, the real curve | Cryptography |
| 14 | The Script VM | Systems + a small VM |
| 15 | HD wallets | Key management + human factors |
| 16 | Hardware signing | Security engineering |
| 17 | A multi-node network | Distributed systems |
| 18 | Script’s safety limits | Consensus safety + resource control |
Eighteen chapters. Five of cryptography.
And what those five contain — hashing, elliptic curves, signatures, Merkle trees — not one of them was invented by Satoshi. They come from papers published in 1976, 1979 and 1991.
The other thirteen chapters are Bitcoin.
Those thirteen all answer the same question: how do mutually distrustful strangers with no central authority agree on who owns what? For thirty years, people held the same bricks and nobody built the house.
The prologue’s claim is now yours to judge:
Cryptography was his brick supply. Building a bank with no manager out of those bricks was the invention.
What you can do now¶
The book is over. The chain is yours.
Add back what was left out: section 17.6 lists six items. Wire them into
toycoin_networkand you have a crude but structurally complete node.Use real formats: transaction serialization, Base58/Bech32 addresses, real Script bytecode.
Go to signet or testnet: run experiments with mature libraries — and now you know what they are doing underneath.
Follow the frontier: Taproot, Schnorr signatures, and proposals like CTV, CSFS and CAT. Sections 14.5 and 18.6 already pushed that door open a crack.
Three appendices are roads you may choose not to take, but should know exist:
Appendix A: the cryptography this book skipped — encryption, zero-knowledge proofs, MPC
Appendix B: why a quantum computer breaks public keys but not hashes
Appendix C: CHECKSIG vs CSFS — same lamp, different piece of paper
The bricks are borrowed and the house is original — and this time, you’re the one who built it.