-
-
Notifications
You must be signed in to change notification settings - Fork 20
fix: make P2P networking work, and let a node mine on its own #143
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?
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 |
|---|---|---|
|
|
@@ -26,6 +26,7 @@ | |
| import sys | ||
| import os | ||
| import json | ||
| import time | ||
|
|
||
| from nacl.signing import SigningKey | ||
| from nacl.encoding import HexEncoder | ||
|
|
@@ -38,9 +39,6 @@ | |
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| TRUSTED_PEERS = set() | ||
| LOCALHOST_PEERS = {"127.0.0.1", "::1", "localhost", "0:0:0:0:0:0:0:1"} | ||
|
|
||
|
|
||
| # ────────────────────────────────────────────── | ||
| # Wallet helpers | ||
|
|
@@ -120,11 +118,15 @@ async def submit_and_broadcast(chain, mempool, network, tx, ok_msg, reject_msg): | |
| # ────────────────────────────────────────────── | ||
|
|
||
| def mine_and_process_block(chain, mempool, miner_pk): | ||
| """Mine pending transactions into a new block.""" | ||
| """ | ||
| Mine pending transactions into a new block. | ||
|
|
||
| A block with no transactions is still mined: it carries the mining reward and | ||
| adds proof-of-work, so the chain keeps advancing while the network is idle. | ||
| Requiring a transaction would also make a fresh chain unusable, since coins | ||
| only come into existence through the mining reward. | ||
| """ | ||
| pending_txs = mempool.get_transactions_for_block() | ||
| if not pending_txs: | ||
| logger.info("Mempool is empty — nothing to mine.") | ||
| return None | ||
|
|
||
| # Filter queue candidates against a temporary state snapshot. | ||
| temp_state = chain.state.copy() | ||
|
|
@@ -147,17 +149,19 @@ def mine_and_process_block(chain, mempool, miner_pk): | |
| if stale_txs: | ||
| mempool.remove_transactions(stale_txs) | ||
|
|
||
| if not mineable_txs: | ||
| logger.info("No mineable transactions in current queue window.") | ||
| return None | ||
|
|
||
| total_fees = sum(getattr(r, 'gas_used', 0) * getattr(tx, 'fee_per_gas', 0) for r, tx in zip(receipts, mineable_txs)) | ||
| temp_state.credit_mining_reward(miner_pk, reward=temp_state.DEFAULT_MINING_REWARD + total_fees) | ||
|
|
||
| # Timestamps are milliseconds and a block must be strictly newer than its parent. | ||
| # Blocks can be mined inside the same millisecond, so clamp forward rather than | ||
| # letting the miner build a block the chain will reject. | ||
| timestamp = max(round(time.time() * 1000), chain.last_block.timestamp + 1) | ||
|
|
||
| block = Block( | ||
| index=chain.last_block.index + 1, | ||
| previous_hash=chain.last_block.hash, | ||
| transactions=mineable_txs, | ||
| timestamp=timestamp, | ||
| state_root=temp_state.state_root(), | ||
| receipt_root=calculate_receipt_root(receipts), | ||
| receipts=receipts, | ||
|
|
@@ -505,6 +509,8 @@ def print_prompt_info(current_pk): | |
| # ── peers ── | ||
| elif cmd == "peers": | ||
| print(f" Connected peers: {network.peer_count}") | ||
| for peer_id in network.peer_ids: | ||
| print(f" {peer_id}") | ||
|
|
||
| # ── connect ── | ||
| elif cmd == "connect": | ||
|
|
@@ -605,7 +611,8 @@ def print_prompt_info(current_pk): | |
| # Main entry point | ||
| # ────────────────────────────────────────────── | ||
|
|
||
| async def run_node(port: int, host: str, connect_to: str | None, fund: int, datadir: str | None): | ||
| async def run_node(port: int, host: str, connect_to: str | None, fund: int, datadir: str | None, | ||
| rpc_host: str = "127.0.0.1"): | ||
| """Boot the node, optionally connect to a peer, then enter the CLI.""" | ||
| sk, pk = load_or_create_wallet(datadir) | ||
|
|
||
|
|
@@ -656,7 +663,7 @@ async def on_peer_connected(writer): | |
|
|
||
| # Start RPC server on a port correlated to the node port (e.g. 8545 if P2P is 9000) | ||
| rpc_port = 8545 + (port - 9000) | ||
|
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. Shouldn't we move these magic numbers to the config file? |
||
| await rpc_server.start(host="127.0.0.1", port=rpc_port) | ||
| await rpc_server.start(host=rpc_host, port=rpc_port) | ||
|
|
||
| # Fund this node's wallet so it can transact in the demo | ||
| if fund > 0: | ||
|
|
@@ -690,6 +697,7 @@ def main(): | |
| parser.add_argument("--connect", type=str, default=None, help="Peer address to connect to (multiaddr)") | ||
| parser.add_argument("--fund", type=int, default=100, help="Initial coins to fund this wallet (default: 100)") | ||
| parser.add_argument("--datadir", type=str, default=".minichain", help="Directory to save/load blockchain state (enables persistence)") | ||
| parser.add_argument("--rpc-host", type=str, default="127.0.0.1", help="Host/IP to bind the JSON-RPC server (default: 127.0.0.1, loopback-only)") | ||
| args = parser.parse_args() | ||
|
|
||
| logging.basicConfig( | ||
|
|
@@ -699,7 +707,8 @@ def main(): | |
| ) | ||
|
|
||
| try: | ||
| asyncio.run(run_node(args.port, args.host, args.connect, args.fund, args.datadir)) | ||
| asyncio.run(run_node(args.port, args.host, args.connect, args.fund, args.datadir, | ||
| rpc_host=args.rpc_host)) | ||
| except KeyboardInterrupt: | ||
| print("\nNode shut down.") | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,7 +11,12 @@ | |
| import struct | ||
| import trio | ||
| import queue | ||
| from .node_config import MALFORMED_THRESHOLD, FAILED_THRESHOLD, INVALID_THRESHOLD, DECAY_INTERVAL_MINUTES | ||
| from collections import OrderedDict | ||
|
|
||
| from .node_config import ( | ||
| MALFORMED_THRESHOLD, FAILED_THRESHOLD, INVALID_THRESHOLD, DECAY_INTERVAL_MINUTES, | ||
| SEEN_CACHE_MAX, | ||
| ) | ||
| from .network_config import SUPPORTED_MESSAGE_TYPES, PROTOCOL_ID, MAX_FRAME_BYTES | ||
|
|
||
| from libp2p import new_host | ||
|
|
@@ -48,12 +53,16 @@ def __init__( | |
| ): | ||
| self._handler_callback = handler_callback | ||
| self._on_peer_connected = None | ||
| self._seen_tx_ids = set() | ||
| self._seen_block_hashes = set() | ||
| # OrderedDict as an LRU: unbounded sets here would let a live node accumulate | ||
| # tx/block ids forever, and since relaying now depends on this set to stop | ||
| # echoes circulating, its size is no longer just a memory concern. | ||
| self._seen_tx_ids = OrderedDict() | ||
| self._seen_block_hashes = OrderedDict() | ||
| self._to_trio = queue.Queue() | ||
| self._to_asyncio = queue.Queue() | ||
| self._peer_count = 0 | ||
| self._peer_count_lock = threading.Lock() | ||
| self._peer_ids: set = set() | ||
|
|
||
| # Misbehavior tracking, keyed directly by ValidationStatus so there is a | ||
| # single vocabulary for statuses (no parallel string keys to convert). | ||
|
|
@@ -111,11 +120,16 @@ def _is_duplicate(self, msg_type, payload): | |
|
|
||
| def _mark_seen(self, msg_type, payload): | ||
| mid = self._message_id(msg_type, payload) | ||
| if mid: | ||
| self._seen_set(msg_type).add(mid) | ||
| if not mid: | ||
| return | ||
| seen = self._seen_set(msg_type) | ||
| seen[mid] = True | ||
| seen.move_to_end(mid) | ||
| if len(seen) > SEEN_CACHE_MAX: | ||
| seen.popitem(last=False) | ||
|
|
||
| async def _broadcast_raw(self, payload: dict): | ||
| self._to_trio.put(("BROADCAST", payload)) | ||
| async def _broadcast_raw(self, payload: dict, exclude: str | None = None): | ||
| self._to_trio.put(("BROADCAST", (payload, exclude))) | ||
|
|
||
| async def _unicast_raw(self, target_addr: str, payload: dict): | ||
| self._to_trio.put(("UNICAST", (target_addr, payload))) | ||
|
|
@@ -144,6 +158,11 @@ def peer_count(self) -> int: | |
| with self._peer_count_lock: | ||
| return self._peer_count | ||
|
|
||
| @property | ||
| def peer_ids(self) -> list: | ||
| with self._peer_count_lock: | ||
| return list(self._peer_ids) | ||
|
|
||
| # ── misbehavior helpers ────────────────────────────────────────────────── | ||
|
|
||
| def _increment_counter(self, peer_id: str, status: ValidationStatus) -> bool: | ||
|
|
@@ -237,6 +256,17 @@ async def _asyncio_reader(self): | |
| except Exception: | ||
| pass | ||
|
|
||
| # Relay accepted content onward, so gossip travels further than one | ||
| # hop. Only VALID is forwarded: rejected content is never amplified, | ||
| # and a None status (hello/chain_request/chain_response) is not | ||
| # relayable at all. Excluding the sender avoids a pointless echo, and | ||
| # _mark_seen above means an echo arriving from another peer is dropped | ||
| # as a duplicate, so cycles in the peer graph terminate. | ||
| if msg_type in ("tx", "block") and status == ValidationStatus.VALID: | ||
| await self._broadcast_raw( | ||
| {"type": msg_type, "data": payload}, exclude=peer_addr | ||
| ) | ||
|
|
||
| elif msg[0] == "MALFORMED": | ||
| # JSON parse failure signalled from the Trio thread. | ||
| peer_addr = msg[1] | ||
|
|
@@ -262,7 +292,14 @@ async def drain(self): pass | |
| async def _trio_main(self): | ||
| host = new_host() | ||
| listen_addr = Multiaddr(f"/ip4/{self.host_addr}/tcp/{self.port}") | ||
| await host.get_network().listen(listen_addr) | ||
| # Swarm.listen() waits on a nursery that only exists while the swarm runs as | ||
| # a background service, so listening must go through host.run(). Calling | ||
| # host.get_network().listen() directly blocks forever and the node comes up | ||
| # with no networking at all. | ||
| async with host.run(listen_addrs=[listen_addr]): | ||
| await self._serve(host, listen_addr) | ||
|
|
||
| async def _serve(self, host, listen_addr): | ||
| print(f" Network Multiaddr: {listen_addr}/p2p/{host.get_id().to_string()}") | ||
|
|
||
| streams = [] | ||
|
|
@@ -283,6 +320,7 @@ async def stream_handler(stream): | |
| streams.append(stream) | ||
| with self._peer_count_lock: | ||
| self._peer_count += 1 | ||
| self._peer_ids.add(peer_id) | ||
| self._to_asyncio.put(("PEER_CONNECTED", None)) | ||
|
|
||
| try: | ||
|
|
@@ -313,10 +351,11 @@ async def stream_handler(stream): | |
| streams.remove(stream) | ||
| with self._peer_count_lock: | ||
| self._peer_count -= 1 | ||
| self._peer_ids.discard(peer_id) | ||
|
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. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: printf '%s\n' '--- repository conventions ---'
find /tmp/coderabbit-repo-knowledge/stabilitynexus-minichain-080fa12a -type f -path '*/conventions/*.md' -print -exec sh -c 'head -5 "$1"; cat "$1"' _ {} \;
printf '%s\n' '--- target file outline ---'
ast-grep outline minichain/p2p.py
printf '%s\n' '--- relevant source ---'
sed -n '1,120p' minichain/p2p.py
sed -n '270,380p' minichain/p2p.py
printf '%s\n' '--- peer_ids consumers and stream lifecycle references ---'
rg -n -C 3 'peer_ids|_peer_ids|streams|PROTOCOL_ID|_handler_callback' minichain/p2p.py minichain/main.pyRepository: StabilityNexus/MiniChain Length of output: 23617 🏁 Script executed: printf '%s\n' '--- peer_ids consumer ---'
fd -t f -i 'main.py' .
rg -n -C 4 'peer_ids|peer_count' $(fd -t f -e py . minichain 2>/dev/null)
printf '%s\n' '--- stream lifecycle and tests ---'
sed -n '145,170p' minichain/p2p.py
sed -n '358,414p' minichain/p2p.py
rg -n -C 4 'P2PNetwork|peer_ids|new_stream|set_stream_handler|stream_handler' -g '*.py' .Repository: StabilityNexus/MiniChain Length of output: 21926 Keep each peer ID until all streams for that peer close. 🤖 Prompt for AI Agents |
||
|
|
||
| host.set_stream_handler(PROTOCOL_ID, stream_handler) | ||
|
|
||
| async def check_queue(): | ||
| async def check_queue(nursery): | ||
| while True: | ||
| try: | ||
| while not self._to_trio.empty(): | ||
|
|
@@ -329,12 +368,18 @@ async def check_queue(): | |
| info = info_from_p2p_addr(maddr) | ||
| await host.connect(info) | ||
| stream = await host.new_stream(info.peer_id, [PROTOCOL_ID]) | ||
| host.get_network().nursery.start_soon(stream_handler, stream) | ||
| # Read the outbound stream in our own nursery: the swarm | ||
| # exposes no nursery to borrow, and without this the | ||
| # dialing side never registers the peer or reads from it. | ||
| nursery.start_soon(stream_handler, stream) | ||
| except Exception as e: | ||
| logger.error(f"Dial error: {e}") | ||
| elif cmd == "BROADCAST": | ||
| msg = (canonical_json_dumps(arg) + "\n").encode() | ||
| payload, exclude = arg | ||
| msg = (canonical_json_dumps(payload) + "\n").encode() | ||
| for s in list(streams): | ||
| if exclude and f"peer:{s.muxed_conn.peer_id}" == exclude: | ||
| continue | ||
| try: | ||
| await s.write(msg) | ||
| except Exception: | ||
|
|
@@ -361,13 +406,14 @@ async def check_queue(): | |
| streams.remove(s) | ||
| with self._peer_count_lock: | ||
| self._peer_count -= 1 | ||
| self._peer_ids.discard(str(s.muxed_conn.peer_id)) | ||
| except Exception: | ||
| pass | ||
| await trio.sleep(0.1) | ||
|
|
||
| async with trio.open_nursery() as nursery: | ||
| async def run_monitor(): | ||
| if await check_queue(): | ||
| if await check_queue(nursery): | ||
| await host.close() | ||
| nursery.cancel_scope.cancel() | ||
| nursery.start_soon(run_monitor) | ||
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.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Add a blank line before the fenced code block.
Insert one blank line between the paragraph and Line 93. This fixes markdownlint rule MD031.
🧰 Tools
🪛 markdownlint-cli2 (0.23.2)
[warning] 93-93: Fenced code blocks should be surrounded by blank lines
(MD031, blanks-around-fences)
🤖 Prompt for AI Agents
Source: Linters/SAST tools