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
32 changes: 29 additions & 3 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,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 +159,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())

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 | 🟡 Minor | ⚡ Quick win

Fire-and-forget asyncio.create_task may silently lose exceptions.

The task reference is not stored, so if the task fails with an exception, it will be silently swallowed. Additionally, the task could theoretically be garbage collected before completion (though CPython currently keeps a reference).

Consider storing the task reference or using a background task set:

♻️ Proposed fix
+# At module level or in handler closure
+_background_tasks = set()
+
 # In the handler:
-                    asyncio.create_task(network.broadcast_chain_request())
+                    task = asyncio.create_task(network.broadcast_chain_request())
+                    _background_tasks.add(task)
+                    task.add_done_callback(_background_tasks.discard)
🧰 Tools
🪛 Ruff (0.15.17)

[warning] 164-164: 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` at line 164, The asyncio.create_task call for
network.broadcast_chain_request() does not store the returned task reference,
which can cause exceptions to be silently lost and allows the task to
potentially be garbage collected. Store the task reference in a variable or
maintain it in a task collection (such as a set) to ensure the task is not
garbage collected and exceptions can be properly handled. This will allow you to
await the task or add callbacks to handle any exceptions that occur during
execution.

Source: Linters/SAST tools

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 +168 to +172

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 | 🔴 Critical | 🏗️ Heavy lift

chain_request handler broadcasts to all peers instead of responding to the requester.

When a peer sends a chain_request, the handler calls network._broadcast_raw(payload) which sends the chain to all connected peers, not just the requesting peer. This is inefficient and could cause unnecessary network traffic and potential amplification.

The handler should use send_chain_response with the specific peer's writer. However, the current handler signature doesn't provide access to the peer's writer. Consider passing the writer through the message data or adding a response mechanism.

🐛 Proposed approach

One solution is to include the writer in the callback data (requires P2P layer changes):

         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))
+            writer = data.get("_writer")
+            if writer:
+                asyncio.create_task(network.send_chain_response(blocks_dicts, writer))
+            else:
+                logger.warning("Cannot respond to chain_request: no writer available")

This requires the P2P layer to pass the writer in the message data alongside _peer_addr.

🧰 Tools
🪛 Ruff (0.15.17)

[warning] 172-172: 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 168 - 172, The chain_request handler at the elif
msg_type == "chain_request" block is using network._broadcast_raw() to send the
chain response to all peers instead of responding only to the requesting peer.
To fix this, modify the P2P layer to pass the requesting peer's writer in the
message data alongside _peer_addr, then update the handler to receive that
writer and use send_chain_response() with that specific peer's writer to send
the response directly to the requester instead of calling
network._broadcast_raw(). This avoids unnecessary network traffic by sending the
chain only to the peer that requested it.


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)
Comment on lines +183 to +188

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

Missing log when resolve_conflicts returns success=False.

When the reorg fails (e.g., the incoming chain has less total work), there's no log message indicating rejection. This makes debugging sync issues harder.

♻️ Proposed fix
             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)
+                else:
+                    logger.info("🔄 Received chain rejected (not heavier than local)")
🤖 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 183 - 188, The code in the chain reorganization block
only logs a message when resolve_conflicts succeeds, but provides no feedback
when it fails. Add a log statement in an else block or separate conditional that
triggers when the success variable returned from
chain.resolve_conflicts(new_chain) is False, so that reorg failures are properly
recorded for debugging sync issues.


return handler

Expand Down Expand Up @@ -389,7 +415,7 @@ 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)

# When a new peer connects, send our state so they can sync
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)

Comment on lines +104 to +113

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

Harden cumulative-work computation against malformed peer difficulty values.

2 ** (block.difficulty or 1) has two problems: it incorrectly treats valid difficulty=0 as 1, and it allows unbounded peer-controlled exponents during conflict checks, which can cause expensive big-int work in the chain-sync path.

Proposed fix
     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)
+        total_work = 0
+        for block in chain_list:
+            difficulty = block.difficulty
+            if not isinstance(difficulty, int) or difficulty < 0 or difficulty > 64:
+                raise ValueError(f"Invalid block difficulty: {difficulty}")
+            total_work += 1 << difficulty
+        return total_work
🤖 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 104 - 113, The get_total_work method has two
security and correctness issues in the sum calculation. First, the expression 2
** (block.difficulty or 1) treats a valid difficulty value of 0 as falsy and
incorrectly substitutes 1, when 0 should be treated as a legitimate difficulty
value. Second, the computation allows unbounded peer-controlled exponents which
can cause expensive big-integer arithmetic during chain-sync operations. Fix
this by replacing the or fallback with explicit validation: ensure difficulty
has a sensible default only when missing or None (not when it is 0), and add a
maximum cap on the difficulty exponent to prevent peer-controlled DoS attacks.
The cap should be reasonable for your consensus algorithm while rejecting
malformed or malicious difficulty values.

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)

# 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
Comment on lines +233 to +234

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

Avoid aliasing external chain lists when committing a reorg.

Assigning self.chain = new_chain_list keeps a shared list reference. If the caller passed a live chain object (for example tests/test_reorg.py Line 89), later appends on that source chain can mutate this node’s chain without replaying state, breaking chain/state consistency.

Proposed fix
-            self.chain = new_chain_list
+            self.chain = list(new_chain_list)
             self.state = temp_state
📝 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
self.chain = new_chain_list
self.state = temp_state
self.chain = list(new_chain_list)
self.state = temp_state
🤖 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 233 - 234, The assignment `self.chain =
new_chain_list` on line 233 creates an alias to the external list instead of
copying it, which means external mutations to the caller's list can modify the
node's chain without replaying state. Replace this assignment with a copy of the
list (using either the copy() method or the list() constructor) to ensure the
node maintains an independent chain that cannot be mutated by the caller's
modifications to their original list.

logger.info("Reorg successful! Switched to new chain tip: Block %s", self.last_block.index)
return True, orphans
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])

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)
34 changes: 33 additions & 1 deletion minichain/p2p.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
logger = logging.getLogger(__name__)

TOPIC = "minichain-global"
SUPPORTED_MESSAGE_TYPES = {"sync", "tx", "block"}
SUPPORTED_MESSAGE_TYPES = {"sync", "tx", "block", "chain_request", "chain_response"}

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

Stale docstring does not reflect new message types.

The class docstring at line 26-27 still documents only "sync" | "tx" | "block" for the JSON wire format, but chain_request and chain_response are now supported. Update the docstring to include the new message types.

Also applies to: 26-27

🤖 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/p2p.py` at line 18, The class docstring does not reflect the new
message types added to SUPPORTED_MESSAGE_TYPES. Update the docstring that
documents the JSON wire format to include the two new message types
chain_request and chain_response alongside the existing sync, tx, and block
types. Ensure the docstring accurately represents all message types currently
defined in SUPPORTED_MESSAGE_TYPES.



