Skip to main content

hopr_lib/builder/
chain_wiring.rs

1use std::sync::Arc;
2
3use futures::{SinkExt, StreamExt, pin_mut};
4use hopr_api::{
5    HoprBalance, Multiaddr, OffchainPublicKey, PeerId,
6    chain::{ChainKeyOperations, WinningProbability},
7    graph::{EdgeBalanceUpdate, MeasurableEdge, NetworkGraphUpdate},
8    types::{
9        chain::chain_events::ChainEvent,
10        internal::prelude::ChannelStatus,
11        primitive::{
12            prelude::{Address, UnitaryFloatOps},
13            traits::KeyIdMapping,
14        },
15    },
16};
17use hopr_transport::{NeighborTelemetry, PathTelemetry, SurbStore};
18use parking_lot::RwLock;
19use tracing::Instrument;
20
21#[cfg(all(feature = "telemetry", not(test)))]
22lazy_static::lazy_static! {
23    static ref METRIC_CHANNELS_COUNT: hopr_api::types::telemetry::MultiGauge = hopr_api::types::telemetry::MultiGauge::new(
24        "hopr_channels_count",
25        "Number of open channels of the node per direction",
26        &["direction"]
27    ).unwrap();
28}
29
30/// Processes chain events and records them as graph updates.
31///
32/// Drives the chain-to-graph edge of the topology pipeline: converts incoming on-chain
33/// `ChainEvent`s into [`NetworkGraphUpdate`] calls so the routing graph stays current.
34/// When `peer_discovery_tx` is `Some`, each [`ChainEvent::Announcement`] is also forwarded
35/// to the p2p network layer so it can initiate connections to newly discovered peers.
36///
37/// Status changes on *our own outgoing* channels are also reported to `surb_store`, so SURBs whose
38/// return path starts at a relayer we can no longer pay are shed rather than replied with.
39///
40/// Runs until the supplied `events` stream terminates.
41#[allow(clippy::too_many_arguments)]
42pub(super) async fn process_chain_events<C, G, S>(
43    chain_reader: C,
44    graph_updater: G,
45    surb_store: S,
46    events: impl futures::Stream<Item = ChainEvent> + Send + 'static,
47    own_chain_addr: Address,
48    own_packet_key: OffchainPublicKey,
49    ticket_price: Arc<RwLock<HoprBalance>>,
50    win_probability: Arc<RwLock<WinningProbability>>,
51    mut peer_discovery_tx: Option<hopr_utils::network_types::crossfire_sink::CrossfireSink<(PeerId, Vec<Multiaddr>)>>,
52) where
53    C: ChainKeyOperations + Clone + Send + Sync + 'static,
54    G: NetworkGraphUpdate + Send + Sync + 'static,
55    S: SurbStore + Send + Sync + 'static,
56{
57    pin_mut!(events);
58
59    // Seed the face value before the first event. Startup replays on-chain state as channel events,
60    // and a pricing event only follows if the price actually changes — so without this the graph
61    // would record balances while `ticket_face_value()` was still `None`, and selection would price
62    // every path off the fallback until the next price change happened to arrive.
63    push_ticket_face_value(&graph_updater, &ticket_price, &win_probability);
64
65    // Tracks the node's currently-open channel IDs per direction so `hopr_channels_count`
66    // can be maintained incrementally from channel events. The initial on-chain state is
67    // replayed as `ChannelOpened` events by the state-sync subscription at startup, so the
68    // sets are seeded correctly without an explicit query. Set operations are idempotent,
69    // making this robust to duplicated events.
70    #[cfg(all(feature = "telemetry", not(test)))]
71    let (mut incoming_open, mut outgoing_open) = (std::collections::HashSet::new(), std::collections::HashSet::new());
72
73    while let Some(chain_event) = events.next().await {
74        tracing::debug!(event = %chain_event, "processing chain event");
75        match chain_event {
76            ChainEvent::Announcement(account) => {
77                tracing::debug!(
78                    account = %account.public_key,
79                    "recording graph node for announced account"
80                );
81                graph_updater.record_node(account.public_key);
82                if let Some(ref mut tx) = peer_discovery_tx {
83                    let peer_id: PeerId = account.public_key.into();
84                    let multiaddrs = account.get_multiaddrs();
85                    let span = tracing::info_span!(
86                        "peer_announcement",
87                        peer = %peer_id,
88                        multiaddresses = ?multiaddrs,
89                    );
90                    if let Err(e) = tx.send((peer_id, multiaddrs.to_vec())).instrument(span.clone()).await {
91                        tracing::error!(parent: &span, %e, "peer-discovery channel closed; announcement dropped");
92                    }
93                }
94            }
95            ChainEvent::ChannelOpened(channel)
96            | ChainEvent::ChannelClosureInitiated(channel)
97            | ChainEvent::ChannelClosed(channel)
98            | ChainEvent::ChannelBalanceIncreased(channel, _)
99            | ChainEvent::ChannelBalanceDecreased(channel, _) => {
100                let src_addr = channel.source;
101                let dst_addr = channel.destination;
102
103                #[cfg(all(feature = "telemetry", not(test)))]
104                {
105                    let channel_id = *channel.get_id();
106                    let is_open = matches!(channel.status, ChannelStatus::Open);
107                    if src_addr == own_chain_addr {
108                        if is_open {
109                            outgoing_open.insert(channel_id);
110                        } else {
111                            outgoing_open.remove(&channel_id);
112                        }
113                        METRIC_CHANNELS_COUNT.set(&["outgoing"], outgoing_open.len() as f64);
114                    } else if dst_addr == own_chain_addr {
115                        if is_open {
116                            incoming_open.insert(channel_id);
117                        } else {
118                            incoming_open.remove(&channel_id);
119                        }
120                        METRIC_CHANNELS_COUNT.set(&["incoming"], incoming_open.len() as f64);
121                    }
122                }
123
124                let reader = chain_reader.clone();
125                let keys = hopr_utils::runtime::prelude::spawn_blocking(move || {
126                    let resolve = |addr: Address| {
127                        if addr == own_chain_addr {
128                            return Ok(Some(own_packet_key));
129                        }
130                        reader.chain_key_to_packet_key(&addr).map_err(anyhow::Error::from)
131                    };
132                    resolve(src_addr).and_then(|src| resolve(dst_addr).map(|dst| src.zip(dst)))
133                })
134                .await
135                .map_err(anyhow::Error::from)
136                .flatten();
137
138                match keys {
139                    Ok(Some((from, to))) => {
140                        // Emit the raw balance, not a ticket count. Dividing by the ticket face
141                        // value here would bake a live ticket price and winning probability into
142                        // every edge, so each price change would stale the whole graph at once.
143                        // Consumers apply the face value when they evaluate a path instead.
144                        let balance = match channel.status {
145                            ChannelStatus::Closed | ChannelStatus::PendingToClose(_) => None,
146                            _ => Some(channel.balance.amount()),
147                        };
148
149                        tracing::debug!(
150                            %channel, ?balance,
151                            "recording graph edge for channel balance"
152                        );
153                        graph_updater.record_edge(MeasurableEdge::<NeighborTelemetry, PathTelemetry>::Balance(
154                            Box::new(EdgeBalanceUpdate {
155                                balance,
156                                src: from,
157                                dest: to,
158                            }),
159                        ));
160
161                        // Only our own outgoing channels matter for SURBs: `to` is then the peer
162                        // we would have to pay as the first relayer of a stored SURB's return path.
163                        if src_addr == own_chain_addr {
164                            match chain_reader.key_id_mapper_ref().map_key_to_id(&to) {
165                                // Matched exhaustively on purpose: a wildcard would silently
166                                // revalidate any future non-payable status, handing out SURBs that
167                                // cannot be paid for, with no compiler warning.
168                                Some(relayer) => match channel.status {
169                                    ChannelStatus::Closed | ChannelStatus::PendingToClose(_) => {
170                                        surb_store.invalidate_relayer(&relayer)
171                                    }
172                                    ChannelStatus::Open => surb_store.revalidate_relayer(&relayer),
173                                },
174                                None => tracing::warn!(
175                                    %channel,
176                                    "no key id for own channel counterparty; SURB validity not updated"
177                                ),
178                            }
179                        }
180                    }
181                    Ok(None) => {
182                        tracing::error!(
183                            %channel,
184                            "could not find packet keys for channel endpoints"
185                        );
186                    }
187                    Err(error) => {
188                        tracing::error!(
189                            %error, %channel,
190                            "failed to convert chain keys to packet keys"
191                        );
192                    }
193                }
194            }
195            ChainEvent::WinningProbabilityIncreased(prob) | ChainEvent::WinningProbabilityDecreased(prob) => {
196                tracing::debug!(%prob, "recording winning probability change");
197                *win_probability.write() = prob;
198                push_ticket_face_value(&graph_updater, &ticket_price, &win_probability);
199            }
200            ChainEvent::TicketPriceChanged(price) => {
201                tracing::debug!(%price, "recording ticket price change");
202                *ticket_price.write() = price;
203                push_ticket_face_value(&graph_updater, &ticket_price, &win_probability);
204            }
205            _ => {}
206        }
207    }
208}
209
210/// Recomputes the single-hop ticket face value and pushes it into the graph.
211///
212/// `price / win_probability` is the amount that makes the expected payout per packet equal the
213/// ticket price, i.e. one ticket's face value. Edges store only a balance, so this is the one place
214/// pricing enters path selection — and a change costs a single write rather than an edge sweep.
215fn push_ticket_face_value<G>(
216    graph: &G,
217    ticket_price: &Arc<RwLock<HoprBalance>>,
218    win_probability: &Arc<RwLock<WinningProbability>>,
219) where
220    G: NetworkGraphUpdate,
221{
222    // Both guards are released before the division: holding one while taking the other is a lock
223    // order this code would then be obliged to honour everywhere else.
224    let price = *ticket_price.read();
225    let probability = win_probability.read().as_f64();
226
227    // The `f64` carries the probability, it does not convert it. `as_f64` packs the 56-bit encoded
228    // value into the mantissa of a number in [1, 2), and `div_f64` strips it straight back out and
229    // divides in `U256` — the balance never becomes a float, and a whole probability short-circuits
230    // to the identity. A hand-rolled integer division here would gain nothing but the guards.
231    match price.div_f64(probability) {
232        Ok(face_value) => {
233            let face_value = face_value.amount();
234            tracing::debug!(%face_value, "recording ticket face value change");
235            graph.set_ticket_face_value(face_value);
236        }
237        Err(error) => tracing::error!(%error, "failed to derive the ticket face value; leaving the previous one"),
238    }
239}
240
241#[cfg(test)]
242mod tests {
243    use std::{
244        collections::HashMap,
245        sync::{Arc, Mutex},
246        time::SystemTime,
247    };
248
249    use anyhow::Context as _;
250    use hopr_api::{
251        HoprBalance, OffchainPublicKey,
252        chain::{ChainKeyOperations, HoprKeyIdent, KeyIdMapping, WinningProbability},
253        graph::{EdgeBalanceUpdate, MeasurableEdge, MeasurablePath, MeasurablePeer, NetworkGraphUpdate},
254        types::{
255            chain::chain_events::ChainEvent,
256            crypto::prelude::{ChainKeypair, Keypair, OffchainKeypair},
257            internal::prelude::{AccountEntry, AccountType, ChannelEntry, ChannelStatus},
258            primitive::prelude::Address,
259        },
260    };
261    use hopr_transport::MemorySurbStore;
262    use parking_lot::RwLock;
263
264    use super::process_chain_events;
265
266    // ---------------------------------------------------------------------------
267    // Stubs
268    // ---------------------------------------------------------------------------
269
270    #[derive(Debug, Clone, thiserror::Error)]
271    #[error("stub: {0}")]
272    struct StubError(String);
273
274    /// Maps only the keys it was given; empty by default, in which case it maps nothing.
275    #[derive(Debug, Clone, Default)]
276    struct StubMapper(HashMap<OffchainPublicKey, HoprKeyIdent>);
277
278    impl KeyIdMapping<HoprKeyIdent, OffchainPublicKey> for StubMapper {
279        fn map_key_to_id(&self, key: &OffchainPublicKey) -> Option<HoprKeyIdent> {
280            self.0.get(key).copied()
281        }
282
283        fn map_id_to_public(&self, id: &HoprKeyIdent) -> Option<OffchainPublicKey> {
284            self.0.iter().find_map(|(k, v)| (v == id).then_some(*k))
285        }
286    }
287
288    #[derive(Debug, Clone)]
289    struct StubChainKeys {
290        keys: HashMap<Address, OffchainPublicKey>,
291        mapper: StubMapper,
292    }
293
294    impl StubChainKeys {
295        fn new(pairs: impl IntoIterator<Item = (Address, OffchainPublicKey)>) -> Self {
296            Self {
297                keys: pairs.into_iter().collect(),
298                mapper: StubMapper::default(),
299            }
300        }
301
302        fn with_key_ids(mut self, ids: impl IntoIterator<Item = (OffchainPublicKey, HoprKeyIdent)>) -> Self {
303            self.mapper = StubMapper(ids.into_iter().collect());
304            self
305        }
306    }
307
308    impl ChainKeyOperations for StubChainKeys {
309        type Error = StubError;
310        type Mapper = StubMapper;
311
312        fn chain_key_to_packet_key(&self, chain: &Address) -> Result<Option<OffchainPublicKey>, Self::Error> {
313            Ok(self.keys.get(chain).copied())
314        }
315
316        fn packet_key_to_chain_key(&self, packet: &OffchainPublicKey) -> Result<Option<Address>, Self::Error> {
317            Ok(self.keys.iter().find_map(|(a, k)| (k == packet).then_some(*a)))
318        }
319
320        fn key_id_mapper_ref(&self) -> &Self::Mapper {
321            &self.mapper
322        }
323    }
324
325    #[derive(Debug, Clone)]
326    enum GraphCall {
327        Node(OffchainPublicKey),
328        Edge(Box<EdgeBalanceUpdate>),
329        FaceValue(hopr_api::graph::traits::Balance),
330    }
331
332    #[derive(Debug, Clone, Default)]
333    struct RecordingGraph {
334        calls: Arc<Mutex<Vec<GraphCall>>>,
335    }
336
337    impl RecordingGraph {
338        fn recorded(&self) -> Vec<GraphCall> {
339            self.calls.lock().unwrap().clone()
340        }
341
342        fn edges(&self) -> Vec<EdgeBalanceUpdate> {
343            self.recorded()
344                .into_iter()
345                .filter_map(|c| if let GraphCall::Edge(e) = c { Some(*e) } else { None })
346                .collect()
347        }
348
349        fn face_values(&self) -> Vec<hopr_api::graph::traits::Balance> {
350            self.recorded()
351                .into_iter()
352                .filter_map(|c| if let GraphCall::FaceValue(v) = c { Some(v) } else { None })
353                .collect()
354        }
355
356        fn nodes(&self) -> Vec<OffchainPublicKey> {
357            self.recorded()
358                .into_iter()
359                .filter_map(|c| if let GraphCall::Node(n) = c { Some(n) } else { None })
360                .collect()
361        }
362    }
363
364    impl NetworkGraphUpdate for RecordingGraph {
365        fn set_ticket_face_value(&self, ticket_face_value: hopr_api::graph::traits::Balance) {
366            self.calls.lock().unwrap().push(GraphCall::FaceValue(ticket_face_value));
367        }
368
369        fn record_edge<N, P>(&self, update: MeasurableEdge<N, P>)
370        where
371            N: MeasurablePeer + Clone + Send + Sync + 'static,
372            P: MeasurablePath + Clone + Send + Sync + 'static,
373        {
374            if let MeasurableEdge::Balance(balance) = update {
375                self.calls.lock().unwrap().push(GraphCall::Edge(balance));
376            }
377        }
378
379        fn record_node<N>(&self, update: N)
380        where
381            N: hopr_api::graph::MeasurableNode + Clone + Send + Sync + 'static,
382        {
383            self.calls.lock().unwrap().push(GraphCall::Node(update.into()));
384        }
385    }
386
387    // ---------------------------------------------------------------------------
388    // Helpers
389    // ---------------------------------------------------------------------------
390
391    fn make_keypairs() -> (OffchainKeypair, ChainKeypair) {
392        (OffchainKeypair::random(), ChainKeypair::random())
393    }
394
395    fn channel(src: Address, dst: Address, balance: u128, status: ChannelStatus) -> ChannelEntry {
396        ChannelEntry::builder()
397            .source(src)
398            .destination(dst)
399            .amount(balance)
400            .status(status)
401            .build()
402            .expect("valid channel")
403    }
404
405    fn account(key: OffchainPublicKey, addr: Address) -> AccountEntry {
406        use hopr_api::types::primitive::prelude::KeyIdent;
407        AccountEntry {
408            public_key: key,
409            chain_addr: addr,
410            entry_type: AccountType::NotAnnounced,
411            safe_address: None,
412            key_id: KeyIdent::default(),
413        }
414    }
415
416    async fn run(
417        events: Vec<ChainEvent>,
418        chain: StubChainKeys,
419        graph: RecordingGraph,
420        own_chain_addr: Address,
421        own_packet_key: OffchainPublicKey,
422        ticket_price: HoprBalance,
423        win_probability: WinningProbability,
424    ) {
425        let _ = run_with_peer_discovery(
426            events,
427            chain,
428            graph,
429            own_chain_addr,
430            own_packet_key,
431            ticket_price,
432            win_probability,
433        )
434        .await;
435    }
436
437    async fn run_with_peer_discovery(
438        events: Vec<ChainEvent>,
439        chain: StubChainKeys,
440        graph: RecordingGraph,
441        own_chain_addr: Address,
442        own_packet_key: OffchainPublicKey,
443        ticket_price: HoprBalance,
444        win_probability: WinningProbability,
445    ) -> Vec<(hopr_api::PeerId, Vec<hopr_api::Multiaddr>)> {
446        use futures::StreamExt;
447        let (tx, rx) = hopr_utils::network_types::crossfire_sink::bounded_sink_channel(64);
448        process_chain_events(
449            chain,
450            graph,
451            MemorySurbStore::default(),
452            futures::stream::iter(events),
453            own_chain_addr,
454            own_packet_key,
455            Arc::new(RwLock::new(ticket_price)),
456            Arc::new(RwLock::new(win_probability)),
457            Some(tx),
458        )
459        .await;
460        rx.collect().await
461    }
462
463    /// Runs the event loop against a caller-supplied SURB store, so the test can inspect it after.
464    async fn run_with_surb_store(
465        events: Vec<ChainEvent>,
466        chain: StubChainKeys,
467        surb_store: MemorySurbStore,
468        own_chain_addr: Address,
469        own_packet_key: OffchainPublicKey,
470    ) {
471        process_chain_events(
472            chain,
473            RecordingGraph::default(),
474            surb_store,
475            futures::stream::iter(events),
476            own_chain_addr,
477            own_packet_key,
478            Arc::new(RwLock::new(HoprBalance::from(1u32))),
479            Arc::new(RwLock::new(WinningProbability::ALWAYS)),
480            None,
481        )
482        .await;
483    }
484
485    // ---------------------------------------------------------------------------
486    // Tests
487    // ---------------------------------------------------------------------------
488
489    #[tokio::test]
490    async fn announcement_records_node() {
491        let (offchain, chain) = make_keypairs();
492        let addr = chain.public().to_address();
493        let graph = RecordingGraph::default();
494
495        run(
496            vec![ChainEvent::Announcement(account(*offchain.public(), addr))],
497            StubChainKeys::new([]),
498            graph.clone(),
499            addr,
500            *offchain.public(),
501            HoprBalance::from(10u64),
502            WinningProbability::ALWAYS,
503        )
504        .await;
505
506        assert_eq!(graph.nodes(), vec![*offchain.public()]);
507        assert!(graph.edges().is_empty());
508    }
509
510    #[tokio::test]
511    async fn announcement_should_forward_to_peer_discovery_when_tx_is_set() -> anyhow::Result<()> {
512        use std::str::FromStr;
513
514        use hopr_api::types::internal::prelude::AccountType;
515
516        let (offchain, chain) = make_keypairs();
517        let addr = chain.public().to_address();
518        let multiaddr = hopr_api::Multiaddr::from_str("/ip4/1.2.3.4/tcp/9000").context("parse multiaddr")?;
519        let entry = AccountEntry {
520            entry_type: AccountType::Announced(vec![multiaddr.clone()]),
521            ..account(*offchain.public(), addr)
522        };
523        let graph = RecordingGraph::default();
524
525        let received = run_with_peer_discovery(
526            vec![ChainEvent::Announcement(entry)],
527            StubChainKeys::new([]),
528            graph.clone(),
529            addr,
530            *offchain.public(),
531            HoprBalance::from(10u64),
532            WinningProbability::ALWAYS,
533        )
534        .await;
535
536        assert_eq!(received.len(), 1, "expected exactly one peer-discovery event");
537        let (peer_id, addrs) = &received[0];
538        assert_eq!(
539            *peer_id,
540            hopr_api::PeerId::from(*offchain.public()),
541            "peer id must match the announced account's public key"
542        );
543        assert_eq!(addrs, &vec![multiaddr], "multiaddrs must be forwarded unchanged");
544        assert_eq!(
545            graph.nodes(),
546            vec![*offchain.public()],
547            "graph must also record the node"
548        );
549        Ok(())
550    }
551
552    #[tokio::test]
553    async fn channel_opened_records_capacity() {
554        let (src_offchain, src_chain) = make_keypairs();
555        let (dst_offchain, dst_chain) = make_keypairs();
556        let src_addr = src_chain.public().to_address();
557        let dst_addr = dst_chain.public().to_address();
558
559        let graph = RecordingGraph::default();
560        let stub = StubChainKeys::new([(src_addr, *src_offchain.public()), (dst_addr, *dst_offchain.public())]);
561
562        // The balance is emitted as-is; pricing no longer enters the per-edge value.
563        run(
564            vec![ChainEvent::ChannelOpened(channel(
565                src_addr,
566                dst_addr,
567                100,
568                ChannelStatus::Open,
569            ))],
570            stub,
571            graph.clone(),
572            src_addr,
573            *src_offchain.public(),
574            HoprBalance::from(10u64),
575            WinningProbability::ALWAYS,
576        )
577        .await;
578
579        let edges = graph.edges();
580        assert_eq!(edges.len(), 1);
581        assert_eq!(edges[0].balance, Some(hopr_api::graph::traits::Balance::from(100u64)));
582        assert_eq!(edges[0].src, *src_offchain.public());
583        assert_eq!(edges[0].dest, *dst_offchain.public());
584    }
585
586    #[tokio::test]
587    async fn channel_balance_decreased_records_updated_capacity() {
588        let (src_offchain, src_chain) = make_keypairs();
589        let (dst_offchain, dst_chain) = make_keypairs();
590        let src_addr = src_chain.public().to_address();
591        let dst_addr = dst_chain.public().to_address();
592
593        let graph = RecordingGraph::default();
594        let stub = StubChainKeys::new([(src_addr, *src_offchain.public()), (dst_addr, *dst_offchain.public())]);
595
596        // The decreased balance is emitted as-is.
597        run(
598            vec![ChainEvent::ChannelBalanceDecreased(
599                channel(src_addr, dst_addr, 50, ChannelStatus::Open),
600                HoprBalance::from(50u64),
601            )],
602            stub,
603            graph.clone(),
604            src_addr,
605            *src_offchain.public(),
606            HoprBalance::from(10u64),
607            WinningProbability::ALWAYS,
608        )
609        .await;
610
611        let edges = graph.edges();
612        assert_eq!(edges.len(), 1);
613        assert_eq!(edges[0].balance, Some(hopr_api::graph::traits::Balance::from(50u64)));
614    }
615
616    #[tokio::test]
617    async fn channel_closed_records_capacity_none() {
618        let (src_offchain, src_chain) = make_keypairs();
619        let (dst_offchain, dst_chain) = make_keypairs();
620        let src_addr = src_chain.public().to_address();
621        let dst_addr = dst_chain.public().to_address();
622
623        let graph = RecordingGraph::default();
624        let stub = StubChainKeys::new([(src_addr, *src_offchain.public()), (dst_addr, *dst_offchain.public())]);
625
626        run(
627            vec![ChainEvent::ChannelClosed(channel(
628                src_addr,
629                dst_addr,
630                0,
631                ChannelStatus::Closed,
632            ))],
633            stub,
634            graph.clone(),
635            src_addr,
636            *src_offchain.public(),
637            HoprBalance::from(10u64),
638            WinningProbability::ALWAYS,
639        )
640        .await;
641
642        let edges = graph.edges();
643        assert_eq!(edges.len(), 1);
644        assert_eq!(edges[0].balance, None);
645    }
646
647    /// Regression test: before the fix, ChannelClosureInitiated was a no-op and the
648    /// graph kept the prior `Some(N)` capacity for the channel lifetime of the close
649    /// timeout window, allowing routing to keep picking the dying edge.
650    #[tokio::test]
651    async fn channel_closure_initiated_records_capacity_none() {
652        let (src_offchain, src_chain) = make_keypairs();
653        let (dst_offchain, dst_chain) = make_keypairs();
654        let src_addr = src_chain.public().to_address();
655        let dst_addr = dst_chain.public().to_address();
656
657        let graph = RecordingGraph::default();
658        let stub = StubChainKeys::new([(src_addr, *src_offchain.public()), (dst_addr, *dst_offchain.public())]);
659
660        run(
661            vec![ChainEvent::ChannelClosureInitiated(channel(
662                src_addr,
663                dst_addr,
664                100,
665                ChannelStatus::PendingToClose(SystemTime::now()),
666            ))],
667            stub,
668            graph.clone(),
669            src_addr,
670            *src_offchain.public(),
671            HoprBalance::from(10u64),
672            WinningProbability::ALWAYS,
673        )
674        .await;
675
676        let edges = graph.edges();
677        assert_eq!(edges.len(), 1, "closure-initiated must emit a graph update");
678        assert_eq!(
679            edges[0].balance, None,
680            "closure-initiated must clear the balance so routing stops using this edge"
681        );
682    }
683
684    #[tokio::test]
685    async fn ticket_price_change_pushes_a_new_face_value() {
686        let (src_offchain, src_chain) = make_keypairs();
687        let (dst_offchain, dst_chain) = make_keypairs();
688        let src_addr = src_chain.public().to_address();
689        let dst_addr = dst_chain.public().to_address();
690
691        let graph = RecordingGraph::default();
692        let stub = StubChainKeys::new([(src_addr, *src_offchain.public()), (dst_addr, *dst_offchain.public())]);
693
694        // A price change recomputes the face value: 20 / 1.0 = 20. The balance is untouched.
695        run(
696            vec![
697                ChainEvent::TicketPriceChanged(HoprBalance::from(20u64)),
698                ChainEvent::ChannelOpened(channel(src_addr, dst_addr, 200, ChannelStatus::Open)),
699            ],
700            stub,
701            graph.clone(),
702            src_addr,
703            *src_offchain.public(),
704            HoprBalance::from(10u64),
705            WinningProbability::ALWAYS,
706        )
707        .await;
708
709        assert_eq!(
710            graph.face_values(),
711            vec![
712                hopr_api::graph::traits::Balance::from(10u64),
713                hopr_api::graph::traits::Balance::from(20u64),
714            ],
715            "the seeded face value, then the one recomputed from the price change"
716        );
717
718        let edges = graph.edges();
719        assert_eq!(edges.len(), 1);
720        assert_eq!(
721            edges[0].balance,
722            Some(hopr_api::graph::traits::Balance::from(200u64)),
723            "the emitted balance must not depend on the price"
724        );
725    }
726
727    #[tokio::test]
728    async fn win_probability_change_pushes_a_new_face_value() -> anyhow::Result<()> {
729        let (src_offchain, src_chain) = make_keypairs();
730        let (dst_offchain, dst_chain) = make_keypairs();
731        let src_addr = src_chain.public().to_address();
732        let dst_addr = dst_chain.public().to_address();
733
734        let graph = RecordingGraph::default();
735        let stub = StubChainKeys::new([(src_addr, *src_offchain.public()), (dst_addr, *dst_offchain.public())]);
736
737        // A winning-probability change recomputes the face value: 10 / 0.5 = 20.
738        let new_prob = WinningProbability::try_from_f64(0.5).context("0.5 is a valid winning probability")?;
739        run(
740            vec![
741                ChainEvent::WinningProbabilityDecreased(new_prob),
742                ChainEvent::ChannelOpened(channel(src_addr, dst_addr, 100, ChannelStatus::Open)),
743            ],
744            stub,
745            graph.clone(),
746            src_addr,
747            *src_offchain.public(),
748            HoprBalance::from(10u64),
749            WinningProbability::ALWAYS,
750        )
751        .await;
752
753        assert_eq!(
754            graph.face_values(),
755            vec![
756                hopr_api::graph::traits::Balance::from(10u64),
757                hopr_api::graph::traits::Balance::from(20u64),
758            ],
759            "the seeded face value, then the one recomputed from the probability change"
760        );
761
762        let edges = graph.edges();
763        assert_eq!(edges.len(), 1);
764        assert_eq!(
765            edges[0].balance,
766            Some(hopr_api::graph::traits::Balance::from(100u64)),
767            "the emitted balance must not depend on the winning probability"
768        );
769        Ok(())
770    }
771
772    /// Startup replays on-chain state as channel events, and a pricing event follows only if the
773    /// price actually changed. Without a seed the graph would then hold balances while
774    /// `ticket_face_value()` was still `None`, and selection would price every path off the
775    /// fallback for as long as the price happened to stay put.
776    #[tokio::test]
777    async fn a_face_value_is_seeded_before_any_pricing_event() {
778        let (src_offchain, src_chain) = make_keypairs();
779        let (dst_offchain, dst_chain) = make_keypairs();
780        let src_addr = src_chain.public().to_address();
781        let dst_addr = dst_chain.public().to_address();
782
783        let graph = RecordingGraph::default();
784        let stub = StubChainKeys::new([(src_addr, *src_offchain.public()), (dst_addr, *dst_offchain.public())]);
785
786        // Only a channel event: exactly the startup replay, with no price or probability change.
787        run(
788            vec![ChainEvent::ChannelOpened(channel(
789                src_addr,
790                dst_addr,
791                200,
792                ChannelStatus::Open,
793            ))],
794            stub,
795            graph.clone(),
796            src_addr,
797            *src_offchain.public(),
798            HoprBalance::from(10u64),
799            WinningProbability::ALWAYS,
800        )
801        .await;
802
803        assert_eq!(
804            graph.face_values(),
805            vec![hopr_api::graph::traits::Balance::from(10u64)],
806            "the graph must know the price before it records a balance it will be compared against"
807        );
808    }
809
810    #[tokio::test]
811    async fn unknown_chain_key_produces_no_graph_update() {
812        let (src_offchain, src_chain) = make_keypairs();
813        let (_, dst_chain) = make_keypairs();
814        let src_addr = src_chain.public().to_address();
815        let dst_addr = dst_chain.public().to_address();
816
817        let graph = RecordingGraph::default();
818        // dst is NOT in the stub map → chain_key_to_packet_key returns None for dst
819        let stub = StubChainKeys::new([(src_addr, *src_offchain.public())]);
820
821        run(
822            vec![ChainEvent::ChannelOpened(channel(
823                src_addr,
824                dst_addr,
825                100,
826                ChannelStatus::Open,
827            ))],
828            stub,
829            graph.clone(),
830            src_addr,
831            *src_offchain.public(),
832            HoprBalance::from(10u64),
833            WinningProbability::ALWAYS,
834        )
835        .await;
836
837        assert!(graph.edges().is_empty(), "unknown key must produce no graph update");
838    }
839
840    #[tokio::test]
841    async fn self_address_resolved_via_own_packet_key() {
842        let (own_offchain, own_chain) = make_keypairs();
843        let (dst_offchain, dst_chain) = make_keypairs();
844        let own_chain_addr = own_chain.public().to_address();
845        let dst_addr = dst_chain.public().to_address();
846
847        let graph = RecordingGraph::default();
848        // own_chain_addr not in stub — must be resolved via own_packet_key
849        let stub = StubChainKeys::new([(dst_addr, *dst_offchain.public())]);
850
851        run(
852            vec![ChainEvent::ChannelOpened(channel(
853                own_chain_addr,
854                dst_addr,
855                100,
856                ChannelStatus::Open,
857            ))],
858            stub,
859            graph.clone(),
860            own_chain_addr,
861            *own_offchain.public(),
862            HoprBalance::from(10u64),
863            WinningProbability::ALWAYS,
864        )
865        .await;
866
867        let edges = graph.edges();
868        assert_eq!(edges.len(), 1);
869        assert_eq!(edges[0].src, *own_offchain.public());
870        assert_eq!(edges[0].dest, *dst_offchain.public());
871    }
872
873    #[tokio::test]
874    async fn announcement_should_handle_disconnected_peer_discovery_tx_gracefully() {
875        let (offchain, chain) = make_keypairs();
876        let addr = chain.public().to_address();
877        let (tx, rx) = hopr_utils::network_types::crossfire_sink::bounded_sink_channel(1);
878        drop(rx); // receiver dropped — send will return Err(Disconnected)
879
880        process_chain_events(
881            StubChainKeys::new([]),
882            RecordingGraph::default(),
883            MemorySurbStore::default(),
884            futures::stream::iter(vec![ChainEvent::Announcement(account(*offchain.public(), addr))]),
885            addr,
886            *offchain.public(),
887            Arc::new(RwLock::new(HoprBalance::from(10u64))),
888            Arc::new(RwLock::new(WinningProbability::ALWAYS)),
889            Some(tx),
890        )
891        .await;
892    }
893
894    // ---------------------------------------------------------------------------
895    // SURB store invalidation
896    // ---------------------------------------------------------------------------
897
898    /// Sets up `me -> peer` with `peer` mapped to key id 1, and runs the given channel events.
899    async fn run_own_channel_events(
900        statuses: impl IntoIterator<Item = ChannelStatus>,
901    ) -> (MemorySurbStore, HoprKeyIdent) {
902        let (me_offchain, me_chain) = make_keypairs();
903        let (peer_offchain, peer_chain) = make_keypairs();
904        let (me_addr, peer_addr) = (me_chain.public().to_address(), peer_chain.public().to_address());
905        let peer_id = HoprKeyIdent::from(1u32);
906
907        let stub = StubChainKeys::new([(me_addr, *me_offchain.public()), (peer_addr, *peer_offchain.public())])
908            .with_key_ids([(*peer_offchain.public(), peer_id)]);
909
910        let surb_store = MemorySurbStore::default();
911        let events = statuses
912            .into_iter()
913            .map(|status| ChainEvent::ChannelOpened(channel(me_addr, peer_addr, 100, status)))
914            .collect();
915
916        run_with_surb_store(events, stub, surb_store.clone(), me_addr, *me_offchain.public()).await;
917
918        (surb_store, peer_id)
919    }
920
921    #[tokio::test]
922    async fn closing_an_own_outgoing_channel_should_invalidate_that_relayer() {
923        let (store, peer_id) =
924            run_own_channel_events([ChannelStatus::PendingToClose(std::time::SystemTime::now())]).await;
925        assert!(store.is_relayer_invalidated(&peer_id), "PendingToClose must invalidate");
926
927        let (store, peer_id) = run_own_channel_events([ChannelStatus::Closed]).await;
928        assert!(store.is_relayer_invalidated(&peer_id), "Closed must invalidate");
929    }
930
931    #[tokio::test]
932    async fn reopening_an_own_outgoing_channel_should_revalidate_that_relayer() {
933        let (store, peer_id) = run_own_channel_events([ChannelStatus::Closed, ChannelStatus::Open]).await;
934        assert!(!store.is_relayer_invalidated(&peer_id), "re-opening must revalidate");
935    }
936
937    #[tokio::test]
938    async fn a_channel_that_is_not_ours_should_not_invalidate_anything() {
939        // Same topology, but the event is for a channel between two other parties.
940        let (me_offchain, me_chain) = make_keypairs();
941        let (a_offchain, a_chain) = make_keypairs();
942        let (b_offchain, b_chain) = make_keypairs();
943        let (me_addr, a_addr, b_addr) = (
944            me_chain.public().to_address(),
945            a_chain.public().to_address(),
946            b_chain.public().to_address(),
947        );
948        let b_id = HoprKeyIdent::from(1u32);
949
950        let stub = StubChainKeys::new([
951            (me_addr, *me_offchain.public()),
952            (a_addr, *a_offchain.public()),
953            (b_addr, *b_offchain.public()),
954        ])
955        .with_key_ids([(*b_offchain.public(), b_id)]);
956
957        let store = MemorySurbStore::default();
958        run_with_surb_store(
959            vec![ChainEvent::ChannelClosed(channel(
960                a_addr,
961                b_addr,
962                100,
963                ChannelStatus::Closed,
964            ))],
965            stub,
966            store.clone(),
967            me_addr,
968            *me_offchain.public(),
969        )
970        .await;
971
972        assert!(
973            !store.is_relayer_invalidated(&b_id),
974            "someone else's channel must not affect our SURBs"
975        );
976    }
977}