Skip to main content

netget/client/devp2p/
mod.rs

1//! devp2p dialler. Connects to a node named by its enode URL, runs the RLPx handshake as the
2//! initiator and the Hello exchange (the server's `session::establish`), then hands the session
3//! to a task that asks the model for this node's Status and carries out its requests.
4//!
5//! `connect()` returns once Hello is done; the model is never consulted inside it, so a parked
6//! manual answer cannot stall the client's creation.
7//!
8//! The Status exchange: go-ethereum and reth send their Status as soon as Hello is done. The
9//! session waits [`STATUS_WAIT`] for it, then raises `eth_status` either way — with the peer's
10//! Status, or with `peer_status: null` when the peer is waiting for ours (another NetGet node
11//! does). Requests the model gives alongside its Status go out once both are exchanged.
12//!
13//! Failing closed: no usable answer to `eth_status` → Disconnect `subprotocol_error`; an answer
14//! without `eth_send_status` → Disconnect `useless_peer`; a peer request still unanswered after
15//! the model's turn → Disconnect `subprotocol_error`. A follow-up chain (an answer prompting a
16//! request whose answer prompts another) stops at [`MAX_FOLLOWUP_DEPTH`].
17pub mod actions;
18
19use crate::client::llm_budget::call_llm_for_client;
20use crate::logging::emit::Log;
21use crate::protocol::{ConnectContext, Event, EventType};
22use crate::server::devp2p::actions::{common_config, CommonConfig};
23use crate::server::devp2p::session::{
24    self, Applied, Command, Inbound, PeerEvent, PeerEventKind, Session,
25};
26use crate::server::devp2p::{crypto, eth};
27use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
28use crate::state::{AccessLogOwner, ClientStatus};
29pub use actions::Devp2pClientProtocol;
30use anyhow::{Context, Result};
31use serde_json::json;
32use std::net::SocketAddr;
33use std::time::Duration;
34use tokio::sync::mpsc;
35
36/// TCP connect.
37pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
38/// How long to wait for the peer's Status before asking the model to speak first.
39pub const STATUS_WAIT: Duration = Duration::from_millis(1500);
40/// How many model turns may follow from one another.
41pub const MAX_FOLLOWUP_DEPTH: u32 = 8;
42const INBOUND_QUEUE: usize = 32;
43
44const USELESS_PEER: u64 = 0x03;
45const BREACH: u64 = 0x02;
46const SUBPROTOCOL_ERROR: u64 = 0x10;
47
48fn event_type_for(kind: PeerEventKind) -> &'static EventType {
49    match kind {
50        PeerEventKind::HeadersRequested => &actions::HEADERS_REQUESTED_EVENT,
51        PeerEventKind::BodiesRequested => &actions::BODIES_REQUESTED_EVENT,
52        PeerEventKind::PooledRequested => &actions::POOLED_REQUESTED_EVENT,
53        PeerEventKind::Announced => &actions::ANNOUNCED_EVENT,
54        PeerEventKind::HeadersReceived => &actions::HEADERS_RECEIVED_EVENT,
55        PeerEventKind::BodiesReceived => &actions::BODIES_RECEIVED_EVENT,
56        PeerEventKind::PooledReceived => &actions::POOLED_RECEIVED_EVENT,
57        PeerEventKind::RequestTimedOut => &actions::REQUEST_TIMEOUT_EVENT,
58    }
59}
60
61pub async fn connect(ctx: ConnectContext) -> Result<SocketAddr> {
62    let config = common_config(ctx.startup_params.as_ref())?;
63    let (node_id, target) = eth::parse_enode(&ctx.remote_addr)?;
64    let remote_key = crypto::public_from_id(&node_id)?;
65    let server = tokio::net::lookup_host(&target)
66        .await
67        .with_context(|| format!("cannot resolve {target}"))?
68        .next()
69        .with_context(|| format!("{target} resolved to no address"))?;
70    let stream = tokio::time::timeout(CONNECT_TIMEOUT, tokio::net::TcpStream::connect(server))
71        .await
72        .with_context(|| format!("connecting to {server} timed out"))?
73        .with_context(|| format!("cannot reach {server}"))?;
74    let _ = stream.set_nodelay(true);
75    let (r, w) = stream.into_split();
76    let r = crate::server::accept_bounded::IdleTimeoutReader::new(r, session::IDLE_TIMEOUT);
77    let est = session::establish(
78        Box::new(r),
79        Box::new(w),
80        &config.key,
81        session::Role::Dial(remote_key),
82        &config.client_id,
83        0,
84    )
85    .await
86    .map_err(|e| anyhow::anyhow!("devp2p session with {server}: {e}"))?;
87    ctx.state
88        .update_client_status(ctx.client_id, ClientStatus::Connected)
89        .await;
90    Log::new(Some(&ctx.status_tx)).info(format!(
91        "devp2p client {} connected to {} ({}) at {server}, eth/{}, as {}",
92        ctx.client_id,
93        &hex::encode(est.remote_id)[..16],
94        est.hello.client_id,
95        est.eth_version,
96        eth::format_enode(&config.key.id(), server)
97            .split('@')
98            .next()
99            .unwrap_or_default()
100    ));
101    let external =
102        crate::client::command_support::register_command_channel(&ctx.state, ctx.client_id).await;
103    let (tx, rx) = mpsc::channel(INBOUND_QUEUE);
104    let reader = tokio::spawn(session::read_loop(
105        est.reader,
106        est.read_half,
107        est.writer.clone(),
108        tx,
109    ));
110    let reader_abort = reader.abort_handle();
111    ctx.state.register_client_task(ctx.client_id, reader).await;
112    let session_ctx = ctx.clone();
113    let task = tokio::spawn(async move {
114        let mut client = Devp2pClient {
115            ctx: session_ctx.clone(),
116            config,
117            session: Session::new(est.writer, est.eth_version),
118            server,
119        };
120        let reason = client.run(&est.hello, est.remote_id, rx, external).await;
121        reader_abort.abort();
122        session::close(&client.session.writer).await;
123        Log::new(Some(&session_ctx.status_tx)).info(format!(
124            "devp2p client {} disconnected: {reason}",
125            session_ctx.client_id
126        ));
127        client
128            .ask(
129                &Event::new(&actions::DISCONNECTED_EVENT, json!({"reason": reason})),
130                0,
131            )
132            .await;
133        session_ctx
134            .state
135            .update_client_status(session_ctx.client_id, ClientStatus::Disconnected)
136            .await;
137        session_ctx
138            .state
139            .remove_client_handle(session_ctx.client_id)
140            .await;
141        let _ = session_ctx.status_tx.send("__UPDATE_UI__".into());
142    });
143    ctx.state.register_client_task(ctx.client_id, task).await;
144    Ok(server)
145}
146
147struct Devp2pClient {
148    ctx: ConnectContext,
149    config: CommonConfig,
150    session: Session,
151    server: SocketAddr,
152}
153
154/// What the model answered: validated commands, or None when there was no answer at all.
155type Answer = Option<Vec<(Command, String)>>;
156
157impl Devp2pClient {
158    fn log(&self) -> Log<'_> {
159        Log::new(Some(&self.ctx.status_tx))
160    }
161
162    /// Ask the model about one event. `None` when the backend failed (logged
163    /// `decision=fail_closed_*`); otherwise the actions it gave, validated, and the ones
164    /// refused with why.
165    async fn ask_raw(
166        &mut self,
167        event: &Event,
168    ) -> Option<(Vec<(Command, String)>, Vec<(String, String)>)> {
169        let instruction = self
170            .ctx
171            .state
172            .get_instruction_for_client(self.ctx.client_id)
173            .await
174            .unwrap_or_default();
175        let memory = self
176            .ctx
177            .state
178            .get_memory_for_client(self.ctx.client_id)
179            .await
180            .unwrap_or_default();
181        let name = event.id().to_string();
182        let result = match call_llm_for_client(
183            &self.ctx.llm_client,
184            &self.ctx.state,
185            self.ctx.client_id.to_string(),
186            &instruction,
187            &memory,
188            Some(event),
189            &Devp2pClientProtocol,
190            &self.ctx.status_tx,
191        )
192        .await
193        {
194            Ok(r) => r,
195            Err(e) => {
196                let tag = match crate::utils::wire_failure::WireFailure::classify(&e) {
197                    crate::utils::wire_failure::WireFailure::Overloaded => {
198                        "fail_closed_llm_overloaded"
199                    }
200                    crate::utils::wire_failure::WireFailure::Unavailable => "fail_closed_llm_error",
201                };
202                self.log().error(format!(
203                    "devp2p client {}: {name} not answered decision={tag}: {e}",
204                    self.ctx.client_id
205                ));
206                return None;
207            }
208        };
209        if let Some(memory) = result.memory_updates {
210            self.ctx
211                .state
212                .set_memory_for_client(self.ctx.client_id, memory)
213                .await;
214        }
215        let mut commands = Vec::new();
216        let mut refused = Vec::new();
217        for action in result.actions {
218            let kind = action["type"].as_str().unwrap_or_default().to_string();
219            match session::parse_command(&action) {
220                Ok(c) => commands.push((c, kind)),
221                Err(e) => {
222                    self.log().error(format!(
223                        "devp2p client {}: {kind} refused decision=fail_closed_bad_action: {e:#}",
224                        self.ctx.client_id
225                    ));
226                    refused.push((kind, format!("{e:#}")));
227                }
228            }
229        }
230        Some((commands, refused))
231    }
232
233    /// [`Self::ask_raw`], telling the model about every refused action.
234    async fn ask(&mut self, event: &Event, depth: u32) -> Answer {
235        let (commands, refused) = self.ask_raw(event).await?;
236        for (kind, message) in refused {
237            self.error_event(&kind, &message, depth).await;
238        }
239        Some(commands)
240    }
241
242    /// Tell the model an action failed, unless the chain is already as deep as it may go. Its
243    /// answer is carried out once; a failure there is only logged, so errors cannot loop.
244    async fn error_event(&mut self, action: &str, message: &str, depth: u32) {
245        if depth + 1 >= MAX_FOLLOWUP_DEPTH {
246            return;
247        }
248        let event = Event::new(
249            &actions::ERROR_EVENT,
250            json!({"action": action, "message": message}),
251        );
252        let Some((commands, _)) = self.ask_raw(&event).await else {
253            return;
254        };
255        for (c, kind) in commands {
256            if let Err(e) = self.apply(c, None, depth + 1).await {
257                self.log().warn(format!(
258                    "devp2p client {}: {kind} after an error also failed: {e:#}",
259                    self.ctx.client_id
260                ));
261            }
262        }
263    }
264
265    async fn apply(
266        &mut self,
267        command: Command,
268        answering: Option<u64>,
269        depth: u32,
270    ) -> Result<Applied> {
271        let applied = self
272            .session
273            .apply(command, answering, depth, &self.config.defaults)
274            .await?;
275        match &applied {
276            Applied::Sent(what) => self
277                .log()
278                .info(format!("devp2p client {}: {what}", self.ctx.client_id)),
279            Applied::StatusSent(st) => self.log().info(format!(
280                "devp2p client {}: Status sent: {}",
281                self.ctx.client_id,
282                st.to_json()
283            )),
284            Applied::Disconnected(_) => {}
285        }
286        Ok(applied)
287    }
288
289    /// Carry out the model's commands; deferred ones (sent before both Statuses were
290    /// exchanged) are returned. `Err(reason)` when the session is over.
291    async fn carry_out(
292        &mut self,
293        commands: Vec<(Command, String)>,
294        answering: Option<u64>,
295        depth: u32,
296    ) -> std::result::Result<Vec<(Command, String)>, String> {
297        let mut deferred = Vec::new();
298        for (command, kind) in commands {
299            let waits = !self.session.ready()
300                && !matches!(command, Command::SendStatus(_) | Command::Disconnect(_));
301            if waits {
302                deferred.push((command, kind));
303                continue;
304            }
305            match self.apply(command, answering, depth).await {
306                Ok(Applied::Disconnected(r)) => {
307                    return Err(format!("disconnected ({})", eth::reason_name(r)))
308                }
309                Ok(_) => {}
310                Err(e) => {
311                    self.log().error(format!(
312                        "devp2p client {}: {kind} refused decision=fail_closed_bad_action: {e:#}",
313                        self.ctx.client_id
314                    ));
315                    self.error_event(&kind, &format!("{e:#}"), depth).await;
316                }
317            }
318        }
319        Ok(deferred)
320    }
321
322    /// Wait for the peer's Status. `Ok(None)` on timeout.
323    async fn read_status(
324        &mut self,
325        rx: &mut mpsc::Receiver<Inbound>,
326        wait: Duration,
327    ) -> std::result::Result<Option<eth::Status>, String> {
328        let m = match tokio::time::timeout(wait, rx.recv()).await {
329            Err(_) => return Ok(None),
330            Ok(m) => m,
331        };
332        match m {
333            None => Err("the reader stopped".into()),
334            Some(Inbound::Ended(end)) => Err(self.ended(end)),
335            Some(Inbound::Message { code, payload }) => {
336                if code == eth::code::DISCONNECT {
337                    return Err(format!(
338                        "the peer disconnected: {}",
339                        eth::reason_name(eth::decode_disconnect(&payload))
340                    ));
341                }
342                match self.session.take_status(code, &payload) {
343                    Ok(Some(s)) => Ok(Some(s)),
344                    Ok(None) => {
345                        self.log().warn(format!(
346                            "devp2p client {}: message {code:#04x} before Status decision=protocol_error",
347                            self.ctx.client_id
348                        ));
349                        session::disconnect(&self.session.writer, BREACH).await;
350                        Err("the peer sent a message before its Status".into())
351                    }
352                    Err(e) => {
353                        self.log().warn(format!(
354                            "devp2p client {}: bad Status decision=protocol_error: {e:#}",
355                            self.ctx.client_id
356                        ));
357                        session::disconnect(&self.session.writer, BREACH).await;
358                        Err(format!("bad Status: {e:#}"))
359                    }
360                }
361            }
362        }
363    }
364
365    fn ended(&self, end: session::End) -> String {
366        if let Some(tag) = end.decision {
367            self.log().warn(format!(
368                "devp2p client {}: {} decision={tag}",
369                self.ctx.client_id, end.reason
370            ));
371        }
372        end.reason
373    }
374
375    async fn run(
376        &mut self,
377        hello: &eth::Hello,
378        remote_id: [u8; 64],
379        mut rx: mpsc::Receiver<Inbound>,
380        mut external: mpsc::Receiver<ClientCommand>,
381    ) -> String {
382        let id = self.ctx.client_id;
383        let peer_status = match self.read_status(&mut rx, STATUS_WAIT).await {
384            Ok(s) => s,
385            Err(reason) => return reason,
386        };
387        let event = Event::new(
388            &actions::STATUS_EVENT,
389            json!({
390                "remote": self.server.to_string(),
391                "client_id": hello.client_id,
392                "node_id": hex::encode(remote_id),
393                "capabilities": hello.caps_strings(),
394                "eth_version": self.session.eth_version,
395                "peer_status": peer_status.as_ref().map(eth::Status::to_json),
396                "configured": self.config.defaults.to_json(),
397            }),
398        );
399        let Some(commands) = self.ask(&event, 0).await else {
400            session::disconnect(&self.session.writer, SUBPROTOCOL_ERROR).await;
401            return "no usable answer to eth_status".into();
402        };
403        let admits = commands
404            .iter()
405            .any(|(c, _)| matches!(c, Command::SendStatus(_)));
406        let refuses = commands
407            .iter()
408            .any(|(c, _)| matches!(c, Command::Disconnect(_)));
409        if commands.is_empty() {
410            self.log().warn(format!(
411                "devp2p client {id}: eth_status produced no action decision=model_silent"
412            ));
413            session::disconnect(&self.session.writer, SUBPROTOCOL_ERROR).await;
414            return "no answer to eth_status".into();
415        }
416        if !admits && !refuses {
417            self.log().info(format!(
418                "devp2p client {id}: eth_status answered without eth_send_status decision=model_reject"
419            ));
420            session::disconnect(&self.session.writer, USELESS_PEER).await;
421            return "the model did not send a Status".into();
422        }
423        self.log().info(format!(
424            "devp2p client {id}: eth_status decision={}",
425            if refuses {
426                "model_reject"
427            } else {
428                "model_answer"
429            }
430        ));
431        let deferred = match self.carry_out(commands, None, 0).await {
432            Ok(d) => d,
433            Err(reason) => return reason,
434        };
435        if self.session.our_status.is_none() {
436            session::disconnect(&self.session.writer, SUBPROTOCOL_ERROR).await;
437            return "the Status could not be sent".into();
438        }
439        if self.session.peer_status.is_none() {
440            match self
441                .read_status(&mut rx, crate::server::devp2p::STATUS_TIMEOUT)
442                .await
443            {
444                Ok(Some(_)) => {}
445                Ok(None) => {
446                    self.log().warn(format!(
447                        "devp2p client {id}: the peer sent no Status decision=fail_closed_status_timeout"
448                    ));
449                    session::disconnect(&self.session.writer, 0x0b).await;
450                    return "the peer sent no Status".into();
451                }
452                Err(reason) => return reason,
453            }
454        }
455        if let (Some(ours), Some(theirs)) = (&self.session.our_status, &self.session.peer_status) {
456            match session::compatible(ours, theirs) {
457                Ok(()) => self.log().info(format!(
458                    "devp2p client {id}: eth handshake done with {}",
459                    theirs.to_json()
460                )),
461                Err(why) => self.log().warn(format!(
462                    "devp2p client {id}: this node's Status is not the peer's chain ({why}); the peer will likely disconnect"
463                )),
464            }
465        }
466        if let Err(reason) = self.carry_out(deferred, None, 0).await {
467            return reason;
468        }
469
470        let mut ping = tokio::time::interval(session::PING_INTERVAL);
471        ping.tick().await;
472        loop {
473            tokio::select! {
474                m = rx.recv() => match m {
475                    None => return "the reader stopped".into(),
476                    Some(Inbound::Ended(end)) => return self.ended(end),
477                    Some(Inbound::Message { code, payload }) => {
478                        if code == eth::code::DISCONNECT {
479                            return format!("the peer disconnected: {}", eth::reason_name(eth::decode_disconnect(&payload)));
480                        }
481                        match self.session.on_message(code, &payload).await {
482                            Ok(None) => {}
483                            Ok(Some(ev)) => {
484                                if let Some(reason) = self.handle(ev).await {
485                                    return reason;
486                                }
487                            }
488                            Err(e) => {
489                                self.log().warn(format!("devp2p client {id}: decision=protocol_error: {e:#}"));
490                                session::disconnect(&self.session.writer, BREACH).await;
491                                return format!("protocol error: {e:#}");
492                            }
493                        }
494                    }
495                },
496                _ = ping.tick() => {
497                    if session::send(&self.session.writer, eth::code::PING, &eth::empty_payload()).await.is_err() {
498                        return "the connection could not be written".into();
499                    }
500                    for ev in self.session.expire() {
501                        if let Some(reason) = self.handle(ev).await {
502                            return reason;
503                        }
504                    }
505                }
506                c = external.recv() => match c {
507                    None => {
508                        session::disconnect(&self.session.writer, 0x08).await;
509                        return "the client was stopped".into();
510                    }
511                    Some(c) => {
512                        if let Some(reason) = self.inject(c).await {
513                            return reason;
514                        }
515                    }
516                },
517            }
518        }
519    }
520
521    /// Raise one session event and carry out the answer.
522    async fn handle(&mut self, ev: PeerEvent) -> Option<String> {
523        let event_type = event_type_for(ev.kind);
524        if ev.depth >= MAX_FOLLOWUP_DEPTH {
525            self.log().warn(format!(
526                "devp2p client {}: not asking the model about {}: {} turns deep decision=followup_depth",
527                self.ctx.client_id, event_type.id, ev.depth
528            ));
529        } else {
530            let event = Event::new(event_type, ev.data.clone());
531            if let Some(commands) = self.ask(&event, ev.depth).await {
532                if commands.is_empty() {
533                    self.log().info(format!(
534                        "devp2p client {}: {} decision=model_silent",
535                        self.ctx.client_id, event_type.id
536                    ));
537                }
538                if let Err(reason) = self.carry_out(commands, ev.request_id, ev.depth).await {
539                    return Some(reason);
540                }
541            }
542        }
543        if let Some(request_id) = ev.request_id {
544            if self.session.owes(request_id) {
545                self.log().warn(format!(
546                    "devp2p client {}: {} {request_id} left unanswered decision=fail_closed_unanswered",
547                    self.ctx.client_id, event_type.id
548                ));
549                session::disconnect(&self.session.writer, SUBPROTOCOL_ERROR).await;
550                return Some(format!("{} {request_id} unanswered", event_type.id));
551            }
552        }
553        None
554    }
555
556    /// An action sent to this client from the dashboard or MCP.
557    async fn inject(&mut self, command: ClientCommand) -> Option<String> {
558        self.ctx
559            .state
560            .record_access_log(
561                AccessLogOwner::Client(self.ctx.client_id.as_u32()),
562                "devp2p",
563                None,
564                "injected_action",
565                command.action.clone(),
566                vec![],
567            )
568            .await;
569        let (outcome, end) = match session::parse_command(&command.action) {
570            Err(e) => (
571                ClientSendOutcome::Rejected {
572                    error: format!("{e:#}"),
573                },
574                None,
575            ),
576            Ok(cmd) => match self.apply(cmd, None, 0).await {
577                Ok(Applied::Disconnected(r)) => (
578                    ClientSendOutcome::Disconnected,
579                    Some(format!("disconnected ({})", eth::reason_name(r))),
580                ),
581                Ok(Applied::StatusSent(st)) => (
582                    ClientSendOutcome::Executed {
583                        detail: st.to_json().to_string(),
584                    },
585                    None,
586                ),
587                Ok(Applied::Sent(what)) => (ClientSendOutcome::Executed { detail: what }, None),
588                Err(e) => (
589                    ClientSendOutcome::Rejected {
590                        error: format!("{e:#}"),
591                    },
592                    None,
593                ),
594            },
595        };
596        crate::client::command_support::reply(command, Ok(outcome));
597        end
598    }
599}