class P2PNetwork:
Expand Down Expand Up @@ -228,6 +228,21 @@ def _validate_block_payload(self, payload):
for tx_payload in payload["transactions"]
)

def _validate_chain_request(self, payload):
if not isinstance(payload, dict):
return False
return True

def _validate_chain_response(self, payload):
if not isinstance(payload, dict) or "blocks" not in payload:
return False
if not isinstance(payload["blocks"], list):
return False
for block_payload in payload["blocks"]:
if not self._validate_block_payload(block_payload):
return False
return True
Comment on lines +236 to +244

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

Missing size limit on blocks list creates potential DoS vector.

_validate_chain_response validates block structure but doesn't limit the number of blocks. A malicious peer could send a chain_response with an extremely large blocks list, causing memory exhaustion or CPU starvation during validation.

Consider adding a reasonable upper bound:

🛡️ Proposed fix
+MAX_CHAIN_RESPONSE_BLOCKS = 500  # or appropriate limit
+
 def _validate_chain_response(self, payload):
     if not isinstance(payload, dict) or "blocks" not in payload:
         return False
     if not isinstance(payload["blocks"], list):
         return False
+    if len(payload["blocks"]) > MAX_CHAIN_RESPONSE_BLOCKS:
+        return False
     for block_payload in payload["blocks"]:
         if not self._validate_block_payload(block_payload):
             return False
     return True
