diff --git a/main.py b/main.py index 838b3aa..cbdcd51 100644 --- a/main.py +++ b/main.py @@ -27,11 +27,11 @@ from minichain import Transaction, Blockchain, Block, State, Mempool, P2PNetwork, mine_block from minichain.validators import is_valid_receiver +from minichain.block import calculate_receipt_root logger = logging.getLogger(__name__) -BURN_ADDRESS = "0" * 40 TRUSTED_PEERS = set() LOCALHOST_PEERS = {"127.0.0.1", "::1", "localhost", "0:0:0:0:0:0:0:1"} @@ -61,13 +61,17 @@ def mine_and_process_block(chain, mempool, miner_pk): temp_state = chain.state.copy() mineable_txs = [] stale_txs = [] + receipts = [] for tx in pending_txs: expected_nonce = temp_state.get_account(tx.sender).get("nonce", 0) if tx.nonce < expected_nonce: stale_txs.append(tx) continue - if temp_state.validate_and_apply(tx): + + receipt = temp_state.validate_and_apply(tx) + if receipt is not None: mineable_txs.append(tx) + receipts.append(receipt) if stale_txs: mempool.remove_transactions(stale_txs) @@ -82,6 +86,9 @@ def mine_and_process_block(chain, mempool, miner_pk): index=chain.last_block.index + 1, previous_hash=chain.last_block.hash, transactions=mineable_txs, + state_root=temp_state.state_root(), + receipt_root=calculate_receipt_root(receipts), + receipts=receipts, miner=miner_pk, ) diff --git a/minichain/block.py b/minichain/block.py index e087c5a..12bc141 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -4,36 +4,36 @@ from collections.abc import Sequence from .transaction import Transaction +from .receipt import Receipt from .serialization import canonical_json_hash, canonical_json_bytes - def _sha256(data: str) -> str: return hashlib.sha256(data.encode()).hexdigest() -# <-- Updated to Sequence to accept the frozen tuple -def _calculate_merkle_root(transactions: Sequence[Transaction]) -> Optional[str]: - if not transactions: +def _calculate_merkle_tree(hashes: Sequence[str]) -> Optional[str]: + if not hashes: return None - - # Hash each transaction deterministically - tx_hashes = [ - tx.tx_id - 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 - + hashes_list = list(hashes) + while len(hashes_list) > 1: + if len(hashes_list) % 2 != 0: + hashes_list.append(hashes_list[-1]) new_level = [] - for i in range(0, len(tx_hashes), 2): - combined = tx_hashes[i] + tx_hashes[i + 1] + for i in range(0, len(hashes_list), 2): + combined = hashes_list[i] + hashes_list[i + 1] new_level.append(_sha256(combined)) + hashes_list = new_level + return hashes_list[0] - tx_hashes = new_level +# <-- Updated to Sequence to accept the frozen tuple +def _calculate_merkle_root(transactions: Sequence[Transaction]) -> Optional[str]: + if not transactions: + return None + return _calculate_merkle_tree([tx.tx_id for tx in transactions]) - return tx_hashes[0] +def calculate_receipt_root(receipts: Sequence[Receipt]) -> Optional[str]: + if not receipts: + return None + return _calculate_merkle_tree([canonical_json_hash(r.to_dict()) for r in receipts]) class Block: def __init__( @@ -43,12 +43,16 @@ def __init__( transactions: Optional[Sequence[Transaction]] = None, timestamp: Optional[float] = None, difficulty: Optional[int] = None, - miner: Optional[str] = None + state_root: Optional[str] = None, + receipt_root: Optional[str] = None, + receipts: Optional[Sequence[Receipt]] = None, + miner: Optional[str] = None, ): self.index = index self.previous_hash = previous_hash # Freeze transactions into an immutable tuple to prevent header/body mismatch self.transactions = tuple(transactions) if transactions else () + self.receipts = tuple(receipts) if receipts else () self.miner = miner # Deterministic timestamp (ms) self.timestamp: int = ( @@ -60,10 +64,15 @@ def __init__( self.nonce: int = 0 self.hash: Optional[str] = None self.state_root: Optional[str] = state_root + self.receipt_root: Optional[str] = receipt_root self.miner: Optional[str] = miner - # NEW: compute merkle root once + # NEW: compute merkle roots once self.merkle_root: Optional[str] = _calculate_merkle_root(self.transactions) + + # If receipt_root is missing but we have receipts, calculate it. + if self.receipt_root is None and self.receipts: + self.receipt_root = calculate_receipt_root(self.receipts) # ------------------------- # HEADER (used for mining) @@ -74,10 +83,10 @@ def to_header_dict(self): "previous_hash": self.previous_hash, "merkle_root": self.merkle_root, "state_root": self.state_root, + "receipt_root": self.receipt_root, "timestamp": self.timestamp, "difficulty": self.difficulty, "nonce": self.nonce, - "miner": self.miner, } # Include miner in header only when present (optional field) <-- Reworded comment if self.miner is not None: @@ -91,6 +100,9 @@ def to_body_dict(self): return { "transactions": [ tx.to_dict() for tx in self.transactions + ], + "receipts": [ + r.to_dict() for r in self.receipts ] } @@ -115,6 +127,10 @@ def from_dict(cls, payload: dict): Transaction.from_dict(tx_payload) for tx_payload in payload.get("transactions", []) ] + receipts = [ + Receipt.from_dict(r_payload) + for r_payload in payload.get("receipts", []) + ] # Safely extract and cast difficulty if it exists raw_diff = payload.get("difficulty") @@ -123,13 +139,15 @@ def from_dict(cls, payload: dict): # 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"]), previous_hash=payload["previous_hash"], transactions=transactions, timestamp=parsed_ts, # <-- Passed the casted timestamp difficulty=parsed_diff, + state_root=payload.get("state_root"), + receipt_root=payload.get("receipt_root"), + receipts=receipts, miner=payload.get("miner"), ) block.nonce = int(payload.get("nonce", 0)) @@ -143,6 +161,12 @@ 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") + + if "receipt_root" in payload: + expected_receipt_root = calculate_receipt_root(block.receipts) + if payload["receipt_root"] != expected_receipt_root: + raise ValueError("receipt_root does not match receipts") + return block @property diff --git a/minichain/chain.py b/minichain/chain.py index c7fe286..0b7f41c 100644 --- a/minichain/chain.py +++ b/minichain/chain.py @@ -1,4 +1,4 @@ -from .block import Block +from .block import Block, calculate_receipt_root from .state import State from .pow import calculate_hash import logging @@ -47,10 +47,10 @@ def _create_genesis_block(self, genesis_path): with open(genesis_path, "r") as f: config = json.load(f) except Exception as e: - logger.error(f"Failed to load genesis config: {e}") + logger.error("Failed to load genesis config: %s", e) sys.exit(1) else: - logger.error(f"Failed to load genesis config: file {genesis_path} does not exist.") + logger.error("Failed to load genesis config: file %s does not exist.", genesis_path) sys.exit(1) # Apply genesis allocations @@ -58,7 +58,7 @@ def _create_genesis_block(self, genesis_path): for address, data in alloc.items(): balance = data.get("balance", 0) if not isinstance(balance, int) or balance < 0: - logger.error(f"Invalid genesis balance for {address}: {balance}. Must be a non-negative integer.") + logger.error("Invalid genesis balance for %s: %s. Must be a non-negative integer.", address, balance) sys.exit(1) account = self.state.get_account(address) account['balance'] = balance @@ -72,7 +72,9 @@ def _create_genesis_block(self, genesis_path): transactions=[], timestamp=timestamp, difficulty=difficulty, - state_root=self.state.state_root() + state_root=self.state.state_root(), + receipt_root=None, + receipts=[] ) computed_hash = calculate_hash(genesis_block.to_header_dict()) @@ -80,7 +82,7 @@ def _create_genesis_block(self, genesis_path): if config_hash: if config_hash != computed_hash: - logger.error(f"Genesis hash mismatch. Config hash: {config_hash}, Computed hash: {computed_hash}") + logger.error("Genesis hash mismatch. Config hash: %s, Computed hash: %s", config_hash, computed_hash) sys.exit(1) genesis_block.hash = config_hash else: @@ -111,17 +113,29 @@ def add_block(self, block): # Validate transactions on a temporary state copy temp_state = self.state.copy() + receipts = [] for tx in block.transactions: - result = temp_state.validate_and_apply(tx) + receipt = temp_state.validate_and_apply(tx) - # Reject block if any transaction fails - if not result: + # Reject block if any transaction fails mathematical validation (None) + if receipt is None: logger.warning("Block %s rejected: Transaction failed validation", block.index) return False + + receipts.append(receipt) if block.miner: temp_state.credit_mining_reward(block.miner) + + computed_receipt_root = calculate_receipt_root(receipts) + if block.receipt_root != computed_receipt_root: + logger.warning("Block %s rejected: Invalid receipt root. Expected %s, got %s", block.index, computed_receipt_root, block.receipt_root) + return False + + if [r.to_dict() for r in block.receipts] != [r.to_dict() for r in receipts]: + logger.warning("Block %s rejected: Receipts payload mismatch", block.index) + return False # Verify state root if block.state_root != temp_state.state_root(): diff --git a/minichain/contract.py b/minichain/contract.py index c88a20f..e7c2fd4 100644 --- a/minichain/contract.py +++ b/minichain/contract.py @@ -114,7 +114,7 @@ def execute(self, contract_address, sender_address, payload, amount): logger.error("Contract execution crashed without result") return False if result["status"] != "success": - logger.error(f"Contract Execution Failed: {result.get('error')}") + logger.error("Contract Execution Failed: %s", result.get('error')) return False # Validate storage is JSON serializable @@ -155,7 +155,7 @@ def _validate_code_ast(self, code): logger.warning("Rejected type() call.") return False if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id in {"getattr", "setattr", "delattr"}: - logger.warning(f"Rejected direct call to {node.func.id}.") + logger.warning("Rejected direct call to %s.", node.func.id) return False if isinstance(node, ast.Constant) and isinstance(node.value, str): if "__" in node.value: diff --git a/minichain/mpt.py b/minichain/mpt.py index ee30b8d..019ed50 100644 --- a/minichain/mpt.py +++ b/minichain/mpt.py @@ -1,164 +1,24 @@ -import hashlib -import json -from typing import Optional, List +from typing import Optional +from trie import HexaryTrie -def hash_data(data: bytes) -> bytes: - return hashlib.sha256(data).digest() - -def to_nibbles(key_hex: str) -> List[int]: - """Converts a hex string key into a list of integer nibbles (0-15).""" - try: - return [int(c, 16) for c in key_hex] - except ValueError: - raise ValueError(f"Invalid MPT key: '{key_hex}'. Keys must be valid hex strings.") - -class Node: - def hash(self) -> bytes: - raise NotImplementedError - -class LeafNode(Node): - def __init__(self, path: List[int], value: str): - self.path = path - self.value = value - - def hash(self) -> bytes: - data = json.dumps({"type": "leaf", "path": self.path, "value": self.value}, sort_keys=True) - return hash_data(data.encode()) - -class ExtensionNode(Node): - def __init__(self, path: List[int], child: Node): - self.path = path - self.child = child - - def hash(self) -> bytes: - child_hash = self.child.hash().hex() - data = json.dumps({"type": "extension", "path": self.path, "child": child_hash}, sort_keys=True) - return hash_data(data.encode()) - -class BranchNode(Node): - def __init__(self): - self.branches: List[Optional[Node]] = [None] * 16 - self.value: Optional[str] = None - - def hash(self) -> bytes: - b_hashes = [b.hash().hex() if b else None for b in self.branches] - data = json.dumps({"type": "branch", "branches": b_hashes, "value": self.value}, sort_keys=True) - return hash_data(data.encode()) class Trie: """ - A simplified Merkle Patricia Trie (MPT) for MiniChain. + A Merkle Patricia Trie (MPT) for MiniChain backed by the `trie` library. Provides O(log N) state verification via cryptographic state roots. """ def __init__(self): - self.root: Optional[Node] = None + self._trie = HexaryTrie({}) def root_hash(self) -> str: """Returns the 32-byte hex hash of the trie's root.""" - if not self.root: - return "0" * 64 - return self.root.hash().hex() + return self._trie.root_hash.hex() def get(self, key_hex: str) -> Optional[str]: - if not self.root: - return None - return self._get(self.root, to_nibbles(key_hex)) - - def _get(self, node: Optional[Node], path: List[int]) -> Optional[str]: - if not node: - return None - - if isinstance(node, LeafNode): - if node.path == path: - return node.value - return None - - elif isinstance(node, ExtensionNode): - if path[:len(node.path)] == node.path: - return self._get(node.child, path[len(node.path):]) - return None - - elif isinstance(node, BranchNode): - if not path: - return node.value - nibble = path[0] - return self._get(node.branches[nibble], path[1:]) - - return None + key = bytes.fromhex(key_hex) + val = self._trie.get(key) + return val.decode() if val is not None else None def put(self, key_hex: str, value: str): - path = to_nibbles(key_hex) - self.root = self._put(self.root, path, value) - - def _put(self, node: Optional[Node], path: List[int], value: str) -> Node: - if node is None: - return LeafNode(path, value) - - if isinstance(node, LeafNode): - if node.path == path: - node.value = value - return node - - # Paths diverge. Find common prefix. - common = 0 - while common < len(node.path) and common < len(path) and node.path[common] == path[common]: - common += 1 - - branch = BranchNode() - - # Handle the leaf's remaining path - leaf_remaining = node.path[common:] - if not leaf_remaining: - branch.value = node.value - else: - branch.branches[leaf_remaining[0]] = LeafNode(leaf_remaining[1:], node.value) - - # Handle the new value's remaining path - new_remaining = path[common:] - if not new_remaining: - branch.value = value - else: - branch.branches[new_remaining[0]] = LeafNode(new_remaining[1:], value) - - if common > 0: - return ExtensionNode(node.path[:common], branch) - return branch - - elif isinstance(node, ExtensionNode): - common = 0 - while common < len(node.path) and common < len(path) and node.path[common] == path[common]: - common += 1 - - if common == len(node.path): - # Path matches extension exactly, continue to child - node.child = self._put(node.child, path[common:], value) - return node - - # Divergence inside the extension node - branch = BranchNode() - ext_remaining = node.path[common:] - - # The child of the extension becomes a branch's branch - if len(ext_remaining) == 1: - branch.branches[ext_remaining[0]] = node.child - else: - branch.branches[ext_remaining[0]] = ExtensionNode(ext_remaining[1:], node.child) - - # Insert the new value - new_remaining = path[common:] - if not new_remaining: - branch.value = value - else: - branch.branches[new_remaining[0]] = LeafNode(new_remaining[1:], value) - - if common > 0: - return ExtensionNode(node.path[:common], branch) - return branch - - elif isinstance(node, BranchNode): - if not path: - node.value = value - else: - nibble = path[0] - node.branches[nibble] = self._put(node.branches[nibble], path[1:], value) - return node + key = bytes.fromhex(key_hex) + self._trie.set(key, value.encode()) diff --git a/minichain/p2p.py b/minichain/p2p.py index cb65c2e..647cb6e 100644 --- a/minichain/p2p.py +++ b/minichain/p2p.py @@ -139,7 +139,7 @@ def _validate_transaction_payload(self, payload): if not isinstance(payload.get(field), expected_type): return False - if payload["amount"] <= 0: + if payload["amount"] < 0: return False receiver = payload.get("receiver") @@ -182,6 +182,8 @@ def _validate_block_payload(self, payload): "previous_hash": str, "merkle_root": (str, type(None)), "state_root": str, + "receipt_root": (str, type(None)), + "receipts": list, "transactions": list, "timestamp": int, "difficulty": (int, type(None)), @@ -204,6 +206,22 @@ def _validate_block_payload(self, payload): if "miner" in payload and not isinstance(payload["miner"], (str, type(None))): return False + for r_payload in payload.get("receipts", []): + if not isinstance(r_payload, dict): + return False + if "tx_hash" not in r_payload or not isinstance(r_payload["tx_hash"], str): + return False + if "status" not in r_payload or not isinstance(r_payload["status"], int): + return False + if "gas_used" in r_payload and not isinstance(r_payload["gas_used"], int): + return False + if "error_message" in r_payload and not isinstance(r_payload["error_message"], (str, type(None))): + return False + if "logs" in r_payload and not isinstance(r_payload["logs"], list): + return False + if "contract_address" in r_payload and not isinstance(r_payload["contract_address"], (str, type(None))): + return False + return all( self._validate_transaction_payload(tx_payload) for tx_payload in payload["transactions"] diff --git a/minichain/persistence.py b/minichain/persistence.py index 6fa1fd6..d142879 100644 --- a/minichain/persistence.py +++ b/minichain/persistence.py @@ -93,7 +93,7 @@ def load(path: str = ".") -> Blockchain: if not isinstance(raw_block, dict): raise ValueError(f"Invalid chain data in '{path}'") try: - blocks.append(_deserialize_block(raw_block)) + blocks.append(Block.from_dict(raw_block)) except (KeyError, TypeError, ValueError) as exc: raise ValueError(f"Invalid chain data in '{path}'") from exc @@ -267,10 +267,4 @@ def _read_legacy_json(filepath: str) -> dict[str, Any]: return json.load(f) -# --------------------------------------------------------------------------- -# Block deserialisation -# --------------------------------------------------------------------------- - -def _deserialize_block(data: dict[str, Any]) -> Block: - return Block.from_dict(data) diff --git a/minichain/receipt.py b/minichain/receipt.py new file mode 100644 index 0000000..60053c9 --- /dev/null +++ b/minichain/receipt.py @@ -0,0 +1,34 @@ +from typing import List, Optional + +class Receipt: + """ + Represents the execution result of a transaction. + """ + def __init__(self, tx_hash: str, status: int, gas_used: int = 0, error_message: Optional[str] = None, logs: Optional[List[dict]] = None, contract_address: Optional[str] = None): + self.tx_hash = tx_hash + self.status = status # 1 for success, 0 for failure + self.gas_used = gas_used + self.error_message = error_message + self.logs = logs or [] + self.contract_address = contract_address + + def to_dict(self) -> dict: + return { + "tx_hash": self.tx_hash, + "status": self.status, + "gas_used": self.gas_used, + "error_message": self.error_message, + "logs": self.logs, + "contract_address": self.contract_address + } + + @classmethod + def from_dict(cls, payload: dict) -> 'Receipt': + return cls( + tx_hash=payload["tx_hash"], + status=payload["status"], + gas_used=payload.get("gas_used", 0), + error_message=payload.get("error_message"), + logs=payload.get("logs", []), + contract_address=payload.get("contract_address") + ) diff --git a/minichain/state.py b/minichain/state.py index e718534..5c62f76 100644 --- a/minichain/state.py +++ b/minichain/state.py @@ -1,8 +1,11 @@ +import copy +import json +import logging from nacl.hash import sha256 from nacl.encoding import HexEncoder from .contract import ContractMachine -import copy -import logging +from .mpt import Trie +from .receipt import Receipt logger = logging.getLogger(__name__) @@ -18,8 +21,6 @@ def state_root(self) -> str: Dynamically builds the Merkle Patricia Trie from the current state dictionary and returns the cryptographic state root hash. """ - import json - from .mpt import Trie trie = Trie() # Sort items to ensure deterministic insertion order if necessary (though MPT is order-independent) for addr, acc in sorted(self.accounts.items()): @@ -42,17 +43,17 @@ def get_account(self, address): def verify_transaction_logic(self, tx): if not tx.verify(): - logger.error(f"Error: Invalid signature for tx from {tx.sender[:8]}...") + logger.error("Error: Invalid signature for tx from %s...", tx.sender[:8]) return False sender_acc = self.get_account(tx.sender) if sender_acc['balance'] < tx.amount: - logger.error(f"Error: Insufficient balance for {tx.sender[:8]}...") + logger.error("Error: Insufficient balance for %s...", tx.sender[:8]) return False if sender_acc['nonce'] != tx.nonce: - logger.error(f"Error: Invalid nonce. Expected {sender_acc['nonce']}, got {tx.nonce}") + logger.error("Error: Invalid nonce. Expected %s, got %s", sender_acc['nonce'], tx.nonce) return False return True @@ -74,20 +75,17 @@ def validate_and_apply(self, tx): """ # Semantic validation: amount must be an integer and non-negative if not isinstance(tx.amount, int) or tx.amount < 0: - return False + return None # Further checks can be added here return self.apply_transaction(tx) def apply_transaction(self, tx): """ Applies transaction and mutates state. - Returns: - - Contract address (str) if deployment - - True if successful execution - - False if failed + Returns: Receipt object if mathematically valid, None if invalid. """ if not self.verify_transaction_logic(tx): - return False + return None sender = self.accounts[tx.sender] @@ -102,12 +100,12 @@ def apply_transaction(self, tx): # Prevent redeploy collision existing = self.accounts.get(contract_address) if existing and existing.get("code"): - # Restore sender state on failure + # Restore sender balance on failure, but keep nonce incremented sender['balance'] += tx.amount - sender['nonce'] -= 1 - return False + return Receipt(tx.tx_id, status=0, error_message="Contract collision") - return self.create_contract(contract_address, tx.data, initial_balance=tx.amount) + self.create_contract(contract_address, tx.data, initial_balance=tx.amount) + return Receipt(tx.tx_id, status=1, contract_address=contract_address) # LOGIC BRANCH 2: Contract Call # If data is provided (non-empty), treat as contract call @@ -116,10 +114,9 @@ def apply_transaction(self, tx): # Fail if contract does not exist or has no code if not receiver or not receiver.get("code"): - # Rollback sender balance and nonce on failure + # Rollback sender balance on failure, but keep nonce incremented sender['balance'] += tx.amount # Refund amount - sender['nonce'] -= 1 - return False + return Receipt(tx.tx_id, status=0, error_message="Contract not found") # Credit contract balance receiver['balance'] += tx.amount @@ -132,18 +129,17 @@ def apply_transaction(self, tx): ) if not success: - # Rollback transfer and nonce if execution fails + # Rollback transfer if execution fails, but keep nonce incremented receiver['balance'] -= tx.amount sender['balance'] += tx.amount # Refund amount - sender['nonce'] -= 1 - return False + return Receipt(tx.tx_id, status=0, error_message="Execution failed") - return True + return Receipt(tx.tx_id, status=1) # LOGIC BRANCH 3: Regular Transfer receiver = self.get_account(tx.receiver) receiver['balance'] += tx.amount - return True + return Receipt(tx.tx_id, status=1) def derive_contract_address(self, sender, nonce): raw = f"{sender}:{nonce}".encode() diff --git a/requirements.txt b/requirements.txt index 819e170..99cd065 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ pynacl==1.6.2 -libp2p==0.5.0 +trie>=3.1.0 diff --git a/setup.py b/setup.py deleted file mode 100644 index 1edff7b..0000000 --- a/setup.py +++ /dev/null @@ -1,18 +0,0 @@ -from setuptools import setup, find_packages - -setup( - name="minichain", - version="0.1.0", - packages=find_packages(), - py_modules=["main"], - install_requires=[ - "PyNaCl>=1.5.0", - "libp2p>=0.5.0", # Correct PyPI package name - ], - entry_points={ - "console_scripts": [ - "minichain=main:main", - ], - }, - python_requires=">=3.9", -) diff --git a/tests/test_contract.py b/tests/test_contract.py index 2ac6e9f..49431e8 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -26,14 +26,18 @@ def test_deploy_and_execute(self): tx_deploy = Transaction(self.pk, None, 0, 0, data=code) tx_deploy.sign(self.sk) - contract_addr = self.state.apply_transaction(tx_deploy) + receipt_deploy = self.state.apply_transaction(tx_deploy) + self.assertIsNotNone(receipt_deploy) + self.assertEqual(receipt_deploy.status, 1) + contract_addr = receipt_deploy.contract_address self.assertTrue(isinstance(contract_addr, str)) tx_call = Transaction(self.pk, contract_addr, 0, 1, data="increment") tx_call.sign(self.sk) - success = self.state.apply_transaction(tx_call) - self.assertTrue(success) + receipt_call = self.state.apply_transaction(tx_call) + self.assertIsNotNone(receipt_call) + self.assertEqual(receipt_call.status, 1) contract_acc = self.state.get_account(contract_addr) self.assertEqual(contract_acc["storage"]["counter"], 1) @@ -49,8 +53,9 @@ def test_deploy_insufficient_balance(self): tx = Transaction(poor_pk, None, 1000, 0, data=code) tx.sign(poor_sk) - result = self.state.apply_transaction(tx) - self.assertFalse(result) + receipt = self.state.apply_transaction(tx) + # deploy with insufficient balance should fail mathematical validation entirely + self.assertIsNone(receipt) def test_call_non_existent_contract(self): """Calling unknown contract should fail with valid hex receiver.""" @@ -61,8 +66,10 @@ def test_call_non_existent_contract(self): tx = Transaction(self.pk, fake_receiver, 0, 0, data="increment") tx.sign(self.sk) - result = self.state.apply_transaction(tx) - self.assertFalse(result) + receipt = self.state.apply_transaction(tx) + self.assertIsNotNone(receipt) + self.assertEqual(receipt.status, 0) + self.assertEqual(receipt.error_message, "Contract not found") def test_contract_runtime_exception(self): """Contract raising exception should fail and not mutate storage.""" @@ -74,14 +81,19 @@ def test_contract_runtime_exception(self): tx_deploy = Transaction(self.pk, None, 0, 0, data=code) tx_deploy.sign(self.sk) - contract_addr = self.state.apply_transaction(tx_deploy) + receipt_deploy = self.state.apply_transaction(tx_deploy) + self.assertIsNotNone(receipt_deploy) + self.assertEqual(receipt_deploy.status, 1) + contract_addr = receipt_deploy.contract_address self.assertTrue(isinstance(contract_addr, str)) tx_call = Transaction(self.pk, contract_addr, 0, 1, data="anything") tx_call.sign(self.sk) - result = self.state.apply_transaction(tx_call) - self.assertFalse(result) + receipt_call = self.state.apply_transaction(tx_call) + self.assertIsNotNone(receipt_call) + self.assertEqual(receipt_call.status, 0) + self.assertEqual(receipt_call.error_message, "Execution failed") contract_acc = self.state.get_account(contract_addr) self.assertEqual(contract_acc["storage"], {}) @@ -95,7 +107,10 @@ def test_redeploy_same_address(self): tx1 = Transaction(self.pk, None, 0, 0, data=code) tx1.sign(self.sk) - addr = self.state.apply_transaction(tx1) + receipt1 = self.state.apply_transaction(tx1) + self.assertIsNotNone(receipt1) + self.assertEqual(receipt1.status, 1) + addr = receipt1.contract_address self.assertTrue(isinstance(addr, str)) # Compute the address that a second deploy would use @@ -109,8 +124,10 @@ def test_redeploy_same_address(self): tx2 = Transaction(self.pk, None, 0, next_nonce, data=code) tx2.sign(self.sk) - result = self.state.apply_transaction(tx2) - self.assertFalse(result) + receipt2 = self.state.apply_transaction(tx2) + self.assertIsNotNone(receipt2) + self.assertEqual(receipt2.status, 0) + self.assertEqual(receipt2.error_message, "Contract collision") def test_balance_and_nonce_updates(self): """Verify sender balance and nonce after deploy and call.""" @@ -124,8 +141,10 @@ def test_balance_and_nonce_updates(self): tx_deploy = Transaction(self.pk, None, 10, initial_nonce, data=code) tx_deploy.sign(self.sk) - # Corrected typo: contract_add_ to contract_addr - contract_addr = self.state.apply_transaction(tx_deploy) + receipt = self.state.apply_transaction(tx_deploy) + self.assertIsNotNone(receipt) + self.assertEqual(receipt.status, 1) + contract_addr = receipt.contract_address self.assertTrue(isinstance(contract_addr, str)) # Verify balance and nonce after deploy diff --git a/tests/test_core.py b/tests/test_core.py index 0a818ed..57d4968 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -30,7 +30,7 @@ def test_transaction_signature(self): self.assertTrue(tx.verify()) # Tamper with amount - tx.amount = 100 + object.__setattr__(tx, 'amount', 100) self.assertFalse(tx.verify()) def test_state_transfer(self): diff --git a/tests/test_persistence.py b/tests/test_persistence.py index fe2d78f..c215712 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -42,14 +42,17 @@ def _chain_with_tx(self): tx.sign(alice_sk) temp_state = bc.state.copy() - temp_state.validate_and_apply(tx) + receipt = temp_state.validate_and_apply(tx) + from minichain.block import calculate_receipt_root block = Block( index=1, previous_hash=bc.last_block.hash, transactions=[tx], difficulty=1, state_root=temp_state.state_root(), + receipt_root=calculate_receipt_root([receipt]), + receipts=[receipt], ) mine_block(block, difficulty=1) bc.add_block(block) @@ -88,6 +91,18 @@ def test_transaction_data_preserved(self): self.assertEqual(original_tx.nonce, loaded_tx.nonce) self.assertEqual(original_tx.signature, loaded_tx.signature) + def test_receipt_data_preserved(self): + bc, _, _ = self._chain_with_tx() + save(bc, path=self.tmpdir) + restored = load(path=self.tmpdir) + original_receipt = bc.chain[1].receipts[0] + loaded_receipt = restored.chain[1].receipts[0] + self.assertEqual(original_receipt.tx_hash, loaded_receipt.tx_hash) + self.assertEqual(original_receipt.status, loaded_receipt.status) + self.assertEqual(original_receipt.gas_used, loaded_receipt.gas_used) + self.assertEqual(original_receipt.error_message, loaded_receipt.error_message) + self.assertEqual(original_receipt.contract_address, loaded_receipt.contract_address) + def test_genesis_only_chain(self): bc = Blockchain() save(bc, path=self.tmpdir) @@ -221,14 +236,17 @@ def test_loaded_chain_can_add_new_block(self): tx2.sign(new_sk) temp_state = restored.state.copy() - temp_state.validate_and_apply(tx2) + receipt2 = temp_state.validate_and_apply(tx2) + from minichain.block import calculate_receipt_root block2 = Block( index=len(restored.chain), previous_hash=restored.last_block.hash, transactions=[tx2], difficulty=1, state_root=temp_state.state_root(), + receipt_root=calculate_receipt_root([receipt2]), + receipts=[receipt2], ) mine_block(block2, difficulty=1) diff --git a/tests/test_persistence_runtime.py b/tests/test_persistence_runtime.py index 21d437b..894ccca 100644 --- a/tests/test_persistence_runtime.py +++ b/tests/test_persistence_runtime.py @@ -63,11 +63,18 @@ def _chain_with_tx(self): tx = Transaction(alice_pk, bob_pk, 30, 0) tx.sign(alice_sk) + temp_state = bc.state.copy() + receipt = temp_state.validate_and_apply(tx) + + from minichain.block import calculate_receipt_root block = Block( index=1, previous_hash=bc.last_block.hash, transactions=[tx], difficulty=1, + state_root=temp_state.state_root(), + receipt_root=calculate_receipt_root([receipt]), + receipts=[receipt], ) mine_block(block, difficulty=1) bc.add_block(block) diff --git a/tests/test_protocol_hardening.py b/tests/test_protocol_hardening.py index ba9028c..538c8cc 100644 --- a/tests/test_protocol_hardening.py +++ b/tests/test_protocol_hardening.py @@ -108,7 +108,20 @@ async def test_block_schema_accepts_current_block_wire_format(self): tx = Transaction(sender_pk, receiver_pk, 1, 0, timestamp=123) tx.sign(sender_sk) - block = Block(index=1, previous_hash="0" * 64, transactions=[tx], timestamp=456, difficulty=2, state_root="0"*64) + from minichain.receipt import Receipt + from minichain.block import calculate_receipt_root + receipt = Receipt(tx_hash=tx.tx_id, status=1) + + block = Block( + index=1, + previous_hash="0" * 64, + transactions=[tx], + timestamp=456, + difficulty=2, + state_root="0"*64, + receipts=[receipt], + receipt_root=calculate_receipt_root([receipt]) + ) block.nonce = 9 block.hash = block.compute_hash() diff --git a/tests/test_transaction_signing.py b/tests/test_transaction_signing.py index 05b79d0..ee3c845 100644 --- a/tests/test_transaction_signing.py +++ b/tests/test_transaction_signing.py @@ -71,7 +71,7 @@ def test_tampered_amount_fails_verification(alice, bob): tx = Transaction(alice_pk, bob_pk, 10, nonce=0) tx.sign(alice_sk) - tx.amount = 9999 # tamper + object.__setattr__(tx, "amount", 9999) # tamper assert not tx.verify(), "A transaction with a tampered amount must not verify." @@ -85,7 +85,7 @@ def test_tampered_receiver_fails_verification(alice, bob): tx.sign(alice_sk) attacker_sk = SigningKey.generate() - tx.receiver = attacker_sk.verify_key.encode(encoder=HexEncoder).decode() # tamper + object.__setattr__(tx, "receiver", attacker_sk.verify_key.encode(encoder=HexEncoder).decode()) # tamper assert not tx.verify(), "A transaction with a tampered receiver must not verify." @@ -97,7 +97,7 @@ def test_tampered_nonce_fails_verification(alice, bob): tx = Transaction(alice_pk, bob_pk, 10, nonce=0) tx.sign(alice_sk) - tx.nonce = 99 # tamper + object.__setattr__(tx, "nonce", 99) # tamper assert not tx.verify(), "A transaction with a tampered nonce must not verify." @@ -124,7 +124,7 @@ def test_forged_sender_field_fails_verification(alice, bob): tx = Transaction(alice_pk, bob_pk, 10, nonce=0) tx.sign(alice_sk) - tx.sender = bob_pk # forge sender + object.__setattr__(tx, "sender", bob_pk) # forge sender assert not tx.verify(), "A transaction with a forged sender field must not verify."