Skip to content

Commit 329e7cb

Browse files
Merge pull request #67 from sanaica/main
[FEATURE]: Implement Canonical Serialization for Blocks and Transactions
2 parents f0fa807 + c968202 commit 329e7cb

4 files changed

Lines changed: 177 additions & 43 deletions

File tree

main.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ def mine_and_process_block(chain, mempool, miner_pk):
8282
index=chain.last_block.index + 1,
8383
previous_hash=chain.last_block.hash,
8484
transactions=mineable_txs,
85-
state_root=temp_state.state_root(),
8685
miner=miner_pk,
8786
)
8887

@@ -236,7 +235,7 @@ async def cli_loop(sk, pk, chain, mempool, network):
236235
elif cmd == "mine":
237236
mined = mine_and_process_block(chain, mempool, pk)
238237
if mined:
239-
await network.broadcast_block(mined, miner=pk)
238+
await network.broadcast_block(mined) # ← just this, no miner assignment above it
240239

241240
# ── peers ──
242241
elif cmd == "peers":

minichain/block.py

Lines changed: 55 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import time
22
import hashlib
3-
from typing import List, Optional
3+
from typing import Optional # <-- Removed 'List' as requested
4+
from collections.abc import Sequence
5+
46
from .transaction import Transaction
5-
from .serialization import canonical_json_hash
7+
from .serialization import canonical_json_hash, canonical_json_bytes
8+
69

710
def _sha256(data: str) -> str:
811
return hashlib.sha256(data.encode()).hexdigest()
912

10-
11-
def _calculate_merkle_root(transactions: List[Transaction]) -> Optional[str]:
13+
# <-- Updated to Sequence to accept the frozen tuple
14+
def _calculate_merkle_root(transactions: Sequence[Transaction]) -> Optional[str]:
1215
if not transactions:
1316
return None
1417

@@ -32,29 +35,27 @@ def _calculate_merkle_root(transactions: List[Transaction]) -> Optional[str]:
3235

3336
return tx_hashes[0]
3437

35-
3638
class Block:
3739
def __init__(
3840
self,
3941
index: int,
4042
previous_hash: str,
41-
transactions: Optional[List[Transaction]] = None,
43+
transactions: Optional[Sequence[Transaction]] = None,
4244
timestamp: Optional[float] = None,
4345
difficulty: Optional[int] = None,
44-
state_root: Optional[str] = None,
45-
miner: Optional[str] = None,
46+
miner: Optional[str] = None
4647
):
4748
self.index = index
4849
self.previous_hash = previous_hash
49-
self.transactions: List[Transaction] = transactions or []
50-
50+
# Freeze transactions into an immutable tuple to prevent header/body mismatch
51+
self.transactions = tuple(transactions) if transactions else ()
52+
self.miner = miner
5153
# Deterministic timestamp (ms)
5254
self.timestamp: int = (
5355
round(time.time() * 1000)
5456
if timestamp is None
5557
else int(timestamp)
5658
)
57-
5859
self.difficulty: Optional[int] = difficulty
5960
self.nonce: int = 0
6061
self.hash: Optional[str] = None
@@ -68,7 +69,7 @@ def __init__(
6869
# HEADER (used for mining)
6970
# -------------------------
7071
def to_header_dict(self):
71-
return {
72+
header = {
7273
"index": self.index,
7374
"previous_hash": self.previous_hash,
7475
"merkle_root": self.merkle_root,
@@ -78,7 +79,11 @@ def to_header_dict(self):
7879
"nonce": self.nonce,
7980
"miner": self.miner,
8081
}
81-
82+
# Include miner in header only when present (optional field) <-- Reworded comment
83+
if self.miner is not None:
84+
header["miner"] = self.miner
85+
return header
86+
8287
# -------------------------
8388
# BODY (transactions only)
8489
# -------------------------
@@ -93,11 +98,10 @@ def to_body_dict(self):
9398
# FULL BLOCK
9499
# -------------------------
95100
def to_dict(self):
96-
return {
97-
**self.to_header_dict(),
98-
**self.to_body_dict(),
99-
"hash": self.hash,
100-
}
101+
data = self.to_header_dict()
102+
data.update(self.to_body_dict()) # Reuses existing serialization logic
103+
data["hash"] = self.hash
104+
return data
101105

102106
# -------------------------
103107
# HASH CALCULATION
@@ -111,17 +115,43 @@ def from_dict(cls, payload: dict):
111115
Transaction.from_dict(tx_payload)
112116
for tx_payload in payload.get("transactions", [])
113117
]
118+
119+
# Safely extract and cast difficulty if it exists
120+
raw_diff = payload.get("difficulty")
121+
parsed_diff = int(raw_diff) if raw_diff is not None else None
122+
123+
# Safely extract and cast timestamp if it exists <-- Added explicit timestamp casting
124+
raw_ts = payload.get("timestamp")
125+
parsed_ts = int(raw_ts) if raw_ts is not None else None
126+
114127
block = cls(
115-
index=payload["index"],
128+
index=int(payload["index"]),
116129
previous_hash=payload["previous_hash"],
117130
transactions=transactions,
118-
timestamp=payload.get("timestamp"),
119-
difficulty=payload.get("difficulty"),
120-
state_root=payload.get("state_root"),
131+
timestamp=parsed_ts, # <-- Passed the casted timestamp
132+
difficulty=parsed_diff,
121133
miner=payload.get("miner"),
122134
)
123-
block.nonce = payload.get("nonce", 0)
135+
block.nonce = int(payload.get("nonce", 0))
124136
block.hash = payload.get("hash")
125-
if "merkle_root" in payload:
126-
block.merkle_root = payload["merkle_root"]
137+
138+
# Verify the block hash
139+
expected_hash = block.compute_hash()
140+
if block.hash is not None and block.hash != expected_hash:
141+
raise ValueError("block hash does not match header")
142+
143+
# Recalculate and verify the Merkle root!
144+
if "merkle_root" in payload and payload["merkle_root"] != block.merkle_root:
145+
raise ValueError("merkle_root does not match transactions")
127146
return block
147+
148+
@property
149+
def canonical_payload(self) -> bytes:
150+
"""Returns the full block (header + body) as canonical bytes for networking."""
151+
# Sanity checks to prevent broadcasting invalid blocks
152+
if self.hash is None:
153+
raise ValueError("block hash is missing")
154+
if self.hash != self.compute_hash():
155+
raise ValueError("block hash does not match header")
156+
157+
return canonical_json_bytes(self.to_dict())

minichain/p2p.py

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import json
1010
import logging
1111

12-
from .serialization import canonical_json_hash
12+
from .serialization import canonical_json_hash, canonical_json_dumps
1313
from .validators import is_valid_receiver
1414

1515
logger = logging.getLogger(__name__)
@@ -58,9 +58,7 @@ async def _notify_peer_connected(self, writer, error_message):
5858
async def start(self, port: int = 9000, host: str = "127.0.0.1"):
5959
"""Start listening for incoming peer connections on the given port."""
6060
self._port = port
61-
self._server = await asyncio.start_server(
62-
self._handle_incoming, host, port
63-
)
61+
self._server = await asyncio.start_server(self._handle_incoming, host, port)
6462
logger.info("Network: Listening on %s:%d", host, port)
6563

6664
async def stop(self):
@@ -212,7 +210,9 @@ def _validate_block_payload(self, payload):
212210
)
213211

