Skip to main content

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}