If you want a high-level overview before diving into code, read Bitcoin in Brief: How It Works, Why It Matters, and What Comes Next.
This tutorial implements a simplified blockchain for educational purposes. Production blockchains require Merkle trees, digital signatures, P2P networking, and careful serialization. Do not use this code for real cryptocurrency development.
What Is a Blockchain?#
A blockchain is an append-only linked list of blocks, where each block contains:
- A set of data (transactions, messages, anything).
- The cryptographic hash of the previous block.
- A timestamp and a nonce used for mining.
The key invariant is: changing any past block invalidates all subsequent blocks, because each block’s hash depends on its predecessor. This makes the chain tamper-evident without requiring a central authority.
Step 1: Cryptographic Hashing#
Before building blocks, we need to understand the primitive that makes everything work: the cryptographic hash function.
A hash function maps arbitrary data to a fixed-size digest with three critical properties:
- Deterministic: the same input always produces the same output.
- One-way: given a digest, you cannot recover the input.
- Avalanche effect: a tiny change in the input completely changes the digest.
Bitcoin block headers are hashed with double-SHA-256 in consensus rules. For tutorial simplicity, we use a single SHA-256 in examples:
import hashlib
def sha256(data: str) -> str:
return hashlib.sha256(data.encode()).hexdigest()
print(sha256("hello"))
# => 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
print(sha256("hello!"))
# => ce06092fb948d9af6d6634baab36bdb1e8fa05bb53c98284aaf24f8282fee43cOne extra exclamation mark completely changes the output. This is the avalanche effect in action.
Step 2: The Block Structure#
A block is a container for data, linked to the previous block through its hash.
import hashlib
import json
import time
from dataclasses import dataclass, field
@dataclass
class Block:
index: int
timestamp: float
data: list[dict]
previous_hash: str
nonce: int = 0
hash: str = field(default="", init=False)
def __post_init__(self) -> None:
if not self.hash:
self.hash = self.compute_hash()
def compute_hash(self) -> str:
"""
Computes the SHA-256 hash of the block contents.
⚠️ Note: For production systems, never rely on JSON serialization
for cryptographic hashing due to floating-point inconsistencies or
formatting differences across languages. Use canonical binary
representations (CBOR, Protobuf) instead.
"""
block_content = {
"index": self.index,
"timestamp": self.timestamp,
"data": self.data,
"previous_hash": self.previous_hash,
"nonce": self.nonce,
}
# sort_keys=True ensures deterministic output regardless of dict insertion order
return hashlib.sha256(
json.dumps(block_content, sort_keys=True, separators=(',', ':')).encode()
).hexdigest()
def mine(self, difficulty: int) -> None:
target = "0" * difficulty
while not self.hash.startswith(target):
self.nonce += 1
self.hash = self.compute_hash()
# Note: In production, add progress logging or async yielding hereNotice that previous_hash is part of the hashed content. This is what chains blocks together: each block cryptographically commits to its entire history.
Step 3: The Genesis Block#
The first block in a chain has no predecessor. By convention it references a well-known constant as its previous_hash.
Bitcoin’s actual genesis block (Jan 3, 2009) contains a newspaper headline about bank bailouts and was mined by Satoshi Nakamoto. We use “0” * 64 here for tutorial simplicity.
def create_genesis_block() -> Block:
return Block(
index=0,
timestamp=int(time.time()),
data=[{
"message": "The Times 03/Jan/2009 Chancellor on brink of second bailout for banks"
}],
previous_hash="0" * 64,
)
genesis = create_genesis_block()
print(genesis.hash)
# => some sha256 digest starting with zeros after miningStep 4: Building the Chain#
A Blockchain class holds the list of blocks and enforces the linking rule when adding new ones.
class Blockchain:
def __init__(self, difficulty: int = 4) -> None:
self.difficulty = difficulty
self.chain: list[Block] = []
self._add_genesis()
def _add_genesis(self) -> None:
genesis = create_genesis_block()
genesis.mine(self.difficulty)
self.chain.append(genesis)
@property
def last_block(self) -> Block:
return self.chain[-1]
def add_block(self, data: list[dict]) -> Block:
block = Block(
index=len(self.chain),
timestamp=int(time.time()), # Use int() to avoid float precision issues
data=data,
previous_hash=self.last_block.hash,
)
block.mine(self.difficulty)
self.chain.append(block)
return block
def is_valid(self) -> bool:
"""
Validates the entire chain:
1. Each block's hash matches recomputed hash
2. Each block correctly references its predecessor
3. Proof of Work is valid (hash starts with required zeros)
"""
target = "0" * self.difficulty
for i in range(1, len(self.chain)):
cur = self.chain[i]
prev = self.chain[i - 1]
# Check hash integrity
if cur.hash != cur.compute_hash():
return False
# Check chain linkage
if cur.previous_hash != prev.hash:
return False
# Check proof of work
if not cur.hash.startswith(target):
return False
return TrueLet’s test the tamper-evidence property:
bc = Blockchain(difficulty=4)
bc.add_block([{"from": "alice", "to": "bob", "amount": 10}])
bc.add_block([{"from": "bob", "to": "carol", "amount": 3}])
print(bc.is_valid()) # True
# Tamper with block 1 (direct modification bypasses mining!)
bc.chain[1].data = [{"from": "alice", "to": "eve", "amount": 100}]
print(bc.is_valid()) # False — hash mismatch detected immediatelyStep 5: Proof of Work#
The chain above is tamper-evident but not tamper-resistant: an attacker can simply recompute all hashes after tampering. This is where proof of work comes in.
The idea: require that a valid block hash must start with a certain number of zeros. This is called the difficulty target. To satisfy it, a miner must increment a nonce field until the hash meets the condition. This is cheap to verify but expensive to compute.
def mine_block(block: Block, difficulty: int) -> Block:
target = "0" * difficulty
while not block.hash.startswith(target):
block.nonce += 1
block.hash = block.compute_hash()
return blockWith difficulty = 4, a valid hash must start with 0000. Because SHA-256 output is effectively random, finding such a nonce requires, on average, $16^4 = 65,536$ attempts.
To keep the example aligned with that estimate, we mine with difficulty 4 below.
import time
block = Block(
index=1,
timestamp=int(time.time()),
data=[{"from": "alice", "to": "bob", "amount": 10}],
previous_hash="0" * 64,
)
start = time.time()
block.mine(difficulty=4)
elapsed = time.time() - start
print(f"Nonce: {block.nonce}")
print(f"Hash: {block.hash}")
print(f"Time: {elapsed:.3f}s")In Bitcoin, the difficulty is recalibrated every 2016 blocks (roughly two weeks) so that the average block time stays near 10 minutes regardless of how much total hash power is on the network.
Step 6: Why Proof of Work Secures History#
Suppose an attacker wants to rewrite block 3 in a chain of 10 blocks. They must:
- Tamper with block 3’s data.
- Re-mine block 3 to find a valid nonce.
- Re-mine blocks 4 through 10, because each one references the hash of its predecessor.
Meanwhile, the honest network keeps extending the chain. The attacker must outpace all honest miners combined. At global Bitcoin scale, this would require more than 50% of the world’s total SHA-256 hash power: the so-called 51% attack. At current network hash rates, sustaining such an attack is extraordinarily expensive and operationally difficult.
Step 7: Distributed Consensus#
A blockchain only achieves its security guarantees when it is distributed across many independent nodes. Each node holds a full copy of the chain and verifies every incoming block.
In practice, nodes follow the valid chain with the most accumulated proof-of-work (often approximated as the longest chain when difficulty is stable).
The consensus rule is simple: competing chains are resolved by accumulating the most cumulative difficulty. When two nodes broadcast competing valid blocks at the same time (a fork), nodes adopt whichever branch accumulates work fastest. The slower branch is abandoned. This is called the Nakamoto consensus.
- Node A: genesis → B1 → B2 → B3 → B4 ← heaviest: canonical
- Node B: genesis → B1 → B2 → B3’ ← abandoned after B4 arrives
No central coordinator is needed. The protocol converges through competition.
Putting It All Together#
Here is a minimal but complete working blockchain in Python, combining all the pieces above:
import hashlib
import json
import time
from dataclasses import dataclass, field
@dataclass
class Block:
index: int
timestamp: int
data: list[dict]
previous_hash: str
nonce: int = 0
hash: str = field(default="", init=False)
def __post_init__(self) -> None:
if not self.hash:
self.hash = self.compute_hash()
def compute_hash(self) -> str:
content = {
"index": self.index,
"timestamp": self.timestamp,
"data": self.data,
"previous_hash": self.previous_hash,
"nonce": self.nonce,
}
return hashlib.sha256(
json.dumps(content, sort_keys=True, separators=(',', ':')).encode()
).hexdigest()
def mine(self, difficulty: int) -> None:
target = "0" * difficulty
while not self.hash.startswith(target):
self.nonce += 1
self.hash = self.compute_hash()
class Blockchain:
def __init__(self, difficulty: int = 4) -> None:
self.difficulty = difficulty
self.chain: list[Block] = []
self._add_genesis()
def _add_genesis(self) -> None:
genesis = Block(0, int(time.time()), [
{"message": "The Times 03/Jan/2009 Chancellor on brink of second bailout for banks"}
], "0" * 64)
genesis.mine(self.difficulty)
self.chain.append(genesis)
@property
def last_block(self) -> Block:
return self.chain[-1]
def add_block(self, data: list[dict]) -> Block:
block = Block(
index=len(self.chain),
timestamp=int(time.time()),
data=data,
previous_hash=self.last_block.hash,
)
block.mine(self.difficulty)
self.chain.append(block)
return block
def is_valid(self) -> bool:
target = "0" * self.difficulty
for i in range(1, len(self.chain)):
cur, prev = self.chain[i], self.chain[i - 1]
if cur.hash != cur.compute_hash():
return False
if cur.previous_hash != prev.hash:
return False
if not cur.hash.startswith(target):
return False
return True
# --- Demo ---
if __name__ == "__main__":
print("Initializing Blockchain with Difficulty 4...")
bc = Blockchain(difficulty=4)
print(f"\nGenesis Block mined. Hash: {bc.last_block.hash[:20]}...")
bc.add_block([{"from": "alice", "to": "bob", "amount": 10}])
bc.add_block([{"from": "bob", "to": "carol", "amount": 3}])
print("\n--- Chain State ---")
for block in bc.chain:
print(f"[{block.index}] Nonce: {block.nonce:>8} | Hash: {block.hash[:20]}…")
print(f"\nChain Valid: {bc.is_valid()}")Sample output:
Initializing Blockchain with Difficulty 4...
Genesis Block mined. Hash: 0000a3f9e1b7c2d5f6...
--- Chain State ---
[0] Nonce: 14823 | Hash: 0000a3f9e1b7c2d5f6…
[1] Nonce: 8741 | Hash: 0000f8a1c93e217b49…
[2] Nonce: 31205 | Hash: 00003d7b5f2ea86c10…
Chain Valid: TrueTry adjusting difficulty from 2 to 6 and observe how mining time scales exponentially ($16^n$). And how CPU burns…
Key Takeaways#
| Property | Mechanism | Code Example |
|---|---|---|
| Immutability | Each block’s hash depends on its data | compute_hash() |
| Proof of Work | Nonce mining to meet difficulty target | mine() method |
| Tamper evidence | Each block hashes its predecessor | hashlib.sha256 |
| Tamper resistance | Mining makes rewriting expensive | while not hash.startswith(target) |
| Ordering | Blocks form a strict sequence | linked list via previous_hash |
| Distributed trust | All nodes verify; heaviest chain wins | Nakamoto consensus |
| Finality | More confirmations → harder to rewrite | exponential cost growth |
What This Simplified Model Leaves Out#
This implementation illustrates the core mechanics but omits several production-critical components found in Bitcoin:
| Missing Component | Purpose | Complexity Added |
|---|---|---|
| Merkle trees | Prove transaction inclusion without downloading all transactions | Requires tree hashing logic |
| UTXO model | Track unspent outputs instead of balances; prevents double-spending | Complex state management |
| Elliptic curve signatures | Verify transaction ownership via ECDSA/Schnorr signatures | Cryptography library integration |
| P2P networking | Nodes discover peers and propagate transactions/blocks | Socket programming + protocols |
| Script/opcodes | Define spending conditions, multi-signature, time locks | Stack-based VM interpreter |
Each of these is a rich topic in its own right. The blockchain data structure itself is the foundation, and as this post shows, it is surprisingly straightforward once you understand the hashing primitive it rests on.
