Part V · Toward real engineering This chapter overturns an assumption you have carried since chapter 6.
Since chapter 6 the ledger has looked like this:
utxos = {'utxo1': (Alice's public key, 10)}A UTXO belongs to a public key. Whoever holds the matching private key can spend it.
Real Bitcoin does not work that way.
In real Bitcoin a UTXO belongs to nobody. It is locked by a script. “Whoever holds the key can spend it” is merely the most common — and simplest — thing that script can say.
A UTXO is not “owned by someone.” It is locked by a program.
14.1 What a stack is¶
Bitcoin’s scripts run on a very small stack machine, so spend a minute on the stack.
A stack is a pile of plates: last in, first out.
push A -> [A]
push B -> [A, B]
pop -> take B, leaving [A]The Script VM has exactly three rules:
See data -> push it
See an opcode -> pop what it needs, compute, push the result
Finish with True on top -> the script passesNo variables, no functions, no jumps. Three rules.
A stand-in signature first¶
This chapter is about the VM, not about ECDSA — you wrote that thoroughly in chapters 3 and 13. So we use a fake signature as a stand-in and keep the attention on the stack.
import hashlib
def H(s): return hashlib.sha256(s.encode()).hexdigest()
# Toy signatures: to keep focus on the Script VM, not to redo ECDSA.
PUB_TO_PRIV = {}
def make_key(name):
priv = "priv:" + name
pub = H(priv)[:16]
PUB_TO_PRIV[pub] = priv
return priv, pub
def sign(priv, msg):
return H("sig|" + priv + "|" + msg)
def verify(pub, msg, sig):
priv = PUB_TO_PRIV.get(pub)
return priv is not None and sign(priv, msg) == sig
alice_priv, alice_pub = make_key("Alice")
msg = "spend coin1 to Bob"
alice_sig = sign(alice_priv, msg)
print("pub =", alice_pub)
print("sig ok?", verify(alice_pub, msg, alice_sig))
pub = aead59eb96b81b6b
sig ok? True
Be clear that this is fake: that verify cheats by consulting a global dictionary
PUB_TO_PRIV. It has no cryptographic value whatsoever. In a real implementation you drop in
chapter 13’s sign/verify — the interface is identical, so the swap is direct.
14.2 The smallest possible script¶
Start with the shortest one:
<sig> <pub> OP_CHECKSIGdef run_script(script, msg, trace=True):
'''Run a tiny Script.'''
stack = []
for item in script:
if item == "OP_CHECKSIG":
pub = stack.pop()
sig = stack.pop()
stack.append(verify(pub, msg, sig))
else:
stack.append(item)
if trace:
print(f"{str(item)[:12]:<14} -> stack = {stack}")
return bool(stack and stack[-1] is True)
script = [alice_sig, alice_pub, "OP_CHECKSIG"]
print("does it pass?", run_script(script, msg))
e3974aa2a01e -> stack = ['e3974aa2a01ec2739c2b2674e7f74dff7bd863ea4991ffab28f5b4ab8732db3b']
aead59eb96b8 -> stack = ['e3974aa2a01ec2739c2b2674e7f74dff7bd863ea4991ffab28f5b4ab8732db3b', 'aead59eb96b81b6b']
OP_CHECKSIG -> stack = [True]
does it pass? True
Be clear what run_script is: a for loop, a list, and an if.
“Virtual machine” sounds enormous. Here it is ten lines of Python.
When it runs, watch the stack column — it plays the execution back like a recording:
<sig> -> stack = ['4f3a...'] pushed
<pub> -> stack = ['4f3a...', 'a1b2...'] pushed
OP_CHECKSIG -> stack = [True] popped two, pushed oneA script is not prose. It is a string of tiny instructions, and the VM is a robot that can only look at a stack, move things, and do small computations.
Note the pop order in OP_CHECKSIG: pub comes off first, sig second — because pub
was pushed last and sits on top. Last in, first out, and that detail is the same in
real Bitcoin.
14.3 Locking script plus unlocking script¶
Now split it into real Bitcoin’s shape.
A UTXO carries a locking script: “here is what must be satisfied to spend me.” The spender supplies an unlocking script: “here is my satisfaction of it.”
Nodes concatenate the two and run them:
unlocking_script = [alice_sig, alice_pub]
locking_script = ["OP_CHECKSIG"]
combined = unlocking_script + locking_script
print("combined script length:", len(combined))
print("does it verify?", run_script(combined, msg, trace=False))
bad_msg = "spend coin1 to Mallory"
print("does it still pass with a different message?", run_script(combined, bad_msg, trace=False))
combined script length: 3
does it verify? True
does it still pass with a different message? False
Look at that +. In code, “the lock” and “the key” are two lists being concatenated.
That is Bitcoin’s real validation model:
The locking script is written when the coin is received (the recipient sets the terms)
The unlocking script is supplied when it is spent (the spender provides evidence)
Nodes join them, run once, and release the funds on
True
Swap in bad_msg and it fails — because the signature is welded to specific content
(section 3.1’s second hole).
14.4 P2PKH: Bitcoin’s classic lock¶
A classic Bitcoin address does not store a public key. It stores a hash of one.
The P2PKH (Pay to Public Key Hash) locking script reads:
OP_DUP OP_HASH160 <pubKeyHash> OP_EQUALVERIFY OP_CHECKSIGIt asks two questions in a row:
1. Does the public key you produced hash to the pubKeyHash this coin is locked to?
2. Does that public key verify your signature?def HASH160(x):
# Real Bitcoin uses RIPEMD160(SHA256(pubkey)); a truncated SHA256 stands in here.
return H("hash160|" + x)[:20]
def run_p2pkh_script(script, msg, trace=True):
stack = []
for item in script:
if item == "OP_DUP":
stack.append(stack[-1])
elif item == "OP_HASH160":
stack.append(HASH160(stack.pop()))
elif item == "OP_EQUALVERIFY":
a = stack.pop()
b = stack.pop()
if a != b:
if trace: print(item, "-> False")
return False
elif item == "OP_CHECKSIG":
pub = stack.pop()
sig = stack.pop()
stack.append(verify(pub, msg, sig))
else:
stack.append(item)
if trace:
print(f"{str(item)[:14]:<16} -> stack = {stack}")
return bool(stack and stack[-1] is True)
pubkey_hash = HASH160(alice_pub)
unlock = [alice_sig, alice_pub]
lock = ["OP_DUP", "OP_HASH160", pubkey_hash, "OP_EQUALVERIFY", "OP_CHECKSIG"]
print("pubKeyHash (address-like) =", pubkey_hash)
print("does it pass?", run_p2pkh_script(unlock + lock, msg))
pubKeyHash (address-like) = 8f194e3ea6c1696c1370
e3974aa2a01ec2 -> stack = ['e3974aa2a01ec2739c2b2674e7f74dff7bd863ea4991ffab28f5b4ab8732db3b']
aead59eb96b81b -> stack = ['e3974aa2a01ec2739c2b2674e7f74dff7bd863ea4991ffab28f5b4ab8732db3b', 'aead59eb96b81b6b']
OP_DUP -> stack = ['e3974aa2a01ec2739c2b2674e7f74dff7bd863ea4991ffab28f5b4ab8732db3b', 'aead59eb96b81b6b', 'aead59eb96b81b6b']
OP_HASH160 -> stack = ['e3974aa2a01ec2739c2b2674e7f74dff7bd863ea4991ffab28f5b4ab8732db3b', 'aead59eb96b81b6b', '8f194e3ea6c1696c1370']
8f194e3ea6c169 -> stack = ['e3974aa2a01ec2739c2b2674e7f74dff7bd863ea4991ffab28f5b4ab8732db3b', 'aead59eb96b81b6b', '8f194e3ea6c1696c1370', '8f194e3ea6c1696c1370']
OP_EQUALVERIFY -> stack = ['e3974aa2a01ec2739c2b2674e7f74dff7bd863ea4991ffab28f5b4ab8732db3b', 'aead59eb96b81b6b']
OP_CHECKSIG -> stack = [True]
does it pass? True
Follow the trace and watch what each of the five opcodes does:
start (unlocking script pushed): [sig, pub]
OP_DUP -> [sig, pub, pub] copy the public key
OP_HASH160 -> [sig, pub, hash(pub)] hash the top copy
<pubKeyHash> -> [sig, pub, hash(pub), stored] push the hash stored in the UTXO
OP_EQUALVERIFY -> [sig, pub] compare; abort immediately if unequal
OP_CHECKSIG -> [True] verify sig against the remaining pubOP_DUP exists for exactly one reason, visible right there: the public key is needed
twice — once to hash and compare against the address, once to verify the signature.
Using it consumes it, so a copy has to be made first.
P2PKH in one sentence:
The address commits to a hash of the public key; the key itself only appears when you spend. The node first checks the key matches the address, then checks the signature matches the key.
(Why the extra layer of hashing? Two reasons: addresses get shorter (20 bytes rather than 33), and the public key stays unpublished until you spend — which gives a small amount of quantum resistance, see appendix B.)
YOUR TURN¶
# YOUR TURN:
# 1) Replace alice_pub in `unlock` with a different key. Which opcode stops it?
# 2) Replace alice_sig with a signature over bad_msg, then verify against msg. What happens?
# 3) Why does P2PKH do OP_EQUALVERIFY before OP_CHECKSIG?
# (Hint: which one is expensive? What do you gain by doing the cheap check first?)
Question 3’s answer matters in chapter 18: verifying a signature costs tens of times more than comparing a hash. Doing the cheap check first means most invalid scripts are rejected before any money is spent on the expensive one.
14.5 What else that lock could say¶
Back to the opening claim. If the lock is a program, it need not only say “whoever holds the key.”
Section 2.0 planted this, and here it is:
| What you want | Roughly what the locking script says |
|---|---|
| An ordinary payment | whoever produces a key matching this hash, plus a signature |
| Multisig | any two of these three public keys must sign |
| Timelock | not spendable until this block height |
| Hashlock | whoever produces a preimage that hashes to this value |
That last one is the foundation of the Lightning Network. It turns “tell me the secret and I’ll pay you” into a rule the entire network will enforce.
An address is not a fixed lock. It is a spending-condition contract.
It is also why the Bitcoin community is so cautious about proposals like CTV, CSFS, and CAT — every new opcode is a new expressive power in this language, and expressive power and validation cost are natural enemies. Chapter 18 explains why.
14.6 The ledger¶
| What you wrote | Field |
|---|---|
run_script() — a ten-line stack VM | Systems design |
| Concatenating locking and unlocking scripts | Protocol design |
| P2PKH’s five opcodes | Protocol design |
The verify inside OP_CHECKSIG | Cryptography (chapter 13’s) |
One line of cryptography again.
But the real yield of this chapter is a shift in how you see it:
Bitcoin’s validation is not “a database row saying these coins belong to Alice.” It is “this UTXO carries a script lock; whoever makes the script evaluate to
Truemay spend it.”
The first is a record. The second is a program. That difference is the entire reason Bitcoin can grow Lightning, multisig custody, and timelocked contracts.
Next¶
Wallets and HD key trees.
Chapter 2 said one key can make unlimited banks. Chapter 6 showed change addresses appearing constantly. Chapter 12 showed that address reuse leaks your balance.
Which raises a practical question: if a wallet has a thousand addresses, must the user back up a thousand private keys?
The next chapter’s answer is a tree, and a handful of English words you have probably seen before.