1use std::fmt::Write;
6
7use hopr_api::{
8 OffchainPublicKey,
9 graph::{
10 NetworkGraphConnectivity,
11 traits::{EdgeLinkObservable, EdgeObservableRead, EdgeProtocolObservable},
12 },
13};
14
15use crate::ChannelGraph;
16
17pub fn render_dot(graph: &ChannelGraph) -> String {
23 render_dot_with_labels(graph, |key| format!("{key}"))
24}
25
26pub fn render_dot_with_labels(graph: &ChannelGraph, label_fn: impl Fn(&OffchainPublicKey) -> String) -> String {
32 render_edges_as_dot(&graph.connected_edges(), &label_fn)
33}
34
35pub fn render_dot_reachable_with_labels(
38 graph: &ChannelGraph,
39 label_fn: impl Fn(&OffchainPublicKey) -> String,
40) -> String {
41 render_edges_as_dot(&graph.reachable_edges(), &label_fn)
42}
43
44pub fn render_edges_as_dot(
45 edges: &[(OffchainPublicKey, OffchainPublicKey, crate::Observations)],
46 label_fn: &impl Fn(&OffchainPublicKey) -> String,
47) -> String {
48 let mut out = String::from("digraph hopr {\n");
49
50 for (src, dst, obs) in edges {
51 let src_label = label_fn(src);
52 let dst_label = label_fn(dst);
53
54 let mut attrs = Vec::new();
55 let score = obs.score();
56 attrs.push(format!("score={score:.2}"));
57
58 if let Some(imm) = obs.immediate_qos()
59 && let Some(latency) = imm.average_latency()
60 {
61 attrs.push(format!("lat={}ms", latency.as_millis()));
62 }
63
64 if let Some(inter) = obs.intermediate_qos()
65 && let Some(cap) = inter.capacity()
66 {
67 attrs.push(format!("cap={cap}"));
68 }
69
70 let label = attrs.join(" ");
71 let _ = writeln!(out, " \"{src_label}\" -> \"{dst_label}\" [label=\"{label}\"];");
72 }
73
74 out.push_str("}\n");
75 out
76}
77
78#[cfg(test)]
79mod tests {
80 use std::collections::HashMap;
81
82 use hopr_api::{
83 graph::{
84 NetworkGraphWrite,
85 traits::{EdgeObservableWrite, EdgeWeightType},
86 },
87 types::crypto::prelude::{Keypair, OffchainKeypair},
88 };
89
90 use super::*;
91 use crate::ChannelGraph;
92
93 fn label_from_map<'a>(
94 addr_map: &'a HashMap<hopr_api::OffchainPublicKey, String>,
95 ) -> impl Fn(&hopr_api::OffchainPublicKey) -> String + 'a {
96 |key| addr_map.get(key).cloned().unwrap_or_else(|| key.to_string())
97 }
98
99 const SECRET_0: [u8; 32] = hex_literal::hex!("60741b83b99e36aa0c1331578156e16b8e21166d01834abb6c64b103f885734d");
100 const SECRET_1: [u8; 32] = hex_literal::hex!("71bf1f42ebbfcd89c3e197a3fd7cda79b92499e509b6fefa0fe44d02821d146a");
101 const SECRET_2: [u8; 32] = hex_literal::hex!("c24bd833704dd2abdae3933fcc9962c2ac404f84132224c474147382d4db2299");
102 const SECRET_3: [u8; 32] = hex_literal::hex!("e0bf93e9c916104da00b1850adc4608bd7e9087bbd3f805451f4556aa6b3fd6e");
103
104 fn pubkey(secret: &[u8; 32]) -> hopr_api::OffchainPublicKey {
105 *OffchainKeypair::from_secret(secret).expect("valid secret").public()
106 }
107
108 #[test]
109 fn empty_graph_should_render_empty_digraph() {
110 let me = pubkey(&SECRET_0);
111 let graph = ChannelGraph::new(me);
112 let dot = render_dot(&graph);
113 assert_eq!(dot, "digraph hopr {\n}\n");
114 }
115
116 #[test]
117 fn isolated_nodes_should_be_excluded_from_dot() {
118 let me = pubkey(&SECRET_0);
119 let graph = ChannelGraph::new(me);
120 graph.add_node(pubkey(&SECRET_1));
121 let dot = render_dot(&graph);
123 assert_eq!(dot, "digraph hopr {\n}\n");
124 }
125
126 #[test]
127 fn diamond_topology_should_render_four_edges() -> anyhow::Result<()> {
128 let me = pubkey(&SECRET_0);
129 let a = pubkey(&SECRET_1);
130 let b = pubkey(&SECRET_2);
131 let dest = pubkey(&SECRET_3);
132 let graph = ChannelGraph::new(me);
133 for n in [a, b, dest] {
134 graph.add_node(n);
135 }
136 graph.add_edge(&me, &a)?;
137 graph.add_edge(&me, &b)?;
138 graph.add_edge(&a, &dest)?;
139 graph.add_edge(&b, &dest)?;
140
141 let dot = render_dot(&graph);
142 assert_eq!(dot.matches("->").count(), 4);
144 assert!(dot.starts_with("digraph hopr {"));
145 assert!(dot.ends_with("}\n"));
146 Ok(())
147 }
148
149 #[test]
150 fn observations_should_appear_as_edge_labels() {
151 let me = pubkey(&SECRET_0);
152 let peer = pubkey(&SECRET_1);
153 let graph = ChannelGraph::new(me);
154 graph.add_node(peer);
155 graph.upsert_edge(&me, &peer, |obs| {
156 obs.record(EdgeWeightType::Connected(true));
157 obs.record(EdgeWeightType::Immediate(Ok(std::time::Duration::from_millis(50))));
158 obs.record(EdgeWeightType::Capacity(Some(1000)));
159 });
160
161 let dot = render_dot(&graph);
162 assert!(dot.contains("lat=50ms"), "should contain latency: {dot}");
163 assert!(dot.contains("cap=1000"), "should contain capacity: {dot}");
164 assert!(dot.contains("score="), "should contain score: {dot}");
165 }
166
167 #[test]
168 fn custom_labels_should_replace_offchain_keys() -> anyhow::Result<()> {
169 let me = pubkey(&SECRET_0);
170 let peer = pubkey(&SECRET_1);
171 let graph = ChannelGraph::new(me);
172 graph.add_node(peer);
173 graph.add_edge(&me, &peer)?;
174
175 let mut addr_map: HashMap<hopr_api::OffchainPublicKey, String> = HashMap::new();
176 addr_map.insert(me, "0xaaaa000000000000000000000000000000000001".into());
177 addr_map.insert(peer, "0xbbbb000000000000000000000000000000000002".into());
178
179 let dot = render_dot_with_labels(&graph, label_from_map(&addr_map));
180
181 assert!(
182 dot.contains("0xaaaa000000000000000000000000000000000001"),
183 "source should use onchain address: {dot}"
184 );
185 assert!(
186 dot.contains("0xbbbb000000000000000000000000000000000002"),
187 "destination should use onchain address: {dot}"
188 );
189 assert!(
191 !dot.contains(&format!("{me}")),
192 "offchain key for 'me' should be replaced: {dot}"
193 );
194 assert!(
195 !dot.contains(&format!("{peer}")),
196 "offchain key for 'peer' should be replaced: {dot}"
197 );
198 Ok(())
199 }
200
201 #[test]
202 fn custom_labels_should_fall_back_to_offchain_key_when_unmapped() -> anyhow::Result<()> {
203 let me = pubkey(&SECRET_0);
204 let peer = pubkey(&SECRET_1);
205 let graph = ChannelGraph::new(me);
206 graph.add_node(peer);
207 graph.add_edge(&me, &peer)?;
208
209 let mut addr_map: HashMap<hopr_api::OffchainPublicKey, String> = HashMap::new();
210 addr_map.insert(me, "0xcccc000000000000000000000000000000000003".into());
211
212 let dot = render_dot_with_labels(&graph, label_from_map(&addr_map));
213
214 assert!(
215 dot.contains("0xcccc000000000000000000000000000000000003"),
216 "mapped node should use onchain address: {dot}"
217 );
218 assert!(
219 dot.contains(&format!("{peer}")),
220 "unmapped node should fall back to offchain key: {dot}"
221 );
222 Ok(())
223 }
224
225 #[test]
226 fn custom_labels_should_preserve_edge_attributes() {
227 let me = pubkey(&SECRET_0);
228 let peer = pubkey(&SECRET_1);
229 let graph = ChannelGraph::new(me);
230 graph.add_node(peer);
231 graph.upsert_edge(&me, &peer, |obs| {
232 obs.record(EdgeWeightType::Connected(true));
233 obs.record(EdgeWeightType::Immediate(Ok(std::time::Duration::from_millis(120))));
234 obs.record(EdgeWeightType::Capacity(Some(500)));
235 });
236
237 let mut addr_map: HashMap<hopr_api::OffchainPublicKey, String> = HashMap::new();
238 addr_map.insert(me, "0x1111111111111111111111111111111111111111".into());
239 addr_map.insert(peer, "0x2222222222222222222222222222222222222222".into());
240
241 let dot = render_dot_with_labels(&graph, label_from_map(&addr_map));
242
243 assert!(dot.contains("lat=120ms"), "latency should be preserved: {dot}");
244 assert!(dot.contains("cap=500"), "capacity should be preserved: {dot}");
245 assert!(dot.contains("score="), "score should be preserved: {dot}");
246 assert!(
247 dot.contains(
248 "\"0x1111111111111111111111111111111111111111\" -> \"0x2222222222222222222222222222222222222222\""
249 ),
250 "edge should use mapped addresses: {dot}"
251 );
252 }
253
254 #[test]
255 fn render_dot_should_be_unchanged_when_identity_label() -> anyhow::Result<()> {
256 let me = pubkey(&SECRET_0);
257 let peer = pubkey(&SECRET_1);
258 let graph = ChannelGraph::new(me);
259 graph.add_node(peer);
260 graph.add_edge(&me, &peer)?;
261
262 let dot_original = render_dot(&graph);
263 let dot_identity = render_dot_with_labels(&graph, |key| format!("{key}"));
264
265 assert_eq!(
266 dot_original, dot_identity,
267 "identity label_fn should produce identical output"
268 );
269 Ok(())
270 }
271
272 #[test]
273 fn reachable_should_exclude_disconnected_subgraph() -> anyhow::Result<()> {
274 let me = pubkey(&SECRET_0);
275 let a = pubkey(&SECRET_1);
276 let b = pubkey(&SECRET_2);
277 let c = pubkey(&SECRET_3);
278 let graph = ChannelGraph::new(me);
279 for n in [a, b, c] {
280 graph.add_node(n);
281 }
282
283 graph.add_edge(&me, &a)?;
285 graph.add_edge(&b, &c)?;
287
288 let all_dot = render_dot(&graph);
289 let reachable_dot = render_dot_reachable_with_labels(&graph, |key| format!("{key}"));
290
291 assert_eq!(
293 all_dot.matches("->").count(),
294 2,
295 "full graph should have 2 edges: {all_dot}"
296 );
297
298 assert_eq!(
300 reachable_dot.matches("->").count(),
301 1,
302 "reachable graph should have 1 edge: {reachable_dot}"
303 );
304 assert!(
305 reachable_dot.contains(&format!("{a}")),
306 "reachable peer 'a' should be present: {reachable_dot}"
307 );
308 assert!(
309 !reachable_dot.contains(&format!("{b}")),
310 "unreachable peer 'b' should be absent: {reachable_dot}"
311 );
312 assert!(
313 !reachable_dot.contains(&format!("{c}")),
314 "unreachable peer 'c' should be absent: {reachable_dot}"
315 );
316 Ok(())
317 }
318
319 #[test]
320 fn reachable_should_include_transitive_peers() -> anyhow::Result<()> {
321 let me = pubkey(&SECRET_0);
322 let a = pubkey(&SECRET_1);
323 let b = pubkey(&SECRET_2);
324 let graph = ChannelGraph::new(me);
325 for n in [a, b] {
326 graph.add_node(n);
327 }
328
329 graph.add_edge(&me, &a)?;
331 graph.add_edge(&a, &b)?;
332
333 let reachable_dot = render_dot_reachable_with_labels(&graph, |key| format!("{key}"));
334
335 assert_eq!(
336 reachable_dot.matches("->").count(),
337 2,
338 "both edges should be reachable: {reachable_dot}"
339 );
340 assert!(
341 reachable_dot.contains(&format!("{b}")),
342 "transitively reachable peer should be present: {reachable_dot}"
343 );
344 Ok(())
345 }
346
347 #[test]
348 fn reachable_should_be_empty_when_node_has_no_outgoing_edges() -> anyhow::Result<()> {
349 let me = pubkey(&SECRET_0);
350 let a = pubkey(&SECRET_1);
351 let b = pubkey(&SECRET_2);
352 let graph = ChannelGraph::new(me);
353 for n in [a, b] {
354 graph.add_node(n);
355 }
356
357 graph.add_edge(&a, &b)?;
359
360 let reachable_dot = render_dot_reachable_with_labels(&graph, |key| format!("{key}"));
361
362 assert_eq!(
363 reachable_dot.matches("->").count(),
364 0,
365 "no edges should be reachable from isolated 'me': {reachable_dot}"
366 );
367 Ok(())
368 }
369}