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 +173

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

Chain response broadcasts to all peers instead of just the requester — amplification risk.

When handling chain_request, the code broadcasts the entire chain to all connected peers via _broadcast_raw, not just the peer who requested it. This is:

  1. Wasteful — peers who didn't request the chain receive it anyway
  2. Potentially exploitable — a malicious peer could trigger large broadcasts to all other peers (amplification attack)

The root cause is that the handler only receives _peer_addr (a string), not the writer object needed to call send_chain_response. Consider passing the writer through the handler callback or storing a peer address → writer mapping.

🧰 Tools
🪛 Ruff (0.15.15)

[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 - 173, The chain_request handler in main.py (lines
169-173) currently broadcasts the chain response to all connected peers via
_broadcast_raw, which is wasteful and creates an amplification attack vector.
Instead of broadcasting, the handler should send the chain response only to the
peer that requested it. Modify the handler to access the writer object for the
requesting peer (either by having it passed through the callback or by
maintaining a peer address to writer mapping in the network module), and then
call a targeted send method (such as send_chain_response) that writes directly
to that specific peer's connection rather than using _broadcast_raw. This
ensures the response reaches only the requester.


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

RPC port calculation can produce invalid or unexpected ports.

The formula 8545 + (port - 9000) assumes P2P port is ≥9000. If a user runs with --port 100, the RPC port becomes -355, which will fail. Even valid-but-low ports like --port 8000 yield 7545, which may not match user expectations.

Consider adding a separate --rpc-port argument or at minimum clamping the port to a valid range.

Proposed fix with validation
     # Start RPC server on a port correlated to the node port (e.g. 8545 if P2P is 9000)
     rpc_port = 8545 + (port - 9000)
+    if rpc_port < 1 or rpc_port > 65535:
+        rpc_port = 8545  # fallback to default
+        logger.warning("Computed RPC port out of range, using default %d", rpc_port)
     rpc_task = asyncio.create_task(rpc_server.start(host="127.0.0.1", port=rpc_port))
🤖 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 438 - 441, The RPC port calculation using `8545 + (port
- 9000)` can produce invalid port numbers when the P2P port argument is less
than 9000, resulting in negative or unexpectedly low port values. Fix this by
adding validation to ensure the calculated rpc_port value falls within the valid
port range (1-65535), either by clamping the result to a minimum valid port or
by implementing a separate --rpc-port command-line argument instead of deriving
it from the P2P port. At minimum, clamp rpc_port to ensure it stays within valid
bounds before passing it to rpc_server.start().


# 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()
Comment on lines +467 to 470

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

RPC task cancellation is not awaited — incomplete cleanup.

Calling rpc_task.cancel() schedules cancellation but doesn't wait for it to complete. The network.stop() call may execute while the RPC server is still running, leading to race conditions during shutdown.

Proposed fix
         if rpc_task:
             rpc_task.cancel()
+            try:
+                await rpc_task
+            except asyncio.CancelledError:
+                pass
         await network.stop()

Ideally, combine this with a proper rpc_server.stop() method that cleans up aiohttp resources (see related comment in minichain/rpc.py).

📝 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
if rpc_task:
rpc_task.cancel()
await network.stop()
if rpc_task:
rpc_task.cancel()
try:
await rpc_task
except asyncio.CancelledError:
pass
await network.stop()
🤖 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 467 - 470, The rpc_task.cancel() call schedules
cancellation but does not await its completion, allowing network.stop() to
execute while the RPC server is still shutting down and causing race conditions.
Replace the non-awaited rpc_task.cancel() with a proper awaited cancellation
that waits for the task to complete before proceeding. This typically involves
wrapping the cancellation in a try-except block to handle CancelledError, then
awaiting the task completion. Additionally, consider implementing a proper
rpc_server.stop() method in minichain/rpc.py that cleanly shuts down aiohttp
resources before network.stop() is called.



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

Unused snapshots can be removed.

state_snapshot and original_chain are captured but never used. Since temp_state is a fresh instance and self.state/self.chain are only reassigned on the success path (lines 233-234), these defensive copies serve no purpose.

🧹 Remove dead code
             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
+            # 2. Rebuild state entirely from genesis using the new chain
             temp_state = State()
             temp_state.restore(self._genesis_state_snapshot)
📝 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
# 2. Snapshot current state and chain in case reorg fails validation
state_snapshot = self.state.snapshot()
original_chain = list(self.chain)
# 2. Rebuild state entirely from genesis using the new chain
temp_state = State()
temp_state.restore(self._genesis_state_snapshot)
🤖 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, The variables state_snapshot and
original_chain are created as defensive copies but are never referenced anywhere
in the code. Remove the two lines that create these snapshots (the assignments
to state_snapshot and original_chain) since they serve no purpose when
temp_state is a fresh instance and self.state/self.chain are only reassigned on
the success path.


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

Consider copying the incoming chain to avoid aliasing.

Directly assigning new_chain_list to self.chain creates a shared reference. If the caller retains and later mutates new_chain_list, it would corrupt the blockchain state. A defensive copy is cheap and eliminates this risk.

🛡️ 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 of new_chain_list
to self.chain at the chain update location creates an alias that exposes the
blockchain to external mutations. Instead of directly assigning new_chain_list
to self.chain, create a defensive copy of the incoming list (for example, using
list slicing or the copy module) to ensure that any future modifications to
new_chain_list by the caller will not corrupt the internal blockchain state.

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
Comment on lines 48 to 51

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

Use elif to reduce nesting.

Per static analysis (PLR5501), convert else: if to elif for cleaner structure.

♻️ Proposed refactor
-            else:
-                if len(self._list) >= self.max_size:
-                    logger.warning("Mempool: Full, rejecting transaction")
-                    return False
+            elif len(self._list) >= self.max_size:
+                logger.warning("Mempool: Full, rejecting transaction")
+                return False
📝 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
else:
if self._size >= self.max_size:
if len(self._list) >= self.max_size:
logger.warning("Mempool: Full, rejecting transaction")
return False
elif len(self._list) >= self.max_size:
logger.warning("Mempool: Full, rejecting transaction")
return False
🧰 Tools
🪛 Ruff (0.15.15)

[warning] 48-49: Use elif instead of else then if, to reduce indentation

Convert to elif

(PLR5501)

🤖 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 48 - 51, Replace the nested `else: if`
structure with `elif` to reduce indentation nesting in the mempool full check
logic. Specifically, convert the `else:` statement followed by `if
len(self._list) >= self.max_size:` into a single `elif len(self._list) >=
self.max_size:` block, keeping the logger.warning and return False statements at
the appropriate indentation level.

Source: Linters/SAST tools

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)

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

Safety clamp silently masks broken invariant.

When i_min > i_max, the per-sender nonce ordering in self._list has already been violated. Silently clamping hides this corruption and allows further insertions into an invalid state, making debugging difficult.

Consider failing fast or logging when this condition occurs:

Proposed fix to detect invariant violation
-            i_min = min(i_min, i_max)
+            if i_min > i_max:
+                logger.error("Mempool: Nonce ordering invariant violated for sender %s (i_min=%d, i_max=%d)", tx.sender[:12], i_min, i_max)
+                # Force valid range to allow recovery; consider raising in debug builds
+                i_min = i_max
🤖 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` at line 53, The line `i_min = min(i_min, i_max)`
silently masks a broken invariant in the per-sender nonce ordering of
`self._list`. When `i_min > i_max`, this indicates the invariant has already
been violated, but clamping the value hides this corruption and allows the code
to continue in an invalid state. Instead of silently clamping, add a check
before this line to detect when `i_min > i_max` and either raise an assertion
error or log a warning to fail fast and make the invariant violation visible
during debugging. This ensures invariant violations are caught and reported
rather than silently masked.


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"}


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

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)

@property
def peer_count(self) -> int:
return len(self._peers)
Loading