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