From b4b1611ae9089bce166640b2efd3cfaac12bfbfa Mon Sep 17 00:00:00 2001 From: Muneer Ali Date: Sun, 22 Feb 2026 20:04:36 +0530 Subject: [PATCH 1/8] feat: add Merkle Tree for SPV transaction verification - Implement MerkleTree utility class in core/merkle.py - Add proof generation and verification methods - Update Block class to use MerkleTree for merkle_root - Add FastAPI server with /verify_transaction endpoint - Add /block/ endpoint with Merkle proofs for each transaction - Add tests for Merkle tree functionality --- api/__init__.py | 0 api/main.py | 152 +++++++++++++++++++++++++++++++++++++++++++ core/block.py | 42 +++++------- core/merkle.py | 102 +++++++++++++++++++++++++++++ requirements.txt | 3 + tests/test_merkle.py | 96 +++++++++++++++++++++++++++ 6 files changed, 368 insertions(+), 27 deletions(-) create mode 100644 api/__init__.py create mode 100644 api/main.py create mode 100644 core/merkle.py create mode 100644 tests/test_merkle.py diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..7e2822c --- /dev/null +++ b/api/main.py @@ -0,0 +1,152 @@ +from fastapi import FastAPI, HTTPException, Query +from pydantic import BaseModel +from typing import List, Optional +import hashlib +import json + +from core import Blockchain, Block, State, Transaction +from core.merkle import MerkleTree + +app = FastAPI(title="MiniChain API", description="SPV-enabled blockchain API") + +blockchain = Blockchain() + + +class TransactionResponse(BaseModel): + sender: str + receiver: str + amount: int + nonce: int + data: Optional[dict] = None + timestamp: int + signature: Optional[str] = None + hash: Optional[str] = None + + +class BlockResponse(BaseModel): + index: int + previous_hash: str + merkle_root: Optional[str] + timestamp: int + difficulty: Optional[int] + nonce: int + hash: str + transactions: List[TransactionResponse] + merkle_proofs: Optional[dict] = None + + +class VerifyTransactionResponse(BaseModel): + tx_hash: str + block_index: int + merkle_root: str + proof: List[dict] + verification_status: bool + message: str + + +class ChainInfo(BaseModel): + length: int + blocks: List[dict] + + +def compute_tx_hash(tx_dict: dict) -> str: + return hashlib.sha256(json.dumps(tx_dict, sort_keys=True).encode()).hexdigest() + + +@app.get("/") +def root(): + return {"message": "MiniChain API with SPV Support"} + + +@app.get("/chain", response_model=ChainInfo) +def get_chain(): + return { + "length": len(blockchain.chain), + "blocks": [block.to_dict() for block in blockchain.chain] + } + + +@app.get("/block/{block_index}", response_model=BlockResponse) +def get_block(block_index: int): + if block_index < 0 or block_index >= len(blockchain.chain): + raise HTTPException(status_code=404, detail="Block not found") + + block = blockchain.chain[block_index] + block_dict = block.to_dict() + + merkle_proofs = {} + for i, tx in enumerate(block.transactions): + tx_hash = compute_tx_hash(tx.to_dict()) + proof = block.get_merkle_proof(i) + if proof: + merkle_proofs[tx_hash] = proof + + return { + **block_dict, + "merkle_proofs": merkle_proofs + } + + +@app.get("/verify_transaction", response_model=VerifyTransactionResponse) +def verify_transaction( + tx_hash: str = Query(..., description="Transaction hash to verify"), + block_index: int = Query(..., description="Block index to verify against") +): + if block_index < 0 or block_index >= len(blockchain.chain): + raise HTTPException(status_code=404, detail="Block not found") + + block = blockchain.chain[block_index] + + tx_found = False + tx_index = -1 + for i, tx in enumerate(block.transactions): + tx_hash_computed = compute_tx_hash(tx.to_dict()) + if tx_hash_computed == tx_hash: + tx_found = True + tx_index = i + break + + if not tx_found: + return { + "tx_hash": tx_hash, + "block_index": block_index, + "merkle_root": block.merkle_root or "", + "proof": [], + "verification_status": False, + "message": "Transaction not found in block" + } + + proof = block.get_merkle_proof(tx_index) + merkle_root = block.merkle_root or "" + + verification_status = MerkleTree.verify_proof(tx_hash, proof, merkle_root) + + return { + "tx_hash": tx_hash, + "block_index": block_index, + "merkle_root": merkle_root, + "proof": proof, + "verification_status": verification_status, + "message": "Transaction verified successfully" if verification_status else "Verification failed" + } + + +@app.post("/mine") +def mine_block_endpoint(): + from main import mine_and_process_block + from node import Mempool + + mempool = Mempool() + pending_nonce_map = {} + + result = mine_and_process_block(blockchain, mempool, pending_nonce_map) + + if result[0]: + return {"message": "Block mined successfully", "block": result[0].to_dict()} + else: + raise HTTPException(status_code=400, detail="Failed to mine block") + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/core/block.py b/core/block.py index 23f7536..4ba40c3 100644 --- a/core/block.py +++ b/core/block.py @@ -3,37 +3,13 @@ import json from typing import List, Optional from core.transaction import Transaction +from core.merkle import MerkleTree def _sha256(data: str) -> str: return hashlib.sha256(data.encode()).hexdigest() -def _calculate_merkle_root(transactions: List[Transaction]) -> Optional[str]: - if not transactions: - return None - - # Hash each transaction deterministically - tx_hashes = [ - _sha256(json.dumps(tx.to_dict(), sort_keys=True)) - for tx in transactions - ] - - # Build Merkle tree - while len(tx_hashes) > 1: - if len(tx_hashes) % 2 != 0: - tx_hashes.append(tx_hashes[-1]) # duplicate last if odd - - new_level = [] - for i in range(0, len(tx_hashes), 2): - combined = tx_hashes[i] + tx_hashes[i + 1] - new_level.append(_sha256(combined)) - - tx_hashes = new_level - - return tx_hashes[0] - - class Block: def __init__( self, @@ -58,8 +34,8 @@ def __init__( self.nonce: int = 0 self.hash: Optional[str] = None - # NEW: compute merkle root once - self.merkle_root: Optional[str] = _calculate_merkle_root(self.transactions) + self._merkle_tree = MerkleTree([tx.to_dict() for tx in self.transactions]) + self.merkle_root: Optional[str] = self._merkle_tree.get_merkle_root() # ------------------------- # HEADER (used for mining) @@ -103,3 +79,15 @@ def compute_hash(self) -> str: sort_keys=True ) return _sha256(header_string) + + # ------------------------- + # MERKLE PROOF + # ------------------------- + def get_merkle_proof(self, tx_index: int) -> Optional[List[dict]]: + return self._merkle_tree.get_proof(tx_index) + + def get_tx_hash(self, tx_index: int) -> Optional[str]: + if tx_index < 0 or tx_index >= len(self.transactions): + return None + tx_dict = self.transactions[tx_index].to_dict() + return _sha256(json.dumps(tx_dict, sort_keys=True)) diff --git a/core/merkle.py b/core/merkle.py new file mode 100644 index 0000000..60c6dc9 --- /dev/null +++ b/core/merkle.py @@ -0,0 +1,102 @@ +import hashlib +import json +from typing import List, Optional, Tuple +from dataclasses import dataclass + + +def _sha256(data: str) -> str: + return hashlib.sha256(data.encode()).hexdigest() + + +@dataclass +class MerkleProof: + tx_hash: str + merkle_root: str + proof: List[dict] + verification_status: bool + + +class MerkleTree: + def __init__(self, transactions: List[dict]): + self.transactions = transactions + self.tx_hashes = self._hash_transactions() + self.tree = self._build_tree() + self.root = self._get_root() + + def _hash_transactions(self) -> List[str]: + return [ + _sha256(json.dumps(tx, sort_keys=True)) + for tx in self.transactions + ] + + def _build_tree(self) -> List[List[str]]: + if not self.tx_hashes: + return [] + + tree = [self.tx_hashes[:]] + + while len(tree[-1]) > 1: + current_level = tree[-1] + if len(current_level) % 2 != 0: + current_level.append(current_level[-1]) + + new_level = [] + for i in range(0, len(current_level), 2): + combined = current_level[i] + current_level[i + 1] + new_level.append(_sha256(combined)) + + tree.append(new_level) + + return tree + + def _get_root(self) -> Optional[str]: + if not self.tree: + return None + return self.tree[-1][0] if self.tree[-1] else None + + def get_merkle_root(self) -> Optional[str]: + return self.root + + def get_proof(self, index: int) -> Optional[List[dict]]: + if index < 0 or index >= len(self.tx_hashes): + return None + + proof = [] + for level_idx in range(len(self.tree) - 1): + level = self.tree[level_idx] + is_right = index % 2 == 1 + sibling_idx = index - 1 if is_right else index + 1 + + if sibling_idx < len(level): + proof.append({ + "hash": level[sibling_idx], + "position": "left" if is_right else "right" + }) + + index //= 2 + + return proof + + @staticmethod + def verify_proof(tx_hash: str, proof: List[dict], merkle_root: str) -> bool: + current_hash = tx_hash + + for item in proof: + sibling_hash = item["hash"] + position = item["position"] + + if position == "left": + combined = sibling_hash + current_hash + else: + combined = current_hash + sibling_hash + + current_hash = _sha256(combined) + + return current_hash == merkle_root + + +def calculate_merkle_root(transactions: List[dict]) -> Optional[str]: + if not transactions: + return None + tree = MerkleTree(transactions) + return tree.get_merkle_root() diff --git a/requirements.txt b/requirements.txt index 819e170..3907305 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,5 @@ pynacl==1.6.2 libp2p==0.5.0 +fastapi==0.109.0 +uvicorn==0.27.0 +pydantic==2.5.3 diff --git a/tests/test_merkle.py b/tests/test_merkle.py new file mode 100644 index 0000000..e1e00d4 --- /dev/null +++ b/tests/test_merkle.py @@ -0,0 +1,96 @@ +import unittest +from core.merkle import MerkleTree, calculate_merkle_root + + +class TestMerkleTree(unittest.TestCase): + def test_empty_transactions(self): + root = calculate_merkle_root([]) + self.assertIsNone(root) + + def test_single_transaction(self): + tx = {"sender": "alice", "receiver": "bob", "amount": 10} + tree = MerkleTree([tx]) + root = tree.get_merkle_root() + self.assertIsNotNone(root) + self.assertEqual(len(root), 64) + + def test_two_transactions(self): + txs = [ + {"sender": "alice", "receiver": "bob", "amount": 10}, + {"sender": "bob", "receiver": "charlie", "amount": 5} + ] + tree = MerkleTree(txs) + root = tree.get_merkle_root() + self.assertIsNotNone(root) + + proof0 = tree.get_proof(0) + proof1 = tree.get_proof(1) + self.assertIsNotNone(proof0) + self.assertIsNotNone(proof1) + + def test_odd_transaction_count(self): + txs = [ + {"sender": "alice", "receiver": "bob", "amount": 10}, + {"sender": "bob", "receiver": "charlie", "amount": 5}, + {"sender": "charlie", "receiver": "dave", "amount": 3} + ] + tree = MerkleTree(txs) + root = tree.get_merkle_root() + self.assertIsNotNone(root) + + def test_proof_generation(self): + txs = [ + {"sender": "alice", "receiver": "bob", "amount": 10}, + {"sender": "bob", "receiver": "charlie", "amount": 5}, + {"sender": "charlie", "receiver": "dave", "amount": 3}, + {"sender": "dave", "receiver": "eve", "amount": 1} + ] + tree = MerkleTree(txs) + + for i in range(len(txs)): + proof = tree.get_proof(i) + self.assertIsNotNone(proof) + self.assertTrue(len(proof) > 0) + + def test_proof_verification(self): + txs = [ + {"sender": "alice", "receiver": "bob", "amount": 10}, + {"sender": "bob", "receiver": "charlie", "amount": 5}, + {"sender": "charlie", "receiver": "dave", "amount": 3}, + {"sender": "dave", "receiver": "eve", "amount": 1} + ] + tree = MerkleTree(txs) + root = tree.get_merkle_root() + + for i, tx_hash in enumerate(tree.tx_hashes): + proof = tree.get_proof(i) + result = MerkleTree.verify_proof(tx_hash, proof, root) + self.assertTrue(result) + + def test_proof_verification_fails_wrong_root(self): + txs = [ + {"sender": "alice", "receiver": "bob", "amount": 10}, + {"sender": "bob", "receiver": "charlie", "amount": 5} + ] + tree = MerkleTree(txs) + + wrong_root = "0" * 64 + tx_hash = tree.tx_hashes[0] + proof = tree.get_proof(0) + + result = MerkleTree.verify_proof(tx_hash, proof, wrong_root) + self.assertFalse(result) + + def test_invalid_index(self): + txs = [ + {"sender": "alice", "receiver": "bob", "amount": 10}, + {"sender": "bob", "receiver": "charlie", "amount": 5} + ] + tree = MerkleTree(txs) + + self.assertIsNone(tree.get_proof(10)) + self.assertIsNone(tree.get_proof(-1)) + + +if __name__ == '__main__': + unittest.main() From 557e78fe4385b2c962c557876c7dbc47793faa02 Mon Sep 17 00:00:00 2001 From: Muneer Ali Date: Sun, 22 Feb 2026 20:39:34 +0530 Subject: [PATCH 2/8] fix: address code review issues - Add blockchain persistence with save_to_file/load_from_file - Fix BlockResponse.hash to Optional[str] - Remove compute_tx_hash, use block.get_tx_hash() - Fix empty proof check in get_block (use is not None) - Handle None proof in verify_transaction - Use shared mempool in /mine endpoint - Change uvicorn host to 127.0.0.1 - Use precomputed tx_hashes from MerkleTree in get_tx_hash - Modernize type hints in merkle.py (remove unused Tuple import) - Add domain separation for leaf vs internal hashing - Add single transaction proof round-trip test - Add test_proof_verification_fails_wrong_tx_hash - Update FastAPI to 0.129.2, uvicorn to 0.41.0, pydantic to 2.12.5 --- api/main.py | 37 ++++++++++++++-------- core/block.py | 5 ++- core/chain.py | 75 ++++++++++++++++++++++++++++++++++++++++++-- core/merkle.py | 28 +++++++++-------- core/state.py | 13 ++++++++ requirements.txt | 6 ++-- tests/test_merkle.py | 21 +++++++++++++ 7 files changed, 151 insertions(+), 34 deletions(-) diff --git a/api/main.py b/api/main.py index 7e2822c..79edced 100644 --- a/api/main.py +++ b/api/main.py @@ -1,15 +1,15 @@ from fastapi import FastAPI, HTTPException, Query from pydantic import BaseModel from typing import List, Optional -import hashlib -import json from core import Blockchain, Block, State, Transaction from core.merkle import MerkleTree +from node import Mempool app = FastAPI(title="MiniChain API", description="SPV-enabled blockchain API") blockchain = Blockchain() +mempool = Mempool() class TransactionResponse(BaseModel): @@ -30,7 +30,7 @@ class BlockResponse(BaseModel): timestamp: int difficulty: Optional[int] nonce: int - hash: str + hash: Optional[str] = None transactions: List[TransactionResponse] merkle_proofs: Optional[dict] = None @@ -49,8 +49,9 @@ class ChainInfo(BaseModel): blocks: List[dict] -def compute_tx_hash(tx_dict: dict) -> str: - return hashlib.sha256(json.dumps(tx_dict, sort_keys=True).encode()).hexdigest() +@app.on_event("shutdown") +def shutdown_event(): + blockchain.save_to_file() @app.get("/") @@ -76,10 +77,11 @@ def get_block(block_index: int): merkle_proofs = {} for i, tx in enumerate(block.transactions): - tx_hash = compute_tx_hash(tx.to_dict()) - proof = block.get_merkle_proof(i) - if proof: - merkle_proofs[tx_hash] = proof + tx_hash = block.get_tx_hash(i) + if tx_hash: + proof = block.get_merkle_proof(i) + if proof is not None: + merkle_proofs[tx_hash] = proof return { **block_dict, @@ -100,7 +102,7 @@ def verify_transaction( tx_found = False tx_index = -1 for i, tx in enumerate(block.transactions): - tx_hash_computed = compute_tx_hash(tx.to_dict()) + tx_hash_computed = block.get_tx_hash(i) if tx_hash_computed == tx_hash: tx_found = True tx_index = i @@ -119,6 +121,16 @@ def verify_transaction( proof = block.get_merkle_proof(tx_index) merkle_root = block.merkle_root or "" + if proof is None: + return { + "tx_hash": tx_hash, + "block_index": block_index, + "merkle_root": merkle_root, + "proof": [], + "verification_status": False, + "message": "Failed to generate Merkle proof" + } + verification_status = MerkleTree.verify_proof(tx_hash, proof, merkle_root) return { @@ -134,14 +146,13 @@ def verify_transaction( @app.post("/mine") def mine_block_endpoint(): from main import mine_and_process_block - from node import Mempool - mempool = Mempool() pending_nonce_map = {} result = mine_and_process_block(blockchain, mempool, pending_nonce_map) if result[0]: + blockchain.save_to_file() return {"message": "Block mined successfully", "block": result[0].to_dict()} else: raise HTTPException(status_code=400, detail="Failed to mine block") @@ -149,4 +160,4 @@ def mine_block_endpoint(): if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) + uvicorn.run(app, host="127.0.0.1", port=8000) diff --git a/core/block.py b/core/block.py index 4ba40c3..c5e7d58 100644 --- a/core/block.py +++ b/core/block.py @@ -87,7 +87,6 @@ def get_merkle_proof(self, tx_index: int) -> Optional[List[dict]]: return self._merkle_tree.get_proof(tx_index) def get_tx_hash(self, tx_index: int) -> Optional[str]: - if tx_index < 0 or tx_index >= len(self.transactions): + if tx_index < 0 or tx_index >= len(self._merkle_tree.tx_hashes): return None - tx_dict = self.transactions[tx_index].to_dict() - return _sha256(json.dumps(tx_dict, sort_keys=True)) + return self._merkle_tree.tx_hashes[tx_index] diff --git a/core/chain.py b/core/chain.py index 9545864..2c0a5eb 100644 --- a/core/chain.py +++ b/core/chain.py @@ -1,22 +1,92 @@ from core.block import Block from core.state import State +from core.transaction import Transaction from consensus import calculate_hash import logging import threading +import json +import os +from typing import Optional logger = logging.getLogger(__name__) +DEFAULT_CHAIN_FILE = "chain_data.json" + class Blockchain: """ Manages the blockchain, validates blocks, and commits state transitions. """ - def __init__(self): + def __init__(self, chain_file: Optional[str] = None): self.chain = [] self.state = State() self._lock = threading.RLock() - self._create_genesis_block() + self._chain_file = chain_file or DEFAULT_CHAIN_FILE + self._load_from_file() + + def _load_from_file(self): + if not os.path.exists(self._chain_file): + self._create_genesis_block() + return + + try: + with open(self._chain_file, 'r') as f: + data = json.load(f) + + self.chain = [] + for block_data in data.get("chain", []): + transactions = [Transaction(**tx) for tx in block_data.get("transactions", [])] + block = Block( + index=block_data["index"], + previous_hash=block_data["previous_hash"], + transactions=transactions, + timestamp=block_data.get("timestamp"), + difficulty=block_data.get("difficulty") + ) + block.nonce = block_data.get("nonce", 0) + block.hash = block_data.get("hash") + self.chain.append(block) + + if data.get("state"): + self.state = State.from_dict(data["state"]) + + logger.info(f"Loaded chain with {len(self.chain)} blocks from {self._chain_file}") + + except Exception as e: + logger.warning(f"Failed to load chain from {self._chain_file}: {e}. Creating new genesis block.") + self._create_genesis_block() + + def save_to_file(self): + try: + data = { + "chain": [ + { + "index": block.index, + "previous_hash": block.previous_hash, + "merkle_root": block.merkle_root, + "timestamp": block.timestamp, + "difficulty": block.difficulty, + "nonce": block.nonce, + "hash": block.hash, + "transactions": [tx.to_dict() for tx in block.transactions] + } + for block in self.chain + ], + "state": self.state.to_dict() if hasattr(self.state, 'to_dict') else {} + } + + temp_file = self._chain_file + ".tmp" + with open(temp_file, 'w') as f: + json.dump(data, f, indent=2) + + os.replace(temp_file, self._chain_file) + logger.info(f"Saved chain with {len(self.chain)} blocks to {self._chain_file}") + + except Exception as e: + logger.error(f"Failed to save chain to {self._chain_file}: {e}") + if os.path.exists(temp_file): + os.remove(temp_file) def _create_genesis_block(self): """ @@ -74,4 +144,5 @@ def add_block(self, block): # All transactions valid → commit state and append block self.state = temp_state self.chain.append(block) + self.save_to_file() return True diff --git a/core/merkle.py b/core/merkle.py index 60c6dc9..071cc4e 100644 --- a/core/merkle.py +++ b/core/merkle.py @@ -1,6 +1,5 @@ import hashlib import json -from typing import List, Optional, Tuple from dataclasses import dataclass @@ -12,24 +11,27 @@ def _sha256(data: str) -> str: class MerkleProof: tx_hash: str merkle_root: str - proof: List[dict] + proof: list[dict] verification_status: bool class MerkleTree: - def __init__(self, transactions: List[dict]): + LEAF_PREFIX = "leaf:" + NODE_PREFIX = "node:" + + def __init__(self, transactions: list[dict]): self.transactions = transactions self.tx_hashes = self._hash_transactions() self.tree = self._build_tree() self.root = self._get_root() - def _hash_transactions(self) -> List[str]: + def _hash_transactions(self) -> list[str]: return [ - _sha256(json.dumps(tx, sort_keys=True)) + _sha256(self.LEAF_PREFIX + json.dumps(tx, sort_keys=True)) for tx in self.transactions ] - def _build_tree(self) -> List[List[str]]: + def _build_tree(self) -> list[list[str]]: if not self.tx_hashes: return [] @@ -43,21 +45,21 @@ def _build_tree(self) -> List[List[str]]: new_level = [] for i in range(0, len(current_level), 2): combined = current_level[i] + current_level[i + 1] - new_level.append(_sha256(combined)) + new_level.append(_sha256(self.NODE_PREFIX + combined)) tree.append(new_level) return tree - def _get_root(self) -> Optional[str]: + def _get_root(self) -> str | None: if not self.tree: return None return self.tree[-1][0] if self.tree[-1] else None - def get_merkle_root(self) -> Optional[str]: + def get_merkle_root(self) -> str | None: return self.root - def get_proof(self, index: int) -> Optional[List[dict]]: + def get_proof(self, index: int) -> list[dict] | None: if index < 0 or index >= len(self.tx_hashes): return None @@ -78,7 +80,7 @@ def get_proof(self, index: int) -> Optional[List[dict]]: return proof @staticmethod - def verify_proof(tx_hash: str, proof: List[dict], merkle_root: str) -> bool: + def verify_proof(tx_hash: str, proof: list[dict], merkle_root: str) -> bool: current_hash = tx_hash for item in proof: @@ -90,12 +92,12 @@ def verify_proof(tx_hash: str, proof: List[dict], merkle_root: str) -> bool: else: combined = current_hash + sibling_hash - current_hash = _sha256(combined) + current_hash = _sha256(MerkleTree.NODE_PREFIX + combined) return current_hash == merkle_root -def calculate_merkle_root(transactions: List[dict]) -> Optional[str]: +def calculate_merkle_root(transactions: list[dict]) -> str | None: if not transactions: return None tree = MerkleTree(transactions) diff --git a/core/state.py b/core/state.py index 17bc68c..1ad73d9 100644 --- a/core/state.py +++ b/core/state.py @@ -161,3 +161,16 @@ def credit_mining_reward(self, miner_address, reward=None): reward = reward if reward is not None else self.DEFAULT_MINING_REWARD account = self.get_account(miner_address) account['balance'] += reward + + def to_dict(self): + return { + "accounts": self.accounts, + "default_mining_reward": self.DEFAULT_MINING_REWARD + } + + @classmethod + def from_dict(cls, data): + new_state = cls() + new_state.accounts = data.get("accounts", {}) + new_state.DEFAULT_MINING_REWARD = data.get("default_mining_reward", 50) + return new_state diff --git a/requirements.txt b/requirements.txt index 3907305..2410f74 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ pynacl==1.6.2 libp2p==0.5.0 -fastapi==0.109.0 -uvicorn==0.27.0 -pydantic==2.5.3 +fastapi==0.129.2 +uvicorn==0.41.0 +pydantic==2.12.5 diff --git a/tests/test_merkle.py b/tests/test_merkle.py index e1e00d4..7b37a1f 100644 --- a/tests/test_merkle.py +++ b/tests/test_merkle.py @@ -13,6 +13,13 @@ def test_single_transaction(self): root = tree.get_merkle_root() self.assertIsNotNone(root) self.assertEqual(len(root), 64) + + proof = tree.get_proof(0) + self.assertEqual(proof, []) + + tx_hash = tree.tx_hashes[0] + result = MerkleTree.verify_proof(tx_hash, proof, root) + self.assertTrue(result) def test_two_transactions(self): txs = [ @@ -81,6 +88,20 @@ def test_proof_verification_fails_wrong_root(self): result = MerkleTree.verify_proof(tx_hash, proof, wrong_root) self.assertFalse(result) + def test_proof_verification_fails_wrong_tx_hash(self): + txs = [ + {"sender": "alice", "receiver": "bob", "amount": 10}, + {"sender": "bob", "receiver": "charlie", "amount": 5} + ] + tree = MerkleTree(txs) + + root = tree.get_merkle_root() + proof = tree.get_proof(0) + + tampered_tx_hash = "a" * 64 + result = MerkleTree.verify_proof(tampered_tx_hash, proof, root) + self.assertFalse(result) + def test_invalid_index(self): txs = [ {"sender": "alice", "receiver": "bob", "amount": 10}, From 49dd96968af300f2d7675d0b56fb334c4e3137a8 Mon Sep 17 00:00:00 2001 From: Muneer Ali Date: Sun, 22 Feb 2026 21:19:04 +0530 Subject: [PATCH 3/8] fix: additional code review issues - Extract _sha256 to shared core/utils.py - Update block.py and merkle.py to import from utils - Fix _build_tree to use copy of level (avoid mutation) - Remove unused MerkleProof dataclass - Fix temp_file declaration in chain.py save_to_file - Add chain validation in _load_from_file (previous_hash, hash, merkle_root) - Fix transaction timestamp handling in chain loader - Move mining import to module level in api/main.py - Add thread safety in get_block and get_chain (acquire lock) - Replace @app.on_event with lifespan context manager - Fix TransactionResponse for contract deploy (Optional receiver, Union data) - Add test_calculate_merkle_root_matches_tree --- api/main.py | 49 +++++++++++++++++++++--------------- core/block.py | 6 +---- core/chain.py | 59 +++++++++++++++++++++++++++++++++++++++----- core/merkle.py | 17 ++----------- core/utils.py | 5 ++++ tests/test_merkle.py | 10 ++++++++ 6 files changed, 100 insertions(+), 46 deletions(-) create mode 100644 core/utils.py diff --git a/api/main.py b/api/main.py index 79edced..54337cd 100644 --- a/api/main.py +++ b/api/main.py @@ -1,12 +1,21 @@ +from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Query from pydantic import BaseModel -from typing import List, Optional +from typing import List, Optional, Union from core import Blockchain, Block, State, Transaction from core.merkle import MerkleTree from node import Mempool +from main import mine_and_process_block -app = FastAPI(title="MiniChain API", description="SPV-enabled blockchain API") + +@asynccontextmanager +async def lifespan(app: FastAPI): + yield + blockchain.save_to_file() + + +app = FastAPI(title="MiniChain API", description="SPV-enabled blockchain API", lifespan=lifespan) blockchain = Blockchain() mempool = Mempool() @@ -14,10 +23,10 @@ class TransactionResponse(BaseModel): sender: str - receiver: str + receiver: Optional[str] = None amount: int nonce: int - data: Optional[dict] = None + data: Optional[Union[dict, str]] = None timestamp: int signature: Optional[str] = None hash: Optional[str] = None @@ -49,11 +58,6 @@ class ChainInfo(BaseModel): blocks: List[dict] -@app.on_event("shutdown") -def shutdown_event(): - blockchain.save_to_file() - - @app.get("/") def root(): return {"message": "MiniChain API with SPV Support"} @@ -61,18 +65,24 @@ def root(): @app.get("/chain", response_model=ChainInfo) def get_chain(): + with blockchain._lock: + chain_copy = list(blockchain.chain) + return { - "length": len(blockchain.chain), - "blocks": [block.to_dict() for block in blockchain.chain] + "length": len(chain_copy), + "blocks": [block.to_dict() for block in chain_copy] } @app.get("/block/{block_index}", response_model=BlockResponse) def get_block(block_index: int): - if block_index < 0 or block_index >= len(blockchain.chain): - raise HTTPException(status_code=404, detail="Block not found") + with blockchain._lock: + if block_index < 0 or block_index >= len(blockchain.chain): + raise HTTPException(status_code=404, detail="Block not found") + + block = blockchain.chain[block_index] + chain_length = len(blockchain.chain) - block = blockchain.chain[block_index] block_dict = block.to_dict() merkle_proofs = {} @@ -94,10 +104,11 @@ def verify_transaction( tx_hash: str = Query(..., description="Transaction hash to verify"), block_index: int = Query(..., description="Block index to verify against") ): - if block_index < 0 or block_index >= len(blockchain.chain): - raise HTTPException(status_code=404, detail="Block not found") - - block = blockchain.chain[block_index] + with blockchain._lock: + if block_index < 0 or block_index >= len(blockchain.chain): + raise HTTPException(status_code=404, detail="Block not found") + + block = blockchain.chain[block_index] tx_found = False tx_index = -1 @@ -145,8 +156,6 @@ def verify_transaction( @app.post("/mine") def mine_block_endpoint(): - from main import mine_and_process_block - pending_nonce_map = {} result = mine_and_process_block(blockchain, mempool, pending_nonce_map) diff --git a/core/block.py b/core/block.py index c5e7d58..01937e0 100644 --- a/core/block.py +++ b/core/block.py @@ -1,13 +1,9 @@ import time -import hashlib import json from typing import List, Optional from core.transaction import Transaction from core.merkle import MerkleTree - - -def _sha256(data: str) -> str: - return hashlib.sha256(data.encode()).hexdigest() +from core.utils import _sha256 class Block: diff --git a/core/chain.py b/core/chain.py index 2c0a5eb..9bd2542 100644 --- a/core/chain.py +++ b/core/chain.py @@ -30,13 +30,26 @@ def _load_from_file(self): self._create_genesis_block() return + temp_file = None try: with open(self._chain_file, 'r') as f: data = json.load(f) self.chain = [] for block_data in data.get("chain", []): - transactions = [Transaction(**tx) for tx in block_data.get("transactions", [])] + transactions = [] + for tx in block_data.get("transactions", []): + t = Transaction( + sender=tx["sender"], + receiver=tx.get("receiver"), + amount=tx["amount"], + nonce=tx["nonce"], + data=tx.get("data"), + signature=tx.get("signature") + ) + t.timestamp = tx.get("timestamp", t.timestamp) + transactions.append(t) + block = Block( index=block_data["index"], previous_hash=block_data["previous_hash"], @@ -48,16 +61,50 @@ def _load_from_file(self): block.hash = block_data.get("hash") self.chain.append(block) - if data.get("state"): - self.state = State.from_dict(data["state"]) - - logger.info(f"Loaded chain with {len(self.chain)} blocks from {self._chain_file}") + for i in range(1, len(self.chain)): + prev_block = self.chain[i - 1] + curr_block = self.chain[i] + + if curr_block.previous_hash != prev_block.hash: + logger.warning(f"Loaded chain has invalid previous_hash at block {i}. Rejecting loaded chain.") + self.chain = [] + break + + if curr_block.hash != calculate_hash(curr_block.to_header_dict()): + logger.warning(f"Loaded chain has invalid hash at block {i}. Rejecting loaded chain.") + self.chain = [] + break + + expected_merkle = curr_block.merkle_root + computed_merkle = Block( + index=curr_block.index, + previous_hash=curr_block.previous_hash, + transactions=curr_block.transactions, + timestamp=curr_block.timestamp, + difficulty=curr_block.difficulty + ).merkle_root + + if expected_merkle != computed_merkle: + logger.warning(f"Loaded chain has invalid merkle_root at block {i}. Rejecting loaded chain.") + self.chain = [] + break + else: + if len(self.chain) == 0: + self._create_genesis_block() + elif data.get("state"): + self.state = State.from_dict(data["state"]) + logger.info(f"Loaded chain with {len(self.chain)} blocks from {self._chain_file}") + return + + if not self.chain: + self._create_genesis_block() except Exception as e: logger.warning(f"Failed to load chain from {self._chain_file}: {e}. Creating new genesis block.") self._create_genesis_block() def save_to_file(self): + temp_file = None try: data = { "chain": [ @@ -85,7 +132,7 @@ def save_to_file(self): except Exception as e: logger.error(f"Failed to save chain to {self._chain_file}: {e}") - if os.path.exists(temp_file): + if temp_file is not None and os.path.exists(temp_file): os.remove(temp_file) def _create_genesis_block(self): diff --git a/core/merkle.py b/core/merkle.py index 071cc4e..31cd5f4 100644 --- a/core/merkle.py +++ b/core/merkle.py @@ -1,18 +1,5 @@ -import hashlib import json -from dataclasses import dataclass - - -def _sha256(data: str) -> str: - return hashlib.sha256(data.encode()).hexdigest() - - -@dataclass -class MerkleProof: - tx_hash: str - merkle_root: str - proof: list[dict] - verification_status: bool +from core.utils import _sha256 class MerkleTree: @@ -38,7 +25,7 @@ def _build_tree(self) -> list[list[str]]: tree = [self.tx_hashes[:]] while len(tree[-1]) > 1: - current_level = tree[-1] + current_level = list(tree[-1]) if len(current_level) % 2 != 0: current_level.append(current_level[-1]) diff --git a/core/utils.py b/core/utils.py new file mode 100644 index 0000000..9384b71 --- /dev/null +++ b/core/utils.py @@ -0,0 +1,5 @@ +import hashlib + + +def _sha256(data: str) -> str: + return hashlib.sha256(data.encode()).hexdigest() diff --git a/tests/test_merkle.py b/tests/test_merkle.py index 7b37a1f..2f700ed 100644 --- a/tests/test_merkle.py +++ b/tests/test_merkle.py @@ -102,6 +102,16 @@ def test_proof_verification_fails_wrong_tx_hash(self): result = MerkleTree.verify_proof(tampered_tx_hash, proof, root) self.assertFalse(result) + def test_calculate_merkle_root_matches_tree(self): + txs = [ + {"sender": "alice", "receiver": "bob", "amount": 10}, + {"sender": "bob", "receiver": "charlie", "amount": 5}, + {"sender": "charlie", "receiver": "dave", "amount": 3} + ] + root1 = calculate_merkle_root(txs) + root2 = MerkleTree(txs).get_merkle_root() + self.assertEqual(root1, root2) + def test_invalid_index(self): txs = [ {"sender": "alice", "receiver": "bob", "amount": 10}, From f38211a1e9039f657a646d426b5d194188f801d7 Mon Sep 17 00:00:00 2001 From: Muneer Ali Date: Sun, 22 Feb 2026 21:34:36 +0530 Subject: [PATCH 4/8] fix: additional code review issues - Extract mine_and_process_block to core/mining.py - Move blockchain/mempool creation to lifespan (lazy init) - Remove unused chain_length variable - Rename unused tx to _ in loops - Move pending_nonce_map to module level - Remove unused temp_file in _load_from_file - Add genesis block validation - Fix merkle validation to compare against block_data - Fix add_block to release lock before saving - Fix get_proof to emit self-sibling for odd levels - Update test_two_transactions to verify proofs --- api/main.py | 26 +++++++++-------- core/chain.py | 66 +++++++++++++++++++++++++++++--------------- core/merkle.py | 5 ++++ core/mining.py | 63 ++++++++++++++++++++++++++++++++++++++++++ main.py | 61 +--------------------------------------- tests/test_merkle.py | 5 ++++ 6 files changed, 133 insertions(+), 93 deletions(-) create mode 100644 core/mining.py diff --git a/api/main.py b/api/main.py index 54337cd..0828f9d 100644 --- a/api/main.py +++ b/api/main.py @@ -6,20 +6,25 @@ from core import Blockchain, Block, State, Transaction from core.merkle import MerkleTree from node import Mempool -from main import mine_and_process_block +from core.mining import mine_and_process_block + + +blockchain: Blockchain = None +mempool: Mempool = None +pending_nonce_map = {} @asynccontextmanager async def lifespan(app: FastAPI): + global blockchain, mempool + blockchain = Blockchain() + mempool = Mempool() yield blockchain.save_to_file() app = FastAPI(title="MiniChain API", description="SPV-enabled blockchain API", lifespan=lifespan) -blockchain = Blockchain() -mempool = Mempool() - class TransactionResponse(BaseModel): sender: str @@ -81,12 +86,11 @@ def get_block(block_index: int): raise HTTPException(status_code=404, detail="Block not found") block = blockchain.chain[block_index] - chain_length = len(blockchain.chain) block_dict = block.to_dict() merkle_proofs = {} - for i, tx in enumerate(block.transactions): + for i, _ in enumerate(block.transactions): tx_hash = block.get_tx_hash(i) if tx_hash: proof = block.get_merkle_proof(i) @@ -112,7 +116,7 @@ def verify_transaction( tx_found = False tx_index = -1 - for i, tx in enumerate(block.transactions): + for i, _ in enumerate(block.transactions): tx_hash_computed = block.get_tx_hash(i) if tx_hash_computed == tx_hash: tx_found = True @@ -156,13 +160,13 @@ def verify_transaction( @app.post("/mine") def mine_block_endpoint(): - pending_nonce_map = {} + global pending_nonce_map - result = mine_and_process_block(blockchain, mempool, pending_nonce_map) + block, *_ = mine_and_process_block(blockchain, mempool, pending_nonce_map) - if result[0]: + if block: blockchain.save_to_file() - return {"message": "Block mined successfully", "block": result[0].to_dict()} + return {"message": "Block mined successfully", "block": block.to_dict()} else: raise HTTPException(status_code=400, detail="Failed to mine block") diff --git a/core/chain.py b/core/chain.py index 9bd2542..8eef528 100644 --- a/core/chain.py +++ b/core/chain.py @@ -30,7 +30,6 @@ def _load_from_file(self): self._create_genesis_block() return - temp_file = None try: with open(self._chain_file, 'r') as f: data = json.load(f) @@ -61,9 +60,20 @@ def _load_from_file(self): block.hash = block_data.get("hash") self.chain.append(block) + if len(self.chain) == 0: + self._create_genesis_block() + return + + genesis = self.chain[0] + if genesis.hash != "0" * 64 or genesis.previous_hash != "0": + logger.warning("Loaded chain has invalid genesis block. Rejecting loaded chain.") + self._create_genesis_block() + return + for i in range(1, len(self.chain)): prev_block = self.chain[i - 1] curr_block = self.chain[i] + curr_data = data["chain"][i] if curr_block.previous_hash != prev_block.hash: logger.warning(f"Loaded chain has invalid previous_hash at block {i}. Rejecting loaded chain.") @@ -75,23 +85,15 @@ def _load_from_file(self): self.chain = [] break - expected_merkle = curr_block.merkle_root - computed_merkle = Block( - index=curr_block.index, - previous_hash=curr_block.previous_hash, - transactions=curr_block.transactions, - timestamp=curr_block.timestamp, - difficulty=curr_block.difficulty - ).merkle_root + stored_merkle = curr_data.get("merkle_root") + computed_merkle = curr_block.merkle_root - if expected_merkle != computed_merkle: + if stored_merkle != computed_merkle: logger.warning(f"Loaded chain has invalid merkle_root at block {i}. Rejecting loaded chain.") self.chain = [] break else: - if len(self.chain) == 0: - self._create_genesis_block() - elif data.get("state"): + if data.get("state"): self.state = State.from_dict(data["state"]) logger.info(f"Loaded chain with {len(self.chain)} blocks from {self._chain_file}") return @@ -152,7 +154,7 @@ def last_block(self): """ Returns the most recent block in the chain. """ - with self._lock: # Acquire lock for thread-safe access + with self._lock: return self.chain[-1] def add_block(self, block): @@ -162,34 +164,54 @@ def add_block(self, block): """ with self._lock: - # Check previous hash linkage if block.previous_hash != self.last_block.hash: logger.warning("Block %s rejected: Invalid previous hash %s != %s", block.index, block.previous_hash, self.last_block.hash) return False - # Check index linkage if block.index != self.last_block.index + 1: logger.warning("Block %s rejected: Invalid index %s != %s", block.index, block.index, self.last_block.index + 1) return False - # Verify block hash if block.hash != calculate_hash(block.to_header_dict()): logger.warning("Block %s rejected: Invalid hash %s", block.index, block.hash) return False - # Validate transactions on a temporary state copy temp_state = self.state.copy() for tx in block.transactions: result = temp_state.validate_and_apply(tx) - # Reject block if any transaction fails if not result: logger.warning("Block %s rejected: Transaction failed validation", block.index) return False - # All transactions valid → commit state and append block self.state = temp_state self.chain.append(block) - self.save_to_file() - return True + + data = { + "chain": [ + { + "index": b.index, + "previous_hash": b.previous_hash, + "merkle_root": b.merkle_root, + "timestamp": b.timestamp, + "difficulty": b.difficulty, + "nonce": b.nonce, + "hash": b.hash, + "transactions": [tx.to_dict() for tx in b.transactions] + } + for b in self.chain + ], + "state": self.state.to_dict() if hasattr(self.state, 'to_dict') else {} + } + + try: + temp_file = self._chain_file + ".tmp" + with open(temp_file, 'w') as f: + json.dump(data, f, indent=2) + os.replace(temp_file, self._chain_file) + logger.info(f"Saved chain with {len(self.chain)} blocks to {self._chain_file}") + except Exception as e: + logger.error(f"Failed to save chain to {self._chain_file}: {e}") + + return True diff --git a/core/merkle.py b/core/merkle.py index 31cd5f4..b8a6e83 100644 --- a/core/merkle.py +++ b/core/merkle.py @@ -61,6 +61,11 @@ def get_proof(self, index: int) -> list[dict] | None: "hash": level[sibling_idx], "position": "left" if is_right else "right" }) + else: + proof.append({ + "hash": level[index], + "position": "left" if is_right else "right" + }) index //= 2 diff --git a/core/mining.py b/core/mining.py new file mode 100644 index 0000000..8348ad7 --- /dev/null +++ b/core/mining.py @@ -0,0 +1,63 @@ +import logging +import re +from core import Transaction, Block +from consensus import mine_block + + +logger = logging.getLogger(__name__) + +BURN_ADDRESS = "0" * 40 + + +def mine_and_process_block(chain, mempool, pending_nonce_map): + """ + Mine block and let Blockchain handle validation + state updates. + DO NOT manually apply transactions again. + """ + + pending_txs = mempool.get_transactions_for_block() + + block = Block( + index=chain.last_block.index + 1, + previous_hash=chain.last_block.hash, + transactions=pending_txs, + ) + + mined_block = mine_block(block) + + if not hasattr(mined_block, "miner"): + mined_block.miner = BURN_ADDRESS + + deployed_contracts: list[str] = [] + + if chain.add_block(mined_block): + logger.info("Block #%s added", mined_block.index) + + miner_attr = getattr(mined_block, "miner", None) + if isinstance(miner_attr, str) and re.match(r'^[0-9a-fA-F]{40}$', miner_attr): + miner_address = miner_attr + else: + logger.warning("Invalid miner address. Crediting burn address.") + miner_address = BURN_ADDRESS + + chain.state.credit_mining_reward(miner_address) + + for tx in mined_block.transactions: + sync_nonce(chain.state, pending_nonce_map, tx.sender) + + result = chain.state.get_account(tx.receiver) if tx.receiver else None + if isinstance(result, dict): + deployed_contracts.append(tx.receiver) + + return mined_block, deployed_contracts + else: + logger.error("Block rejected by chain") + return None, [] + + +def sync_nonce(state, pending_nonce_map, address): + account = state.get_account(address) + if account and "nonce" in account: + pending_nonce_map[address] = account["nonce"] + else: + pending_nonce_map[address] = 0 diff --git a/main.py b/main.py index d9670c0..26adeae 100644 --- a/main.py +++ b/main.py @@ -1,19 +1,16 @@ import asyncio import logging -import re from nacl.signing import SigningKey from nacl.encoding import HexEncoder from core import Transaction, Blockchain, Block, State +from core.mining import mine_and_process_block, sync_nonce from node import Mempool from network import P2PNetwork -from consensus import mine_block logger = logging.getLogger(__name__) -BURN_ADDRESS = "0" * 40 - def create_wallet(): sk = SigningKey.generate() @@ -21,62 +18,6 @@ def create_wallet(): return sk, pk -def mine_and_process_block(chain, mempool, pending_nonce_map): - """ - Mine block and let Blockchain handle validation + state updates. - DO NOT manually apply transactions again. - """ - - pending_txs = mempool.get_transactions_for_block() - - block = Block( - index=chain.last_block.index + 1, - previous_hash=chain.last_block.hash, - transactions=pending_txs, - ) - - mined_block = mine_block(block) - - if not hasattr(mined_block, "miner"): - mined_block.miner = BURN_ADDRESS - - deployed_contracts: list[str] = [] - - if chain.add_block(mined_block): - logger.info("Block #%s added", mined_block.index) - - miner_attr = getattr(mined_block, "miner", None) - if isinstance(miner_attr, str) and re.match(r'^[0-9a-fA-F]{40}$', miner_attr): - miner_address = miner_attr - else: - logger.warning("Invalid miner address. Crediting burn address.") - miner_address = BURN_ADDRESS - - # Reward must go through chain.state - chain.state.credit_mining_reward(miner_address) - - for tx in mined_block.transactions: - sync_nonce(chain.state, pending_nonce_map, tx.sender) - - # Track deployed contracts if your state.apply_transaction returns address - result = chain.state.get_account(tx.receiver) if tx.receiver else None - if isinstance(result, dict): - deployed_contracts.append(tx.receiver) - - return mined_block, deployed_contracts - else: - logger.error("Block rejected by chain") - return None, [] - - -def sync_nonce(state, pending_nonce_map, address): - account = state.get_account(address) - if account and "nonce" in account: - pending_nonce_map[address] = account["nonce"] - else: - pending_nonce_map[address] = 0 - - async def node_loop(): logger.info("Starting MiniChain Node with Smart Contracts") diff --git a/tests/test_merkle.py b/tests/test_merkle.py index 2f700ed..319cbdd 100644 --- a/tests/test_merkle.py +++ b/tests/test_merkle.py @@ -34,6 +34,11 @@ def test_two_transactions(self): proof1 = tree.get_proof(1) self.assertIsNotNone(proof0) self.assertIsNotNone(proof1) + + result0 = MerkleTree.verify_proof(tree.tx_hashes[0], proof0, root) + result1 = MerkleTree.verify_proof(tree.tx_hashes[1], proof1, root) + self.assertTrue(result0) + self.assertTrue(result1) def test_odd_transaction_count(self): txs = [ From dae3fcd47f62dfe700278d4c1fd8f4ecb37234a5 Mon Sep 17 00:00:00 2001 From: Muneer Ali Date: Sun, 22 Feb 2026 21:47:45 +0530 Subject: [PATCH 5/8] fix: additional code review issues - Add Optional/Dict typing annotations for blockchain, mempool, pending_nonce_map - Add public get_chain_copy() method to Blockchain class - Remove redundant blockchain.save_to_file() in mine_block_endpoint - Fix _load_from_file control flow for loaded chain logging - Add _serialize_chain_data() and _save_to_file_unlocked() helpers - Fix save_to_file to use lock properly - Fix mining.py to return transactions on mining failure - Add proof verification for odd_transaction_count test --- api/main.py | 34 ++++++++--------- core/chain.py | 91 +++++++++++++++++--------------------------- core/mining.py | 22 +++++++---- tests/test_merkle.py | 5 +++ 4 files changed, 71 insertions(+), 81 deletions(-) diff --git a/api/main.py b/api/main.py index 0828f9d..9586541 100644 --- a/api/main.py +++ b/api/main.py @@ -1,7 +1,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Query from pydantic import BaseModel -from typing import List, Optional, Union +from typing import List, Optional, Union, Dict from core import Blockchain, Block, State, Transaction from core.merkle import MerkleTree @@ -9,9 +9,9 @@ from core.mining import mine_and_process_block -blockchain: Blockchain = None -mempool: Mempool = None -pending_nonce_map = {} +blockchain: Optional[Blockchain] = None +mempool: Optional[Mempool] = None +pending_nonce_map: Dict[str, int] = {} @asynccontextmanager @@ -70,8 +70,7 @@ def root(): @app.get("/chain", response_model=ChainInfo) def get_chain(): - with blockchain._lock: - chain_copy = list(blockchain.chain) + chain_copy = blockchain.get_chain_copy() return { "length": len(chain_copy), @@ -81,11 +80,12 @@ def get_chain(): @app.get("/block/{block_index}", response_model=BlockResponse) def get_block(block_index: int): - with blockchain._lock: - if block_index < 0 or block_index >= len(blockchain.chain): - raise HTTPException(status_code=404, detail="Block not found") - - block = blockchain.chain[block_index] + chain_copy = blockchain.get_chain_copy() + + if block_index < 0 or block_index >= len(chain_copy): + raise HTTPException(status_code=404, detail="Block not found") + + block = chain_copy[block_index] block_dict = block.to_dict() @@ -108,11 +108,12 @@ def verify_transaction( tx_hash: str = Query(..., description="Transaction hash to verify"), block_index: int = Query(..., description="Block index to verify against") ): - with blockchain._lock: - if block_index < 0 or block_index >= len(blockchain.chain): - raise HTTPException(status_code=404, detail="Block not found") - - block = blockchain.chain[block_index] + chain_copy = blockchain.get_chain_copy() + + if block_index < 0 or block_index >= len(chain_copy): + raise HTTPException(status_code=404, detail="Block not found") + + block = chain_copy[block_index] tx_found = False tx_index = -1 @@ -165,7 +166,6 @@ def mine_block_endpoint(): block, *_ = mine_and_process_block(blockchain, mempool, pending_nonce_map) if block: - blockchain.save_to_file() return {"message": "Block mined successfully", "block": block.to_dict()} else: raise HTTPException(status_code=400, detail="Failed to mine block") diff --git a/core/chain.py b/core/chain.py index 8eef528..83f4c65 100644 --- a/core/chain.py +++ b/core/chain.py @@ -25,6 +25,10 @@ def __init__(self, chain_file: Optional[str] = None): self._chain_file = chain_file or DEFAULT_CHAIN_FILE self._load_from_file() + def get_chain_copy(self): + with self._lock: + return list(self.chain) + def _load_from_file(self): if not os.path.exists(self._chain_file): self._create_genesis_block() @@ -62,12 +66,14 @@ def _load_from_file(self): if len(self.chain) == 0: self._create_genesis_block() + logger.info(f"Loaded chain with {len(self.chain)} blocks from {self._chain_file}") return genesis = self.chain[0] if genesis.hash != "0" * 64 or genesis.previous_hash != "0": logger.warning("Loaded chain has invalid genesis block. Rejecting loaded chain.") self._create_genesis_block() + logger.info(f"Created new genesis block after rejecting invalid chain") return for i in range(1, len(self.chain)): @@ -95,36 +101,38 @@ def _load_from_file(self): else: if data.get("state"): self.state = State.from_dict(data["state"]) - logger.info(f"Loaded chain with {len(self.chain)} blocks from {self._chain_file}") - return + logger.info(f"Loaded chain with {len(self.chain)} blocks from {self._chain_file}") + return if not self.chain: self._create_genesis_block() + logger.info(f"Created new genesis block after rejecting invalid chain") except Exception as e: logger.warning(f"Failed to load chain from {self._chain_file}: {e}. Creating new genesis block.") self._create_genesis_block() - def save_to_file(self): + def _serialize_chain_data(self): + return { + "chain": [ + { + "index": block.index, + "previous_hash": block.previous_hash, + "merkle_root": block.merkle_root, + "timestamp": block.timestamp, + "difficulty": block.difficulty, + "nonce": block.nonce, + "hash": block.hash, + "transactions": [tx.to_dict() for tx in block.transactions] + } + for block in self.chain + ], + "state": self.state.to_dict() if hasattr(self.state, 'to_dict') else {} + } + + def _save_to_file_unlocked(self, data): temp_file = None try: - data = { - "chain": [ - { - "index": block.index, - "previous_hash": block.previous_hash, - "merkle_root": block.merkle_root, - "timestamp": block.timestamp, - "difficulty": block.difficulty, - "nonce": block.nonce, - "hash": block.hash, - "transactions": [tx.to_dict() for tx in block.transactions] - } - for block in self.chain - ], - "state": self.state.to_dict() if hasattr(self.state, 'to_dict') else {} - } - temp_file = self._chain_file + ".tmp" with open(temp_file, 'w') as f: json.dump(data, f, indent=2) @@ -137,10 +145,12 @@ def save_to_file(self): if temp_file is not None and os.path.exists(temp_file): os.remove(temp_file) + def save_to_file(self): + with self._lock: + data = self._serialize_chain_data() + self._save_to_file_unlocked(data) + def _create_genesis_block(self): - """ - Creates the genesis block with a fixed hash. - """ genesis_block = Block( index=0, previous_hash="0", @@ -151,18 +161,10 @@ def _create_genesis_block(self): @property def last_block(self): - """ - Returns the most recent block in the chain. - """ with self._lock: return self.chain[-1] def add_block(self, block): - """ - Validates and adds a block to the chain if all transactions succeed. - Uses a copied State to ensure atomic validation. - """ - with self._lock: if block.previous_hash != self.last_block.hash: logger.warning("Block %s rejected: Invalid previous hash %s != %s", block.index, block.previous_hash, self.last_block.hash) @@ -188,30 +190,7 @@ def add_block(self, block): self.state = temp_state self.chain.append(block) - data = { - "chain": [ - { - "index": b.index, - "previous_hash": b.previous_hash, - "merkle_root": b.merkle_root, - "timestamp": b.timestamp, - "difficulty": b.difficulty, - "nonce": b.nonce, - "hash": b.hash, - "transactions": [tx.to_dict() for tx in b.transactions] - } - for b in self.chain - ], - "state": self.state.to_dict() if hasattr(self.state, 'to_dict') else {} - } - - try: - temp_file = self._chain_file + ".tmp" - with open(temp_file, 'w') as f: - json.dump(data, f, indent=2) - os.replace(temp_file, self._chain_file) - logger.info(f"Saved chain with {len(self.chain)} blocks to {self._chain_file}") - except Exception as e: - logger.error(f"Failed to save chain to {self._chain_file}: {e}") + data = self._serialize_chain_data() + self._save_to_file_unlocked(data) return True diff --git a/core/mining.py b/core/mining.py index 8348ad7..a19fbae 100644 --- a/core/mining.py +++ b/core/mining.py @@ -1,7 +1,7 @@ import logging import re from core import Transaction, Block -from consensus import mine_block +from consensus import mine_block, MiningExceededError logger = logging.getLogger(__name__) @@ -10,12 +10,8 @@ def mine_and_process_block(chain, mempool, pending_nonce_map): - """ - Mine block and let Blockchain handle validation + state updates. - DO NOT manually apply transactions again. - """ - pending_txs = mempool.get_transactions_for_block() + tx_hashes = [mempool._get_tx_id(tx) for tx in pending_txs] block = Block( index=chain.last_block.index + 1, @@ -23,7 +19,14 @@ def mine_and_process_block(chain, mempool, pending_nonce_map): transactions=pending_txs, ) - mined_block = mine_block(block) + try: + mined_block = mine_block(block) + except MiningExceededError: + for tx in pending_txs: + mempool._pending_txs.append(tx) + mempool._seen_tx_ids.update(tx_hashes) + logger.warning("Mining failed, transactions returned to mempool") + return None, [] if not hasattr(mined_block, "miner"): mined_block.miner = BURN_ADDRESS @@ -51,7 +54,10 @@ def mine_and_process_block(chain, mempool, pending_nonce_map): return mined_block, deployed_contracts else: - logger.error("Block rejected by chain") + for tx in pending_txs: + mempool._pending_txs.append(tx) + mempool._seen_tx_ids.update(tx_hashes) + logger.error("Block rejected by chain, transactions returned to mempool") return None, [] diff --git a/tests/test_merkle.py b/tests/test_merkle.py index 319cbdd..dd5b4b6 100644 --- a/tests/test_merkle.py +++ b/tests/test_merkle.py @@ -49,6 +49,11 @@ def test_odd_transaction_count(self): tree = MerkleTree(txs) root = tree.get_merkle_root() self.assertIsNotNone(root) + + for i in range(len(txs)): + proof = tree.get_proof(i) + result = MerkleTree.verify_proof(tree.tx_hashes[i], proof, root) + self.assertTrue(result) def test_proof_generation(self): txs = [ From f75d68ae01e22570e7c689a28f4627d29777f636 Mon Sep 17 00:00:00 2001 From: Muneerali199 Date: Mon, 23 Feb 2026 14:13:23 +0530 Subject: [PATCH 6/8] Fix TOCTOU race in mining.py by capturing chain.last_block once - Store chain.last_block in local variable last_block to prevent race condition - Use last_block.index and last_block.hash when constructing new Block - Ensures index and previous_hash come from the same block instance --- core/mining.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/core/mining.py b/core/mining.py index a19fbae..6b73d7e 100644 --- a/core/mining.py +++ b/core/mining.py @@ -13,9 +13,10 @@ def mine_and_process_block(chain, mempool, pending_nonce_map): pending_txs = mempool.get_transactions_for_block() tx_hashes = [mempool._get_tx_id(tx) for tx in pending_txs] + last_block = chain.last_block block = Block( - index=chain.last_block.index + 1, - previous_hash=chain.last_block.hash, + index=last_block.index + 1, + previous_hash=last_block.hash, transactions=pending_txs, ) From 3a3b299989a349901e99cfb9b01491361b4116ea Mon Sep 17 00:00:00 2001 From: Muneerali199 Date: Mon, 23 Feb 2026 15:20:07 +0530 Subject: [PATCH 7/8] Fix multiple issues for thread safety and code quality - Remove unnecessary global pending_nonce_map declaration in mine_block_endpoint - Update logger message for empty chain in _load_from_file to clarify genesis block creation - Remove unnecessary f-string prefixes on logger.info calls without placeholders - Fix _save_to_file_unlocked to accept explicit block count to avoid stale length reads - Add return_transactions method to Mempool and use it instead of direct attribute manipulation - Fix redundant mempool._seen_tx_ids.update calls in mining rollback loops --- api/main.py | 2 -- core/chain.py | 26 ++++++++++++++------------ core/mining.py | 12 ++++-------- node/mempool.py | 15 ++++++++++++--- 4 files changed, 30 insertions(+), 25 deletions(-) diff --git a/api/main.py b/api/main.py index 9586541..2a20b46 100644 --- a/api/main.py +++ b/api/main.py @@ -161,8 +161,6 @@ def verify_transaction( @app.post("/mine") def mine_block_endpoint(): - global pending_nonce_map - block, *_ = mine_and_process_block(blockchain, mempool, pending_nonce_map) if block: diff --git a/core/chain.py b/core/chain.py index 83f4c65..20fd2b8 100644 --- a/core/chain.py +++ b/core/chain.py @@ -64,16 +64,16 @@ def _load_from_file(self): block.hash = block_data.get("hash") self.chain.append(block) - if len(self.chain) == 0: +if len(self.chain) == 0: self._create_genesis_block() - logger.info(f"Loaded chain with {len(self.chain)} blocks from {self._chain_file}") + logger.info("Chain file %s contained no blocks; created genesis block", self._chain_file) return genesis = self.chain[0] if genesis.hash != "0" * 64 or genesis.previous_hash != "0": - logger.warning("Loaded chain has invalid genesis block. Rejecting loaded chain.") +logger.warning("Loaded chain has invalid genesis block. Rejecting loaded chain.") self._create_genesis_block() - logger.info(f"Created new genesis block after rejecting invalid chain") + logger.info("Created new genesis block after rejecting invalid chain") return for i in range(1, len(self.chain)): @@ -104,9 +104,9 @@ def _load_from_file(self): logger.info(f"Loaded chain with {len(self.chain)} blocks from {self._chain_file}") return - if not self.chain: +if not self.chain: self._create_genesis_block() - logger.info(f"Created new genesis block after rejecting invalid chain") + logger.info("Created new genesis block after rejecting invalid chain") except Exception as e: logger.warning(f"Failed to load chain from {self._chain_file}: {e}. Creating new genesis block.") @@ -130,7 +130,7 @@ def _serialize_chain_data(self): "state": self.state.to_dict() if hasattr(self.state, 'to_dict') else {} } - def _save_to_file_unlocked(self, data): +def _save_to_file_unlocked(self, data, block_count): temp_file = None try: temp_file = self._chain_file + ".tmp" @@ -138,17 +138,18 @@ def _save_to_file_unlocked(self, data): json.dump(data, f, indent=2) os.replace(temp_file, self._chain_file) - logger.info(f"Saved chain with {len(self.chain)} blocks to {self._chain_file}") + logger.info("Saved chain with %s blocks to %s", block_count, self._chain_file) except Exception as e: logger.error(f"Failed to save chain to {self._chain_file}: {e}") if temp_file is not None and os.path.exists(temp_file): os.remove(temp_file) - def save_to_file(self): +def save_to_file(self): with self._lock: data = self._serialize_chain_data() - self._save_to_file_unlocked(data) + block_count = len(self.chain) + self._save_to_file_unlocked(data, block_count) def _create_genesis_block(self): genesis_block = Block( @@ -187,10 +188,11 @@ def add_block(self, block): logger.warning("Block %s rejected: Transaction failed validation", block.index) return False - self.state = temp_state +self.state = temp_state self.chain.append(block) data = self._serialize_chain_data() + block_count = len(self.chain) - self._save_to_file_unlocked(data) + self._save_to_file_unlocked(data, block_count) return True diff --git a/core/mining.py b/core/mining.py index 6b73d7e..d40c50b 100644 --- a/core/mining.py +++ b/core/mining.py @@ -22,10 +22,8 @@ def mine_and_process_block(chain, mempool, pending_nonce_map): try: mined_block = mine_block(block) - except MiningExceededError: - for tx in pending_txs: - mempool._pending_txs.append(tx) - mempool._seen_tx_ids.update(tx_hashes) +except MiningExceededError: + mempool.return_transactions(pending_txs) logger.warning("Mining failed, transactions returned to mempool") return None, [] @@ -54,10 +52,8 @@ def mine_and_process_block(chain, mempool, pending_nonce_map): deployed_contracts.append(tx.receiver) return mined_block, deployed_contracts - else: - for tx in pending_txs: - mempool._pending_txs.append(tx) - mempool._seen_tx_ids.update(tx_hashes) +else: + mempool.return_transactions(pending_txs) logger.error("Block rejected by chain, transactions returned to mempool") return None, [] diff --git a/node/mempool.py b/node/mempool.py index 8bb941a..2429bae 100644 --- a/node/mempool.py +++ b/node/mempool.py @@ -18,7 +18,7 @@ def _get_tx_id(self, tx): """ return calculate_hash(tx.to_dict()) - def add_transaction(self, tx): +def add_transaction(self, tx): """ Adds a transaction to the pool if: - Signature is valid @@ -38,14 +38,23 @@ def add_transaction(self, tx): if len(self._pending_txs) >= self.max_size: # Simple eviction: drop oldest or reject. Here we reject. - logger.warning("Mempool: Full, rejecting transaction") + logger.warning(f"Mempool: Pool full, transaction rejected") return False self._pending_txs.append(tx) self._seen_tx_ids.add(tx_id) - + logger.info(f"Mempool: Added transaction {tx_id}") return True + def return_transactions(self, transactions): + """ + Return transactions to the pool after failed mining attempt. + """ + tx_ids = {self._get_tx_id(tx) for tx in transactions} + with self._lock: + self._pending_txs.extend(transactions) + self._seen_tx_ids.update(tx_ids) + def get_transactions_for_block(self): """ Returns pending transactions and clears the pool. From e9c6ee192de83b3911efe291b535e6d25cc42999 Mon Sep 17 00:00:00 2001 From: Muneerali199 Date: Mon, 23 Feb 2026 16:19:36 +0530 Subject: [PATCH 8/8] Fix indentation and remove incorrect contract deployment logic - Fix indentation in chain.py logger.warning call - Remove incorrect contract deployment tracking logic that appended tx.receiver to deployed_contracts list regardless of actual contract creation - The loop now only calls sync_nonce as intended --- core/chain.py | 2 +- core/mining.py | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/core/chain.py b/core/chain.py index 20fd2b8..60e27e7 100644 --- a/core/chain.py +++ b/core/chain.py @@ -71,7 +71,7 @@ def _load_from_file(self): genesis = self.chain[0] if genesis.hash != "0" * 64 or genesis.previous_hash != "0": -logger.warning("Loaded chain has invalid genesis block. Rejecting loaded chain.") + logger.warning("Loaded chain has invalid genesis block. Rejecting loaded chain.") self._create_genesis_block() logger.info("Created new genesis block after rejecting invalid chain") return diff --git a/core/mining.py b/core/mining.py index d40c50b..b2eb455 100644 --- a/core/mining.py +++ b/core/mining.py @@ -44,13 +44,9 @@ def mine_and_process_block(chain, mempool, pending_nonce_map): chain.state.credit_mining_reward(miner_address) - for tx in mined_block.transactions: +for tx in mined_block.transactions: sync_nonce(chain.state, pending_nonce_map, tx.sender) - result = chain.state.get_account(tx.receiver) if tx.receiver else None - if isinstance(result, dict): - deployed_contracts.append(tx.receiver) - return mined_block, deployed_contracts else: mempool.return_transactions(pending_txs)