tenzro_network/mpc_relay.rs
1//! libp2p binding for the MPC `Transport` surface used by `tenzro-bridge`.
2//!
3//! ## Two transports, one behaviour family
4//!
5//! Per the bridge-side documentation in
6//! `tenzro_bridge::mpc::libp2p_relay`, MPC sessions use two libp2p surfaces:
7//!
8//! - **request-response (`/tenzro/mpc/req-resp/1.0.0`)** — point-to-point
9//! delivery of `MpcRoundMessage` envelopes between named DIDs. Every
10//! per-recipient round message goes through this codec; reliable, ordered,
11//! back-pressure-aware. The DKLS23 broadcast-round messages are unrolled
12//! into per-peer messages by the session driver (see
13//! `tenzro_bridge::mpc::sign::SignSession::run`); the wire only ever sees
14//! point-to-point.
15//! - **gossipsub topic prefix `/tenzro/mpc/session/<instance_id>`** — broadcast
16//! channel for session-control envelopes only (quorum-open / quorum-close /
17//! abort evidence) carried in a later wave. NOT used for DKLS23 rounds;
18//! broadcasting them would leak the session graph and waste mesh
19//! bandwidth.
20//!
21//! ## DID ↔ PeerId routing
22//!
23//! The bridge layer addresses messages by TDIP DID strings; libp2p moves
24//! bytes by `PeerId`. The network crate does not own identity state, so it
25//! takes an `Arc<dyn MpcDidResolver>` from the node and uses it for every
26//! outbound dispatch and every inbound `from_did` audit. The resolver is
27//! authoritative: a missing mapping for an outbound `to_did` is a hard
28//! `TransportError`; a missing mapping for an inbound `from_did` causes the
29//! message to be rejected with `MpcRelayError::UnknownSender` so a peer
30//! can't pretend to be a DID they don't control.
31//!
32//! ## Why we don't reuse `ConsensusDirect`
33//!
34//! Consensus addresses by `PeerId` and broadcasts to the full validator set.
35//! MPC addresses by DID and routes to one specific counterparty in the
36//! signing quorum. Sharing a single codec would force both call sites to
37//! agree on a single envelope shape, blocking either side from evolving its
38//! wire format independently. The codecs are small (~150 LOC each); the
39//! separation is cheap and structurally honest.
40//!
41//! ## Concurrency limits
42//!
43//! Borrowed from `consensus_direct_proto` with tighter inbound caps because
44//! DKLS23 rounds are smaller and we expect at most one in-flight session per
45//! group at any given time:
46//!
47//! * Per-peer in-flight outbound: 32 (a 5-of-N signing session emits at
48//! most ~10 messages per round across 4 phases — 32 leaves headroom for
49//! two concurrent sessions sharing the same counterparty).
50//! * Per-peer inbound concurrent streams: 8 (overflow rejected, not
51//! queued — see the `consensus_direct_proto` design note for why).
52//! * Max request size: 1 MiB (DKLS23 phase-3 broadcasts are ~50 KB at
53//! n=10; 1 MiB is 20x headroom for future curves with larger payloads).
54//! * Max response size: 1 KiB (`Ack`/`Error` only).
55//! * Request timeout: 10 s — DKLS23 rounds are interactive, so a
56//! per-message timeout that's significantly longer than typical RTT
57//! (≤300 ms even cross-region) gives plenty of slack for a slow peer
58//! without letting the session hang indefinitely on a stuck stream.
59
60use libp2p::{
61 request_response::{self, ProtocolSupport},
62 PeerId, StreamProtocol,
63};
64use serde::{Deserialize, Serialize};
65use std::time::Duration;
66
67/// Stream-protocol identifier for the MPC round-message RPC.
68///
69/// The trailing `/1.0.0` version segment is required by libp2p's
70/// `StreamProtocol` (a third-party convention — same exception as block-sync
71/// and consensus-direct). Internal Tenzro identifiers (gossipsub topics,
72/// JSON-RPC methods, SHA-256 domain tags) intentionally omit version segments
73/// per the project's identifier conventions.
74pub const MPC_RELAY_PROTOCOL: &str = "/tenzro/mpc/req-resp/1.0.0";
75
76/// gossipsub topic prefix for per-session MPC session-control broadcasts
77/// (quorum-open / quorum-close / abort-evidence in a later wave). Per-session
78/// topic is `MPC_RELAY_GOSSIP_TOPIC_PREFIX + hex(instance_id)`. DKLS23 round
79/// messages themselves go via request-response, never gossipsub.
80pub const MPC_RELAY_GOSSIP_TOPIC_PREFIX: &str = "/tenzro/mpc/session/";
81
82/// Per-peer in-flight outbound request cap.
83pub const MAX_INFLIGHT_REQUESTS_PER_PEER: usize = 32;
84
85/// Per-peer inbound concurrent stream cap. Overflow MUST be rejected, not
86/// queued — queueing causes the requester's response timeout to fire while
87/// the request still sits in our backlog.
88pub const MAX_INBOUND_STREAMS_PER_PEER: usize = 8;
89
90/// Outbound request timeout. Tuned to ≥30x typical cross-region RTT.
91pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
92
93/// Compute the per-session gossipsub topic for an `instance_id` hex.
94pub fn session_topic(instance_id_hex: &str) -> String {
95 format!("{}{}", MPC_RELAY_GOSSIP_TOPIC_PREFIX, instance_id_hex)
96}
97
98/// Wire envelope for one MPC round message.
99///
100/// Mirrors `tenzro_bridge::mpc::transport::MpcRoundMessage` field-for-field
101/// rather than re-exporting it directly — `tenzro-network` must not depend
102/// on `tenzro-bridge` (the bridge depends on the network indirectly via the
103/// node-layer adapter). The bridge-side type re-encodes through this wire
104/// envelope at the node-layer adapter boundary.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct MpcRelayRequest {
107 /// Session-derived `InstanceId` as 32-byte hash bytes. Opaque to the
108 /// network layer; the bridge uses it to demultiplex by session.
109 pub instance_id: [u8; 32],
110 /// Phase discriminant (DKG=0, Sign=1, Refresh=2, ReKey=3). Opaque to
111 /// the network; the bridge decodes it.
112 pub phase: u8,
113 /// Monotonic round index within `phase`. Receiver-side ordering invariant
114 /// is enforced by the bridge, not the wire.
115 pub round_index: u32,
116 /// Sender TDIP DID.
117 pub from_did: String,
118 /// Receiver TDIP DID.
119 pub to_did: String,
120 /// Opaque dkls23-core round payload (bincode-serialized by the bridge).
121 pub payload: Vec<u8>,
122}
123
124/// Wire response for one MPC round message.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub enum MpcRelayResponse {
127 /// Receiver enqueued the message into its MPC session pipeline.
128 Ack,
129 /// Receiver rejected the message at the transport layer.
130 Error(MpcRelayError),
131}
132
133/// Errors returned to the sender across the wire.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
135pub enum MpcRelayError {
136 /// Receiver is at its inbound concurrency cap. Sender should back off
137 /// briefly before retrying — typically the peer is processing a burst
138 /// of round messages from a concurrent session.
139 #[error("server busy — exceeded {limit} concurrent inbound streams from this peer")]
140 ServerBusy { limit: usize },
141
142 /// Receiver has no MPC subscriber attached. Sender should NOT retry
143 /// against this peer; the peer may not be participating in this session.
144 #[error("no MPC subscriber attached")]
145 NoSubscriber,
146
147 /// Sender claimed a `from_did` whose DID-to-PeerId mapping does not
148 /// resolve back to the sending `PeerId`. The connection is dropped and
149 /// the sender is penalised at the peer-score layer.
150 #[error("sender DID does not match connection peer ID")]
151 UnknownSender,
152
153 /// Generic server-side error; payload was decoded but the receiver's
154 /// downstream pipeline rejected it.
155 #[error("server error: {0}")]
156 Internal(String),
157}
158
159/// CBOR-framed request-response behaviour for the MPC relay.
160pub type MpcRelayBehaviour =
161 request_response::cbor::Behaviour<MpcRelayRequest, MpcRelayResponse>;
162
163/// Constructs a fresh MPC relay `Behaviour` with production-tuned config.
164pub fn new_behaviour() -> MpcRelayBehaviour {
165 let protocol = StreamProtocol::new(MPC_RELAY_PROTOCOL);
166 let cfg = request_response::Config::default().with_request_timeout(REQUEST_TIMEOUT);
167 request_response::cbor::Behaviour::new(
168 std::iter::once((protocol, ProtocolSupport::Full)),
169 cfg,
170 )
171}
172
173/// DID-to-PeerId resolver injected by the node layer.
174///
175/// Implementations consult `IdentityRegistry` (or equivalent) to map TDIP
176/// DIDs ↔ libp2p PeerIds. The network layer holds an `Arc<dyn MpcDidResolver>`
177/// for the lifetime of the node so the MPC relay can route by DID without
178/// pulling in the identity-crate dependency tree.
179pub trait MpcDidResolver: Send + Sync {
180 /// Resolve a TDIP DID to the libp2p PeerId of the node that controls it.
181 /// Returns `None` if no mapping is registered (the DID is unknown to
182 /// this node, or has been revoked).
183 fn peer_id_for_did(&self, did: &str) -> Option<PeerId>;
184
185 /// Reverse lookup: which DID is bound to this PeerId? Returns `None`
186 /// for peers that have not announced an identity (RPC nodes, light
187 /// clients) or for DIDs that have been revoked. Used to audit inbound
188 /// `from_did` claims.
189 fn did_for_peer_id(&self, peer_id: &PeerId) -> Option<String>;
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn protocol_id_is_namespaced() {
198 assert!(MPC_RELAY_PROTOCOL.starts_with("/tenzro/mpc/"));
199 assert!(MPC_RELAY_GOSSIP_TOPIC_PREFIX.starts_with("/tenzro/mpc/"));
200 }
201
202 #[test]
203 fn session_topic_formats() {
204 let t = session_topic("deadbeef");
205 assert_eq!(t, "/tenzro/mpc/session/deadbeef");
206 }
207
208 #[test]
209 fn request_round_trip() {
210 let req = MpcRelayRequest {
211 instance_id: [7u8; 32],
212 phase: 1,
213 round_index: 3,
214 from_did: "did:tenzro:machine:p1".to_string(),
215 to_did: "did:tenzro:machine:p2".to_string(),
216 payload: vec![0xAA, 0xBB, 0xCC],
217 };
218 let bytes = bincode::serialize(&req).expect("encode");
219 let decoded: MpcRelayRequest = bincode::deserialize(&bytes).expect("decode");
220 assert_eq!(req.instance_id, decoded.instance_id);
221 assert_eq!(req.phase, decoded.phase);
222 assert_eq!(req.round_index, decoded.round_index);
223 assert_eq!(req.from_did, decoded.from_did);
224 assert_eq!(req.to_did, decoded.to_did);
225 assert_eq!(req.payload, decoded.payload);
226 }
227
228 #[test]
229 fn response_round_trip() {
230 let cases = vec![
231 MpcRelayResponse::Ack,
232 MpcRelayResponse::Error(MpcRelayError::ServerBusy {
233 limit: MAX_INBOUND_STREAMS_PER_PEER,
234 }),
235 MpcRelayResponse::Error(MpcRelayError::NoSubscriber),
236 MpcRelayResponse::Error(MpcRelayError::UnknownSender),
237 MpcRelayResponse::Error(MpcRelayError::Internal("downstream queue full".to_string())),
238 ];
239 for resp in cases {
240 let bytes = bincode::serialize(&resp).expect("encode");
241 let decoded: MpcRelayResponse = bincode::deserialize(&bytes).expect("decode");
242 assert_eq!(format!("{:?}", resp), format!("{:?}", decoded));
243 }
244 }
245
246 #[test]
247 fn behaviour_constructs() {
248 let _ = new_behaviour();
249 }
250}