From 01536203f4d0fdb61aebe587460c585e5b6e9a89 Mon Sep 17 00:00:00 2001 From: sanaica Date: Sun, 22 Mar 2026 14:27:19 +0530 Subject: [PATCH 01/27] feat: implement canonical serialization and add unit tests --- minichain/block.py | 7 ++++- tests/test_serialization.py | 52 +++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 tests/test_serialization.py diff --git a/minichain/block.py b/minichain/block.py index 9854cf4..0188af6 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -2,7 +2,7 @@ import hashlib from typing import List, Optional from .transaction import Transaction -from .serialization import canonical_json_hash +from .serialization import canonical_json_hash, canonical_json_bytes def _sha256(data: str) -> str: return hashlib.sha256(data.encode()).hexdigest() @@ -117,3 +117,8 @@ def from_dict(cls, payload: dict): if "merkle_root" in payload: block.merkle_root = payload["merkle_root"] return block + + @property + def canonical_payload(self) -> bytes: + """Returns the full block (header + body) as canonical bytes for networking.""" + return canonical_json_bytes(self.to_dict()) diff --git a/tests/test_serialization.py b/tests/test_serialization.py new file mode 100644 index 0000000..9d1ba69 --- /dev/null +++ b/tests/test_serialization.py @@ -0,0 +1,52 @@ +from minichain.serialization import canonical_json_hash +from minichain.transaction import Transaction +from minichain.block import Block + +def test_raw_data_determinism(): + print("--- Testing Raw Data Determinism ---") + # Same data, different key order + data_v1 = {"amount": 100, "nonce": 1, "receiver": "Alice", "sender": "Bob"} + data_v2 = {"sender": "Bob", "receiver": "Alice", "nonce": 1, "amount": 100} + + hash_1 = canonical_json_hash(data_v1) + hash_2 = canonical_json_hash(data_v2) + + print(f"Hash 1: {hash_1}") + print(f"Hash 2: {hash_2}") + assert hash_1 == hash_2 + print("Success: Raw hashes match regardless of key order!\n") + +def test_transaction_id_stability(): + print("--- Testing Transaction ID Stability ---") + # Create a transaction + tx = Transaction(sender="Alice_PK", receiver="Bob_PK", amount=50, nonce=1) + + first_id = tx.tx_id + # Re-triggering the ID calculation + second_id = tx.tx_id + + print(f"TX ID: {first_id}") + assert first_id == second_id + print("Success: Transaction ID is stable and deterministic!\n") + +def test_block_hash_consistency(): + print("--- Testing Block Hash Consistency ---") + # Create a block with one transaction + tx = Transaction(sender="A", receiver="B", amount=10, nonce=5) + block = Block(index=1, previous_hash="0"*64, transactions=[tx], difficulty=2) + + initial_hash = block.compute_hash() + print(f"Initial Block Hash: {initial_hash}") + + # Manually re-computing to ensure it's identical + assert block.compute_hash() == initial_hash + print("Success: Block hash is consistent!\n") + +if __name__ == "__main__": + try: + test_raw_data_determinism() + test_transaction_id_stability() + test_block_hash_consistency() + print("ALL CANONICAL TESTS PASSED!") + except AssertionError as e: + print("TEST FAILED: Serialization is not deterministic!") \ No newline at end of file From 2359fe4cfecde953e34fd047b4e1bb925ab46647 Mon Sep 17 00:00:00 2001 From: sanaica Date: Sun, 22 Mar 2026 14:57:58 +0530 Subject: [PATCH 02/27] refactor: implement canonical p2p broadcasting and cross-instance tests --- minichain/p2p.py | 27 +++++++++++++----------- tests/test_serialization.py | 41 ++++++++++++++++++++++--------------- 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/minichain/p2p.py b/minichain/p2p.py index ee52d7d..8f622fd 100644 --- a/minichain/p2p.py +++ b/minichain/p2p.py @@ -9,7 +9,7 @@ import json import logging -from .serialization import canonical_json_hash +from .serialization import canonical_json_hash, canonical_json_dumps from .validators import is_valid_receiver logger = logging.getLogger(__name__) @@ -46,10 +46,8 @@ def register_handler(self, handler_callback): async def start(self, port: int = 9000): """Start listening for incoming peer connections on the given port.""" self._port = port - self._server = await asyncio.start_server( - self._handle_incoming, host, port - ) - logger.info("Network: Listening on %s:%d", host, port) + self._server = await asyncio.start_server(self._handle_incoming, "0.0.0.0", port) + logger.info("Network: Listening on 0.0.0.0:%d", port) async def stop(self): """Gracefully shut down the server and disconnect all peers.""" @@ -204,9 +202,7 @@ def _validate_block_payload(self, payload): ) def _validate_message(self, message): - if not isinstance(message, dict): - return False - if set(message) != {"type", "data"}: + if not {"type", "data"}.issubset(set(message)): return False msg_type = message.get("type") @@ -303,6 +299,7 @@ async def _listen_to_peer( async def _broadcast_raw(self, payload: dict): """Send a JSON message to every connected peer.""" line = (json.dumps(payload) + "\n").encode() + line = (canonical_json_dumps(payload) + "\n").encode() disconnected = [] for reader, writer in self._peers: try: @@ -333,10 +330,16 @@ async def broadcast_transaction(self, tx): async def broadcast_block(self, block, miner=None): logger.info("Network: Broadcasting Block #%d", block.index) - block_payload = block.to_dict() - if miner is not None: - block_payload["miner"] = miner - payload = {"type": "block", "data": block_payload} + + # 1. Convert block to a dict (deterministic via serialization.py) + block_data = json.loads(block.canonical_payload.decode('utf-8')) + + # 2. Wrap it in an envelope where 'miner' is OUTSIDE the 'data' + payload = { + "type": "block", + "data": block_data, + "miner": miner + } self._mark_seen("block", payload["data"]) await self._broadcast_raw(payload) diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 9d1ba69..4dcc2de 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -29,24 +29,31 @@ def test_transaction_id_stability(): assert first_id == second_id print("Success: Transaction ID is stable and deterministic!\n") -def test_block_hash_consistency(): - print("--- Testing Block Hash Consistency ---") - # Create a block with one transaction - tx = Transaction(sender="A", receiver="B", amount=10, nonce=5) - block = Block(index=1, previous_hash="0"*64, transactions=[tx], difficulty=2) +def test_block_serialization_determinism(): + print("--- Testing Block Serialization & Cross-Instance Determinism ---") + tx_params = {"sender": "A", "receiver": "B", "amount": 10, "nonce": 5} - initial_hash = block.compute_hash() - print(f"Initial Block Hash: {initial_hash}") + # Create two SEPARATE block instances with the exact same data + tx1 = Transaction(**tx_params) + block1 = Block(index=1, previous_hash="0"*64, transactions=[tx1], difficulty=2) - # Manually re-computing to ensure it's identical - assert block.compute_hash() == initial_hash - print("Success: Block hash is consistent!\n") + tx2 = Transaction(**tx_params) + block2 = Block(index=1, previous_hash="0"*64, transactions=[tx2], difficulty=2) + + # 1. Test stable bytes on one instance (same object, twice) + assert block1.canonical_payload == block1.canonical_payload + + # 2. Test cross-instance determinism (different objects, same data) + assert block1.canonical_payload == block2.canonical_payload, "Identical blocks must have identical payloads" + + # 3. Test hash consistency + assert block1.compute_hash() == block2.compute_hash() + + print("✅ Success: Block serialization is cross-instance deterministic!\n") if __name__ == "__main__": - try: - test_raw_data_determinism() - test_transaction_id_stability() - test_block_hash_consistency() - print("ALL CANONICAL TESTS PASSED!") - except AssertionError as e: - print("TEST FAILED: Serialization is not deterministic!") \ No newline at end of file + # Removed try/except so that AssertionErrors 'bubble up' to the test runner + test_raw_data_determinism() + test_transaction_id_stability() + test_block_serialization_determinism() + print("🚀 ALL CANONICAL TESTS PASSED!") \ No newline at end of file From 131941945fee9a8987919241b0ca083d01870923 Mon Sep 17 00:00:00 2001 From: sanaica Date: Sun, 22 Mar 2026 15:23:14 +0530 Subject: [PATCH 03/27] fix: address reviewer feedback for type safety and determinism --- minichain/p2p.py | 17 +++++++++++----- tests/test_serialization.py | 40 ++++++++++++++++++------------------- 2 files changed, 31 insertions(+), 26 deletions(-) diff --git a/minichain/p2p.py b/minichain/p2p.py index 8f622fd..e316c38 100644 --- a/minichain/p2p.py +++ b/minichain/p2p.py @@ -202,7 +202,12 @@ def _validate_block_payload(self, payload): ) def _validate_message(self, message): - if not {"type", "data"}.issubset(set(message)): + # FIX: Check if message is a dictionary first to prevent crashes + if not isinstance(message, dict): + logger.warning("Network: Received non-dict message") + return False + + if not {"type", "data"}.issubset(message): return False msg_type = message.get("type") @@ -331,14 +336,16 @@ async def broadcast_transaction(self, tx): async def broadcast_block(self, block, miner=None): logger.info("Network: Broadcasting Block #%d", block.index) - # 1. Convert block to a dict (deterministic via serialization.py) + # 1. Convert block to a dict (deterministic) block_data = json.loads(block.canonical_payload.decode('utf-8')) - # 2. Wrap it in an envelope where 'miner' is OUTSIDE the 'data' + # 2. FIX: Put miner INSIDE block_data so main.py can still find it + if miner: + block_data["miner"] = miner + payload = { "type": "block", - "data": block_data, - "miner": miner + "data": block_data } self._mark_seen("block", payload["data"]) await self._broadcast_raw(payload) diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 4dcc2de..65f0579 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -18,36 +18,34 @@ def test_raw_data_determinism(): def test_transaction_id_stability(): print("--- Testing Transaction ID Stability ---") - # Create a transaction - tx = Transaction(sender="Alice_PK", receiver="Bob_PK", amount=50, nonce=1) + # FIX: Add a fixed timestamp so tx1 and tx2 are identical + tx_params = {"sender": "Alice", "receiver": "Bob", "amount": 50, "nonce": 1, "timestamp": 123456789} - first_id = tx.tx_id - # Re-triggering the ID calculation - second_id = tx.tx_id + tx1 = Transaction(**tx_params) + tx2 = Transaction(**tx_params) - print(f"TX ID: {first_id}") - assert first_id == second_id - print("Success: Transaction ID is stable and deterministic!\n") + print(f"TX ID: {tx1.tx_id}") + assert tx1.tx_id == tx2.tx_id, "Cross-instance TX IDs must match with same timestamp" + print("✅ Success: Transaction ID is stable!\n") def test_block_serialization_determinism(): print("--- Testing Block Serialization & Cross-Instance Determinism ---") - tx_params = {"sender": "A", "receiver": "B", "amount": 10, "nonce": 5} + # FIX: Use fixed timestamps for both transaction and block + tx = Transaction(sender="A", receiver="B", amount=10, nonce=5, timestamp=1000) - # Create two SEPARATE block instances with the exact same data - tx1 = Transaction(**tx_params) - block1 = Block(index=1, previous_hash="0"*64, transactions=[tx1], difficulty=2) + block_params = { + "index": 1, + "previous_hash": "0"*64, + "transactions": [tx], + "difficulty": 2, + "timestamp": 999999 + } - tx2 = Transaction(**tx_params) - block2 = Block(index=1, previous_hash="0"*64, transactions=[tx2], difficulty=2) + block1 = Block(**block_params) + block2 = Block(**block_params) - # 1. Test stable bytes on one instance (same object, twice) - assert block1.canonical_payload == block1.canonical_payload - - # 2. Test cross-instance determinism (different objects, same data) assert block1.canonical_payload == block2.canonical_payload, "Identical blocks must have identical payloads" - - # 3. Test hash consistency - assert block1.compute_hash() == block2.compute_hash() + assert block1.compute_hash() == block2.compute_hash(), "Identical blocks must have identical hashes" print("✅ Success: Block serialization is cross-instance deterministic!\n") From 2e8efb5109b4bc9e58f5e4fb7d6455268190ebb1 Mon Sep 17 00:00:00 2001 From: sanaica Date: Sun, 22 Mar 2026 15:43:35 +0530 Subject: [PATCH 04/27] fix: security: include miner in canonical hash and restrict p2p host --- minichain/block.py | 4 ++++ minichain/p2p.py | 21 +++++++-------------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/minichain/block.py b/minichain/block.py index 0188af6..9757566 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -41,10 +41,12 @@ def __init__( transactions: Optional[List[Transaction]] = None, timestamp: Optional[float] = None, difficulty: Optional[int] = None, + miner=None ): self.index = index self.previous_hash = previous_hash self.transactions: List[Transaction] = transactions or [] + self.miner = miner # Deterministic timestamp (ms) self.timestamp: int = ( @@ -71,6 +73,7 @@ def to_header_dict(self): "timestamp": self.timestamp, "difficulty": self.difficulty, "nonce": self.nonce, + "miner": self.miner, } # ------------------------- @@ -111,6 +114,7 @@ def from_dict(cls, payload: dict): transactions=transactions, timestamp=payload.get("timestamp"), difficulty=payload.get("difficulty"), + miner=payload.get("miner"), ) block.nonce = payload.get("nonce", 0) block.hash = payload.get("hash") diff --git a/minichain/p2p.py b/minichain/p2p.py index e316c38..02cb06c 100644 --- a/minichain/p2p.py +++ b/minichain/p2p.py @@ -43,11 +43,11 @@ def register_handler(self, handler_callback): raise ValueError("handler_callback must be callable") self._handler_callback = handler_callback - async def start(self, port: int = 9000): - """Start listening for incoming peer connections on the given port.""" + async def start(self, port: int = 9000, host: str = "127.0.0.1"): + """Start listening for incoming peer connections.""" self._port = port - self._server = await asyncio.start_server(self._handle_incoming, "0.0.0.0", port) - logger.info("Network: Listening on 0.0.0.0:%d", port) + self._server = await asyncio.start_server(self._handle_incoming, host, port) + logger.info("Network: Listening on %s:%d", host, port) async def stop(self): """Gracefully shut down the server and disconnect all peers.""" @@ -333,19 +333,12 @@ async def broadcast_transaction(self, tx): self._mark_seen("tx", payload["data"]) await self._broadcast_raw(payload) - async def broadcast_block(self, block, miner=None): + async def broadcast_block(self, block): logger.info("Network: Broadcasting Block #%d", block.index) - - # 1. Convert block to a dict (deterministic) - block_data = json.loads(block.canonical_payload.decode('utf-8')) - - # 2. FIX: Put miner INSIDE block_data so main.py can still find it - if miner: - block_data["miner"] = miner - payload = { "type": "block", - "data": block_data + "data": json.loads(block.canonical_payload.decode('utf-8')), + "miner": block.miner } self._mark_seen("block", payload["data"]) await self._broadcast_raw(payload) From 90a31948fd2e4e67752f02228d5f0c4c0b41f48c Mon Sep 17 00:00:00 2001 From: sanaica Date: Sun, 22 Mar 2026 16:03:06 +0530 Subject: [PATCH 05/27] fixed --- minichain/block.py | 11 +++++------ minichain/p2p.py | 11 +++++++---- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/minichain/block.py b/minichain/block.py index 9757566..0b0b892 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -73,7 +73,6 @@ def to_header_dict(self): "timestamp": self.timestamp, "difficulty": self.difficulty, "nonce": self.nonce, - "miner": self.miner, } # ------------------------- @@ -90,11 +89,11 @@ def to_body_dict(self): # FULL BLOCK # ------------------------- def to_dict(self): - return { - **self.to_header_dict(), - **self.to_body_dict(), - "hash": self.hash, - } + data = self.to_header_dict() + data["transactions"] = [tx.to_dict() for tx in self.transactions] + data["hash"] = self.hash + data["miner"] = self.miner + return data # ------------------------- # HASH CALCULATION diff --git a/minichain/p2p.py b/minichain/p2p.py index 02cb06c..8c6785e 100644 --- a/minichain/p2p.py +++ b/minichain/p2p.py @@ -303,7 +303,6 @@ async def _listen_to_peer( async def _broadcast_raw(self, payload: dict): """Send a JSON message to every connected peer.""" - line = (json.dumps(payload) + "\n").encode() line = (canonical_json_dumps(payload) + "\n").encode() disconnected = [] for reader, writer in self._peers: @@ -333,12 +332,16 @@ async def broadcast_transaction(self, tx): self._mark_seen("tx", payload["data"]) await self._broadcast_raw(payload) - async def broadcast_block(self, block): + async def broadcast_block(self, block, miner=None): logger.info("Network: Broadcasting Block #%d", block.index) + + # Ensure the block object has the miner set if provided + if miner: + block.miner = miner + payload = { "type": "block", - "data": json.loads(block.canonical_payload.decode('utf-8')), - "miner": block.miner + "data": json.loads(block.canonical_payload.decode('utf-8')) } self._mark_seen("block", payload["data"]) await self._broadcast_raw(payload) From 6625e38050e66c6ed1a9fc8e6dbf0e83a6d87cd4 Mon Sep 17 00:00:00 2001 From: sanaica Date: Sun, 22 Mar 2026 16:18:25 +0530 Subject: [PATCH 06/27] fix: include top-level miner in p2p envelope for main.py compatibility --- minichain/p2p.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/minichain/p2p.py b/minichain/p2p.py index 8c6785e..32833ae 100644 --- a/minichain/p2p.py +++ b/minichain/p2p.py @@ -343,6 +343,9 @@ async def broadcast_block(self, block, miner=None): "type": "block", "data": json.loads(block.canonical_payload.decode('utf-8')) } + + payload["miner"] = block.miner + self._mark_seen("block", payload["data"]) await self._broadcast_raw(payload) From 48703eb5b244590c99c8fcdfc6469183ce9f7c4c Mon Sep 17 00:00:00 2001 From: sanaica Date: Sat, 28 Mar 2026 21:24:13 +0530 Subject: [PATCH 07/27] fix: address CodeRabbit review for p2p payload and tests --- minichain/p2p.py | 2 -- tests/test_serialization.py | 16 ++++++---------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/minichain/p2p.py b/minichain/p2p.py index 32833ae..5250658 100644 --- a/minichain/p2p.py +++ b/minichain/p2p.py @@ -343,8 +343,6 @@ async def broadcast_block(self, block, miner=None): "type": "block", "data": json.loads(block.canonical_payload.decode('utf-8')) } - - payload["miner"] = block.miner self._mark_seen("block", payload["data"]) await self._broadcast_raw(payload) diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 65f0579..f0a63ee 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -31,18 +31,14 @@ def test_transaction_id_stability(): def test_block_serialization_determinism(): print("--- Testing Block Serialization & Cross-Instance Determinism ---") # FIX: Use fixed timestamps for both transaction and block - tx = Transaction(sender="A", receiver="B", amount=10, nonce=5, timestamp=1000) + tx_params = {"sender": "A", "receiver": "B", "amount": 10, "nonce": 5, "timestamp": 1000} - block_params = { - "index": 1, - "previous_hash": "0"*64, - "transactions": [tx], - "difficulty": 2, - "timestamp": 999999 - } + # Create two separate but identical transaction instances + tx1 = Transaction(**tx_params) + tx2 = Transaction(**tx_params) - block1 = Block(**block_params) - block2 = Block(**block_params) + block1 = Block(index=1, previous_hash="0"*64, transactions=[tx1], difficulty=2, timestamp=999999) + block2 = Block(index=1, previous_hash="0"*64, transactions=[tx2], difficulty=2, timestamp=999999) assert block1.canonical_payload == block2.canonical_payload, "Identical blocks must have identical payloads" assert block1.compute_hash() == block2.compute_hash(), "Identical blocks must have identical hashes" From 98d882c750f98b61dc9569631cc8d945ceb40dc8 Mon Sep 17 00:00:00 2001 From: sanaica Date: Sat, 28 Mar 2026 21:41:23 +0530 Subject: [PATCH 08/27] refactor(p2p): make broadcast_block strictly read-only --- minichain/p2p.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/minichain/p2p.py b/minichain/p2p.py index 0467077..4af2822 100644 --- a/minichain/p2p.py +++ b/minichain/p2p.py @@ -336,18 +336,19 @@ async def broadcast_transaction(self, tx): self._mark_seen("tx", payload["data"]) await self._broadcast_raw(payload) - async def broadcast_block(self, block, miner=None): + async def broadcast_block(self, block): + """Broadcast a block. The block MUST have its miner property set before calling.""" logger.info("Network: Broadcasting Block #%d", block.index) - # Ensure the block object has the miner set if provided - if miner: - block.miner = miner + # Enforce that the block is fully populated before it enters the network layer + if getattr(block, "miner", None) is None: + raise ValueError("block.miner must be populated before broadcasting") payload = { "type": "block", - "data": json.loads(block.canonical_payload.decode('utf-8')) + "data": json.loads(block.canonical_payload.decode("utf-8")) } - + self._mark_seen("block", payload["data"]) await self._broadcast_raw(payload) From 9091307647619279413a99924b5c42065fc03bec Mon Sep 17 00:00:00 2001 From: sanaica Date: Sat, 28 Mar 2026 21:45:03 +0530 Subject: [PATCH 09/27] chore: trigger CI From e2a0cd564282edd167f6e85b90450c7e3d4e5352 Mon Sep 17 00:00:00 2001 From: sanaica Date: Sat, 28 Mar 2026 22:08:10 +0530 Subject: [PATCH 10/27] fix(p2p): restore miner kwarg for backward compatibility --- minichain/p2p.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/minichain/p2p.py b/minichain/p2p.py index 4af2822..a61b4c6 100644 --- a/minichain/p2p.py +++ b/minichain/p2p.py @@ -336,10 +336,14 @@ async def broadcast_transaction(self, tx): self._mark_seen("tx", payload["data"]) await self._broadcast_raw(payload) - async def broadcast_block(self, block): - """Broadcast a block. The block MUST have its miner property set before calling.""" + async def broadcast_block(self, block, miner=None): + """Broadcast a block.""" logger.info("Network: Broadcasting Block #%d", block.index) + # Maintain backward compatibility for callers like main.py + if miner is not None: + block.miner = miner + # Enforce that the block is fully populated before it enters the network layer if getattr(block, "miner", None) is None: raise ValueError("block.miner must be populated before broadcasting") From 464e9511f9e32d2b5989f5a8faefec34a2af538f Mon Sep 17 00:00:00 2001 From: sanaica Date: Sat, 28 Mar 2026 22:15:24 +0530 Subject: [PATCH 11/27] chore: trigger CI From e91e4972a336d6be5393bd9624fcbc3082f2233f Mon Sep 17 00:00:00 2001 From: sanaica Date: Sat, 28 Mar 2026 22:32:22 +0530 Subject: [PATCH 12/27] refactor(p2p): strictly enforce read-only broadcast_block --- minichain/p2p.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/minichain/p2p.py b/minichain/p2p.py index a61b4c6..2be7436 100644 --- a/minichain/p2p.py +++ b/minichain/p2p.py @@ -336,13 +336,9 @@ async def broadcast_transaction(self, tx): self._mark_seen("tx", payload["data"]) await self._broadcast_raw(payload) - async def broadcast_block(self, block, miner=None): - """Broadcast a block.""" + async def broadcast_block(self, block): + """Broadcast a block. Block must have miner populated.""" logger.info("Network: Broadcasting Block #%d", block.index) - - # Maintain backward compatibility for callers like main.py - if miner is not None: - block.miner = miner # Enforce that the block is fully populated before it enters the network layer if getattr(block, "miner", None) is None: From 00ed3401d68881d648969369eccd1431aa3ae14e Mon Sep 17 00:00:00 2001 From: sanaica Date: Sat, 28 Mar 2026 23:34:05 +0530 Subject: [PATCH 13/27] fix(main): update broadcast_block caller to match new API --- main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/main.py b/main.py index e1edc51..78366f7 100644 --- a/main.py +++ b/main.py @@ -238,7 +238,8 @@ async def cli_loop(sk, pk, chain, mempool, network): elif cmd == "mine": mined = mine_and_process_block(chain, mempool, pk) if mined: - await network.broadcast_block(mined, miner=pk) + mined.miner = pk + await network.broadcast_block(mined) # ── peers ── elif cmd == "peers": From 08533b5f3cff7dfc8209f1c74a0702c4ac3aef73 Mon Sep 17 00:00:00 2001 From: sanaica Date: Sat, 28 Mar 2026 23:42:04 +0530 Subject: [PATCH 14/27] fix(main): set block miner before chain admission to prevent state mismatch --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index 78366f7..bbf8fc0 100644 --- a/main.py +++ b/main.py @@ -81,6 +81,7 @@ def mine_and_process_block(chain, mempool, miner_pk): index=chain.last_block.index + 1, previous_hash=chain.last_block.hash, transactions=mineable_txs, + miner=miner_pk, ) mined_block = mine_block(block) @@ -238,7 +239,6 @@ async def cli_loop(sk, pk, chain, mempool, network): elif cmd == "mine": mined = mine_and_process_block(chain, mempool, pk) if mined: - mined.miner = pk await network.broadcast_block(mined) # ── peers ── From 1f7eddc910322799e221c9e8561e01f254f71e79 Mon Sep 17 00:00:00 2001 From: sanaica Date: Sun, 29 Mar 2026 00:01:44 +0530 Subject: [PATCH 15/27] fix(main): set block miner before chain admission as per Claude/CodeRabbit review --- main.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/main.py b/main.py index bbf8fc0..b39de4c 100644 --- a/main.py +++ b/main.py @@ -78,10 +78,10 @@ def mine_and_process_block(chain, mempool, miner_pk): return None block = Block( - index=chain.last_block.index + 1, - previous_hash=chain.last_block.hash, - transactions=mineable_txs, - miner=miner_pk, + index=chain.last_block.index + 1, + previous_hash=chain.last_block.hash, + transactions=mineable_txs, + miner=miner_pk, # ← ADD THIS ) mined_block = mine_block(block) @@ -239,7 +239,7 @@ async def cli_loop(sk, pk, chain, mempool, network): elif cmd == "mine": mined = mine_and_process_block(chain, mempool, pk) if mined: - await network.broadcast_block(mined) + await network.broadcast_block(mined) # ← just this, no miner assignment above it # ── peers ── elif cmd == "peers": From e90dd22c74c70ad63ea7c0fb557191c0e28f9ae7 Mon Sep 17 00:00:00 2001 From: sanaica Date: Sun, 29 Mar 2026 00:18:49 +0530 Subject: [PATCH 16/27] chore: trigger CI build From f674b2ab17ca1646c943836e0e9181a0fc77260f Mon Sep 17 00:00:00 2001 From: sanaica Date: Sun, 29 Mar 2026 00:57:09 +0530 Subject: [PATCH 17/27] fix(security): include miner in hash, verify merkle root, and update tests --- minichain/block.py | 9 ++++++--- tests/test_serialization.py | 9 +++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/minichain/block.py b/minichain/block.py index 0b0b892..db2522e 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -73,6 +73,7 @@ def to_header_dict(self): "timestamp": self.timestamp, "difficulty": self.difficulty, "nonce": self.nonce, + "miner": self.miner, } # ------------------------- @@ -117,10 +118,12 @@ def from_dict(cls, payload: dict): ) block.nonce = payload.get("nonce", 0) block.hash = payload.get("hash") - if "merkle_root" in payload: - block.merkle_root = payload["merkle_root"] + + # Recalculate and verify the Merkle root! + if "merkle_root" in payload and payload["merkle_root"] != block.merkle_root: + raise ValueError("merkle_root does not match transactions") + return block - @property def canonical_payload(self) -> bytes: """Returns the full block (header + body) as canonical bytes for networking.""" diff --git a/tests/test_serialization.py b/tests/test_serialization.py index f0a63ee..6d07a44 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -37,8 +37,13 @@ def test_block_serialization_determinism(): tx1 = Transaction(**tx_params) tx2 = Transaction(**tx_params) - block1 = Block(index=1, previous_hash="0"*64, transactions=[tx1], difficulty=2, timestamp=999999) - block2 = Block(index=1, previous_hash="0"*64, transactions=[tx2], difficulty=2, timestamp=999999) + # Add the miner field + block1 = Block(index=1, previous_hash="0"*64, transactions=[tx1], difficulty=2, timestamp=999999, miner="a" * 40) + block2 = Block(index=1, previous_hash="0"*64, transactions=[tx2], difficulty=2, timestamp=999999, miner="a" * 40) + + # Pre-compute the hashes before asserting + block1.hash = block1.compute_hash() + block2.hash = block2.compute_hash() assert block1.canonical_payload == block2.canonical_payload, "Identical blocks must have identical payloads" assert block1.compute_hash() == block2.compute_hash(), "Identical blocks must have identical hashes" From 8aaee282f470d4c866d2948b4caca8cb08084543 Mon Sep 17 00:00:00 2001 From: sanaica Date: Sun, 29 Mar 2026 01:23:16 +0530 Subject: [PATCH 18/27] fix(security): remove duplicate miner assignment, add hash verification, and add tamper tests --- minichain/block.py | 6 +++++- tests/test_serialization.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/minichain/block.py b/minichain/block.py index db2522e..8ff729e 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -93,7 +93,6 @@ def to_dict(self): data = self.to_header_dict() data["transactions"] = [tx.to_dict() for tx in self.transactions] data["hash"] = self.hash - data["miner"] = self.miner return data # ------------------------- @@ -119,6 +118,11 @@ def from_dict(cls, payload: dict): block.nonce = payload.get("nonce", 0) block.hash = payload.get("hash") + # Verify the block hash + expected_hash = block.compute_hash() + if block.hash is not None and block.hash != expected_hash: + raise ValueError("block hash does not match header") + # Recalculate and verify the Merkle root! if "merkle_root" in payload and payload["merkle_root"] != block.merkle_root: raise ValueError("merkle_root does not match transactions") diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 6d07a44..0a14a16 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -50,6 +50,35 @@ def test_block_serialization_determinism(): print("✅ Success: Block serialization is cross-instance deterministic!\n") +def test_block_from_dict_rejects_tampered_payload(): + print("--- Testing Tamper Rejection ---") + tx = Transaction(sender="A", receiver="B", amount=10, nonce=5, timestamp=1000) + block = Block( + index=1, previous_hash="0"*64, transactions=[tx], + difficulty=2, timestamp=999999, miner="a"*40 + ) + block.hash = block.compute_hash() + + # Test tampered Merkle Root + bad_merkle = block.to_dict() + bad_merkle["merkle_root"] = "f" * 64 + try: + Block.from_dict(bad_merkle) + assert False, "Expected ValueError for tampered merkle_root" + except ValueError: + pass + + # Test tampered Hash + bad_hash = block.to_dict() + bad_hash["hash"] = "0" * 64 + try: + Block.from_dict(bad_hash) + assert False, "Expected ValueError for tampered hash" + except ValueError: + pass + + print("✅ Success: Tampered payloads are rejected!\n") + if __name__ == "__main__": # Removed try/except so that AssertionErrors 'bubble up' to the test runner test_raw_data_determinism() From 7421205f956933d07db182b119c0f87a6806717d Mon Sep 17 00:00:00 2001 From: sanaica Date: Sun, 29 Mar 2026 01:46:59 +0530 Subject: [PATCH 19/27] chore: add miner type hint, update test assertions, and invoke tamper test --- minichain/block.py | 2 +- tests/test_serialization.py | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/minichain/block.py b/minichain/block.py index 8ff729e..c7d703e 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -41,7 +41,7 @@ def __init__( transactions: Optional[List[Transaction]] = None, timestamp: Optional[float] = None, difficulty: Optional[int] = None, - miner=None + miner: Optional[str] = None ): self.index = index self.previous_hash = previous_hash diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 0a14a16..f5938f3 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -68,20 +68,28 @@ def test_block_from_dict_rejects_tampered_payload(): except ValueError: pass + # Test tampered Merkle Root + bad_merkle = block.to_dict() + bad_merkle["merkle_root"] = "f" * 64 + try: + Block.from_dict(bad_merkle) + raise AssertionError("Expected ValueError for tampered merkle_root") # <-- CHANGED + except ValueError: + pass + # Test tampered Hash bad_hash = block.to_dict() bad_hash["hash"] = "0" * 64 try: Block.from_dict(bad_hash) - assert False, "Expected ValueError for tampered hash" + raise AssertionError("Expected ValueError for tampered hash") # <-- CHANGED except ValueError: pass - - print("✅ Success: Tampered payloads are rejected!\n") if __name__ == "__main__": # Removed try/except so that AssertionErrors 'bubble up' to the test runner test_raw_data_determinism() test_transaction_id_stability() test_block_serialization_determinism() + test_block_from_dict_rejects_tampered_payload() # <--- ADDED THIS LINE print("🚀 ALL CANONICAL TESTS PASSED!") \ No newline at end of file From 5b9fb54471eb9bc76ef54aa2956eec3b12162e7c Mon Sep 17 00:00:00 2001 From: sanaica Date: Sun, 29 Mar 2026 02:20:06 +0530 Subject: [PATCH 20/27] fix: deduplicate merkle tamper test and reuse to_body_dict in to_dict --- minichain/block.py | 2 +- tests/test_serialization.py | 17 +++++------------ 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/minichain/block.py b/minichain/block.py index c7d703e..4892954 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -91,7 +91,7 @@ def to_body_dict(self): # ------------------------- def to_dict(self): data = self.to_header_dict() - data["transactions"] = [tx.to_dict() for tx in self.transactions] + data.update(self.to_body_dict()) # Reuses existing serialization logic data["hash"] = self.hash return data diff --git a/tests/test_serialization.py b/tests/test_serialization.py index f5938f3..aa5f2b7 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -59,21 +59,12 @@ def test_block_from_dict_rejects_tampered_payload(): ) block.hash = block.compute_hash() - # Test tampered Merkle Root + # Test tampered Merkle Root (only one instance needed) bad_merkle = block.to_dict() bad_merkle["merkle_root"] = "f" * 64 try: Block.from_dict(bad_merkle) - assert False, "Expected ValueError for tampered merkle_root" - except ValueError: - pass - - # Test tampered Merkle Root - bad_merkle = block.to_dict() - bad_merkle["merkle_root"] = "f" * 64 - try: - Block.from_dict(bad_merkle) - raise AssertionError("Expected ValueError for tampered merkle_root") # <-- CHANGED + raise AssertionError("Expected ValueError for tampered merkle_root") # Robust error except ValueError: pass @@ -82,9 +73,11 @@ def test_block_from_dict_rejects_tampered_payload(): bad_hash["hash"] = "0" * 64 try: Block.from_dict(bad_hash) - raise AssertionError("Expected ValueError for tampered hash") # <-- CHANGED + raise AssertionError("Expected ValueError for tampered hash") except ValueError: pass + + print("✅ Success: Tampered payloads are rejected!\n") if __name__ == "__main__": # Removed try/except so that AssertionErrors 'bubble up' to the test runner From 90336810f0abacf1ab56dc9d022f3061383f93a8 Mon Sep 17 00:00:00 2001 From: sanaica Date: Sun, 29 Mar 2026 09:23:30 +0530 Subject: [PATCH 21/27] fix(block): make transactions immutable and support legacy block hashes --- minichain/block.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/minichain/block.py b/minichain/block.py index 4892954..d9e7fc8 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -45,7 +45,8 @@ def __init__( ): self.index = index self.previous_hash = previous_hash - self.transactions: List[Transaction] = transactions or [] + # Freeze transactions into an immutable tuple to prevent header/body mismatch + self.transactions = tuple(transactions) if transactions else () self.miner = miner # Deterministic timestamp (ms) @@ -66,15 +67,20 @@ def __init__( # HEADER (used for mining) # ------------------------- def to_header_dict(self): - return { + header = { "index": self.index, "previous_hash": self.previous_hash, "merkle_root": self.merkle_root, "timestamp": self.timestamp, "difficulty": self.difficulty, "nonce": self.nonce, - "miner": self.miner, } + + # Backward compatibility: Only include miner in hash if it exists + if self.miner is not None: + header["miner"] = self.miner + + return header # ------------------------- # BODY (transactions only) From ecb7031ce01779360216430550fada94fa8e6381 Mon Sep 17 00:00:00 2001 From: sanaica Date: Sun, 29 Mar 2026 09:38:07 +0530 Subject: [PATCH 22/27] fix(block): validate block invariants before generating canonical_payload --- minichain/block.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/minichain/block.py b/minichain/block.py index d9e7fc8..91c268d 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -137,4 +137,12 @@ def from_dict(cls, payload: dict): @property def canonical_payload(self) -> bytes: """Returns the full block (header + body) as canonical bytes for networking.""" + # Sanity checks to prevent broadcasting invalid blocks + if self.hash is None: + raise ValueError("block hash is missing") + if self.hash != self.compute_hash(): + raise ValueError("block hash does not match header") + if _calculate_merkle_root(self.transactions) != self.merkle_root: + raise ValueError("merkle_root does not match transactions") + return canonical_json_bytes(self.to_dict()) From 128f2c04685a873f30923acc9b22929b6e7f7adc Mon Sep 17 00:00:00 2001 From: sanaica Date: Sun, 29 Mar 2026 10:07:42 +0530 Subject: [PATCH 23/27] fix(block): cast integral header fields to int in from_dict to ensure canonical hashing --- minichain/block.py | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/minichain/block.py b/minichain/block.py index 91c268d..9c6add0 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -1,13 +1,18 @@ import time + import hashlib + from typing import List, Optional + from .transaction import Transaction + from .serialization import canonical_json_hash, canonical_json_bytes + + 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 @@ -32,7 +37,6 @@ def _calculate_merkle_root(transactions: List[Transaction]) -> Optional[str]: return tx_hashes[0] - class Block: def __init__( self, @@ -48,14 +52,12 @@ def __init__( # Freeze transactions into an immutable tuple to prevent header/body mismatch self.transactions = tuple(transactions) if transactions else () self.miner = miner - # Deterministic timestamp (ms) self.timestamp: int = ( round(time.time() * 1000) if timestamp is None else int(timestamp) ) - self.difficulty: Optional[int] = difficulty self.nonce: int = 0 self.hash: Optional[str] = None @@ -75,13 +77,10 @@ def to_header_dict(self): "difficulty": self.difficulty, "nonce": self.nonce, } - # Backward compatibility: Only include miner in hash if it exists if self.miner is not None: - header["miner"] = self.miner - + header["miner"] = self.miner return header - # ------------------------- # BODY (transactions only) # ------------------------- @@ -113,17 +112,21 @@ def from_dict(cls, payload: dict): Transaction.from_dict(tx_payload) for tx_payload in payload.get("transactions", []) ] + + # Safely extract and cast difficulty if it exists + raw_diff = payload.get("difficulty") + parsed_diff = int(raw_diff) if raw_diff is not None else None block = cls( - index=payload["index"], + index=int(payload["index"]), # <-- Cast to int previous_hash=payload["previous_hash"], transactions=transactions, timestamp=payload.get("timestamp"), - difficulty=payload.get("difficulty"), + difficulty=parsed_diff, # <-- Cast to int miner=payload.get("miner"), ) - block.nonce = payload.get("nonce", 0) + block.nonce = int(payload.get("nonce", 0)) # <-- Cast to int block.hash = payload.get("hash") - + # Verify the block hash expected_hash = block.compute_hash() if block.hash is not None and block.hash != expected_hash: @@ -132,8 +135,8 @@ def from_dict(cls, payload: dict): # Recalculate and verify the Merkle root! if "merkle_root" in payload and payload["merkle_root"] != block.merkle_root: raise ValueError("merkle_root does not match transactions") - return block + @property def canonical_payload(self) -> bytes: """Returns the full block (header + body) as canonical bytes for networking.""" @@ -143,6 +146,5 @@ def canonical_payload(self) -> bytes: if self.hash != self.compute_hash(): raise ValueError("block hash does not match header") if _calculate_merkle_root(self.transactions) != self.merkle_root: - raise ValueError("merkle_root does not match transactions") - - return canonical_json_bytes(self.to_dict()) + raise ValueError("merkle_root does not match transactions") + return canonical_json_bytes(self.to_dict()) \ No newline at end of file From ec74fae581e88ce6ae99a2877d4874e8945d71dc Mon Sep 17 00:00:00 2001 From: sanaica Date: Sun, 29 Mar 2026 10:21:28 +0530 Subject: [PATCH 24/27] chore(block): apply CodeRabbit nitpicks for typing, casting, and optimization --- minichain/block.py | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/minichain/block.py b/minichain/block.py index 9c6add0..34ee156 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -1,19 +1,16 @@ import time - import hashlib - -from typing import List, Optional +from typing import Optional # <-- Removed 'List' as requested from .transaction import Transaction - from .serialization import canonical_json_hash, canonical_json_bytes - def _sha256(data: str) -> str: return hashlib.sha256(data.encode()).hexdigest() -def _calculate_merkle_root(transactions: List[Transaction]) -> Optional[str]: +# <-- Updated 'List' to built-in 'list' +def _calculate_merkle_root(transactions: list[Transaction]) -> Optional[str]: if not transactions: return None @@ -42,7 +39,7 @@ def __init__( self, index: int, previous_hash: str, - transactions: Optional[List[Transaction]] = None, + transactions: Optional[list[Transaction]] = None, # <-- Updated to built-in 'list' timestamp: Optional[float] = None, difficulty: Optional[int] = None, miner: Optional[str] = None @@ -77,10 +74,11 @@ def to_header_dict(self): "difficulty": self.difficulty, "nonce": self.nonce, } - # Backward compatibility: Only include miner in hash if it exists + # Include miner in header only when present (optional field) <-- Reworded comment if self.miner is not None: header["miner"] = self.miner return header + # ------------------------- # BODY (transactions only) # ------------------------- @@ -116,15 +114,20 @@ def from_dict(cls, payload: dict): # Safely extract and cast difficulty if it exists raw_diff = payload.get("difficulty") parsed_diff = int(raw_diff) if raw_diff is not None else None + + # Safely extract and cast timestamp if it exists <-- Added explicit timestamp casting + raw_ts = payload.get("timestamp") + parsed_ts = int(raw_ts) if raw_ts is not None else None + block = cls( - index=int(payload["index"]), # <-- Cast to int + index=int(payload["index"]), previous_hash=payload["previous_hash"], transactions=transactions, - timestamp=payload.get("timestamp"), - difficulty=parsed_diff, # <-- Cast to int + timestamp=parsed_ts, # <-- Passed the casted timestamp + difficulty=parsed_diff, miner=payload.get("miner"), ) - block.nonce = int(payload.get("nonce", 0)) # <-- Cast to int + block.nonce = int(payload.get("nonce", 0)) block.hash = payload.get("hash") # Verify the block hash @@ -145,6 +148,8 @@ def canonical_payload(self) -> bytes: raise ValueError("block hash is missing") if self.hash != self.compute_hash(): raise ValueError("block hash does not match header") - if _calculate_merkle_root(self.transactions) != self.merkle_root: - raise ValueError("merkle_root does not match transactions") + + # <-- Removed the redundant and slow merkle_root check here as requested + # merkle_root consistency guaranteed by immutable transactions tuple + return canonical_json_bytes(self.to_dict()) \ No newline at end of file From c5523166e330b70023b335ef8dbb5ee356dc1747 Mon Sep 17 00:00:00 2001 From: sanaica Date: Sun, 29 Mar 2026 10:22:33 +0530 Subject: [PATCH 25/27] chore(block): apply CodeRabbit nitpicks for typing, casting, and optimization --- minichain/block.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/minichain/block.py b/minichain/block.py index 34ee156..c6ff571 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -148,8 +148,5 @@ def canonical_payload(self) -> bytes: raise ValueError("block hash is missing") if self.hash != self.compute_hash(): raise ValueError("block hash does not match header") - - # <-- Removed the redundant and slow merkle_root check here as requested - # merkle_root consistency guaranteed by immutable transactions tuple return canonical_json_bytes(self.to_dict()) \ No newline at end of file From 504a9e45b3f18d24881436441102ae96b4e164fd Mon Sep 17 00:00:00 2001 From: sanaica Date: Sun, 29 Mar 2026 10:46:22 +0530 Subject: [PATCH 26/27] chore(block): update type hint for _calculate_merkle_root to Sequence --- minichain/block.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/minichain/block.py b/minichain/block.py index c6ff571..fabb768 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -1,6 +1,7 @@ import time import hashlib from typing import Optional # <-- Removed 'List' as requested +from collections.abc import Sequence from .transaction import Transaction from .serialization import canonical_json_hash, canonical_json_bytes @@ -9,8 +10,8 @@ def _sha256(data: str) -> str: return hashlib.sha256(data.encode()).hexdigest() -# <-- Updated 'List' to built-in 'list' -def _calculate_merkle_root(transactions: list[Transaction]) -> Optional[str]: +# <-- Updated to Sequence to accept the frozen tuple +def _calculate_merkle_root(transactions: Sequence[Transaction]) -> Optional[str]: if not transactions: return None From 785cedd6d0e83d467f85fd99992bcccb6dffd0a3 Mon Sep 17 00:00:00 2001 From: sanaica Date: Sun, 29 Mar 2026 11:58:51 +0530 Subject: [PATCH 27/27] chore: force CodeRabbit status update