diff --git a/.gitignore b/.gitignore index b8edfb6..2420683 100644 --- a/.gitignore +++ b/.gitignore @@ -326,9 +326,12 @@ TSWLatexianTemp* #*Notes.bib # Python +venv/ +.venv/ __pycache__/ *.py[cod] *$py.class *.so *bore.zip *bore_bin +*.egg-info/ diff --git a/conftest.py b/conftest.py index 0d6b08e..2ca7a99 100644 --- a/conftest.py +++ b/conftest.py @@ -1,4 +1,4 @@ -import sys import os +import sys -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) \ No newline at end of file +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) diff --git a/main.py b/main.py index e1edc51..a925b26 100644 --- a/main.py +++ b/main.py @@ -23,13 +23,13 @@ import re import sys -from nacl.signing import SigningKey from nacl.encoding import HexEncoder +from nacl.signing import SigningKey -from minichain import Transaction, Blockchain, Block, State, Mempool, P2PNetwork, mine_block +from minichain import (Block, Blockchain, Mempool, P2PNetwork, State, + Transaction, mine_block) from minichain.validators import is_valid_receiver - logger = logging.getLogger(__name__) BURN_ADDRESS = "0" * 40 @@ -41,6 +41,7 @@ # Wallet helpers # ────────────────────────────────────────────── + def create_wallet(): sk = SigningKey.generate() pk = sk.verify_key.encode(encoder=HexEncoder).decode() @@ -51,6 +52,7 @@ def create_wallet(): # Block mining # ────────────────────────────────────────────── + def mine_and_process_block(chain, mempool, miner_pk): """Mine pending transactions into a new block.""" pending_txs = mempool.get_transactions_for_block() @@ -86,7 +88,11 @@ def mine_and_process_block(chain, mempool, miner_pk): mined_block = mine_block(block) if chain.add_block(mined_block): - logger.info("✅ Block #%d mined and added (%d txs)", mined_block.index, len(mineable_txs)) + logger.info( + "✅ Block #%d mined and added (%d txs)", + mined_block.index, + len(mineable_txs), + ) mempool.remove_transactions(mineable_txs) chain.state.credit_mining_reward(miner_pk) return mined_block @@ -96,7 +102,9 @@ def mine_and_process_block(chain, mempool, miner_pk): for tx in pending_txs: if mempool.add_transaction(tx): restored += 1 - logger.info("Mempool: Restored %d/%d txs after rejection", restored, len(pending_txs)) + logger.info( + "Mempool: Restored %d/%d txs after rejection", restored, len(pending_txs) + ) return None @@ -104,6 +112,7 @@ def mine_and_process_block(chain, mempool, miner_pk): # Network message handler # ────────────────────────────────────────────── + def make_network_handler(chain, mempool): """Return an async callback that processes incoming P2P messages.""" @@ -122,24 +131,40 @@ async def handler(data): return # Merge remote state into local state (for accounts we don't have yet) - remote_accounts = payload.get("accounts") if isinstance(payload, dict) else None + remote_accounts = ( + payload.get("accounts") if isinstance(payload, dict) else None + ) if not isinstance(remote_accounts, dict): - logger.warning("🔒 Rejected sync from %s with invalid accounts payload", peer_addr) + logger.warning( + "🔒 Rejected sync from %s with invalid accounts payload", peer_addr + ) return for addr, acc in remote_accounts.items(): if not isinstance(acc, dict): - logger.warning("🔒 Skipping malformed account %r from %s", addr, peer_addr) + logger.warning( + "🔒 Skipping malformed account %r from %s", addr, peer_addr + ) continue if addr not in chain.state.accounts: chain.state.accounts[addr] = acc - logger.info("🔄 Synced account %s... (balance=%d)", addr[:12], acc.get("balance", 0)) - logger.info("🔄 Accepted state sync from %s — %d accounts", peer_addr, len(chain.state.accounts)) + logger.info( + "🔄 Synced account %s... (balance=%d)", + addr[:12], + acc.get("balance", 0), + ) + logger.info( + "🔄 Accepted state sync from %s — %d accounts", + peer_addr, + len(chain.state.accounts), + ) elif msg_type == "tx": tx = Transaction.from_dict(payload) if mempool.add_transaction(tx): - logger.info("📥 Received tx from %s... (amount=%s)", tx.sender[:8], tx.amount) + logger.info( + "📥 Received tx from %s... (amount=%s)", tx.sender[:8], tx.amount + ) elif msg_type == "block": block = Block.from_dict(payload) @@ -204,7 +229,9 @@ 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}") + print( + f" {addr[:12]}... balance={acc['balance']} nonce={acc['nonce']}{tag}" + ) # ── send ── elif cmd == "send": @@ -232,7 +259,9 @@ async def cli_loop(sk, pk, chain, mempool, network): await network.broadcast_transaction(tx) print(f" ✅ Tx sent: {amount} coins → {receiver[:12]}...") else: - print(" ❌ Transaction rejected (invalid sig, duplicate, or mempool full).") + print( + " ❌ Transaction rejected (invalid sig, duplicate, or mempool full)." + ) # ── mine ── elif cmd == "mine": @@ -288,7 +317,10 @@ async def cli_loop(sk, pk, chain, mempool, network): # Main entry point # ────────────────────────────────────────────── -async def run_node(port: int, host: str, connect_to: str | None, fund: int, datadir: str | None): + +async def run_node( + port: int, host: str, connect_to: str | None, fund: int, datadir: str | None +): """Boot the node, optionally connect to a peer, then enter the CLI.""" sk, pk = create_wallet() @@ -297,6 +329,7 @@ async def run_node(port: int, host: str, connect_to: str | None, fund: int, data if datadir and os.path.exists(os.path.join(datadir, "data.json")): try: from minichain.persistence import load + chain = load(datadir) logger.info("Restored chain from '%s'", datadir) except FileNotFoundError as e: @@ -318,10 +351,11 @@ async def run_node(port: int, host: str, connect_to: str | None, fund: int, data # When a new peer connects, send our state so they can sync async def on_peer_connected(writer): import json as _json - sync_msg = _json.dumps({ - "type": "sync", - "data": {"accounts": chain.state.accounts} - }) + "\n" + + sync_msg = ( + _json.dumps({"type": "sync", "data": {"accounts": chain.state.accounts}}) + + "\n" + ) writer.write(sync_msg.encode()) await writer.drain() logger.info("🔄 Sent state sync to new peer") @@ -350,6 +384,7 @@ async def on_peer_connected(writer): if datadir: try: from minichain.persistence import save + save(chain, datadir) logger.info("Chain saved to '%s'", datadir) except Exception as e: @@ -359,11 +394,33 @@ async def on_peer_connected(writer): def main(): parser = argparse.ArgumentParser(description="MiniChain Node — Testnet Demo") - parser.add_argument("--host", type=str, default="127.0.0.1", help="Host/IP to bind the P2P server (default: 127.0.0.1)") - parser.add_argument("--port", type=int, default=9000, help="TCP port to listen on (default: 9000)") - parser.add_argument("--connect", type=str, default=None, help="Peer address to connect to (host:port)") - parser.add_argument("--fund", type=int, default=100, help="Initial coins to fund this wallet (default: 100)") - parser.add_argument("--datadir", type=str, default=None, help="Directory to save/load blockchain state (enables persistence)") + parser.add_argument( + "--host", + type=str, + default="127.0.0.1", + help="Host/IP to bind the P2P server (default: 127.0.0.1)", + ) + parser.add_argument( + "--port", type=int, default=9000, help="TCP port to listen on (default: 9000)" + ) + parser.add_argument( + "--connect", + type=str, + default=None, + help="Peer address to connect to (host:port)", + ) + parser.add_argument( + "--fund", + type=int, + default=100, + help="Initial coins to fund this wallet (default: 100)", + ) + parser.add_argument( + "--datadir", + type=str, + default=None, + help="Directory to save/load blockchain state (enables persistence)", + ) args = parser.parse_args() logging.basicConfig( @@ -373,7 +430,9 @@ def main(): ) try: - asyncio.run(run_node(args.port, args.host, args.connect, args.fund, args.datadir)) + asyncio.run( + run_node(args.port, args.host, args.connect, args.fund, args.datadir) + ) except KeyboardInterrupt: print("\nNode shut down.") diff --git a/minichain/__init__.py b/minichain/__init__.py index ae52604..0fc5fe4 100644 --- a/minichain/__init__.py +++ b/minichain/__init__.py @@ -1,12 +1,12 @@ -from .pow import mine_block, calculate_hash, MiningExceededError from .block import Block from .chain import Blockchain -from .transaction import Transaction -from .state import State from .contract import ContractMachine -from .p2p import P2PNetwork from .mempool import Mempool -from .persistence import save, load +from .p2p import P2PNetwork +from .persistence import load, save +from .pow import MiningExceededError, calculate_hash, mine_block +from .state import State +from .transaction import Transaction __all__ = [ "mine_block", diff --git a/minichain/block.py b/minichain/block.py index 9854cf4..ccef1f0 100644 --- a/minichain/block.py +++ b/minichain/block.py @@ -1,8 +1,10 @@ -import time import hashlib +import time from typing import List, Optional -from .transaction import Transaction + from .serialization import canonical_json_hash +from .transaction import Transaction + def _sha256(data: str) -> str: return hashlib.sha256(data.encode()).hexdigest() @@ -13,10 +15,7 @@ def _calculate_merkle_root(transactions: List[Transaction]) -> Optional[str]: return None # Hash each transaction deterministically - tx_hashes = [ - tx.tx_id - for tx in transactions - ] + tx_hashes = [tx.tx_id for tx in transactions] # Build Merkle tree while len(tx_hashes) > 1: @@ -48,9 +47,7 @@ def __init__( # Deterministic timestamp (ms) self.timestamp: int = ( - round(time.time() * 1000) - if timestamp is None - else int(timestamp) + round(time.time() * 1000) if timestamp is None else int(timestamp) ) self.difficulty: Optional[int] = difficulty @@ -77,11 +74,7 @@ def to_header_dict(self): # BODY (transactions only) # ------------------------- def to_body_dict(self): - return { - "transactions": [ - tx.to_dict() for tx in self.transactions - ] - } + return {"transactions": [tx.to_dict() for tx in self.transactions]} # ------------------------- # FULL BLOCK diff --git a/minichain/chain.py b/minichain/chain.py index b65d575..70dacbf 100644 --- a/minichain/chain.py +++ b/minichain/chain.py @@ -1,8 +1,10 @@ -from .block import Block -from .state import State -from .pow import calculate_hash import logging import threading +import time + +from .block import Block +from .pow import calculate_hash +from .state import State logger = logging.getLogger(__name__) @@ -14,9 +16,7 @@ def validate_block_link_and_hash(previous_block, block): ) if block.index != previous_block.index + 1: - raise ValueError( - f"invalid index {block.index} != {previous_block.index + 1}" - ) + raise ValueError(f"invalid index {block.index} != {previous_block.index + 1}") expected_hash = calculate_hash(block.to_header_dict()) if block.hash != expected_hash: @@ -38,11 +38,7 @@ def _create_genesis_block(self): """ Creates the genesis block with a fixed hash. """ - genesis_block = Block( - index=0, - previous_hash="0", - transactions=[] - ) + genesis_block = Block(index=0, previous_hash="0", transactions=[]) genesis_block.hash = "0" * 64 self.chain.append(genesis_block) @@ -51,7 +47,7 @@ def last_block(self): """ Returns the most recent block in the chain. """ - with self._lock: # Acquire lock for thread-safe access + with self._lock: return self.chain[-1] def add_block(self, block): @@ -67,18 +63,42 @@ def add_block(self, block): logger.warning("Block %s rejected: %s", block.index, exc) return False - # Validate transactions on a temporary state copy + previous_block = self.last_block + + # Timestamp Validation + + if block.timestamp < previous_block.timestamp: + logger.warning( + "Block %s rejected: timestamp older than previous block", + block.index, + ) + return False + + current_time = int(time.time() * 1000) + + if block.timestamp > current_time + 60000: + logger.warning( + "Block %s rejected: timestamp too far in future", + block.index, + ) + return False + + # Transaction Validation + 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) + logger.warning( + "Block %s rejected: Transaction failed validation", + block.index, + ) return False - # All transactions valid → commit state and append block + # Commit state self.state = temp_state self.chain.append(block) + return True diff --git a/minichain/contract.py b/minichain/contract.py index c88a20f..19c9f09 100644 --- a/minichain/contract.py +++ b/minichain/contract.py @@ -1,10 +1,11 @@ +import ast +import json # Moved to module-level import 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. @@ -13,11 +14,18 @@ def _safe_exec_worker(code, globals_dict, context_dict, result_queue): # 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)) + 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.") + logger.warning( + "Resource module not available. Contract will run without OS-level resource limits." + ) except (OSError, ValueError) as e: logger.warning("Failed to set resource limits: %s", e) @@ -27,6 +35,7 @@ def _safe_exec_worker(code, globals_dict, context_dict, result_queue): except Exception as e: result_queue.put({"status": "error", "error": str(e)}) + class ContractMachine: """ A minimal execution environment for Python-based smart contracts. @@ -67,19 +76,17 @@ def execute(self, contract_address, sender_address, payload, amount): "min": min, "max": max, "abs": abs, - "str": str, # Keeping str for basic functionality, relying on AST checks for safety + "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 + "Exception": Exception, # Added to allow contracts to raise exceptions } - globals_for_exec = { - "__builtins__": safe_builtins - } + globals_for_exec = {"__builtins__": safe_builtins} # Execution context (locals) context = { @@ -96,8 +103,7 @@ def execute(self, contract_address, sender_address, payload, amount): # Execute in a subprocess with timeout queue = multiprocessing.Queue() p = multiprocessing.Process( - target=_safe_exec_worker, - args=(code, globals_for_exec, context, queue) + target=_safe_exec_worker, args=(code, globals_for_exec, context, queue) ) p.start() p.join(timeout=2) # 2 second timeout @@ -125,10 +131,7 @@ def execute(self, contract_address, sender_address, payload, amount): return False # Commit updated storage only after successful execution - self.state.update_contract_storage( - contract_address, - result["storage"] - ) + self.state.update_contract_storage(contract_address, result["storage"]) return True @@ -142,26 +145,36 @@ def _validate_code_ast(self, code): 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.") + 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.") + 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': + 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"}: + 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.") + logger.warning( + "Rejected string literal with double-underscore." + ) return False - if isinstance(node, ast.JoinedStr): # f-strings + if isinstance(node, ast.JoinedStr): # f-strings logger.warning("Rejected f-string usage.") return False return True diff --git a/minichain/mempool.py b/minichain/mempool.py index 4b71e08..3272465 100644 --- a/minichain/mempool.py +++ b/minichain/mempool.py @@ -3,6 +3,7 @@ logger = logging.getLogger(__name__) + class Mempool: def __init__(self, max_size=1000, transactions_per_block=100): self._pool = {} @@ -21,14 +22,16 @@ def add_transaction(self, tx): if existing: if existing.tx_id == tx.tx_id: - logger.warning("Mempool: Duplicate transaction rejected %s", tx.tx_id) + logger.warning( + "Mempool: Duplicate transaction rejected %s", tx.tx_id + ) return False # Fix: Guard against older replacements (e.g. rejected block restore) # Only allow overwrite if it's a genuinely newer replacement if tx.timestamp <= existing.timestamp: logger.warning("Mempool: Ignoring older replacement %s", tx.tx_id) return False - + else: if self._size >= self.max_size: logger.warning("Mempool: Full, rejecting transaction") @@ -48,16 +51,20 @@ def get_transactions_for_block(self): while len(selected) < self.transactions_per_block: best_tx = None best_sender = None - + 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): + if best_tx is None or (txs[0].timestamp, sender, txs[0].nonce) < ( + best_tx.timestamp, + best_sender, + best_tx.nonce, + ): best_tx = txs[0] best_sender = sender - + if not best_tx: break - + selected.append(best_tx) snapshot[best_sender].pop(0) diff --git a/minichain/p2p.py b/minichain/p2p.py index 3271598..3a0d12a 100644 --- a/minichain/p2p.py +++ b/minichain/p2p.py @@ -58,9 +58,7 @@ async def _notify_peer_connected(self, writer, error_message): async def start(self, port: int = 9000, host: str = "127.0.0.1"): """Start listening for incoming peer connections on the given port.""" self._port = port - self._server = await asyncio.start_server( - self._handle_incoming, host, port - ) + self._server = await asyncio.start_server(self._handle_incoming, host, port) logger.info("Network: Listening on %s:%d", host, port) async def stop(self): @@ -92,7 +90,9 @@ async def connect_to_peer(self, host: str, port: int) -> bool: self._listen_to_peer(reader, writer, f"{host}:{port}") ) self._listen_tasks.append(task) - await self._notify_peer_connected(writer, "Network: Error during outbound peer sync") + await self._notify_peer_connected( + writer, "Network: Error during outbound peer sync" + ) logger.info("Network: Connected to peer %s:%d", host, port) return True except Exception as exc: diff --git a/minichain/persistence.py b/minichain/persistence.py index b49f307..86ad759 100644 --- a/minichain/persistence.py +++ b/minichain/persistence.py @@ -15,11 +15,11 @@ blockchain = load(path="data/") """ +import copy import json +import logging import os import tempfile -import logging -import copy from .block import Block from .chain import Blockchain, validate_block_link_and_hash @@ -33,6 +33,7 @@ # Public API # --------------------------------------------------------------------------- + def save(blockchain: Blockchain, path: str = ".") -> None: """ Persist the blockchain and account state to a JSON file inside *path*. @@ -47,10 +48,7 @@ def save(blockchain: Blockchain, path: str = ".") -> None: chain_data = [block.to_dict() for block in blockchain.chain] state_data = copy.deepcopy(blockchain.state.accounts) - snapshot = { - "chain": chain_data, - "state": state_data - } + snapshot = {"chain": chain_data, "state": state_data} _atomic_write_json(os.path.join(path, _DATA_FILE), snapshot) @@ -95,8 +93,8 @@ def load(path: str = ".") -> Blockchain: _verify_chain_integrity(blocks) # --- Rebuild blockchain properly (no __new__ hack) --- - blockchain = Blockchain() # creates genesis + fresh state - blockchain.chain = blocks # replace with loaded chain + blockchain = Blockchain() # creates genesis + fresh state + blockchain.chain = blocks # replace with loaded chain # Restore state blockchain.state.accounts = raw_accounts @@ -114,6 +112,7 @@ def load(path: str = ".") -> Blockchain: # Integrity verification # --------------------------------------------------------------------------- + def _verify_chain_integrity(blocks: list) -> None: """Verify genesis, hash linkage, and block hashes.""" # Check genesis @@ -135,6 +134,7 @@ def _verify_chain_integrity(blocks: list) -> None: # Helpers # --------------------------------------------------------------------------- + def _atomic_write_json(filepath: str, data) -> None: """Write JSON atomically with fsync for durability.""" dir_name = os.path.dirname(filepath) or "." @@ -144,7 +144,7 @@ def _atomic_write_json(filepath: str, data) -> None: json.dump(data, f, indent=2) f.flush() os.fsync(f.fileno()) # Ensure data is on disk - os.replace(tmp_path, filepath) # Atomic rename + os.replace(tmp_path, filepath) # Atomic rename # Attempt to fsync the directory so the rename is durable if hasattr(os, "O_DIRECTORY"): diff --git a/minichain/pow.py b/minichain/pow.py index 40503a5..8591715 100644 --- a/minichain/pow.py +++ b/minichain/pow.py @@ -1,4 +1,5 @@ import time + from .serialization import canonical_json_hash @@ -17,7 +18,7 @@ def mine_block( max_nonce=10_000_000, timeout_seconds=None, logger=None, - progress_callback=None + progress_callback=None, ): """Mines a block using Proof-of-Work without mutating input block until success.""" @@ -26,7 +27,7 @@ def mine_block( target = "0" * difficulty local_nonce = 0 - header_dict = block.to_header_dict() # Construct header dict once outside loop + header_dict = block.to_header_dict() # Construct header dict once outside loop start_time = time.monotonic() if logger: @@ -45,7 +46,10 @@ def mine_block( 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 ( + 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") diff --git a/minichain/serialization.py b/minichain/serialization.py index 46741f0..4f8a5d0 100644 --- a/minichain/serialization.py +++ b/minichain/serialization.py @@ -4,7 +4,9 @@ def canonical_json_dumps(payload) -> str: """Serialize payloads deterministically for signing and hashing.""" - return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return json.dumps( + payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False + ) def canonical_json_bytes(payload) -> bytes: diff --git a/minichain/state.py b/minichain/state.py index ce9a6f0..adab04c 100644 --- a/minichain/state.py +++ b/minichain/state.py @@ -1,9 +1,11 @@ -from nacl.hash import sha256 -from nacl.encoding import HexEncoder -from .contract import ContractMachine import copy import logging +from nacl.encoding import HexEncoder +from nacl.hash import sha256 + +from .contract import ContractMachine + logger = logging.getLogger(__name__) @@ -18,10 +20,10 @@ def __init__(self): def get_account(self, address): if address not in self.accounts: self.accounts[address] = { - 'balance': 0, - 'nonce': 0, - 'code': None, - 'storage': {} + "balance": 0, + "nonce": 0, + "code": None, + "storage": {}, } return self.accounts[address] @@ -32,12 +34,14 @@ def verify_transaction_logic(self, tx): sender_acc = self.get_account(tx.sender) - if sender_acc['balance'] < tx.amount: + 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}") + if sender_acc["nonce"] != tx.nonce: + logger.error( + f"Error: Invalid nonce. Expected {sender_acc['nonce']}, got {tx.nonce}" + ) return False return True @@ -47,7 +51,9 @@ 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 + new_state.contract_machine = ContractMachine( + new_state + ) # Reinitialize contract_machine return new_state def validate_and_apply(self, tx): @@ -77,8 +83,8 @@ def apply_transaction(self, tx): sender = self.accounts[tx.sender] # Deduct funds and increment nonce - sender['balance'] -= tx.amount - sender['nonce'] += 1 + sender["balance"] -= tx.amount + sender["nonce"] += 1 # LOGIC BRANCH 1: Contract Deployment if tx.receiver is None or tx.receiver == "": @@ -88,11 +94,13 @@ def apply_transaction(self, tx): existing = self.accounts.get(contract_address) if existing and existing.get("code"): # Restore sender state on failure - sender['balance'] += tx.amount - sender['nonce'] -= 1 + sender["balance"] += tx.amount + sender["nonce"] -= 1 return False - return self.create_contract(contract_address, tx.data, initial_balance=tx.amount) + 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 @@ -102,32 +110,32 @@ 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 - sender['balance'] += tx.amount # Refund amount - sender['nonce'] -= 1 + sender["balance"] += tx.amount # Refund amount + sender["nonce"] -= 1 return False # Credit contract balance - receiver['balance'] += tx.amount + receiver["balance"] += tx.amount success = self.contract_machine.execute( - contract_address=tx.receiver, # Pass receiver as contract_address + contract_address=tx.receiver, # Pass receiver as contract_address sender_address=tx.sender, payload=tx.data, - amount=tx.amount + 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 + 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 + receiver["balance"] += tx.amount return True def derive_contract_address(self, sender, nonce): @@ -136,16 +144,16 @@ def derive_contract_address(self, sender, nonce): def create_contract(self, contract_address, code, initial_balance=0): self.accounts[contract_address] = { - 'balance': initial_balance, - 'nonce': 0, - 'code': code, - 'storage': {} + "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 + self.accounts[address]["storage"] = new_storage else: raise KeyError(f"Contract address not found: {address}") @@ -153,11 +161,11 @@ 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) + 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 + account["balance"] += reward diff --git a/minichain/transaction.py b/minichain/transaction.py index 27f41f3..ecd04a9 100644 --- a/minichain/transaction.py +++ b/minichain/transaction.py @@ -1,23 +1,27 @@ import time -from nacl.signing import SigningKey, VerifyKey + from nacl.encoding import HexEncoder from nacl.exceptions import BadSignatureError, CryptoError +from nacl.signing import SigningKey, VerifyKey + from .serialization import canonical_json_bytes, canonical_json_hash 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 + 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.data = data # Preserve None (do NOT normalize to "") if timestamp is None: self.timestamp = round(time.time() * 1000) # New tx: seconds → ms elif timestamp > 1e12: - self.timestamp = int(timestamp) # Already in ms (from network) + self.timestamp = int(timestamp) # Already in ms (from network) else: - self.timestamp = round(timestamp * 1000) # Seconds → ms + self.timestamp = round(timestamp * 1000) # Seconds → ms self.signature = signature # Hex str def to_dict(self): diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a52dbf2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "MiniChain", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/reproduce_timestamp_bug.py b/reproduce_timestamp_bug.py new file mode 100644 index 0000000..2170cd8 --- /dev/null +++ b/reproduce_timestamp_bug.py @@ -0,0 +1,67 @@ +from minichain.block import Block +from minichain.chain import Blockchain + +print("\nInitializing Blockchain...") + +blockchain = Blockchain() + +genesis = blockchain.last_block + +print("\nGenesis Block Created") +print("Genesis Index:", genesis.index) +print("Genesis Timestamp:", genesis.timestamp) +print("Genesis Hash:", genesis.hash) + +print("\n--- Current Blockchain ---") +for block in blockchain.chain: + print(f"Index: {block.index}, Timestamp: {block.timestamp}, Hash: {block.hash}") +print("--------------------------") + +# ------------------------- +# PAST TIMESTAMP BLOCK +# ------------------------- + +print("\nCreating malicious block with PAST timestamp...") + +past_block = Block(index=1, previous_hash=genesis.hash, transactions=[], timestamp=0) + +past_block.hash = past_block.compute_hash() + +result1 = blockchain.add_block(past_block) + +print("\nPast Block Added:", result1) +print("Past Block Timestamp:", past_block.timestamp) + +print("\n--- Current Blockchain ---") +for block in blockchain.chain: + print(f"Index: {block.index}, Timestamp: {block.timestamp}, Hash: {block.hash}") +print("--------------------------") + +# ------------------------- +# FUTURE TIMESTAMP BLOCK +# ------------------------- + +print("\nCreating malicious block with FUTURE timestamp...") + +future_block = Block( + index=2, previous_hash=past_block.hash, transactions=[], timestamp=9999999999999 +) + +future_block.hash = future_block.compute_hash() + +result2 = blockchain.add_block(future_block) + +print("\nFuture Block Added:", result2) +print("Future Block Timestamp:", future_block.timestamp) + +print("\n--- Current Blockchain ---") +for block in blockchain.chain: + print(f"Index: {block.index}, Timestamp: {block.timestamp}, Hash: {block.hash}") +print("--------------------------") + +print("\nFinal Blockchain Length:", len(blockchain.chain)) + +if result1 and result2: + print("\nVULNERABILITY CONFIRMED") + print("Blockchain accepts miner-controlled timestamps") + print("No timestamp validation in chain.py") diff --git a/setup.py b/setup.py index 1edff7b..afce5f7 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ -from setuptools import setup, find_packages +from setuptools import find_packages, setup setup( name="minichain", @@ -7,7 +7,7 @@ py_modules=["main"], install_requires=[ "PyNaCl>=1.5.0", - "libp2p>=0.5.0", # Correct PyPI package name + "libp2p>=0.5.0", # Correct PyPI package name ], entry_points={ "console_scripts": [ diff --git a/tests/test_contract.py b/tests/test_contract.py index 2ac6e9f..3766e95 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -1,10 +1,11 @@ -import unittest -import sys import os +import sys +import unittest -from minichain import State, Transaction -from nacl.signing import SigningKey from nacl.encoding import HexEncoder +from nacl.signing import SigningKey + +from minichain import State, Transaction class TestSmartContract(unittest.TestCase): @@ -125,7 +126,7 @@ def test_balance_and_nonce_updates(self): tx_deploy.sign(self.sk) # Corrected typo: contract_add_ to contract_addr - contract_addr = self.state.apply_transaction(tx_deploy) + contract_addr = self.state.apply_transaction(tx_deploy) self.assertTrue(isinstance(contract_addr, str)) # Verify balance and nonce after deploy diff --git a/tests/test_core.py b/tests/test_core.py index 0a818ed..31b69f0 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1,18 +1,20 @@ import unittest -from nacl.signing import SigningKey + from nacl.encoding import HexEncoder +from nacl.signing import SigningKey + +from minichain import Blockchain, State, Transaction # Removed unused imports -from minichain 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() @@ -37,39 +39,39 @@ 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) + 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) + + 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 + 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 +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_persistence.py b/tests/test_persistence.py index e758227..a66ea21 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -8,11 +8,11 @@ import tempfile import unittest -from nacl.signing import SigningKey from nacl.encoding import HexEncoder +from nacl.signing import SigningKey -from minichain import Blockchain, Transaction, Block, mine_block -from minichain.persistence import save, load +from minichain import Block, Blockchain, Transaction, mine_block +from minichain.persistence import load, save def _make_keypair(): diff --git a/tests/test_protocol_hardening.py b/tests/test_protocol_hardening.py index 6b169e7..ea5ed4a 100644 --- a/tests/test_protocol_hardening.py +++ b/tests/test_protocol_hardening.py @@ -3,7 +3,8 @@ from nacl.encoding import HexEncoder from nacl.signing import SigningKey -from minichain import Block, Mempool, P2PNetwork, State, Transaction, calculate_hash +from minichain import (Block, Mempool, P2PNetwork, State, Transaction, + calculate_hash) from minichain.serialization import canonical_json_dumps @@ -16,7 +17,9 @@ def test_canonical_json_is_order_independent(self): self.assertEqual(calculate_hash(left), calculate_hash(right)) def test_block_hash_matches_compute_hash(self): - block = Block(index=1, previous_hash="abc", transactions=[], timestamp=1234567890) + block = Block( + index=1, previous_hash="abc", transactions=[], timestamp=1234567890 + ) block.difficulty = 2 block.nonce = 7 @@ -28,7 +31,9 @@ def setUp(self): self.state = State() self.sender_sk = SigningKey.generate() self.sender_pk = self.sender_sk.verify_key.encode(encoder=HexEncoder).decode() - self.receiver_pk = SigningKey.generate().verify_key.encode(encoder=HexEncoder).decode() + self.receiver_pk = ( + SigningKey.generate().verify_key.encode(encoder=HexEncoder).decode() + ) self.state.credit_mining_reward(self.sender_pk, 100) def _signed_tx(self, nonce, amount=1, timestamp=None) -> Transaction: @@ -45,7 +50,9 @@ def _signed_tx(self, nonce, amount=1, timestamp=None) -> Transaction: def test_transactions_for_block_are_sorted_and_capped(self): mempool = Mempool() for nonce in range(mempool.transactions_per_block + 5): - self.assertTrue(mempool.add_transaction(self._signed_tx(nonce, timestamp=5000 + nonce))) + self.assertTrue( + mempool.add_transaction(self._signed_tx(nonce, timestamp=5000 + nonce)) + ) selected = mempool.get_transactions_for_block() @@ -103,12 +110,20 @@ async def test_invalid_message_schema_is_rejected(self): async def test_block_schema_accepts_current_block_wire_format(self): sender_sk = SigningKey.generate() sender_pk = sender_sk.verify_key.encode(encoder=HexEncoder).decode() - receiver_pk = SigningKey.generate().verify_key.encode(encoder=HexEncoder).decode() + receiver_pk = ( + SigningKey.generate().verify_key.encode(encoder=HexEncoder).decode() + ) 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) + block = Block( + index=1, + previous_hash="0" * 64, + transactions=[tx], + timestamp=456, + difficulty=2, + ) block.nonce = 9 block.hash = block.compute_hash() diff --git a/tests/test_transaction_signing.py b/tests/test_transaction_signing.py index 05b79d0..4e74de8 100644 --- a/tests/test_transaction_signing.py +++ b/tests/test_transaction_signing.py @@ -13,16 +13,16 @@ """ import pytest -from nacl.signing import SigningKey from nacl.encoding import HexEncoder +from nacl.signing import SigningKey -from minichain import Transaction, State - +from minichain import State, Transaction # ------------------------------------------------------------------ # Fixtures # ------------------------------------------------------------------ + @pytest.fixture def alice(): sk = SigningKey.generate() @@ -49,6 +49,7 @@ def funded_state(alice): # 1. Valid transaction # ------------------------------------------------------------------ + def test_valid_signature_verifies(alice, bob): """A properly signed transaction must pass signature verification.""" alice_sk, alice_pk = alice @@ -64,6 +65,7 @@ def test_valid_signature_verifies(alice, bob): # 2. Modified transaction data # ------------------------------------------------------------------ + def test_tampered_amount_fails_verification(alice, bob): """Changing `amount` after signing must invalidate the signature.""" alice_sk, alice_pk = alice @@ -106,6 +108,7 @@ def test_tampered_nonce_fails_verification(alice, bob): # 3. Invalid public key # ------------------------------------------------------------------ + def test_wrong_sender_key_raises(alice, bob): """Signing with a key that doesn't match sender must raise ValueError.""" _, alice_pk = alice @@ -144,6 +147,7 @@ def test_unsigned_transaction_fails_verification(alice, bob): # 4. Replay protection # ------------------------------------------------------------------ + def test_replay_attack_same_nonce_rejected(alice, bob, funded_state): """Replaying the same transaction must be rejected the second time.""" alice_sk, alice_pk = alice @@ -153,12 +157,16 @@ def test_replay_attack_same_nonce_rejected(alice, bob, funded_state): tx.sign(alice_sk) assert funded_state.apply_transaction(tx), "First submission must succeed." - assert not funded_state.apply_transaction(tx), "Replayed transaction must be rejected." + assert not funded_state.apply_transaction( + tx + ), "Replayed transaction must be rejected." # Ensure the rejected replay did not mutate the ledger - assert funded_state.get_account(alice_pk)["balance"] == 90, \ - "Alice's balance must not change after a rejected replay." - assert funded_state.get_account(alice_pk)["nonce"] == 1, \ - "Alice's nonce must not advance after a rejected replay." + assert ( + funded_state.get_account(alice_pk)["balance"] == 90 + ), "Alice's balance must not change after a rejected replay." + assert ( + funded_state.get_account(alice_pk)["nonce"] == 1 + ), "Alice's nonce must not advance after a rejected replay." def test_out_of_order_nonce_rejected(alice, bob, funded_state): @@ -169,12 +177,16 @@ def test_out_of_order_nonce_rejected(alice, bob, funded_state): tx = Transaction(alice_pk, bob_pk, 10, nonce=5) tx.sign(alice_sk) - assert not funded_state.apply_transaction(tx), "A transaction with a skipped nonce must be rejected." + assert not funded_state.apply_transaction( + tx + ), "A transaction with a skipped nonce must be rejected." # Ensure the rejected transaction did not mutate the ledger - assert funded_state.get_account(alice_pk)["balance"] == 100, \ - "Alice's balance must remain unchanged after a rejected transaction." - assert funded_state.get_account(alice_pk)["nonce"] == 0, \ - "Alice's nonce must remain unchanged after a rejected transaction." + assert ( + funded_state.get_account(alice_pk)["balance"] == 100 + ), "Alice's balance must remain unchanged after a rejected transaction." + assert ( + funded_state.get_account(alice_pk)["nonce"] == 0 + ), "Alice's nonce must remain unchanged after a rejected transaction." def test_sequential_nonces_accepted(alice, bob, funded_state): @@ -190,9 +202,12 @@ def test_sequential_nonces_accepted(alice, bob, funded_state): tx1.sign(alice_sk) assert funded_state.apply_transaction(tx1) - assert funded_state.get_account(alice_pk)["nonce"] == 2, \ - "Alice's nonce should advance to 2 after two accepted transactions." - assert funded_state.get_account(alice_pk)["balance"] == 80, \ - "Alice's balance should be 80 after two 10-coin transfers." - assert funded_state.get_account(bob_pk)["balance"] == 20, \ - "Bob's balance should be 20 after receiving two transfers." \ No newline at end of file + assert ( + funded_state.get_account(alice_pk)["nonce"] == 2 + ), "Alice's nonce should advance to 2 after two accepted transactions." + assert ( + funded_state.get_account(alice_pk)["balance"] == 80 + ), "Alice's balance should be 80 after two 10-coin transfers." + assert ( + funded_state.get_account(bob_pk)["balance"] == 20 + ), "Bob's balance should be 20 after receiving two transfers."