Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
22 changes: 22 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,28 @@ async def handler(data):
elif msg_type == "chain_request":
start_index = payload.get("start_index", 0)
limit = payload.get("limit", 500)
if (
not isinstance(start_index, int)
or isinstance(start_index, bool)
or start_index < 0
):
logger.warning(
"Malformed chain_request from %s: start_index is not a non-negative int (got %r). Ignoring.",
peer_addr, start_index,
)
return ValidationStatus.MALFORMED

if (
not isinstance(limit, int)
or isinstance(limit, bool)
or limit < 0
):
logger.warning(
"Malformed chain_request from %s: limit is not a non-negative int (got %r). Ignoring.",
peer_addr, limit,
)
return ValidationStatus.MALFORMED

Comment on lines +267 to +288
logger.info("📡 Peer requested blocks from %d (limit %d).", start_index, limit)

if start_index < len(chain.chain):
Expand Down
96 changes: 95 additions & 1 deletion tests/test_protocol_hardening.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import unittest
from unittest.mock import AsyncMock, patch

from nacl.encoding import HexEncoder
from nacl.signing import SigningKey

from minichain import Block, Mempool, P2PNetwork, State, Transaction, calculate_hash
from minichain.serialization import canonical_json_dumps
from minichain.validators import ValidationStatus


class TestDeterministicConsensus(unittest.TestCase):
Expand Down Expand Up @@ -92,7 +94,6 @@ def test_remove_transactions_by_sender_nonce_when_tx_id_differs(self):

self.assertEqual(len(mempool), 0)


class TestP2PValidationAndDedup(unittest.IsolatedAsyncioTestCase):
async def test_invalid_message_schema_is_rejected(self):
invalid_payload = {"sender": "abc"}
Expand Down Expand Up @@ -162,3 +163,96 @@ async def test_duplicate_tx_and_block_detection(self):
self.assertFalse(network._is_duplicate("block", block_message["data"]))
network._mark_seen("block", block_message["data"])
self.assertTrue(network._is_duplicate("block", block_message["data"]))

class TestChainRequestValidation(unittest.IsolatedAsyncioTestCase):
"""Verify that chain_request handler rejects malformed start_index/limit values."""

def _make_handler(self):
"""Return (handler, network) so tests can mock network internals."""
from minichain import Blockchain, Mempool, P2PNetwork
from main import make_network_handler
chain = Blockchain()
mempool = Mempool()
network = P2PNetwork()
handler = make_network_handler(chain, mempool, network)
return handler, network

async def _call(self, handler_or_tuple, data):
"""Wrap handler call to include required _peer_addr field.

Accepts either a bare handler or the (handler, network) tuple
returned by _make_handler.
"""
handler = handler_or_tuple[0] if isinstance(handler_or_tuple, tuple) else handler_or_tuple
data.setdefault("_peer_addr", "test-peer")
return await handler(data)

async def test_string_start_index_is_rejected(self):
handler, _ = self._make_handler()
result = await self._call(handler, {
"type": "chain_request",
"data": {"start_index": "0", "limit": 10},
})
self.assertEqual(result, ValidationStatus.MALFORMED)

async def test_bool_start_index_is_rejected(self):
handler, _ = self._make_handler()
result = await self._call(handler, {
"type": "chain_request",
"data": {"start_index": True, "limit": 10},
})
self.assertEqual(result, ValidationStatus.MALFORMED)

async def test_negative_start_index_is_rejected(self):
handler, _ = self._make_handler()
result = await self._call(handler, {
"type": "chain_request",
"data": {"start_index": -1, "limit": 10},
})
self.assertEqual(result, ValidationStatus.MALFORMED)

async def test_string_limit_is_rejected(self):
handler, _ = self._make_handler()
result = await self._call(handler, {
"type": "chain_request",
"data": {"start_index": 0, "limit": "500"},
})
self.assertEqual(result, ValidationStatus.MALFORMED)

async def test_bool_limit_is_rejected(self):
handler, _ = self._make_handler()
result = await self._call(handler, {
"type": "chain_request",
"data": {"start_index": 0, "limit": False},
})
self.assertEqual(result, ValidationStatus.MALFORMED)

async def test_valid_chain_request_is_accepted(self):
"""A valid request must dispatch a chain_response via _unicast_raw."""
handler, network = self._make_handler()
mock_unicast = AsyncMock()
network._unicast_raw = mock_unicast

await self._call(handler, {
"type": "chain_request",
"data": {"start_index": 0, "limit": 10},
})

mock_unicast.assert_awaited_once()
_, call_payload = mock_unicast.call_args.args
self.assertEqual(call_payload.get("type"), "chain_response")
self.assertIn("blocks", call_payload.get("data", {}))
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async def test_negative_limit_is_rejected(self):
"""limit: -1 must be rejected — handler returns None and sends no response."""
handler, network = self._make_handler()
mock_unicast = AsyncMock()
network._unicast_raw = mock_unicast

result = await self._call(handler, {
"type": "chain_request",
"data": {"start_index": 0, "limit": -1},
})

self.assertEqual(result, ValidationStatus.MALFORMED)
mock_unicast.assert_not_awaited()