Skip to content
Open
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
42 changes: 39 additions & 3 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from nacl.encoding import HexEncoder

from minichain import Transaction, Blockchain, Block, State, Mempool, P2PNetwork, mine_block
from minichain.rpc import JSONRPCServer
from minichain.validators import is_valid_receiver
from minichain.block import calculate_receipt_root

Expand Down Expand Up @@ -113,7 +114,7 @@ def mine_and_process_block(chain, mempool, miner_pk):
# Network message handler
# ──────────────────────────────────────────────

def make_network_handler(chain, mempool):
def make_network_handler(chain, mempool, network):
"""Return an async callback that processes incoming P2P messages."""

async def handler(data):
Expand Down Expand Up @@ -159,7 +160,33 @@ async def handler(data):
# Drop only confirmed transactions so higher nonces can remain queued.
mempool.remove_transactions(block.transactions)
else:
logger.warning("📥 Received Block #%s — rejected", block.index)
if block.index > chain.last_block.index:
logger.warning("📥 Received Block #%s — ahead of us (tip: %s). Requesting chain sync...", block.index, chain.last_block.index)
asyncio.create_task(network.broadcast_chain_request())
else:
logger.warning("📥 Received Block #%s — rejected", block.index)

elif msg_type == "chain_request":
logger.info("📡 Peer requested chain sync. Broadcasting our chain...")
blocks_dicts = [b.to_dict() for b in chain.chain]
payload = {"type": "chain_response", "data": {"blocks": blocks_dicts}}
asyncio.create_task(network._broadcast_raw(payload))

Comment on lines +169 to +174

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.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Avoid broadcasting chain_response to all peers for a single chain_request.

At Line 173, one requester triggers a full-chain broadcast to every peer, which creates amplification and unnecessary reorg parsing load. This should be a unicast reply to the requesting connection.

🧰 Tools
🪛 Ruff (0.15.17)

[warning] 173-173: Store a reference to the return value of asyncio.create_task

(RUF006)

🤖 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 169 - 174, The chain response is being broadcasted to
all peers via network._broadcast_raw() when it should only be sent to the
specific peer that requested it. Replace the
asyncio.create_task(network._broadcast_raw(payload)) call with a unicast method
that sends the chain_response payload only to the requesting peer connection.
You will need to capture the requesting peer's connection identifier from the
message context and use an appropriate network method to send the response back
to only that peer rather than broadcasting to all connected peers.

elif msg_type == "chain_response":
blocks_payload = payload.get("blocks", [])
new_chain = []
try:
new_chain = [Block.from_dict(b) for b in blocks_payload]
except Exception as e:
logger.warning("❌ Failed to parse chain_response: %s", e)
return

if new_chain:
success, orphans = chain.resolve_conflicts(new_chain)
if success:
logger.info("🔄 Reorg complete! Restoring %d orphaned txs to mempool.", len(orphans))
for tx in orphans:
mempool.add_transaction(tx)

return handler

