diff --git a/main.py b/main.py index b81ca95..fe1c1ce 100644 --- a/main.py +++ b/main.py @@ -256,6 +256,9 @@ async def handler(data): ║ connect : - connect to a peer ║ ║ address - show your public key ║ ║ chain - show chain summary ║ +║ list-banned - show banned peers ║ +║ ban - ban a peer ║ +║ unban - unban a peer ║ ║ help - show this help ║ ║ quit - shut down ║ ╚════════════════════════════════════════════════╝ @@ -420,6 +423,37 @@ async def cli_loop(sk, pk, chain, mempool, network): tx_count = len(b.transactions) if b.transactions else 0 print(f" Block #{b.index} hash={b.hash[:16]}... txs={tx_count}") + # ── list-banned ── + elif cmd == "list-banned": + from minichain.persistence import get_banned_peers + banned = get_banned_peers() + if not banned: + print(" No peers are currently banned.") + else: + print(f" {len(banned)} banned peer(s):") + for p in banned: + print(f" - {p['peer_id']} (Reason: {p['reason']}, Time: {p['timestamp']})") + + # ── ban ── + elif cmd == "ban": + if len(parts) < 2: + print(" Usage: ban ") + continue + peer_id = parts[1] + from minichain.persistence import ban_peer + ban_peer(peer_id, reason="Manual ban via CLI") + print(f" ✅ Peer {peer_id} banned.") + + # ── unban ── + elif cmd == "unban": + if len(parts) < 2: + print(" Usage: unban ") + continue + peer_id = parts[1] + from minichain.persistence import unban_peer + unban_peer(peer_id) + print(f" ✅ Peer {peer_id} unbanned.") + # ── help ── elif cmd == "help": print(HELP_TEXT) diff --git a/minichain/chain.py b/minichain/chain.py index 5c31972..1ed9b84 100644 --- a/minichain/chain.py +++ b/minichain/chain.py @@ -125,17 +125,18 @@ 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. """ + from .validators import ValidationStatus with self._lock: try: validate_block_link_and_hash(self.last_block, block) except ValueError as exc: logger.warning("Block %s rejected: %s", block.index, exc) - return False + return ValidationStatus.INVALID if "hash" in str(exc) else ValidationStatus.FAILED if block.difficulty != self.current_difficulty: logger.warning("Block %s rejected: Invalid difficulty. Expected %s, got %s", block.index, self.current_difficulty, block.difficulty) - return False + return ValidationStatus.INVALID # Validate transactions on a temporary state copy temp_state = self.state.copy() @@ -143,12 +144,12 @@ def add_block(self, block): receipts = [] for tx in block.transactions: - receipt = temp_state.validate_and_apply(tx) + status, receipt = temp_state.validate_and_apply_with_status(tx) - # Reject block if any transaction fails mathematical validation (None) - if receipt is None: + # Reject block if any transaction fails mathematical validation + if status != ValidationStatus.VALID: logger.warning("Block %s rejected: Transaction failed validation", block.index) - return False + return status receipts.append(receipt) @@ -159,16 +160,16 @@ def add_block(self, block): computed_receipt_root = calculate_receipt_root(receipts) if block.receipt_root != computed_receipt_root: logger.warning("Block %s rejected: Invalid receipt root. Expected %s, got %s", block.index, computed_receipt_root, block.receipt_root) - return False + return ValidationStatus.INVALID if [r.to_dict() for r in block.receipts] != [r.to_dict() for r in receipts]: logger.warning("Block %s rejected: Receipts payload mismatch", block.index) - return False + return ValidationStatus.INVALID # Verify state root if block.state_root != temp_state.state_root(): logger.warning("Block %s rejected: Invalid state root. Expected %s, got %s", block.index, temp_state.state_root(), block.state_root) - return False + return ValidationStatus.INVALID # Update EMA difficulty state time_diff = block.timestamp - self.last_block.timestamp @@ -182,7 +183,7 @@ def add_block(self, block): # All transactions valid → commit state and append block self.state = temp_state self.chain.append(block) - return True + return ValidationStatus.VALID def resolve_conflicts(self, new_chain_list) -> tuple[bool, list]: """ @@ -236,8 +237,9 @@ def resolve_conflicts(self, new_chain_list) -> tuple[bool, list]: receipts = [] for tx in block.transactions: - receipt = temp_state.validate_and_apply(tx) - if receipt is None: + from .validators import ValidationStatus + status, receipt = temp_state.validate_and_apply_with_status(tx) + if status != ValidationStatus.VALID: logger.warning("Reorg failed: Transaction validation failed in block %s", block.index) return False, [] receipts.append(receipt) diff --git a/minichain/persistence.py b/minichain/persistence.py index d142879..8de4148 100644 --- a/minichain/persistence.py +++ b/minichain/persistence.py @@ -257,6 +257,68 @@ def _load_snapshot_from_sqlite(db_path: str) -> dict[str, Any]: return {"chain": chain, "state": state} +# --------------------------------------------------------------------------- +# Banned Peers (Track 1) +# --------------------------------------------------------------------------- + +import time + +def _ensure_banned_peers_table(conn: sqlite3.Connection) -> None: + conn.execute( + "CREATE TABLE IF NOT EXISTS banned_peers (peer_id TEXT PRIMARY KEY, reason TEXT, timestamp REAL)" + ) + +def ban_peer(peer_id: str, reason: str, path: str = ".") -> None: + db_path = os.path.join(path, _DB_FILE) + os.makedirs(path, exist_ok=True) + conn = _connect(db_path) + try: + _ensure_banned_peers_table(conn) + with conn: + conn.execute( + "INSERT OR REPLACE INTO banned_peers (peer_id, reason, timestamp) VALUES (?, ?, ?)", + (peer_id, reason, time.time()) + ) + finally: + conn.close() + +def unban_peer(peer_id: str, path: str = ".") -> None: + db_path = os.path.join(path, _DB_FILE) + if not os.path.exists(db_path): + return + conn = _connect(db_path) + try: + _ensure_banned_peers_table(conn) + with conn: + conn.execute("DELETE FROM banned_peers WHERE peer_id = ?", (peer_id,)) + finally: + conn.close() + +def is_peer_banned(peer_id: str, path: str = ".") -> bool: + db_path = os.path.join(path, _DB_FILE) + if not os.path.exists(db_path): + return False + conn = _connect(db_path) + try: + _ensure_banned_peers_table(conn) + row = conn.execute("SELECT peer_id FROM banned_peers WHERE peer_id = ?", (peer_id,)).fetchone() + return row is not None + finally: + conn.close() + +def get_banned_peers(path: str = ".") -> list[dict[str, Any]]: + db_path = os.path.join(path, _DB_FILE) + if not os.path.exists(db_path): + return [] + conn = _connect(db_path) + try: + _ensure_banned_peers_table(conn) + rows = conn.execute("SELECT peer_id, reason, timestamp FROM banned_peers ORDER BY timestamp DESC").fetchall() + return [{"peer_id": r["peer_id"], "reason": r["reason"], "timestamp": r["timestamp"]} for r in rows] + finally: + conn.close() + + # --------------------------------------------------------------------------- # Legacy JSON helpers # --------------------------------------------------------------------------- diff --git a/minichain/state.py b/minichain/state.py index 13c7c02..413fec5 100644 --- a/minichain/state.py +++ b/minichain/state.py @@ -43,26 +43,27 @@ def get_account(self, address): return self.accounts[address] def verify_transaction_logic(self, tx): + from .validators import ValidationStatus if not tx.verify(): logger.error("Error: Invalid signature for tx from %s...", tx.sender[:8]) - return False + return ValidationStatus.INVALID if getattr(tx, "chain_id", None) != self.chain_id: logger.error("Error: Invalid chain_id in tx from %s...", tx.sender[:8]) - return False + return ValidationStatus.INVALID sender_acc = self.get_account(tx.sender) total_cost = tx.amount + getattr(tx, 'fee', 0) if sender_acc['balance'] < total_cost: logger.warning("Invalid tx %s: insufficient balance", tx.tx_id) - return False + return ValidationStatus.FAILED if sender_acc['nonce'] != tx.nonce: logger.error("Error: Invalid nonce. Expected %s, got %s", sender_acc['nonce'], tx.nonce) - return False + return ValidationStatus.FAILED - return True + return ValidationStatus.VALID def copy(self): """ @@ -88,22 +89,37 @@ def restore(self, snapshot_data): 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. + Returns: Receipt|None """ # Semantic validation: amount must be an integer and non-negative if not isinstance(tx.amount, int) or tx.amount < 0: return None - # Further checks can be added here return self.apply_transaction(tx) + def validate_and_apply_with_status(self, tx): + """ + Validate and apply a transaction, bubbling up the precise ValidationStatus. + Returns: (ValidationStatus, Receipt|None) + """ + from .validators import ValidationStatus + if not isinstance(tx.amount, int) or tx.amount < 0: + return ValidationStatus.MALFORMED, None + + status = self.verify_transaction_logic(tx) + if status != ValidationStatus.VALID: + return status, None + + # We know it's valid, so apply_transaction will succeed and return a Receipt + return ValidationStatus.VALID, self.apply_transaction(tx) + def apply_transaction(self, tx): """ Applies transaction and mutates state. Returns: Receipt object if mathematically valid, None if invalid. """ - if not self.verify_transaction_logic(tx): + from .validators import ValidationStatus + status = self.verify_transaction_logic(tx) + if status != ValidationStatus.VALID: return None sender = self.accounts[tx.sender] diff --git a/minichain/validators.py b/minichain/validators.py index b813df4..3994e3a 100644 --- a/minichain/validators.py +++ b/minichain/validators.py @@ -1,5 +1,14 @@ import re +from enum import Enum, auto + + +class ValidationStatus(Enum): + VALID = auto() + INVALID = auto() + FAILED = auto() + MALFORMED = auto() def is_valid_receiver(receiver): return bool(re.fullmatch(r"[0-9a-fA-F]{40}|[0-9a-fA-F]{64}", receiver)) +