Proof of Work Implementation: The Principle of Mining 'Gold' by Iterating Through Nonces

Lesson 520min4,189 chars

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:

  1. Add a mine_block(difficulty) method to block.py
  2. Write a mining_experiment.py script that measures mining time by difficulty level 1~5
  3. 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)
TimeMinutes to hoursSeconds
StrategyTrial and error, backtrackingRule 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.

DifficultyRequired leading zerosSuccess probabilityAverage attempts
101/16 ≈ 6.25%~16
2001/256 ≈ 0.39%~256
30001/4,096 ≈ 0.024%~4,096
400001/65,536 ≈ 0.0015%~65,536
5000001/1,048,576~1,048,576
60000001/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:

  1. 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.)
  2. 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 strategyRandom (duplicates possible)Sequential (no duplicates)Sequential (no duplicates)
Difficulty checkfor loop manual countingh[:d] == zeros slicingh.startswith(target)
String creation per iterationNone (but loop cost)New object per h[:d]None (C-level comparison)
ReadabilityLow (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) return True?
  • Is block.nonce a value greater than 0? (if difficulty > 0)
  • Does the next block's prev_hash match the previous block's hash?
  • 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_hash field 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
        ...

Code Playground

Python비트코인에서 `difficulty`는 **타깃 값(target)**으로 변환된다. 해시값이 이 타깃보다 작으면 유효한 블록이다. 우리 미니 블록체인에서는 단순화해서 **"해시 문자열이 '0' × difficulty로 시작하면 성공"**으로 구현한다.
Python핵심 전략은 단순 무식하다: nonce를 0부터 1씩 올리며 해시를 반복 계산하고, 조건에 맞는 해시가 나오면 즉시 멈춘다. 지름길이 없으니 정직하게 노가다하는 수밖에 없다.
Python
Python
Python
Python작업 증명의 핵심 약속을 코드로 증명해보자: **"풀기는 어렵고, 검증은 쉽다."** 채굴과 검증의 소요 시간을 나란히 측정해서 비대칭성이 실제로 얼마나 극단적인지 숫자로 확인한다.
Python비대칭성을 확인했으니, 이번엔 난이도 축을 따라 이동해보자. 이론적으로 매 단계 16배라고 했다. 정말 그런지 난이도 1부터 5까지 실제로 돌려서 눈으로 확인한다.
Python방금 실험에서 봤듯이, 같은 난이도라도 실행할 때마다 걸리는 시간이 들쭉날쭉하다. 그런데 비트코인은 블록 하나가 평균 **10분**에 생성되도록 설계되어 있다. 채굴자가 늘어나서 해시파워가 올라가면? 블록이 10분보다 빨리 만들어진다. 그러면 네트워크가 **2,016블록(약 2주)마다** 난이도를 재조정한다.
Python**`block.py` — 누적 코드 (레슨 1~5)**
Python**`mining_experiment.py` — 이번 레슨의 새 파일**

Q&A