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/__pycache__/__init__.cpython-311.pyc b/consensus/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..0c30acc Binary files /dev/null and b/consensus/__pycache__/__init__.cpython-311.pyc differ diff --git a/consensus/__pycache__/pow.cpython-311.pyc b/consensus/__pycache__/pow.cpython-311.pyc new file mode 100644 index 0000000..a0afd52 Binary files /dev/null and b/consensus/__pycache__/pow.cpython-311.pyc differ diff --git a/consensus/pow.py b/consensus/pow.py new file mode 100644 index 0000000..dd72652 --- /dev/null +++ b/consensus/pow.py @@ -0,0 +1,73 @@ +import json +import time +from nacl.hash import sha256 +from nacl.encoding import HexEncoder + + +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 sha256(block_string, encoder=HexEncoder).decode("utf-8") + + +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.""" + + target = "0" * difficulty + local_nonce = 0 + start_time = time.time() + + 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.time() - start_time) > timeout_seconds: + if logger: + logger.warning("Mining timeout exceeded.") + raise MiningExceededError("Mining failed: timeout exceeded") + + # Temporarily set nonce for hashing only + block.nonce = local_nonce + block_hash = calculate_hash(block.to_header_dict()) + + # 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") + + # Check difficulty target + if block_hash.startswith(target): + block.nonce = local_nonce + block.hash = block_hash + if logger: + logger.info("Success! Hash: %s", block_hash) + return block + + # 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/__pycache__/__init__.cpython-311.pyc b/core/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..8b3aab2 Binary files /dev/null and b/core/__pycache__/__init__.cpython-311.pyc differ diff --git a/core/__pycache__/block.cpython-311.pyc b/core/__pycache__/block.cpython-311.pyc new file mode 100644 index 0000000..16bd63d Binary files /dev/null and b/core/__pycache__/block.cpython-311.pyc differ diff --git a/core/__pycache__/chain.cpython-311.pyc b/core/__pycache__/chain.cpython-311.pyc new file mode 100644 index 0000000..4f8772e Binary files /dev/null and b/core/__pycache__/chain.cpython-311.pyc differ diff --git a/core/__pycache__/contract.cpython-311.pyc b/core/__pycache__/contract.cpython-311.pyc new file mode 100644 index 0000000..731dfd0 Binary files /dev/null and b/core/__pycache__/contract.cpython-311.pyc differ diff --git a/core/__pycache__/state.cpython-311.pyc b/core/__pycache__/state.cpython-311.pyc new file mode 100644 index 0000000..4aa9288 Binary files /dev/null and b/core/__pycache__/state.cpython-311.pyc differ diff --git a/core/__pycache__/transaction.cpython-311.pyc b/core/__pycache__/transaction.cpython-311.pyc new file mode 100644 index 0000000..ad39fc3 Binary files /dev/null and b/core/__pycache__/transaction.cpython-311.pyc differ diff --git a/core/block.py b/core/block.py new file mode 100644 index 0000000..495119d --- /dev/null +++ b/core/block.py @@ -0,0 +1,34 @@ +import time + +class Block: + def __init__(self, index, previous_hash, transactions, timestamp=None, difficulty=None): + self.index = index + self.previous_hash = previous_hash + self.transactions = transactions + self.timestamp = time.time() if timestamp is None else timestamp + self.nonce = 0 + self.hash = None + self.difficulty = difficulty + + def to_dict(self): + """Full block data for serialization/transport.""" + return { + "index": self.index, + "previous_hash": self.previous_hash, + "transactions": [tx.to_dict() for tx in self.transactions], + "timestamp": self.timestamp, + "difficulty": self.difficulty, + "nonce": self.nonce, + "hash": self.hash + } + + def to_header_dict(self): + """Data used for mining (consensus).""" + return { + "index": self.index, + "previous_hash": self.previous_hash, + "transactions": [tx.to_dict() for tx in self.transactions], + "timestamp": self.timestamp, + "difficulty": self.difficulty, + "nonce": self.nonce + } diff --git a/core/chain.py b/core/chain.py new file mode 100644 index 0000000..b03791f --- /dev/null +++ b/core/chain.py @@ -0,0 +1,73 @@ +from core.block import Block +from core.state import State +from consensus import calculate_hash +import logging + +logger = logging.getLogger(__name__) + + +class Blockchain: + """ + Manages the blockchain, validates blocks, and commits state transitions. + """ + + def __init__(self): + self.chain = [] + self.state = State() + 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. + """ + 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. + """ + + # 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_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 result is False or result is None: + 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..de12131 --- /dev/null +++ b/core/contract.py @@ -0,0 +1,135 @@ +import logging +import multiprocessing +import ast +import sys + +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, (1, 1)) + # resource.setrlimit(resource.RLIMIT_AS, (100 * 1024 * 1024, 100 * 1024 * 1024)) + except ImportError as e: + logger.error(f"Resource limits not enforced: {e}") + raise RuntimeError(f"Resource limits not enforced: {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, + } + + globals_for_exec = { + "__builtins__": safe_builtins + } + + # Execution context (locals) + context = { + "storage": storage, + "msg": { + "sender": sender_address, + "value": amount, + "data": payload, + }, + "print": print, # Explicitly allowed for debugging + } + + 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() + logger.error("Contract execution timed out") + return False + + if queue.empty(): + logger.error("Contract execution crashed without result") + return False + + result = queue.get() + if result["status"] != "success": + logger.error(f"Contract Execution Failed: {result.get('error')}") + 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 + return True + except SyntaxError: + return False diff --git a/core/state.py b/core/state.py new file mode 100644 index 0000000..17df4c3 --- /dev/null +++ b/core/state.py @@ -0,0 +1,146 @@ +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) + + 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. + """ + return copy.deepcopy(self) + + 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. + TODO: Implement specific semantic validation logic 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, but nonce remains consumed + sender['balance'] += tx.amount + # Do NOT rollback nonce + return False + + # Credit contract balance + receiver['balance'] += tx.amount + + success = self.contract_machine.execute( + contract_address=tx.receiver, + sender_address=tx.sender, + payload=tx.data, + amount=tx.amount + ) + + if not success: + # Rollback transfer if execution fails, nonce remains consumed + receiver['balance'] -= tx.amount + sender['balance'] += tx.amount + 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 credit_mining_reward(self, miner_address, reward=50): + 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..efd7483 --- /dev/null +++ b/core/transaction.py @@ -0,0 +1,59 @@ +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): + 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 = time.time() + 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, + } + return json.dumps(payload, sort_keys=True).encode("utf-8") + + def sign(self, signing_key: SigningKey): + 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): + # 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..a2f0b13 --- /dev/null +++ b/main.py @@ -0,0 +1,231 @@ +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, state, pending_nonce_map): + """Helper to mine a block and apply transactions.""" + 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) + contract_address = None + + if chain.add_block(mined_block): + logger.info("Block #%s added", mined_block.index) + + # Credit miner reward + 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("Block has no miner or invalid address. Crediting burn address.") + miner_address = BURN_ADDRESS + + state.credit_mining_reward(miner_address) + + for tx in mined_block.transactions: + result = state.apply_transaction(tx) + + # Check for valid contract address (40 hex chars) + if isinstance(result, str) and re.match(r'^[0-9a-fA-F]{40}$', result): + contract_address = result + logger.info("New Contract Deployed at: %s", contract_address) + sync_nonce(state, pending_nonce_map, tx.sender) + elif result is True: + sync_nonce(state, pending_nonce_map, tx.sender) + elif result is False or result is None: + logger.error("Transaction failed in block %s", mined_block.index) + sync_nonce(state, pending_nonce_map, tx.sender) + + return mined_block, contract_address + else: + logger.error("Block rejected by chain") + return None, 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() + mempool = Mempool() + + pending_nonce_map = {} + + def get_next_nonce(address): + account_nonce = state.get_account(address)["nonce"] + local_nonce = pending_nonce_map.get(address, account_nonce) + next_nonce = max(account_nonce, local_nonce) + return next_nonce + + async def handle_network_data(data): + logger.info("Received network data: %s", data) + + network = P2PNetwork(handle_network_data) + + try: + await _run_node(network, state, chain, mempool, pending_nonce_map, get_next_nonce) + finally: + await network.stop() + +async def _run_node(network, state, 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") + state.credit_mining_reward(alice_pk, reward=100) + sync_nonce(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): + pending_nonce_map[alice_pk] = nonce + 1 + await network.broadcast_transaction(tx_payment) + else: + logger.warning("Transaction rejected by mempool") + + # ------------------------------- + # Contract Deployment (UNSAFE) + # ------------------------------- + + logger.info("[3] Smart Contract: Alice deploys a 'Storage' contract") + + # WARNING: + # This contract uses raw Python executed via exec inside ContractMachine. + # This is UNSAFE and should NEVER be used in production. + # TODO: Replace ContractMachine exec-based runtime with: + # - RestrictedPython + # - WASM-based VM + # - Custom DSL interpreter + contract_code = """ +# Storage Contract (UNSAFE EXAMPLE) +if msg['data']: + storage['value'] = msg['data'] +""" + + nonce = get_next_nonce(alice_pk) + + tx_deploy = Transaction( + sender=alice_pk, + receiver=None, + amount=0, + nonce=nonce, + data=contract_code, + ) + tx_deploy.sign(alice_sk) + + if mempool.add_transaction(tx_deploy): + pending_nonce_map[alice_pk] = nonce + 1 + await network.broadcast_transaction(tx_deploy) + else: + logger.warning("Contract deploy rejected") + + # ------------------------------- + # Mine Block 1 + # ------------------------------- + + logger.info("[4] Consensus: Mining Block 1") + + _, contract_address = mine_and_process_block(chain, mempool, state, pending_nonce_map) + + # ------------------------------- + # Bob Interaction + # ------------------------------- + + logger.info("[5] Interaction: Bob sends data to Contract") + + if contract_address is None: + logger.error("Contract not deployed. Skipping interaction.") + return + + nonce = get_next_nonce(bob_pk) + + tx_call = Transaction( + sender=bob_pk, + receiver=contract_address, + amount=0, + nonce=nonce, + data="Hello Blockchain", + ) + tx_call.sign(bob_sk) + + if mempool.add_transaction(tx_call): + pending_nonce_map[bob_pk] = nonce + 1 + await network.broadcast_transaction(tx_call) + else: + logger.warning("Contract call rejected") + + # ------------------------------- + # Mine Block 2 + # ------------------------------- + + logger.info("[6] Consensus: Mining Block 2") + + mine_and_process_block(chain, mempool, state, pending_nonce_map) + + # ------------------------------- + # Final State + # ------------------------------- + + logger.info("[7] Final State Check") + logger.info("Alice Balance: %s", state.get_account(alice_pk)["balance"]) + logger.info("Bob Balance: %s", state.get_account(bob_pk)["balance"]) + + if contract_address: + contract_acc = state.get_account(contract_address) + logger.info("Contract Storage: %s", contract_acc["storage"]) + else: + logger.info("No contract deployed.") + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + asyncio.run(node_loop()) 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/__pycache__/__init__.cpython-311.pyc b/network/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..dbc6bae Binary files /dev/null and b/network/__pycache__/__init__.cpython-311.pyc differ diff --git a/network/__pycache__/p2p.cpython-311.pyc b/network/__pycache__/p2p.cpython-311.pyc new file mode 100644 index 0000000..3462cd2 Binary files /dev/null and b/network/__pycache__/p2p.cpython-311.pyc differ diff --git a/network/p2p.py b/network/p2p.py new file mode 100644 index 0000000..7e18412 --- /dev/null +++ b/network/p2p.py @@ -0,0 +1,71 @@ +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): + self.peers = [] + 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_transaction(self, tx): + msg = json.dumps({"type": "tx", "data": tx.to_dict()}) + logger.info("Network: Broadcasting Tx from %s...", tx.sender[:5]) + + if self.pubsub: + await self.pubsub.publish("minichain-global", msg.encode()) + else: + logger.debug("Network: pubsub not initialized (mock mode)") + + async def broadcast_block(self, block): + msg = json.dumps({"type": "block", "data": block.to_dict()}) + logger.info("Network: Broadcasting Block #%d", block.index) + + if self.pubsub: + await self.pubsub.publish("minichain-global", msg.encode()) + else: + logger.debug("Network: pubsub not initialized (mock mode)") + + 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") + + decoded = msg.data.decode() + 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: %s", e) + return + + await self.handler_callback(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/__pycache__/__init__.cpython-311.pyc b/node/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..99d0cb7 Binary files /dev/null and b/node/__pycache__/__init__.cpython-311.pyc differ diff --git a/node/__pycache__/mempool.cpython-311.pyc b/node/__pycache__/mempool.cpython-311.pyc new file mode 100644 index 0000000..e3cb038 Binary files /dev/null and b/node/__pycache__/mempool.cpython-311.pyc differ diff --git a/node/mempool.py b/node/mempool.py new file mode 100644 index 0000000..5e1c818 --- /dev/null +++ b/node/mempool.py @@ -0,0 +1,61 @@ +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 + """ + + if not tx.verify(): + logger.warning("Mempool: Invalid signature rejected") + return False + + with self._lock: + tx_id = self._get_tx_id(tx) + + 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 = [] + self.seen_tx_ids.clear() + + 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..142768d --- /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.6.2", + "libp2p==0.5.0", # Fixed: was "py-libp2p" + ], + entry_points={ + "console_scripts": [ + "minichain=main:main", # Requires main() function in main.py + ], + }, + 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/__pycache__/__init__.cpython-311.pyc b/tests/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..94a9cde Binary files /dev/null and b/tests/__pycache__/__init__.cpython-311.pyc differ diff --git a/tests/__pycache__/test_contract.cpython-311-pytest-9.0.2.pyc b/tests/__pycache__/test_contract.cpython-311-pytest-9.0.2.pyc new file mode 100644 index 0000000..94215a9 Binary files /dev/null and b/tests/__pycache__/test_contract.cpython-311-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_contract.cpython-311.pyc b/tests/__pycache__/test_contract.cpython-311.pyc new file mode 100644 index 0000000..362872c Binary files /dev/null and b/tests/__pycache__/test_contract.cpython-311.pyc differ diff --git a/tests/__pycache__/test_core.cpython-311-pytest-9.0.2.pyc b/tests/__pycache__/test_core.cpython-311-pytest-9.0.2.pyc new file mode 100644 index 0000000..b19cc98 Binary files /dev/null and b/tests/__pycache__/test_core.cpython-311-pytest-9.0.2.pyc differ diff --git a/tests/__pycache__/test_core.cpython-311.pyc b/tests/__pycache__/test_core.cpython-311.pyc new file mode 100644 index 0000000..9397038 Binary files /dev/null and b/tests/__pycache__/test_core.cpython-311.pyc differ diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 0000000..de9521f --- /dev/null +++ b/tests/test_contract.py @@ -0,0 +1,127 @@ +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) + + contract_add_ diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..16e5ef6 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,71 @@ +import unittest +from nacl.signing import SigningKey +from nacl.encoding import HexEncoder + +# Adjust import path to look at root directory +import sys +import os + +from core import Transaction, Blockchain, Block, State +from consensus import mine_block + +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) + +if __name__ == '__main__': + unittest.main() \ No newline at end of file