Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
32 changes: 32 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 @@ -456,6 +457,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