Proof of Work Implementation: The Principle of Mining 'Gold' by Iterating Through Nonces
Learning Objectives
- ✓작업 증명의 '풀기 어렵고 검증 쉽다' 비대칭성을 구체적 수치로 설명할 수 있다
- ✓mine_block() 함수를 구현하여 주어진 난이도 조건을 만족하는 넌스를 탐색할 수 있다
- ✓난이도 값에 따른 채굴 소요 시간 변화를 실험하고 그 결과를 해석할 수 있다
Below is the complete existing lesson with the ❌ WRONG WAY → 🤔 BETTER → ✅ BEST pattern inserted immediately after the mine_block_simple implementation and just before the "Is Verification Really Easy?" section.
Proof of Work Implementation: The Principle of Mining 'Gold' by Spinning the Nonce
In Lesson 4, we completely dissected a block. index, timestamp, prev_hash, nonce, difficulty, merkle_root — we confirmed in code that 6 fields make up the block header, and that prev_hash chains blocks together like links to create immutability. But there was one thing that felt unresolved. We just glossed over nonce=0, difficulty=1. You probably wondered, "What even is this?"
Today we pay that debt. We'll directly implement what role nonce and difficulty play in the Bitcoin network, and why miners around the world are obsessed with finding a single number while burning 150 terawatt-hours of electricity annually.
Today's Mission
Concrete deliverables:
- Add a
mine_block(difficulty)method toblock.py - Write a
mining_experiment.pyscript that measures mining time by difficulty level 1~5 - Be able to explain in one sentence "why Bitcoin mining consumes so much electricity" after seeing the experimental results
What is Proof of Work — The 'Solving a Sudoku' Analogy
When I'm doing Ethereum smart contract audits, clients sometimes ask: "What's the underlying principle that makes blockchain secure?" My answer is always the same: "Because it's insanely hard to solve, but verifying the answer takes just 1 second."
Think of Sudoku. People who've solved an 81-blank Sudoku themselves know — you struggle for tens of minutes, but verifying a completed answer sheet takes only a few seconds to scan the rows, columns, and boxes. Proof of Work is exactly this structure.
| Solving (Mining) | Verification (Node) | |
|---|---|---|
| Time | Minutes to hours | Seconds |
| Strategy | Trial and error, backtracking | Rule checking |
| Asymmetry | ✅ Extreme | ✅ Extreme |
But there's one aspect far more powerful than Sudoku. The core property of SHA-256 we learned in Lesson 1 — one-way function. You can't reverse-engineer the original from the hash value. Thanks to this property, the problem "find a hash satisfying a specific condition" has no mathematical shortcut — only brute force. With Sudoku, skill makes you faster, but with SHA-256, even a genius has the same probability. This is the essence of Proof of Work.
That's the entire loop. Miners keep changing nonce — 0, 1, 2, 3... — and compute the hash, and when the condition is satisfied, they announce "I found it!" to the network. Other nodes plug in that nonce, compute the hash exactly once, and confirm "looks right."
🤔 Think about it: Among the 5 properties of SHA-256 from Lesson 1 (determinism, fast computation, pre-image resistance, small change → big change, collision resistance), which is the core property that makes Proof of Work possible?
See answer
Pre-image Resistance. Because you can't reverse-compute the input from the hash output, the problem "find a nonce that produces a hash with 4 leading zeros" can only be solved through trial and error, with no mathematical shortcut. If you could compute the pre-image? Mining would take 1 millisecond and Proof of Work would lose its meaning.
Additionally, determinism is also important. The same input must always produce the same hash for verification to be possible.
The Relationship Between Difficulty Target and Leading Zeros
Let's unpack the statement "there must be n zeros at the front of the hash value" more precisely.
In Bitcoin, difficulty is converted to a target value. If the hash value is less than this target, it's a valid block. In our mini blockchain, we simplify it to: "if the hash string starts with '0' × difficulty, it's a success."
# Intuitively understanding the relationship between difficulty and target
difficulty = 4
target_prefix = "0" * difficulty # "0000"
# Valid hash example
valid_hash = "0000a3f2b1c8e9d7..." # ✅ Starts with "0000"
invalid_hash = "0003a3f2b1c8e9d7..." # ❌ Starts with "0003" — only 3 zeros
print(valid_hash.startswith(target_prefix)) # True
print(invalid_hash.startswith(target_prefix)) # False
# Output:
# True
# False
Let's work out the probability directly. Each hexadecimal digit in a SHA-256 hash has 16 possible values from 0 to f. The probability that the first digit is 0 is 1/16.
| Difficulty | Required leading zeros | Success probability | Average attempts |
|---|---|---|---|
| 1 | 0 | 1/16 ≈ 6.25% | ~16 |
| 2 | 00 | 1/256 ≈ 0.39% | ~256 |
| 3 | 000 | 1/4,096 ≈ 0.024% | ~4,096 |
| 4 | 0000 | 1/65,536 ≈ 0.0015% | ~65,536 |
| 5 | 00000 | 1/1,048,576 | ~1,048,576 |
| 6 | 000000 | 1/16,777,216 | ~16,777,216 |
Every time difficulty increases by 1, the average number of attempts increases by 16x. That's exponential growth. Imagine a backpack where the weight multiplies by 16 every time you climb one step. At current Bitcoin mainnet difficulty, an average of tens of trillions of hash operations are needed to find a valid hash.
🤔 Think about it: If mining at difficulty 4 requires an average of 65,536 attempts, will you definitely find it by the 65,536th attempt?
See answer
No. 65,536 is the average (expected value), not a guarantee. Since hash function output is essentially random, you might get lucky and find it on the 3rd attempt, or you might need 200,000 tries. This is like flipping a coin — heads appears on average every 2 flips, but you can get 10 consecutive tails. Mining is fundamentally a probability game.
Implementing the mine_block() Algorithm
Enough theory. Let's go to code. Do you remember the calculate_hash() method of the Block class from Lesson 4? It was the one that combined all the block's fields into a string and computed the SHA-256 hash. Now we add mine_block() on top of that.
The core strategy is simple and blunt: increment nonce from 0 by 1 while repeatedly computing the hash, and stop immediately when a matching hash appears. There's no shortcut, so we grind honestly.
import hashlib
import time
def mine_block_simple(block_data: str, difficulty: int) -> tuple:
"""
The simplest form of Proof of Work function.
block_data: Block header information (string)
difficulty: Number of leading zeros
Returns: (successful nonce, hash value, number of attempts)
"""
target = "0" * difficulty # Goal: hash prefix starts with this string
nonce = 0
start_time = time.time()
while True:
# Append nonce to data and compute hash
text = f"{block_data}{nonce}"
hash_result = hashlib.sha256(text.encode()).hexdigest()
if hash_result.startswith(target):
elapsed = time.time() - start_time
print(f"✅ Mining success!")
print(f" nonce: {nonce}")
print(f" hash: {hash_result}")
print(f" attempts: {nonce + 1}")
print(f" elapsed: {elapsed:.4f}s")
return nonce, hash_result, nonce + 1
nonce += 1
# Run mining with difficulty 4
nonce, hash_val, attempts = mine_block_simple("Block5Data", 4)
# Output (different every run — it's probabilistic):
# ✅ Mining success!
# nonce: 52873
# hash: 0000a8c3f2e1b9d4...
# attempts: 52874
# elapsed: 0.0832s
Running it yourself makes it tangible. difficulty=4 means tens of thousands of attempts, difficulty=6 means millions. On my MacBook, running difficulty=6 takes roughly 10~30 seconds. On the Bitcoin mainnet, tens of thousands of dedicated ASIC chips are performing this work simultaneously.
❌→🤔→✅ How Should We Write the Difficulty Validation Logic?
When implementing mine_block(), there's an inevitable question: "How do we check whether a hash satisfies the difficulty condition?" Even for the same result, performance and readability vary greatly depending on the implementation approach. Let's compare the three stages beginners commonly go through.
❌ WRONG WAY: Random nonce + manual zero counting
import hashlib
import random
def mine_wrong(block_data: str, difficulty: int):
"""❌ Two mistakes at once: random nonce + inefficient validation"""
nonce = 0
while True:
nonce = random.randint(0, 10_000_000) # Random selection
text = f"{block_data}{nonce}"
h = hashlib.sha256(text.encode()).hexdigest()
# Loop to count leading zeros — traverses up to 64 chars per hash
leading_zeros = 0
for char in h:
if char == "0":
leading_zeros += 1
else:
break
if leading_zeros >= difficulty:
return nonce, h
Two problems:
- Random nonce — the same value can be tried multiple times. The theoretical average at difficulty 4 is 65,536 attempts, but random selection repeatedly picks already-tried values, wasting effort. (By the birthday paradox, duplicates start appearing after roughly 5,000 draws.)
- Manual for loop — traverses the string character by character counting zeros for every hash. It finishes quickly when difficulty is low (1~2 leading zeros), but unnecessary iteration and variable management complicate the code.
🤔 BETTER: Sequential nonce + slicing comparison
import hashlib
def mine_better(block_data: str, difficulty: int):
"""🤔 Works but has room for improvement"""
nonce = 0
zeros = "0" * difficulty # Build target string once upfront ✅
while True:
text = f"{block_data}{nonce}"
h = hashlib.sha256(text.encode()).hexdigest()
# Slicing comparison — works but creates a new string object each time
if h[:difficulty] == zeros:
return nonce, h
nonce += 1 # Sequential increment — no duplicates ✅
Improvements: Eliminates duplicate attempts with sequential nonce, replaces for loop with slicing comparison.
Shortcoming: h[:difficulty] creates a new string object every iteration. With millions of iterations, these tiny overheads accumulate. Also, if difficulty exceeds the hash length (64), it may behave unexpectedly.
✅ BEST: Sequential nonce + startswith()
import hashlib
def mine_best(block_data: str, difficulty: int):
"""✅ Clean and efficient implementation"""
target = "0" * difficulty # Create target only once, outside the loop
nonce = 0
while True:
text = f"{block_data}{nonce}"
h = hashlib.sha256(text.encode()).hexdigest()
if h.startswith(target): # Direct comparison without creating a new string
return nonce, h
nonce += 1
Why this is best:
| Comparison | ❌ WRONG | 🤔 BETTER | ✅ BEST |
|---|---|---|---|
| nonce strategy | Random (duplicates possible) | Sequential (no duplicates) | Sequential (no duplicates) |
| Difficulty check | for loop manual counting | h[:d] == zeros slicing | h.startswith(target) |
| String creation per iteration | None (but loop cost) | New object per h[:d] | None (C-level comparison) |
| Readability | Low (7 lines) | Medium (4 lines) | High (2 lines) |
| Intent communication | "Counting zeros" | "Cutting and comparing the prefix" | "Checking if it starts with this string" |
startswith() uses memcmp implemented in C inside CPython, directly comparing memory without creating a new string object. With millions of loop iterations, this difference shows up as tangible performance. More importantly, the code says exactly what it means — "does the hash start with the target?" is expressed in a single line of code.
💡 Remember: In Python, always use
startswith()when checking "does a string begin with a specific prefix?" The intent is clearer than slicing, and it's more efficient internally. This is a principle that applies to all Python code, not just mining.
Is Verification Really Easy? — The Asymmetry Experiment
Let's prove the core promise of Proof of Work in code: "Hard to solve, easy to verify." We measure mining and verification time side by side to confirm numerically just how extreme the asymmetry actually is.
import hashlib
import time
def verify_pow(block_data: str, nonce: int, difficulty: int) -> bool:
"""Verify mining result — just compute the hash exactly 1 time"""
target = "0" * difficulty
text = f"{block_data}{nonce}"
hash_result = hashlib.sha256(text.encode()).hexdigest()
return hash_result.startswith(target)
# Mining: tens of thousands of attempts
block_data = "Lesson5TestBlock"
start = time.time()
nonce = 0
target = "0" * 5
while True:
text = f"{block_data}{nonce}"
h = hashlib.sha256(text.encode()).hexdigest()
if h.startswith(target):
break
nonce += 1
mining_time = time.time() - start
# Verification: exactly 1 time
start = time.time()
is_valid = verify_pow(block_data, nonce, 5)
verify_time = time.time() - start
print(f"⛏️ Mining time: {mining_time:.4f}s (nonce: {nonce})")
print(f"✅ Verification time: {verify_time:.8f}s")
print(f"📊 Ratio: Mining took {mining_time/max(verify_time, 1e-9):.0f}x longer than verification")
print(f"🔍 Verification result: {is_valid}")
# Example output:
# ⛏️ Mining time: 1.2847s (nonce: 834291)
# ✅ Verification time: 0.00000215s
# 📊 Ratio: Mining took 597535x longer than verification
# 🔍 Verification result: True
A difference of over 500,000x. This is asymmetry. The digital signature from Lesson 2 had the same structure — signing with the private key, verification with the public key. Asymmetry is the design philosophy that permeates the entire blockchain.
Difficulty-Based Mining Time Measurement Experiment
Now that we've confirmed the asymmetry, let's move along the difficulty axis. In theory, it should be 16x at each step. Let's run it from difficulty 1 through 5 and see with our own eyes.
import hashlib
import time
def mining_experiment(block_data: str, max_difficulty: int = 5):
"""Experiment to measure mining time by difficulty"""
print("=" * 60)
print("⛏️ Mining Experiment by Difficulty")
print("=" * 60)
results = []
for diff in range(1, max_difficulty + 1):
target = "0" * diff
nonce = 0
start = time.time()
while True:
text = f"{block_data}{nonce}"
h = hashlib.sha256(text.encode()).hexdigest()
if h.startswith(target):
break
nonce += 1
elapsed = time.time() - start
results.append({
"difficulty": diff,
"nonce": nonce,
"attempts": nonce + 1,
"time": elapsed,
"hash": h[:20] + "..."
})
print(f"\nDifficulty {diff} | Target: {'0' * diff + '*' * (4 - diff)}")
print(f" nonce : {nonce:>10,}")
print(f" attempts : {nonce + 1:>10,}")
print(f" elapsed : {elapsed:>10.4f}s")
print(f" hash : {h[:20]}...")
# Multiplier comparison
print("\n" + "=" * 60)
print("📊 Time multiplier with increasing difficulty")
print("=" * 60)
for i in range(1, len(results)):
prev_time = max(results[i-1]["time"], 1e-9)
ratio = results[i]["time"] / prev_time
print(f" Difficulty {results[i-1]['difficulty']}→{results[i]['difficulty']}: "
f"~{ratio:.1f}x increase")
# Run experiment
mining_experiment("CapstoneProjectBlock", 5)
# Example output (differs each run):
# ============================================================
# ⛏️ Mining Experiment by Difficulty
# ============================================================
#
# Difficulty 1 | Target: 0***
# nonce : 3
# attempts : 4
# elapsed : 0.0000s
# hash : 07a3b2c1e9f8d4a5...
#
# Difficulty 2 | Target: 00**
# nonce : 178
# attempts : 179
# elapsed : 0.0003s
# hash : 00f2a1b3c4d5e6f7...
#
# Difficulty 3 | Target: 000*
# nonce : 2,841
# attempts : 2,842
# elapsed : 0.0043s
# hash : 000a9b8c7d6e5f4a...
#
# Difficulty 4 | Target: 0000
# nonce : 58,392
# attempts : 58,393
# elapsed : 0.0891s
# hash : 0000c3d2e1f0a9b8...
#
# Difficulty 5 | Target: 00000
# nonce : 743,218
# attempts : 743,219
# elapsed : 1.1234s
# hash : 00000f1e2d3c4b5a...
#
# ============================================================
# 📊 Time multiplier with increasing difficulty
# ============================================================
# Difficulty 1→2: ~7.5x increase
# Difficulty 2→3: ~14.3x increase
# Difficulty 3→4: ~20.7x increase
# Difficulty 4→5: ~12.6x increase
In theory it should be 16x at each step, but since it's probabilistic there's variance. Run it multiple times and you'll see the average converge to 16x. This is exactly why Bitcoin mining consumes so much electricity. As difficulty increases, the number of attempts explodes exponentially.
🤔 Think about it: If we ran difficulty 6 in the experiment above, roughly how many seconds would it take? If difficulty 5 was about 1 second?
See answer
Theoretically about 16 seconds (1 second × 16). But with probabilistic variance, it could land anywhere between 5 and 40 seconds. Try it yourself — mining_experiment("data", 6). Note that difficulty 7 and above can take several minutes.
Bitcoin's 10-Minute Block Interval and Difficulty Adjustment
As we saw in the experiment, even at the same difficulty, the time taken varies from run to run. Yet Bitcoin is designed so that one block is generated on average every 10 minutes. What if more miners join and hashpower increases? Blocks get created faster than 10 minutes. Then the network readjusts difficulty every 2,016 blocks (roughly 2 weeks).
# Simplified version of Bitcoin's difficulty adjustment algorithm
def adjust_difficulty(
current_difficulty: int,
actual_time_for_2016_blocks: float, # in seconds
target_time: float = 2016 * 10 * 60 # 2,016 × 10 min = 1,209,600s
) -> int:
"""
Core logic of actual Bitcoin difficulty adjustment (simplified).
actual_time < target_time → too fast → difficulty UP
actual_time > target_time → too slow → difficulty DOWN
"""
ratio = actual_time_for_2016_blocks / target_time
# Bitcoin doesn't allow more than 4x change at once
if ratio < 0.25:
ratio = 0.25
elif ratio > 4.0:
ratio = 4.0
new_difficulty = int(current_difficulty / ratio)
print(f"Current difficulty: {current_difficulty}")
print(f"2016 blocks took: {actual_time_for_2016_blocks/86400:.1f} days "
f"(target: {target_time/86400:.1f} days)")
print(f"Ratio: {ratio:.2f} ({'too fast ↑' if ratio < 1 else 'too slow ↓'})")
print(f"New difficulty: {new_difficulty}")
return new_difficulty
# Scenario 1: Miners flood in and blocks are generated quickly
print("=== Scenario 1: Miner surge ===")
adjust_difficulty(
current_difficulty=1000,
actual_time_for_2016_blocks=7 * 86400 # 2016 blocks completed in 7 days (target: 14 days)
)
print()
# Scenario 2: Miners leave and block generation slows
print("=== Scenario 2: Miner exodus ===")
adjust_difficulty(
current_difficulty=1000,
actual_time_for_2016_blocks=28 * 86400 # Took 28 days (target: 14 days)
)
# Output:
# === Scenario 1: Miner surge ===
# Current difficulty: 1000
# 2016 blocks took: 7.0 days (target: 14.0 days)
# Ratio: 0.50 (too fast ↑)
# New difficulty: 2000
#
# === Scenario 2: Miner exodus ===
# Current difficulty: 1000
# 2016 blocks took: 28.0 days (target: 14.0 days)
# Ratio: 2.00 (too slow ↓)
# New difficulty: 500
I find this automatic adjustment mechanism beautiful. Without a central administrator, using purely mathematics and consensus rules, it keeps the network speed constant. Like a thermostat that alternates between heating and cooling to hold room temperature at 22°C, the Bitcoin network raises and lowers difficulty itself to maintain the 10-minute rhythm. This is why I still respect Bitcoin's design even after moving to Ethereum PoS.
Do you remember from Lesson 3 when we compared transactions to 'digital checks'? The rhythm that puts those checks into blocks and 'notarizes' them every 10 minutes is created by exactly this difficulty adjustment.
🔨 Project Update
Theory, experiment, and verification are all done — let's consolidate the code we've built so far. We add the mine_block() implemented today to the block.py from through Lesson 4.
block.py — Cumulative code (Lessons 1~5)
# block.py — Mini blockchain project: cumulative code through Lesson 5
import hashlib
import time
from datetime import datetime
class Block:
"""Class representing a single block"""
def __init__(self, index: int, transactions: list, prev_hash: str,
difficulty: int = 1):
self.index = index # Block number
self.timestamp = datetime.now().isoformat() # Creation time
self.transactions = transactions # Transaction list
self.prev_hash = prev_hash # Previous block hash
self.difficulty = difficulty # Mining difficulty
self.nonce = 0 # Nonce for proof of work
self.hash = self.calculate_hash() # Current block hash
def calculate_hash(self) -> str:
"""Combines all block fields and computes SHA-256 hash"""
block_string = (
f"{self.index}"
f"{self.timestamp}"
f"{self.transactions}"
f"{self.prev_hash}"
f"{self.difficulty}"
f"{self.nonce}"
)
return hashlib.sha256(block_string.encode()).hexdigest()
def mine_block(self, difficulty: int = None) -> str:
"""
Proof of Work — finds a hash satisfying the given difficulty condition.
If difficulty is not specified, uses self.difficulty.
Returns: the hash value that succeeded in mining
"""
if difficulty is not None:
self.difficulty = difficulty
target = "0" * self.difficulty
start_time = time.time()
attempts = 0
while not self.hash.startswith(target):
self.nonce += 1
self.hash = self.calculate_hash()
attempts += 1
elapsed = time.time() - start_time
print(f"⛏️ Block #{self.index} mined!")
print(f" difficulty: {self.difficulty} (target: {target}...)")
print(f" nonce: {self.nonce:,}")
print(f" attempts: {attempts:,}")
print(f" elapsed: {elapsed:.4f}s")
print(f" hash: {self.hash}")
return self.hash
def __repr__(self) -> str:
return (f"Block(index={self.index}, hash={self.hash[:16]}..., "
f"nonce={self.nonce}, difficulty={self.difficulty})")
def create_genesis_block(difficulty: int = 1) -> Block:
"""Create the genesis block (first block)"""
genesis = Block(
index=0,
transactions=["Genesis Block"],
prev_hash="0" * 64,
difficulty=difficulty
)
genesis.mine_block()
return genesis
def create_next_block(prev_block: Block, transactions: list,
difficulty: int = 1) -> Block:
"""Create and mine a new block following the previous block"""
new_block = Block(
index=prev_block.index + 1,
transactions=transactions,
prev_hash=prev_block.hash,
difficulty=difficulty
)
new_block.mine_block()
return new_block
def verify_pow(block: Block) -> bool:
"""Verify the block's proof of work — compute the hash only once"""
target = "0" * block.difficulty
recalculated = block.calculate_hash()
return (recalculated == block.hash and
recalculated.startswith(target))
if __name__ == "__main__":
# Quick test: mine 3 blocks at difficulty 3
print("=" * 50)
print("Mini Blockchain — PoW Test")
print("=" * 50)
difficulty = 3
genesis = create_genesis_block(difficulty)
print(f"\nVerification: {verify_pow(genesis)}")
block1 = create_next_block(genesis, ["Alice→Bob: 5 BTC"], difficulty)
print(f"\nVerification: {verify_pow(block1)}")
block2 = create_next_block(block1, ["Bob→Charlie: 2 BTC"], difficulty)
print(f"\nVerification: {verify_pow(block2)}")
# Chain linkage check
print("\n" + "=" * 50)
print("Chain Linkage Check")
print("=" * 50)
print(f"Block 1 prev_hash == genesis hash? "
f"{block1.prev_hash == genesis.hash}")
print(f"Block 2 prev_hash == block 1 hash? "
f"{block2.prev_hash == block1.hash}")
mining_experiment.py — New file for this lesson
# mining_experiment.py — Mining time measurement experiment by difficulty
import time
from block import Block
def run_experiment(max_difficulty: int = 5, trials: int = 3):
"""
Measures average time and attempt count by running multiple mining
attempts at each difficulty level.
trials: number of repetitions per difficulty level (for averaging)
"""
print("=" * 65)
print(f"⛏️ Mining Experiment — Difficulty 1~{max_difficulty}, {trials} trials each")
print("=" * 65)
for diff in range(1, max_difficulty + 1):
times = []
attempts_list = []
for trial in range(trials):
block = Block(
index=trial,
transactions=[f"Test transaction {trial}"],
prev_hash="0" * 64,
difficulty=diff
)
target = "0" * diff
start = time.time()
nonce = 0
h = block.calculate_hash()
while not h.startswith(target):
block.nonce += 1
h = block.calculate_hash()
nonce += 1
elapsed = time.time() - start
times.append(elapsed)
attempts_list.append(nonce)
avg_time = sum(times) / len(times)
avg_attempts = sum(attempts_list) / len(attempts_list)
print(f"\n📊 Difficulty {diff}")
print(f" Avg attempts : {avg_attempts:>12,.0f}")
print(f" Theoretical expected: {16**diff:>12,}")
print(f" Avg elapsed : {avg_time:>12.4f}s")
if diff > 1:
prev_avg = sum(times) / len(times) # Using current value is meaningless here
print(f" Multiplier vs prev : theoretically 16x")
print("\n" + "=" * 65)
print("💡 Conclusion: Every 1 increase in difficulty multiplies mining time by ~16x.")
print(" This is why Bitcoin mining requires enormous electricity.")
print("=" * 65)
if __name__ == "__main__":
run_experiment(max_difficulty=5, trials=3)
Try running the project we've built so far:
# 1. Standalone test of block.py (difficulty 3)
python block.py
# 2. Mining experiment (compare difficulty 1~5)
python mining_experiment.py
Expected output (python block.py):
==================================================
Mini Blockchain — PoW Test
==================================================
⛏️ Block #0 mined!
difficulty: 3 (target: 000...)
nonce: 1,847
attempts: 1,847
elapsed: 0.0031s
hash: 000a7b3c...
Verification: True
⛏️ Block #1 mined!
difficulty: 3 (target: 000...)
nonce: 3,291
attempts: 3,291
elapsed: 0.0052s
hash: 0004e2f1...
Verification: True
⛏️ Block #2 mined!
difficulty: 3 (target: 000...)
nonce: 5,103
attempts: 5,103
elapsed: 0.0078s
hash: 000b1c9d...
Verification: True
==================================================
Chain Linkage Check
==================================================
Block 1 prev_hash == genesis hash? True
Block 2 prev_hash == block 1 hash? True
Common Mistakes and Self-Check Checklist
Three mistakes I've personally made or frequently seen in code reviews:
❌ Mistake 1: Not updating the hash inside mine_block()
# Wrong code
def mine_block_wrong(self):
while not self.hash.startswith("0" * self.difficulty):
self.nonce += 1
# self.hash = self.calculate_hash() ← missing this line!
# Falls into an infinite loop
❌ Mistake 2: Not including nonce in the hash calculation
# If self.nonce is omitted from calculate_hash()
# changing nonce won't change the hash → infinite loop
❌ Mistake 3: Not saving the final hash after mining
# Missing self.hash = self.calculate_hash() at the end of mine_block()
# → prev_hash mismatch during later chain verification
All three mistakes have the same symptom — either an infinite loop, or the chain breaks. When writing mine_block(), the three beats "change nonce → recompute hash → save hash" must always move together as one set.
Self-check checklist:
- After calling
mine_block(), does the block's hash start with"0" × difficulty? - Does
verify_pow(block)returnTrue? - Is
block.noncea value greater than 0? (if difficulty > 0) - Does the next block's
prev_hashmatch the previous block'shash? - Can calling
mine_block()twice on the same block produce a different nonce? (It shouldn't — it must be deterministic)
🔍 Deep dive: Does Bitcoin really set difficulty by counting leading zeros?
No. Real Bitcoin uses a 256-bit target value. It interprets the hash value as a 256-bit integer and checks whether it's below the target. "Leading zeros" is an approximate description.
# Implementation closer to actual Bitcoin
target = 2 ** (256 - difficulty_bits) # higher difficulty_bits → lower target
hash_int = int(hash_hex, 16)
is_valid = hash_int < target
This approach allows for more fine-grained difficulty adjustment. Our "number of leading zeros" approach can only adjust in 16x increments, but Bitcoin's approach can adjust down to 0.01% increments. The principle is still the same — the hash must be less than a certain threshold.
A Higher Perspective: How a Senior Engineer Sees It
Things I've come to realize working in the Ethereum ecosystem:
1. PoW is a "fair lottery." A miner with 10% of the hashpower finds roughly 10% of the blocks. This connects to the transaction signing from Lesson 3 — signing proves "who sent it," and PoW proves "who has the right to create this block."
2. In production, it's not simple leading zeros. As covered in the deep dive above, the actual comparison is hash_int < target. Knowing this difference is what separates junior from senior in interviews.
3. The true purpose of PoW is to prove "time." The reason Satoshi Nakamoto was brilliant — PoW is not merely spam prevention; it's physical evidence that approximately 10 minutes of computational resources were invested in creating this block. The blockchain encodes time as a hash.
4. The transition to Proof of Stake. Ethereum switched from PoW to PoS in 2022. Energy consumption decreased by 99.95%. The right order is to understand PoW's security model before learning PoS. Having implemented PoW today, you now have that foundation.
📝 Comprehensive Quiz
The quiz below covers not only this lesson but also key concepts from previous lessons.
Q1: How many hash operations are needed on average to find a valid hash at difficulty 3?
See answer
About 4,096 (16³ = 4,096). The probability that each hex digit is 0 is 1/16, and all 3 digits must be 0, so (1/16)³ = 1/4,096.
Q2: (Lesson 1 review) Which property of SHA-256 makes PoW "hard to solve"?
See answer
Pre-image Resistance. Because you can't reverse-compute the input (nonce) from a desired output (hash with n leading zeros), only brute force is possible.
Q3: (Lesson 4 review) What is the relationship between the
prev_hashfield and PoW?
See answer
Tampering with a block changes its hash, which creates a mismatch with the next block's prev_hash. To make the tampered block valid, PoW must be redone for that block and every subsequent block — which is practically impossible, making the blockchain immutable.
Summary Diagram
In the next lesson, we'll chain the blocks we built today into a real chain. We'll create a Blockchain class to automate block addition, verification, and tamper detection, and directly simulate how PoW guarantees immutability at the chain level.
Closing by Difficulty
🟢 If it was easy
Just the key takeaways:
- Proof of Work = finding a nonce that produces a hash with n leading zeros
- Asymmetry = mining requires tens of thousands of attempts, verification takes 1
- Difficulty +1 → ~16x more time (exponential growth)
mine_block()is a while loop + nonce increment + hash recomputation
In the next lesson we'll chain these blocks together and verify their integrity.
🟡 If it was difficult
Let me explain Proof of Work with a different analogy.
Think of guessing a combination lock. You have a 4-digit lock (0000~9999). There's no hint; you have to try each one. On average, you'd need about 5,000 attempts to open it. But if someone says "the answer is 3847"? Dial to 3847 and pull once, and you're done.
That's all PoW is:
- The combination = nonce
- "Opened/didn't open" = does the hash start with zeros?
- Increasing digits = increasing difficulty (4 digits → 5 digits is 10x harder)
Extra practice: Run mine_block_simple("yourname", 2) and mine_block_simple("yourname", 3) separately and record the difference in attempt counts.
🔴 Challenge
Interview question: "An attacker wants to tamper with block 100. The current chain goes up to block 200, and difficulty is 4. If the attacker has 30% less hashpower than the honest network, can they realistically re-mine blocks 100~200?"
Hint: Each block requires an average of 65,536 hash operations to mine, and while re-mining 100 blocks you must simultaneously catch up to the honest chain. Think about the probability with inferior hashpower.
Production challenge: Add a timeout feature to mine_block(). If mining fails within a specified number of seconds, return None. In real mining pools, this kind of timeout logic matters.
# Skeleton:
def mine_block_with_timeout(self, difficulty, timeout_seconds=60):
start = time.time()
while ...:
if time.time() - start > timeout_seconds:
return None # Timeout
...