The UTXO Model and Balance Tracking: How Does Bitcoin Know Your 'Balance'?
Learning Objectives
- ✓UTXO 모델과 계좌 모델의 차이를 비유를 사용해 설명할 수 있다
- ✓UTXO 풀에서 특정 주소의 잔고를 계산하는 함수를 작성할 수 있다
- ✓이중 지불 시도를 UTXO 검증으로 탐지·거부하는 로직을 구현할 수 있다
UTXO Model and Balance Tracking: How Does Bitcoin Know Your 'Balance'?
Ask your blockchain "How much does Alice have?" It can't answer.
In Lesson 6, we completed the chain structure that links blocks together with hashes. We were able to detect tampering with is_chain_valid(). Blocks do contain transactions (what we called 'digital checks' in Lesson 3), but there's no logic to track who has how much.
Today we solve that problem. But Bitcoin's approach differs from our intuition. It doesn't manage account balances like a bank.
A Real Case: An $8 Million Mistake
In 2013, a Bitcoin user trying to send 0.01 BTC accidentally sent 291 BTC (worth roughly $8 million at the time) as a fee. It wasn't a bug. It was a failure to understand the UTXO model.
What happened? The user used a 500 BTC UTXO as input to send 0.01 BTC. The problem was not creating an output — a change output — to return the remainder to their own address. In the Bitcoin protocol, the difference between the sum of inputs and the sum of outputs automatically becomes the miner fee. 500 − 0.01 = 499.99 BTC evaporated as fees. A miner included this transaction in a block and kept every bit of that fee.
| Item | Intended Transaction | Actual Result |
|---|---|---|
| Input | 500 BTC UTXO | 500 BTC UTXO |
| Output 1 (recipient) | 0.01 BTC | 0.01 BTC |
| Output 2 (change) | 499.99 BTC → self | ❌ Missing |
| Miner fee | 0 BTC | 499.99 BTC 😱 |
I was shocked when I first encountered Bitcoin's UTXO system after working on the Ethereum (Account model) side. "No balance? What does that even mean?" But once you understand UTXO, you realize why it's a superior design in terms of security and privacy. Honestly, Ethereum's Account model is more convenient from a developer's perspective. Still, once you grasp the elegance of UTXO, you can't help but respect it.
🤔 Think about it: When you transfer money from a bank account, do you worry about "change"? Why does this become a problem in Bitcoin?
See answer
Banks use the Account model. They store a single number in a DB — something like "balance = 500,000 won" — and directly subtract/add that number on transfer. No concept of change is needed. But Bitcoin has no "balance" field. Instead, you must collect and sum up all unspent outputs (UTXOs) to know the balance. It's like counting the bills in your wallet — if you pay 30,000 won with a 50,000 won bill, you need to receive 20,000 won in change.
Account Model vs. UTXO Model — The Core Framework
Having written Ethereum smart contracts for several years, here's the difference between the two models as I've experienced it firsthand.
Account model — used by Ethereum and traditional banks.
# Account model: directly manage balances like a bank account
accounts = {"Alice": 100, "Bob": 50}
def transfer_account(sender, receiver, amount):
if accounts[sender] >= amount:
accounts[sender] -= amount
accounts[receiver] += amount
print(f"✅ {sender} → {receiver}: {amount} BTC")
print(f" Alice: {accounts['Alice']}, Bob: {accounts['Bob']}")
else:
print(f"❌ Insufficient balance")
transfer_account("Alice", "Bob", 30)
# Output:
# ✅ Alice → Bob: 30 BTC
# Alice: 70, Bob: 80
Simple and intuitive. When I write ERC-20 token contracts in Solidity, it's the same approach — tracking balances with mapping(address => uint256) balances. From a developer's perspective, it's paradise.
UTXO model — used by Bitcoin.
# UTXO model: manage individual 'coin chunks' like bills in a wallet
utxo_pool = [
{"id": "tx001:0", "owner": "Alice", "amount": 50},
{"id": "tx001:1", "owner": "Alice", "amount": 30},
{"id": "tx002:0", "owner": "Bob", "amount": 20},
]
def get_balance(address):
"""Balance for an address = sum of all UTXOs for that address"""
total = 0
for utxo in utxo_pool:
if utxo["owner"] == address:
total += utxo["amount"]
return total
print(f"Alice balance: {get_balance('Alice')} BTC") # 50 + 30 = 80
print(f"Bob balance: {get_balance('Bob')} BTC") # 20
# Output:
# Alice balance: 80 BTC
# Bob balance: 20 BTC
The number "balance" isn't stored anywhere. It's calculated by summing UTXOs every time. This is the essence of the UTXO model.
| Comparison | Account Model | UTXO Model |
|---|---|---|
| Balance storage | Single number (state) | Not stored (calculated) |
| Analogy | Bank account | Bundle of bills in a wallet |
| Parallel processing | Difficult (competing on same account) | Easy (independent UTXOs) |
| Privacy | Low (easy to trace accounts) | High (new address possible each time) |
| Development complexity | Easy | Difficult |
| Representative chain | Ethereum | Bitcoin |
When building DeFi protocols, the Account model is more convenient. But for design as "currency", UTXO is superior. Each UTXO is independent, enabling parallel verification, and using a new address per transaction also ensures privacy. Bitcoin's choice of UTXO was no accident.
🤔 Think about it: In Lesson 3, we learned about the Input and Output structure of transactions. In the UTXO model, what exactly does an "input" refer to?
See answer
In the UTXO model, a transaction's Input is a reference to an unspent output (UTXO) from a previous transaction. It's a declaration of "I'm going to spend this money." In Lesson 3, we compared it to a digital check — more precisely, it's "tearing up a check that was previously issued to you and writing a new one." The original check (UTXO) is destroyed, and new checks (new UTXOs) are created.
The UTXO Lifecycle: From Birth to Death
Now that we understand the difference between the two models, let's trace the journey of a single UTXO.
A UTXO goes through three states. Do you remember the 'Sudoku analogy' for proof of work from Lesson 5? When a miner finds a nonce and creates a block, a Coinbase transaction is created as the reward. This is the moment a UTXO is born into the world.
Let's look at a concrete scenario in code.
# UTXO lifecycle simulation
utxo_pool = {}
# Step 1: UTXO born as mining reward (coinbase transaction)
# The result of PoW mining we learned in Lesson 5
coinbase_tx_hash = "abc123"
utxo_pool[("abc123", 0)] = {"address": "Alice", "amount": 50}
print("1️⃣ UTXO pool after mining:")
print(f" Alice's UTXO: 50 BTC (ID: abc123:0)")
print(f" Alice balance: 50 BTC")
# Step 2: Alice sends 30 BTC to Bob
# Input: spend Alice's 50 BTC UTXO
# Output 1: 30 BTC to Bob (new UTXO)
# Output 2: 20 BTC change to Alice (new UTXO)
del utxo_pool[("abc123", 0)] # Spend existing UTXO (destroy)
tx1_hash = "def456"
utxo_pool[("def456", 0)] = {"address": "Bob", "amount": 30} # new UTXO
utxo_pool[("def456", 1)] = {"address": "Alice", "amount": 20} # change
print("\n2️⃣ UTXO pool after transfer:")
print(f" Bob's UTXO: 30 BTC (ID: def456:0)")
print(f" Alice's UTXO: 20 BTC (ID: def456:1) ← change")
print(f" Alice balance: 20 BTC, Bob balance: 30 BTC")
# Step 3: Double-spend attempt!
# Alice tries to use the already-spent abc123:0 again
key = ("abc123", 0)
if key in utxo_pool:
print("\n3️⃣ Double spend succeeded!") # This line won't execute
else:
print(f"\n3️⃣ ❌ Double spend blocked! UTXO {key} already spent")
# Output:
# 1️⃣ UTXO pool after mining:
# Alice's UTXO: 50 BTC (ID: abc123:0)
# Alice balance: 50 BTC
#
# 2️⃣ UTXO pool after transfer:
# Bob's UTXO: 30 BTC (ID: def456:0)
# Alice's UTXO: 20 BTC (ID: def456:1) ← change
# Alice balance: 20 BTC, Bob balance: 30 BTC
#
# 3️⃣ ❌ Double spend blocked! UTXO ('abc123', 0) already spent
Remember three things:
- A UTXO can only be used once. Once spent, it's deleted from the pool. You cannot split it or use only part of it.
- Balance = sum of UTXOs at your address. There is no separate "balance" field.
- Change is not automatic. You must explicitly create it as an output — this is exactly why the $8 million incident at the start happened.
🔍 Deep dive: How is Bitcoin's actual UTXO pool managed?
An actual Bitcoin node (Bitcoin Core) stores the UTXO set in a LevelDB database called chainstate. As of 2024, there are approximately 80 million UTXOs, occupying about 7 GB on disk. This entire set needs to be loaded into memory for fast transaction validation. Our utxo_pool dictionary is a simplified version of this LevelDB key-value structure.
The Mechanics of Change — Going to a Convenience Store with a $50 Bill
Now that we've seen the UTXO lifecycle, let's dig deeper into why change is so critical.
The easiest analogy for understanding UTXO is cash bills.
You have one $50 bill in your wallet. You buy a $12 lunch box at a convenience store. You hand over $50 and receive $38 in change. In this process:
- Input: one $50 bill (destroyed — you don't get it back)
- Output 1: $12 to the store
- Output 2: $38 to you (change)
Bitcoin works exactly the same way. If you send 12 BTC using a 50 BTC UTXO, you must create a 38 BTC change output back to your own address. If you don't? 38 BTC evaporates as a miner fee.
# Example showing why change is essential
def create_transaction(utxo_pool, sender, receiver, amount, sender_utxos):
"""Create a UTXO-based transaction (with change)"""
# Calculate input total
input_total = sum(u["amount"] for u in sender_utxos)
if input_total < amount:
print(f"❌ Insufficient balance! Have: {input_total}, Need: {amount}")
return None
outputs = [{"address": receiver, "amount": amount}]
# Calculate change
change = input_total - amount
if change > 0:
outputs.append({"address": sender, "amount": change})
print(f"💰 Change: {change} BTC → {sender}")
print(f"✅ Transaction created: {sender} → {receiver}: {amount} BTC")
return {"inputs": sender_utxos, "outputs": outputs}
# Alice sends 12 BTC using a 50 BTC UTXO
alice_utxo = {"id": "tx001:0", "address": "Alice", "amount": 50}
tx = create_transaction({}, "Alice", "Bob", 12, [alice_utxo])
print(f"\nTransaction outputs:")
for i, out in enumerate(tx["outputs"]):
print(f" Output {i}: {out['amount']} BTC to {out['address']}")
# Output:
# 💰 Change: 38 BTC → Alice
# ✅ Transaction created: Alice → Bob: 12 BTC
#
# Transaction outputs:
# Output 0: 12 BTC to Bob
# Output 1: 38 BTC to Alice
❌ → 🤔 → ✅ Creating UTXO Transactions: From Common Mistakes to Correct Implementation
Now that we understand the mechanics of change, let's compare three stages of actually creating a transaction in code. You'll feel in code exactly why the $8 million incident at the start happened.
❌ WRONG WAY — A transaction missing the change output
# ❌ Beginner's mistake: not creating a change output
def send_btc_wrong(utxo_pool, sender_utxo, receiver, amount):
"""Dangerous! A transaction that doesn't account for change"""
outputs = [{"address": receiver, "amount": amount}]
# Input 50 BTC, output 12 BTC... what about the remaining 38 BTC?
# It's not designated as an output anywhere!
return {"inputs": [sender_utxo], "outputs": outputs}
utxo = {"id": "tx001:0", "address": "Alice", "amount": 50}
tx = send_btc_wrong({}, utxo, "Bob", 12)
input_total = utxo["amount"] # 50 BTC
output_total = sum(o["amount"] for o in tx["outputs"]) # 12 BTC
lost_to_fee = input_total - output_total # 38 BTC 😱
print(f"Input: {input_total} BTC")
print(f"Output: {output_total} BTC (to Bob)")
print(f"Miner fee: {lost_to_fee} BTC ← 💸 Alice's money evaporated!")
# Output:
# Input: 50 BTC
# Output: 12 BTC (to Bob)
# Miner fee: 38 BTC ← 💸 Alice's money evaporated!
This is exactly the code behind the $8 million incident. It happened because the person didn't know that the difference between inputs and outputs automatically becomes the miner fee.
🤔 BETTER — Change is added, but no validation
# 🤔 Change is returned, but validation logic is missing
def send_btc_better(utxo_pool, sender_utxo, sender, receiver, amount):
"""A transaction with change but no input validation"""
change = sender_utxo["amount"] - amount
outputs = [
{"address": receiver, "amount": amount},
{"address": sender, "amount": change}, # Change added!
]
return {"inputs": [sender_utxo], "outputs": outputs}
utxo = {"id": "tx001:0", "address": "Alice", "amount": 50}
# Normal case: works fine
tx = send_btc_better({}, utxo, "Alice", "Bob", 12)
print(f"Bob: {tx['outputs'][0]['amount']} BTC")
print(f"Alice: {tx['outputs'][1]['amount']} BTC (change)")
# Output:
# Bob: 12 BTC
# Alice: 38 BTC (change)
# ⚠️ Problem 1: What if we send more than the balance?
tx_bad = send_btc_better({}, utxo, "Alice", "Bob", 999)
print(f"\nDangerous transaction — change: {tx_bad['outputs'][1]['amount']} BTC")
# Output: Dangerous transaction — change: -949 BTC ← negative amount!
# ⚠️ Problem 2: Can't prevent reuse of already-spent UTXOs
# ⚠️ Problem 3: Creates an unnecessary empty output even when change is 0
Money isn't lost because change is created. But without input validation, critical bugs like sending more than the balance, reusing already-spent UTXOs, and negative change are possible.
✅ BEST — A transaction with full validation and change
# ✅ Production level: validation + change + UTXO pool update
def send_btc_best(utxo_pool, sender_utxos, sender, receiver, amount):
"""Full UTXO transaction: validate → create → update pool"""
# 1. Verify input UTXOs exist in pool (prevent double-spend)
for utxo in sender_utxos:
key = (utxo["id"], utxo["address"])
if utxo["id"] not in [uid for uid, _ in utxo_pool]:
return None, f"❌ UTXO {utxo['id']} not in pool"
# 2. Verify input total ≥ output amount
input_total = sum(u["amount"] for u in sender_utxos)
if input_total < amount:
return None, f"❌ Insufficient balance (have: {input_total}, need: {amount})"
# 3. Compose outputs: recipient + change (only if needed)
outputs = [{"address": receiver, "amount": amount}]
change = input_total - amount
if change > 0:
outputs.append({"address": sender, "amount": change})
# 4. Update UTXO pool: remove spent UTXOs, add new UTXOs
for utxo in sender_utxos:
utxo_pool.pop(utxo["id"], None)
tx_id = f"tx_{hash(str(outputs)) % 10000:04d}"
for i, out in enumerate(outputs):
utxo_pool[f"{tx_id}:{i}"] = out
fee = input_total - sum(o["amount"] for o in outputs)
return outputs, f"✅ Transfer complete (fee: {fee} BTC)"
# Example run
pool = {"tx001:0": {"address": "Alice", "amount": 50}}
utxo = {"id": "tx001:0", "address": "Alice", "amount": 50}
result, msg = send_btc_best(pool, [utxo], "Alice", "Bob", 12)
print(msg)
for i, out in enumerate(result):
print(f" Output {i}: {out['amount']} BTC to {out['address']}")
# Double-spend attempt
result2, msg2 = send_btc_best(pool, [utxo], "Alice", "Charlie", 12)
print(msg2)
# Output:
# ✅ Transfer complete (fee: 0 BTC)
# Output 0: 12 BTC to Bob
# Output 1: 38 BTC to Alice
# ❌ UTXO tx001:0 not in pool
| Stage | Change | Input Validation | Double-Spend Prevention | Result |
|---|---|---|---|---|
| ❌ Wrong | None | None | None | 38 BTC evaporated |
| 🤔 Better | Yes | None | None | Negative amount bug possible |
| ✅ Best | Conditional | Amount + pool check | Remove from UTXO pool | Safe transfer |
These three stages compress the core design of Bitcoin transactions. The validate_transaction() and update_utxo_pool() methods in the project code are exactly what implements the ✅ BEST stage.
You can also combine multiple UTXOs as inputs. It's like paying 25,000 won with three 10,000 won bills.
# Combining multiple UTXOs as inputs
alice_utxos = [
{"id": "tx001:0", "amount": 10},
{"id": "tx002:0", "amount": 10},
{"id": "tx003:0", "amount": 10},
]
total_input = sum(u["amount"] for u in alice_utxos)
send_amount = 25
change = total_input - send_amount
print(f"3 input UTXOs: {[u['amount'] for u in alice_utxos]} = {total_input} BTC")
print(f"Send: {send_amount} BTC → Bob")
print(f"Change: {change} BTC → Alice")
print(f"\nSpent UTXOs: 3 (removed)")
print(f"Created UTXOs: 2 (Bob 25, Alice 5)")
# Output:
# 3 input UTXOs: [10, 10, 10] = 30 BTC
# Send: 25 BTC → Bob
# Change: 5 BTC → Alice
#
# Spent UTXOs: 3 (removed)
# Created UTXOs: 2 (Bob 25, Alice 5)
🤔 Think about it: If Alice has exactly one 10 BTC UTXO and wants to send exactly 10 BTC to Bob, does she need a change output?
See answer
She doesn't! If the input total (10) and output total (10) are exactly the same, a transaction can be made without a change output. If the difference is 0, the fee is also 0. In actual Bitcoin, a fee of 0 may cause miners to not include the transaction, so you typically leave a small fee by making the output total slightly less than the input — e.g., input 10 BTC, output 9.9999 BTC (recipient) → fee 0.0001 BTC.
Double-Spend Prevention — The Fundamental Problem UTXO Solves
Now that we understand the change mechanism, it's time to look at the most fundamental problem the UTXO model solves. The same money must not be spendable twice. In the UTXO model, double-spend prevention is surprisingly simple:
"Is this UTXO in the pool?" — That's it.
Once a UTXO is spent, it's deleted from the pool. When a second transaction referencing the same UTXO arrives, it can't be found in the pool and is immediately rejected. Compare this with the hash verification in is_chain_valid() from Lesson 6 — chain integrity validates "has past data been altered?", while UTXO validation verifies "has this money already been spent?". Both are essential.
❌ Double-spend scenario (without UTXO):
# Dangerous: double-spend possible with simultaneous requests in Account model
balance = {"Alice": 100}
# Thread 1: Alice → Bob 100 BTC (balance check: 100 ≥ 100 ✅)
# Thread 2: Alice → Charlie 100 BTC (balance check: 100 ≥ 100 ✅)
# If executed simultaneously, both could pass!
print("⚠️ Account model requires concurrency control — race condition risk")
# Output: ⚠️ Account model requires concurrency control — race condition risk
✅ The UTXO model's solution:
# The UTXO model structurally prevents double-spending
utxo_pool = {
("tx_abc", 0): {"address": "Alice", "amount": 100}
}
def validate_and_spend(tx_inputs, tx_outputs, pool):
"""UTXO-based double-spend validation"""
# 1. Check all input UTXOs exist in the pool
for inp in tx_inputs:
if inp not in pool:
return False, f"❌ UTXO {inp} not found — double spend blocked!"
# 2. Verify input sum ≥ output sum
input_sum = sum(pool[inp]["amount"] for inp in tx_inputs)
output_sum = sum(out["amount"] for out in tx_outputs)
if output_sum > input_sum:
return False, f"❌ Output({output_sum}) > Input({input_sum})"
# 3. Validation passed → spend UTXOs (remove from pool)
for inp in tx_inputs:
del pool[inp]
return True, "✅ Transaction valid"
# First transfer: Alice → Bob 100 BTC
inputs_1 = [("tx_abc", 0)]
outputs_1 = [{"address": "Bob", "amount": 100}]
ok, msg = validate_and_spend(inputs_1, outputs_1, utxo_pool)
print(f"TX1 (Alice→Bob): {msg}")
# Second transfer attempt: same UTXO, Alice → Charlie (double spend!)
inputs_2 = [("tx_abc", 0)]
outputs_2 = [{"address": "Charlie", "amount": 100}]
ok, msg = validate_and_spend(inputs_2, outputs_2, utxo_pool)
print(f"TX2 (Alice→Charlie): {msg}")
# Output:
# TX1 (Alice→Bob): ✅ Transaction valid
# TX2 (Alice→Charlie): ❌ UTXO ('tx_abc', 0) not found — double spend blocked!
Race conditions are structurally impossible in the UTXO model. A single UTXO exists as exactly one key in the dictionary, so once it's deled, that's the end. When writing smart contracts on Ethereum's Account model, I always had to worry about Reentrancy attacks (which is why the Checks-Effects-Interactions pattern exists). With UTXO, that concern doesn't exist at all.
🔍 Deep dive: Double-spend detection in the Mempool
In the actual Bitcoin network, transactions enter a queue called the Mempool before being included in a block. If two transactions referencing the same UTXO arrive at the mempool simultaneously, the node accepts only the first one received and discards the later one (first-seen rule). However, different nodes may receive different transactions first — this is ultimately resolved because only one gets included in a block. The role of PoW we learned in Lesson 5 shines here too.
UTXO by the Numbers
Enough theory. Let's get a sense of the scale at which UTXO operates on the actual Bitcoin network.
| Metric | Value | Meaning |
|---|---|---|
| Total UTXOs | ~180 million (2025) | All outputs not yet spent |
| UTXO set size | ~7 GB | Data a full node must keep in memory |
| Average inputs/TX | 2.1 | Usually combines 2+ UTXOs |
| Average outputs/TX | 2.3 | Recipient + change as the baseline |
| Dust threshold | 546 satoshis | UTXOs smaller than this cost more to send than they're worth |
The concept of dust UTXOs is interesting. Just like a pile of hundreds of pennies where the cost of counting exceeds the value of the coins, a very small UTXO can have a transaction fee larger than the UTXO's value. The Bitcoin network considers outputs below 546 satoshis (approximately 0.00000546 BTC) as "dust" and refuses to relay them.
🤔 Think about it: In Lesson 4, we learned about the fields in the block header. Is UTXO pool information stored in the block header, or somewhere else?
See answer
The UTXO pool itself is not stored in the block header. The UTXO pool is derived state that each node builds locally by processing every transaction in the blockchain. Only transaction data is stored in blocks, and nodes track "which outputs haven't been spent yet" by executing all transactions from the genesis block in order. That said, one of the block header fields we learned about in Lesson 4 is the Merkle root, which is a summary of the transactions in the block — not a summary of the UTXO pool (that's covered in Lesson 8).
Practical Application: "What if You Were a Bitcoin Wallet Developer?"
We've now learned about UTXO structure, change, and double-spend prevention. Let's work through a real-world problem where all this knowledge comes together.
Alice wants to send 0.7 BTC to Bob. Alice's UTXO list:
- UTXO A: 0.3 BTC
- UTXO B: 0.25 BTC
- UTXO C: 0.5 BTC
Which UTXOs would you choose?
| Strategy | Selection | Input Total | Change | UTXO Count Change |
|---|---|---|---|---|
| Minimum count | C + A | 0.8 | 0.1 | 3→2 (−1) |
| Exact amount | A + B + C | 1.05 | 0.35 | 3→2 (−1) |
| Largest first | C + A | 0.8 | 0.1 | 3→2 (−1) |
My choice: UTXO C (0.5) + A (0.3) = 0.8 BTC → change 0.1 BTC. Reasons:
- Two inputs are sufficient, keeping transaction size (in bytes) small → saves on fees
- Small change reduces dust risk
- B (0.25) is left at a size that's convenient to spend individually later
The actual Bitcoin wallet (Bitcoin Core) uses the "Branch and Bound" algorithm to find the optimal UTXO combination. It first looks for a combination with zero change, and if none exists, selects the one with minimum change.
🔨 Project Update
We add UTXO management functionality to the Blockchain class from Lesson 6. The code below is the complete cumulative project up to this point. Comments marked # 🆕 Lesson 7 indicate what's newly added this time.
# pychain.py — Lesson 7 cumulative project
import hashlib
import json
import time
# ───── Transaction-related classes (based on Lesson 3, upgraded for UTXO in Lesson 7) ─────
class TxOutput: # 🆕 Lesson 7
"""Transaction output: recipient address and amount"""
def __init__(self, address, amount):
self.address = address
self.amount = amount
def to_dict(self):
return {"address": self.address, "amount": self.amount}
class TxInput: # 🆕 Lesson 7
"""Transaction input: references a previous UTXO"""
def __init__(self, tx_hash, output_index):
self.tx_hash = tx_hash
self.output_index = output_index
def to_dict(self):
return {"tx_hash": self.tx_hash, "output_index": self.output_index}
class Transaction:
"""UTXO-based transaction (inputs → outputs)"""
def __init__(self, inputs, outputs):
self.inputs = inputs # list of TxInput
self.outputs = outputs # list of TxOutput
self.timestamp = time.time()
self.tx_hash = self.compute_hash()
def to_dict(self):
return {
"inputs": [inp.to_dict() for inp in self.inputs],
"outputs": [out.to_dict() for out in self.outputs],
"timestamp": self.timestamp,
}
def compute_hash(self):
tx_string = json.dumps(self.to_dict(), sort_keys=True)
return hashlib.sha256(tx_string.encode()).hexdigest()
@staticmethod
def create_coinbase(miner_address, reward=50): # 🆕 Lesson 7
"""Coinbase (mining reward) transaction — creates new coins with no inputs"""
return Transaction(
inputs=[],
outputs=[TxOutput(miner_address, reward)],
)
# ───── Block (Lessons 4-5) ─────
class Block:
def __init__(self, index, transactions, prev_hash, nonce=0):
self.index = index
self.transactions = transactions
self.prev_hash = prev_hash
self.nonce = nonce
self.timestamp = time.time()
self.hash = self.compute_hash()
def compute_hash(self):
block_string = json.dumps({
"index": self.index,
"transactions": [tx.to_dict() for tx in self.transactions],
"prev_hash": self.prev_hash,
"nonce": self.nonce,
"timestamp": self.timestamp,
}, sort_keys=True)
return hashlib.sha256(block_string.encode()).hexdigest()
def mine(self, difficulty):
target = "0" * difficulty
while not self.hash.startswith(target):
self.nonce += 1
self.hash = self.compute_hash()
return self.hash
# ───── Blockchain (Lesson 6 + Lesson 7 UTXO extension) ─────
class Blockchain:
def __init__(self, difficulty=2):
self.chain = []
self.difficulty = difficulty
self.pending_transactions = []
self.utxo_pool = {} # 🆕 Lesson 7
self.create_genesis_block()
def create_genesis_block(self):
genesis = Block(0, [], "0")
genesis.mine(self.difficulty)
self.chain.append(genesis)
def get_last_block(self):
return self.chain[-1]
# 🆕 Lesson 7: UTXO-based transaction validation
def validate_transaction(self, tx):
"""Check that input UTXOs are valid and amounts match"""
# Coinbase transactions are valid with no inputs
if not tx.inputs:
return True
input_total = 0
for inp in tx.inputs:
utxo_key = (inp.tx_hash, inp.output_index)
if utxo_key not in self.utxo_pool:
print(f" ⚠️ UTXO not found: {inp.tx_hash[:8]}...:{inp.output_index}")
return False
input_total += self.utxo_pool[utxo_key]["amount"]
output_total = sum(out.amount for out in tx.outputs)
if output_total > input_total:
print(f" ⚠️ Output({output_total}) > Input({input_total})")
return False
return True
def add_transaction(self, transaction):
if not self.validate_transaction(transaction):
print(f"❌ Transaction rejected")
return False
self.pending_transactions.append(transaction)
return True
# 🆕 Lesson 7: Update UTXO pool
def update_utxo_pool(self, tx):
"""Remove spent UTXOs, add new UTXOs"""
for inp in tx.inputs:
utxo_key = (inp.tx_hash, inp.output_index)
if utxo_key in self.utxo_pool:
del self.utxo_pool[utxo_key]
for i, out in enumerate(tx.outputs):
utxo_key = (tx.tx_hash, i)
self.utxo_pool[utxo_key] = {
"address": out.address,
"amount": out.amount,
}
# 🆕 Lesson 7: Query balance
def get_balance(self, address):
"""Balance for an address = sum of UTXOs at that address"""
return sum(
utxo["amount"]
for utxo in self.utxo_pool.values()
if utxo["address"] == address
)
# 🆕 Lesson 7: List UTXOs by address
def get_utxos_for_address(self, address):
"""Return list of UTXOs owned by a specific address"""
result = []
for (tx_hash, idx), utxo in self.utxo_pool.items():
if utxo["address"] == address:
result.append({
"tx_hash": tx_hash,
"output_index": idx,
"amount": utxo["amount"],
})
return result
# 🆕 Lesson 7: Mine (including coinbase TX)
def mine_pending(self, miner_address):
"""Mine pending transactions + coinbase into a block"""
coinbase = Transaction.create_coinbase(miner_address)
all_txs = [coinbase] + self.pending_transactions
last = self.get_last_block()
new_block = Block(
index=last.index + 1,
transactions=all_txs,
prev_hash=last.hash,
)
new_block.mine(self.difficulty)
self.chain.append(new_block)
for tx in all_txs:
self.update_utxo_pool(tx)
self.pending_transactions = []
return new_block
def is_chain_valid(self):
for i in range(1, len(self.chain)):
current = self.chain[i]
previous = self.chain[i - 1]
if current.hash != current.compute_hash():
return False
if current.prev_hash != previous.hash:
return False
if not current.hash.startswith("0" * self.difficulty):
return False
return True
# ═══════════════ Demo ═══════════════
if __name__ == "__main__":
bc = Blockchain(difficulty=2)
print("=" * 55)
print(" PyChain — UTXO Demo (Lesson 7)")
print("=" * 55)
# 1) Alice mines → 50 BTC reward
block1 = bc.mine_pending("Alice")
print(f"\n[Block #{block1.index}] Alice mining complete")
print(f" Alice balance: {bc.get_balance('Alice')} BTC")
# 2) Alice → Bob 30 BTC (change 20 BTC)
alice_utxos = bc.get_utxos_for_address("Alice")
utxo = alice_utxos[0]
tx1 = Transaction(
inputs=[TxInput(utxo["tx_hash"], utxo["output_index"])],
outputs=[
TxOutput("Bob", 30),
TxOutput("Alice", 20), # change!
],
)
bc.add_transaction(tx1)
# 3) Alice mines again → block includes tx1 + 50 BTC reward
block2 = bc.mine_pending("Alice")
print(f"\n[Block #{block2.index}] Alice mines, Bob transfer included")
print(f" Alice balance: {bc.get_balance('Alice')} BTC") # 20 + 50 = 70
print(f" Bob balance: {bc.get_balance('Bob')} BTC") # 30
# 4) Double-spend attempt! (reuse already-spent UTXO)
print(f"\n[Double-spend attempt] Sending to Charlie with already-spent UTXO...")
double_spend = Transaction(
inputs=[TxInput(utxo["tx_hash"], utxo["output_index"])],
outputs=[TxOutput("Charlie", 30)],
)
result = bc.add_transaction(double_spend)
print(f" Charlie balance: {bc.get_balance('Charlie')} BTC")
# 5) Chain validity check
print(f"\nChain validity: {'✅ Valid' if bc.is_chain_valid() else '❌ Invalid'}")
print(f"UTXO pool size: {len(bc.utxo_pool)}")
Expected output:
=======================================================
PyChain — UTXO Demo (Lesson 7)
=======================================================
[Block #1] Alice mining complete
Alice balance: 50 BTC
[Block #2] Alice mines, Bob transfer included
Alice balance: 70 BTC
Bob balance: 30 BTC
[Double-spend attempt] Sending to Charlie with already-spent UTXO...
⚠️ UTXO not found: <first 8 chars of hash>...:0
❌ Transaction rejected
Charlie balance: 0 BTC
Chain validity: ✅ Valid
UTXO pool size: 3
Try running the project you've built so far. Running python pychain.py will show the output above. Try tracing Alice's balance of 70 directly through UTXOs — it's 20 in change plus the 50 second mining reward.
Key Takeaways — 3 Practical Points
-
UTXO = an unspent bill. Balance is counting the bills in your name. There's no "account balance" field anywhere.
-
Create change yourself. Input total − amount sent = change output. Forget it and the difference evaporates as a fee. Don't forget the $8 million incident from the start.
-
Double-spend prevention is one line:
key in dict. If the UTXO is in the pool, it's valid; if not, it's rejected. This simplicity is the design principle that has sustained Bitcoin for 15 years.
Difficulty Fork
🟢 If it was easy
Key takeaways: UTXO is an "unspent transaction output"; balance = sum of UTXOs; change must always be explicitly created; double-spending is blocked by checking UTXO pool existence. In the next lesson, we implement a Merkle tree that summarizes thousands of transactions in a block into a single hash.
🟡 If it was difficult
Let's reframe UTXO using the bill analogy:
- In your wallet: two 50,000 won bills, one 10,000 won bill = 110,000 won total. These are 3 UTXOs.
- Want to pay 70,000 won? Hand over the two 50,000 won bills (2 inputs), receive 30,000 won change (1 change output).
- After payment: the two 50,000 won bills are destroyed, and a new 30,000 won bill is created. The 10,000 won bill is untouched.
- Someone says "I want to use that 50,000 won bill I already destroyed!" → ❌ That bill no longer exists = double-spend blocked.
Extra practice: In the project code above, try adding a transaction where Bob sends 15 BTC to Charlie. Use Bob's 30 BTC UTXO as input, and don't forget to include an output returning 15 BTC in change to Bob!
🔴 Challenge
Implement a UTXO selection algorithm: Add a select_utxos(address, amount) method to the Blockchain class. It should return the minimum number of UTXOs that satisfy the given amount. If there's a combination that exactly matches the amount, prioritize that (zero change is ideal). Hint: the perfectly optimal solution is NP-hard, so a greedy approach (selecting the largest UTXOs first) is sufficient.
Interview question: "Could Ethereum have used the UTXO model? Why did it choose the Account model?" Explain from the perspective of smart contract state management.
In the next lesson, we implement a Merkle Tree that summarizes thousands of transactions in a block into a single hash. When the SHA-256 we learned in Lesson 1 meets a tree structure — we'll uncover the secret of SPV (Simplified Payment Verification).