🧰 Tools
🪛 Ruff (0.15.17)

[warning] 236-236: Missing return type annotation for private function _validate_chain_response

Add return type annotation: bool

(ANN202)

🤖 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/p2p.py` around lines 236 - 244, The _validate_chain_response method
validates the structure of the chain response but lacks a size limit check on
the blocks list, creating a DoS vulnerability. After verifying that
payload["blocks"] is a list, add a check to ensure the list length does not
exceed a reasonable maximum threshold (define an appropriate constant for max
blocks allowed). Insert this length validation before the loop that iterates
through payload["blocks"] to validate each block.


def _validate_message(self, message):
# FIX: Check if message is a dictionary first to prevent crashes
if not isinstance(message, dict):
Expand All @@ -249,6 +264,8 @@ def _validate_message(self, message):
"sync": self._validate_sync_payload,
"tx": self._validate_transaction_payload,
"block": self._validate_block_payload,
"chain_request": self._validate_chain_request,
"chain_response": self._validate_chain_response,
}
return validators[msg_type](payload)

Expand Down Expand Up @@ -385,6 +402,21 @@ async def broadcast_block(self, block):
self._mark_seen("block", payload["data"])
await self._broadcast_raw(payload)

async def broadcast_chain_request(self):
logger.info("Network: Broadcasting chain request")
payload = {"type": "chain_request", "data": {}}
await self._broadcast_raw(payload)

async def send_chain_response(self, blocks_dicts, writer):
logger.info("Network: Sending chain response with %d blocks", len(blocks_dicts))
payload = {"type": "chain_response", "data": {"blocks": blocks_dicts}}
line = (canonical_json_dumps(payload) + "\n").encode()
try:
writer.write(line)
await writer.drain()
except Exception as e:
logger.error("Network: Failed to send chain response: %s", e)
Comment on lines +417 to +418

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

Use logger.exception to preserve stack trace on send failure.

The broad Exception catch loses the stack trace. Using logger.exception instead of logger.error will preserve the traceback for debugging network issues.

♻️ Proposed fix
         except Exception as e:
-            logger.error("Network: Failed to send chain response: %s", e)
+            logger.exception("Network: Failed to send chain response: %s", e)
📝 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
except Exception as e:
logger.error("Network: Failed to send chain response: %s", e)
except Exception as e:
logger.exception("Network: Failed to send chain response: %s", e)
🧰 Tools
🪛 Ruff (0.15.17)

[warning] 417-417: Do not catch blind exception: Exception

(BLE001)


[warning] 418-418: Use logging.exception instead of logging.error

Replace with exception

(TRY400)

🤖 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/p2p.py` around lines 417 - 418, In the exception handler catching
Exception for the send chain response failure, replace the logger.error call
with logger.exception. The logger.exception method automatically preserves and
includes the full stack trace in the log output, whereas logger.error only logs
the error message itself. This change will provide the traceback needed for
debugging network issues.

Source: Linters/SAST tools


@property
def peer_count(self) -> int:
return len(self._peers)
12 changes: 12 additions & 0 deletions minichain/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,18 @@ def copy(self):
new_state.contract_machine = ContractMachine(new_state) # Reinitialize contract_machine
return new_state

def snapshot(self):
"""
Returns a deep copy of the current accounts dictionary for rollback safety.
"""
return copy.deepcopy(self.accounts)

def restore(self, snapshot_data):
"""
Restores the state's accounts dictionary from a snapshot.
"""
self.accounts = copy.deepcopy(snapshot_data)

def validate_and_apply(self, tx):
"""
Validate and apply a transaction.
Expand Down
Loading