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}