diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..0d6b08e --- /dev/null +++ b/conftest.py @@ -0,0 +1,4 @@ +import sys +import os + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) \ No newline at end of file diff --git a/consensus/__init__.py b/consensus/__init__.py new file mode 100644 index 0000000..119baf4 --- /dev/null +++ b/consensus/__init__.py @@ -0,0 +1,3 @@ +from .pow import mine_block, calculate_hash, MiningExceededError + +__all__ = ["mine_block", "calculate_hash", "MiningExceededError"] \ No newline at end of file diff --git a/consensus/pow.py b/consensus/pow.py new file mode 100644 index 0000000..b8484b1 --- /dev/null +++ b/consensus/pow.py @@ -0,0 +1,75 @@ +import json +import time +import hashlib + + +class MiningExceededError(Exception): + """Raised when max_nonce, timeout, or cancellation is exceeded during mining.""" + + +def calculate_hash(block_dict): + """Calculates SHA256 hash of a block header.""" + block_string = json.dumps(block_dict, sort_keys=True).encode("utf-8") + return hashlib.sha256(block_string).hexdigest() + + +def mine_block( + block, + difficulty=4, + max_nonce=10_000_000, + timeout_seconds=None, + logger=None, + progress_callback=None +): + """Mines a block using Proof-of-Work without mutating input block until success.""" + + if not isinstance(difficulty, int) or difficulty <= 0: + raise ValueError("Difficulty must be a positive integer.") + + target = "0" * difficulty + local_nonce = 0 + header_dict = block.to_header_dict() # Construct header dict once outside loop + start_time = time.monotonic() + + if logger: + logger.info( + "Mining block %s (Difficulty: %s)", + block.index, + difficulty, + ) + + while True: + + # Enforce max_nonce limit before hashing + if local_nonce >= max_nonce: + if logger: + logger.warning("Max nonce exceeded during mining.") + raise MiningExceededError("Mining failed: max_nonce exceeded") + + # Enforce timeout if specified + if timeout_seconds is not None and (time.monotonic() - start_time) > timeout_seconds: + if logger: + logger.warning("Mining timeout exceeded.") + raise MiningExceededError("Mining failed: timeout exceeded") + + header_dict["nonce"] = local_nonce + block_hash = calculate_hash(header_dict) + + # Check difficulty target + if block_hash.startswith(target): + block.nonce = local_nonce # Assign only on success + block.hash = block_hash + if logger: + logger.info("Success! Hash: %s", block_hash) + return block + + # Allow cancellation via progress callback (pass nonce explicitly) + if progress_callback: + should_continue = progress_callback(local_nonce, block_hash) + if should_continue is False: + if logger: + logger.info("Mining cancelled via progress_callback.") + raise MiningExceededError("Mining cancelled") + + # Increment nonce after attempt + local_nonce += 1 diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..ce204c7 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,13 @@ +from .block import Block +from .chain import Blockchain +from .transaction import Transaction +from .state import State +from .contract import ContractMachine + +__all__ = [ + "Block", + "Blockchain", + "Transaction", + "State", + "ContractMachine", +] diff --git a/core/block.py b/core/block.py new file mode 100644 index 0000000..23f7536 --- /dev/null +++ b/core/block.py @@ -0,0 +1,105 @@ +import time +import hashlib +import json +from typing import List, Optional +from core.transaction import Transaction + + +def _sha256(data: str) -> str: + return hashlib.sha256(data.encode()).hexdigest() + + +def _calculate_merkle_root(transactions: List[Transaction]) -> Optional[str]: + if not transactions: + return None + + # Hash each transaction deterministically + tx_hashes = [ + _sha256(json.dumps(tx.to_dict(), sort_keys=True)) + for tx in transactions + ] + + # Build Merkle tree + while len(tx_hashes) > 1: + if len(tx_hashes) % 2 != 0: + tx_hashes.append(tx_hashes[-1]) # duplicate last if odd + + new_level = [] + for i in range(0, len(tx_hashes), 2): + combined = tx_hashes[i] + tx_hashes[i + 1] + new_level.append(_sha256(combined)) + + tx_hashes = new_level + + return tx_hashes[0] + + +class Block: + def __init__( + self, + index: int, + previous_hash: str, + transactions: Optional[List[Transaction]] = None, + timestamp: Optional[float] = None, + difficulty: Optional[int] = None, + ): + self.index = index + self.previous_hash = previous_hash + self.transactions: List[Transaction] = transactions or [] + + # Deterministic timestamp (ms) + self.timestamp: int = ( + round(time.time() * 1000) + if timestamp is None + else int(timestamp) + ) + + self.difficulty: Optional[int] = difficulty + self.nonce: int = 0 + self.hash: Optional[str] = None + + # NEW: compute merkle root once + self.merkle_root: Optional[str] = _calculate_merkle_root(self.transactions) + + # ------------------------- + # HEADER (used for mining) + # ------------------------- + def to_header_dict(self): + return { + "index": self.index, + "previous_hash": self.previous_hash, + "merkle_root": self.merkle_root, + "timestamp": self.timestamp, + "difficulty": self.difficulty, + "nonce": self.nonce, + } + + # ------------------------- + # BODY (transactions only) + # ------------------------- + def to_body_dict(self): + return { + "transactions": [ + tx.to_dict() for tx in self.transactions + ] + } + + # ------------------------- + # FULL BLOCK + # ------------------------- + def to_dict(self): + return { + **self.to_header_dict(), + **self.to_body_dict(), + "hash": self.hash, + } + + # ------------------------- + # HASH CALCULATION + # ------------------------- + def compute_hash(self) -> str: + header_string = json.dumps( + self.to_header_dict(), + sort_keys=True + ) + return _sha256(header_string) diff --git a/core/chain.py b/core/chain.py new file mode 100644 index 0000000..9545864 --- /dev/null +++ b/core/chain.py @@ -0,0 +1,77 @@ +from core.block import Block +from core.state import State +from consensus import calculate_hash +import logging +import threading + +logger = logging.getLogger(__name__) + + +class Blockchain: + """ + Manages the blockchain, validates blocks, and commits state transitions. + """ + + def __init__(self): + self.chain = [] + self.state = State() + self._lock = threading.RLock() + self._create_genesis_block() + + def _create_genesis_block(self): + """ + Creates the genesis block with a fixed hash. + """ + genesis_block = Block( + index=0, + previous_hash="0", + transactions=[] + ) + genesis_block.hash = "0" * 64 + self.chain.append(genesis_block) + + @property + def last_block(self): + """ + Returns the most recent block in the chain. + """ + with self._lock: # Acquire lock for thread-safe access + return self.chain[-1] + + def add_block(self, block): + """ + Validates and adds a block to the chain if all transactions succeed. + Uses a copied State to ensure atomic validation. + """ + + with self._lock: + # Check previous hash linkage + if block.previous_hash != self.last_block.hash: + logger.warning("Block %s rejected: Invalid previous hash %s != %s", block.index, block.previous_hash, self.last_block.hash) + return False + + # Check index linkage + if block.index != self.last_block.index + 1: + logger.warning("Block %s rejected: Invalid index %s != %s", block.index, block.index, self.last_block.index + 1) + return False + + # Verify block hash + if block.hash != calculate_hash(block.to_header_dict()): + logger.warning("Block %s rejected: Invalid hash %s", block.index, block.hash) + return False + + # Validate transactions on a temporary state copy + temp_state = self.state.copy() + + for tx in block.transactions: + result = temp_state.validate_and_apply(tx) + + # Reject block if any transaction fails + if not result: + logger.warning("Block %s rejected: Transaction failed validation", block.index) + return False + + # All transactions valid → commit state and append block + self.state = temp_state + self.chain.append(block) + return True diff --git a/core/contract.py b/core/contract.py new file mode 100644 index 0000000..c12fddd --- /dev/null +++ b/core/contract.py @@ -0,0 +1,170 @@ +import logging +import multiprocessing +import ast + +import json # Moved to module-level import +logger = logging.getLogger(__name__) + +def _safe_exec_worker(code, globals_dict, context_dict, result_queue): + """ + Worker function to execute contract code in a separate process. + """ + try: + # Attempt to set resource limits (Unix only) + try: + import resource + # Limit CPU time (seconds) and memory (bytes) - example values + resource.setrlimit(resource.RLIMIT_CPU, (2, 2)) # Align with p.join timeout (2 seconds) + resource.setrlimit(resource.RLIMIT_AS, (100 * 1024 * 1024, 100 * 1024 * 1024)) + except ImportError: + logger.warning("Resource module not available. Contract will run without OS-level resource limits.") + except (OSError, ValueError) as e: + logger.error(f"Failed to set resource limits: {e}") + raise RuntimeError(f"Failed to set resource limits: {e}") + + exec(code, globals_dict, context_dict) + # Return the updated storage + result_queue.put({"status": "success", "storage": context_dict.get("storage")}) + except Exception as e: + result_queue.put({"status": "error", "error": str(e)}) + +class ContractMachine: + """ + A minimal execution environment for Python-based smart contracts. + WARNING: Still not production-safe. For educational use only. + """ + + def __init__(self, state): + self.state = state + + def execute(self, contract_address, sender_address, payload, amount): + """ + Executes the contract code associated with the contract_address. + """ + + account = self.state.get_account(contract_address) + if not account: + return False + + code = account.get("code") + + # Defensive copy of storage to prevent direct mutation + storage = dict(account.get("storage", {})) + + if not code: + return False + + # AST Validation to prevent introspection + if not self._validate_code_ast(code): + return False + + # Restricted builtins (explicit allowlist) + safe_builtins = { + "True": True, + "False": False, + "None": None, + "range": range, + "len": len, + "min": min, + "max": max, + "abs": abs, + "str": str, # Keeping str for basic functionality, relying on AST checks for safety + "bool": bool, + "float": float, + "list": list, + "dict": dict, + "tuple": tuple, + "sum": sum, + "Exception": Exception, # Added to allow contracts to raise exceptions + } + + globals_for_exec = { + "__builtins__": safe_builtins + } + + # Execution context (locals) + context = { + "storage": storage, + "msg": { + "sender": sender_address, + "value": amount, + "data": payload, + }, + # "print": print, # Removed for security + } + + try: + # Execute in a subprocess with timeout + queue = multiprocessing.Queue() + p = multiprocessing.Process( + target=_safe_exec_worker, + args=(code, globals_for_exec, context, queue) + ) + p.start() + p.join(timeout=2) # 2 second timeout + + if p.is_alive(): + p.kill() + p.join() + logger.error("Contract execution timed out") + return False + + try: + result = queue.get(timeout=1) + except Exception: + logger.error("Contract execution crashed without result") + return False + if result["status"] != "success": + logger.error(f"Contract Execution Failed: {result.get('error')}") + return False + + # Validate storage is JSON serializable + try: + json.dumps(result["storage"]) + except (TypeError, ValueError): + logger.error("Contract storage not JSON serializable") + return False + + # Commit updated storage only after successful execution + self.state.update_contract_storage( + contract_address, + result["storage"] + ) + + return True + + except Exception as e: + logger.error("Contract Execution Failed", exc_info=True) + return False + + def _validate_code_ast(self, code): + """Reject code that uses double underscores or introspection.""" + try: + tree = ast.parse(code) + for node in ast.walk(tree): + if isinstance(node, ast.Attribute) and node.attr.startswith("__"): + logger.warning("Rejected contract code with double-underscore attribute access.") + return False + if isinstance(node, ast.Name) and node.id.startswith("__"): + logger.warning("Rejected contract code with double-underscore name.") + return False + if isinstance(node, (ast.Import, ast.ImportFrom)): + logger.warning("Rejected contract code with import statement.") + return False + if isinstance(node, ast.Call): + if isinstance(node.func, ast.Name) and node.func.id == 'type': + 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}.") + return False + if isinstance(node, ast.Constant) and isinstance(node.value, str): + if "__" in node.value: + logger.warning("Rejected string literal with double-underscore.") + return False + if isinstance(node, ast.JoinedStr): # f-strings + logger.warning("Rejected f-string usage.") + return False + return True + except SyntaxError: + return False diff --git a/core/state.py b/core/state.py new file mode 100644 index 0000000..17bc68c --- /dev/null +++ b/core/state.py @@ -0,0 +1,163 @@ +from nacl.hash import sha256 +from nacl.encoding import HexEncoder +from core.contract import ContractMachine +import copy +import logging + +logger = logging.getLogger(__name__) + + +class State: + def __init__(self): + # { address: {'balance': int, 'nonce': int, 'code': str|None, 'storage': dict} } + self.accounts = {} + self.contract_machine = ContractMachine(self) + + DEFAULT_MINING_REWARD = 50 + + def get_account(self, address): + if address not in self.accounts: + self.accounts[address] = { + 'balance': 0, + 'nonce': 0, + 'code': None, + 'storage': {} + } + return self.accounts[address] + + def verify_transaction_logic(self, tx): + if not tx.verify(): + logger.error(f"Error: Invalid signature for tx from {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]}...") + return False + + if sender_acc['nonce'] != tx.nonce: + logger.error(f"Error: Invalid nonce. Expected {sender_acc['nonce']}, got {tx.nonce}") + return False + + return True + + def copy(self): + """ + Return an independent copy of state for transactional validation. + """ + new_state = copy.deepcopy(self) + new_state.contract_machine = ContractMachine(new_state) # Reinitialize contract_machine + return new_state + + def validate_and_apply(self, tx): + """ + Validate and apply a transaction. + Returns the same success/failure shape as apply_transaction(). + NOTE: Delegates to apply_transaction. Callers should use this for + semantic validation entry points. + """ + # Semantic validation: amount must be an integer and non-negative + if not isinstance(tx.amount, int) or tx.amount < 0: + return False + # 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 + """ + if not self.verify_transaction_logic(tx): + return False + + sender = self.accounts[tx.sender] + + # Deduct funds and increment nonce + sender['balance'] -= tx.amount + sender['nonce'] += 1 + + # LOGIC BRANCH 1: Contract Deployment + if tx.receiver is None or tx.receiver == "": + contract_address = self.derive_contract_address(tx.sender, tx.nonce) + + # Prevent redeploy collision + existing = self.accounts.get(contract_address) + if existing and existing.get("code"): + # Restore sender state on failure + sender['balance'] += tx.amount + sender['nonce'] -= 1 + return False + + return self.create_contract(contract_address, tx.data, initial_balance=tx.amount) + + # LOGIC BRANCH 2: Contract Call + # If data is provided (non-empty), treat as contract call + if tx.data: + receiver = self.accounts.get(tx.receiver) + + # 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 + sender['balance'] += tx.amount # Refund amount + sender['nonce'] -= 1 + return False + + # Credit contract balance + receiver['balance'] += tx.amount + + success = self.contract_machine.execute( + contract_address=tx.receiver, # Pass receiver as contract_address + sender_address=tx.sender, + payload=tx.data, + amount=tx.amount + ) + + if not success: + # Rollback transfer and nonce if execution fails + receiver['balance'] -= tx.amount + sender['balance'] += tx.amount # Refund amount + sender['nonce'] -= 1 + return False + + return True + + # LOGIC BRANCH 3: Regular Transfer + receiver = self.get_account(tx.receiver) + receiver['balance'] += tx.amount + return True + + def derive_contract_address(self, sender, nonce): + raw = f"{sender}:{nonce}".encode() + return sha256(raw, encoder=HexEncoder).decode()[:40] + + def create_contract(self, contract_address, code, initial_balance=0): + self.accounts[contract_address] = { + 'balance': initial_balance, + 'nonce': 0, + 'code': code, + 'storage': {} + } + return contract_address + + def update_contract_storage(self, address, new_storage): + if address in self.accounts: + self.accounts[address]['storage'] = new_storage + else: + raise KeyError(f"Contract address not found: {address}") + + def update_contract_storage_partial(self, address, updates): + if address not in self.accounts: + raise KeyError(f"Contract address not found: {address}") + if isinstance(updates, dict): + self.accounts[address]['storage'].update(updates) + else: + raise ValueError("Updates must be a dictionary") + + def credit_mining_reward(self, miner_address, reward=None): + reward = reward if reward is not None else self.DEFAULT_MINING_REWARD + account = self.get_account(miner_address) + account['balance'] += reward diff --git a/core/transaction.py b/core/transaction.py new file mode 100644 index 0000000..cdf4d99 --- /dev/null +++ b/core/transaction.py @@ -0,0 +1,63 @@ +import json +import time +from nacl.signing import SigningKey, VerifyKey +from nacl.encoding import HexEncoder +from nacl.exceptions import BadSignatureError, CryptoError + + +class Transaction: + def __init__(self, sender, receiver, amount, nonce, data=None, signature=None, timestamp=None): + self.sender = sender # Public key (Hex str) + self.receiver = receiver # Public key (Hex str) or None for Deploy + self.amount = amount + self.nonce = nonce + self.data = data # Preserve None (do NOT normalize to "") + self.timestamp = round(timestamp * 1000) if timestamp is not None else round(time.time() * 1000) # Integer milliseconds for determinism + self.signature = signature # Hex str + + def to_dict(self): + return { + "sender": self.sender, + "receiver": self.receiver, + "amount": self.amount, + "nonce": self.nonce, + "data": self.data, + "timestamp": self.timestamp, + "signature": self.signature, + } + + @property + def hash_payload(self): + """Returns the bytes to be signed.""" + payload = { + "sender": self.sender, + "receiver": self.receiver, + "amount": self.amount, + "nonce": self.nonce, + "data": self.data, + "timestamp": self.timestamp, # Already integer milliseconds + } + return json.dumps(payload, sort_keys=True).encode("utf-8") + + def sign(self, signing_key: SigningKey): + # Validate that the signing key matches the sender + if signing_key.verify_key.encode(encoder=HexEncoder).decode() != self.sender: + raise ValueError("Signing key does not match sender") + signed = signing_key.sign(self.hash_payload) + self.signature = signed.signature.hex() + + def verify(self): + if not self.signature: + return False + + try: + verify_key = VerifyKey(self.sender, encoder=HexEncoder) + verify_key.verify(self.hash_payload, bytes.fromhex(self.signature)) + return True + + except (BadSignatureError, CryptoError, ValueError, TypeError): + # Covers: + # - Invalid signature + # - Malformed public key hex + # - Invalid hex in signature + return False diff --git a/main.py b/main.py new file mode 100644 index 0000000..d9670c0 --- /dev/null +++ b/main.py @@ -0,0 +1,196 @@ +import asyncio +import logging +import re +from nacl.signing import SigningKey +from nacl.encoding import HexEncoder + +from core import Transaction, Blockchain, Block, State +from node import Mempool +from network import P2PNetwork +from consensus import mine_block + + +logger = logging.getLogger(__name__) + +BURN_ADDRESS = "0" * 40 + + +def create_wallet(): + sk = SigningKey.generate() + pk = sk.verify_key.encode(encoder=HexEncoder).decode() + return sk, pk + + +def mine_and_process_block(chain, mempool, pending_nonce_map): + """ + Mine block and let Blockchain handle validation + state updates. + DO NOT manually apply transactions again. + """ + + pending_txs = mempool.get_transactions_for_block() + + block = Block( + index=chain.last_block.index + 1, + previous_hash=chain.last_block.hash, + transactions=pending_txs, + ) + + mined_block = mine_block(block) + + if not hasattr(mined_block, "miner"): + mined_block.miner = BURN_ADDRESS + + deployed_contracts: list[str] = [] + + if chain.add_block(mined_block): + logger.info("Block #%s added", mined_block.index) + + miner_attr = getattr(mined_block, "miner", None) + if isinstance(miner_attr, str) and re.match(r'^[0-9a-fA-F]{40}$', miner_attr): + miner_address = miner_attr + else: + logger.warning("Invalid miner address. Crediting burn address.") + miner_address = BURN_ADDRESS + + # Reward must go through chain.state + chain.state.credit_mining_reward(miner_address) + + for tx in mined_block.transactions: + sync_nonce(chain.state, pending_nonce_map, tx.sender) + + # Track deployed contracts if your state.apply_transaction returns address + result = chain.state.get_account(tx.receiver) if tx.receiver else None + if isinstance(result, dict): + deployed_contracts.append(tx.receiver) + + return mined_block, deployed_contracts + else: + logger.error("Block rejected by chain") + return None, [] + + +def sync_nonce(state, pending_nonce_map, address): + account = state.get_account(address) + if account and "nonce" in account: + pending_nonce_map[address] = account["nonce"] + else: + pending_nonce_map[address] = 0 + + +async def node_loop(): + logger.info("Starting MiniChain Node with Smart Contracts") + + state = State() + chain = Blockchain(state) + mempool = Mempool() + + pending_nonce_map = {} + + def claim_nonce(address): + account = state.get_account(address) + account_nonce = account.get("nonce", 0) if account else 0 + local_nonce = pending_nonce_map.get(address, account_nonce) + next_nonce = max(account_nonce, local_nonce) + pending_nonce_map[address] = next_nonce + 1 + return next_nonce + + network = P2PNetwork(None) + + async def _handle_network_data(data): + logger.info("Received network data: %s", data) + + try: + if data["type"] == "tx": + tx = Transaction(**data["data"]) + if mempool.add_transaction(tx): + await network.broadcast_transaction(tx) + + elif data["type"] == "block": + block_data = data["data"] + transactions_raw = block_data.get("transactions", []) + transactions = [Transaction(**tx_data) for tx_data in transactions_raw] + + block = Block( + index=block_data.get("index"), + previous_hash=block_data.get("previous_hash"), + transactions=transactions, + timestamp=block_data.get("timestamp"), + difficulty=block_data.get("difficulty") + ) + + block.nonce = block_data.get("nonce", 0) + block.hash = block_data.get("hash") + + if chain.add_block(block): + logger.info("Received block added to chain: #%s", block.index) + + except Exception: + logger.exception("Error processing network data: %s", data) + + network.handler_callback = _handle_network_data + + try: + await _run_node(network, state, chain, mempool, pending_nonce_map, claim_nonce) + finally: + await network.stop() + + +async def _run_node(network, chain, mempool, pending_nonce_map, get_next_nonce): + await network.start() + + alice_sk, alice_pk = create_wallet() + bob_sk, bob_pk = create_wallet() + + logger.info("Alice Address: %s...", alice_pk[:10]) + logger.info("Bob Address: %s...", bob_pk[:10]) + + logger.info("[1] Genesis: Crediting Alice with 100 coins") + chain.state.credit_mining_reward(alice_pk, reward=100) + sync_nonce(chain.state, pending_nonce_map, alice_pk) + + # ------------------------------- + # Alice Payment + # ------------------------------- + + logger.info("[2] Transaction: Alice sends 10 coins to Bob") + + nonce = get_next_nonce(alice_pk) + + tx_payment = Transaction( + sender=alice_pk, + receiver=bob_pk, + amount=10, + nonce=nonce, + ) + tx_payment.sign(alice_sk) + + if mempool.add_transaction(tx_payment): + await network.broadcast_transaction(tx_payment) + + # ------------------------------- + # Mine Block 1 + # ------------------------------- + + logger.info("[3] Mining Block 1") + mine_and_process_block(chain, mempool, pending_nonce_map) + + # ------------------------------- + # Final State Check + # ------------------------------- + + logger.info("[4] Final State Check") + + alice_acc = chain.state.get_account(alice_pk) + bob_acc = chain.state.get_account(bob_pk) + + logger.info("Alice Balance: %s", alice_acc.get("balance", 0) if alice_acc else 0) + logger.info("Bob Balance: %s", bob_acc.get("balance", 0) if bob_acc else 0) + + +def main(): + logging.basicConfig(level=logging.INFO) + asyncio.run(node_loop()) + + +if __name__ == "__main__": + main() diff --git a/network/__init__.py b/network/__init__.py new file mode 100644 index 0000000..742fbe2 --- /dev/null +++ b/network/__init__.py @@ -0,0 +1,3 @@ +from .p2p import P2PNetwork + +__all__ = ["P2PNetwork"] \ No newline at end of file diff --git a/network/p2p.py b/network/p2p.py new file mode 100644 index 0000000..ef0a9dd --- /dev/null +++ b/network/p2p.py @@ -0,0 +1,89 @@ +import json +import logging + +logger = logging.getLogger(__name__) + + +class P2PNetwork: + """ + A minimal abstraction for Peer-to-Peer networking. + + Expected incoming message interface for handle_message(): + msg must have attribute: + - data: bytes (JSON-encoded payload) + + JSON structure: + { + "type": "tx" | "block", + "data": {...} + } + """ + + def __init__(self, handler_callback): + if not callable(handler_callback): + raise ValueError("handler_callback must be callable") + self.handler_callback = handler_callback + self.pubsub = None # Will be set in real implementation + + async def start(self): + logger.info("Network: Listening on /ip4/0.0.0.0/tcp/0") + # In real libp2p, we would await host.start() here + + async def _broadcast_message(self, topic, msg_type, payload): + msg = json.dumps({"type": msg_type, "data": payload}) + if self.pubsub: + try: + await self.pubsub.publish(topic, msg.encode()) + except Exception as e: + logger.error("Network: Publish failed: %s", e) + else: + logger.debug("Network: pubsub not initialized (mock mode)") + + async def broadcast_transaction(self, tx): + sender = getattr(tx, "sender", "") + logger.info("Network: Broadcasting Tx from %s...", sender[:5]) + try: + payload = tx.to_dict() + except (TypeError, ValueError) as e: + logger.error("Network: Failed to serialize tx: %s", e) + return + await self._broadcast_message("minichain-global", "tx", payload) + + async def broadcast_block(self, block): + logger.info("Network: Broadcasting Block #%d", block.index) + await self._broadcast_message("minichain-global", "block", block.to_dict()) + + async def handle_message(self, msg): + """ + Callback when a p2p message is received. + """ + + try: + if not hasattr(msg, "data"): + raise TypeError("Incoming message missing 'data' attribute") + + if not isinstance(msg.data, (bytes, bytearray)): + raise TypeError("msg.data must be bytes") + + if len(msg.data) > 1024 * 1024: # 1MB limit + logger.warning("Network: Message too large") + return + + try: + decoded = msg.data.decode('utf-8') + except UnicodeDecodeError as e: + logger.warning("Network Error: UnicodeDecodeError during message decode: %s", e) + return + data = json.loads(decoded) + + if not isinstance(data, dict) or "type" not in data or "data" not in data: + raise ValueError("Invalid message format") + + except (TypeError, ValueError, json.JSONDecodeError) as e: + logger.warning("Network Error parsing message: %s", e) + return + + try: + await self.handler_callback(data) + except Exception: + logger.exception("Error in network handler callback for data: %s", data) diff --git a/node/__init__.py b/node/__init__.py new file mode 100644 index 0000000..434db3e --- /dev/null +++ b/node/__init__.py @@ -0,0 +1,3 @@ +from .mempool import Mempool + +__all__ = ["Mempool"] \ No newline at end of file diff --git a/node/mempool.py b/node/mempool.py new file mode 100644 index 0000000..8bb941a --- /dev/null +++ b/node/mempool.py @@ -0,0 +1,62 @@ +from consensus.pow import calculate_hash +import logging +import threading + +logger = logging.getLogger(__name__) + +class Mempool: + def __init__(self, max_size=1000): + self._pending_txs = [] + self._seen_tx_ids = set() # Dedup tracking + self._lock = threading.Lock() + self.max_size = max_size + + def _get_tx_id(self, tx): + """ + Compute a unique deterministic ID for a transaction. + Uses full serialized tx (payload + signature). + """ + return calculate_hash(tx.to_dict()) + + def add_transaction(self, tx): + """ + Adds a transaction to the pool if: + - Signature is valid + - Transaction is not a duplicate + """ + + tx_id = self._get_tx_id(tx) + + if not tx.verify(): + logger.warning("Mempool: Invalid signature rejected") + return False + + with self._lock: + if tx_id in self._seen_tx_ids: + logger.warning(f"Mempool: Duplicate transaction rejected {tx_id}") + return False + + if len(self._pending_txs) >= self.max_size: + # Simple eviction: drop oldest or reject. Here we reject. + logger.warning("Mempool: Full, rejecting transaction") + return False + + self._pending_txs.append(tx) + self._seen_tx_ids.add(tx_id) + + return True + + def get_transactions_for_block(self): + """ + Returns pending transactions and clears the pool. + """ + + with self._lock: + txs = self._pending_txs[:] + + # Clear both list and dedup set to stay in sync + self._pending_txs = [] + confirmed_ids = {self._get_tx_id(tx) for tx in txs} + self._seen_tx_ids.difference_update(confirmed_ids) + + return txs diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..819e170 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pynacl==1.6.2 +libp2p==0.5.0 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..1edff7b --- /dev/null +++ b/setup.py @@ -0,0 +1,18 @@ +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/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 0000000..111222d --- /dev/null +++ b/tests/test_contract.py @@ -0,0 +1,136 @@ +import unittest +import sys +import os + +from core import State, Transaction +from nacl.signing import SigningKey +from nacl.encoding import HexEncoder + + +class TestSmartContract(unittest.TestCase): + + 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) + + def test_deploy_and_execute(self): + """Happy path: deploy and increment counter.""" + + code = """ +if msg['data'] == 'increment': + storage['counter'] = storage.get('counter', 0) + 1 +""" + + tx_deploy = Transaction(self.pk, None, 0, 0, data=code) + tx_deploy.sign(self.sk) + + contract_addr = self.state.apply_transaction(tx_deploy) + 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) + + contract_acc = self.state.get_account(contract_addr) + self.assertEqual(contract_acc["storage"]["counter"], 1) + + def test_deploy_insufficient_balance(self): + """Deploy should fail if sender balance is insufficient.""" + + poor_sk = SigningKey.generate() + poor_pk = poor_sk.verify_key.encode(encoder=HexEncoder).decode() + + code = "storage['x'] = 1" + + tx = Transaction(poor_pk, None, 1000, 0, data=code) + tx.sign(poor_sk) + + result = self.state.apply_transaction(tx) + self.assertFalse(result) + + def test_call_non_existent_contract(self): + """Calling unknown contract should fail with valid hex receiver.""" + + 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.sign(self.sk) + + result = self.state.apply_transaction(tx) + self.assertFalse(result) + + def test_contract_runtime_exception(self): + """Contract raising exception should fail and not mutate storage.""" + + code = """ +raise Exception("boom") +""" + + tx_deploy = Transaction(self.pk, None, 0, 0, data=code) + tx_deploy.sign(self.sk) + + contract_addr = self.state.apply_transaction(tx_deploy) + 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) + + contract_acc = self.state.get_account(contract_addr) + self.assertEqual(contract_acc["storage"], {}) + + def test_redeploy_same_address(self): + """Deploying to an already-occupied contract address should fail.""" + + code = "storage['x'] = 1" + + # First deploy + tx1 = Transaction(self.pk, None, 0, 0, data=code) + tx1.sign(self.sk) + + addr = self.state.apply_transaction(tx1) + self.assertTrue(isinstance(addr, str)) + + # Compute the address that a second deploy would use + next_nonce = self.state.get_account(self.pk)["nonce"] + collision_addr = self.state.derive_contract_address(self.pk, next_nonce) + + # Pre-place contract to simulate collision + self.state.create_contract(collision_addr, "storage['y'] = 2") + + # Attempt redeploy + tx2 = Transaction(self.pk, None, 0, next_nonce, data=code) + tx2.sign(self.sk) + + result = self.state.apply_transaction(tx2) + self.assertFalse(result) + + def test_balance_and_nonce_updates(self): + """Verify sender balance and nonce after deploy and call.""" + + sender_before = self.state.get_account(self.pk) + initial_balance = sender_before["balance"] + initial_nonce = sender_before["nonce"] + + code = "storage['x'] = 1" + + 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) + self.assertTrue(isinstance(contract_addr, str)) + + # Verify balance and nonce after deploy + sender_after = self.state.get_account(self.pk) + self.assertEqual(sender_after["balance"], initial_balance - 10) + self.assertEqual(sender_after["nonce"], initial_nonce + 1) + + # Further test calls if needed diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..1b7a189 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,75 @@ +import unittest +from nacl.signing import SigningKey +from nacl.encoding import HexEncoder + +from core import Transaction, Blockchain, State # Removed unused imports + +class TestCore(unittest.TestCase): + def setUp(self): + self.state = State() + self.chain = Blockchain() + + # Setup Alice + self.alice_sk = SigningKey.generate() + self.alice_pk = self.alice_sk.verify_key.encode(encoder=HexEncoder).decode() + + # Setup Bob + self.bob_sk = SigningKey.generate() + self.bob_pk = self.bob_sk.verify_key.encode(encoder=HexEncoder).decode() + + def test_genesis_block(self): + """Check if genesis block is created correctly.""" + self.assertEqual(len(self.chain.chain), 1) + self.assertEqual(self.chain.last_block.index, 0) + self.assertEqual(self.chain.last_block.previous_hash, "0") + + def test_transaction_signature(self): + """Check that valid signatures pass and invalid ones fail.""" + tx = Transaction(self.alice_pk, self.bob_pk, 10, 0) + tx.sign(self.alice_sk) + self.assertTrue(tx.verify()) + + # Tamper with amount + tx.amount = 100 + self.assertFalse(tx.verify()) + + def test_state_transfer(self): + """Test simple balance transfer.""" + # 1. Credit Alice + self.state.credit_mining_reward(self.alice_pk, 100) + + # 2. Transfer + tx = Transaction(self.alice_pk, self.bob_pk, 40, 0) + tx.sign(self.alice_sk) + + result = self.state.apply_transaction(tx) + self.assertTrue(result) + + # 3. Check Balances + self.assertEqual(self.state.get_account(self.alice_pk)['balance'], 60) + self.assertEqual(self.state.get_account(self.bob_pk)['balance'], 40) + + def test_insufficient_funds(self): + """Test that you cannot spend more than you have.""" + self.state.credit_mining_reward(self.alice_pk, 10) + + tx = Transaction(self.alice_pk, self.bob_pk, 50, 0) + tx.sign(self.alice_sk) + + result = self.state.apply_transaction(tx) + self.assertFalse(result) + + 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_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 + # Attempt to sign with Bob's key, which should raise ValueError + with self.assertRaises(ValueError) as cm: + tx.sign(self.bob_sk) + self.assertIn("Signing key does not match sender", str(cm.exception)) + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file