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            // A redemption decreases the channel's on-chain balance but arrives as its own event,
101            // not `ChannelBalanceDecreased`. Fold it in here so the graph edge tracks the drain;
102            // otherwise path selection keeps routing over a channel the ticket factory can no
103            // longer fund (GNO-805). The carried `ChannelEntry` holds the post-redemption balance.
104            | ChainEvent::TicketRedeemed(channel, _) => {
105                let src_addr = channel.source;
106                let dst_addr = channel.destination;
107
108                #[cfg(all(feature = "telemetry", not(test)))]
109                {
110                    let channel_id = *channel.get_id();
111                    let is_open = matches!(channel.status, ChannelStatus::Open);
112                    if src_addr == own_chain_addr {
113                        if is_open {
114                            outgoing_open.insert(channel_id);
115                        } else {
116                            outgoing_open.remove(&channel_id);
117                        }
118                        METRIC_CHANNELS_COUNT.set(&["outgoing"], outgoing_open.len() as f64);
119                    } else if dst_addr == own_chain_addr {
120                        if is_open {
121                            incoming_open.insert(channel_id);
122                        } else {
123                            incoming_open.remove(&channel_id);
124                        }
125                        METRIC_CHANNELS_COUNT.set(&["incoming"], incoming_open.len() as f64);
126                    }
127                }
128
129                let reader = chain_reader.clone();
130                let keys = hopr_utils::runtime::prelude::spawn_blocking(move || {
131                    let resolve = |addr: Address| {
132                        if addr == own_chain_addr {
133                            return Ok(Some(own_packet_key));
134                        }
135                        reader.chain_key_to_packet_key(&addr).map_err(anyhow::Error::from)
136                    };
137                    resolve(src_addr).and_then(|src| resolve(dst_addr).map(|dst| src.zip(dst)))
138                })
139                .await
140                .map_err(anyhow::Error::from)
141                .flatten();
142
143                match keys {
144                    Ok(Some((from, to))) => {
145                        // Emit the raw balance, not a ticket count. Dividing by the ticket face
146                        // value here would bake a live ticket price and winning probability into
147                        // every edge, so each price change would stale the whole graph at once.
148                        // Consumers apply the face value when they evaluate a path instead.
149                        let balance = match channel.status {
150                            ChannelStatus::Closed | ChannelStatus::PendingToClose(_) => None,
151                            _ => Some(channel.balance.amount()),
152                        };
153
154                        tracing::debug!(
155                            %channel, ?balance,
156                            "recording graph edge for channel balance"
157                        );
158                        graph_updater.record_edge(MeasurableEdge::<NeighborTelemetry, PathTelemetry>::Balance(
159                            Box::new(EdgeBalanceUpdate {
160                                balance,
161                                src: from,
162                                dest: to,
163                            }),
164                        ));
165
166                        // Only our own outgoing channels matter for SURBs: `to` is then the peer
167                        // we would have to pay as the first relayer of a stored SURB's return path.
168                        if src_addr == own_chain_addr {
169                            match chain_reader.key_id_mapper_ref().map_key_to_id(&to) {
170                                // Matched exhaustively on purpose: a wildcard would silently
171                                // revalidate any future non-payable status, handing out SURBs that
172                                // cannot be paid for, with no compiler warning.
173                                Some(relayer) => match channel.status {
174                                    ChannelStatus::Closed | ChannelStatus::PendingToClose(_) => {
175                                        surb_store.invalidate_relayer(&relayer)
176                                    }
177                                    ChannelStatus::Open => surb_store.revalidate_relayer(&relayer),
178                                },
179                                None => tracing::warn!(
180                                    %channel,
181                                    "no key id for own channel counterparty; SURB validity not updated"
182                                ),
183                            }
184                        }
185                    }
186                    Ok(None) => {
187                        tracing::error!(
188                            %channel,
189                            "could not find packet keys for channel endpoints"
190                        );
191                    }
192                    Err(error) => {
193                        tracing::error!(
194                            %error, %channel,
195                            "failed to convert chain keys to packet keys"
196                        );
197                    }
198                }
199            }
200            ChainEvent::WinningProbabilityIncreased(prob) | ChainEvent::WinningProbabilityDecreased(prob) => {
201                tracing::debug!(%prob, "recording winning probability change");
202                *win_probability.write() = prob;
203                push_ticket_face_value(&graph_updater, &ticket_price, &win_probability);
204            }
205            ChainEvent::TicketPriceChanged(price) => {
206                tracing::debug!(%price, "recording ticket price change");
207                *ticket_price.write() = price;
208                push_ticket_face_value(&graph_updater, &ticket_price, &win_probability);
209            }
210            ChainEvent::ServiceRegistered(_)
211            | ChainEvent::ServiceUpdated(_)
212            | ChainEvent::ServiceDeregistered(..)
213            | ChainEvent::ServiceTypeRegistered(..)
214            | ChainEvent::ServiceTypeOwnerChanged(..)
215            | ChainEvent::ServiceTypeRequirementChanged(..)
216            | ChainEvent::ServiceTypeRegistrationBurnChanged(..)
217            | ChainEvent::ServiceTypeUpdateBurnChanged(..)
218            | ChainEvent::ServiceTypeRegistrationFeeChanged(_)
219            | ChainEvent::ServiceRegistryPointerChanged(_) => {}
220        }
221    }
222}
223
224/// Recomputes the single-hop ticket face value and pushes it into the graph.
225///
226/// `price / win_probability` is the amount that makes the expected payout per packet equal the
227/// ticket price, i.e. one ticket's face value. Edges store only a balance, so this is the one place
228/// pricing enters path selection — and a change costs a single write rather than an edge sweep.
229fn push_ticket_face_value<G>(
230    graph: &G,
231    ticket_price: &Arc<RwLock<HoprBalance>>,
232    win_probability: &Arc<RwLock<WinningProbability>>,
233) where
234    G: NetworkGraphUpdate,
235{
236    // Both guards are released before the division: holding one while taking the other is a lock
237    // order this code would then be obliged to honour everywhere else.
238    let price = *ticket_price.read();
239    let probability = win_probability.read().as_f64();
240
241    // The `f64` carries the probability, it does not convert it. `as_f64` packs the 56-bit encoded
242    // value into the mantissa of a number in [1, 2), and `div_f64` strips it straight back out and
243    // divides in `U256` — the balance never becomes a float, and a whole probability short-circuits
244    // to the identity. A hand-rolled integer division here would gain nothing but the guards.
245    match price.div_f64(probability) {
246        Ok(face_value) => {
247            let face_value = face_value.amount();
248            tracing::debug!(%face_value, "recording ticket face value change");
249            graph.set_ticket_face_value(face_value);
250        }
251        Err(error) => tracing::error!(%error, "failed to derive the ticket face value; leaving the previous one"),
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use std::{
258        collections::HashMap,
259        sync::{Arc, Mutex},
260        time::SystemTime,
261    };
262
263    use anyhow::Context as _;
264    use hopr_api::{
265        HoprBalance, OffchainPublicKey,
266        chain::{ChainKeyOperations, HoprKeyIdent, KeyIdMapping, WinningProbability},
267        graph::{EdgeBalanceUpdate, MeasurableEdge, MeasurablePath, MeasurablePeer, NetworkGraphUpdate},
268        types::{
269            chain::chain_events::ChainEvent,
270            crypto::prelude::{ChainKeypair, Keypair, OffchainKeypair},
271            internal::prelude::{
272                AccountEntry, AccountType, ChannelEntry, ChannelStatus, ServiceEntry, ServiceMetadata, ServiceType,
273            },
274            primitive::prelude::Address,
275        },
276    };
277    use hopr_transport::MemorySurbStore;
278    use parking_lot::RwLock;
279    use rstest::rstest;
280
281    use super::process_chain_events;
282
283    // ---------------------------------------------------------------------------
284    // Stubs
285    // ---------------------------------------------------------------------------
286
287    #[derive(Debug, Clone, thiserror::Error)]
288    #[error("stub: {0}")]
289    struct StubError(String);
290
291    /// Maps only the keys it was given; empty by default, in which case it maps nothing.
292    #[derive(Debug, Clone, Default)]
293    struct StubMapper(HashMap<OffchainPublicKey, HoprKeyIdent>);
294
295    impl KeyIdMapping<HoprKeyIdent, OffchainPublicKey> for StubMapper {
296        fn map_key_to_id(&self, key: &OffchainPublicKey) -> Option<HoprKeyIdent> {
297            self.0.get(key).copied()
298        }
299
300        fn map_id_to_public(&self, id: &HoprKeyIdent) -> Option<OffchainPublicKey> {
301            self.0.iter().find_map(|(k, v)| (v == id).then_some(*k))
302        }
303    }
304
305    #[derive(Debug, Clone)]
306    struct StubChainKeys {
307        keys: HashMap<Address, OffchainPublicKey>,
308        mapper: StubMapper,
309    }
310
311    impl StubChainKeys {
312        fn new(pairs: impl IntoIterator<Item = (Address, OffchainPublicKey)>) -> Self {
313            Self {
314                keys: pairs.into_iter().collect(),
315                mapper: StubMapper::default(),
316            }
317        }
318
319        fn with_key_ids(mut self, ids: impl IntoIterator<Item = (OffchainPublicKey, HoprKeyIdent)>) -> Self {
320            self.mapper = StubMapper(ids.into_iter().collect());
321            self
322        }
323    }
324
325    impl ChainKeyOperations for StubChainKeys {
326        type Error = StubError;
327        type Mapper = StubMapper;
328
329        fn chain_key_to_packet_key(&self, chain: &Address) -> Result<Option<OffchainPublicKey>, Self::Error> {
330            Ok(self.keys.get(chain).copied())
331        }
332
333        fn packet_key_to_chain_key(&self, packet: &OffchainPublicKey) -> Result<Option<Address>, Self::Error> {
334            Ok(self.keys.iter().find_map(|(a, k)| (k == packet).then_some(*a)))
335        }
336
337        fn key_id_mapper_ref(&self) -> &Self::Mapper {
338            &self.mapper
339        }
340    }
341
342    #[derive(Debug, Clone)]
343    enum GraphCall {
344        Node(OffchainPublicKey),
345        Edge(Box<EdgeBalanceUpdate>),
346        FaceValue(hopr_api::graph::traits::Balance),
347    }
348
349    #[derive(Debug, Clone, Default)]
350    struct RecordingGraph {
351        calls: Arc<Mutex<Vec<GraphCall>>>,
352    }
353
354    impl RecordingGraph {
355        fn recorded(&self) -> Vec<GraphCall> {
356            self.calls.lock().unwrap().clone()
357        }
358
359        fn edges(&self) -> Vec<EdgeBalanceUpdate> {
360            self.recorded()
361                .into_iter()
362                .filter_map(|c| if let GraphCall::Edge(e) = c { Some(*e) } else { None })
363                .collect()
364        }
365
366        fn face_values(&self) -> Vec<hopr_api::graph::traits::Balance> {
367            self.recorded()
368                .into_iter()
369                .filter_map(|c| if let GraphCall::FaceValue(v) = c { Some(v) } else { None })
370                .collect()
371        }
372
373        fn nodes(&self) -> Vec<OffchainPublicKey> {
374            self.recorded()
375                .into_iter()
376                .filter_map(|c| if let GraphCall::Node(n) = c { Some(n) } else { None })
377                .collect()
378        }
379    }
380
381    impl NetworkGraphUpdate for RecordingGraph {
382        fn set_ticket_face_value(&self, ticket_face_value: hopr_api::graph::traits::Balance) {
383            self.calls.lock().unwrap().push(GraphCall::FaceValue(ticket_face_value));
384        }
385
386        fn record_edge<N, P>(&self, update: MeasurableEdge<N, P>)
387        where
388            N: MeasurablePeer + Clone + Send + Sync + 'static,
389            P: MeasurablePath + Clone + Send + Sync + 'static,
390        {
391            if let MeasurableEdge::Balance(balance) = update {
392                self.calls.lock().unwrap().push(GraphCall::Edge(balance));
393            }
394        }
395
396        fn record_node<N>(&self, update: N)
397        where
398            N: hopr_api::graph::MeasurableNode + Clone + Send + Sync + 'static,
399        {
400            self.calls.lock().unwrap().push(GraphCall::Node(update.into()));
401        }
402    }
403
404    // ---------------------------------------------------------------------------
405    // Helpers
406    // ---------------------------------------------------------------------------
407
408    fn make_keypairs() -> (OffchainKeypair, ChainKeypair) {
409        (OffchainKeypair::random(), ChainKeypair::random())
410    }
411
412    fn channel(src: Address, dst: Address, balance: u128, status: ChannelStatus) -> ChannelEntry {
413        ChannelEntry::builder()
414            .source(src)
415            .destination(dst)
416            .amount(balance)
417            .status(status)
418            .build()
419            .expect("valid channel")
420    }
421
422    fn account(key: OffchainPublicKey, addr: Address) -> AccountEntry {
423        use hopr_api::types::primitive::prelude::KeyIdent;
424        AccountEntry {
425            public_key: key,
426            chain_addr: addr,
427            entry_type: AccountType::NotAnnounced,
428            safe_address: None,
429            key_id: KeyIdent::default(),
430        }
431    }
432
433    async fn run(
434        events: Vec<ChainEvent>,
435        chain: StubChainKeys,
436        graph: RecordingGraph,
437        own_chain_addr: Address,
438        own_packet_key: OffchainPublicKey,
439        ticket_price: HoprBalance,
440        win_probability: WinningProbability,
441    ) {
442        let _ = run_with_peer_discovery(
443            events,
444            chain,
445            graph,
446            own_chain_addr,
447            own_packet_key,
448            ticket_price,
449            win_probability,
450        )
451        .await;
452    }
453
454    async fn run_with_peer_discovery(
455        events: Vec<ChainEvent>,
456        chain: StubChainKeys,
457        graph: RecordingGraph,
458        own_chain_addr: Address,
459        own_packet_key: OffchainPublicKey,
460        ticket_price: HoprBalance,
461        win_probability: WinningProbability,
462    ) -> Vec<(hopr_api::PeerId, Vec<hopr_api::Multiaddr>)> {
463        use futures::StreamExt;
464        let (tx, rx) = hopr_utils::network_types::crossfire_sink::bounded_sink_channel(64);
465        process_chain_events(
466            chain,
467            graph,
468            MemorySurbStore::default(),
469            futures::stream::iter(events),
470            own_chain_addr,
471            own_packet_key,
472            Arc::new(RwLock::new(ticket_price)),
473            Arc::new(RwLock::new(win_probability)),
474            Some(tx),
475        )
476        .await;
477        rx.collect().await
478    }
479
480    /// Runs the event loop against a caller-supplied SURB store, so the test can inspect it after.
481    async fn run_with_surb_store(
482        events: Vec<ChainEvent>,
483        chain: StubChainKeys,
484        surb_store: MemorySurbStore,
485        own_chain_addr: Address,
486        own_packet_key: OffchainPublicKey,
487    ) {
488        process_chain_events(
489            chain,
490            RecordingGraph::default(),
491            surb_store,
492            futures::stream::iter(events),
493            own_chain_addr,
494            own_packet_key,
495            Arc::new(RwLock::new(HoprBalance::from(1u32))),
496            Arc::new(RwLock::new(WinningProbability::ALWAYS)),
497            None,
498        )
499        .await;
500    }
501
502    // ---------------------------------------------------------------------------
503    // Tests
504    // ---------------------------------------------------------------------------
505
506    #[tokio::test]
507    async fn announcement_records_node() {
508        let (offchain, chain) = make_keypairs();
509        let addr = chain.public().to_address();
510        let graph = RecordingGraph::default();
511
512        run(
513            vec![ChainEvent::Announcement(account(*offchain.public(), addr))],
514            StubChainKeys::new([]),
515            graph.clone(),
516            addr,
517            *offchain.public(),
518            HoprBalance::from(10u64),
519            WinningProbability::ALWAYS,
520        )
521        .await;
522
523        assert_eq!(graph.nodes(), vec![*offchain.public()]);
524        assert!(graph.edges().is_empty());
525    }
526
527    #[tokio::test]
528    async fn announcement_should_forward_to_peer_discovery_when_tx_is_set() -> anyhow::Result<()> {
529        use std::str::FromStr;
530
531        use hopr_api::types::internal::prelude::AccountType;
532
533        let (offchain, chain) = make_keypairs();
534        let addr = chain.public().to_address();
535        let multiaddr = hopr_api::Multiaddr::from_str("/ip4/1.2.3.4/tcp/9000").context("parse multiaddr")?;
536        let entry = AccountEntry {
537            entry_type: AccountType::Announced(vec![multiaddr.clone()]),
538            ..account(*offchain.public(), addr)
539        };
540        let graph = RecordingGraph::default();
541
542        let received = run_with_peer_discovery(
543            vec![ChainEvent::Announcement(entry)],
544            StubChainKeys::new([]),
545            graph.clone(),
546            addr,
547            *offchain.public(),
548            HoprBalance::from(10u64),
549            WinningProbability::ALWAYS,
550        )
551        .await;
552
553        assert_eq!(received.len(), 1, "expected exactly one peer-discovery event");
554        let (peer_id, addrs) = &received[0];
555        assert_eq!(
556            *peer_id,
557            hopr_api::PeerId::from(*offchain.public()),
558            "peer id must match the announced account's public key"
559        );
560        assert_eq!(addrs, &vec![multiaddr], "multiaddrs must be forwarded unchanged");
561        assert_eq!(
562            graph.nodes(),
563            vec![*offchain.public()],
564            "graph must also record the node"
565        );
566        Ok(())
567    }
568
569    #[tokio::test]
570    async fn channel_opened_records_capacity() {
571        let (src_offchain, src_chain) = make_keypairs();
572        let (dst_offchain, dst_chain) = make_keypairs();
573        let src_addr = src_chain.public().to_address();
574        let dst_addr = dst_chain.public().to_address();
575
576        let graph = RecordingGraph::default();
577        let stub = StubChainKeys::new([(src_addr, *src_offchain.public()), (dst_addr, *dst_offchain.public())]);
578
579        // The balance is emitted as-is; pricing no longer enters the per-edge value.
580        run(
581            vec![ChainEvent::ChannelOpened(channel(
582                src_addr,
583                dst_addr,
584                100,
585                ChannelStatus::Open,
586            ))],
587            stub,
588            graph.clone(),
589            src_addr,
590            *src_offchain.public(),
591            HoprBalance::from(10u64),
592            WinningProbability::ALWAYS,
593        )
594        .await;
595
596        let edges = graph.edges();
597        assert_eq!(edges.len(), 1);
598        assert_eq!(edges[0].balance, Some(hopr_api::graph::traits::Balance::from(100u64)));
599        assert_eq!(edges[0].src, *src_offchain.public());
600        assert_eq!(edges[0].dest, *dst_offchain.public());
601    }
602
603    #[tokio::test]
604    async fn channel_balance_decreased_records_updated_capacity() {
605        let (src_offchain, src_chain) = make_keypairs();
606        let (dst_offchain, dst_chain) = make_keypairs();
607        let src_addr = src_chain.public().to_address();
608        let dst_addr = dst_chain.public().to_address();
609
610        let graph = RecordingGraph::default();
611        let stub = StubChainKeys::new([(src_addr, *src_offchain.public()), (dst_addr, *dst_offchain.public())]);
612
613        // The decreased balance is emitted as-is.
614        run(
615            vec![ChainEvent::ChannelBalanceDecreased(
616                channel(src_addr, dst_addr, 50, ChannelStatus::Open),
617                HoprBalance::from(50u64),
618            )],
619            stub,
620            graph.clone(),
621            src_addr,
622            *src_offchain.public(),
623            HoprBalance::from(10u64),
624            WinningProbability::ALWAYS,
625        )
626        .await;
627
628        let edges = graph.edges();
629        assert_eq!(edges.len(), 1);
630        assert_eq!(edges[0].balance, Some(hopr_api::graph::traits::Balance::from(50u64)));
631    }
632
633    #[tokio::test]
634    async fn ticket_redeemed_records_updated_balance() {
635        // A redemption drains a channel's on-chain balance, but it arrives as `TicketRedeemed`,
636        // not `ChannelBalanceDecreased`. The graph must still learn the new balance, otherwise path
637        // selection keeps routing over a channel the ticket factory can no longer fund (GNO-805).
638        let (src_offchain, src_chain) = make_keypairs();
639        let (dst_offchain, dst_chain) = make_keypairs();
640        let src_addr = src_chain.public().to_address();
641        let dst_addr = dst_chain.public().to_address();
642
643        let graph = RecordingGraph::default();
644        let stub = StubChainKeys::new([(src_addr, *src_offchain.public()), (dst_addr, *dst_offchain.public())]);
645
646        // Post-redemption the channel is still Open but holds only 3; its balance must be recorded.
647        run(
648            vec![ChainEvent::TicketRedeemed(
649                channel(src_addr, dst_addr, 3, ChannelStatus::Open),
650                None,
651            )],
652            stub,
653            graph.clone(),
654            src_addr,
655            *src_offchain.public(),
656            HoprBalance::from(10u64),
657            WinningProbability::ALWAYS,
658        )
659        .await;
660
661        let edges = graph.edges();
662        assert_eq!(edges.len(), 1, "redemption must record a graph edge balance update");
663        assert_eq!(edges[0].balance, Some(hopr_api::graph::traits::Balance::from(3u64)));
664    }
665
666    #[tokio::test]
667    async fn channel_closed_records_capacity_none() {
668        let (src_offchain, src_chain) = make_keypairs();
669        let (dst_offchain, dst_chain) = make_keypairs();
670        let src_addr = src_chain.public().to_address();
671        let dst_addr = dst_chain.public().to_address();
672
673        let graph = RecordingGraph::default();
674        let stub = StubChainKeys::new([(src_addr, *src_offchain.public()), (dst_addr, *dst_offchain.public())]);
675
676        run(
677            vec![ChainEvent::ChannelClosed(channel(
678                src_addr,
679                dst_addr,
680                0,
681                ChannelStatus::Closed,
682            ))],
683            stub,
684            graph.clone(),
685            src_addr,
686            *src_offchain.public(),
687            HoprBalance::from(10u64),
688            WinningProbability::ALWAYS,
689        )
690        .await;
691
692        let edges = graph.edges();
693        assert_eq!(edges.len(), 1);
694        assert_eq!(edges[0].balance, None);
695    }
696
697    /// Regression test: before the fix, ChannelClosureInitiated was a no-op and the
698    /// graph kept the prior `Some(N)` capacity for the channel lifetime of the close
699    /// timeout window, allowing routing to keep picking the dying edge.
700    #[tokio::test]
701    async fn channel_closure_initiated_records_capacity_none() {
702        let (src_offchain, src_chain) = make_keypairs();
703        let (dst_offchain, dst_chain) = make_keypairs();
704        let src_addr = src_chain.public().to_address();
705        let dst_addr = dst_chain.public().to_address();
706
707        let graph = RecordingGraph::default();
708        let stub = StubChainKeys::new([(src_addr, *src_offchain.public()), (dst_addr, *dst_offchain.public())]);
709
710        run(
711            vec![ChainEvent::ChannelClosureInitiated(channel(
712                src_addr,
713                dst_addr,
714                100,
715                ChannelStatus::PendingToClose(SystemTime::now()),
716            ))],
717            stub,
718            graph.clone(),
719            src_addr,
720            *src_offchain.public(),
721            HoprBalance::from(10u64),
722            WinningProbability::ALWAYS,
723        )
724        .await;
725
726        let edges = graph.edges();
727        assert_eq!(edges.len(), 1, "closure-initiated must emit a graph update");
728        assert_eq!(
729            edges[0].balance, None,
730            "closure-initiated must clear the balance so routing stops using this edge"
731        );
732    }
733
734    #[tokio::test]
735    async fn ticket_price_change_pushes_a_new_face_value() {
736        let (src_offchain, src_chain) = make_keypairs();
737        let (dst_offchain, dst_chain) = make_keypairs();
738        let src_addr = src_chain.public().to_address();
739        let dst_addr = dst_chain.public().to_address();
740
741        let graph = RecordingGraph::default();
742        let stub = StubChainKeys::new([(src_addr, *src_offchain.public()), (dst_addr, *dst_offchain.public())]);
743
744        // A price change recomputes the face value: 20 / 1.0 = 20. The balance is untouched.
745        run(
746            vec![
747                ChainEvent::TicketPriceChanged(HoprBalance::from(20u64)),
748                ChainEvent::ChannelOpened(channel(src_addr, dst_addr, 200, ChannelStatus::Open)),
749            ],
750            stub,
751            graph.clone(),
752            src_addr,
753            *src_offchain.public(),
754            HoprBalance::from(10u64),
755            WinningProbability::ALWAYS,
756        )
757        .await;
758
759        assert_eq!(
760            graph.face_values(),
761            vec![
762                hopr_api::graph::traits::Balance::from(10u64),
763                hopr_api::graph::traits::Balance::from(20u64),
764            ],
765            "the seeded face value, then the one recomputed from the price change"
766        );
767
768        let edges = graph.edges();
769        assert_eq!(edges.len(), 1);
770        assert_eq!(
771            edges[0].balance,
772            Some(hopr_api::graph::traits::Balance::from(200u64)),
773            "the emitted balance must not depend on the price"
774        );
775    }
776
777    #[tokio::test]
778    async fn win_probability_change_pushes_a_new_face_value() -> anyhow::Result<()> {
779        let (src_offchain, src_chain) = make_keypairs();
780        let (dst_offchain, dst_chain) = make_keypairs();
781        let src_addr = src_chain.public().to_address();
782        let dst_addr = dst_chain.public().to_address();
783
784        let graph = RecordingGraph::default();
785        let stub = StubChainKeys::new([(src_addr, *src_offchain.public()), (dst_addr, *dst_offchain.public())]);
786
787        // A winning-probability change recomputes the face value: 10 / 0.5 = 20.
788        let new_prob = WinningProbability::try_from_f64(0.5).context("0.5 is a valid winning probability")?;
789        run(
790            vec![
791                ChainEvent::WinningProbabilityDecreased(new_prob),
792                ChainEvent::ChannelOpened(channel(src_addr, dst_addr, 100, 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![
806                hopr_api::graph::traits::Balance::from(10u64),
807                hopr_api::graph::traits::Balance::from(20u64),
808            ],
809            "the seeded face value, then the one recomputed from the probability change"
810        );
811
812        let edges = graph.edges();
813        assert_eq!(edges.len(), 1);
814        assert_eq!(
815            edges[0].balance,
816            Some(hopr_api::graph::traits::Balance::from(100u64)),
817            "the emitted balance must not depend on the winning probability"
818        );
819        Ok(())
820    }
821
822    /// Startup replays on-chain state as channel events, and a pricing event follows only if the
823    /// price actually changed. Without a seed the graph would then hold balances while
824    /// `ticket_face_value()` was still `None`, and selection would price every path off the
825    /// fallback for as long as the price happened to stay put.
826    #[tokio::test]
827    async fn a_face_value_is_seeded_before_any_pricing_event() {
828        let (src_offchain, src_chain) = make_keypairs();
829        let (dst_offchain, dst_chain) = make_keypairs();
830        let src_addr = src_chain.public().to_address();
831        let dst_addr = dst_chain.public().to_address();
832
833        let graph = RecordingGraph::default();
834        let stub = StubChainKeys::new([(src_addr, *src_offchain.public()), (dst_addr, *dst_offchain.public())]);
835
836        // Only a channel event: exactly the startup replay, with no price or probability change.
837        run(
838            vec![ChainEvent::ChannelOpened(channel(
839                src_addr,
840                dst_addr,
841                200,
842                ChannelStatus::Open,
843            ))],
844            stub,
845            graph.clone(),
846            src_addr,
847            *src_offchain.public(),
848            HoprBalance::from(10u64),
849            WinningProbability::ALWAYS,
850        )
851        .await;
852
853        assert_eq!(
854            graph.face_values(),
855            vec![hopr_api::graph::traits::Balance::from(10u64)],
856            "the graph must know the price before it records a balance it will be compared against"
857        );
858    }
859
860    #[tokio::test]
861    async fn unknown_chain_key_produces_no_graph_update() {
862        let (src_offchain, src_chain) = make_keypairs();
863        let (_, dst_chain) = make_keypairs();
864        let src_addr = src_chain.public().to_address();
865        let dst_addr = dst_chain.public().to_address();
866
867        let graph = RecordingGraph::default();
868        // dst is NOT in the stub map → chain_key_to_packet_key returns None for dst
869        let stub = StubChainKeys::new([(src_addr, *src_offchain.public())]);
870
871        run(
872            vec![ChainEvent::ChannelOpened(channel(
873                src_addr,
874                dst_addr,
875                100,
876                ChannelStatus::Open,
877            ))],
878            stub,
879            graph.clone(),
880            src_addr,
881            *src_offchain.public(),
882            HoprBalance::from(10u64),
883            WinningProbability::ALWAYS,
884        )
885        .await;
886
887        assert!(graph.edges().is_empty(), "unknown key must produce no graph update");
888    }
889
890    #[tokio::test]
891    async fn self_address_resolved_via_own_packet_key() {
892        let (own_offchain, own_chain) = make_keypairs();
893        let (dst_offchain, dst_chain) = make_keypairs();
894        let own_chain_addr = own_chain.public().to_address();
895        let dst_addr = dst_chain.public().to_address();
896
897        let graph = RecordingGraph::default();
898        // own_chain_addr not in stub — must be resolved via own_packet_key
899        let stub = StubChainKeys::new([(dst_addr, *dst_offchain.public())]);
900
901        run(
902            vec![ChainEvent::ChannelOpened(channel(
903                own_chain_addr,
904                dst_addr,
905                100,
906                ChannelStatus::Open,
907            ))],
908            stub,
909            graph.clone(),
910            own_chain_addr,
911            *own_offchain.public(),
912            HoprBalance::from(10u64),
913            WinningProbability::ALWAYS,
914        )
915        .await;
916
917        let edges = graph.edges();
918        assert_eq!(edges.len(), 1);
919        assert_eq!(edges[0].src, *own_offchain.public());
920        assert_eq!(edges[0].dest, *dst_offchain.public());
921    }
922
923    #[tokio::test]
924    async fn announcement_should_handle_disconnected_peer_discovery_tx_gracefully() {
925        let (offchain, chain) = make_keypairs();
926        let addr = chain.public().to_address();
927        let (tx, rx) = hopr_utils::network_types::crossfire_sink::bounded_sink_channel(1);
928        drop(rx); // receiver dropped — send will return Err(Disconnected)
929
930        process_chain_events(
931            StubChainKeys::new([]),
932            RecordingGraph::default(),
933            MemorySurbStore::default(),
934            futures::stream::iter(vec![ChainEvent::Announcement(account(*offchain.public(), addr))]),
935            addr,
936            *offchain.public(),
937            Arc::new(RwLock::new(HoprBalance::from(10u64))),
938            Arc::new(RwLock::new(WinningProbability::ALWAYS)),
939            Some(tx),
940        )
941        .await;
942    }
943
944    type MakeServiceEvent = fn(Address, Address, ServiceEntry, HoprBalance) -> ChainEvent;
945
946    #[rstest]
947    #[case::registered(|_, _, entry, _| ChainEvent::ServiceRegistered(entry))]
948    #[case::updated(|_, _, entry, _| ChainEvent::ServiceUpdated(entry))]
949    #[case::deregistered(|node, _, _, _| ChainEvent::ServiceDeregistered(ServiceType::GVPN_EXIT, node))]
950    #[case::type_registered(|_, owner, _, _| ChainEvent::ServiceTypeRegistered(ServiceType::GVPN_EXIT, owner))]
951    #[case::type_owner_changed(|_, owner, _, _| ChainEvent::ServiceTypeOwnerChanged(ServiceType::GVPN_EXIT, Some(owner)))]
952    #[case::type_requirement_changed(
953        |_, owner, _, _| ChainEvent::ServiceTypeRequirementChanged(ServiceType::GVPN_EXIT, Some(owner))
954    )]
955    #[case::type_registration_burn_changed(
956        |_, _, _, burn| ChainEvent::ServiceTypeRegistrationBurnChanged(ServiceType::GVPN_EXIT, burn)
957    )]
958    #[case::type_update_burn_changed(
959        |_, _, _, burn| ChainEvent::ServiceTypeUpdateBurnChanged(ServiceType::GVPN_EXIT, burn)
960    )]
961    #[case::type_registration_fee_changed(|_, _, _, burn| ChainEvent::ServiceTypeRegistrationFeeChanged(burn))]
962    #[case::registry_pointer_changed(|_, owner, _, _| ChainEvent::ServiceRegistryPointerChanged(owner))]
963    #[tokio::test]
964    async fn service_events_are_ignored_without_stopping_processing(
965        #[case] make_event: MakeServiceEvent,
966    ) -> anyhow::Result<()> {
967        let (src_offchain, src_chain) = make_keypairs();
968        let (dst_offchain, dst_chain) = make_keypairs();
969        let src_addr = src_chain.public().to_address();
970        let dst_addr = dst_chain.public().to_address();
971        let stub = StubChainKeys::new([(src_addr, *src_offchain.public()), (dst_addr, *dst_offchain.public())]);
972
973        let now = SystemTime::now();
974        let entry = ServiceEntry::new(
975            ServiceType::GVPN_EXIT,
976            dst_addr,
977            src_addr,
978            ServiceMetadata::try_from(b"exit-node".to_vec())?,
979            now,
980            now,
981        )?;
982        let event = make_event(dst_addr, src_addr, entry, HoprBalance::from(7u64));
983
984        // Held here rather than inside `run`, so the values after the run can be inspected.
985        let ticket_price = Arc::new(RwLock::new(HoprBalance::from(10u64)));
986        let win_probability = Arc::new(RwLock::new(WinningProbability::ALWAYS));
987        let graph = RecordingGraph::default();
988
989        // The trailing channel event is the regression guard: an arm that panicked or
990        // short-circuited the loop would swallow everything behind it.
991        let events = vec![
992            event,
993            ChainEvent::ChannelOpened(channel(src_addr, dst_addr, 100, ChannelStatus::Open)),
994        ];
995
996        process_chain_events(
997            stub,
998            graph.clone(),
999            MemorySurbStore::default(),
1000            futures::stream::iter(events),
1001            src_addr,
1002            *src_offchain.public(),
1003            ticket_price.clone(),
1004            win_probability.clone(),
1005            None,
1006        )
1007        .await;
1008
1009        assert_eq!(
1010            graph.face_values(),
1011            vec![hopr_api::graph::traits::Balance::from(10u64)],
1012            "only the unconditional startup face-value seed should be recorded"
1013        );
1014        assert_eq!(
1015            *ticket_price.read(),
1016            HoprBalance::from(10u64),
1017            "a service event must not change the ticket price"
1018        );
1019        assert_eq!(
1020            win_probability.read().as_f64(),
1021            WinningProbability::ALWAYS.as_f64(),
1022            "a service event must not change the winning probability"
1023        );
1024        assert!(graph.nodes().is_empty(), "a service event must not record a graph node");
1025
1026        let edges = graph.edges();
1027        assert_eq!(
1028            edges.len(),
1029            1,
1030            "only the channel event may record an edge, and it must still be routed"
1031        );
1032        assert_eq!(
1033            edges[0].balance,
1034            Some(hopr_api::graph::traits::Balance::from(100u64)),
1035            "the balance inputs must have survived the service event"
1036        );
1037        assert_eq!(edges[0].src, *src_offchain.public());
1038        assert_eq!(edges[0].dest, *dst_offchain.public());
1039
1040        Ok(())
1041    }
1042
1043    // ---------------------------------------------------------------------------
1044    // SURB store invalidation
1045    // ---------------------------------------------------------------------------
1046
1047    /// Sets up `me -> peer` with `peer` mapped to key id 1, and runs the given channel events.
1048    async fn run_own_channel_events(
1049        statuses: impl IntoIterator<Item = ChannelStatus>,
1050    ) -> (MemorySurbStore, HoprKeyIdent) {
1051        let (me_offchain, me_chain) = make_keypairs();
1052        let (peer_offchain, peer_chain) = make_keypairs();
1053        let (me_addr, peer_addr) = (me_chain.public().to_address(), peer_chain.public().to_address());
1054        let peer_id = HoprKeyIdent::from(1u32);
1055
1056        let stub = StubChainKeys::new([(me_addr, *me_offchain.public()), (peer_addr, *peer_offchain.public())])
1057            .with_key_ids([(*peer_offchain.public(), peer_id)]);
1058
1059        let surb_store = MemorySurbStore::default();
1060        let events = statuses
1061            .into_iter()
1062            .map(|status| ChainEvent::ChannelOpened(channel(me_addr, peer_addr, 100, status)))
1063            .collect();
1064
1065        run_with_surb_store(events, stub, surb_store.clone(), me_addr, *me_offchain.public()).await;
1066
1067        (surb_store, peer_id)
1068    }
1069
1070    #[tokio::test]
1071    async fn closing_an_own_outgoing_channel_should_invalidate_that_relayer() {
1072        let (store, peer_id) =
1073            run_own_channel_events([ChannelStatus::PendingToClose(std::time::SystemTime::now())]).await;
1074        assert!(store.is_relayer_invalidated(&peer_id), "PendingToClose must invalidate");
1075
1076        let (store, peer_id) = run_own_channel_events([ChannelStatus::Closed]).await;
1077        assert!(store.is_relayer_invalidated(&peer_id), "Closed must invalidate");
1078    }
1079
1080    #[tokio::test]
1081    async fn reopening_an_own_outgoing_channel_should_revalidate_that_relayer() {
1082        let (store, peer_id) = run_own_channel_events([ChannelStatus::Closed, ChannelStatus::Open]).await;
1083        assert!(!store.is_relayer_invalidated(&peer_id), "re-opening must revalidate");
1084    }
1085
1086    #[tokio::test]
1087    async fn a_channel_that_is_not_ours_should_not_invalidate_anything() {
1088        // Same topology, but the event is for a channel between two other parties.
1089        let (me_offchain, me_chain) = make_keypairs();
1090        let (a_offchain, a_chain) = make_keypairs();
1091        let (b_offchain, b_chain) = make_keypairs();
1092        let (me_addr, a_addr, b_addr) = (
1093            me_chain.public().to_address(),
1094            a_chain.public().to_address(),
1095            b_chain.public().to_address(),
1096        );
1097        let b_id = HoprKeyIdent::from(1u32);
1098
1099        let stub = StubChainKeys::new([
1100            (me_addr, *me_offchain.public()),
1101            (a_addr, *a_offchain.public()),
1102            (b_addr, *b_offchain.public()),
1103        ])
1104        .with_key_ids([(*b_offchain.public(), b_id)]);
1105
1106        let store = MemorySurbStore::default();
1107        run_with_surb_store(
1108            vec![ChainEvent::ChannelClosed(channel(
1109                a_addr,
1110                b_addr,
1111                100,
1112                ChannelStatus::Closed,
1113            ))],
1114            stub,
1115            store.clone(),
1116            me_addr,
1117            *me_offchain.public(),
1118        )
1119        .await;
1120
1121        assert!(
1122            !store.is_relayer_invalidated(&b_id),
1123            "someone else's channel must not affect our SURBs"
1124        );
1125    }
1126}