Skip to main content

netget/server/devp2p/
actions.rs

1//! What the model decides on a devp2p node: whether to admit a peer and with what Status, how
2//! to answer its `eth` requests, and what to ask it for.
3//!
4//! Rust owns everything under that — the RLPx handshake and frame encryption, Snappy, RLP, the
5//! Hello exchange, Ping/Pong, block hashes. The model writes headers as the JSON fields
6//! `eth_getBlockByNumber` returns and never a byte of RLP; Rust encodes them, hashes them, and
7//! refuses an answer whose headers are not the ones the peer asked for (wrong numbers, a first
8//! hash that is not the requested one, a header whose parentHash is not the previous header's
9//! hash, or a `hash` field the fields do not produce).
10//!
11//! The action definitions here are shared with the client (`src/client/devp2p/actions.rs`):
12//! once the handshake is done both sides of a devp2p session speak the same protocol.
13use crate::llm::actions::{
14    protocol_trait::{ActionResult, Protocol, Server},
15    ActionDefinition, Parameter, ParameterDefinition, StartupExamples,
16};
17use crate::protocol::log_template::LogTemplate;
18use crate::protocol::EventType;
19use crate::server::devp2p::session::{self, StatusDefaults};
20use crate::server::devp2p::{crypto::NodeKey, eth};
21use crate::state::app_state::AppState;
22use anyhow::{anyhow, Context, Result};
23use serde_json::{json, Value};
24use std::sync::LazyLock;
25
26/// The client id NetGet sends in Hello unless configured otherwise.
27pub fn default_client_id() -> String {
28    format!("NetGet/v{}", env!("CARGO_PKG_VERSION"))
29}
30
31#[derive(Clone, Default)]
32pub struct Devp2pProtocol;
33
34impl Devp2pProtocol {
35    pub fn new() -> Self {
36        Self
37    }
38}
39
40pub fn p(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
41    Parameter {
42        name: name.into(),
43        type_hint: type_hint.into(),
44        description: description.into(),
45        required,
46    }
47}
48
49/// A complete, valid header for examples: block 1 of an empty post-Cancun chain.
50pub fn example_header() -> Value {
51    let z32 = format!("0x{}", "00".repeat(32));
52    json!({
53        "parentHash": format!("0x{}", "11".repeat(32)),
54        "sha3Uncles": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347",
55        "miner": format!("0x{}", "00".repeat(20)),
56        "stateRoot": format!("0x{}", "22".repeat(32)),
57        "transactionsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421",
58        "receiptsRoot": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421",
59        "logsBloom": format!("0x{}", "00".repeat(256)),
60        "difficulty": "0x0",
61        "number": "0x1",
62        "gasLimit": "0x1c9c380",
63        "gasUsed": "0x0",
64        "timestamp": "0x6553f100",
65        "extraData": "0x",
66        "mixHash": z32,
67        "nonce": "0x0000000000000000",
68        "baseFeePerGas": "0x3b9aca00"
69    })
70}
71
72fn action(
73    name: &str,
74    description: &str,
75    parameters: Vec<Parameter>,
76    example: Value,
77) -> ActionDefinition {
78    ActionDefinition {
79        name: name.into(),
80        description: description.into(),
81        parameters,
82        example,
83        log_template: Some(
84            LogTemplate::new()
85                .with_info(format!("devp2p {name}"))
86                .with_debug(format!("devp2p {name}: {{json(.)}}")),
87        ),
88    }
89}
90
91pub fn send_status_action() -> ActionDefinition {
92    action(
93        session::SEND_STATUS,
94        "Send this node's eth Status, which admits the peer: the eth handshake completes when \
95         both Statuses are exchanged. Fields left out come from the peer's Status when \
96         mirror_peer is true, else from the startup configuration. The fork hash, when no one \
97         gives it, is computed from genesis_hash and forks (EIP-2124). A peer on another \
98         network or genesis will drop the session itself.",
99        vec![
100            p("mirror_peer", "boolean", "Copy every field not given here from the peer's own Status (claim its chain and head)", false),
101            p("network_id", "number", "Network id (1 is mainnet)", false),
102            p("genesis_hash", "string", "0x-prefixed 32-byte genesis block hash", false),
103            p("fork_hash", "string", "0x-prefixed 4-byte EIP-2124 fork hash", false),
104            p("fork_next", "number", "Next fork block or timestamp, 0 for none", false),
105            p("forks", "array", "Fork block numbers or timestamps this chain has passed, to compute fork_hash from", false),
106            p("earliest_block", "number", "eth/69: oldest block served", false),
107            p("latest_block", "number", "eth/69: newest block number", false),
108            p("latest_block_hash", "string", "eth/69: newest block hash", false),
109            p("total_difficulty", "number", "eth/68: total difficulty", false),
110            p("head_hash", "string", "eth/68: head block hash", false),
111        ],
112        json!({"type": session::SEND_STATUS, "mirror_peer": true}),
113    )
114}
115
116pub fn send_headers_action() -> ActionDefinition {
117    action(
118        session::SEND_HEADERS,
119        "Answer a GetBlockHeaders with block headers, each an object of the fields \
120         eth_getBlockByNumber returns (parentHash, sha3Uncles, miner, stateRoot, \
121         transactionsRoot, receiptsRoot, logsBloom, difficulty, number, gasLimit, gasUsed, \
122         timestamp, extraData, mixHash, nonce, then baseFeePerGas, withdrawalsRoot, \
123         blobGasUsed, excessBlobGas, parentBeaconBlockRoot, requestsHash when the chain has \
124         them). NetGet encodes and hashes them; the first must be the requested block, the \
125         numbers must follow the request's skip and direction, each must name the previous as \
126         its parent, and a 'hash' field must equal the computed hash. An empty list says \
127         'I do not have them'.",
128        vec![
129            p(
130                "headers",
131                "array",
132                "Header objects, at most the amount requested",
133                true,
134            ),
135            p(
136                "request_id",
137                "number",
138                "The request answered; defaults to the one in the event",
139                false,
140            ),
141        ],
142        json!({"type": session::SEND_HEADERS, "headers": [example_header()]}),
143    )
144}
145
146pub fn send_bodies_action() -> ActionDefinition {
147    action(
148        session::SEND_BODIES,
149        "Answer a GetBlockBodies: one body per requested hash, in order (fewer is allowed). A \
150         body is {transactions: [signed transactions as 0x-hex, as eth_getRawTransactionByHash \
151         returns them], uncles: [header objects], withdrawals: [{index, validatorIndex, \
152         address, amount}] for post-Shanghai blocks}. An empty list says 'I do not have them'.",
153        vec![
154            p(
155                "bodies",
156                "array",
157                "One block body per requested hash, in the order requested",
158                true,
159            ),
160            p(
161                "request_id",
162                "number",
163                "The request answered; defaults to the one in the event",
164                false,
165            ),
166        ],
167        json!({"type": session::SEND_BODIES, "bodies": [{"transactions": [], "uncles": [], "withdrawals": []}]}),
168    )
169}
170
171pub fn send_pooled_action() -> ActionDefinition {
172    action(
173        session::SEND_POOLED,
174        "Answer a GetPooledTransactions with signed transactions (0x-hex canonical encodings). \
175         NetGet hashes each one and refuses any the peer did not ask for. An empty list says \
176         'I do not have them'.",
177        vec![
178            p("transactions", "array", "Signed transactions, 0x-hex", true),
179            p(
180                "request_id",
181                "number",
182                "The request answered; defaults to the one in the event",
183                false,
184            ),
185        ],
186        json!({"type": session::SEND_POOLED, "transactions": []}),
187    )
188}
189
190pub fn request_headers_action() -> ActionDefinition {
191    action(
192        session::REQUEST_HEADERS,
193        "Ask the peer for block headers (GetBlockHeaders). The answer arrives as \
194         eth_block_headers_received with every header decoded and its hash computed.",
195        vec![
196            p(
197                "origin",
198                "number",
199                "First block: a number, or a 0x-prefixed 32-byte block hash",
200                true,
201            ),
202            p(
203                "amount",
204                "number",
205                "How many headers, 1 to 64 (default 1)",
206                false,
207            ),
208            p(
209                "skip",
210                "number",
211                "Blocks skipped between consecutive headers (default 0)",
212                false,
213            ),
214            p(
215                "reverse",
216                "boolean",
217                "Walk towards genesis instead of the head (default false)",
218                false,
219            ),
220        ],
221        json!({"type": session::REQUEST_HEADERS, "origin": 0, "amount": 4}),
222    )
223}
224
225pub fn request_bodies_action() -> ActionDefinition {
226    action(
227        session::REQUEST_BODIES,
228        "Ask the peer for block bodies by block hash (GetBlockBodies). The answer arrives as \
229         eth_block_bodies_received.",
230        vec![p("hashes", "array", "Block hashes, 0x-hex, 1 to 256", true)],
231        json!({"type": session::REQUEST_BODIES, "hashes": [format!("0x{}", "ab".repeat(32))]}),
232    )
233}
234
235pub fn request_pooled_action() -> ActionDefinition {
236    action(
237        session::REQUEST_POOLED,
238        "Ask the peer for transactions from its pool by hash (GetPooledTransactions), \
239         typically ones it announced. The answer arrives as eth_pooled_transactions_received.",
240        vec![p(
241            "hashes",
242            "array",
243            "Transaction hashes, 0x-hex, 1 to 256",
244            true,
245        )],
246        json!({"type": session::REQUEST_POOLED, "hashes": [format!("0x{}", "cd".repeat(32))]}),
247    )
248}
249
250pub fn disconnect_action() -> ActionDefinition {
251    action(
252        session::DISCONNECT,
253        "Send Disconnect with a reason and close the session. Reasons: requested, \
254         useless_peer, too_many_peers, already_connected, incompatible_version, \
255         breach_of_protocol, subprotocol_error, client_quitting.",
256        vec![p(
257            "reason",
258            "string",
259            "The reason (default requested)",
260            false,
261        )],
262        json!({"type": session::DISCONNECT, "reason": "useless_peer"}),
263    )
264}
265
266/// Every action, in the order the model sees them.
267pub fn all_actions() -> Vec<ActionDefinition> {
268    vec![
269        send_status_action(),
270        send_headers_action(),
271        send_bodies_action(),
272        send_pooled_action(),
273        request_headers_action(),
274        request_bodies_action(),
275        request_pooled_action(),
276        disconnect_action(),
277    ]
278}
279
280fn requests() -> Vec<ActionDefinition> {
281    vec![
282        request_headers_action(),
283        request_bodies_action(),
284        request_pooled_action(),
285        disconnect_action(),
286    ]
287}
288
289fn with(first: Vec<ActionDefinition>) -> Vec<ActionDefinition> {
290    let mut v = first;
291    v.extend(requests());
292    v
293}
294
295pub static STATUS_EVENT: LazyLock<EventType> = LazyLock::new(|| {
296    EventType::new(
297        "eth_status",
298        "A peer completed the RLPx handshake and Hello and sent its eth Status. Admit it with \
299         eth_send_status (follow-up requests may come in the same answer) or refuse it with \
300         devp2p_disconnect",
301        json!({"type": session::SEND_STATUS, "mirror_peer": true}),
302    )
303    .with_parameters(vec![
304        p("remote", "string", "The peer's address", true),
305        p("client_id", "string", "The client id from its Hello", true),
306        p("node_id", "string", "Its node id (128 hex characters)", true),
307        p("capabilities", "array", "Capabilities from its Hello, as name/version", true),
308        p("eth_version", "number", "The eth version both sides speak", true),
309        p("peer_status", "object", "Its Status: network_id, genesis_hash, fork_hash, fork_next, and the eth/69 block range or eth/68 total difficulty and head", true),
310        p("configured", "object", "The Status values configured at startup", true),
311    ])
312    .with_actions(with(vec![send_status_action()]))
313    .with_alternative_example(json!({"type": session::DISCONNECT, "reason": "useless_peer"}))
314    .with_log_template(
315        LogTemplate::new()
316            .with_info("devp2p peer {client_id} at {remote} sent its Status")
317            .with_trace("devp2p status: {json_pretty(.)}"),
318    )
319});
320
321pub static HEADERS_REQUESTED_EVENT: LazyLock<EventType> =
322    LazyLock::new(|| {
323        EventType::new(
324        "eth_block_headers_requested",
325        "The peer asked for block headers. Answer with eth_send_block_headers (an empty list if \
326         you do not have them); a request left unanswered closes the session",
327        json!({"type": session::SEND_HEADERS, "headers": []}),
328    )
329    .with_parameters(vec![
330        p("request_id", "number", "The request's id", true),
331        p("origin", "number", "First block: a number, or a 0x block hash", true),
332        p("amount", "number", "How many headers at most", true),
333        p("skip", "number", "Blocks between consecutive headers", true),
334        p("reverse", "boolean", "Towards genesis instead of the head", true),
335    ])
336    .with_actions(with(vec![send_headers_action()]))
337    .with_log_template(
338        LogTemplate::new()
339            .with_info("devp2p peer asks for {amount} headers from {origin}")
340            .with_debug("devp2p GetBlockHeaders: {json(.)}"),
341    )
342    });
343
344pub static BODIES_REQUESTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
345    EventType::new(
346        "eth_block_bodies_requested",
347        "The peer asked for block bodies by hash. Answer with eth_send_block_bodies (an empty \
348         list if you do not have them)",
349        json!({"type": session::SEND_BODIES, "bodies": []}),
350    )
351    .with_parameters(vec![
352        p("request_id", "number", "The request's id", true),
353        p(
354            "hashes",
355            "array",
356            "The hashes of the blocks whose bodies are wanted",
357            true,
358        ),
359    ])
360    .with_actions(with(vec![send_bodies_action()]))
361    .with_log_template(LogTemplate::new().with_info("devp2p peer asks for block bodies"))
362});
363
364pub static POOLED_REQUESTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
365    EventType::new(
366        "eth_pooled_transactions_requested",
367        "The peer asked for transactions from this node's pool. Answer with \
368         eth_send_pooled_transactions (an empty list if you do not have them)",
369        json!({"type": session::SEND_POOLED, "transactions": []}),
370    )
371    .with_parameters(vec![
372        p("request_id", "number", "The request's id", true),
373        p("hashes", "array", "Transaction hashes", true),
374    ])
375    .with_actions(with(vec![send_pooled_action()]))
376    .with_log_template(LogTemplate::new().with_info("devp2p peer asks for pooled transactions"))
377});
378
379pub static ANNOUNCED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
380    EventType::new(
381        "eth_transactions_announced",
382        "The peer announced transactions in its pool (NewPooledTransactionHashes). Request \
383         them with eth_request_pooled_transactions, or do nothing",
384        json!({"type": session::REQUEST_POOLED, "hashes": [format!("0x{}", "cd".repeat(32))]}),
385    )
386    .with_parameters(vec![p(
387        "transactions",
388        "array",
389        "Announced transactions: hash, type and size",
390        true,
391    )])
392    .with_actions(requests())
393    .with_log_template(LogTemplate::new().with_info("devp2p peer announced transactions"))
394});
395
396pub static HEADERS_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
397    EventType::new(
398        "eth_block_headers_received",
399        "The peer answered a header request. Every header is decoded with its computed hash; \
400         matches_request says whether they are the blocks asked for, linked whether each \
401         names the previous as its parent",
402        json!({"type": session::REQUEST_HEADERS, "origin": 4, "amount": 4}),
403    )
404    .with_parameters(vec![
405        p("request_id", "number", "The request answered", true),
406        p(
407            "request",
408            "object",
409            "The request this answers: its kind and its query or hashes",
410            true,
411        ),
412        p("count", "number", "How many headers came back", true),
413        p(
414            "headers",
415            "array",
416            "Header fields and hash, by eth_getBlockByNumber names",
417            true,
418        ),
419        p(
420            "matches_request",
421            "boolean",
422            "The numbers (or first hash) are the ones asked for",
423            true,
424        ),
425        p(
426            "linked",
427            "boolean",
428            "Each header's parentHash is the previous header's hash",
429            true,
430        ),
431    ])
432    .with_actions(requests())
433    .with_log_template(
434        LogTemplate::new()
435            .with_info("devp2p received {count} headers")
436            .with_trace("devp2p headers: {json_pretty(.)}"),
437    )
438});
439
440pub static BODIES_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
441    EventType::new(
442        "eth_block_bodies_received",
443        "The peer answered a body request: per block, its transactions (hash, type, size), \
444         uncles and withdrawals",
445        json!({"type": session::REQUEST_HEADERS, "origin": 0}),
446    )
447    .with_parameters(vec![
448        p("request_id", "number", "The request answered", true),
449        p(
450            "request",
451            "object",
452            "The request this answers: its kind and its query or hashes",
453            true,
454        ),
455        p("count", "number", "How many bodies came back", true),
456        p(
457            "bodies",
458            "array",
459            "Per block: its hash, transactions (hash, type, size), uncles and withdrawals",
460            true,
461        ),
462    ])
463    .with_actions(requests())
464    .with_log_template(LogTemplate::new().with_info("devp2p received {count} block bodies"))
465});
466
467pub static POOLED_RECEIVED_EVENT: LazyLock<EventType> =
468    LazyLock::new(|| {
469        EventType::new(
470        "eth_pooled_transactions_received",
471        "The peer answered a pooled-transaction request: each transaction's hash, type and size",
472        json!({"type": session::REQUEST_POOLED, "hashes": [format!("0x{}", "cd".repeat(32))]}),
473    )
474    .with_parameters(vec![
475        p("request_id", "number", "The request answered", true),
476        p("request", "object", "The request this answers: its kind and its query or hashes", true),
477        p("count", "number", "How many came back", true),
478        p("transactions", "array", "hash, type and size of each", true),
479        p("unrequested", "number", "How many were not asked for", true),
480    ])
481    .with_actions(requests())
482    .with_log_template(LogTemplate::new().with_info("devp2p received {count} pooled transactions"))
483    });
484
485pub static REQUEST_TIMEOUT_EVENT: LazyLock<EventType> = LazyLock::new(|| {
486    EventType::new(
487        "eth_request_timed_out",
488        "A request this node sent got no answer in time; it is forgotten",
489        json!({"type": session::REQUEST_HEADERS, "origin": 0}),
490    )
491    .with_parameters(vec![
492        p(
493            "request_id",
494            "number",
495            "The id of the request that went unanswered",
496            true,
497        ),
498        p(
499            "request",
500            "object",
501            "The request this answers: its kind and its query or hashes",
502            true,
503        ),
504        p("timeout_secs", "number", "How long NetGet waited", true),
505    ])
506    .with_actions(requests())
507    .with_log_template(LogTemplate::new().with_info("devp2p request {request_id} timed out"))
508});
509
510pub fn event_types() -> Vec<EventType> {
511    vec![
512        STATUS_EVENT.clone(),
513        HEADERS_REQUESTED_EVENT.clone(),
514        BODIES_REQUESTED_EVENT.clone(),
515        POOLED_REQUESTED_EVENT.clone(),
516        ANNOUNCED_EVENT.clone(),
517        HEADERS_RECEIVED_EVENT.clone(),
518        BODIES_RECEIVED_EVENT.clone(),
519        POOLED_RECEIVED_EVENT.clone(),
520        REQUEST_TIMEOUT_EVENT.clone(),
521    ]
522}
523
524/// The startup parameters both roles share: identity and the Status defaults.
525pub fn common_startup_parameters() -> Vec<ParameterDefinition> {
526    let param =
527        |name: &str, type_hint: &str, description: &str, example: Value| ParameterDefinition {
528            name: name.into(),
529            type_hint: type_hint.into(),
530            description: description.into(),
531            required: false,
532            example,
533            default: None,
534        };
535    vec![
536        param(
537            "node_private_key",
538            "string",
539            "The node's secp256k1 private key, 64 hex characters; its public key is the node id \
540             in the enode URL. Without it a fresh key is made at start",
541            json!("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291"),
542        ),
543        ParameterDefinition {
544            name: "client_id".into(),
545            type_hint: "string".into(),
546            description: "The client id sent in Hello (peers show it as the node's name)".into(),
547            required: false,
548            example: json!("NetGet/v1.0/linux"),
549            default: Some(json!(default_client_id())),
550        },
551        param(
552            "network_id",
553            "number",
554            "Status default: network id (1 is mainnet)",
555            json!(1337),
556        ),
557        param(
558            "genesis_hash",
559            "string",
560            "Status default: 0x-prefixed genesis block hash",
561            json!(format!("0x{}", "00".repeat(32))),
562        ),
563        param(
564            "forks",
565            "array",
566            "Status default: fork block numbers or timestamps the chain has passed, for the \
567             EIP-2124 fork hash (forks at genesis are left out)",
568            json!([1150000, 1920000]),
569        ),
570        param(
571            "fork_next",
572            "number",
573            "Status default: the next fork, 0 for none",
574            json!(0),
575        ),
576        param(
577            "latest_block",
578            "number",
579            "Status default: newest block number",
580            json!(0),
581        ),
582        param(
583            "latest_block_hash",
584            "string",
585            "Status default: newest block hash (the genesis hash when not given)",
586            json!(format!("0x{}", "00".repeat(32))),
587        ),
588    ]
589}
590
591/// Parsed startup configuration shared by both roles.
592pub struct CommonConfig {
593    pub key: NodeKey,
594    pub client_id: String,
595    pub defaults: StatusDefaults,
596}
597
598pub fn common_config(params: Option<&crate::protocol::StartupParams>) -> Result<CommonConfig> {
599    let s = |k: &str| -> Result<Option<String>> {
600        Ok(params
601            .map(|p| p.get_optional_string(k))
602            .transpose()?
603            .flatten())
604    };
605    let u = |k: &str| -> Result<Option<u64>> {
606        Ok(params.map(|p| p.get_optional_u64(k)).transpose()?.flatten())
607    };
608    let key = match s("node_private_key")? {
609        Some(k) => NodeKey::from_hex(&k).context("node_private_key")?,
610        None => NodeKey::random(),
611    };
612    let client_id = s("client_id")?.unwrap_or_else(default_client_id);
613    anyhow::ensure!(
614        !client_id.is_empty()
615            && client_id.len() <= eth::MAX_CLIENT_ID_CHARS
616            && !crate::utils::sanitize::has_controls(&client_id),
617        "client_id is 1 to {} printable characters",
618        eth::MAX_CLIENT_ID_CHARS
619    );
620    let hash = |k: &str| -> Result<Option<[u8; 32]>> {
621        s(k)?
622            .map(|h| eth::parse_fixed::<32>(&json!(h), k))
623            .transpose()
624    };
625    let forks = match params
626        .map(|p| p.get_optional_array("forks"))
627        .transpose()?
628        .flatten()
629    {
630        Some(a) => {
631            anyhow::ensure!(a.len() <= 64, "at most 64 forks");
632            a.iter()
633                .map(|f| eth::parse_u64(f, "forks entry"))
634                .collect::<Result<Vec<_>>>()?
635        }
636        None => Vec::new(),
637    };
638    Ok(CommonConfig {
639        key,
640        client_id,
641        defaults: StatusDefaults {
642            network_id: u("network_id")?,
643            genesis: hash("genesis_hash")?,
644            forks,
645            fork_next: u("fork_next")?,
646            latest_block: u("latest_block")?,
647            latest_block_hash: hash("latest_block_hash")?,
648        },
649    })
650}
651
652impl Protocol for Devp2pProtocol {
653    fn protocol_name(&self) -> &'static str {
654        "devp2p"
655    }
656    fn stack_name(&self) -> &'static str {
657        "ETH>IP>TCP>RLPx>devp2p"
658    }
659    fn description(&self) -> &'static str {
660        "Ethereum devp2p node (RLPx, eth/68 and eth/69) whose admission, Status and block-header answers the LLM decides"
661    }
662    fn example_prompt(&self) -> &'static str {
663        "Start an Ethereum devp2p node on port 30303; admit every peer by mirroring its Status and answer header requests from these blocks"
664    }
665    fn keywords(&self) -> Vec<&'static str> {
666        vec![
667            "devp2p",
668            "ethereum",
669            "eth/68",
670            "eth/69",
671            "rlpx",
672            "enode",
673            "geth",
674            "ethereum node",
675            "execution client",
676        ]
677    }
678    fn group_name(&self) -> &'static str {
679        "Blockchain"
680    }
681
682    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
683        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
684        ProtocolMetadataV2::builder()
685            .state(DevelopmentState::Experimental)
686            .well_known_port(30303)
687            .answers_on_failure()
688            .max_inbound_bytes(session::MAX_MESSAGE_BYTES)
689            .implementation(
690                "Hand-written RLPx (no Ethereum crate): the EIP-8 auth/ack handshake (secp256k1 \
691                 ECDH and recoverable signatures from the secp256k1 crate, ECIES with \
692                 AES-128-CTR and HMAC-SHA256 over a SHA-256 concat KDF), AES-256-CTR frames \
693                 with go-ethereum's keccak MAC construction, Snappy after Hello v5, a bounded \
694                 canonical RLP codec, the base protocol (Hello, Disconnect, Ping/Pong) and \
695                 eth/68 and eth/69 (Status, GetBlockHeaders/BlockHeaders, \
696                 GetBlockBodies/BlockBodies, NewPooledTransactionHashes, \
697                 GetPooledTransactions/PooledTransactions, BlockRangeUpdate; GetReceipts is \
698                 answered empty). Block hashes are keccak-256 of the RLP header, computed here",
699            )
700            .llm_control(
701                "Admission and this node's Status (eth_status), every answer to the peer's \
702                 header, body and pooled-transaction requests, and the requests sent to it; \
703                 headers are JSON fields, never bytes",
704            )
705            .e2e_testing(
706                "tests/server/devp2p: go-ethereum 1.17 dials NetGet after admin_addPeer, \
707                 completes the eth/69 handshake, lists NetGet in admin_peers with the block \
708                 range NetGet sent, and verifies with --eth.requiredblocks that the header \
709                 NetGet served hashes to its own block; go-ethereum's devp2p tool reads \
710                 NetGet's Hello; reth 2.7 dials NetGet and lists it in admin_peers. \
711                 tshark has no RLPx dissector (every frame is encrypted), so no pcap oracle",
712            )
713            .notes(
714                "Experimental: two independent implementations (go-ethereum, with its devp2p \
715                 tool, and reth) complete sessions with it in tests that fail rather than skip, \
716                 but no fuzz target exists, nobody has reviewed it against the devp2p specs \
717                 line by line, and its pair job had not run in CI when it landed. No discovery \
718                 (discv4/discv5): peers are added statically (admin_addPeer, --bootnodes, \
719                 static nodes). No snap protocol. Bodies and pooled transactions are checked \
720                 for shape and, for pooled transactions, against the hashes asked for; a body \
721                 is not checked against its header's transactionsRoot (that needs a Merkle \
722                 Patricia trie). Nothing is stored: every header the node serves is one the \
723                 model wrote.",
724            )
725            .build()
726    }
727
728    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
729        common_startup_parameters()
730    }
731
732    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
733        Vec::new()
734    }
735
736    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
737        all_actions()
738    }
739
740    fn get_event_types(&self) -> Vec<EventType> {
741        event_types()
742    }
743
744    fn get_startup_examples(&self) -> StartupExamples {
745        StartupExamples::new(
746            json!({
747                "type": "open_server",
748                "port": 30303,
749                "base_stack": "devp2p",
750                "startup_params": {"network_id": 1337},
751                "instruction": "Admit every peer on network 1337 by mirroring its Status. Answer \
752                                header requests with an empty list."
753            }),
754            json!({
755                "type": "open_server",
756                "port": 30303,
757                "base_stack": "devp2p",
758                "event_handlers": [{
759                    "event_pattern": "*",
760                    "handler": {
761                        "type": "script",
762                        "language": "python",
763                        "code": "import json,sys\ni=json.load(sys.stdin); t=i['event_type_id']\na=[]\nif t=='eth_status': a=[{'type':'eth_send_status','mirror_peer':True}]\nelif t=='eth_block_headers_requested': a=[{'type':'eth_send_block_headers','headers':[]}]\nprint(json.dumps({'actions':a}))"
764                    }
765                }]
766            }),
767            json!({
768                "type": "open_server",
769                "port": 30303,
770                "base_stack": "devp2p",
771                "event_handlers": [
772                    {"event_pattern": "eth_status", "handler": {"type": "static", "actions": [
773                        {"type": session::SEND_STATUS, "mirror_peer": true}
774                    ]}},
775                    {"event_pattern": "eth_block_headers_requested", "handler": {"type": "static", "actions": [
776                        {"type": session::SEND_HEADERS, "headers": []}
777                    ]}},
778                    {"event_pattern": "*", "handler": {"type": "static", "actions": []}}
779                ]
780            }),
781        )
782    }
783}
784
785impl Server for Devp2pProtocol {
786    fn spawn(
787        &self,
788        ctx: crate::protocol::SpawnContext,
789    ) -> std::pin::Pin<
790        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
791    > {
792        Box::pin(async move {
793            let config = common_config(ctx.startup_params.as_ref())?;
794            crate::server::devp2p::Devp2pServer::spawn(
795                ctx.legacy_listen_addr(),
796                ctx.llm_client,
797                ctx.state,
798                ctx.status_tx,
799                ctx.server_id,
800                config,
801            )
802            .await
803        })
804    }
805
806    fn execute_action(&self, action: Value) -> Result<ActionResult> {
807        let kind = action
808            .get("type")
809            .and_then(|v| v.as_str())
810            .ok_or_else(|| anyhow!("Missing 'type' field in action"))?
811            .to_string();
812        session::parse_command(&action)?;
813        Ok(ActionResult::Custom {
814            name: kind,
815            data: action,
816        })
817    }
818}