Skip to main content

dig_rpc_types/
frames.rs

1//! Shape-dispatched peer frame families: the DHT wire and the PEX wire.
2//!
3//! These travel over the mTLS peer transport but are NOT JSON-RPC `method`
4//! calls — they are dispatched on a `type` discriminator, not a `method` field,
5//! so they are modeled separately from [`crate::method::Method`]. Both belong to
6//! the [`Peer`](crate::tier::Tier::Peer) tier.
7//!
8//! - The **DHT** wire ([`DhtRequest`] / [`DhtResponse`]) is the Kademlia
9//!   content-location protocol: `find_node`, `find_providers`, `add_provider`,
10//!   `ping`.
11//! - The **PEX** wire ([`PexMessage`]) is peer-exchange: `pex_handshake`,
12//!   `pex_snapshot`, `pex_delta`, `pex_error`.
13
14use serde::{Deserialize, Serialize};
15
16use crate::types::{PeerAddress, Provider};
17
18// ===========================================================================
19// DHT wire (Kademlia content location)
20// ===========================================================================
21
22/// A DHT node's routing entry: a `peer_id` (32-byte, hex or base64 per the
23/// transport) with its addresses and record TTL.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
26pub struct DhtNode {
27    /// The node's `peer_id` (64-hex).
28    pub peer_id: String,
29    /// The node's candidate addresses (IPv6-first).
30    pub addresses: Vec<PeerAddress>,
31    /// The record's absolute expiry (unix seconds), when present.
32    #[serde(skip_serializing_if = "Option::is_none", default)]
33    pub expires_at: Option<u64>,
34}
35
36/// A DHT request frame, dispatched on `type`.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(tag = "type", rename_all = "snake_case")]
39#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
40pub enum DhtRequest {
41    /// Find the nodes closest to `target_id`.
42    FindNode {
43        /// The requesting node's id (64-hex).
44        node_id: String,
45        /// The lookup target id (64-hex).
46        target_id: String,
47    },
48    /// Find providers for a content key (plus closer nodes to continue the walk).
49    FindProviders {
50        /// The requesting node's id (64-hex).
51        node_id: String,
52        /// The content key = `SHA-256(tag ‖ ids)` (64-hex).
53        content_key: String,
54    },
55    /// Announce that a provider holds a content key.
56    AddProvider {
57        /// The requesting node's id (64-hex).
58        node_id: String,
59        /// The content key (64-hex).
60        content_key: String,
61        /// The provider being announced.
62        provider: Provider,
63    },
64    /// Liveness probe.
65    Ping {
66        /// The requesting node's id (64-hex).
67        node_id: String,
68    },
69}
70
71/// A DHT response frame.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73#[serde(untagged)]
74#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
75pub enum DhtResponse {
76    /// Response to `find_providers`: providers plus closer nodes.
77    FindProviders {
78        /// Nodes known to hold the content.
79        providers: Vec<DhtNode>,
80        /// Closer nodes to continue the iterative lookup.
81        closer: Vec<DhtNode>,
82    },
83    /// Response to `find_node`: closer nodes only.
84    FindNode {
85        /// Closer nodes to continue the iterative lookup.
86        closer: Vec<DhtNode>,
87    },
88    /// Response to `add_provider`.
89    AddProvider {
90        /// Whether the provider record was stored.
91        stored: bool,
92    },
93    /// Response to `ping`: the responder's own id.
94    Ping {
95        /// The responding node's id (64-hex).
96        node_id: String,
97    },
98}
99
100// ===========================================================================
101// PEX wire (peer exchange)
102// ===========================================================================
103
104/// A PEX peer entry: a `peer_id` with addresses, flags, and provenance.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
107pub struct PexPeer {
108    /// The peer's id (64-hex).
109    pub peer_id: String,
110    /// The peer's addresses (IPv6-first).
111    pub addresses: Vec<PeerAddress>,
112    /// Capability / status flags.
113    #[serde(default)]
114    pub flags: Vec<String>,
115    /// How this entry was learned: `"firsthand"` or `"secondhand"`.
116    #[serde(skip_serializing_if = "Option::is_none", default)]
117    pub provenance: Option<String>,
118}
119
120/// A PEX message frame, dispatched on `type`.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(tag = "type", rename_all = "snake_case")]
123#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
124pub enum PexMessage {
125    /// Handshake: capabilities + wire version.
126    PexHandshake {
127        /// Advertised capabilities.
128        capabilities: Vec<String>,
129        /// The PEX wire version.
130        version: u32,
131    },
132    /// A full peer snapshot (≤ 200 peers).
133    PexSnapshot {
134        /// The known peers.
135        peers: Vec<PexPeer>,
136    },
137    /// An incremental peer delta (≤ 50 added, ≤ 50 removed).
138    PexDelta {
139        /// Peers added since the last snapshot/delta.
140        added: Vec<PexPeer>,
141        /// The `peer_id`s removed since the last snapshot/delta (64-hex).
142        removed: Vec<String>,
143    },
144    /// A PEX-level error.
145    PexError {
146        /// The PEX error code.
147        code: u32,
148        /// A human-readable message.
149        message: String,
150    },
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    /// **Proves:** a DHT request dispatches on the `type` tag with snake_case
158    /// names matching the canonical wire.
159    #[test]
160    fn dht_request_tagged_snake_case() {
161        let req = DhtRequest::FindProviders {
162            node_id: "ab".repeat(32),
163            content_key: "cd".repeat(32),
164        };
165        let v = serde_json::to_value(&req).unwrap();
166        assert_eq!(v["type"], "find_providers");
167        assert_eq!(serde_json::from_value::<DhtRequest>(v).unwrap(), req);
168    }
169
170    /// **Proves:** `ping` round-trips and carries `node_id`.
171    #[test]
172    fn dht_ping_round_trips() {
173        let req = DhtRequest::Ping {
174            node_id: "ef".repeat(32),
175        };
176        let s = serde_json::to_string(&req).unwrap();
177        assert!(s.contains("\"type\":\"ping\""));
178        assert_eq!(serde_json::from_str::<DhtRequest>(&s).unwrap(), req);
179    }
180
181    /// **Proves:** a PEX message dispatches on `type` with snake_case names.
182    #[test]
183    fn pex_message_tagged() {
184        let msg = PexMessage::PexHandshake {
185            capabilities: vec!["snapshot".into()],
186            version: 1,
187        };
188        let v = serde_json::to_value(&msg).unwrap();
189        assert_eq!(v["type"], "pex_handshake");
190        assert_eq!(serde_json::from_value::<PexMessage>(v).unwrap(), msg);
191    }
192
193    /// **Proves:** a PEX delta carries added peers + removed ids.
194    #[test]
195    fn pex_delta_shape() {
196        let msg = PexMessage::PexDelta {
197            added: vec![PexPeer {
198                peer_id: "12".repeat(32),
199                addresses: vec![],
200                flags: vec![],
201                provenance: Some("firsthand".into()),
202            }],
203            removed: vec!["34".repeat(32)],
204        };
205        let v = serde_json::to_value(&msg).unwrap();
206        assert_eq!(v["type"], "pex_delta");
207        assert_eq!(v["added"][0]["provenance"], "firsthand");
208        assert_eq!(serde_json::from_value::<PexMessage>(v).unwrap(), msg);
209    }
210}