214212
def _validate_message(self, message):
213+
# FIX: Check if message is a dictionary first to prevent crashes
215214
if not isinstance(message, dict):
215+
logger.warning("Network: Received non-dict message")
216216
return False
217217
required_fields = {"type", "data"}
218218
if not required_fields.issubset(set(message)):
@@ -310,22 +310,32 @@ async def _listen_to_peer(
310310
self._peers.remove((reader, writer))
311311

312312
async def _broadcast_raw(self, payload: dict):
313-
"""Send a JSON message to every connected peer."""
314-
line = (json.dumps(payload) + "\n").encode()
315-
disconnected = []
316-
for reader, writer in self._peers:
313+
"""Send a JSON message to every connected peer concurrently."""
314+
line = (canonical_json_dumps(payload) + "\n").encode()
315+
peers_snapshot = list(self._peers)
316+
317+
async def _send(reader, writer):
318+
"""Send to a single peer; return the pair on failure."""
317319
try:
318320
writer.write(line)
319321
await writer.drain()
322+
return None
320323
except Exception:
321-
disconnected.append((reader, writer))
322-
for reader, writer in disconnected:
324+
return (reader, writer)
325+
326+
results = await asyncio.gather(
327+
*(_send(r, w) for r, w in peers_snapshot)
328+
)
329+
330+
for pair in results:
331+
if pair is None:
332+
continue
333+
_reader, writer = pair
323334
try:
324335
writer.close()
325336
await writer.wait_closed()
326337
except Exception:
327338
pass
328-
pair = (reader, writer)
329339
if pair in self._peers:
330340
self._peers.remove(pair)
331341

@@ -340,12 +350,19 @@ async def broadcast_transaction(self, tx):
340350
self._mark_seen("tx", payload["data"])
341351
await self._broadcast_raw(payload)
342352

343-
async def broadcast_block(self, block, miner=None):
353+
async def broadcast_block(self, block):
354+
"""Broadcast a block. Block must have miner populated."""
344355
logger.info("Network: Broadcasting Block #%d", block.index)
345-
block_payload = block.to_dict()
346-
if miner is not None:
347-
block_payload["miner"] = miner
348-
payload = {"type": "block", "data": block_payload}
356+
357+
# Enforce that the block is fully populated before it enters the network layer
358+
if getattr(block, "miner", None) is None:
359+
raise ValueError("block.miner must be populated before broadcasting")
360+
361+
payload = {
362+
"type": "block",
363+
"data": block.to_dict()
364+
}
365+
349366
self._mark_seen("block", payload["data"])
350367
await self._broadcast_raw(payload)
351368

tests/test_serialization.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
from minichain.serialization import canonical_json_hash
2+
from minichain.transaction import Transaction
3+
from minichain.block import Block
4+
5+
def test_raw_data_determinism():
6+
print("--- Testing Raw Data Determinism ---")
7+
# Same data, different key order
8+
data_v1 = {"amount": 100, "nonce": 1, "receiver": "Alice", "sender": "Bob"}
9+
data_v2 = {"sender": "Bob", "receiver": "Alice", "nonce": 1, "amount": 100}
10+
11+
hash_1 = canonical_json_hash(data_v1)
12+
hash_2 = canonical_json_hash(data_v2)
13+
14+
print(f"Hash 1: {hash_1}")
15+
print(f"Hash 2: {hash_2}")
16+
assert hash_1 == hash_2
17+
print("Success: Raw hashes match regardless of key order!\n")
18+
19+
def test_transaction_id_stability():
20+
print("--- Testing Transaction ID Stability ---")
21+
# FIX: Add a fixed timestamp so tx1 and tx2 are identical
22+
tx_params = {"sender": "Alice", "receiver": "Bob", "amount": 50, "nonce": 1, "timestamp": 123456789}
23+
24+
tx1 = Transaction(**tx_params)
25+
tx2 = Transaction(**tx_params)
26+
27+
print(f"TX ID: {tx1.tx_id}")
28+
assert tx1.tx_id == tx2.tx_id, "Cross-instance TX IDs must match with same timestamp"
29+
print("✅ Success: Transaction ID is stable!\n")
30+
31+
def test_block_serialization_determinism():
32+
print("--- Testing Block Serialization & Cross-Instance Determinism ---")
33+
# FIX: Use fixed timestamps for both transaction and block
34+
tx_params = {"sender": "A", "receiver": "B", "amount": 10, "nonce": 5, "timestamp": 1000}
35+
36+
# Create two separate but identical transaction instances
37+
tx1 = Transaction(**tx_params)
38+
tx2 = Transaction(**tx_params)
39+
40+
# Add the miner field
41+
block1 = Block(index=1, previous_hash="0"*64, transactions=[tx1], difficulty=2, timestamp=999999, miner="a" * 40)
42+
block2 = Block(index=1, previous_hash="0"*64, transactions=[tx2], difficulty=2, timestamp=999999, miner="a" * 40)
43+
44+
# Pre-compute the hashes before asserting
45+
block1.hash = block1.compute_hash()
46+
block2.hash = block2.compute_hash()
47+
48+
assert block1.canonical_payload == block2.canonical_payload, "Identical blocks must have identical payloads"
49+
assert block1.compute_hash() == block2.compute_hash(), "Identical blocks must have identical hashes"
50+
51+
print("✅ Success: Block serialization is cross-instance deterministic!\n")
52+
53+
def test_block_from_dict_rejects_tampered_payload():
54+
print("--- Testing Tamper Rejection ---")
55+
tx = Transaction(sender="A", receiver="B", amount=10, nonce=5, timestamp=1000)
56+
block = Block(
57+
index=1, previous_hash="0"*64, transactions=[tx],
58+
difficulty=2, timestamp=999999, miner="a"*40
59+
)
60+
block.hash = block.compute_hash()
61+
62+
# Test tampered Merkle Root (only one instance needed)
63+
bad_merkle = block.to_dict()
64+
bad_merkle["merkle_root"] = "f" * 64
65+
try:
66+
Block.from_dict(bad_merkle)
67+
raise AssertionError("Expected ValueError for tampered merkle_root") # Robust error
68+
except ValueError:
69+
pass
70+
71+
# Test tampered Hash
72+
bad_hash = block.to_dict()
73+
bad_hash["hash"] = "0" * 64
74+
try:
75+
Block.from_dict(bad_hash)
76+
raise AssertionError("Expected ValueError for tampered hash")
77+
except ValueError:
78+
pass
79+
80+
print("✅ Success: Tampered payloads are rejected!\n")
81+
82+
if __name__ == "__main__":
83+
# Removed try/except so that AssertionErrors 'bubble up' to the test runner
84+
test_raw_data_determinism()
85+
test_transaction_id_stability()
86+
test_block_serialization_determinism()
87+
test_block_from_dict_rejects_tampered_payload() # <--- ADDED THIS LINE
88+
print("🚀 ALL CANONICAL TESTS PASSED!")

0 commit comments

Comments
 (0)