Skip to main content

dig_peer/
lib.rs

1//! # dig-peer — the DIG Network peer client
2//!
3//! [`DigPeer`] is the one client every consumer uses to talk to a DIG Network peer. You describe the
4//! peer once ([`PeerTarget`]) and get back a connected [`DigPeer`]; it drives [`dig-nat`](dig_nat)'s
5//! full traversal ladder (direct → UPnP → NAT-PMP → PCP → hole-punch → relayed, IPv6-first) under the
6//! hood, so the caller never chooses a transport. On top of that mutually-authenticated (mTLS)
7//! connection it exposes **typed RPC** over [`dig-rpc-protocol`](dig_rpc_protocol) and seals
8//! **directed** calls end-to-end to the peer's verified BLS-G1 identity (§5.4). [`DigPeer::disconnect`]
9//! tears the connection down cleanly.
10//!
11//! dig-peer is the **client mirror** of `dig-rpc`'s server. It wraps a [`dig_nat::PeerConnection`]
12//! (point-to-point) — NOT `dig-gossip`, which is the mesh/broadcast layer. It is also **not** a
13//! `ChiaPeer`: DigPeer connects to DIG Network peers over dig-nat mTLS + dig-rpc-protocol; it pulls in
14//! zero Chia full-node protocol.
15//!
16//! ## Reaching a SPECIFIC peer requires its `peer_id` (security)
17//!
18//! [`DigPeer::connect`] takes a [`PeerTarget`] carrying the peer's **`peer_id`** (`SHA-256(SPKI DER)`)
19//! and pins the mTLS handshake to it (via dig-nat / dig-tls). Chaining to the DigNetwork CA alone
20//! authorizes *a* DIG peer, never a *specific* one — so a caller that means to reach peer X MUST
21//! supply X's `peer_id`, or a different CA-valid peer could answer in its place. This is enforced, not
22//! advisory: connect fails if the peer that answers does not present the expected `peer_id`.
23//!
24//! ## Directed calls are sealed, fail-closed (§5.4)
25//!
26//! Control RPCs that carry peer-specific content (`getNetworkInfo`, `getPeers`, `announce`) are
27//! DIRECTED: dig-peer seals their payload to the peer's captured BLS-G1 key before sending, so an
28//! intermediary that terminates TLS (a relay) forwards ciphertext it cannot read. A directed call is
29//! **refused** (never downgraded to plaintext) if the peer presented no verified BLS-G1 key or no
30//! local sealing identity is configured — set one with [`DigPeer::with_sealing_identity`].
31//!
32//! Public-read/availability calls (`health`, `getAvailability`, byte-range fetch) carry
33//! public-by-nature, merkle-verified content and are NOT directed (§5.4 sensible-scope exemption):
34//! they ride mTLS unsealed.
35//!
36//! ## Example
37//!
38//! ```no_run
39//! # use std::sync::Arc;
40//! # use dig_peer::{DigPeer, PeerTarget, NodeCert, PeerId};
41//! # async fn run(node: Arc<NodeCert>, peer_id: PeerId, addr: std::net::SocketAddr) -> Result<(), dig_peer::DigPeerError> {
42//! let target = PeerTarget::with_addr(peer_id, addr, "DIG_MAINNET");
43//! let mut peer = DigPeer::connect(&target, &node).await?;
44//! let health = peer.health().await?;
45//! println!("peer status: {}", health.status);
46//! peer.disconnect().await;
47//! # Ok(()) }
48//! ```
49
50#![forbid(unsafe_code)]
51#![warn(missing_docs)]
52
53pub mod error;
54pub mod rpc;
55pub mod seal;
56pub mod state;
57
58use std::sync::Arc;
59
60use dig_nat::{connect_with_runtime, NatConfig, NatRuntime, PeerConnection, PeerId as NatPeerId};
61use dig_rpc_protocol::envelope::{JsonRpcRequest, RequestId};
62use dig_rpc_protocol::types::{
63    AnnounceAck, AnnounceParams, FetchModuleRangeParams, GetModuleInfoParams, Health, Methods,
64    ModuleInfo, NetworkInfo, PeersList,
65};
66use dig_rpc_protocol::{JsonRpcResponse, Method};
67use serde::de::DeserializeOwned;
68use serde::Serialize;
69
70pub use dig_nat::{AvailabilityItem, AvailabilityResponse, PeerStream, PeerTarget, RangeRequest};
71pub use dig_tls::{NodeCert, PeerId};
72
73pub use error::{DigPeerError, Result};
74pub use seal::SealingIdentity;
75pub use state::PeerState;
76
77/// A connected DIG Network peer — the client every consumer uses.
78///
79/// Obtain one with [`DigPeer::connect`]. It owns the underlying mTLS [`PeerConnection`], the local and
80/// remote `peer_id`s, the peer's captured BLS-G1 key (the seal target), and an optional
81/// [`SealingIdentity`] for directed calls. Each RPC opens a fresh multiplexed stream, so calls are
82/// naturally concurrent-safe against the mux; the `&mut self` receiver serializes them per client.
83pub struct DigPeer {
84    /// This node's own `peer_id` — the sender identity for directed seals.
85    local_peer_id: PeerId,
86    /// The verified remote `peer_id` (== the [`PeerTarget::peer_id`] asked for).
87    peer_id: PeerId,
88    /// The peer's verified BLS-G1 identity (48-byte compressed), captured from the cert binding.
89    /// `None` for a legacy/unbound peer — directed sealed calls are then refused (fail-closed).
90    peer_bls_pub: Option<[u8; 48]>,
91    /// The multiplexed mTLS connection.
92    conn: PeerConnection,
93    /// The local sealing identity for directed calls; `None` until [`Self::with_sealing_identity`].
94    sealing: Option<SealingIdentity>,
95    /// The connection lifecycle state.
96    state: PeerState,
97    /// The next JSON-RPC request id.
98    next_id: u64,
99}
100
101impl std::fmt::Debug for DigPeer {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        f.debug_struct("DigPeer")
104            .field("peer_id", &self.peer_id)
105            .field("state", &self.state)
106            .field("sealable", &self.peer_bls_pub.is_some())
107            .field("has_sealing_identity", &self.sealing.is_some())
108            .finish()
109    }
110}
111
112impl DigPeer {
113    /// Connect to `peer`, driving the Direct traversal tier only (the convenience entry point for a
114    /// publicly-reachable peer / loopback). A NAT'd peer needing the full ladder uses
115    /// [`Self::connect_with_runtime`].
116    ///
117    /// `tls` is this node's mTLS [`NodeCert`]; the handshake pins the remote to
118    /// [`PeerTarget::peer_id`] (see the crate-level security note). Captures the peer's BLS-G1 key for
119    /// sealing.
120    ///
121    /// # Errors
122    /// [`DigPeerError::Transport`] if the peer is unreachable or the `peer_id` pin fails.
123    pub async fn connect(peer: &PeerTarget, tls: &Arc<NodeCert>) -> Result<Self> {
124        Self::connect_with_runtime(peer, tls, &NatConfig::default(), &NatRuntime::default()).await
125    }
126
127    /// Connect to `peer`, auto-composing the **full** NAT-traversal ladder from `config` + the live
128    /// `runtime` handles (direct → UPnP → NAT-PMP → PCP → hole-punch → relayed, IPv6-first). The
129    /// caller never chooses the method — dig-nat picks the first tier that establishes a
130    /// `peer_id`-verified mTLS connection.
131    ///
132    /// # Errors
133    /// [`DigPeerError::Transport`] if no tier could be composed or every composed tier failed.
134    pub async fn connect_with_runtime(
135        peer: &PeerTarget,
136        tls: &Arc<NodeCert>,
137        config: &NatConfig,
138        runtime: &NatRuntime,
139    ) -> Result<Self> {
140        let conn = connect_with_runtime(peer, tls, config, runtime).await?;
141        verify_pinned_peer_id(peer.peer_id, conn.peer_id)?;
142        Ok(Self::from_connection(tls.peer_id(), conn))
143    }
144
145    /// Wrap an already-established [`PeerConnection`] as a [`DigPeer`], recording this node's own
146    /// `peer_id` (`local_peer_id`, the sender identity for directed seals).
147    ///
148    /// Useful for a serving node that accepted an inbound dig-nat connection and wants the typed-RPC
149    /// client surface over it, without re-dialing.
150    ///
151    /// Unlike [`Self::connect_with_runtime`], this does NOT run the [`verify_pinned_peer_id`]
152    /// defense-in-depth check: an *accepted inbound* connection has no caller-pinned target to compare
153    /// against — its remote `peer_id` is *captured* from the verified handshake (`conn.peer_id`),
154    /// not *pinned* by this node. The mTLS handshake still authenticated that identity; there is simply
155    /// no expected value to assert equality against.
156    #[must_use]
157    pub fn from_connection(local_peer_id: NatPeerId, conn: PeerConnection) -> Self {
158        Self {
159            local_peer_id,
160            peer_id: conn.peer_id,
161            peer_bls_pub: conn.peer_bls_pub,
162            conn,
163            sealing: None,
164            state: PeerState::Connected,
165            next_id: 1,
166        }
167    }
168
169    /// Attach a [`SealingIdentity`] so this client can make **directed** (sealed) RPC calls. Without
170    /// one, directed calls fail with [`DigPeerError::NoSealingIdentity`].
171    #[must_use]
172    pub fn with_sealing_identity(mut self, sealing: SealingIdentity) -> Self {
173        self.sealing = Some(sealing);
174        self
175    }
176
177    /// The verified remote `peer_id`.
178    #[must_use]
179    pub fn peer_id(&self) -> PeerId {
180        self.peer_id
181    }
182
183    /// The peer's verified BLS-G1 identity (the seal target), or `None` for an unbound peer.
184    #[must_use]
185    pub fn peer_bls_pub(&self) -> Option<[u8; 48]> {
186        self.peer_bls_pub
187    }
188
189    /// The connection lifecycle state.
190    #[must_use]
191    pub fn state(&self) -> PeerState {
192        self.state
193    }
194
195    /// Whether directed sealed RPC is possible right now (the peer is bound AND a sealing identity is
196    /// configured).
197    #[must_use]
198    pub fn is_sealable(&self) -> bool {
199        self.peer_bls_pub.is_some() && self.sealing.is_some()
200    }
201
202    // ---- Typed RPC methods ---------------------------------------------------------------------
203
204    /// `dig.health` — liveness + capability summary. Public-read (unsealed).
205    ///
206    /// # Errors
207    /// [`DigPeerError`] on transport/protocol failure or a peer RPC error.
208    pub async fn health(&mut self) -> Result<Health> {
209        self.call_public(Method::Health, &serde_json::Value::Null)
210            .await
211    }
212
213    /// `dig.methods` — the method names this peer implements (agent self-describe). Public-read.
214    ///
215    /// # Errors
216    /// [`DigPeerError`] on transport/protocol failure or a peer RPC error.
217    pub async fn methods(&mut self) -> Result<Methods> {
218        self.call_public(Method::Methods, &serde_json::Value::Null)
219            .await
220    }
221
222    /// `dig.getNetworkInfo` — this peer's own network posture. **Directed** (sealed §5.4).
223    ///
224    /// # Errors
225    /// [`DigPeerError::PeerNotSealable`]/[`DigPeerError::NoSealingIdentity`] if sealing is impossible;
226    /// otherwise transport/protocol/seal failures or a peer RPC error.
227    pub async fn get_network_info(&mut self) -> Result<NetworkInfo> {
228        self.call_directed(Method::GetNetworkInfo, &serde_json::Value::Null)
229            .await
230    }
231
232    /// `dig.getPeers` — the peers this peer knows (peer exchange). **Directed** (sealed §5.4).
233    ///
234    /// # Errors
235    /// As [`Self::get_network_info`].
236    pub async fn get_peers(&mut self) -> Result<PeersList> {
237        self.call_directed(Method::GetPeers, &serde_json::Value::Null)
238            .await
239    }
240
241    /// `dig.announce` — announce this node's presence to the peer. **Directed** (sealed §5.4).
242    ///
243    /// # Errors
244    /// As [`Self::get_network_info`].
245    pub async fn announce(&mut self, params: &AnnounceParams) -> Result<AnnounceAck> {
246        self.call_directed(Method::Announce, params).await
247    }
248
249    /// `dig.getAvailability` — batch presence pre-check across stores/roots/capsules. Public content
250    /// presence (unsealed §5.4 exemption); delegates to the dig-nat mux availability primitive.
251    ///
252    /// # Errors
253    /// [`DigPeerError::Io`] on stream failure; [`DigPeerError::InvalidState`] after disconnect.
254    pub async fn get_availability(
255        &mut self,
256        items: Vec<AvailabilityItem>,
257    ) -> Result<AvailabilityResponse> {
258        self.ensure_usable()?;
259        Ok(self.conn.query_availability(items).await?)
260    }
261
262    /// `dig.fetchRange` — open a byte-range stream for `req` (public merkle-verified content, unsealed
263    /// §5.4 exemption); delegates to the dig-nat mux range primitive. Returns the raw stream the
264    /// caller reads [`dig_nat::RangeFrame`]s from.
265    ///
266    /// # Errors
267    /// [`DigPeerError::Io`] on stream failure; [`DigPeerError::InvalidState`] after disconnect.
268    pub async fn fetch_range(&mut self, req: &RangeRequest) -> Result<dig_nat::PeerStream> {
269        self.ensure_usable()?;
270        Ok(self.conn.open_range_stream(req).await?)
271    }
272
273    /// `dig.getModuleInfo` — the transfer descriptor for a whole `.dig` module (public merkle-verified
274    /// content presence, unsealed §5.4 exemption).
275    ///
276    /// The returned [`ModuleInfo`] is the peer's DECLARATION, never a trust anchor: it plans the pull
277    /// and gives per-chunk attribution, but the puller's authority is the whole-blob hash plus the
278    /// chain-anchor gate it runs on the assembled module (NC-9). A caller that admits a module on this
279    /// descriptor alone would reshare whatever the peer chose to describe.
280    ///
281    /// # Errors
282    /// [`DigPeerError::Rpc`] if the peer does not hold the module; [`DigPeerError::Io`] on stream
283    /// failure; [`DigPeerError::InvalidState`] after disconnect.
284    pub async fn get_module_info(&mut self, params: &GetModuleInfoParams) -> Result<ModuleInfo> {
285        self.call_public(Method::GetModuleInfo, params).await
286    }
287
288    /// `dig.fetchModuleRange` — open a byte-range stream over the whole `.dig` module blob for
289    /// `(store_id, root)` (public merkle-verified content, unsealed §5.4 exemption).
290    ///
291    /// Returns the stream the caller reads [`dig_nat::RangeFrame`]s from until a frame reports
292    /// `complete`. A holder legitimately answers at its OWN frame granularity, so one request can
293    /// yield MANY frames — a caller that reads only the first truncates the window.
294    ///
295    /// This mirrors [`Self::fetch_range`] but names the WHOLE module rather than one resource inside
296    /// it, and so carries no `retrieval_key`: the whole-`.dig` pull is what lets a node that read one
297    /// resource become a complete resharer of the capsule (#1576).
298    ///
299    /// # Errors
300    /// [`DigPeerError::Io`] on stream failure; [`DigPeerError::InvalidState`] after disconnect.
301    pub async fn fetch_module_range(
302        &mut self,
303        params: &FetchModuleRangeParams,
304    ) -> Result<PeerStream> {
305        self.ensure_usable()?;
306        // A JSON-RPC-named request whose RESPONSE is a frame stream rather than one envelope, so it
307        // rides the raw stream rather than `call_public`. The method name is the discriminator the
308        // server routes on — the request shape alone cannot say "stream me frames".
309        let request = self.build_request(Method::FetchModuleRange, params)?;
310        let body = rpc::to_json(&request)?;
311        let mut stream = self.conn.open_stream().await?;
312        rpc::write_framed(&mut stream, &body).await?;
313        Ok(stream)
314    }
315
316    /// Open a generic **raw** multiplexed stream over the (already-established, mTLS-authenticated)
317    /// connection and hand it to the caller — the **unsealed raw-stream escape hatch** for a consumer
318    /// that carries its OWN wire framing rather than dig-peer's typed RPC methods.
319    ///
320    /// This is the same authenticated mux path [`Self::fetch_range`] and the internal RPC calls ride;
321    /// it performs NO framing, NO JSON, and NO sealing — the bytes on the wire are entirely the
322    /// caller's. It exists because a same-level (L20) consumer (e.g. `dig-dht`, whose `DhtRequest`
323    /// dig-peer cannot typed-wrap without an illegal same-level dependency) must encode/decode its own
324    /// frame over the authenticated connection.
325    ///
326    /// **Unsealed, by design.** Like content/[`Self::fetch_range`], the stream rides mTLS but is NOT
327    /// end-to-end sealed. Directed/secret messages MUST use the sealed typed methods
328    /// ([`Self::get_network_info`], [`Self::get_peers`], [`Self::announce`]) — never this escape hatch.
329    /// A caller that puts recipient-specific secret content on this stream is responsible for its own
330    /// §5.4 sealing; dig-peer does not seal it.
331    ///
332    /// # Errors
333    /// [`DigPeerError::Io`] on stream failure; [`DigPeerError::InvalidState`] after disconnect.
334    pub async fn open_stream(&mut self) -> Result<PeerStream> {
335        self.ensure_usable()?;
336        Ok(self.conn.open_stream().await?)
337    }
338
339    /// Cleanly tear down the connection. Once closed, RPCs fail with [`DigPeerError::InvalidState`].
340    /// Dropping the underlying session ends the mux driver and closes the mTLS byte stream.
341    pub async fn disconnect(mut self) {
342        self.state = PeerState::Closed;
343        // Dropping `self` (and thus `conn`/its `PeerSession`) closes the mux command channel, which
344        // ends the driver task and tears down the underlying byte stream. The explicit state flip is
345        // for symmetry + any post-teardown hooks; the drop does the real work.
346    }
347
348    // ---- Internal call plumbing ----------------------------------------------------------------
349
350    /// Issue an UNSEALED (public-read) JSON-RPC call over a fresh stream.
351    async fn call_public<P, R>(&mut self, method: Method, params: &P) -> Result<R>
352    where
353        P: Serialize,
354        R: DeserializeOwned,
355    {
356        self.ensure_usable()?;
357        let request = self.build_request(method, params)?;
358        let body = rpc::to_json(&request)?;
359
360        let mut stream = self.conn.open_stream().await?;
361        rpc::write_framed(&mut stream, &body).await?;
362        let response_bytes = rpc::read_framed(&mut stream).await?;
363        Self::decode_result(&response_bytes)
364    }
365
366    /// Issue a DIRECTED (sealed §5.4) JSON-RPC call over a fresh stream — fail-closed if the peer is
367    /// unbound or no sealing identity is configured.
368    async fn call_directed<P, R>(&mut self, method: Method, params: &P) -> Result<R>
369    where
370        P: Serialize,
371        R: DeserializeOwned,
372    {
373        self.ensure_usable()?;
374        let peer_bls_pub = self.peer_bls_pub.ok_or(DigPeerError::PeerNotSealable)?;
375        if self.sealing.is_none() {
376            return Err(DigPeerError::NoSealingIdentity);
377        }
378
379        let request = self.build_request(method, params)?;
380        let plaintext = rpc::to_json(&request)?;
381
382        let (local_peer_id, peer_id) = (self.local_peer_id, self.peer_id);
383        let sealing = self.sealing.as_mut().expect("checked is_some above");
384        let (sealed_request, correlation) =
385            sealing.seal_request(local_peer_id, peer_id, &peer_bls_pub, &plaintext)?;
386
387        let mut stream = self.conn.open_stream().await?;
388        rpc::write_framed(&mut stream, &sealed_request).await?;
389        let sealed_response = rpc::read_framed(&mut stream).await?;
390
391        let sealing = self.sealing.as_mut().expect("checked is_some above");
392        let plaintext_response =
393            sealing.open_response(&peer_bls_pub, correlation, &sealed_response)?;
394        Self::decode_result(&plaintext_response)
395    }
396
397    /// Build a typed JSON-RPC request envelope with the next correlation id.
398    fn build_request<P: Serialize>(
399        &mut self,
400        method: Method,
401        params: &P,
402    ) -> Result<JsonRpcRequest<serde_json::Value>> {
403        let params_value =
404            serde_json::to_value(params).map_err(|e| DigPeerError::Codec(e.to_string()))?;
405        let id = self.next_id;
406        self.next_id = self.next_id.wrapping_add(1);
407        Ok(JsonRpcRequest {
408            jsonrpc: dig_rpc_protocol::Version,
409            id: RequestId::Num(id),
410            method: method.name().to_string(),
411            params: Some(params_value),
412        })
413    }
414
415    /// Decode a JSON-RPC response body into `R`, mapping a peer error envelope to
416    /// [`DigPeerError::Rpc`].
417    fn decode_result<R: DeserializeOwned>(bytes: &[u8]) -> Result<R> {
418        let response: JsonRpcResponse<serde_json::Value> = rpc::from_json(bytes)?;
419        if let Some(error) = response.as_error() {
420            return Err(DigPeerError::Rpc(Box::new(error.clone())));
421        }
422        match response.as_result() {
423            Some(value) => serde_json::from_value(value.clone())
424                .map_err(|e| DigPeerError::Codec(e.to_string())),
425            None => Err(DigPeerError::Codec(
426                "response carried neither result nor error".into(),
427            )),
428        }
429    }
430
431    /// Reject an operation if the connection is not usable (post-disconnect).
432    ///
433    /// Defensive guard. Today the [`PeerState::Closed`] branch is effectively **unreachable through the
434    /// public API**: the only transition to `Closed` is [`Self::disconnect`], which consumes `self`, so
435    /// a caller cannot hold a `DigPeer` and issue an RPC after closing it (use-after-close is prevented
436    /// at the type level, not merely by this runtime check). The branch is retained as an explicit
437    /// invariant so that a future non-consuming close / opportunistic re-dial (a documented follow-up)
438    /// stays fail-closed by construction rather than by accident.
439    fn ensure_usable(&self) -> Result<()> {
440        if self.state.is_usable() {
441            Ok(())
442        } else {
443            Err(DigPeerError::InvalidState(self.state))
444        }
445    }
446}
447
448/// Verify the connection's authenticated remote `peer_id` matches the one the caller pinned in the
449/// [`PeerTarget`]. `dig-nat`/`dig-tls` already enforce this pin during the mTLS handshake (a peer that
450/// presents the wrong `peer_id` is rejected before a connection is ever returned), so this check is
451/// **defense-in-depth**: if a future transport regression returned a connection to a different peer
452/// than requested, dig-peer fails closed with [`DigPeerError::PeerIdMismatch`] rather than silently
453/// handing back the wrong peer.
454fn verify_pinned_peer_id(expected: PeerId, actual: PeerId) -> Result<()> {
455    if expected == actual {
456        Ok(())
457    } else {
458        Err(DigPeerError::PeerIdMismatch { expected, actual })
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use dig_rpc_protocol::error::{ErrorCode, ErrorOrigin, RpcError};
466
467    /// **Proves:** a JSON-RPC success body decodes into the typed result.
468    #[test]
469    fn decode_result_returns_the_typed_result() {
470        let response = JsonRpcResponse::success(RequestId::Num(1), serde_json::json!({"n": 7}));
471        let bytes = serde_json::to_vec(&response).unwrap();
472        let value: serde_json::Value = DigPeer::decode_result(&bytes).expect("decodes");
473        assert_eq!(value["n"], 7);
474    }
475
476    /// **Proves:** a JSON-RPC error body maps to [`DigPeerError::Rpc`] carrying the peer's error.
477    #[test]
478    fn decode_result_maps_a_peer_error_envelope() {
479        let rpc_error = RpcError::new(
480            ErrorCode::MethodNotFound,
481            "method not found",
482            ErrorOrigin::Node,
483        );
484        let response: JsonRpcResponse = JsonRpcResponse::error(RequestId::Num(1), rpc_error);
485        let bytes = serde_json::to_vec(&response).unwrap();
486        let result: Result<serde_json::Value> = DigPeer::decode_result(&bytes);
487        assert!(matches!(result, Err(DigPeerError::Rpc(_))));
488    }
489
490    /// **Proves:** a body that is neither result nor error is a `Codec` failure, not a silent success.
491    #[test]
492    fn decode_result_rejects_a_bodyless_response() {
493        let result: Result<serde_json::Value> = DigPeer::decode_result(b"garbage");
494        assert!(matches!(result, Err(DigPeerError::Codec(_))));
495    }
496
497    /// **Proves:** the defense-in-depth pin check accepts a connection whose `peer_id` matches the
498    /// pinned target.
499    #[test]
500    fn verify_pinned_peer_id_accepts_a_match() {
501        let id = PeerId::from_bytes([0x11; 32]);
502        assert!(verify_pinned_peer_id(id, id).is_ok());
503    }
504
505    /// **Proves:** the defense-in-depth pin check fails CLOSED (`PeerIdMismatch`) when the answering
506    /// peer's `peer_id` differs from the pinned target — dig-peer never hands back the wrong peer even
507    /// if the transport's own pin regressed.
508    /// **Catches:** a regression that dropped the belt-and-suspenders check and trusted the transport
509    /// to have upheld the pin (the check silently accepting any peer).
510    #[test]
511    fn verify_pinned_peer_id_rejects_a_mismatch() {
512        let expected = PeerId::from_bytes([0x11; 32]);
513        let actual = PeerId::from_bytes([0x22; 32]);
514        assert!(matches!(
515            verify_pinned_peer_id(expected, actual),
516            Err(DigPeerError::PeerIdMismatch { .. })
517        ));
518    }
519}