Skip to main content

netget/client/devp2p/
actions.rs

1//! What the model can do as a devp2p dialler: choose this node's Status, ask the peer for
2//! headers, bodies and pooled transactions, answer the peer's requests, and disconnect.
3//!
4//! The actions and the session events are the server's (`crate::server::devp2p::actions`):
5//! after the handshake both ends of a devp2p session speak the same protocol. What differs is
6//! the Status event — a dialler may have to speak first — and the two events only a client has,
7//! `devp2p_error` and `devp2p_disconnected`.
8use crate::llm::actions::{
9    client_trait::{Client, ClientActionResult},
10    protocol_trait::Protocol,
11    ActionDefinition, ParameterDefinition, StartupExamples,
12};
13use crate::protocol::log_template::LogTemplate;
14use crate::protocol::{ConnectContext, EventType};
15use crate::server::devp2p::actions as shared;
16use crate::server::devp2p::actions::p;
17use crate::server::devp2p::session;
18use crate::state::app_state::AppState;
19use anyhow::Result;
20use serde_json::{json, Value};
21use std::sync::LazyLock;
22
23#[derive(Default)]
24pub struct Devp2pClientProtocol;
25
26impl Devp2pClientProtocol {
27    pub fn new() -> Self {
28        Self
29    }
30}
31
32pub static STATUS_EVENT: LazyLock<EventType> = LazyLock::new(|| {
33    EventType::new(
34        "eth_status",
35        "The RLPx handshake and Hello are done and this node must send its eth Status. \
36         peer_status is the peer's own Status, or null when the peer is waiting for ours. Send \
37         eth_send_status (requests in the same answer go out once both Statuses are \
38         exchanged) or devp2p_disconnect",
39        json!({"type": session::SEND_STATUS, "mirror_peer": true}),
40    )
41    .with_parameters(vec![
42        p("remote", "string", "The peer's address", true),
43        p("client_id", "string", "The client id from its Hello", true),
44        p(
45            "node_id",
46            "string",
47            "Its node id (128 hex characters)",
48            true,
49        ),
50        p("capabilities", "array", "Capabilities from its Hello", true),
51        p(
52            "eth_version",
53            "number",
54            "The eth version both sides speak",
55            true,
56        ),
57        p(
58            "peer_status",
59            "object",
60            "Its Status, or null if it has not sent one yet",
61            true,
62        ),
63        p(
64            "configured",
65            "object",
66            "The Status values configured at startup",
67            true,
68        ),
69    ])
70    .with_actions(shared::all_actions())
71    .with_alternative_example(json!({"type": session::DISCONNECT, "reason": "useless_peer"}))
72    .with_log_template(
73        LogTemplate::new()
74            .with_info("devp2p connected to {client_id} at {remote}, eth/{eth_version}")
75            .with_trace("devp2p status: {json_pretty(.)}"),
76    )
77});
78
79pub static HEADERS_REQUESTED_EVENT: LazyLock<EventType> =
80    LazyLock::new(|| shared::HEADERS_REQUESTED_EVENT.clone());
81pub static BODIES_REQUESTED_EVENT: LazyLock<EventType> =
82    LazyLock::new(|| shared::BODIES_REQUESTED_EVENT.clone());
83pub static POOLED_REQUESTED_EVENT: LazyLock<EventType> =
84    LazyLock::new(|| shared::POOLED_REQUESTED_EVENT.clone());
85pub static ANNOUNCED_EVENT: LazyLock<EventType> = LazyLock::new(|| shared::ANNOUNCED_EVENT.clone());
86pub static HEADERS_RECEIVED_EVENT: LazyLock<EventType> =
87    LazyLock::new(|| shared::HEADERS_RECEIVED_EVENT.clone());
88pub static BODIES_RECEIVED_EVENT: LazyLock<EventType> =
89    LazyLock::new(|| shared::BODIES_RECEIVED_EVENT.clone());
90pub static POOLED_RECEIVED_EVENT: LazyLock<EventType> =
91    LazyLock::new(|| shared::POOLED_RECEIVED_EVENT.clone());
92pub static REQUEST_TIMEOUT_EVENT: LazyLock<EventType> =
93    LazyLock::new(|| shared::REQUEST_TIMEOUT_EVENT.clone());
94
95pub static ERROR_EVENT: LazyLock<EventType> = LazyLock::new(|| {
96    EventType::new(
97        "devp2p_error",
98        "An action could not be carried out; nothing was sent for it",
99        json!({"type": session::REQUEST_HEADERS, "origin": 0}),
100    )
101    .with_parameters(vec![
102        p("action", "string", "The action's type", true),
103        p("message", "string", "Why it was refused", true),
104    ])
105    .with_actions(shared::all_actions())
106    .with_log_template(LogTemplate::new().with_info("devp2p {action} refused: {message}"))
107});
108
109pub static DISCONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
110    EventType::new(
111        "devp2p_disconnected",
112        "The session ended. Nothing more can be sent on it",
113        json!({"type": "show_message", "message": "devp2p session ended"}),
114    )
115    .with_parameters(vec![p(
116        "reason",
117        "string",
118        "Why the session ended, and which side ended it",
119        true,
120    )])
121    .with_no_actions()
122    .with_log_template(LogTemplate::new().with_info("devp2p session ended: {reason}"))
123});
124
125impl Protocol for Devp2pClientProtocol {
126    fn protocol_name(&self) -> &'static str {
127        "devp2p"
128    }
129    fn stack_name(&self) -> &'static str {
130        "ETH>IP>TCP>RLPx>devp2p"
131    }
132    fn keywords(&self) -> Vec<&'static str> {
133        vec![
134            "devp2p",
135            "ethereum",
136            "eth/68",
137            "eth/69",
138            "rlpx",
139            "enode",
140            "geth",
141            "ethereum client",
142        ]
143    }
144    fn description(&self) -> &'static str {
145        "Ethereum devp2p dialler: connects to a node by enode URL over RLPx, exchanges Hello and an eth Status the LLM chooses, and fetches block headers, bodies and pooled transactions with hashes computed locally"
146    }
147    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
148        shared::all_actions()
149    }
150    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
151        vec![]
152    }
153    fn get_event_types(&self) -> Vec<EventType> {
154        vec![
155            STATUS_EVENT.clone(),
156            HEADERS_REQUESTED_EVENT.clone(),
157            BODIES_REQUESTED_EVENT.clone(),
158            POOLED_REQUESTED_EVENT.clone(),
159            ANNOUNCED_EVENT.clone(),
160            HEADERS_RECEIVED_EVENT.clone(),
161            BODIES_RECEIVED_EVENT.clone(),
162            POOLED_RECEIVED_EVENT.clone(),
163            REQUEST_TIMEOUT_EVENT.clone(),
164            ERROR_EVENT.clone(),
165            DISCONNECTED_EVENT.clone(),
166        ]
167    }
168    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
169        shared::common_startup_parameters()
170    }
171    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
172        use crate::protocol::metadata::*;
173        ProtocolMetadataV2::builder()
174            .state(DevelopmentState::Experimental)
175            .privilege_requirement(PrivilegeRequirement::None)
176            .implementation("The server's hand-written RLPx (EIP-8 handshake as the initiator, AES-256-CTR frames, keccak MACs, Snappy), RLP codec, Hello and eth/68 and eth/69, dialled to an enode URL. Every header received is decoded and hashed here; a response is checked against the request it answers")
177            .llm_control("This node's Status (eth_status, with the peer's Status when it has sent one), the header, body and pooled-transaction requests to send, answers to the peer's requests, and when to disconnect")
178            .e2e_testing("tests/client/devp2p: go-ethereum 1.17 and reth 2.7, each on a private dev chain with blocks, accept NetGet's handshake and eth/69 Status and serve headers whose computed hashes match their own eth_getBlockByNumber")
179            .notes("Experimental: two independent servers (go-ethereum and reth) drive it in tests that fail rather than skip, but nobody has reviewed it and its pair job had not run in CI when it landed. No discovery: the peer is named by its enode URL. No snap protocol. Received bodies are decoded (transaction hashes, uncles, withdrawals) but not checked against the header's transactionsRoot.")
180            .build()
181    }
182    fn example_prompt(&self) -> &'static str {
183        "Connect to enode://<node id>@127.0.0.1:30303, mirror its Status, and fetch the first four block headers"
184    }
185    fn get_startup_examples(&self) -> StartupExamples {
186        let enode = format!("enode://{}@127.0.0.1:30303", "8a".repeat(64));
187        let llm = json!({"type": "open_client", "protocol": "devp2p", "remote_addr": enode,
188            "instruction": "Mirror the peer's Status, then fetch headers 0 to 3 and report their hashes"});
189        let mut static_example = llm.clone();
190        static_example["event_handlers"] = json!([
191            {"event_pattern": "eth_status", "handler": {"type": "static", "actions": [
192                {"type": session::SEND_STATUS, "mirror_peer": true},
193                {"type": session::REQUEST_HEADERS, "origin": 0, "amount": 4}
194            ]}},
195            {"event_pattern": "*", "handler": {"type": "static", "actions": []}}
196        ]);
197        let mut scripted = static_example.clone();
198        scripted["event_handlers"][0] = json!({"event_pattern": "eth_status", "handler": {"type": "script", "language": "python",
199            "code": "import json,sys\ne=json.load(sys.stdin)['event']\nlatest=(e.get('peer_status') or {}).get('latest_block',0)\nprint(json.dumps({'actions':[{'type':'eth_send_status','mirror_peer':e.get('peer_status') is not None},{'type':'eth_request_block_headers','origin':latest,'amount':1}]}))"}});
200        StartupExamples::new(llm, scripted, static_example)
201    }
202    fn group_name(&self) -> &'static str {
203        "Blockchain"
204    }
205}
206
207impl Client for Devp2pClientProtocol {
208    fn connect(
209        &self,
210        ctx: ConnectContext,
211    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
212    {
213        Box::pin(super::connect(ctx))
214    }
215
216    fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
217        session::parse_command(&v)?;
218        Ok(ClientActionResult::Custom {
219            name: v["type"].as_str().unwrap_or_default().to_string(),
220            data: v,
221        })
222    }
223}