netget/server/bitcoin/mod.rs
1//! Bitcoin P2P protocol server implementation
2pub mod actions;
3
4use anyhow::{Context, Result};
5use bitcoin::consensus::Decodable;
6use bitcoin::p2p::message::{NetworkMessage, RawNetworkMessage};
7use bitcoin::p2p::Magic;
8use std::collections::HashMap;
9use std::io::Cursor;
10use std::net::SocketAddr;
11use std::sync::Arc;
12use tokio::io::{AsyncReadExt, AsyncWriteExt};
13use tokio::net::TcpStream;
14use tokio::sync::{mpsc, Mutex};
15use tracing::debug;
16
17use super::connection::ConnectionId;
18use crate::llm::action_helper::call_llm;
19use crate::llm::ollama_client::OllamaClient;
20use crate::llm::ActionResult;
21use crate::logging::emit::Log;
22use crate::protocol::Event;
23use crate::server::BitcoinProtocol;
24use crate::state::app_state::AppState;
25use actions::{BITCOIN_CONNECTION_OPENED_EVENT, BITCOIN_MESSAGE_RECEIVED_EVENT};
26
27/// Connection state for LLM processing
28#[derive(Debug, Clone, PartialEq)]
29enum ConnectionState {
30 Idle,
31 Processing,
32 Accumulating,
33}
34
35/// Per-connection data for Bitcoin protocol
36struct ConnectionData {
37 state: ConnectionState,
38 queued_data: Vec<u8>,
39 write_half: Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
40 #[allow(dead_code)]
41 handshake_complete: bool,
42}
43
44/// Bitcoin Core's own cap on a P2P message body, and the largest single message this server
45/// will accept before closing the peer.
46///
47/// Module level rather than inside `try_parse_bitcoin_message`, so `metadata()` can declare
48/// the constant the parser actually enforces instead of repeating the number.
49pub const MAX_MESSAGE_BYTES: usize = 4_000_000;
50
51/// How long to wait for a peer's first bytes after it connects.
52///
53/// Bitcoin Core's own number for the same thing: `DEFAULT_PEER_CONNECT_TIMEOUT` is 60 seconds,
54/// after which a peer that has not completed the version handshake is disconnected. This bound
55/// is on the first *byte* rather than on the handshake, so it is strictly more permissive than
56/// Core's and cannot close a peer Core would keep.
57const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
58
59/// How long a peer that has already sent something may send nothing further.
60///
61/// Bitcoin has a keepalive and every node uses it: Core pings on a `PING_INTERVAL` of **2
62/// minutes** and disconnects a peer that has sent nothing for `TIMEOUT_INTERVAL`, **20 minutes**.
63/// Thirty minutes therefore sits above Core's own inactivity limit — a peer this server closes
64/// is one Core would already have dropped — and fifteen times above the interval at which a live
65/// node speaks.
66///
67/// The margin over 20 minutes matters for a reason specific to NetGet: `handle_data_with_actions`
68/// runs on its own task, so the reader keeps reading while a message is being answered, and a
69/// peer waiting on a model round-trip — or on a `manual` rule parked for a human — is silent on
70/// this socket for the whole of that work.
71const IDLE_BETWEEN_MESSAGES_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1800);
72
73/// Concurrent connections this server admits.
74///
75/// **125**, which is Bitcoin Core's `-maxconnections` default rather than this project's shared
76/// 256: a P2P node's connection count is part of how it behaves, and a Bitcoin server that
77/// admitted twice what Core does would be conspicuous. Each admitted connection may buffer a
78/// message of up to [`MAX_MESSAGE_BYTES`] (4 MB) before anything is handshaked, so the cap is
79/// also what turns that per-connection bound into a total one — at 125 it is 500 MB rather than
80/// a gigabyte.
81const MAX_CONNECTIONS: usize = 125;
82
83/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes: **nothing**.
84///
85/// Bitcoin P2P has no "busy" message and no error message of any kind — `reject` was removed in
86/// Core 0.20 and never applied to a connection anyway — and every message this server could send
87/// is a positive assertion about a node that has not handshaked. Core itself simply drops the
88/// connection when it is over `-maxconnections`. So the refusal is a plain close and the reason
89/// lives in the log, under the `decision=fail_closed_connection_cap` tag `accept_bounded` writes.
90const CONNECTION_CAP_REFUSAL: &[u8] = b"";
91
92/// Bitcoin P2P protocol server
93pub struct BitcoinServer;
94
95impl BitcoinServer {
96 /// Spawn the Bitcoin P2P server with integrated LLM actions
97 pub async fn spawn_with_llm_actions(
98 listen_addr: SocketAddr,
99 llm_client: OllamaClient,
100 app_state: Arc<AppState>,
101 status_tx: mpsc::UnboundedSender<String>,
102 server_id: crate::state::ServerId,
103 network: String,
104 ) -> Result<SocketAddr> {
105 // Parse network magic
106 let magic = match network.to_lowercase().as_str() {
107 "mainnet" | "main" => Magic::BITCOIN,
108 "testnet" | "test" => Magic::TESTNET3,
109 "signet" => Magic::SIGNET,
110 "regtest" => Magic::REGTEST,
111 _ => {
112 Log::new(Some(&status_tx)).info(format!(
113 "Unknown network '{}', defaulting to mainnet",
114 network
115 ));
116 Magic::BITCOIN
117 }
118 };
119
120 // Create and bind TCP server
121 let listener =
122 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
123 let local_addr = listener.local_addr()?;
124 Log::new(Some(&status_tx)).info(format!(
125 "Bitcoin P2P server listening on {} (network: {:?})",
126 local_addr, magic
127 ));
128
129 let connections = Arc::new(Mutex::new(HashMap::new()));
130 let protocol = Arc::new(BitcoinProtocol::new());
131
132 // Spawn accept loop
133 let task_registrar = app_state.clone();
134 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
135 let accept_handle = tokio::spawn(async move {
136 loop {
137 match crate::server::accept_bounded::accept_bounded(
138 &listener,
139 &limiter,
140 CONNECTION_CAP_REFUSAL,
141 "Bitcoin",
142 Some(&status_tx),
143 )
144 .await
145 {
146 Ok((stream, remote_addr, permit)) => {
147 let connection_id =
148 ConnectionId::new(app_state.get_next_unified_id().await);
149 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
150 Log::new(Some(&status_tx)).info(format!(
151 "Accepted Bitcoin P2P connection {} from {}",
152 connection_id, remote_addr
153 ));
154
155 // Split stream
156 let (read_half, write_half) = tokio::io::split(stream);
157 let write_half_arc = Arc::new(Mutex::new(write_half));
158
159 // Add connection to ServerInstance
160 use crate::state::server::{
161 ConnectionState as ServerConnectionState, ConnectionStatus,
162 ProtocolConnectionInfo,
163 };
164 let now = crate::utils::clock::Instant::now();
165 let conn_state = ServerConnectionState {
166 id: connection_id,
167 remote_addr,
168 local_addr: local_addr_conn,
169 bytes_sent: 0,
170 bytes_received: 0,
171 packets_sent: 0,
172 packets_received: 0,
173 last_activity: now,
174 status: ConnectionStatus::Active,
175 status_changed_at: now,
176 protocol_info: ProtocolConnectionInfo::empty(),
177 };
178 app_state
179 .add_connection_to_server(server_id, conn_state)
180 .await;
181 let _ = status_tx.send("__UPDATE_UI__".to_string());
182
183 // Peer messaging: the dashboard's "message this peer" / "disconnect
184 // this peer" inject actions into THIS connection through the same
185 // executor the LLM path uses. Registered before the opened event so a
186 // manual `*` rule parking that event leaves the operator able to reach
187 // the connection while it waits.
188 let peer_rx = crate::server::peer_support::register_peer_channel(
189 &app_state,
190 server_id,
191 connection_id.as_u32(),
192 )
193 .await;
194 crate::server::peer_support::spawn_peer_command_task(
195 peer_rx,
196 protocol.clone(),
197 app_state.clone(),
198 server_id,
199 connection_id.as_u32(),
200 write_half_arc.clone(),
201 status_tx.clone(),
202 );
203
204 // Register the connection *before* either task is spawned.
205 //
206 // This used to happen inside `handle_connection_opened`, which runs in
207 // its own task alongside the reader task below — so a peer that sent
208 // its first message promptly raced the insert. `handle_data_with_actions`
209 // finds no entry, returns at its "connection not found" guard, and the
210 // bytes are dropped with no event, no queue and no log line: the read
211 // loop reports "received N bytes" and nothing further ever happens.
212 //
213 // A Bitcoin peer sends `version` immediately on connect, so this was hit
214 // constantly — three E2E tests waited out their 120s read timeouts, and
215 // flakily, since it is a race.
216 connections.lock().await.insert(
217 connection_id,
218 ConnectionData {
219 state: ConnectionState::Idle,
220 queued_data: Vec::new(),
221 write_half: write_half_arc.clone(),
222 handshake_complete: false,
223 },
224 );
225
226 // Handle connection opened event
227 let llm_client_clone = llm_client.clone();
228 let app_state_clone = app_state.clone();
229 let status_tx_clone = status_tx.clone();
230 let connections_clone = connections.clone();
231 let write_half_for_conn = write_half_arc.clone();
232 let protocol_clone = protocol.clone();
233 let magic_clone = magic;
234 // Tracked, not detached: stop_server must abort this task too.
235 let task_owner = app_state.clone();
236 task_owner
237 .spawn_server_task(server_id, async move {
238 Self::handle_connection_opened(
239 connection_id,
240 server_id,
241 llm_client_clone,
242 app_state_clone,
243 status_tx_clone,
244 connections_clone,
245 write_half_for_conn,
246 protocol_clone,
247 magic_clone,
248 )
249 .await;
250 })
251 .await;
252
253 // Spawn reader task
254 let llm_client_clone = llm_client.clone();
255 let app_state_clone = app_state.clone();
256 let status_tx_clone = status_tx.clone();
257 let connections_clone = connections.clone();
258 let protocol_clone = protocol.clone();
259 let magic_clone = magic;
260 // Tracked, not detached: stop_server must abort this task too.
261 let task_owner = app_state.clone();
262 task_owner
263 .spawn_server_task(server_id, async move {
264 // Held for the life of the connection. The reader is the task
265 // that lives as long as the socket does — the connection-opened
266 // task above answers one event and ends — so the slot is
267 // released exactly when the peer is gone.
268 let _permit = permit;
269 let mut buffer = vec![0u8; 8192];
270 let mut read_half = read_half;
271 // "Has said nothing at all" and "has gone quiet mid-session"
272 // are different claims and get different deadlines.
273 let mut spoke_once = false;
274
275 loop {
276 let read_deadline = if spoke_once {
277 IDLE_BETWEEN_MESSAGES_TIMEOUT
278 } else {
279 FIRST_BYTE_READ_TIMEOUT
280 };
281 // The deadline wraps this read and nothing else:
282 // `handle_data_with_actions` answers on its own task, so a
283 // model round-trip and a `manual` rule parked for a human
284 // are outside it by construction.
285 let read_result = match tokio::time::timeout(
286 read_deadline,
287 read_half.read(&mut buffer),
288 )
289 .await
290 {
291 Ok(v) => v,
292 Err(_) => {
293 // Nothing on the wire: Bitcoin P2P has no message a
294 // node may send to explain a disconnect, and Core
295 // itself just drops. The reason is the log line.
296 Log::new(Some(&status_tx_clone)).info(format!(
297 "Bitcoin connection {} sent nothing for {}s; \
298 closing decision=fail_closed_idle_timeout",
299 connection_id,
300 read_deadline.as_secs()
301 ));
302 Self::teardown_connection(
303 &connections_clone,
304 &app_state_clone,
305 server_id,
306 connection_id,
307 )
308 .await;
309 let _ =
310 status_tx_clone.send("__UPDATE_UI__".to_string());
311 break;
312 }
313 };
314 match read_result {
315 Ok(0) => {
316 // Connection closed
317 Self::teardown_connection(
318 &connections_clone,
319 &app_state_clone,
320 server_id,
321 connection_id,
322 )
323 .await;
324 Log::new(Some(&status_tx_clone)).info(format!(
325 "Bitcoin connection {} closed",
326 connection_id
327 ));
328 let _ =
329 status_tx_clone.send("__UPDATE_UI__".to_string());
330 break;
331 }
332 Ok(n) => {
333 spoke_once = true;
334 let data = &buffer[..n];
335 app_state_clone
336 .update_connection_stats(
337 server_id,
338 connection_id,
339 Some(n as u64),
340 None,
341 Some(1),
342 None,
343 )
344 .await;
345
346 // Byte-count summary and full hex payload are
347 // FileOnly: the bitcoin_message_received event
348 // template reports the message to the TUI, so
349 // streaming raw bytes here would duplicate it and
350 // load the unbounded status channel.
351 let log = Log::new(Some(&status_tx_clone));
352 log.debug(format!(
353 "Bitcoin P2P received {} bytes on {}",
354 n, connection_id
355 ));
356 log.trace(format!(
357 "Bitcoin P2P data (hex): {}",
358 hex::encode(data)
359 ));
360
361 // Handle data in separate task
362 let llm_clone = llm_client_clone.clone();
363 let state_clone = app_state_clone.clone();
364 let status_clone = status_tx_clone.clone();
365 let conns_clone = connections_clone.clone();
366 let protocol_clone = protocol_clone.clone();
367 let data_vec = data.to_vec();
368 // Tracked, not detached: stop_server must abort this task too.
369 let task_owner = app_state_clone.clone();
370 task_owner
371 .spawn_server_task(server_id, async move {
372 Self::handle_data_with_actions(
373 connection_id,
374 server_id,
375 data_vec,
376 llm_clone,
377 state_clone,
378 status_clone,
379 conns_clone,
380 protocol_clone,
381 magic_clone,
382 )
383 .await;
384 })
385 .await;
386 }
387 Err(e) => {
388 Log::new(Some(&status_tx_clone)).error(format!(
389 "Read error on Bitcoin connection {}: {}",
390 connection_id, e
391 ));
392 Self::teardown_connection(
393 &connections_clone,
394 &app_state_clone,
395 server_id,
396 connection_id,
397 )
398 .await;
399 let _ =
400 status_tx_clone.send("__UPDATE_UI__".to_string());
401 break;
402 }
403 }
404 }
405 })
406 .await;
407 }
408 Err(e) => {
409 Log::new(Some(&status_tx))
410 .error(format!("Accept error on Bitcoin server: {}", e));
411 break;
412 }
413 }
414 }
415 });
416
417 // Register the accept loop so stop_server can abort it and release the port.
418 task_registrar
419 .register_server_task(server_id, accept_handle)
420 .await;
421
422 Ok(local_addr)
423 }
424
425 /// Handle new connection opened event
426 async fn handle_connection_opened(
427 connection_id: ConnectionId,
428 server_id: crate::state::ServerId,
429 llm_client: OllamaClient,
430 app_state: Arc<AppState>,
431 status_tx: mpsc::UnboundedSender<String>,
432 connections: Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
433 write_half: Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
434 protocol: Arc<BitcoinProtocol>,
435 magic: Magic,
436 ) {
437 // The connection is registered by the accept loop before this task and the reader task
438 // are spawned — inserting it here raced the reader and silently dropped whatever the
439 // peer sent first. Deliberately not re-inserted: doing so would reset the state machine
440 // and discard any bytes the reader has already queued. (`connections` is still used
441 // below, to remove the entry when the model closes the connection.)
442
443 // Create connection opened event
444 let event = Event::new(&BITCOIN_CONNECTION_OPENED_EVENT, serde_json::json!({}));
445
446 // Call LLM to decide what to do (wait or send version message)
447 match call_llm(
448 &llm_client,
449 &app_state,
450 server_id,
451 Some(connection_id),
452 &event,
453 protocol.as_ref(),
454 )
455 .await
456 {
457 Ok(execution_result) => {
458 debug!("LLM Bitcoin connection opened response received");
459
460 // Display messages
461 for msg in execution_result.messages {
462 let _ = status_tx.send(msg);
463 }
464
465 // The model answering with no action is a real answer here - most of the
466 // Bitcoin handshake is peer-driven and "say nothing, wait for their version"
467 // is correct - so it stays silent. It is still tagged, because in the log it
468 // must not look like the LLM-error path below.
469 if execution_result.protocol_results.is_empty() {
470 Log::new(Some(&status_tx)).debug(format!(
471 "Bitcoin connection {} opened decision=model_no_action (no message sent)",
472 connection_id
473 ));
474 }
475
476 // Handle protocol results
477 for protocol_result in execution_result.protocol_results {
478 match protocol_result {
479 ActionResult::Output(output_data) => {
480 if let Err(e) = Self::send_bitcoin_message(
481 &write_half,
482 &output_data,
483 connection_id,
484 server_id,
485 &app_state,
486 &status_tx,
487 magic,
488 )
489 .await
490 {
491 Log::new(Some(&status_tx))
492 .error(format!("Failed to send Bitcoin message: {}", e));
493 }
494 }
495 ActionResult::CloseConnection => {
496 Self::close_from_model(
497 &write_half,
498 &connections,
499 &app_state,
500 server_id,
501 connection_id,
502 )
503 .await;
504 Log::new(Some(&status_tx)).info(format!(
505 "Closed Bitcoin connection {} after connection opened \
506 decision=model_close",
507 connection_id
508 ));
509 }
510 _ => {}
511 }
512 }
513 }
514 Err(e) => {
515 Self::fail_closed(
516 &write_half,
517 &connections,
518 &app_state,
519 &status_tx,
520 server_id,
521 connection_id,
522 &e,
523 "connection opened",
524 )
525 .await;
526 }
527 }
528 }
529
530 /// The LLM call itself failed. Bitcoin P2P has no error frame a modern peer will act on -
531 /// BIP61 `reject` was removed from Bitcoin Core in 0.20 and is ignored by everything on
532 /// the network today - so the only signal that reaches the peer is a disconnect, which is
533 /// exactly what a real node does when it cannot serve a connection. Half-close the write
534 /// side so the peer reads EOF at once and moves on to another peer, instead of blocking
535 /// until its own timeout.
536 ///
537 /// Nothing derived from the error is written to the socket: FIN carries no text, and the
538 /// error goes to the log and the status stream, where an operator looks. The overload and
539 /// non-overload categories cannot be distinguished on the wire because Bitcoin has only
540 /// this one shape, so they are distinguished in the log instead - `decision=` is stable so
541 /// an operator can grep for it.
542 #[allow(clippy::too_many_arguments)]
543 async fn fail_closed(
544 write_half: &Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
545 connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
546 app_state: &Arc<AppState>,
547 status_tx: &mpsc::UnboundedSender<String>,
548 server_id: crate::state::ServerId,
549 connection_id: ConnectionId,
550 err: &anyhow::Error,
551 context: &str,
552 ) {
553 let decision = match crate::utils::WireFailure::classify(err) {
554 crate::utils::WireFailure::Overloaded => "fail_closed_llm_overloaded",
555 crate::utils::WireFailure::Unavailable => "fail_closed_llm_error",
556 };
557 let log = Log::new(Some(status_tx));
558 // The error text belongs here, never on the wire.
559 log.warn(format!(
560 "LLM error on Bitcoin {} for connection {} decision={}: {}",
561 context, connection_id, decision, err
562 ));
563 Self::close_from_model(write_half, connections, app_state, server_id, connection_id).await;
564 log.info(format!(
565 "Closed Bitcoin connection {} decision={}",
566 connection_id, decision
567 ));
568 let _ = status_tx.send("__UPDATE_UI__".to_string());
569 }
570
571 /// Handle data received on a connection with LLM actions
572 async fn handle_data_with_actions(
573 connection_id: ConnectionId,
574 server_id: crate::state::ServerId,
575 data: Vec<u8>,
576 llm_client: OllamaClient,
577 app_state: Arc<AppState>,
578 status_tx: mpsc::UnboundedSender<String>,
579 connections: Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
580 protocol: Arc<BitcoinProtocol>,
581 magic: Magic,
582 ) {
583 // Check connection state
584 let current_state = {
585 let conns = connections.lock().await;
586 if let Some(conn_data) = conns.get(&connection_id) {
587 conn_data.state.clone()
588 } else {
589 // Never silent. A miss here means the connection was torn down between the
590 // read and this lookup (peer reset, or close_this_connection on another
591 // task) - legitimate, but indistinguishable from a registration race, which
592 // is exactly what made the bitcoin accept-order bug so hard to find: the
593 // read loop logged "received N bytes" and then nothing at all.
594 debug!(
595 "Bitcoin connection {} is no longer registered; dropping {} received bytes",
596 connection_id,
597 data.len()
598 );
599 return;
600 }
601 };
602
603 // If processing, queue the data
604 if current_state == ConnectionState::Processing {
605 connections
606 .lock()
607 .await
608 .entry(connection_id)
609 .and_modify(|conn| {
610 conn.queued_data.extend_from_slice(&data);
611 });
612 debug!(
613 "Queued {} bytes for Bitcoin connection {}",
614 data.len(),
615 connection_id
616 );
617 let _ = status_tx.send(format!(
618 "⏸ Queued {} bytes for {}",
619 data.len(),
620 connection_id
621 ));
622 return;
623 }
624
625 // Merge any queued data with new data.
626 //
627 // The lock was released after the state check above, so the reader task's EOF branch
628 // or a close_this_connection may have removed this entry in between - `.unwrap()` here
629 // panicked the task whenever a peer disconnected while a datagram was in flight.
630 let Some(mut buffer) = ({
631 let mut conns = connections.lock().await;
632 conns.get_mut(&connection_id).map(|conn_data| {
633 conn_data.state = ConnectionState::Processing;
634 let mut merged = std::mem::take(&mut conn_data.queued_data);
635 merged.extend_from_slice(&data);
636 merged
637 })
638 }) else {
639 debug!(
640 "Bitcoin connection {} went away before its data could be processed",
641 connection_id
642 );
643 return;
644 };
645
646 loop {
647 // Try to parse Bitcoin message
648 let parsed_message = Self::try_parse_bitcoin_message(&buffer, magic);
649
650 match parsed_message {
651 Ok(Some((message, remaining))) => {
652 // Consume what we just parsed. Leaving `buffer` untouched (the old
653 // behaviour, which dropped `remaining`) meant that whenever a peer
654 // pipelined a second message the loop re-parsed the *first* one and
655 // called the LLM on it again, without end.
656 buffer = remaining;
657
658 // Successfully parsed a message
659 let payload = message.payload();
660 let message_type = Self::get_message_type_name(payload);
661 Log::new(Some(&status_tx)).info(format!(
662 "Received Bitcoin message: {} from {}",
663 message_type, connection_id
664 ));
665
666 // Update connection info with message type
667 app_state
668 .update_bitcoin_connection_info(
669 server_id,
670 connection_id,
671 message_type.clone(),
672 )
673 .await;
674
675 // Get write_half for context
676 let write_half = {
677 let conns = connections.lock().await;
678 conns.get(&connection_id).map(|c| c.write_half.clone())
679 };
680
681 let Some(write_half) = write_half else {
682 return; // Connection not found
683 };
684
685 // Serialize message for LLM
686 let message_json = Self::serialize_message_to_json(payload);
687
688 // Create data received event
689 let event = Event::new(
690 &BITCOIN_MESSAGE_RECEIVED_EVENT,
691 serde_json::json!({
692 "message_type": message_type.clone(),
693 "message": message_json,
694 }),
695 );
696
697 // Call LLM
698 match call_llm(
699 &llm_client,
700 &app_state,
701 server_id,
702 Some(connection_id),
703 &event,
704 protocol.as_ref(),
705 )
706 .await
707 {
708 Ok(execution_result) => {
709 debug!("LLM Bitcoin response received");
710
711 // Display messages
712 for msg in execution_result.messages {
713 let _ = status_tx.send(msg);
714 }
715
716 // As on the opened event: no action is a legitimate answer for
717 // most message types (an `addr` or an `inv` needs no reply), so
718 // stay silent - but tag it, so the log never confuses it with the
719 // LLM-error path below.
720 if execution_result.protocol_results.is_empty() {
721 Log::new(Some(&status_tx)).debug(format!(
722 "Bitcoin {} on {} decision=model_no_action (no reply sent)",
723 message_type, connection_id
724 ));
725 }
726
727 // Handle protocol results
728 let mut should_close = false;
729
730 for protocol_result in execution_result.protocol_results {
731 match protocol_result {
732 ActionResult::Output(output_data) => {
733 if let Err(e) = Self::send_bitcoin_message(
734 &write_half,
735 &output_data,
736 connection_id,
737 server_id,
738 &app_state,
739 &status_tx,
740 magic,
741 )
742 .await
743 {
744 Log::new(Some(&status_tx)).error(format!(
745 "Failed to send Bitcoin response: {}",
746 e
747 ));
748 }
749 }
750 ActionResult::CloseConnection => {
751 should_close = true;
752 }
753 _ => {}
754 }
755 }
756
757 // Handle close_connection
758 if should_close {
759 Self::close_from_model(
760 &write_half,
761 &connections,
762 &app_state,
763 server_id,
764 connection_id,
765 )
766 .await;
767 let _ = status_tx.send("__UPDATE_UI__".to_string());
768 Log::new(Some(&status_tx)).info(format!(
769 "Closed Bitcoin connection {} decision=model_close",
770 connection_id
771 ));
772 return;
773 }
774
775 // Check for queued data
776 let has_queued = {
777 let conns = connections.lock().await;
778 conns
779 .get(&connection_id)
780 .map(|c| !c.queued_data.is_empty())
781 .unwrap_or(false)
782 };
783
784 if has_queued || !buffer.is_empty() {
785 Log::new(Some(&status_tx)).debug(format!(
786 "Processing queued data for Bitcoin connection {}",
787 connection_id
788 ));
789 // Fold anything that arrived during the LLM call onto the
790 // bytes we have not consumed yet, then go round again.
791 {
792 let mut conns = connections.lock().await;
793 if let Some(conn) = conns.get_mut(&connection_id) {
794 let queued = std::mem::take(&mut conn.queued_data);
795 buffer.extend_from_slice(&queued);
796 }
797 }
798 } else {
799 // Go to Idle state
800 connections
801 .lock()
802 .await
803 .entry(connection_id)
804 .and_modify(|conn| conn.state = ConnectionState::Idle);
805 return;
806 }
807 }
808 Err(e) => {
809 // This used to reset the state to Idle and write nothing, leaving
810 // a peer that had just sent us a `version` or a `ping` blocked
811 // until its own timeout with no indication anything went wrong.
812 Self::fail_closed(
813 &write_half,
814 &connections,
815 &app_state,
816 &status_tx,
817 server_id,
818 connection_id,
819 &e,
820 &format!("{} message", message_type),
821 )
822 .await;
823 return;
824 }
825 }
826 }
827 Ok(None) => {
828 // Need more data to complete message.
829 //
830 // try_parse_bitcoin_message only reports "incomplete" for a header that
831 // validates, so the outstanding bytes are bounded by MAX_MESSAGE_BYTES and
832 // this cannot be used to grow the buffer without limit.
833 Log::new(Some(&status_tx)).debug(format!(
834 "Incomplete Bitcoin message ({} bytes buffered), waiting for more on {}",
835 buffer.len(),
836 connection_id
837 ));
838 connections
839 .lock()
840 .await
841 .entry(connection_id)
842 .and_modify(|conn| {
843 conn.state = ConnectionState::Accumulating;
844 let mut pending = buffer;
845 // Anything that landed during the LLM call goes after it.
846 let queued = std::mem::take(&mut conn.queued_data);
847 pending.extend_from_slice(&queued);
848 conn.queued_data = pending;
849 });
850 return;
851 }
852 Err(e) => {
853 // Parse error
854 Log::new(Some(&status_tx)).error(format!(
855 "Failed to parse Bitcoin message on {}: {}",
856 connection_id, e
857 ));
858 connections
859 .lock()
860 .await
861 .entry(connection_id)
862 .and_modify(|conn| conn.state = ConnectionState::Idle);
863 return;
864 }
865 }
866 }
867 }
868
869 /// Try to parse one Bitcoin P2P message off the front of `data`.
870 ///
871 /// Returns `Ok(None)` **only** when the bytes so far are a valid prefix of a message whose
872 /// declared length has not arrived yet. Everything else - bad magic, an absurd length, a
873 /// body that will not decode - is `Err`, so the caller drops the connection instead of
874 /// buffering forever. The previous version mapped every decode failure to `Ok(None)`,
875 /// which turned a stream of garbage into unbounded memory growth: the caller kept the
876 /// whole thing as "incomplete" and re-parsed it on every read.
877 fn try_parse_bitcoin_message(
878 data: &[u8],
879 magic: Magic,
880 ) -> Result<Option<(RawNetworkMessage, Vec<u8>)>> {
881 // magic(4) | command(12) | length(4) | checksum(4)
882 const HEADER_LEN: usize = 24;
883
884 if data.len() < HEADER_LEN {
885 return Ok(None);
886 }
887
888 // Validate the header before trusting the length field.
889 let got_magic = Magic::from_bytes(
890 data[0..4]
891 .try_into()
892 .expect("4-byte slice is always convertible"),
893 );
894 if got_magic != magic {
895 return Err(anyhow::anyhow!(
896 "Magic bytes mismatch: expected {:?}, got {:?}",
897 magic,
898 got_magic
899 ));
900 }
901
902 let payload_len = u32::from_le_bytes(
903 data[16..20]
904 .try_into()
905 .expect("4-byte slice is always convertible"),
906 ) as usize;
907 if payload_len > MAX_MESSAGE_BYTES {
908 return Err(anyhow::anyhow!(
909 "Bitcoin message declares {} byte payload, over the {} byte limit",
910 payload_len,
911 MAX_MESSAGE_BYTES
912 ));
913 }
914
915 let total = HEADER_LEN + payload_len;
916 if data.len() < total {
917 // Genuinely incomplete, and bounded: at most MAX_MESSAGE_BYTES outstanding.
918 return Ok(None);
919 }
920
921 let mut cursor = Cursor::new(&data[..total]);
922 let message = RawNetworkMessage::consensus_decode(&mut cursor)
923 .map_err(|e| anyhow::anyhow!("Malformed Bitcoin message: {}", e))?;
924
925 Ok(Some((message, data[total..].to_vec())))
926 }
927
928 /// Get message type name from NetworkMessage
929 fn get_message_type_name(payload: &NetworkMessage) -> String {
930 match payload {
931 NetworkMessage::Version(_) => "version".to_string(),
932 NetworkMessage::Verack => "verack".to_string(),
933 NetworkMessage::Addr(_) => "addr".to_string(),
934 NetworkMessage::Inv(_) => "inv".to_string(),
935 NetworkMessage::GetData(_) => "getdata".to_string(),
936 NetworkMessage::NotFound(_) => "notfound".to_string(),
937 NetworkMessage::GetBlocks(_) => "getblocks".to_string(),
938 NetworkMessage::GetHeaders(_) => "getheaders".to_string(),
939 NetworkMessage::MemPool => "mempool".to_string(),
940 NetworkMessage::Tx(_) => "tx".to_string(),
941 NetworkMessage::Block(_) => "block".to_string(),
942 NetworkMessage::Headers(_) => "headers".to_string(),
943 NetworkMessage::SendHeaders => "sendheaders".to_string(),
944 NetworkMessage::GetAddr => "getaddr".to_string(),
945 NetworkMessage::Ping(_) => "ping".to_string(),
946 NetworkMessage::Pong(_) => "pong".to_string(),
947 NetworkMessage::MerkleBlock(_) => "merkleblock".to_string(),
948 NetworkMessage::FilterLoad(_) => "filterload".to_string(),
949 NetworkMessage::FilterAdd(_) => "filteradd".to_string(),
950 NetworkMessage::FilterClear => "filterclear".to_string(),
951 NetworkMessage::GetCFilters(_) => "getcfilters".to_string(),
952 NetworkMessage::CFilter(_) => "cfilter".to_string(),
953 NetworkMessage::GetCFHeaders(_) => "getcfheaders".to_string(),
954 NetworkMessage::CFHeaders(_) => "cfheaders".to_string(),
955 NetworkMessage::GetCFCheckpt(_) => "getcfcheckpt".to_string(),
956 NetworkMessage::CFCheckpt(_) => "cfcheckpt".to_string(),
957 NetworkMessage::SendCmpct(_) => "sendcmpct".to_string(),
958 NetworkMessage::CmpctBlock(_) => "cmpctblock".to_string(),
959 NetworkMessage::GetBlockTxn(_) => "getblocktxn".to_string(),
960 NetworkMessage::BlockTxn(_) => "blocktxn".to_string(),
961 NetworkMessage::Alert(_) => "alert".to_string(),
962 NetworkMessage::Reject(_) => "reject".to_string(),
963 NetworkMessage::FeeFilter(_) => "feefilter".to_string(),
964 NetworkMessage::WtxidRelay => "wtxidrelay".to_string(),
965 NetworkMessage::AddrV2(_) => "addrv2".to_string(),
966 NetworkMessage::SendAddrV2 => "sendaddrv2".to_string(),
967 NetworkMessage::Unknown { command, .. } => format!("unknown({})", command),
968 }
969 }
970
971 /// Serialize NetworkMessage to JSON for LLM
972 fn serialize_message_to_json(payload: &NetworkMessage) -> serde_json::Value {
973 match payload {
974 NetworkMessage::Version(v) => serde_json::json!({
975 "version": v.version,
976 "services": v.services.to_u64(),
977 "timestamp": v.timestamp,
978 "receiver": v.receiver.socket_addr().ok().map(|a| a.to_string()),
979 "sender": v.sender.socket_addr().ok().map(|a| a.to_string()),
980 "nonce": v.nonce,
981 "user_agent": v.user_agent,
982 "start_height": v.start_height,
983 "relay": v.relay,
984 }),
985 NetworkMessage::Ping(nonce) => serde_json::json!({ "nonce": nonce }),
986 NetworkMessage::Pong(nonce) => serde_json::json!({ "nonce": nonce }),
987 NetworkMessage::Addr(addrs) => {
988 let addr_strings: Vec<Option<String>> = addrs
989 .iter()
990 .map(|(_, addr)| addr.socket_addr().ok().map(|a| a.to_string()))
991 .collect();
992 serde_json::json!({ "count": addrs.len(), "addresses": addr_strings })
993 }
994 NetworkMessage::GetAddr => serde_json::json!({}),
995 NetworkMessage::Verack => serde_json::json!({}),
996 NetworkMessage::Inv(inv) => {
997 serde_json::json!({ "count": inv.len(), "inventory": inv.iter().map(|i| format!("{:?}", i)).collect::<Vec<_>>() })
998 }
999 // For other message types, provide basic info
1000 _ => serde_json::json!({ "type": Self::get_message_type_name(payload) }),
1001 }
1002 }
1003
1004 /// Forget a connection on every exit path of its reader task (EOF, read error): drop the
1005 /// state-machine entry, the dashboard's peer handle and the tracked connection.
1006 async fn teardown_connection(
1007 connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
1008 app_state: &Arc<AppState>,
1009 server_id: crate::state::ServerId,
1010 connection_id: ConnectionId,
1011 ) {
1012 connections.lock().await.remove(&connection_id);
1013 app_state
1014 .remove_peer_handle(server_id, connection_id.as_u32())
1015 .await;
1016 app_state
1017 .close_connection_on_server(server_id, connection_id)
1018 .await;
1019 }
1020
1021 /// `close_this_connection` from the model. Half-closes the write side so the peer reads
1022 /// EOF - previously this only dropped the map entry and the socket stayed open until the
1023 /// peer hung up - then runs the same teardown as the reader's exit paths. The reader task
1024 /// sees EOF once the peer closes and tears down again; every step is idempotent.
1025 async fn close_from_model(
1026 write_half: &Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
1027 connections: &Arc<Mutex<HashMap<ConnectionId, ConnectionData>>>,
1028 app_state: &Arc<AppState>,
1029 server_id: crate::state::ServerId,
1030 connection_id: ConnectionId,
1031 ) {
1032 {
1033 let mut write = write_half.lock().await;
1034 let _ = write.shutdown().await;
1035 }
1036 Self::teardown_connection(connections, app_state, server_id, connection_id).await;
1037 }
1038
1039 /// Send a Bitcoin message (raw bytes that will be wrapped in Bitcoin message format)
1040 #[allow(clippy::too_many_arguments)]
1041 async fn send_bitcoin_message(
1042 write_half: &Arc<Mutex<tokio::io::WriteHalf<TcpStream>>>,
1043 data: &[u8],
1044 connection_id: ConnectionId,
1045 server_id: crate::state::ServerId,
1046 app_state: &Arc<AppState>,
1047 status_tx: &mpsc::UnboundedSender<String>,
1048 _magic: Magic,
1049 ) -> Result<()> {
1050 {
1051 let mut write = write_half.lock().await;
1052 write
1053 .write_all(data)
1054 .await
1055 .context("Failed to write Bitcoin message")?;
1056 write
1057 .flush()
1058 .await
1059 .context("Failed to flush Bitcoin message")?;
1060 }
1061 app_state
1062 .update_connection_stats(
1063 server_id,
1064 connection_id,
1065 None,
1066 Some(data.len() as u64),
1067 None,
1068 Some(1),
1069 )
1070 .await;
1071
1072 // Byte-count summary and full hex payload are FileOnly; the one lifecycle
1073 // line to the TUI is the INFO below.
1074 let log = Log::new(Some(status_tx));
1075 log.debug(format!(
1076 "Bitcoin P2P sent {} bytes to {}",
1077 data.len(),
1078 connection_id
1079 ));
1080 log.trace(format!("Bitcoin P2P sent (hex): {}", hex::encode(data)));
1081 log.info(format!("Sent Bitcoin message to {}", connection_id));
1082
1083 Ok(())
1084 }
1085}