Expand Down Expand Up @@ -389,8 +416,10 @@ async def run_node(port: int, host: str, connect_to: str | None, fund: int, data
mempool = Mempool()
network = P2PNetwork()

handler = make_network_handler(chain, mempool)
handler = make_network_handler(chain, mempool, network)
network.register_handler(handler)

rpc_server = JSONRPCServer(chain, mempool, network)

# When a new peer connects, send our state so they can sync
async def on_peer_connected(writer):
Expand All @@ -406,6 +435,10 @@ async def on_peer_connected(writer):
network.register_on_peer_connected(on_peer_connected)

await network.start(port=port, host=host)

# Start RPC server on a port correlated to the node port (e.g. 8545 if P2P is 9000)
rpc_port = 8545 + (port - 9000)
rpc_task = asyncio.create_task(rpc_server.start(host="127.0.0.1", port=rpc_port))
Comment on lines +440 to +441

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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not fire-and-forget RPC startup; await startup and stop explicitly.

At Line 441, create_task can hide startup/bind failures. At Line 469, canceling that task is not a reliable server shutdown mechanism. Start the RPC server with await and call an explicit await rpc_server.stop() during shutdown.

Also applies to: 468-469

🤖 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 440 - 441, The RPC server startup uses
asyncio.create_task which can hide failures and does not await the actual
startup. Replace the create_task call that assigns to rpc_task with an await of
rpc_server.start() directly, and store only the server reference if needed. At
shutdown (around line 469 where the task is currently canceled), replace the
task cancellation with an explicit await rpc_server.stop() call to reliably stop
the RPC server instead of just canceling the task.


# Fund this node's wallet so it can transact in the demo
if fund > 0:
Expand All @@ -431,6 +464,9 @@ async def on_peer_connected(writer):
logger.info("Chain saved to '%s'", datadir)
except Exception as e:
logger.error("Failed to save chain during shutdown: %s", e)

if rpc_task:
rpc_task.cancel()
await network.stop()


Expand Down
87 changes: 87 additions & 0 deletions minichain/chain.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ def _create_genesis_block(self, genesis_path):
genesis_block.hash = computed_hash

self.chain.append(genesis_block)

# Snapshot the state exactly after genesis allocation for clean reorg rebuilds
self._genesis_state_snapshot = self.state.snapshot()

@property
def last_block(self):
Expand All @@ -98,6 +101,16 @@ def last_block(self):
with self._lock: # Acquire lock for thread-safe access
return self.chain[-1]

def get_total_work(self, chain_list=None):
"""
Calculates the cumulative PoW of a chain.
Work is proportional to 2^difficulty.
"""
if chain_list is None:
with self._lock:
chain_list = self.chain
return sum(2 ** (block.difficulty or 1) for block in chain_list)

def add_block(self, block):
"""
Validates and adds a block to the chain if all transactions succeed.
Expand Down Expand Up @@ -147,3 +160,77 @@ def add_block(self, block):
self.state = temp_state
self.chain.append(block)
return True

def resolve_conflicts(self, new_chain_list) -> tuple[bool, list]:
"""
Evaluates a competing chain. If it has strictly greater cumulative work,
attempts a reorg. Rebuilds state from genesis to guarantee validity.
Returns: (success_bool, list_of_orphaned_transactions)
"""
if not new_chain_list:
return False, []

with self._lock:
current_work = self.get_total_work()
new_work = self.get_total_work(new_chain_list)

if new_work <= current_work:
logger.debug("Incoming chain (work: %s) is not heavier than local chain (work: %s). Rejecting.", new_work, current_work)
return False, []

# 1. Verify genesis block matches
if new_chain_list[0].hash != self.chain[0].hash:
logger.warning("Reorg failed: Genesis hash mismatch.")
return False, []

logger.info("Incoming chain is heavier (%s > %s). Attempting reorg...", new_work, current_work)

# 2. Snapshot current state and chain in case reorg fails validation
state_snapshot = self.state.snapshot()
original_chain = list(self.chain)
Comment on lines +188 to +190

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.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Remove unused snapshot variables.

state_snapshot is captured but never used for rollback since all validation operates on temp_state. The original state is preserved naturally because self.state is only replaced upon successful reorg at line 234.

🧹 Suggested cleanup
             logger.info("Incoming chain is heavier (%s > %s). Attempting reorg...", new_work, current_work)
 
-            # 2. Snapshot current state and chain in case reorg fails validation
-            state_snapshot = self.state.snapshot()
+            # 2. Preserve reference to original chain for orphan calculation
             original_chain = list(self.chain)
🤖 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/chain.py` around lines 188 - 190, Remove the unused variables
state_snapshot and original_chain from the reorg validation logic. These
variables are captured at the beginning but never actually used for rollback
purposes since all validation operations work with temp_state instead, and
self.state is only replaced upon successful reorg completion. Simply delete the
lines that assign to state_snapshot and original_chain to clean up unnecessary
variable assignments.


# 3. Rebuild state entirely from genesis using the new chain
temp_state = State()
temp_state.restore(self._genesis_state_snapshot)

# 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]

try:
validate_block_link_and_hash(prev_block, block)
except ValueError as exc:
logger.warning("Reorg failed at block %s: %s", block.index, exc)
return False, []

receipts = []
for tx in block.transactions:
receipt = temp_state.validate_and_apply(tx)
if receipt is None:
logger.warning("Reorg failed: Transaction validation failed in block %s", block.index)
return False, []
receipts.append(receipt)

total_fees = sum(getattr(r, 'gas_used', 0) for r in receipts)
if block.miner:
temp_state.credit_mining_reward(block.miner, reward=temp_state.DEFAULT_MINING_REWARD + total_fees)

computed_receipt_root = calculate_receipt_root(receipts)
if block.receipt_root != computed_receipt_root:
logger.warning("Reorg failed: Invalid receipt root at block %s. Expected %s, got %s", block.index, computed_receipt_root, block.receipt_root)
return False, []

if block.state_root != temp_state.state_root():
logger.warning("Reorg failed: Invalid state root at block %s", block.index)
return False, []

# 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
logger.info("Reorg successful! Switched to new chain tip: Block %s", self.last_block.index)
return True, orphans
21 changes: 13 additions & 8 deletions minichain/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ def _safe_exec_worker(code, globals_dict, context_dict, result_queue, gas_limit)
except (OSError, ValueError) as e:
logger.warning("Failed to set resource limits: %s", e)

transfers = []

def transfer_out(address, amount):
if not isinstance(amount, int) or amount <= 0:
raise ValueError("Invalid transfer amount")
if not isinstance(address, str):
raise ValueError("Invalid address type")
transfers.append({"to": address, "amount": amount})

globals_dict["__builtins__"]["transfer_out"] = transfer_out

Comment on lines +48 to +58

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.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider validating address format in transfer_out.

The address validation only checks for str type but does not validate the format (e.g., length, hex characters). This allows contracts to transfer to invalid or malformed addresses like empty strings. While State.get_account() will create accounts for any string, this could lead to funds being sent to unretrievable addresses.

🛡️ Suggested validation
         def transfer_out(address, amount):
             if not isinstance(amount, int) or amount <= 0:
                 raise ValueError("Invalid transfer amount")
             if not isinstance(address, str):
-                raise ValueError("Invalid address type")
+                raise TypeError("Invalid address type")
+            if len(address) < 40 or not all(c in '0123456789abcdefABCDEF' for c in address):
+                raise ValueError("Invalid address format")
             transfers.append({"to": address, "amount": amount})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
transfers = []
def transfer_out(address, amount):
if not isinstance(amount, int) or amount <= 0:
raise ValueError("Invalid transfer amount")
if not isinstance(address, str):
raise ValueError("Invalid address type")
transfers.append({"to": address, "amount": amount})
globals_dict["__builtins__"]["transfer_out"] = transfer_out
transfers = []
def transfer_out(address, amount):
if not isinstance(amount, int) or amount <= 0:
raise ValueError("Invalid transfer amount")
if not isinstance(address, str):
raise TypeError("Invalid address type")
if len(address) < 40 or not all(c in '0123456789abcdefABCDEF' for c in address):
raise ValueError("Invalid address format")
transfers.append({"to": address, "amount": amount})
globals_dict["__builtins__"]["transfer_out"] = transfer_out
🧰 Tools
🪛 Ruff (0.15.17)

[warning] 50-50: Missing return type annotation for private function transfer_out

Add return type annotation: None

(ANN202)


[warning] 52-52: Abstract raise to an inner function

(TRY301)


[warning] 52-52: Avoid specifying long messages outside the exception class

(TRY003)


[warning] 54-54: Abstract raise to an inner function

(TRY301)


[warning] 54-54: Prefer TypeError exception for invalid type

(TRY004)


[warning] 54-54: Avoid specifying long messages outside the exception class

(TRY003)

🤖 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/contract.py` around lines 48 - 58, The address validation in the
transfer_out function only checks if the address is a string type but does not
validate the actual format of the address. Add additional validation to ensure
the address has a valid format by checking for non-empty strings and any other
format requirements (such as proper hex character format or minimum length).
This validation should occur after the type check for the address parameter and
before appending the transfer to the transfers list.

meter = GasMeter(gas_limit)
sys.settrace(meter.trace_calls)

Expand All @@ -54,7 +65,7 @@ def _safe_exec_worker(code, globals_dict, context_dict, result_queue, gas_limit)
sys.settrace(None)

gas_used = meter.initial_gas - meter.gas
result_queue.put({"status": "success", "storage": context_dict.get("storage"), "gas_used": gas_used})
result_queue.put({"status": "success", "storage": context_dict.get("storage"), "transfers": transfers, "gas_used": gas_used})
except OutOfGasException as e:
result_queue.put({"status": "error", "error": "Out of gas!", "gas_used": gas_limit})
except Exception as e:
Expand Down Expand Up @@ -172,13 +183,7 @@ def execute(self, contract_address, sender_address, payload, amount, gas_limit):
logger.error("Contract storage not JSON serializable")
return {"success": False, "gas_used": result.get("gas_used", gas_limit), "error": "Storage not JSON serializable"}

# Commit updated storage only after successful execution
self.state.update_contract_storage(
contract_address,
result["storage"]
)

return {"success": True, "gas_used": result["gas_used"], "error": None}
return {"success": True, "gas_used": result["gas_used"], "transfers": result.get("transfers", []), "storage": result["storage"], "error": None}

except Exception as e:
logger.error("Contract Execution Failed", exc_info=True)
Expand Down
85 changes: 41 additions & 44 deletions minichain/mempool.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@

class Mempool:
def __init__(self, max_size=1000, transactions_per_block=100):
self._pool = {}
self._size = 0
self._list = [] # Single sorted list
self._lock = threading.Lock()
self.max_size = max_size
self.transactions_per_block = transactions_per_block
Expand All @@ -17,64 +16,62 @@ def add_transaction(self, tx):
return False

with self._lock:
existing = self._pool.get(tx.sender, {}).get(tx.nonce)
existing_idx = None
i_min = 0
i_max = len(self._list)

for i, existing_tx in enumerate(self._list):
if existing_tx.sender == tx.sender:
if existing_tx.nonce == tx.nonce:
existing_idx = i
elif existing_tx.nonce < tx.nonce:
# Must insert AFTER the largest lower-nonce transaction
i_min = max(i_min, i + 1)
elif existing_tx.nonce > tx.nonce:
# Must insert BEFORE the smallest higher-nonce transaction
i_max = min(i_max, i)

if existing:
if existing.tx_id == tx.tx_id:
if existing_idx is not None:
existing_tx = self._list[existing_idx]
if existing_tx.tx_id == 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:
if tx.timestamp <= existing_tx.timestamp:
logger.warning("Mempool: Ignoring older replacement %s", tx.tx_id)
return False

self._list.pop(existing_idx)
if i_max > existing_idx:
i_max -= 1
if i_min > existing_idx:
i_min -= 1
else:
if self._size >= self.max_size:
if len(self._list) >= self.max_size:
logger.warning("Mempool: Full, rejecting transaction")
return False
self._size += 1
self._pool.setdefault(tx.sender, {})[tx.nonce] = tx
return True

def get_transactions_for_block(self):
with self._lock:
snapshot = {s: list(pool.values()) for s, pool in self._pool.items()}

for txs in snapshot.values():
txs.sort(key=lambda t: t.nonce)
i_min = min(i_min, i_max)

selected = []
while len(selected) < self.transactions_per_block:
best_tx = None
best_sender = None
# Insert before the first tx in [i_min, i_max] that has a lower fee
insert_idx = i_max
for j in range(i_min, i_max):
if getattr(self._list[j], 'fee', 0) < getattr(tx, 'fee', 0):
insert_idx = j
break

for sender, txs in snapshot.items():
if txs:
current_criteria = (-getattr(txs[0], 'fee', 0), txs[0].timestamp, sender, txs[0].nonce)
best_criteria = (-getattr(best_tx, 'fee', 0), best_tx.timestamp, best_sender, best_tx.nonce) if best_tx else None
if best_tx is None or current_criteria < best_criteria:
best_tx = txs[0]
best_sender = sender

if not best_tx:
break

selected.append(best_tx)
snapshot[best_sender].pop(0)
self._list.insert(insert_idx, tx)
return True

return selected
def get_transactions_for_block(self):
with self._lock:
# O(1) retrieval! The list is strictly ordered upon insertion.
return list(self._list[:self.transactions_per_block])
Comment on lines +65 to +68

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.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

Comment inaccuracy: slice is O(k), not O(1).

The retrieval is O(k) where k = transactions_per_block, not strictly O(1). This is still efficient and much better than the prior implementation, but the comment is slightly misleading.

📝 Suggested fix
     def get_transactions_for_block(self):
         with self._lock:
-            # O(1) retrieval! The list is strictly ordered upon insertion.
+            # O(k) retrieval where k = transactions_per_block. The list is strictly ordered upon insertion.
             return list(self._list[:self.transactions_per_block])
🤖 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/mempool.py` around lines 65 - 68, The comment in the
get_transactions_for_block method incorrectly states that the retrieval is O(1),
but the list slicing operation that creates a new list from
self._list[:self.transactions_per_block] is actually O(k) where k equals
transactions_per_block. Update the comment to accurately reflect that this is an
O(k) operation, while noting it remains efficient compared to alternative
approaches.


def remove_transactions(self, transactions):
with self._lock:
for tx in transactions:
pool = self._pool.get(tx.sender)
if pool and tx.nonce in pool:
del pool[tx.nonce]
self._size -= 1
if not pool:
del self._pool[tx.sender]
keys_to_remove = {(tx.sender, tx.nonce) for tx in transactions}
self._list = [tx for tx in self._list if (tx.sender, tx.nonce) not in keys_to_remove]

def __len__(self):
with self._lock:
return self._size
return len(self._list)
Loading