Part III · The house: building a chain This is where cryptography runs out.
You have hand-written every borrowed brick. From here on, nobody handed Satoshi a blueprint.
And the first thing that happens is that you hit a wall.
6.1 Fold the signature into a tool¶
You wrote ECDSA by hand in chapter 3. This chapter is about systems, so the signature code gets folded up and used as a tool without further comment.
import hashlib
# ---- Chapter 3's ECDSA, folded into a tool ----
p, a, b = 23, 1, 4; G = (1, 12); n = 29
def _inv(x, m): return pow(x % m, m-2, m)
def _padd(P, Q):
if P is None: return Q
if Q is None: return P
x1,y1=P; x2,y2=Q
if x1==x2 and (y1+y2)%p==0: return None
s=(3*x1*x1+a)*_inv(2*y1,p)%p if P==Q else (y2-y1)*_inv(x2-x1,p)%p
x3=(s*s-x1-x2)%p; y3=(s*(x1-x3)-y1)%p
return (x3,y3)
def _smul(k, P):
R=None; k%=n
for _ in range(k): R=_padd(R,P)
return R
def _hint(msg): return int.from_bytes(hashlib.sha256(msg.encode()).digest(),"big")%n
def make_key(priv):
'''priv -> (priv, pub).'''
return priv, _smul(priv, G)
def sign(priv, msg, k=11):
'''Sign with the private key.'''
z=_hint(msg); R=_smul(k,G); r=R[0]%n
return (r, (_inv(k,n)*(z+r*priv))%n)
def verify(pub, msg, sig):
'''Verify with the public key only.'''
r,s=sig
if not (1<=r<n and 1<=s<n): return False
z=_hint(msg); w=_inv(s,n)
X=_padd(_smul((z*w)%n,G), _smul((r*w)%n,pub))
return X is not None and X[0]%n==r
def H(s): return hashlib.sha256(s.encode()).hexdigest()
print("signature tools ready.")
signature tools ready.
Nothing in that cell is new — it is chapter 3’s code compressed. Three functions to remember:
make_key(priv)→ returns(priv, pub)sign(priv, msg)→ signs with the private keyverify(pub, msg, sig)→ verifies with the public key alone
(Note that sign here gives k a default of k=11. Section 3.6 explained why that is a
fatal mistake in a real system — two messages signed with the same k and the private key
falls out. It is written this way purely so your output matches the book’s.
Never do this in real code.)
6.2 What is money, structurally?¶
Here is a question that sounds trivial: in your ledger, what data structure is “money”?
The obvious answer is a dictionary mapping public key → balance. Bitcoin does not do that.
In Bitcoin, money is not an account balance. It is a pile of unspent transaction outputs (UTXOs) — each one like a banknote you cannot tear, marked “this much, belongs to this public key.”
Spending means destroying old notes and issuing new ones.
class Ledger:
'''The ledger: every currently-unspent note.'''
def __init__(self):
self.utxos = {} # note_id -> (owner_pub, amount)
self.counter = 0
def _new_id(self):
self.counter += 1
return f"utxo{self.counter}"
def mint(self, owner_pub, amount):
'''Create coins from nothing (genesis / mining reward).'''
oid = self._new_id()
self.utxos[oid] = (owner_pub, amount)
return oid
def balance(self, pub):
return sum(amt for owner, amt in self.utxos.values() if owner == pub)
# Three people.
alice = make_key(7); bob = make_key(9); carol = make_key(13)
apriv, apub = alice; bpriv, bpub = bob; cpriv, cpub = carol
L = Ledger()
coin = L.mint(apub, 10) # Alice receives a 10-coin note
print("Alice's note id:", coin)
print("Alice's balance:", L.balance(apub))
print("the ledger's utxos:", L.utxos)
Alice's note id: utxo1
Alice's balance: 10
the ledger's utxos: {'utxo1': ((0, 21), 10)}
That L.utxos line matters. Its shape is roughly:
{'utxo1': (Alice's public key, 10)}Meaning: the ledger currently recognizes exactly one unspent note, called utxo1,
owned by Alice, worth 10.
Every transaction from here on just edits that dictionary: delete old notes, insert new ones.
Also notice balance() is computed, not stored — it walks every UTXO and adds up the ones
that are yours. In Bitcoin, “balance” is not a field. It is the result of a query.
That design choice comes back in chapter 15 (why wallets have to scan) and chapter 12
(why “dust” is a real problem).
6.3 What a transaction looks like¶
class TX:
def __init__(self, inputs, outputs):
self.inputs = inputs # note ids to spend
self.outputs = outputs # new notes [(recipient_pub, amount), ...]
self.sig = None # the spender's signature
def message(self):
'''The content to sign: what is spent, and to whom.'''
return H(str(self.inputs) + str([(str(o), amt) for o, amt in self.outputs]))
# Alice sends 6 to Bob, 4 back to herself as change.
tx = TX(inputs=[coin], outputs=[(bpub, 6), (apub, 4)])
tx.sig = sign(apriv, tx.message()) # signed with Alice's private key
print("signed message:", tx.message()[:16], "...")
print("Alice's signature:", tx.sig)
print("tx.inputs :", tx.inputs)
print("tx.outputs:", tx.outputs)
print("object fields:", tx.__dict__)
signed message: c6ee49246480cc72 ...
Alice's signature: (18, 22)
tx.inputs : ['utxo1']
tx.outputs: [((15, 6), 6), ((0, 21), 4)]
object fields: {'inputs': ['utxo1'], 'outputs': [((15, 6), 6), ((0, 21), 4)], 'sig': (18, 22)}
In plain English, this transaction says:
inputs=['utxo1']— I want to spend Alice’s original 10-coin noteoutputs=[(Bob's key, 6), (Alice's key, 4)]— issue two new notes: 6 to Bob, 4 back to Alicesig— Alice signs it, proving the old note really is hers to spend
Pause on the change. Alice only wants to give Bob 6, but the note in her hand is worth 10. A UTXO cannot be torn in half — it must be spent whole. So she destroys the entire 10 and issues herself a fresh 4.
This is exactly why your Bitcoin wallet keeps producing “change addresses” you never asked for. It is not the wallet being clever. It is a direct consequence of the UTXO model.
6.4 How the ledger validates a transaction¶
apply runs four checks. Count them first: only one is cryptography.
def apply(ledger, tx):
'''Validate and apply a tx. Returns (ok, reason/new notes).'''
# Check 1: are all inputs still unspent? -- DOUBLE-SPEND is caught HERE.
for oid in tx.inputs:
if oid not in ledger.utxos:
return False, f"note {oid} does not exist or was already spent (double-spend!)"
# Check 2: is the spender the owner? (This -- and only this -- is cryptography.)
owner, total_in = None, 0
for oid in tx.inputs:
o, amt = ledger.utxos[oid]
if owner is None: owner = o
elif owner != o: return False, "these notes have different owners"
total_in += amt
if not verify(owner, tx.message(), tx.sig):
return False, "invalid signature -- not the owner"
# Check 3: is value conserved?
total_out = sum(amt for _, amt in tx.outputs)
if total_out != total_in:
return False, f"value not conserved: in={total_in} out={total_out}"
# Check 4: apply it -- destroy inputs, create outputs.
for oid in tx.inputs:
del ledger.utxos[oid]
new_ids = []
for o, amt in tx.outputs:
oid = ledger._new_id()
ledger.utxos[oid] = (o, amt)
new_ids.append(oid)
return True, new_ids
ok, res = apply(L, tx)
print("Alice->Bob accepted?:", ok, " new notes:", res)
print("Alice:", L.balance(apub), " Bob:", L.balance(bpub))
print("utxos after:", L.utxos)
Alice->Bob accepted?: True new notes: ['utxo2', 'utxo3']
Alice: 4 Bob: 6
utxos after: {'utxo2': ((15, 6), 6), 'utxo3': ((0, 21), 4)}
It works. Alice’s 10-coin note is consumed and becomes Bob’s 6 plus Alice’s 4 in change.
Before: {'utxo1': (Alice, 10)}
After: utxo1 is gone, and utxo2 -> (Bob, 6) and utxo3 -> (Alice, 4) appear.
That is the UTXO model in one line: money is not a number in an account, it is a stack of notes nobody has spent yet.
Now go back to the four checks and label each one:
| Check | The question | Field |
|---|---|---|
| 1. Is the note still there | Was it already spent | Systems / double-spend prevention |
| 2. Is the signature valid | Is this the owner | Cryptography |
| 3. Is value conserved | Are coins being invented | Systems |
| 4. Apply | Delete old, insert new | Systems |
One out of four. Remember that ratio — the next section makes it starker.
6.5 The climax: what stops a double-spend?¶
Alice tries to cheat. She already spent that 10-coin note to Bob. Now she tries to spend it again, to Carol.
Before reading on, guess: what is going to stop her?
# Alice tries to spend the already-spent coin a second time.
tx_double = TX(inputs=[coin], outputs=[(cpub, 10)])
tx_double.sig = sign(apriv, tx_double.message()) # a perfectly valid signature!
print("is the double-spend's signature valid:", verify(apub, tx_double.message(), tx_double.sig))
ok, res = apply(L, tx_double)
print("does the ledger accept it?:", ok)
print("reason:", res)
is the double-spend's signature valid: True
does the ledger accept it?: False
reason: note utxo1 does not exist or was already spent (double-spend!)
Read those two lines carefully.
The signature is valid. The transaction is rejected anyway.
Alice’s signature is completely legitimate — she really is the owner of that note, she really is entitled to sign for it, and chapter 3’s mathematics has nothing to complain about.
What stops her is not cryptography. It is the ledger noticing the note is gone.
The control experiment¶
To make this unmistakable, run the opposite attack: Carol tries to steal Bob’s coin.
# Control: a forgery attack -- THIS one is stopped by cryptography.
bob_coin = [k for k, v in L.utxos.items() if v[0] == bpub][0]
tx_forge = TX(inputs=[bob_coin], outputs=[(cpub, 6)])
tx_forge.sig = sign(cpriv, tx_forge.message()) # Carol signs with her own key
ok, res = apply(L, tx_forge)
print("Carol tries to steal Bob's coin:", ok)
print("reason:", res, " <- this time cryptography caught it")
Carol tries to steal Bob's coin: False
reason: invalid signature -- not the owner <- this time cryptography caught it
False, because the signature is invalid.
Two attacks, two defences:
| Attack | What caught it | Field |
|---|---|---|
| Stealing someone else’s coin (forgery) | Signature verification failed | Cryptography |
| Spending your own coin twice | The ledger saw the note was gone | Systems |
Cryptography guards ownership. The system guards against reuse.
These sound like the same job. They are completely different — and for thirty years, everything anyone built had only the first defence.
6.6 But this ledger has a fatal hole¶
That apply looks like it solved double-spending. It did not.
Because L exists on one computer.
Now picture Alice doing this:
On machine A, she spends the note to Bob
At the same moment, on machine B, she spends the same note to Carol
Each machine has its own Ledger object. In each machine’s own ledger, both transactions are
entirely legal — signature valid, note present, value conserved. Both calls to apply
return True.
Neither machine can detect anything wrong.
The bug is not in the code. The bug is this:
“That note has already been spent” presupposes there is one ledger everyone agrees on. If there are many ledgers telling different stories, what does “already” even mean?
Deeper still: both double-spending transactions carry genuine signatures. Cryptography can prove Alice signed both. It has no capacity whatsoever to decide which of Alice’s two conflicting payments came first.
Double-spending is, at bottom, an ordering problem for conflicting transactions. And ordering requires a group of mutually distrustful people to agree on one sequence.
That is not a cryptography problem. A stronger signature algorithm does not move you one inch closer.
This is where Chaum stalled (his answer was to add a central bank). This is where b-money and Bitgold stopped, in papers. Thirty years, everyone stuck on this page.
YOUR TURN¶
# YOUR TURN:
# 1) What happens if value isn't conserved? Have Bob try to spend 6 as 100.
# bob_coin = [k for k,v in L.utxos.items() if v[0]==bpub][0]
# tx_bad = TX([bob_coin], [(cpub, 100)])
# tx_bad.sig = sign(bpriv, tx_bad.message())
# print(apply(L, tx_bad)) # expect: rejected, value not conserved
# 2) A thinking exercise (no code -- actually think about it):
# Our ledger L lives on YOUR machine only.
# If Alice spends the same coin against two different ledgers on two machines,
# both calls to apply() return True. Who notices?
# -- that is exactly what the next chapter has to solve: whose ledger counts?
Please actually stop on question 2. Not being able to answer it is the correct response — it took the human race thirty years.
6.7 The ledger¶
| What you wrote | Field |
|---|---|
Ledger.utxos — money as unspent notes | Systems design |
class TX — inputs, outputs, signature | Systems design |
apply check 1 (is the note still there) | Systems / double-spend prevention |
apply check 2 (is the signature valid) | Cryptography (imported from chapter 3) |
apply checks 3–4 (conservation, application) | Systems design |
One of the four checks is cryptography, and that one was borrowed.
But this chapter’s real conclusion is not in the table. It is in the hole:
You wrote every line of code in this chapter, and double-spending is still unsolved. Because solving it requires something that is not in your toolbox — and your toolbox contains only cryptography.
Next¶
Blocks and longest-chain consensus.
Alice will fork two chains from two machines, and both will be entirely valid. Then you will write a function to settle between them.
That function is one line. It does not check signatures, does not ask who anyone is, does not care who is right — it only compares who burned more electricity.
And afterwards you will notice something: chapter 7 contains no cryptography at all, from beginning to end.
That is what chapter 1 promised you.