From e232e2c2d1681d75904b6713d3c395fb85f42b13 Mon Sep 17 00:00:00 2001 From: siddhant Date: Sun, 31 May 2026 04:06:14 +0530 Subject: [PATCH 1/8] feat: add receipts --- main.py | 10 +++++- minichain/block.py | 58 +++++++++++++++++++++---------- minichain/chain.py | 19 +++++++--- minichain/p2p.py | 2 ++ minichain/receipt.py | 34 ++++++++++++++++++ minichain/state.py | 33 ++++++++---------- tests/test_contract.py | 49 ++++++++++++++++++-------- tests/test_persistence.py | 10 ++++-- tests/test_persistence_runtime.py | 7 ++++ tests/test_protocol_hardening.py | 15 +++++++- 10 files changed, 177 insertions(+), 60 deletions(-) create mode 100644 minichain/receipt.py diff --git a/main.py b/main.py index 17de79a..4fb8c70 100644 --- a/main.py +++ b/main.py @@ -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) @@ -78,11 +82,15 @@ def mine_and_process_block(chain, mempool, miner_pk): temp_state.credit_mining_reward(miner_pk) + from minichain.block import _calculate_receipt_root + block = Block( 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 d68d985..e0d6805 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -2,35 +2,37 @@ import hashlib from typing import List, Optional from .transaction import Transaction +from .receipt import Receipt from .serialization import canonical_json_hash def _sha256(data: str) -> str: return hashlib.sha256(data.encode()).hexdigest() -def _calculate_merkle_root(transactions: List[Transaction]) -> Optional[str]: - if not transactions: +def _calculate_merkle_tree(hashes: List[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 - + while len(hashes) > 1: + if len(hashes) % 2 != 0: + hashes.append(hashes[-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), 2): + combined = hashes[i] + hashes[i + 1] new_level.append(_sha256(combined)) + hashes = new_level + return hashes[0] - tx_hashes = new_level +def _calculate_merkle_root(transactions: List[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: List[Receipt]) -> Optional[str]: + if not receipts: + return None + return _calculate_merkle_tree([canonical_json_hash(r.to_dict()) for r in receipts]) + + # Logic moved to _calculate_merkle_tree class Block: @@ -42,11 +44,14 @@ def __init__( timestamp: Optional[float] = None, difficulty: Optional[int] = None, state_root: Optional[str] = None, + receipt_root: Optional[str] = None, + receipts: Optional[List[Receipt]] = None, miner: Optional[str] = None, ): self.index = index self.previous_hash = previous_hash self.transactions: List[Transaction] = transactions or [] + self.receipts: List[Receipt] = receipts or [] # Deterministic timestamp (ms) self.timestamp: int = ( @@ -59,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) @@ -73,6 +83,7 @@ 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, @@ -86,6 +97,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 ] } @@ -111,6 +125,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", []) + ] block = cls( index=payload["index"], previous_hash=payload["previous_hash"], @@ -118,6 +136,8 @@ def from_dict(cls, payload: dict): timestamp=payload.get("timestamp"), difficulty=payload.get("difficulty"), state_root=payload.get("state_root"), + receipt_root=payload.get("receipt_root"), + receipts=receipts, miner=payload.get("miner"), ) block.nonce = payload.get("nonce", 0) diff --git a/minichain/chain.py b/minichain/chain.py index c7fe286..2d11aa5 100644 --- a/minichain/chain.py +++ b/minichain/chain.py @@ -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()) @@ -111,17 +113,26 @@ 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) + + from .block import _calculate_receipt_root + 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 # Verify state root if block.state_root != temp_state.state_root(): diff --git a/minichain/p2p.py b/minichain/p2p.py index 7462962..2374e92 100644 --- a/minichain/p2p.py +++ b/minichain/p2p.py @@ -184,6 +184,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)), 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..0d5c088 100644 --- a/minichain/state.py +++ b/minichain/state.py @@ -74,20 +74,19 @@ 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): + from .receipt import Receipt + """ 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 +101,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 +115,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 +130,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/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_persistence.py b/tests/test_persistence.py index fe2d78f..a615892 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) @@ -221,14 +224,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..15e9ef7 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..7644f38 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() From 8e80004191a708615db069dcedea8bb2a15e3099 Mon Sep 17 00:00:00 2001 From: siddhant Date: Wed, 3 Jun 2026 19:29:12 +0530 Subject: [PATCH 2/8] implement trie lib, remove redundant code --- main.py | 6 +- minichain/block.py | 6 +- minichain/chain.py | 13 ++- minichain/contract.py | 4 +- minichain/mpt.py | 160 ++---------------------------- minichain/p2p.py | 2 +- minichain/persistence.py | 8 +- minichain/state.py | 17 ++-- requirements.txt | 2 +- setup.py | 2 +- tests/test_persistence.py | 8 +- tests/test_persistence_runtime.py | 4 +- tests/test_protocol_hardening.py | 4 +- 13 files changed, 42 insertions(+), 194 deletions(-) diff --git a/main.py b/main.py index 4fb8c70..9d1e774 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"} @@ -82,14 +82,12 @@ def mine_and_process_block(chain, mempool, miner_pk): temp_state.credit_mining_reward(miner_pk) - from minichain.block import _calculate_receipt_root - block = Block( 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), + receipt_root=calculate_receipt_root(receipts), receipts=receipts, miner=miner_pk, ) diff --git a/minichain/block.py b/minichain/block.py index e0d6805..24eaadb 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -27,13 +27,11 @@ def _calculate_merkle_root(transactions: List[Transaction]) -> Optional[str]: return None return _calculate_merkle_tree([tx.tx_id for tx in transactions]) -def _calculate_receipt_root(receipts: List[Receipt]) -> Optional[str]: +def calculate_receipt_root(receipts: List[Receipt]) -> Optional[str]: if not receipts: return None return _calculate_merkle_tree([canonical_json_hash(r.to_dict()) for r in receipts]) - # Logic moved to _calculate_merkle_tree - class Block: def __init__( @@ -72,7 +70,7 @@ def __init__( # 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) + self.receipt_root = calculate_receipt_root(self.receipts) # ------------------------- # HEADER (used for mining) diff --git a/minichain/chain.py b/minichain/chain.py index 2d11aa5..1ce119f 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 @@ -82,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: @@ -128,8 +128,7 @@ def add_block(self, block): if block.miner: temp_state.credit_mining_reward(block.miner) - from .block import _calculate_receipt_root - computed_receipt_root = _calculate_receipt_root(receipts) + 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 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 2374e92..0fe1e79 100644 --- a/minichain/p2p.py +++ b/minichain/p2p.py @@ -141,7 +141,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") 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/state.py b/minichain/state.py index 0d5c088..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 @@ -79,8 +80,6 @@ def validate_and_apply(self, tx): return self.apply_transaction(tx) def apply_transaction(self, tx): - from .receipt import Receipt - """ Applies transaction and mutates state. Returns: Receipt object if mathematically valid, None if invalid. 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 index 1edff7b..c7c452a 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ py_modules=["main"], install_requires=[ "PyNaCl>=1.5.0", - "libp2p>=0.5.0", # Correct PyPI package name + "trie>=3.1.0", ], entry_points={ "console_scripts": [ diff --git a/tests/test_persistence.py b/tests/test_persistence.py index a615892..ac683c6 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -44,14 +44,14 @@ def _chain_with_tx(self): temp_state = bc.state.copy() receipt = temp_state.validate_and_apply(tx) - from minichain.block import _calculate_receipt_root + 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]), + receipt_root=calculate_receipt_root([receipt]), receipts=[receipt], ) mine_block(block, difficulty=1) @@ -226,14 +226,14 @@ def test_loaded_chain_can_add_new_block(self): temp_state = restored.state.copy() receipt2 = temp_state.validate_and_apply(tx2) - from minichain.block import _calculate_receipt_root + 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]), + 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 15e9ef7..894ccca 100644 --- a/tests/test_persistence_runtime.py +++ b/tests/test_persistence_runtime.py @@ -66,14 +66,14 @@ def _chain_with_tx(self): temp_state = bc.state.copy() receipt = temp_state.validate_and_apply(tx) - from minichain.block import _calculate_receipt_root + 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]), + receipt_root=calculate_receipt_root([receipt]), receipts=[receipt], ) mine_block(block, difficulty=1) diff --git a/tests/test_protocol_hardening.py b/tests/test_protocol_hardening.py index 7644f38..538c8cc 100644 --- a/tests/test_protocol_hardening.py +++ b/tests/test_protocol_hardening.py @@ -109,7 +109,7 @@ async def test_block_schema_accepts_current_block_wire_format(self): tx.sign(sender_sk) from minichain.receipt import Receipt - from minichain.block import _calculate_receipt_root + from minichain.block import calculate_receipt_root receipt = Receipt(tx_hash=tx.tx_id, status=1) block = Block( @@ -120,7 +120,7 @@ async def test_block_schema_accepts_current_block_wire_format(self): difficulty=2, state_root="0"*64, receipts=[receipt], - receipt_root=_calculate_receipt_root([receipt]) + receipt_root=calculate_receipt_root([receipt]) ) block.nonce = 9 block.hash = block.compute_hash() From 1998d91ea90ba9b4abf51ab81cfcddd6b5df7aa9 Mon Sep 17 00:00:00 2001 From: siddhant Date: Thu, 4 Jun 2026 04:27:56 +0530 Subject: [PATCH 3/8] remove setup.py anf fixes --- minichain/chain.py | 4 ++++ minichain/p2p.py | 16 ++++++++++++++++ setup.py | 18 ------------------ tests/test_persistence.py | 12 ++++++++++++ 4 files changed, 32 insertions(+), 18 deletions(-) delete mode 100644 setup.py diff --git a/minichain/chain.py b/minichain/chain.py index 1ce119f..0b7f41c 100644 --- a/minichain/chain.py +++ b/minichain/chain.py @@ -133,6 +133,10 @@ def add_block(self, block): 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(): logger.warning("Block %s rejected: Invalid state root. Expected %s, got %s", block.index, temp_state.state_root(), block.state_root) diff --git a/minichain/p2p.py b/minichain/p2p.py index 0fe1e79..0efab33 100644 --- a/minichain/p2p.py +++ b/minichain/p2p.py @@ -208,6 +208,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/setup.py b/setup.py deleted file mode 100644 index c7c452a..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", - "trie>=3.1.0", - ], - entry_points={ - "console_scripts": [ - "minichain=main:main", - ], - }, - python_requires=">=3.9", -) diff --git a/tests/test_persistence.py b/tests/test_persistence.py index ac683c6..c215712 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -91,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) From 0812e468edf592ead43158320d1d7f4141676004 Mon Sep 17 00:00:00 2001 From: siddhant Date: Thu, 4 Jun 2026 16:28:55 +0530 Subject: [PATCH 4/8] address coderabbit --- minichain/block.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/minichain/block.py b/minichain/block.py index 8ed2936..12bc141 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -87,7 +87,6 @@ def to_header_dict(self): "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: @@ -162,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 From e556935b49f668b4118a456da51d7a8811fb6f35 Mon Sep 17 00:00:00 2001 From: siddhant Date: Fri, 5 Jun 2026 20:20:17 +0530 Subject: [PATCH 5/8] implement transaction fee system --- main.py | 13 +++++++---- minichain/chain.py | 3 ++- minichain/mempool.py | 4 +++- minichain/p2p.py | 1 + minichain/state.py | 9 +++++--- minichain/transaction.py | 11 +++++---- tests/test_core.py | 50 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 77 insertions(+), 14 deletions(-) diff --git a/main.py b/main.py index cbdcd51..bc209ca 100644 --- a/main.py +++ b/main.py @@ -80,7 +80,8 @@ def mine_and_process_block(chain, mempool, miner_pk): logger.info("No mineable transactions in current queue window.") return None - temp_state.credit_mining_reward(miner_pk) + total_fees = sum(getattr(tx, 'fee', 0) for tx in mineable_txs) + temp_state.credit_mining_reward(miner_pk, reward=temp_state.DEFAULT_MINING_REWARD + total_fees) block = Block( index=chain.last_block.index + 1, @@ -213,7 +214,7 @@ async def cli_loop(sk, pk, chain, mempool, network): # ── send ── elif cmd == "send": if len(parts) < 3: - print(" Usage: send ") + print(" Usage: send [fee]") continue receiver = parts[1] if not is_valid_receiver(receiver): @@ -221,15 +222,19 @@ async def cli_loop(sk, pk, chain, mempool, network): continue try: amount = int(parts[2]) + fee = int(parts[3]) if len(parts) > 3 else 0 except ValueError: - print(" Amount must be an integer.") + print(" Amount and fee must be integers.") continue if amount <= 0: print(" Amount must be greater than 0.") continue + if fee < 0: + print(" Fee cannot be negative.") + continue nonce = chain.state.get_account(pk).get("nonce", 0) - tx = Transaction(sender=pk, receiver=receiver, amount=amount, nonce=nonce) + tx = Transaction(sender=pk, receiver=receiver, amount=amount, nonce=nonce, fee=fee) tx.sign(sk) if mempool.add_transaction(tx): diff --git a/minichain/chain.py b/minichain/chain.py index 0b7f41c..9ef8ee6 100644 --- a/minichain/chain.py +++ b/minichain/chain.py @@ -125,8 +125,9 @@ def add_block(self, block): receipts.append(receipt) + total_fees = sum(getattr(tx, 'fee', 0) for tx in block.transactions) if block.miner: - temp_state.credit_mining_reward(block.miner) + temp_state.credit_mining_reward(block.miner, reward=temp_state.DEFAULT_MINING_REWARD + total_fees) computed_receipt_root = calculate_receipt_root(receipts) if block.receipt_root != computed_receipt_root: diff --git a/minichain/mempool.py b/minichain/mempool.py index 4b71e08..6e3d8d9 100644 --- a/minichain/mempool.py +++ b/minichain/mempool.py @@ -51,7 +51,9 @@ def get_transactions_for_block(self): for sender, txs in snapshot.items(): if txs: - if best_tx is None or (txs[0].timestamp, sender, txs[0].nonce) < (best_tx.timestamp, best_sender, best_tx.nonce): + current_criteria = (-getattr(txs[0], 'fee', 0), txs[0].timestamp, sender, txs[0].nonce) + best_criteria = (-getattr(best_tx, 'fee', 0), best_tx.timestamp, best_sender, best_tx.nonce) if best_tx else None + if best_tx is None or current_criteria < best_criteria: best_tx = txs[0] best_sender = sender diff --git a/minichain/p2p.py b/minichain/p2p.py index 647cb6e..7bb1e34 100644 --- a/minichain/p2p.py +++ b/minichain/p2p.py @@ -118,6 +118,7 @@ def _validate_transaction_payload(self, payload): required_fields = { "sender": str, "amount": int, + "fee": int, "nonce": int, "timestamp": int, "signature": str, diff --git a/minichain/state.py b/minichain/state.py index 5c62f76..cfbb771 100644 --- a/minichain/state.py +++ b/minichain/state.py @@ -48,8 +48,9 @@ def verify_transaction_logic(self, tx): sender_acc = self.get_account(tx.sender) - if sender_acc['balance'] < tx.amount: - logger.error("Error: Insufficient balance for %s...", tx.sender[:8]) + total_cost = tx.amount + getattr(tx, 'fee', 0) + if sender_acc['balance'] < total_cost: + logger.warning("Invalid tx %s: insufficient balance", tx.tx_id) return False if sender_acc['nonce'] != tx.nonce: @@ -89,8 +90,10 @@ def apply_transaction(self, tx): sender = self.accounts[tx.sender] + total_cost = tx.amount + getattr(tx, 'fee', 0) + # Deduct funds and increment nonce - sender['balance'] -= tx.amount + sender['balance'] -= total_cost sender['nonce'] += 1 # LOGIC BRANCH 1: Contract Deployment diff --git a/minichain/transaction.py b/minichain/transaction.py index 4abe268..ca282ea 100644 --- a/minichain/transaction.py +++ b/minichain/transaction.py @@ -6,7 +6,7 @@ class Transaction: - _TX_FIELDS = frozenset({"sender", "receiver", "amount", "nonce", "data", "timestamp", "signature"}) + _TX_FIELDS = frozenset({"sender", "receiver", "amount", "fee", "nonce", "data", "timestamp", "signature"}) def __setattr__(self, name, value) -> None: if name in self._TX_FIELDS and getattr(self, "_sealed", False): @@ -23,10 +23,11 @@ def _normalize_ts(ts) -> int: # If it's already in milliseconds (>= 1e12), just ensure it's an integer return int(ts) - def __init__(self, sender, receiver, amount, nonce, data=None, signature=None, timestamp=None): + def __init__(self, sender, receiver, amount, nonce, fee=0, data=None, signature=None, timestamp=None): self.sender = sender self.receiver = receiver self.amount = amount + self.fee = fee self.nonce = nonce self.data = data self.timestamp = self._normalize_ts(timestamp) if timestamp is not None else round(time.time() * 1000) @@ -35,18 +36,18 @@ def __init__(self, sender, receiver, amount, nonce, data=None, signature=None, t self._sealed = False def to_dict(self): - return {"sender": self.sender, "receiver": self.receiver, "amount": self.amount, + return {"sender": self.sender, "receiver": self.receiver, "amount": self.amount, "fee": self.fee, "nonce": self.nonce, "data": self.data, "timestamp": self.timestamp, "signature": self.signature} def to_signing_dict(self): - return {"sender": self.sender, "receiver": self.receiver, "amount": self.amount, + return {"sender": self.sender, "receiver": self.receiver, "amount": self.amount, "fee": self.fee, "nonce": self.nonce, "data": self.data, "timestamp": self.timestamp} @classmethod def from_dict(cls, payload: dict): return cls(sender=payload["sender"], receiver=payload.get("receiver"), - amount=payload["amount"], nonce=payload["nonce"], + amount=payload["amount"], nonce=payload["nonce"], fee=payload["fee"], data=payload.get("data"), signature=payload.get("signature"), timestamp=payload.get("timestamp")) diff --git a/tests/test_core.py b/tests/test_core.py index 57d4968..93d68d6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -62,6 +62,56 @@ def test_insufficient_funds(self): self.assertEqual(self.state.get_account(self.alice_pk)['balance'], 10) self.assertEqual(self.state.get_account(self.bob_pk)['balance'], 0) + def test_transaction_fee(self): + """Test that transaction fees are properly deducted and credited.""" + self.state.credit_mining_reward(self.alice_pk, 100) + + tx = Transaction(self.alice_pk, self.bob_pk, 40, 0, fee=5) + tx.sign(self.alice_sk) + + receipt = self.state.validate_and_apply(tx) + self.assertIsNotNone(receipt) + + # Check sender balance (100 - 40 - 5 = 55) + self.assertEqual(self.state.get_account(self.alice_pk)['balance'], 55) + # Check receiver balance (40) + self.assertEqual(self.state.get_account(self.bob_pk)['balance'], 40) + + # Test miner reward with fee + from minichain.block import Block, calculate_receipt_root + import main + + # Manually create a block to simulate chain processing + block = Block( + index=1, + previous_hash="0", + transactions=[tx], + difficulty=1, + state_root=self.state.state_root(), + receipt_root=calculate_receipt_root([receipt]), + receipts=[receipt], + miner=self.bob_pk + ) + + # We need to simulate the chain.add_block logic for the miner credit + # Actually, let's just use Blockchain.add_block logic directly by mining + self.chain.state = self.state.copy() + + from minichain.mempool import Mempool + mempool = Mempool() + mempool.add_transaction(tx) + + # Revert state to before tx since mine_and_process_block will re-apply it + self.chain.state.accounts[self.alice_pk]['balance'] = 100 + self.chain.state.accounts[self.alice_pk]['nonce'] = 0 + self.chain.state.accounts[self.bob_pk]['balance'] = 0 + + mined_block = main.mine_and_process_block(self.chain, mempool, self.bob_pk) + self.assertIsNotNone(mined_block) + + # Bob was the miner. Bob gets amount(40) + mining_reward(50) + fee(5) = 95 + self.assertEqual(self.chain.state.get_account(self.bob_pk)['balance'], 95) + def test_transaction_wrong_signer(self): """Test that a transaction signed with the wrong key is invalid.""" tx = Transaction(self.alice_pk, self.bob_pk, 10, 0) # Alice is sender From 116a09b0a0f79dc31cb65ad8df0cbc11dbee51fb Mon Sep 17 00:00:00 2001 From: siddhant Date: Sun, 7 Jun 2026 01:20:24 +0530 Subject: [PATCH 6/8] feat: implement smart contract CLI, gas metering, and examples --- README.md | 20 ++++++++++ examples/counter.py | 37 +++++++++++++++++ examples/dex.py | 90 ++++++++++++++++++++++++++++++++++++++++++ examples/stablecoin.py | 41 +++++++++++++++++++ main.py | 71 ++++++++++++++++++++++++++++++++- minichain/chain.py | 2 +- minichain/contract.py | 64 ++++++++++++++++++++++-------- minichain/state.py | 28 ++++++++----- tests/test_contract.py | 53 +++++++++++++++++++------ 9 files changed, 363 insertions(+), 43 deletions(-) create mode 100644 examples/counter.py create mode 100644 examples/dex.py create mode 100644 examples/stablecoin.py diff --git a/README.md b/README.md index 58fea8e..b14934e 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,26 @@ It is encouraged that you develop an initial prototype during the application ph * Read this book: https://www.marabu.dev/blockchain-foundations.pdf +--- + +## Smart Contracts + +MiniChain supports fully-functional smart contracts written directly in Python! +The execution engine uses `sys.settrace` for precise **Gas Metering** (charging 1 gas per executed opcode) and `multiprocessing` for **Sandboxed Execution** to ensure network security. + +### Writing a Contract +Smart contracts in MiniChain have access to a persistent `storage` dictionary and a `msg` dictionary containing transaction context (`sender`, `value`, `data`). + +Check out the `/examples` directory for tutorials: +- `examples/counter.py` - A basic state mutation example. +- `examples/stablecoin.py` - A minimal ERC-20 style fungible token. +- `examples/dex.py` - An Automated Market Maker (AMM) using the constant product formula (x * y = k). + +### Interacting via CLI +Start the interactive node using `python main.py` and use the following commands: +1. **Deploy:** `deploy [amount] [gas_limit]` +2. **Call:** `call [amount] [gas_limit]` + --- ## Tech Stack diff --git a/examples/counter.py b/examples/counter.py new file mode 100644 index 0000000..e689460 --- /dev/null +++ b/examples/counter.py @@ -0,0 +1,37 @@ +# Counter Smart Contract Example +# +# This is a simple counter contract designed to demonstrate the basic +# structure of smart contracts in MiniChain. +# +# Available built-ins in the MiniChain Sandbox: +# - `storage`: A dictionary persisting state across executions. +# - `msg`: A dictionary containing transaction info: +# - `msg['sender']` : The address of the caller. +# - `msg['value']` : The amount of coins attached to the call. +# - `msg['data']` : The payload string. +# +# Available functions: range(), len(), min(), max(), abs(), str(), bool(), float(), int(), list(), dict(), tuple(), sum() +# +# NOTE: The sandbox does NOT allow imports, print(), or any double-underscore methods. + +if msg['data'] == 'increment': + # Retrieve the current counter value, defaulting to 0 if it doesn't exist + current_value = storage.get('counter', 0) + + # Increment the counter + storage['counter'] = current_value + 1 + +elif msg['data'] == 'decrement': + current_value = storage.get('counter', 0) + storage['counter'] = current_value - 1 + +elif msg['data'] == 'reset': + # You can restrict who can reset the counter by checking the sender! + # (Just an example, anyone can call this one) + storage['counter'] = 0 + +else: + # If the payload doesn't match any known command, raise an exception. + # This will fail the transaction and refund the 'amount' to the sender, + # but the network will keep the 'fee' as gas. + raise Exception("Unknown command. Valid commands: increment, decrement, reset") diff --git a/examples/dex.py b/examples/dex.py new file mode 100644 index 0000000..6e2025b --- /dev/null +++ b/examples/dex.py @@ -0,0 +1,90 @@ +# MiniSwap (DEX) Smart Contract Example +# +# This contract implements a minimal Automated Market Maker (AMM) +# using the x * y = k constant product formula. +# It trades the native MiniChain coin (msg['value']) against a minted DEX Token. +# +# Valid Payloads: +# - 'init' (Must send initial native coins to provide liquidity) +# - 'buy' (Sends native coins, receives DEX tokens) +# - 'sell:' (Sells DEX tokens, receives native coins) +# +# Note: Since native coins sent to the contract are automatically added to the +# contract's balance by the state manager BEFORE execution, msg['value'] is already +# inside the contract's physical balance. + +if msg['data'] == 'init': + # Initialize the liquidity pool + if storage.get('k') is not None: + raise Exception("Already initialized") + if msg['value'] <= 0: + raise Exception("Must provide initial native coin liquidity") + + # We will arbitrarily mint 1000 DEX tokens to match the initial coin liquidity + storage['native_reserve'] = msg['value'] + storage['token_reserve'] = 1000 + storage['k'] = storage['native_reserve'] * storage['token_reserve'] + + # Give the initial tokens to the creator + storage[msg['sender']] = 1000 + +elif msg['data'] == 'buy': + # User sends native coins to buy DEX tokens + if storage.get('k') is None: + raise Exception("Not initialized") + if msg['value'] <= 0: + raise Exception("Must send coins to buy tokens") + + # Calculate how many tokens to give using x * y = k + # (native_reserve + msg['value']) * (token_reserve - tokens_out) = k + new_native_reserve = storage['native_reserve'] + msg['value'] + new_token_reserve = storage['k'] // new_native_reserve + + tokens_out = storage['token_reserve'] - new_token_reserve + if tokens_out <= 0: + raise Exception("Not enough tokens to dispense") + + # Update reserves + storage['native_reserve'] = new_native_reserve + storage['token_reserve'] = new_token_reserve + + # Credit tokens to buyer + sender = msg['sender'] + storage[sender] = storage.get(sender, 0) + tokens_out + +elif msg['data'].startswith('sell:'): + # User sells DEX tokens to get native coins back + if storage.get('k') is None: + raise Exception("Not initialized") + + parts = msg['data'].split(':') + tokens_to_sell = int(parts[1]) + + sender = msg['sender'] + sender_tokens = storage.get(sender, 0) + if sender_tokens < tokens_to_sell: + raise Exception("Insufficient token balance") + + # Deduct tokens from user + storage[sender] -= tokens_to_sell + + # Calculate how many native coins to give using x * y = k + # (token_reserve + tokens_to_sell) * (native_reserve - coins_out) = k + new_token_reserve = storage['token_reserve'] + tokens_to_sell + new_native_reserve = storage['k'] // new_token_reserve + + coins_out = storage['native_reserve'] - new_native_reserve + if coins_out <= 0: + raise Exception("Not enough coins to dispense") + + # Update reserves + storage['native_reserve'] = new_native_reserve + storage['token_reserve'] = new_token_reserve + + # Wait! In MiniChain, smart contracts cannot arbitrarily initiate outgoing transactions yet. + # To properly implement 'sell', the contract engine would need a 'transfer_out' API. + # For now, we will just record their native coin balance in storage. + storage[f"{sender}_native_credit"] = storage.get(f"{sender}_native_credit", 0) + coins_out + +else: + raise Exception("Unknown command.") diff --git a/examples/stablecoin.py b/examples/stablecoin.py new file mode 100644 index 0000000..ed07e7d --- /dev/null +++ b/examples/stablecoin.py @@ -0,0 +1,41 @@ +# Stablecoin (ERC-20 style) Smart Contract Example +# +# This contract implements a minimal fungible token. +# +# Valid Payloads: +# - 'mint:' +# - 'transfer::' + +if msg['data'].startswith('mint:'): + # In a real contract, you would restrict this to an owner address! + # For this example, anyone can mint tokens to themselves. + amount = int(msg['data'].split(':')[1]) + if amount <= 0: + raise Exception("Amount must be positive") + + sender = msg['sender'] + storage[sender] = storage.get(sender, 0) + amount + storage['total_supply'] = storage.get('total_supply', 0) + amount + +elif msg['data'].startswith('transfer:'): + parts = msg['data'].split(':') + if len(parts) != 3: + raise Exception("Invalid transfer format") + + to_address = parts[1] + amount = int(parts[2]) + + if amount <= 0: + raise Exception("Amount must be positive") + + sender = msg['sender'] + sender_balance = storage.get(sender, 0) + + if sender_balance >= amount: + storage[sender] -= amount + storage[to_address] = storage.get(to_address, 0) + amount + else: + raise Exception("Insufficient token balance") + +else: + raise Exception("Unknown command. Valid commands: mint:, transfer::") diff --git a/main.py b/main.py index bc209ca..44fecc6 100644 --- a/main.py +++ b/main.py @@ -80,7 +80,7 @@ def mine_and_process_block(chain, mempool, miner_pk): logger.info("No mineable transactions in current queue window.") return None - total_fees = sum(getattr(tx, 'fee', 0) for tx in mineable_txs) + total_fees = sum(getattr(r, 'gas_used', 0) for r in receipts) temp_state.credit_mining_reward(miner_pk, reward=temp_state.DEFAULT_MINING_REWARD + total_fees) block = Block( @@ -209,7 +209,8 @@ async def cli_loop(sk, pk, chain, mempool, network): print(" (no accounts yet)") for addr, acc in accounts.items(): tag = " (you)" if addr == pk else "" - print(f" {addr[:12]}... balance={acc['balance']} nonce={acc['nonce']}{tag}") + contract_tag = " [Contract]" if acc.get("code") else "" + print(f" {addr[:12]}... balance={acc['balance']} nonce={acc['nonce']}{tag}{contract_tag}") # ── send ── elif cmd == "send": @@ -243,6 +244,72 @@ async def cli_loop(sk, pk, chain, mempool, network): else: print(" ❌ Transaction rejected (invalid sig, duplicate, or mempool full).") + # ── deploy ── + elif cmd == "deploy": + if len(parts) < 2: + print(" Usage: deploy [amount] [fee]") + continue + filepath = parts[1] + try: + with open(filepath, "r") as f: + code = f.read() + except FileNotFoundError: + print(f" File not found: {filepath}") + continue + + try: + amount = int(parts[2]) if len(parts) > 2 else 0 + fee = int(parts[3]) if len(parts) > 3 else 0 + except ValueError: + print(" Amount and fee must be integers.") + continue + + if amount < 0 or fee < 0: + print(" Amount and fee cannot be negative.") + continue + + nonce = chain.state.get_account(pk).get("nonce", 0) + tx = Transaction(sender=pk, receiver=None, amount=amount, nonce=nonce, fee=fee, data=code) + tx.sign(sk) + + if mempool.add_transaction(tx): + await network.broadcast_transaction(tx) + print(f" ✅ Deploy Tx sent (nonce={nonce}). Mine a block to confirm.") + else: + print(" ❌ Deploy Transaction rejected.") + + # ── call ── + elif cmd == "call": + if len(parts) < 3: + print(" Usage: call [amount] [fee]") + continue + receiver = parts[1] + if not is_valid_receiver(receiver): + print(" Invalid receiver format. Expected 40 or 64 hex characters.") + continue + payload = parts[2] + + try: + amount = int(parts[3]) if len(parts) > 3 else 0 + fee = int(parts[4]) if len(parts) > 4 else 0 + except ValueError: + print(" Amount and fee must be integers.") + continue + + if amount < 0 or fee < 0: + print(" Amount and fee cannot be negative.") + continue + + nonce = chain.state.get_account(pk).get("nonce", 0) + tx = Transaction(sender=pk, receiver=receiver, amount=amount, nonce=nonce, fee=fee, data=payload) + tx.sign(sk) + + if mempool.add_transaction(tx): + await network.broadcast_transaction(tx) + print(f" ✅ Call Tx sent to {receiver[:12]}... (payload='{payload}'). Mine a block to confirm.") + else: + print(" ❌ Call Transaction rejected.") + # ── mine ── elif cmd == "mine": mined = mine_and_process_block(chain, mempool, pk) diff --git a/minichain/chain.py b/minichain/chain.py index 9ef8ee6..b37dcad 100644 --- a/minichain/chain.py +++ b/minichain/chain.py @@ -125,7 +125,7 @@ def add_block(self, block): receipts.append(receipt) - total_fees = sum(getattr(tx, 'fee', 0) for tx in block.transactions) + total_fees = sum(getattr(r, 'gas_used', 0) for r in receipts) if block.miner: temp_state.credit_mining_reward(block.miner, reward=temp_state.DEFAULT_MINING_REWARD + total_fees) diff --git a/minichain/contract.py b/minichain/contract.py index e7c2fd4..be526b5 100644 --- a/minichain/contract.py +++ b/minichain/contract.py @@ -1,13 +1,30 @@ import logging import multiprocessing import ast +import sys + +class OutOfGasException(Exception): + pass + +class GasMeter: + def __init__(self, limit): + self.gas = limit + self.initial_gas = limit + + def trace_calls(self, frame, event, arg): + frame.f_trace_opcodes = True + if event == 'opcode': + self.gas -= 1 + if self.gas <= 0: + raise OutOfGasException("Out of gas!") + return self.trace_calls import json # Moved to module-level import logger = logging.getLogger(__name__) -def _safe_exec_worker(code, globals_dict, context_dict, result_queue): +def _safe_exec_worker(code, globals_dict, context_dict, result_queue, gas_limit): """ - Worker function to execute contract code in a separate process. + Worker function to execute contract code in a separate process with gas metering. """ try: # Attempt to set resource limits (Unix only) @@ -21,11 +38,22 @@ def _safe_exec_worker(code, globals_dict, context_dict, result_queue): except (OSError, ValueError) as e: logger.warning("Failed to set resource limits: %s", e) - exec(code, globals_dict, context_dict) - # Return the updated storage - result_queue.put({"status": "success", "storage": context_dict.get("storage")}) + meter = GasMeter(gas_limit) + sys.settrace(meter.trace_calls) + + try: + exec(code, globals_dict, context_dict) + finally: + sys.settrace(None) + + gas_used = meter.initial_gas - meter.gas + result_queue.put({"status": "success", "storage": context_dict.get("storage"), "gas_used": gas_used}) + except OutOfGasException as e: + result_queue.put({"status": "error", "error": "Out of gas!", "gas_used": gas_limit}) except Exception as e: - result_queue.put({"status": "error", "error": str(e)}) + # If it failed for another reason, we still charge the gas it consumed up to the failure + gas_used = gas_limit if 'meter' not in locals() else meter.initial_gas - meter.gas + result_queue.put({"status": "error", "error": str(e), "gas_used": gas_used}) class ContractMachine: """ @@ -36,14 +64,15 @@ class ContractMachine: def __init__(self, state): self.state = state - def execute(self, contract_address, sender_address, payload, amount): + def execute(self, contract_address, sender_address, payload, amount, gas_limit): """ Executes the contract code associated with the contract_address. + Returns a dict: {"success": bool, "gas_used": int, "error": str} """ account = self.state.get_account(contract_address) if not account: - return False + return {"success": False, "gas_used": 0, "error": "Account not found"} code = account.get("code") @@ -51,11 +80,11 @@ def execute(self, contract_address, sender_address, payload, amount): storage = dict(account.get("storage", {})) if not code: - return False + return {"success": False, "gas_used": 0, "error": "No code"} # AST Validation to prevent introspection if not self._validate_code_ast(code): - return False + return {"success": False, "gas_used": 0, "error": "AST Validation Failed"} # Restricted builtins (explicit allowlist) safe_builtins = { @@ -97,7 +126,7 @@ def execute(self, contract_address, sender_address, payload, amount): queue = multiprocessing.Queue() p = multiprocessing.Process( target=_safe_exec_worker, - args=(code, globals_for_exec, context, queue) + args=(code, globals_for_exec, context, queue, gas_limit) ) p.start() p.join(timeout=2) # 2 second timeout @@ -106,23 +135,24 @@ def execute(self, contract_address, sender_address, payload, amount): p.kill() p.join() logger.error("Contract execution timed out") - return False + return {"success": False, "gas_used": gas_limit, "error": "Execution timed out"} try: result = queue.get(timeout=1) except Exception: logger.error("Contract execution crashed without result") - return False + return {"success": False, "gas_used": gas_limit, "error": "Crashed"} + if result["status"] != "success": logger.error("Contract Execution Failed: %s", result.get('error')) - return False + return {"success": False, "gas_used": result.get("gas_used", gas_limit), "error": result.get('error')} # Validate storage is JSON serializable try: json.dumps(result["storage"]) except (TypeError, ValueError): logger.error("Contract storage not JSON serializable") - return False + return {"success": False, "gas_used": result.get("gas_used", gas_limit), "error": "Storage not JSON serializable"} # Commit updated storage only after successful execution self.state.update_contract_storage( @@ -130,11 +160,11 @@ def execute(self, contract_address, sender_address, payload, amount): result["storage"] ) - return True + return {"success": True, "gas_used": result["gas_used"], "error": None} except Exception as e: logger.error("Contract Execution Failed", exc_info=True) - return False + return {"success": False, "gas_used": gas_limit, "error": "System Error"} def _validate_code_ast(self, code): """Reject code that uses double underscores or introspection.""" diff --git a/minichain/state.py b/minichain/state.py index cfbb771..f817c6c 100644 --- a/minichain/state.py +++ b/minichain/state.py @@ -99,50 +99,58 @@ def apply_transaction(self, tx): # LOGIC BRANCH 1: Contract Deployment if tx.receiver is None or tx.receiver == "": contract_address = self.derive_contract_address(tx.sender, tx.nonce) + gas_used = getattr(tx, 'fee', 0) # Prevent redeploy collision existing = self.accounts.get(contract_address) if existing and existing.get("code"): # Restore sender balance on failure, but keep nonce incremented sender['balance'] += tx.amount - return Receipt(tx.tx_id, status=0, error_message="Contract collision") + return Receipt(tx.tx_id, status=0, error_message="Contract collision", gas_used=gas_used) self.create_contract(contract_address, tx.data, initial_balance=tx.amount) - return Receipt(tx.tx_id, status=1, contract_address=contract_address) + return Receipt(tx.tx_id, status=1, contract_address=contract_address, gas_used=gas_used) # LOGIC BRANCH 2: Contract Call # If data is provided (non-empty), treat as contract call if tx.data: receiver = self.accounts.get(tx.receiver) + gas_limit = getattr(tx, 'fee', 0) # Fail if contract does not exist or has no code if not receiver or not receiver.get("code"): # Rollback sender balance on failure, but keep nonce incremented sender['balance'] += tx.amount # Refund amount - return Receipt(tx.tx_id, status=0, error_message="Contract not found") + return Receipt(tx.tx_id, status=0, error_message="Contract not found", gas_used=gas_limit) # Credit contract balance receiver['balance'] += tx.amount - success = self.contract_machine.execute( - contract_address=tx.receiver, # Pass receiver as contract_address + result = self.contract_machine.execute( + contract_address=tx.receiver, sender_address=tx.sender, payload=tx.data, - amount=tx.amount + amount=tx.amount, + gas_limit=gas_limit ) - if not success: + gas_used = result.get("gas_used", gas_limit) + gas_refund = gas_limit - gas_used + if gas_refund > 0: + sender['balance'] += gas_refund + + if not result.get("success"): # Rollback transfer if execution fails, but keep nonce incremented receiver['balance'] -= tx.amount sender['balance'] += tx.amount # Refund amount - return Receipt(tx.tx_id, status=0, error_message="Execution failed") + return Receipt(tx.tx_id, status=0, error_message=result.get("error", "Execution failed"), gas_used=gas_used) - return Receipt(tx.tx_id, status=1) + return Receipt(tx.tx_id, status=1, gas_used=gas_used) # LOGIC BRANCH 3: Regular Transfer receiver = self.get_account(tx.receiver) receiver['balance'] += tx.amount - return Receipt(tx.tx_id, status=1) + return Receipt(tx.tx_id, status=1, gas_used=getattr(tx, 'fee', 0)) def derive_contract_address(self, sender, nonce): raw = f"{sender}:{nonce}".encode() diff --git a/tests/test_contract.py b/tests/test_contract.py index 49431e8..f3f27de 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -13,7 +13,7 @@ def setUp(self): self.state = State() self.sk = SigningKey.generate() self.pk = self.sk.verify_key.encode(encoder=HexEncoder).decode() - self.state.credit_mining_reward(self.pk, 100) + self.state.credit_mining_reward(self.pk, 10000) def test_deploy_and_execute(self): """Happy path: deploy and increment counter.""" @@ -23,7 +23,7 @@ def test_deploy_and_execute(self): storage['counter'] = storage.get('counter', 0) + 1 """ - tx_deploy = Transaction(self.pk, None, 0, 0, data=code) + tx_deploy = Transaction(self.pk, None, 0, 0, fee=500, data=code) tx_deploy.sign(self.sk) receipt_deploy = self.state.apply_transaction(tx_deploy) @@ -32,7 +32,7 @@ def test_deploy_and_execute(self): 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 = Transaction(self.pk, contract_addr, 0, 1, fee=1000, data="increment") tx_call.sign(self.sk) receipt_call = self.state.apply_transaction(tx_call) @@ -50,7 +50,7 @@ def test_deploy_insufficient_balance(self): code = "storage['x'] = 1" - tx = Transaction(poor_pk, None, 1000, 0, data=code) + tx = Transaction(poor_pk, None, 1000, 0, fee=500, data=code) tx.sign(poor_sk) receipt = self.state.apply_transaction(tx) @@ -63,7 +63,7 @@ def test_call_non_existent_contract(self): fake_sk = SigningKey.generate() fake_receiver = fake_sk.verify_key.encode(encoder=HexEncoder).decode() - tx = Transaction(self.pk, fake_receiver, 0, 0, data="increment") + tx = Transaction(self.pk, fake_receiver, 0, 0, fee=500, data="increment") tx.sign(self.sk) receipt = self.state.apply_transaction(tx) @@ -78,7 +78,7 @@ def test_contract_runtime_exception(self): raise Exception("boom") """ - tx_deploy = Transaction(self.pk, None, 0, 0, data=code) + tx_deploy = Transaction(self.pk, None, 0, 0, fee=500, data=code) tx_deploy.sign(self.sk) receipt_deploy = self.state.apply_transaction(tx_deploy) @@ -87,13 +87,13 @@ def test_contract_runtime_exception(self): 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 = Transaction(self.pk, contract_addr, 0, 1, fee=1000, data="anything") tx_call.sign(self.sk) 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") + self.assertEqual(receipt_call.error_message, "boom") contract_acc = self.state.get_account(contract_addr) self.assertEqual(contract_acc["storage"], {}) @@ -104,7 +104,7 @@ def test_redeploy_same_address(self): code = "storage['x'] = 1" # First deploy - tx1 = Transaction(self.pk, None, 0, 0, data=code) + tx1 = Transaction(self.pk, None, 0, 0, fee=500, data=code) tx1.sign(self.sk) receipt1 = self.state.apply_transaction(tx1) @@ -121,7 +121,7 @@ def test_redeploy_same_address(self): self.state.create_contract(collision_addr, "storage['y'] = 2") # Attempt redeploy - tx2 = Transaction(self.pk, None, 0, next_nonce, data=code) + tx2 = Transaction(self.pk, None, 0, next_nonce, fee=500, data=code) tx2.sign(self.sk) receipt2 = self.state.apply_transaction(tx2) @@ -138,7 +138,7 @@ def test_balance_and_nonce_updates(self): code = "storage['x'] = 1" - tx_deploy = Transaction(self.pk, None, 10, initial_nonce, data=code) + tx_deploy = Transaction(self.pk, None, 10, initial_nonce, fee=500, data=code) tx_deploy.sign(self.sk) receipt = self.state.apply_transaction(tx_deploy) @@ -148,8 +148,35 @@ def test_balance_and_nonce_updates(self): self.assertTrue(isinstance(contract_addr, str)) # Verify balance and nonce after deploy + # We spent 10 amount + 500 gas for deploy = 510 total deduction sender_after = self.state.get_account(self.pk) - self.assertEqual(sender_after["balance"], initial_balance - 10) + self.assertEqual(sender_after["balance"], initial_balance - 510) self.assertEqual(sender_after["nonce"], initial_nonce + 1) - # Further test calls if needed + def test_out_of_gas(self): + """Contract with infinite loop should run out of gas and revert.""" + + code = "while True: pass" + + # 1. Deploy code + tx_deploy = Transaction(self.pk, None, 0, 0, fee=500, data=code) + tx_deploy.sign(self.sk) + receipt_deploy = self.state.apply_transaction(tx_deploy) + self.assertEqual(receipt_deploy.status, 1) + contract_addr = receipt_deploy.contract_address + + # 2. Call code with specific fee (gas limit) + tx_call = Transaction(self.pk, contract_addr, 0, 1, fee=1000, data="loop") + tx_call.sign(self.sk) + + balance_before = self.state.get_account(self.pk)["balance"] + + receipt_call = self.state.apply_transaction(tx_call) + + self.assertEqual(receipt_call.status, 0) + self.assertEqual(receipt_call.error_message, "Out of gas!") + self.assertEqual(receipt_call.gas_used, 1000) + + balance_after = self.state.get_account(self.pk)["balance"] + # Entire fee should be deducted because gas was completely consumed + self.assertEqual(balance_after, balance_before - 1000) From 87b0d967e3e35d2ec2d06596c436df0ab93bb511 Mon Sep 17 00:00:00 2001 From: siddhant Date: Thu, 11 Jun 2026 17:28:04 +0530 Subject: [PATCH 7/8] fix and document contracts properly --- minichain/contract.py | 18 ++++++++++++++++++ tests/test_contract.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/minichain/contract.py b/minichain/contract.py index be526b5..4ad8233 100644 --- a/minichain/contract.py +++ b/minichain/contract.py @@ -25,6 +25,13 @@ def trace_calls(self, frame, event, arg): def _safe_exec_worker(code, globals_dict, context_dict, result_queue, gas_limit): """ Worker function to execute contract code in a separate process with gas metering. + + SECURITY: + This function relies on `globals_dict` (which has `__builtins__` stripped down + to a minimal safe allowlist) to prevent malicious code from accessing file systems + (e.g., `open()`), networking, or OS-level commands (e.g., `__import__('os')`). + Because `exec` is run with these restricted globals, any attempt to call unauthorized + builtins or standard library modules will result in a NameError or ImportError. """ try: # Attempt to set resource limits (Unix only) @@ -59,6 +66,17 @@ class ContractMachine: """ A minimal execution environment for Python-based smart contracts. WARNING: Still not production-safe. For educational use only. + + SANDBOX ENFORCEMENT: + 1. Builtins Restriction: `__builtins__` is aggressively filtered. Functions like + `open`, `exec`, `eval`, `__import__`, `print`, and `input` are completely removed. + This inherently prevents file deletion, network requests, or OS command execution. + 2. AST Validation: `_validate_code_ast` statically analyzes the code before execution + to block double-underscore access (preventing sandbox escape via introspection) + and entirely blocks the `import` statement. + + Allowed Builtins: range(), len(), min(), max(), abs(), str(), bool(), float(), int(), list(), dict(), tuple(), sum(), Exception + Blocked Builtins: Imports, File IO (open), OS modules, Networking, Introspection. """ def __init__(self, state): diff --git a/tests/test_contract.py b/tests/test_contract.py index f3f27de..c8a5571 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -180,3 +180,39 @@ def test_out_of_gas(self): balance_after = self.state.get_account(self.pk)["balance"] # Entire fee should be deducted because gas was completely consumed self.assertEqual(balance_after, balance_before - 1000) + + def test_malicious_import(self): + """Contract attempting to import a module should fail AST validation.""" + code = "import os\nstorage['x'] = 1" + tx_deploy = Transaction(self.pk, None, 0, 0, fee=500, data=code) + tx_deploy.sign(self.sk) + + receipt_deploy = self.state.apply_transaction(tx_deploy) + self.assertEqual(receipt_deploy.status, 1) # Deploy succeeds, saves code + + tx_call = Transaction(self.pk, receipt_deploy.contract_address, 0, 1, fee=500, data="call") + tx_call.sign(self.sk) + receipt_call = self.state.apply_transaction(tx_call) + + self.assertIsNotNone(receipt_call) + self.assertEqual(receipt_call.status, 0) + self.assertEqual(receipt_call.error_message, "AST Validation Failed") + + def test_malicious_file_deletion(self): + """Contract attempting to use open() or file IO should fail at runtime due to missing builtins.""" + # Using open() which is stripped from __builtins__ + code = "f = open('critical_file.txt', 'w')\nf.write('hacked')" + tx_deploy = Transaction(self.pk, None, 0, 0, fee=500, data=code) + tx_deploy.sign(self.sk) + + receipt_deploy = self.state.apply_transaction(tx_deploy) + self.assertEqual(receipt_deploy.status, 1) + + tx_call = Transaction(self.pk, receipt_deploy.contract_address, 0, 1, fee=500, data="call") + tx_call.sign(self.sk) + receipt_call = self.state.apply_transaction(tx_call) + + self.assertIsNotNone(receipt_call) + self.assertEqual(receipt_call.status, 0) + # Should throw a NameError because 'open' is not defined in safe_builtins + self.assertIn("name 'open' is not defined", receipt_call.error_message) From ce2cd5232154d45a91d36ce90b47ccf9382a1749 Mon Sep 17 00:00:00 2001 From: siddhant Date: Fri, 12 Jun 2026 02:40:13 +0530 Subject: [PATCH 8/8] feat implement fork-choice rule and state reorganization --- main.py | 32 ++++++++++-- minichain/chain.py | 87 ++++++++++++++++++++++++++++++++ minichain/p2p.py | 34 ++++++++++++- minichain/state.py | 12 +++++ tests/test_reorg.py | 117 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 278 insertions(+), 4 deletions(-) create mode 100644 tests/test_reorg.py diff --git a/main.py b/main.py index 44fecc6..1fd917b 100644 --- a/main.py +++ b/main.py @@ -113,7 +113,7 @@ def mine_and_process_block(chain, mempool, miner_pk): # Network message handler # ────────────────────────────────────────────── -def make_network_handler(chain, mempool): +def make_network_handler(chain, mempool, network): """Return an async callback that processes incoming P2P messages.""" async def handler(data): @@ -159,7 +159,33 @@ async def handler(data): # Drop only confirmed transactions so higher nonces can remain queued. mempool.remove_transactions(block.transactions) else: - logger.warning("📥 Received Block #%s — rejected", block.index) + if block.index > chain.last_block.index: + logger.warning("📥 Received Block #%s — ahead of us (tip: %s). Requesting chain sync...", block.index, chain.last_block.index) + asyncio.create_task(network.broadcast_chain_request()) + else: + logger.warning("📥 Received Block #%s — rejected", block.index) + + elif msg_type == "chain_request": + logger.info("📡 Peer requested chain sync. Broadcasting our chain...") + blocks_dicts = [b.to_dict() for b in chain.chain] + payload = {"type": "chain_response", "data": {"blocks": blocks_dicts}} + asyncio.create_task(network._broadcast_raw(payload)) + + elif msg_type == "chain_response": + blocks_payload = payload.get("blocks", []) + new_chain = [] + try: + new_chain = [Block.from_dict(b) for b in blocks_payload] + except Exception as e: + logger.warning("❌ Failed to parse chain_response: %s", e) + return + + if new_chain: + success, orphans = chain.resolve_conflicts(new_chain) + if success: + logger.info("🔄 Reorg complete! Restoring %d orphaned txs to mempool.", len(orphans)) + for tx in orphans: + mempool.add_transaction(tx) return handler @@ -389,7 +415,7 @@ async def run_node(port: int, host: str, connect_to: str | None, fund: int, data mempool = Mempool() network = P2PNetwork() - handler = make_network_handler(chain, mempool) + handler = make_network_handler(chain, mempool, network) network.register_handler(handler) # When a new peer connects, send our state so they can sync diff --git a/minichain/chain.py b/minichain/chain.py index b37dcad..af7a43d 100644 --- a/minichain/chain.py +++ b/minichain/chain.py @@ -89,6 +89,9 @@ def _create_genesis_block(self, genesis_path): genesis_block.hash = computed_hash self.chain.append(genesis_block) + + # Snapshot the state exactly after genesis allocation for clean reorg rebuilds + self._genesis_state_snapshot = self.state.snapshot() @property def last_block(self): @@ -98,6 +101,16 @@ def last_block(self): with self._lock: # Acquire lock for thread-safe access return self.chain[-1] + def get_total_work(self, chain_list=None): + """ + Calculates the cumulative PoW of a chain. + Work is proportional to 2^difficulty. + """ + if chain_list is None: + with self._lock: + chain_list = self.chain + return sum(2 ** (block.difficulty or 1) for block in chain_list) + def add_block(self, block): """ Validates and adds a block to the chain if all transactions succeed. @@ -147,3 +160,77 @@ def add_block(self, block): self.state = temp_state self.chain.append(block) return True + + def resolve_conflicts(self, new_chain_list) -> tuple[bool, list]: + """ + Evaluates a competing chain. If it has strictly greater cumulative work, + attempts a reorg. Rebuilds state from genesis to guarantee validity. + Returns: (success_bool, list_of_orphaned_transactions) + """ + if not new_chain_list: + return False, [] + + with self._lock: + current_work = self.get_total_work() + new_work = self.get_total_work(new_chain_list) + + if new_work <= current_work: + logger.debug("Incoming chain (work: %s) is not heavier than local chain (work: %s). Rejecting.", new_work, current_work) + return False, [] + + # 1. Verify genesis block matches + if new_chain_list[0].hash != self.chain[0].hash: + logger.warning("Reorg failed: Genesis hash mismatch.") + return False, [] + + logger.info("Incoming chain is heavier (%s > %s). Attempting reorg...", new_work, current_work) + + # 2. Snapshot current state and chain in case reorg fails validation + state_snapshot = self.state.snapshot() + original_chain = list(self.chain) + + # 3. Rebuild state entirely from genesis using the new chain + temp_state = State() + temp_state.restore(self._genesis_state_snapshot) + + # Verify and apply blocks 1 to N + for i in range(1, len(new_chain_list)): + prev_block = new_chain_list[i-1] + block = new_chain_list[i] + + try: + validate_block_link_and_hash(prev_block, block) + except ValueError as exc: + logger.warning("Reorg failed at block %s: %s", block.index, exc) + return False, [] + + receipts = [] + for tx in block.transactions: + receipt = temp_state.validate_and_apply(tx) + if receipt is None: + logger.warning("Reorg failed: Transaction validation failed in block %s", block.index) + return False, [] + receipts.append(receipt) + + total_fees = sum(getattr(r, 'gas_used', 0) for r in receipts) + if block.miner: + temp_state.credit_mining_reward(block.miner, reward=temp_state.DEFAULT_MINING_REWARD + total_fees) + + computed_receipt_root = calculate_receipt_root(receipts) + if block.receipt_root != computed_receipt_root: + logger.warning("Reorg failed: Invalid receipt root at block %s. Expected %s, got %s", block.index, computed_receipt_root, block.receipt_root) + return False, [] + + if block.state_root != temp_state.state_root(): + logger.warning("Reorg failed: Invalid state root at block %s", block.index) + return False, [] + + # 4. Success! Compute orphaned transactions. + old_txs = {tx.tx_id: tx for b in original_chain[1:] for tx in b.transactions} + new_tx_ids = {tx.tx_id for b in new_chain_list[1:] for tx in b.transactions} + orphans = [tx for tx_id, tx in old_txs.items() if tx_id not in new_tx_ids] + + self.chain = new_chain_list + self.state = temp_state + logger.info("Reorg successful! Switched to new chain tip: Block %s", self.last_block.index) + return True, orphans diff --git a/minichain/p2p.py b/minichain/p2p.py index 7bb1e34..262d102 100644 --- a/minichain/p2p.py +++ b/minichain/p2p.py @@ -15,7 +15,7 @@ logger = logging.getLogger(__name__) TOPIC = "minichain-global" -SUPPORTED_MESSAGE_TYPES = {"sync", "tx", "block"} +SUPPORTED_MESSAGE_TYPES = {"sync", "tx", "block", "chain_request", "chain_response"} class P2PNetwork: @@ -228,6 +228,21 @@ def _validate_block_payload(self, payload): for tx_payload in payload["transactions"] ) + def _validate_chain_request(self, payload): + if not isinstance(payload, dict): + return False + return True + + def _validate_chain_response(self, payload): + if not isinstance(payload, dict) or "blocks" not in payload: + return False + if not isinstance(payload["blocks"], list): + return False + for block_payload in payload["blocks"]: + if not self._validate_block_payload(block_payload): + return False + return True + def _validate_message(self, message): # FIX: Check if message is a dictionary first to prevent crashes if not isinstance(message, dict): @@ -249,6 +264,8 @@ def _validate_message(self, message): "sync": self._validate_sync_payload, "tx": self._validate_transaction_payload, "block": self._validate_block_payload, + "chain_request": self._validate_chain_request, + "chain_response": self._validate_chain_response, } return validators[msg_type](payload) @@ -385,6 +402,21 @@ async def broadcast_block(self, block): self._mark_seen("block", payload["data"]) await self._broadcast_raw(payload) + async def broadcast_chain_request(self): + logger.info("Network: Broadcasting chain request") + payload = {"type": "chain_request", "data": {}} + await self._broadcast_raw(payload) + + async def send_chain_response(self, blocks_dicts, writer): + logger.info("Network: Sending chain response with %d blocks", len(blocks_dicts)) + payload = {"type": "chain_response", "data": {"blocks": blocks_dicts}} + line = (canonical_json_dumps(payload) + "\n").encode() + try: + writer.write(line) + await writer.drain() + except Exception as e: + logger.error("Network: Failed to send chain response: %s", e) + @property def peer_count(self) -> int: return len(self._peers) diff --git a/minichain/state.py b/minichain/state.py index f817c6c..73759f8 100644 --- a/minichain/state.py +++ b/minichain/state.py @@ -67,6 +67,18 @@ def copy(self): new_state.contract_machine = ContractMachine(new_state) # Reinitialize contract_machine return new_state + def snapshot(self): + """ + Returns a deep copy of the current accounts dictionary for rollback safety. + """ + return copy.deepcopy(self.accounts) + + def restore(self, snapshot_data): + """ + Restores the state's accounts dictionary from a snapshot. + """ + self.accounts = copy.deepcopy(snapshot_data) + def validate_and_apply(self, tx): """ Validate and apply a transaction. diff --git a/tests/test_reorg.py b/tests/test_reorg.py new file mode 100644 index 0000000..f678bea --- /dev/null +++ b/tests/test_reorg.py @@ -0,0 +1,117 @@ +import pytest +import os +import json +import time + +from minichain.chain import Blockchain +from minichain.transaction import Transaction +from minichain.mempool import Mempool + +import sys +import os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from main import mine_and_process_block + +from nacl.signing import SigningKey +from nacl.encoding import HexEncoder + +@pytest.fixture +def genesis_file(tmp_path): + path = tmp_path / "genesis_reorg.json" + sk = SigningKey.generate() + pk = sk.verify_key.encode(encoder=HexEncoder).decode() + data = { + "timestamp": int(time.time()), + "difficulty": 0, + "alloc": { + pk: {"balance": 1000} + } + } + with open(path, "w") as f: + json.dump(data, f) + return str(path), sk, pk + +def test_resolve_conflicts_heavier_chain(genesis_file): + g_path, sk, pk = genesis_file + + node_a = Blockchain(genesis_path=g_path) + node_b = Blockchain(genesis_path=g_path) + + assert node_a.get_total_work() == node_b.get_total_work() + + pool_b = Mempool() + tx = Transaction(sender=pk, receiver="b"*64, amount=10, nonce=0, fee=1) + tx.sign(sk) + pool_b.add_transaction(tx) + + mined_b = mine_and_process_block(node_b, pool_b, pk) + assert mined_b is not None + assert node_b.get_total_work() > node_a.get_total_work() + + # Node A receives Node B's chain + success, orphans = node_a.resolve_conflicts(node_b.chain) + + assert success is True + assert node_a.last_block.hash == node_b.last_block.hash + assert node_a.state.accounts == node_b.state.accounts + assert len(orphans) == 0 + +def test_resolve_conflicts_reorg_with_orphans(genesis_file): + g_path, sk, pk = genesis_file + + node_a = Blockchain(genesis_path=g_path) + node_b = Blockchain(genesis_path=g_path) + + pool_a = Mempool() + pool_b = Mempool() + + # Node A mines tx1 (nonce 0) + tx1 = Transaction(sender=pk, receiver="a"*64, amount=10, nonce=0, fee=1) + tx1.sign(sk) + pool_a.add_transaction(tx1) + mine_and_process_block(node_a, pool_a, pk) + + # Node B mines tx2 (nonce 0, competing transaction) + tx2 = Transaction(sender=pk, receiver="b"*64, amount=20, nonce=0, fee=1) + tx2.sign(sk) + pool_b.add_transaction(tx2) + mine_and_process_block(node_b, pool_b, pk) + + # Node B mines tx3 (nonce 1) to become the heavier chain + tx3 = Transaction(sender=pk, receiver="c"*64, amount=30, nonce=1, fee=1) + tx3.sign(sk) + pool_b.add_transaction(tx3) + block_b2 = mine_and_process_block(node_b, pool_b, pk) + + assert node_b.get_total_work() > node_a.get_total_work() + + # Node A attempts reorg using B's heavier chain + success, orphans = node_a.resolve_conflicts(node_b.chain) + + assert success is True + assert node_a.last_block.hash == block_b2.hash + + # tx1 was in A's chain but NOT in B's chain. It should be orphaned. + assert len(orphans) == 1 + assert orphans[0].tx_id == tx1.tx_id + +def test_resolve_conflicts_rejects_lighter_chain(genesis_file): + g_path, sk, pk = genesis_file + + node_a = Blockchain(genesis_path=g_path) + node_b = Blockchain(genesis_path=g_path) + + pool_a = Mempool() + + # Node A mines a block + tx1 = Transaction(sender=pk, receiver="a"*64, amount=10, nonce=0, fee=1) + tx1.sign(sk) + pool_a.add_transaction(tx1) + mine_and_process_block(node_a, pool_a, pk) + + # Node B is empty. It tries to reorg Node A with its shorter chain. + success, orphans = node_a.resolve_conflicts(node_b.chain) + + assert success is False + assert len(orphans) == 0 + assert node_a.get_total_work() > node_b.get_total_work()