Capstone Project: Completing PyChain — REST API Node and Portfolio Deployment
Learning Objectives
- ✓Flask를 사용하여 블록체인 노드의 REST API를 구현하고 curl 또는 Postman으로 테스트할 수 있다
- ✓전체 프로젝트에 대한 통합 테스트를 작성하여 핵심 기능(채굴, 검증, 이중 지불 방지)을 자동 검증할 수 있다
- ✓해시 함수부터 체인 검증까지 전체 블록체인 동작 원리를 처음부터 끝까지 자신의 코드를 기반으로 설명할 수 있다
Capstone Project: Completing PyChain — REST API Node and Portfolio Deployment
To You Who Made It This Far
Many people know how to build a blockchain. Few know how to deploy it as a service.
In Lesson 9, we dissected the blockchain through the eyes of an attacker. 51% attacks, selfish mining, Sybil attacks. Only those who have looked directly into the cracks of a system can design one that's truly robust. By implementing consensus logic with resolve_conflicts() to select the longest valid chain, PyChain took one step up from "toy" to "working prototype."
Today is the graduation exam. All the pieces accumulated over 9 lessons — hash functions, digital signatures, transactions, blocks, PoW, chain validation, UTXO, Merkle trees, security — get integrated into a single Flask REST API server.
When I first read Ethereum node client code, the most surprising thing wasn't the blockchain logic. It was the sheer volume of the network layer wrapping it. The chain logic was less than 30% of the total. The rest was all API, peer communication, state management. The body is bigger than the engine. Today, drawing on that experience, I'll teach the skill of "wrapping core logic in an HTTP API."
Today's Mission: A Blockchain Node That Works in the Browser
Deliverables to complete today:
| File | Role | Status |
|---|---|---|
blockchain.py | Core blockchain engine | ✅ Completed in Lessons 1~9 |
app.py | Flask REST API server | 🆕 Written today |
test_pychain.py | pytest integration tests | 🆕 Written today |
README.md | Portfolio documentation | 🆕 Written today |
Final result: a working blockchain node where you can send a transaction, mine a block, and query the entire chain with a single curl command.
🤔 Think about it: Why does a blockchain node need a REST API? Bitcoin Core also provides a JSON-RPC API. Wallet apps, block explorers, mining pools — they all communicate with nodes through this API.
💡 REST vs JSON-RPC
Bitcoin uses JSON-RPC, and so does Ethereum. But in learning projects, REST is more intuitive. GET /chain is easier to read than {"jsonrpc":"2.0","method":"getblockchain"}. In production, gRPC is the growing trend (in go-ethereum's case), but the principle is the same — exposing core logic through a network interface.
Step 1: Cleaning Up the Core Engine — Final blockchain.py
We'll consolidate the code built across 9 lessons into a single file. The chain list and pending_transactions from the Blockchain class designed in Lesson 6, the UTXO balance tracking from Lesson 7, the Merkle root calculation from Lesson 8 — the scattered puzzle pieces finally come together into one picture.
# blockchain.py — PyChain Core Engine (Lessons 1~9 Integrated)
import hashlib
import json
import time
from typing import List, Dict, Any
class Block:
"""Class representing a single block (structure designed in Lesson 4)"""
def __init__(self, index: int, transactions: List[Dict],
previous_hash: str, nonce: int = 0):
self.index = index
self.timestamp = time.time()
self.transactions = transactions
self.previous_hash = previous_hash
self.nonce = nonce
self.merkle_root = self.compute_merkle_root()
self.hash = self.compute_hash()
def compute_hash(self) -> str:
"""Hash block data with SHA-256 (core of Lesson 1)"""
block_data = json.dumps({
"index": self.index,
"timestamp": self.timestamp,
"transactions": self.transactions,
"previous_hash": self.previous_hash,
"nonce": self.nonce,
"merkle_root": self.merkle_root
}, sort_keys=True)
return hashlib.sha256(block_data.encode()).hexdigest()
def compute_merkle_root(self) -> str:
"""Compute Merkle root of transactions (the 'tournament bracket' from Lesson 8)"""
if not self.transactions:
return hashlib.sha256(b"empty").hexdigest()
tx_hashes = [
hashlib.sha256(json.dumps(tx, sort_keys=True).encode()).hexdigest()
for tx in self.transactions
]
while len(tx_hashes) > 1:
if len(tx_hashes) % 2 == 1:
tx_hashes.append(tx_hashes[-1]) # Duplicate last if odd
tx_hashes = [
hashlib.sha256((tx_hashes[i] + tx_hashes[i+1]).encode()).hexdigest()
for i in range(0, len(tx_hashes), 2)
]
return tx_hashes[0]
def to_dict(self) -> Dict[str, Any]:
"""Convert block to a JSON-serializable dictionary"""
return {
"index": self.index,
"timestamp": self.timestamp,
"transactions": self.transactions,
"previous_hash": self.previous_hash,
"nonce": self.nonce,
"merkle_root": self.merkle_root,
"hash": self.hash
}
class Blockchain:
"""
PyChain Blockchain — chain structure from Lesson 6 + PoW from Lesson 5
"""
DIFFICULTY = 4 # Hash must start with 4 '0's
MINING_REWARD = 50 # Mining reward
def __init__(self):
self.chain: List[Block] = []
self.pending_transactions: List[Dict] = []
self.utxo_pool: Dict[str, List[Dict]] = {} # Lesson 7: UTXO pool
self._create_genesis_block()
def _create_genesis_block(self):
"""Create the genesis block — the start of every chain"""
genesis = Block(0, [], "0" * 64)
genesis.nonce = 0
genesis.hash = genesis.compute_hash()
self.chain.append(genesis)
def get_last_block(self) -> Block:
return self.chain[-1]
def add_transaction(self, sender: str, recipient: str, amount: float) -> int:
"""
Add a pending transaction — transaction structure from Lesson 3
Returns: block index where this transaction will be included
"""
if sender != "MINING_REWARD":
balance = self.get_balance(sender)
if balance < amount:
raise ValueError(
f"Insufficient balance: {sender}'s balance {balance}, attempted to send {amount}"
)
self.pending_transactions.append({
"sender": sender,
"recipient": recipient,
"amount": amount
})
return self.get_last_block().index + 1
def get_balance(self, address: str) -> float:
"""
Calculate balance by traversing the entire chain
Lesson 7's UTXO 'bundle of bills' concept — simplified version
"""
balance = 0.0
for block in self.chain:
for tx in block.transactions:
if tx["sender"] == address:
balance -= tx["amount"]
if tx["recipient"] == address:
balance += tx["amount"]
# Also reflect pending transactions
for tx in self.pending_transactions:
if tx["sender"] == address:
balance -= tx["amount"]
return balance
def proof_of_work(self, block: Block) -> Block:
"""
PoW mining — nonce iteration implemented in Lesson 5
Repeat until hash starts with '0' * DIFFICULTY
"""
target = "0" * self.DIFFICULTY
while not block.hash.startswith(target):
block.nonce += 1
block.hash = block.compute_hash()
return block
def mine_pending_transactions(self, miner_address: str) -> Block:
"""Bundle pending transactions into a block and mine it"""
# Add mining reward transaction
self.pending_transactions.append({
"sender": "MINING_REWARD",
"recipient": miner_address,
"amount": self.MINING_REWARD
})
new_block = Block(
index=len(self.chain),
transactions=self.pending_transactions,
previous_hash=self.get_last_block().hash
)
mined_block = self.proof_of_work(new_block)
self.chain.append(mined_block)
self.pending_transactions = []
return mined_block
def is_chain_valid(self) -> bool:
"""
Chain integrity validation — core of Lesson 6
What we learned in Lesson 9: this validation is what lets us reject attack chains
"""
target = "0" * self.DIFFICULTY
for i in range(1, len(self.chain)):
current = self.chain[i]
previous = self.chain[i - 1]
# Verify hash recomputation
if current.hash != current.compute_hash():
return False
# Verify previous block hash linkage
if current.previous_hash != previous.hash:
return False
# Verify PoW
if not current.hash.startswith(target):
return False
return True
def to_dict(self) -> List[Dict]:
"""Convert the entire chain to a list of dictionaries"""
return [block.to_dict() for block in self.chain]
Under 200 lines of code. But inside it — hash functions, Merkle trees, PoW, balance tracking, chain validation — every core mechanism of Bitcoin is included without exception. The engine is complete. Now it's time to add handles and pedals so anyone can drive it.
🤔 Think about it: In
mine_pending_transactions, the mining reward is added topending_transactionsand immediately included in the block. Why does this order matter?
View Answer
The mining reward transaction (coinbase) must be inside that block's transaction list in order to be included in the Merkle root. If the Merkle root changes, the block hash changes, and PoW must be redone. In Bitcoin too, the coinbase transaction is always the first transaction in a block.
Step 2: Flask API Design — The Endpoint Map
Why Flask? Django is like using a cannon to shoot a sparrow at this scale, and FastAPI's async is overkill for a single-node prototype. For a server that needs to be lightweight and fast like a blockchain node, Flask's minimalism is a perfect fit. Ethereum's early Python client (pyethereum) was built on a similar philosophy.
Let's start with the design. A client sends a request, Flask receives it and passes it to the blockchain engine, and returns the result as JSON. Think of it like a restaurant waiter — delivering orders between the customer (client) and the kitchen (engine), then bringing back the food.
| Endpoint | Method | Description | Request Body |
|---|---|---|---|
/transactions/new | POST | Add a new transaction | {sender, recipient, amount} |
/mine | POST | Mine pending transactions | {miner_address} |
/chain | GET | Query the full chain | — |
/chain/valid | GET | Validate chain integrity | — |
/balance/<address> | GET | Query balance by address | — |
The blueprint is complete. Let's convert it to code.
Step 3: Implementing app.py — Wrapping the Blockchain in HTTP
# app.py — PyChain Flask REST API Node
from flask import Flask, jsonify, request
from blockchain import Blockchain
app = Flask(__name__)
node = Blockchain() # Create blockchain instance
@app.route("/transactions/new", methods=["POST"])
def new_transaction():
"""Add a new transaction to the pending pool"""
data = request.get_json()
required = ["sender", "recipient", "amount"]
if not data or not all(k in data for k in required):
return jsonify({"error": "sender, recipient, amount fields are required"}), 400
try:
block_index = node.add_transaction(
sender=data["sender"],
recipient=data["recipient"],
amount=float(data["amount"])
)
return jsonify({
"message": f"Transaction scheduled for block {block_index}",
"transaction": data
}), 201
except ValueError as e:
return jsonify({"error": str(e)}), 400
@app.route("/mine", methods=["POST"])
def mine():
"""Mine pending transactions to create a new block"""
data = request.get_json()
if not data or "miner_address" not in data:
return jsonify({"error": "miner_address field is required"}), 400
if len(node.pending_transactions) == 0:
return jsonify({"error": "No transactions to mine"}), 400
new_block = node.mine_pending_transactions(data["miner_address"])
return jsonify({
"message": "New block has been mined! ⛏️",
"block": new_block.to_dict()
}), 200
@app.route("/chain", methods=["GET"])
def get_chain():
"""Query the entire blockchain"""
return jsonify({
"chain": node.to_dict(),
"length": len(node.chain)
}), 200
@app.route("/chain/valid", methods=["GET"])
def validate_chain():
"""Chain integrity validation — security check from Lesson 9"""
is_valid = node.is_chain_valid()
return jsonify({
"valid": is_valid,
"length": len(node.chain),
"message": "Chain is valid ✅" if is_valid
else "Chain is corrupted ❌"
}), 200
@app.route("/balance/<address>", methods=["GET"])
def get_balance(address):
"""Query the balance of a specific address"""
balance = node.get_balance(address)
return jsonify({
"address": address,
"balance": balance
}), 200
@app.route("/pending", methods=["GET"])
def get_pending():
"""Query pending transactions"""
return jsonify({
"pending_transactions": node.pending_transactions,
"count": len(node.pending_transactions)
}), 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)
A single 50-line file opens a blockchain node to the internet. Short but not sloppy. There's a lesson I learned painfully from doing smart contract audits — the moment you omit input validation in the API layer, a security hole opens. Converting amount with float(), checking required fields — each of these seemingly trivial validation lines is a line of defense.
Throwing a ValueError on insufficient balance is the same principle. Recall the analogy from Lesson 7 where we compared UTXO to a 'bundle of bills' — you can't pull out more than what's in your pocket. This is the first line of defense against double-spending.
❌ → 🤔 → ✅ Three Stages of API Error Handling Evolution
How you handle input validation and error handling in an API is what separates a "student project" from "code worth putting in a portfolio." Let's compare the differences directly using the transaction endpoint as an example.
❌ WRONG WAY — Passing through without validation
@app.route("/transactions/new", methods=["POST"])
def new_transaction():
data = request.get_json()
# Calling directly without any validation — what's the problem?
block_index = node.add_transaction(
data["sender"], data["recipient"], data["amount"]
)
return jsonify({"message": "Added"}), 200
Problems abound:
- What if
dataisNone? →TypeError: 'NoneType' object is not subscriptable— a 500 error along with Flask's internal stack trace exposed to the client. Essentially telling an attacker the server's structure. - What if
"amount"receives"abc"? → It blows up deep inside the blockchain engine. Hard to trace where the problem originated. - On insufficient balance? →
ValueErrorexception goes unhandled, again resulting in a 500 error. The client has no idea what they did wrong. - Status code is always 200 — returning the same code regardless of success or failure means client programs can't distinguish outcomes.
🤔 BETTER — Wrapping with try/except
@app.route("/transactions/new", methods=["POST"])
def new_transaction():
try:
data = request.get_json()
block_index = node.add_transaction(
data["sender"], data["recipient"], data["amount"]
)
return jsonify({"message": f"Scheduled for block {block_index}"}), 201
except Exception as e:
return jsonify({"error": str(e)}), 400
Better — at least it returns 400 instead of a 500 error. But:
- All errors go in one basket. Missing fields (client's fault), insufficient balance (business rule), or internal server error — all get the same 400. The client can't distinguish "was my request format wrong, or do I have insufficient balance?"
- A string in
amountstill gets passed to the engine. Filtering at the boundary is letting things flow inward — a mistake. - Catching
Exceptionbroadly also hides real server bugs as 400 errors. Debugging becomes hell.
✅ BEST — Validate at the boundary, distinguish errors
@app.route("/transactions/new", methods=["POST"])
def new_transaction():
data = request.get_json()
# Stage 1: Input format validation — filter at the API boundary
required = ["sender", "recipient", "amount"]
if not data or not all(k in data for k in required):
return jsonify({"error": "sender, recipient, amount fields are required"}), 400
# Stage 2: Type conversion — sanitize before passing to the engine
try:
amount = float(data["amount"])
except (ValueError, TypeError):
return jsonify({"error": "amount must be a number"}), 400
# Stage 3: Handle only business logic errors separately
try:
block_index = node.add_transaction(
sender=data["sender"],
recipient=data["recipient"],
amount=amount
)
return jsonify({
"message": f"Transaction scheduled for block {block_index}",
"transaction": data
}), 201
except ValueError as e:
# Business rule violations such as insufficient balance
return jsonify({"error": str(e)}), 400
Why is this BEST:
- Three layers of defense: format validation → type conversion → business logic. Each stage catches only the errors it's responsible for.
- The error messages the client receives are actionable. "Missing field" vs "not a number" vs "insufficient balance" — each requires a different response, and the message guides that.
- Unexpected errors (
RuntimeError, etc.) are not caught, so they bubble up as 500. This is intentional design — server bugs shouldn't be hidden, they need to be surfaced and fixed. - Practical application of the security principle from Lesson 9: at the boundaries of a system (API layer), treat all external input with suspicion and validate it.
💡 This pattern doesn't just apply to blockchain. It applies to every REST API. If you can explain these 3 stages when asked "how would you handle API errors?" in an interview, that's an answer that demonstrates the difference between a junior and mid-level developer.
The engine is built, and the handles are attached. It's time to take it for a drive.
Step 4: Real-World Testing — Talking to the Node with curl
Start the server and follow along in order.
Terminal 1 — Start the server:
# Install required packages
pip install flask pytest
# Run the server
python app.py
# Output:
# * Running on http://0.0.0.0:5000
# * Debug mode: on
Terminal 2 — Make API calls:
# 1) Create transaction — Alice sends to Bob
curl -X POST http://localhost:5000/transactions/new \
-H "Content-Type: application/json" \
-d '{"sender":"alice","recipient":"bob","amount":30}'
# Output:
# {"message":"Transaction scheduled for block 1","transaction":{...}}
# 2) Check pending transactions
curl http://localhost:5000/pending
# Output:
# {"count":1,"pending_transactions":[{"amount":30,"recipient":"bob","sender":"alice"}]}
# 3) Mine — alice first gives herself a reward (as miner)
curl -X POST http://localhost:5000/mine \
-H "Content-Type: application/json" \
-d '{"miner_address":"alice"}'
# Output:
# {"block":{"hash":"0000...","index":1,...},"message":"New block has been mined! ⛏️"}
# 4) Check balance — alice has mining reward(50) - sent(30) = 20
curl http://localhost:5000/balance/alice
# Output:
# {"address":"alice","balance":20.0}
# bob received 30
curl http://localhost:5000/balance/bob
# Output:
# {"address":"bob","balance":30.0}
# 5) Query the full chain
curl http://localhost:5000/chain
# Output:
# {"chain":[{"index":0,...},{"index":1,...}],"length":2}
# 6) Validate the chain
curl http://localhost:5000/chain/valid
# Output:
# {"length":2,"message":"Chain is valid ✅","valid":true}
🤔 Think about it: With alice's balance at 20, what happens if she tries to send 50 to bob?
View Answer
add_transaction checks the balance and throws a ValueError. The API returns a 400 error with an "insufficient balance" message. This is the practical application of double-spend prevention we learned in Lesson 9. Try it yourself:
curl -X POST http://localhost:5000/transactions/new \
-H "Content-Type: application/json" \
-d '{"sender":"alice","recipient":"bob","amount":50}'
# Output:
# {"error":"Insufficient balance: alice's balance 20.0, attempted to send 50.0"}
Step 5: pytest Integration Tests — The Safety Net for Your Code
"It seems to be working?" isn't evidence. Manual testing only confirms it works at this specific moment. There's one principle I've witnessed firsthand at Solidity audit sites: code without tests isn't working — it's just lucky. I've personally seen multiple DeFi protocols get hacked for millions of dollars due to low test coverage.
# test_pychain.py — PyChain Integration Tests
import pytest
import json
from app import app
from blockchain import Blockchain, Block
@pytest.fixture
def client():
"""Create a Flask test client"""
app.config["TESTING"] = True
with app.test_client() as client:
yield client
@pytest.fixture
def fresh_chain():
"""A clean blockchain instance"""
return Blockchain()
# === Core Engine Tests ===
class TestBlockchain:
def test_genesis_block_exists(self, fresh_chain):
"""Is the genesis block created correctly?"""
assert len(fresh_chain.chain) == 1
assert fresh_chain.chain[0].index == 0
assert fresh_chain.chain[0].previous_hash == "0" * 64
def test_mining_creates_new_block(self, fresh_chain):
"""Does mining create a new block?"""
fresh_chain.add_transaction("alice", "bob", 10)
block = fresh_chain.mine_pending_transactions("miner1")
assert len(fresh_chain.chain) == 2
assert block.index == 1
# PoW verification — hash must start with '0000' (Lesson 5)
assert block.hash.startswith("0" * Blockchain.DIFFICULTY)
def test_mining_reward(self, fresh_chain):
"""Does the miner receive a reward?"""
fresh_chain.add_transaction("alice", "bob", 10)
fresh_chain.mine_pending_transactions("miner1")
balance = fresh_chain.get_balance("miner1")
assert balance == 50 # MINING_REWARD
def test_insufficient_balance(self, fresh_chain):
"""Does an error occur on insufficient balance? (double-spend prevention)"""
with pytest.raises(ValueError, match="Insufficient balance"):
fresh_chain.add_transaction("alice", "bob", 100)
def test_chain_validity(self, fresh_chain):
"""Does chain integrity validation work? (Lesson 6)"""
fresh_chain.add_transaction("alice", "bob", 10)
fresh_chain.mine_pending_transactions("miner1")
assert fresh_chain.is_chain_valid() is True
def test_tampered_chain_detected(self, fresh_chain):
"""Is a tampered chain detected? (Lesson 9 security)"""
fresh_chain.add_transaction("alice", "bob", 10)
fresh_chain.mine_pending_transactions("miner1")
# Tamper with block data!
fresh_chain.chain[1].transactions[0]["amount"] = 99999
assert fresh_chain.is_chain_valid() is False
# === API Endpoint Tests ===
class TestAPI:
def test_get_chain(self, client):
"""Does GET /chain return the genesis block?"""
resp = client.get("/chain")
data = json.loads(resp.data)
assert resp.status_code == 200
assert data["length"] >= 1
def test_create_transaction(self, client):
"""Does POST /transactions/new add a transaction?"""
# First mine to get a balance
client.post("/mine", json={"miner_address": "alice"})
resp = client.post("/transactions/new", json={
"sender": "alice",
"recipient": "bob",
"amount": 10
})
assert resp.status_code == 201
def test_missing_fields_returns_400(self, client):
"""400 error on missing required fields"""
resp = client.post("/transactions/new", json={
"sender": "alice"
# recipient, amount missing
})
assert resp.status_code == 400
def test_mine_and_validate(self, client):
"""Is the chain valid after mining?"""
client.post("/mine", json={"miner_address": "tester"})
resp = client.get("/chain/valid")
data = json.loads(resp.data)
assert data["valid"] is True
def test_balance_after_transaction(self, client):
"""Is the balance correct after a transaction?"""
# Give alice a mining reward
client.post("/mine", json={"miner_address": "alice"})
# alice → bob transfer
client.post("/transactions/new", json={
"sender": "alice", "recipient": "bob", "amount": 20
})
client.post("/mine", json={"miner_address": "alice"})
resp = client.get("/balance/bob")
data = json.loads(resp.data)
assert data["balance"] == 20.0
Run the tests:
pytest test_pychain.py -v
# Output:
# test_pychain.py::TestBlockchain::test_genesis_block_exists PASSED
# test_pychain.py::TestBlockchain::test_mining_creates_new_block PASSED
# test_pychain.py::TestBlockchain::test_mining_reward PASSED
# test_pychain.py::TestBlockchain::test_insufficient_balance PASSED
# test_pychain.py::TestBlockchain::test_chain_validity PASSED
# test_pychain.py::TestBlockchain::test_tampered_chain_detected PASSED
# test_pychain.py::TestAPI::test_get_chain PASSED
# test_pychain.py::TestAPI::test_create_transaction PASSED
# test_pychain.py::TestAPI::test_missing_fields_returns_400 PASSED
# test_pychain.py::TestAPI::test_mine_and_validate PASSED
# test_pychain.py::TestAPI::test_balance_after_transaction PASSED
# ======================== 11 passed ========================
All 11 green. From this moment on, your blockchain can be called "verified" rather than "seems to be working."
🔍 Deep Dive: Why use the `fresh_chain` fixture?
The Flask app's node object is a global variable, so state is shared between tests. API tests use this global state, giving them an integration test character, but for core engine tests, we use the fresh_chain fixture for a clean chain with every test. In production as well, the principle is unit tests are isolated, integration tests share state.
Step 6: README.md — The Face of Your Portfolio
Writing good code and presenting it well are entirely different skills. GitHub recruiters look at a README for 15 seconds. The judgment is made in the README before they even open the code. What I've learned from reviewing hundreds of Web3 project repos: a README without usage examples gets ignored.
# ⛓️ PyChain — A Mini Blockchain Built in Python
An educational blockchain + REST API node with SHA-256 hashing,
Proof of Work (PoW), UTXO balance tracking, and Merkle trees
implemented from scratch.
## Architecture
PyChain/ ├── blockchain.py # Core engine (Block, Blockchain classes) ├── app.py # Flask REST API server ├── test_pychain.py # pytest integration tests (11 tests) └── README.md
## Key Features
- ⛏️ SHA-256 based Proof-of-Work mining
- 💰 Transaction creation and balance validation (double-spend prevention)
- 🌳 Transaction integrity guaranteed by Merkle tree
- 🔗 Chain integrity validation (hash chain + PoW verification)
- 🌐 Interactive via REST API from browser/curl
## Quick Start
```bash
pip install flask pytest
python app.py
API Usage Examples
# Mine
curl -X POST http://localhost:5000/mine \
-H "Content-Type: application/json" \
-d '{"miner_address":"alice"}'
# Send a transaction
curl -X POST http://localhost:5000/transactions/new \
-H "Content-Type: application/json" \
-d '{"sender":"alice","recipient":"bob","amount":10}'
# Query the chain
curl http://localhost:5000/chain
Tests
pytest test_pychain.py -v
Learning Materials
This project is the capstone project of the 'Complete Introduction to Bitcoin and Blockchain' course. Implementation principles:
- SHA-256 hash functions and data integrity
- Public key cryptography and digital signatures
- UTXO model-based balance tracking
- Transaction summarization using Merkle trees
- 51% attacks and blockchain security models
> ⚠️ The README above looks complex because it's inside a markdown code block, but when saved as an actual `README.md` file, it renders cleanly.
```mermaid
mindmap
root((PyChain<br/>Project))
Core Engine
Block Class
Blockchain Class
PoW Mining
Merkle Root
API Layer
/transactions/new
/mine
/chain
/chain/valid
/balance
Tests
6 Core Tests
5 API Tests
Documentation
README.md
Architecture Diagram
Usage Examples
Review: Self-Assessment Checklist
Let's check each item to see if the project is ready for a portfolio.
| # | Item | Check |
|---|---|---|
| 1 | Does the server start normally with python app.py? | ☐ |
| 2 | Does the create transaction → mine → check balance flow work with curl? | ☐ |
| 3 | Is a 400 error returned on insufficient balance? | ☐ |
| 4 | Do all 11 tests PASS with pytest test_pychain.py -v? | ☐ |
| 5 | Does /chain/valid return True? | ☐ |
| 6 | Does README.md include installation instructions, usage examples, and architecture? | ☐ |
| 7 | Does the tampering detection test work? (False when chain is forged) | ☐ |
❌ Top 3 Common Mistakes:
- Not placing
blockchain.pyandapp.pyin the same directory —from blockchain import Blockchainwill fail. - Attempting to send a transaction without mining first — balance is 0 so it always fails. You must mine first to get a reward before you have money to send.
- Unknowingly restarting the server with Flask's global
nodeobject — the entire chain gets reset. It only exists in memory. In production, you'd save it to disk.
💡 Stuck on checklist item 7?
Look at the test_tampered_chain_detected test. When you directly modify the transactions list of a block, the result of compute_hash() differs from the stored hash. is_chain_valid() catches this discrepancy. The principle from Lesson 6 — "the next block stores the previous block's hash" — shines here: changing one block breaks the hashes of all blocks that follow it.
Next Level: What Seniors Add Here
If you've completed this far, you already have a solid prototype. But to stand out in a portfolio, you need to close the gap with real production blockchain projects.
1. Peer Network (P2P)
Right now it's a single node. A blockchain alone is just a database. A real blockchain has multiple nodes sharing and validating chains with each other. Try running two Flask servers on different ports and add /nodes/register and /nodes/resolve endpoints. Lesson 9's resolve_conflicts() — the logic for selecting the longest valid chain — finally finds its proper place.
2. Disk Persistence If you turn the server off and on again, the entire chain is gone. Adding functionality to save chain state to SQLite or a JSON file brings a strong sense of real-world practicality.
3. Web Frontend
Try parsing the /chain response with JavaScript and building a block explorer UI. A simple SPA that shows blocks as cards and expands into a transaction list when clicked is sufficient.
4. Docker Containerization
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
COPY . .
RUN pip install flask pytest
EXPOSE 5000
CMD ["python", "app.py"]
These 4 lines let you spin up a node anywhere with docker run. When an interviewer asks "how would you deploy this?", you already have the answer prepared.
🔨 Project Update
Full Project Structure So Far
pychain/
├── blockchain.py ← Lessons 1~9 integrated core engine
├── app.py ← 🆕 Lesson 10: Flask REST API
├── test_pychain.py ← 🆕 Lesson 10: pytest integration tests
└── README.md ← 🆕 Lesson 10: portfolio documentation
What Was Added This Lesson
app.py— Transforms the blockchain node into an HTTP service with 6 REST endpointstest_pychain.py— 11 automated tests (6 core + 5 API)README.md— Project documentation for GitHub portfolio
Execution Order
# 1. Create project directory
mkdir pychain && cd pychain
# 2. Create blockchain.py, app.py, test_pychain.py files
# (copy-paste the code above into each file)
# 3. Install dependencies
pip install flask pytest
# 4. Run tests first — verify everything works
pytest test_pychain.py -v
# Expected output: 11 passed
# 5. Run the server
python app.py
# Expected output: * Running on http://0.0.0.0:5000
# 6. Test the API from a new terminal
curl -X POST http://localhost:5000/mine \
-H "Content-Type: application/json" \
-d '{"miner_address":"alice"}'
curl http://localhost:5000/chain
curl http://localhost:5000/chain/valid
Try it right now. If all 11 tests pass and you can see mining and transactions working with curl directly in front of you — congratulations. You have built a blockchain from scratch, from hash functions all the way to a REST API node.
Full Journey Summary Diagram
Look at the dotted arrows. They show exactly where each individual function built in each lesson plugs into the final API node. These aren't imported libraries. Code written line by line with your own hands is now meshing together as one system. That's the true meaning of "building from the ground up."
Difficulty Fork
🟢 If it was easy
Key takeaways:
- Blockchain core logic fits in under 200 lines in a single
blockchain.py - Wrapping it with Flask API makes it accessible to anyone via HTTP
- pytest tests both core and API simultaneously
- README is the first impression of a portfolio — always include usage examples
Next step: Put this project on GitHub and try adding peer network (P2P) functionality. Two nodes synchronizing their chains — that's a truly "distributed" blockchain.
🟡 If it was difficult
Flask might have been unfamiliar. Let's re-anchor the essentials:
@app.route("/path", methods=["GET"])means "when a request comes to this URL, execute the function below"request.get_json()converts the JSON data sent by the client into a Python dictionaryjsonify()converts a Python dictionary into a JSON response- Ultimately, Flask is an intermediary that receives a dictionary and returns a dictionary
Additional practice: Start the server and access http://localhost:5000/chain in your browser instead of curl. You'll see the JSON directly. A GET request is the same as typing a URL in the browser's address bar.
🔴 Challenge Tasks
Interview-level extension tasks:
-
Automatic difficulty adjustment: Recalculate
DIFFICULTYevery 10 blocks so that average mining time becomes 10 seconds. This is a simplified version of Bitcoin's 2016-block difficulty adjustment. -
Multi-node consensus: Run
app.pyon ports 5000 and 5001 respectively, register them with each other via/nodes/register, then implement the ability to synchronize to the longest chain with/nodes/resolve. Use therequestslibrary to send HTTP requests to other nodes. -
Digital signature integration: Apply the ECDSA signatures from Lesson 2 to transactions. Add logic to
add_transactionthat verifies the signature and rejects transactions that are missing or have invalid signatures.
Course Wrap-Up: What You Built
10 lessons. 200 lines of Python. A blockchain built from the ground up.
You fingerprinted data with SHA-256. You proved identity with digital signatures. You structured transactions, packed them into blocks, mined them with PoW, linked them into a chain, summarized them with Merkle trees, analyzed attacks, and finally opened it to the world with a REST API.
Now when someone asks "what is a blockchain?", you don't need to reel off abstract analogies. You can show them code you wrote yourself. That's what real understanding looks like.