Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions genesis.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
"chain_id": "minichain-default",
"timestamp": 1716880000000,
"difficulty": 4,
"target_block_time": 10000,
"alpha": 0.1,
"alloc": {
"0000000000000000000000000000000000000001": {
"balance": 1000000000
Expand Down
35 changes: 35 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ def mine_and_process_block(chain, mempool, miner_pk):
receipt_root=calculate_receipt_root(receipts),
receipts=receipts,
miner=miner_pk,
difficulty=chain.current_difficulty,
)

mined_block = mine_block(block)
Expand Down Expand Up @@ -255,6 +256,9 @@ async def handler(data):
║ connect <host>:<port> - connect to a peer ║
║ address - show your public key ║
║ chain - show chain summary ║
║ list-banned - show banned peers ║
║ ban <peer_id> - ban a peer ║
║ unban <peer_id> - unban a peer ║
║ help - show this help ║
║ quit - shut down ║
╚════════════════════════════════════════════════╝
Expand Down Expand Up @@ -419,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 <peer_id>")
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 <peer_id>")
continue
peer_id = parts[1]
from minichain.persistence import unban_peer
unban_peer(peer_id)
print(f" ✅ Peer {peer_id} unbanned.")
Comment on lines +426 to +455

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm banned-peer checks are wired into the networking layer
rg -nP -C3 '\bis_peer_banned\s*\(' --type=py

Repository: StabilityNexus/MiniChain

Length of output: 162


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== relevant files =="
git ls-files | rg '(^|/)(main\.py|network\.py|.*persistence.*\.py|.*peer.*\.py)$'

echo
echo "== search for ban-related symbols =="
rg -n -C 3 '\b(ban_peer|unban_peer|get_banned_peers|is_peer_banned|banned peer|banned_peers|ban)\b' --type=py .

echo
echo "== inspect likely networking files =="
for f in $(git ls-files | rg '(^|/)network\.py$|(^|/)persistence\.py$|(^|/)main\.py$'); do
  echo "--- $f ($(wc -l < "$f") lines) ---"
  sed -n '1,260p' "$f" | cat -n
done

Repository: StabilityNexus/MiniChain

Length of output: 28629


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== all ban/network references =="
rg -n -C 3 '\b(is_peer_banned|banned_peers|ban_peer|unban_peer|get_banned_peers|disconnect_peer|connect_peer|_broadcast_raw|_unicast_raw|hello|chain_request|chain_response|tx|block)\b' . --type=py

echo
echo "== P2PNetwork definition(s) =="
rg -n -C 4 '\bclass\s+P2PNetwork\b|\bdef\s+(connect_peer|disconnect_peer|add_peer|remove_peer|handle_message|handle_connection|start|stop|listen)\b' . --type=py

echo
echo "== exported symbols around minichain package =="
git ls-files minichain | sort

Repository: StabilityNexus/MiniChain

Length of output: 50381


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== minichain/p2p.py (focused ranges) =="
cat -n minichain/p2p.py | sed -n '1,220p'

echo
echo "== exact ban symbol usage =="
rg -n -C 2 '\bis_peer_banned\b|\bban_peer\b|\bunban_peer\b|\bget_banned_peers\b' minichain main.py tests --type=py

Repository: StabilityNexus/MiniChain

Length of output: 12428


Wire banned-peer checks into the P2P path. ban_peer() only persists to SQLite; minichain/p2p.py never calls is_peer_banned(), so a banned peer can remain connected and reconnect normally.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@main.py` around lines 426 - 455, Banned peers are only being recorded in
persistence, but the P2P connection flow still ignores that state. Update the
relevant logic in minichain/p2p.py to consult is_peer_banned() before accepting
or maintaining a connection, and ensure any banned peer is rejected/disconnected
there. Use the existing ban_peer/unban_peer helpers and the P2P
connection-handling code paths so the ban status is enforced at runtime, not
just stored in SQLite.


# ── help ──
elif cmd == "help":
print(HELP_TEXT)
Expand Down
59 changes: 48 additions & 11 deletions minichain/chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ def _create_genesis_block(self, genesis_path):
timestamp = config.get("timestamp")
difficulty = config.get("difficulty")

self.target_block_time = config.get("target_block_time", 10000)
self.alpha = config.get("alpha", 0.1)
self.current_difficulty = difficulty
self.avg_block_time = self.target_block_time

genesis_block = Block(
index=0,
previous_hash="0",
Expand Down Expand Up @@ -120,26 +125,31 @@ 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 ValidationStatus.INVALID

# Validate transactions on a temporary state copy
temp_state = self.state.copy()
temp_state.chain_id = self.chain_id
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)

Expand All @@ -150,21 +160,30 @@ 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
self.avg_block_time = self.alpha * time_diff + (1 - self.alpha) * self.avg_block_time

if self.avg_block_time > self.target_block_time:
self.current_difficulty = max(1, self.current_difficulty - 1)
elif self.avg_block_time < self.target_block_time:
self.current_difficulty += 1

# 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]:
"""
Expand Down Expand Up @@ -198,11 +217,18 @@ def resolve_conflicts(self, new_chain_list) -> tuple[bool, list]:
temp_state.chain_id = self.chain_id
temp_state.restore(self._genesis_state_snapshot)

temp_difficulty = new_chain_list[0].difficulty
temp_avg_block_time = self.target_block_time

# Verify and apply blocks 1 to N
for i in range(1, len(new_chain_list)):
prev_block = new_chain_list[i-1]
block = new_chain_list[i]

if block.difficulty != temp_difficulty:
logger.warning("Reorg failed at block %s: Invalid difficulty. Expected %s, got %s", block.index, temp_difficulty, block.difficulty)
return False, []

try:
validate_block_link_and_hash(prev_block, block)
except ValueError as exc:
Expand All @@ -211,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)
Expand All @@ -230,12 +257,22 @@ def resolve_conflicts(self, new_chain_list) -> tuple[bool, list]:
logger.warning("Reorg failed: Invalid state root at block %s", block.index)
return False, []

# Update EMA difficulty state for reorg validation
time_diff = block.timestamp - prev_block.timestamp
temp_avg_block_time = self.alpha * time_diff + (1 - self.alpha) * temp_avg_block_time
if temp_avg_block_time > self.target_block_time:
temp_difficulty = max(1, temp_difficulty - 1)
elif temp_avg_block_time < self.target_block_time:
temp_difficulty += 1

# 4. Success! Compute orphaned transactions.
old_txs = {tx.tx_id: tx for b in original_chain[1:] for tx in b.transactions}
new_tx_ids = {tx.tx_id for b in new_chain_list[1:] for tx in b.transactions}
orphans = [tx for tx_id, tx in old_txs.items() if tx_id not in new_tx_ids]

self.chain = new_chain_list
self.state = temp_state
self.current_difficulty = temp_difficulty
self.avg_block_time = temp_avg_block_time
logger.info("Reorg successful! Switched to new chain tip: Block %s", self.last_block.index)
return True, orphans
62 changes: 62 additions & 0 deletions minichain/persistence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +260 to +264

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move import time to the top-level imports.

Placing import time mid-file, inside a new section, is inconsistent with standard module structure and existing import conventions (likely already established at the top of this file for os, sqlite3, etc.).

♻️ Suggested fix
 # ---------------------------------------------------------------------------
 # Banned Peers (Track 1)
 # ---------------------------------------------------------------------------
 
-import time
-
 def _ensure_banned_peers_table(conn: sqlite3.Connection) -> None:

And add import time alongside the other imports at the top of the file.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@minichain/persistence.py` around lines 260 - 264, The module-level import for
time is misplaced in the middle of the file near the “Banned Peers (Track 1)”
section; move it into the top import block alongside the existing imports such
as os and sqlite3 in minichain/persistence.py. Keep the import list organized
with all standard library imports together and remove the mid-file import from
that section.


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()
Comment on lines +271 to +283

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

No validation on peer_id/reason inputs.

ban_peer accepts arbitrary strings without checking for emptiness or type, and there's no upper bound on reason length. Given this is reachable from the CLI (ban <peer_id> in main.py) with user-supplied input, an empty or malformed peer_id would silently create a banned-peer row with an empty primary key.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@minichain/persistence.py` around lines 271 - 283, The ban_peer helper
currently writes whatever peer_id and reason it receives, so add input
validation before _connect/_ensure_banned_peers_table is used. In ban_peer,
reject non-string or empty/whitespace-only peer_id values, validate reason is a
string, and enforce a reasonable maximum length for reason before inserting into
banned_peers. Keep the checks close to ban_peer so the CLI path in main.py
cannot create rows with an empty or malformed peer_id.


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()

Comment on lines +266 to +320

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Repeated connect/ensure-table/close boilerplate across all four functions.

Each of ban_peer, unban_peer, is_peer_banned, and get_banned_peers repeats the same connect → _ensure_banned_peers_table → try/finally close pattern, along with duplicated os.path.join(path, _DB_FILE) construction. Consider factoring this into a small context manager (e.g., _banned_peers_connection(path)) that yields a ready connection with the table ensured, or a decorator, to reduce duplication and centralize connection handling for this new table.

♻️ Example consolidation
+from contextlib import contextmanager
+
+@contextmanager
+def _banned_peers_conn(path: str, create_if_missing: bool = True):
+    db_path = os.path.join(path, _DB_FILE)
+    if not create_if_missing and not os.path.exists(db_path):
+        yield None
+        return
+    os.makedirs(path, exist_ok=True)
+    conn = _connect(db_path)
+    try:
+        _ensure_banned_peers_table(conn)
+        yield conn
+    finally:
+        conn.close()

Each function can then use this helper, handling the None case for read/delete operations against a nonexistent DB.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@minichain/persistence.py` around lines 266 - 320, The new banned-peers
helpers repeat the same database setup and teardown logic in ban_peer,
unban_peer, is_peer_banned, and get_banned_peers. Factor the shared db_path
creation, _connect call, _ensure_banned_peers_table invocation, and conn.close
handling into a small shared helper such as a context manager or private
function, then update each of those functions to use it. Keep the existing
behavior for missing databases in the read/delete paths, but centralize the
connection lifecycle around the banned_peers table.

Comment on lines +271 to +320

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Synchronous, blocking sqlite calls invoked from async CLI context.

main.py's cli_loop is an async def that awaits input() via an executor, and directly calls ban_peer, unban_peer, and get_banned_peers synchronously inline. Each call opens/closes a fresh SQLite connection and executes a blocking CREATE TABLE IF NOT EXISTS. For a single-user interactive CLI this is low risk, but if this network layer / cli_loop shares an event loop with other async network I/O (peer connections, block broadcasting per the AI summary), these blocking calls will stall the loop until the DB operation completes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@minichain/persistence.py` around lines 271 - 320, The banned-peer persistence
helpers are doing blocking SQLite work directly from the async CLI path, which
can stall the event loop. Update the call sites in cli_loop to run ban_peer,
unban_peer, and get_banned_peers off the loop (for example via an executor or
async wrapper), and keep the synchronous DB helpers in persistence.py as the
isolated implementation behind those async-safe wrappers. Use the ban_peer,
unban_peer, and get_banned_peers symbols to locate the affected flow.


# ---------------------------------------------------------------------------
# Legacy JSON helpers
# ---------------------------------------------------------------------------
Expand Down
3 changes: 2 additions & 1 deletion minichain/pow.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ def calculate_hash(block_dict):

def mine_block(
block,
difficulty=4,
difficulty=None,
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."""

difficulty = difficulty if difficulty is not None else block.difficulty
if not isinstance(difficulty, int) or difficulty <= 0:
raise ValueError("Difficulty must be a positive integer.")

Expand Down
36 changes: 26 additions & 10 deletions minichain/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand All @@ -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]
Expand Down
9 changes: 9 additions & 0 deletions minichain/validators.py
Original file line number Diff line number Diff line change
@@ -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))

Loading