Skip to main content

dig_nat/
wire.rs

1//! Relay protocol wire types — **vendored, byte-identical** to `dig-relay`'s `src/wire.rs`,
2//! `dig-node`'s `relay::RelayMessage`, and `dig-gossip`'s `relay_types` (requirements
3//! **RLY-001** through **RLY-007**).
4//!
5//! # Provenance & contract
6//!
7//! The canonical relay wire lives in `dig-gossip` (`src/relay/relay_types.rs`); `dig-relay` is the
8//! SERVER, `dig-node`/`dig-nat` are CLIENTS of the same JSON-over-WebSocket wire. These types are
9//! copied here verbatim rather than depending on `dig-gossip` because the wire depends only on
10//! `serde` + `std`, whereas `dig-gossip` pulls the entire L2/Chia stack just to expose two structs.
11//! The `#[serde(tag = "type")]` discriminators + field names MUST stay byte-identical to the
12//! server's so both speak the same JSON; this is pinned by `tests/wire_conformance.rs`. The
13//! superproject `SYSTEM.md` records the change-impact edge: a change to the relay wire must be
14//! mirrored across all four copies in the same unit of work.
15
16use std::net::SocketAddr;
17use std::time::{SystemTime, UNIX_EPOCH};
18
19use serde::{Deserialize, Serialize};
20
21/// Complete relay protocol message enum — JSON over WebSocket, `#[serde(tag = "type")]`.
22// Field-level docs are intentionally omitted on this VENDORED type: the fields are the wire
23// contract, kept byte-identical to the four copies (dig-relay, dig-node, dig-gossip, dig-nat), and
24// documenting them per-copy would invite drift. The variant docs above each `#[serde(rename)]`
25// carry the RLY-00x meaning; the field names ARE the JSON keys.
26// `non_exhaustive` so ADDING a wire message is no longer a breaking change.
27//
28// This wire grows: RLY-008 added PEX, RLY-009 added the DHT-record view. Each addition was a
29// semver-major event for a `pub enum`, because a downstream exhaustive `match` stops compiling —
30// and in this ecosystem that meant every consumer pinned to the old minor (dig-gossip, dig-dht,
31// dig-download, dig-peer-selector, dig-peer) had to be bumped and re-released before dig-node could
32// pick the change up at all. RLY-009 cost exactly that cascade (dig_ecosystem #1935).
33//
34// With this attribute a new variant is ADDITIVE: external matches already carry a wildcard arm, so
35// the next RLY-0xx ships as a PATCH and reaches every consumer on a plain `cargo update`. Adding it
36// is itself the last breaking change of this class.
37#[allow(missing_docs)]
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(tag = "type")]
40#[non_exhaustive]
41pub enum RelayMessage {
42    // -- RLY-001: Registration --
43    /// Client → Relay: register after WebSocket connect.
44    #[serde(rename = "register")]
45    Register {
46        peer_id: String,
47        network_id: String,
48        protocol_version: u32,
49        // The node's advertised gossip LISTEN candidate address(es), IPv6-first (§5.2). Additive
50        // since protocol v1 (NC-6 soft-fork): appended LAST, default-empty + skip-when-empty so the
51        // wire stays byte-identical for pre-#924 peers. Byte-identical to dig-relay-protocol 0.2.0.
52        #[serde(default, skip_serializing_if = "Vec::is_empty")]
53        listen_addrs: Vec<SocketAddr>,
54    },
55
56    /// Relay → Client: registration acknowledgement.
57    #[serde(rename = "register_ack")]
58    RegisterAck {
59        success: bool,
60        message: String,
61        connected_peers: usize,
62    },
63
64    /// Client → Relay: graceful disconnect.
65    #[serde(rename = "unregister")]
66    Unregister { peer_id: String },
67
68    // -- RLY-002: Targeted message forwarding --
69    /// Client → Relay → Client: forward to a specific peer.
70    #[serde(rename = "relay_message")]
71    RelayGossipMessage {
72        from: String,
73        to: String,
74        payload: Vec<u8>,
75        seq: u64,
76    },
77
78    // -- RLY-003: Broadcast --
79    /// Client → Relay → All: broadcast to all relay peers.
80    #[serde(rename = "broadcast")]
81    Broadcast {
82        from: String,
83        payload: Vec<u8>,
84        exclude: Vec<String>,
85    },
86
87    // -- Peer notifications --
88    /// Relay → Client: new peer connected to relay.
89    #[serde(rename = "peer_connected")]
90    PeerConnected { peer: RelayPeerInfo },
91
92    /// Relay → Client: peer disconnected from relay.
93    #[serde(rename = "peer_disconnected")]
94    PeerDisconnected { peer_id: String },
95
96    // -- RLY-005: Peer list --
97    /// Client → Relay: request connected peer list.
98    #[serde(rename = "get_peers")]
99    GetPeers { network_id: Option<String> },
100
101    /// Relay → Client: peer list response.
102    #[serde(rename = "peers")]
103    Peers { peers: Vec<RelayPeerInfo> },
104
105    // -- RLY-006: Keepalive --
106    /// Bidirectional keepalive.
107    #[serde(rename = "ping")]
108    Ping { timestamp: u64 },
109
110    /// Keepalive response.
111    #[serde(rename = "pong")]
112    Pong { timestamp: u64 },
113
114    // -- RLY-007: NAT traversal --
115    /// Client → Relay: request hole-punch coordination.
116    #[serde(rename = "hole_punch_request")]
117    HolePunchRequest {
118        peer_id: String,
119        target_peer_id: String,
120        external_addr: SocketAddr,
121    },
122
123    /// Relay → Client: hole-punch coordination (the other peer's external address).
124    #[serde(rename = "hole_punch_coordinate")]
125    HolePunchCoordinate {
126        peer_id: String,
127        external_addr: SocketAddr,
128    },
129
130    /// Client → Relay: hole-punch result.
131    #[serde(rename = "hole_punch_result")]
132    HolePunchResult { peer_id: String, success: bool },
133
134    // -- RLY-009: DHT record observability (dig_ecosystem #1935) --
135    /// Relay → Client: ask this node for an AGGREGATED view of its DHT provider records.
136    ///
137    /// The relay is not a DHT node and holds no records, but it already keeps a live reservation to
138    /// every registered peer — and a Kademlia node stores records for keys near its OWN `peer_id`,
139    /// so its store describes MANY OTHER peers' content. Asking each connected node therefore yields
140    /// a broad slice of the real DHT without the relay ever joining it.
141    ///
142    /// `max_keys` bounds the answer. Additive (NC-6 soft-fork): a pre-RLY-009 node does not
143    /// recognise this `type` and simply never answers, which the relay MUST treat as "no data"
144    /// rather than an error.
145    #[serde(rename = "get_dht_records")]
146    GetDhtRecords { max_keys: usize },
147
148    /// Client → Relay: the aggregated view requested by [`RelayMessage::GetDhtRecords`].
149    ///
150    /// Carries COUNTS, never provider identities — a provider record is a `(peer_id, content_key)`
151    /// pair, and publishing that linkage is exactly what the relay's `/map` privacy contract
152    /// forbids.
153    #[serde(rename = "dht_records")]
154    DhtRecords {
155        records: Vec<DhtRecordEntry>,
156        /// Keys with a live provider BEFORE `max_keys` was applied, so the relay can report
157        /// "showing N of M" instead of presenting a truncated view as complete.
158        total_keys: usize,
159        truncated: bool,
160    },
161
162    // -- Error --
163    /// Relay → Client: error notification.
164    #[serde(rename = "error")]
165    Error { code: u32, message: String },
166}
167
168/// One content key in a [`RelayMessage::DhtRecords`] answer: the key and how many live providers the
169/// answering node knows for it.
170///
171/// Deliberately carries no provider identity (see [`RelayMessage::DhtRecords`]). Mirrors
172/// `dig_dht::ProviderSnapshotEntry` by shape rather than by dependency — `dig-dht` sits ABOVE
173/// `dig-nat` in the crate hierarchy, so the type is defined here and the node maps into it.
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
175pub struct DhtRecordEntry {
176    /// The 64-hex content key.
177    pub content_key: String,
178    /// How many non-expired providers the answering node holds a record for.
179    pub providers: usize,
180}
181
182/// Peer info as tracked by the relay server. `#[serde]` field names are part of the wire contract
183/// (vendored byte-identical — see the module docs; field-level docs omitted to avoid drift).
184#[allow(missing_docs)]
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
186pub struct RelayPeerInfo {
187    pub peer_id: String,
188    pub network_id: String,
189    pub protocol_version: u32,
190    pub connected_at: u64,
191    pub last_seen: u64,
192    // Relay-resolved dialable candidate address(es) for this peer, IPv6-first (§5.2) — the relay
193    // substitutes the observed reflexive IP for any unspecified/loopback/private advertised
194    // `listen_addr` host (keeping the port). Additive since protocol v1 (NC-6 soft-fork): appended
195    // LAST, default-empty + skip-when-empty. Byte-identical to dig-relay-protocol 0.2.0.
196    #[serde(default, skip_serializing_if = "Vec::is_empty")]
197    pub addresses: Vec<SocketAddr>,
198}
199
200impl RelayPeerInfo {
201    /// Build a `RelayPeerInfo` stamped with the current unix time for `connected_at`/`last_seen`.
202    pub fn new(peer_id: String, network_id: String, protocol_version: u32) -> Self {
203        let now = unix_secs();
204        Self {
205            peer_id,
206            network_id,
207            protocol_version,
208            connected_at: now,
209            last_seen: now,
210            addresses: Vec::new(),
211        }
212    }
213}
214
215/// Current unix time in seconds (saturating). Mirrors dig-gossip's metric timestamp helper.
216fn unix_secs() -> u64 {
217    SystemTime::now()
218        .duration_since(UNIX_EPOCH)
219        .map(|d| d.as_secs())
220        .unwrap_or(0)
221}