-
-
Notifications
You must be signed in to change notification settings - Fork 16
Feat/fork choice #100
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Feat/fork choice #100
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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): | ||
|
|
@@ -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()) | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a peer sends a The handler should use 🐛 Proposed approachOne 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 🧰 Tools🪛 Ruff (0.15.17)[warning] 172-172: Store a reference to the return value of (RUF006) 🤖 Prompt for AI Agents |
||
|
|
||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win Missing log when 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 |
||
|
|
||
| return handler | ||
|
|
||
|
|
@@ -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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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): | ||||||||||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Harden cumulative-work computation against malformed peer difficulty values.
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 |
||||||||||
| def add_block(self, block): | ||||||||||
| """ | ||||||||||
| Validates and adds a block to the chain if all transactions succeed. | ||||||||||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid aliasing external chain lists when committing a reorg. Assigning Proposed fix- self.chain = new_chain_list
+ self.chain = list(new_chain_list)
self.state = temp_state📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| logger.info("Reorg successful! Switched to new chain tip: Block %s", self.last_block.index) | ||||||||||
| return True, orphans | ||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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"} | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Also applies to: 26-27 🤖 Prompt for AI Agents |
||||||||||
|
|
||||||||||
|
|
||||||||||
| class P2PNetwork: | ||||||||||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing size limit on
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 Add return type annotation: (ANN202) 🤖 Prompt for AI Agents |
||||||||||
|
|
||||||||||
| def _validate_message(self, message): | ||||||||||
| # FIX: Check if message is a dictionary first to prevent crashes | ||||||||||
| if not isinstance(message, dict): | ||||||||||
|
|
@@ -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) | ||||||||||
|
|
||||||||||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win Use The broad ♻️ 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
Suggested change
🧰 Tools🪛 Ruff (0.15.17)[warning] 417-417: Do not catch blind exception: (BLE001) [warning] 418-418: Use Replace with (TRY400) 🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||||||||||
|
|
||||||||||
| @property | ||||||||||
| def peer_count(self) -> int: | ||||||||||
| return len(self._peers) | ||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fire-and-forget
asyncio.create_taskmay 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
🧰 Tools
🪛 Ruff (0.15.17)
[warning] 164-164: Store a reference to the return value of
asyncio.create_task(RUF006)
🤖 Prompt for AI Agents
Source: Linters/SAST tools