Skip to main content

hopr_chain_connector/connector/
mod.rs

1use std::{cmp::Ordering, str::FromStr, sync::atomic::Ordering as AtomicOrdering, time::Duration};
2
3use blokli_client::api::{BlokliQueryClient, BlokliSubscriptionClient, BlokliTransactionClient};
4use futures::{FutureExt, StreamExt, TryFutureExt, TryStreamExt};
5use futures_concurrency::stream::Merge;
6use futures_time::future::FutureExt as FuturesTimeExt;
7use hopr_api::{
8    chain::{ChainPathResolver, ChainReceipt, HoprKeyIdent},
9    types::{chain::prelude::*, crypto::prelude::*, internal::prelude::*, primitive::prelude::*},
10};
11use hopr_utils::runtime::AbortHandle;
12use petgraph::prelude::DiGraphMap;
13
14use crate::{
15    backend::Backend,
16    connector::{keys::HoprKeyMapper, sequencer::TransactionSequencer, values::CHAIN_INFO_CACHE_KEY},
17    errors::ConnectorError,
18    utils::{
19        ParsedChainInfo, model_to_account_entry, model_to_graph_entry, model_to_ticket_params,
20        process_channel_changes_into_events,
21    },
22};
23
24mod accounts;
25mod channels;
26mod events;
27mod keys;
28mod safe;
29mod sequencer;
30mod tickets;
31mod values;
32
33type EventsChannel = (
34    async_broadcast::Sender<ChainEvent>,
35    async_broadcast::InactiveReceiver<ChainEvent>,
36);
37
38const MIN_CONNECTION_TIMEOUT: Duration = Duration::from_millis(100);
39const MIN_TX_CONFIRM_TIMEOUT: Duration = Duration::from_secs(1);
40const TX_TIMEOUT_MULTIPLIER: u32 = 2;
41const DEFAULT_SYNC_TOLERANCE_PCT: usize = 90;
42
43/// Connector health states.
44///
45/// Each value maps to a `ComponentStatus` variant plus a fixed detail message.
46/// Storage and updates are lock-free via [`AtomicChainHealthState`]; reads
47/// convert to `ComponentStatus` which allocates for non-`Ready` variants.
48#[atomic_enum::atomic_enum]
49#[derive(PartialEq, Eq)]
50enum ChainHealthState {
51    Ready = 0,
52    WaitingForConnection = 1,
53    Connecting = 2,
54    SubscriptionEnded = 3,
55    SyncTimedOut = 4,
56    ServerNotHealthy = 5,
57    ConnectionFailed = 6,
58    Dropped = 7,
59}
60
61impl From<ChainHealthState> for hopr_api::node::ComponentStatus {
62    fn from(state: ChainHealthState) -> Self {
63        match state {
64            ChainHealthState::Ready => Self::Ready,
65            ChainHealthState::WaitingForConnection => Self::Initializing("waiting for chain connection".into()),
66            ChainHealthState::Connecting => Self::Initializing("connecting to blokli".into()),
67            ChainHealthState::SubscriptionEnded => Self::Degraded("chain subscription ended".into()),
68            ChainHealthState::SyncTimedOut => Self::Degraded("connection sync timed out".into()),
69            ChainHealthState::ServerNotHealthy => Self::Unavailable("blokli server not healthy".into()),
70            ChainHealthState::ConnectionFailed => Self::Unavailable("chain connection failed".into()),
71            ChainHealthState::Dropped => Self::Unavailable("connector dropped".into()),
72        }
73    }
74}
75
76/// Configuration of the [`HoprBlockchainConnector`].
77#[derive(Clone, Copy, Debug, PartialEq, Eq, smart_default::SmartDefault)]
78pub struct BlockchainConnectorConfig {
79    /// Maximum time to wait for [connection](HoprBlockchainConnector::connect) to complete.
80    ///
81    /// Default is 30 seconds, minimum is 100 milliseconds.
82    #[default(Duration::from_secs(30))]
83    pub connection_sync_timeout: Duration,
84    /// Percentage of the total number of accounts and opened channels that must
85    /// be received during a [connection attempt](HoprBlockchainConnector::connect)
86    /// to be successful.
87    ///
88    /// Default is 90%, minimum is 1, maximum is 100.
89    #[default(DEFAULT_SYNC_TOLERANCE_PCT)]
90    pub sync_tolerance: usize,
91    /// Transaction waits for confirmation by multiplying chain's blocktime, finality, and this multiplier.
92    /// Set it to higher values if transactions are failing due to timeout at the client.
93    ///
94    /// Default is 2, minimum is 1.
95    #[default(TX_TIMEOUT_MULTIPLIER)]
96    pub tx_timeout_multiplier: u32,
97}
98
99/// A connector acting as middleware between the HOPR APIs (see the [`hopr_api`] crate) and the Blokli Client API (see
100/// the [`blokli_client`] crate).
101///
102/// The connector object cannot be cloned and shall be used inside an `Arc` if cloning is needed.
103pub struct HoprBlockchainConnector<C, B, P, R> {
104    payload_generator: P,
105    chain_key: ChainKeypair,
106    client: std::sync::Arc<C>,
107    graph: std::sync::Arc<parking_lot::RwLock<DiGraphMap<HoprKeyIdent, ChannelId, ahash::RandomState>>>,
108    backend: std::sync::Arc<B>,
109    connection_handle: Option<AbortHandle>,
110    sequencer: TransactionSequencer<C, R>,
111    events: EventsChannel,
112    cfg: BlockchainConnectorConfig,
113    health: std::sync::Arc<AtomicChainHealthState>,
114
115    // KeyId <-> OffchainPublicKey mapping
116    mapper: HoprKeyMapper<B>,
117    // Fast retrieval of chain keys by address
118    chain_to_packet: moka::sync::Cache<Address, Option<OffchainPublicKey>, ahash::RandomState>,
119    // Fast retrieval of packet keys by chain key
120    packet_to_chain: moka::sync::Cache<OffchainPublicKey, Option<Address>, ahash::RandomState>,
121    // Fast retrieval of channel entries by id
122    channel_by_id: moka::sync::Cache<ChannelId, Option<ChannelEntry>, ahash::RandomState>,
123    // Fast retrieval of channel entries by parties
124    channel_by_parties: moka::sync::Cache<ChannelParties, Option<ChannelEntry>, ahash::RandomState>,
125    // Contains chain info (no TTL - kept fresh by subscription handler)
126    values: moka::future::Cache<u32, ParsedChainInfo>,
127    // Ticket values (winning probability, price), kept fresh by subscription handler
128    // Set only when connected
129    ticket_values: std::sync::Arc<parking_lot::RwLock<Option<(WinningProbability, HoprBalance)>>>,
130}
131
132const EXPECTED_NUM_NODES: usize = 10_000;
133const EXPECTED_NUM_CHANNELS: usize = 100_000;
134
135const DEFAULT_CACHE_TIMEOUT: Duration = Duration::from_mins(10);
136
137impl<B, C, P> HoprBlockchainConnector<C, B, P, P::TxRequest>
138where
139    B: Backend + Send + Sync + 'static,
140    C: BlokliSubscriptionClient + BlokliQueryClient + BlokliTransactionClient + Send + Sync + 'static,
141    P: PayloadGenerator + Send + Sync + 'static,
142    P::TxRequest: Send + Sync + 'static,
143{
144    /// Creates a new instance.
145    pub fn new(
146        chain_key: ChainKeypair,
147        cfg: BlockchainConnectorConfig,
148        client: C,
149        backend: B,
150        payload_generator: P,
151    ) -> Self {
152        let backend = std::sync::Arc::new(backend);
153        let (mut events_tx, events_rx) = async_broadcast::broadcast(1024);
154        events_tx.set_overflow(true);
155        events_tx.set_await_active(false);
156
157        let client = std::sync::Arc::new(client);
158        Self {
159            payload_generator,
160            health: std::sync::Arc::new(AtomicChainHealthState::new(ChainHealthState::WaitingForConnection)),
161            graph: std::sync::Arc::new(parking_lot::RwLock::new(DiGraphMap::with_capacity_and_hasher(
162                EXPECTED_NUM_NODES,
163                EXPECTED_NUM_CHANNELS,
164                ahash::RandomState::default(),
165            ))),
166            backend: backend.clone(),
167            connection_handle: None,
168            sequencer: TransactionSequencer::new(chain_key.clone(), client.clone()),
169            events: (events_tx, events_rx.deactivate()),
170            client,
171            chain_key,
172            cfg,
173            mapper: HoprKeyMapper {
174                id_to_key: moka::sync::CacheBuilder::new(EXPECTED_NUM_NODES as u64)
175                    .time_to_idle(DEFAULT_CACHE_TIMEOUT)
176                    .build_with_hasher(ahash::RandomState::default()),
177                key_to_id: moka::sync::CacheBuilder::new(EXPECTED_NUM_NODES as u64)
178                    .time_to_idle(DEFAULT_CACHE_TIMEOUT)
179                    .build_with_hasher(ahash::RandomState::default()),
180                backend,
181            },
182            chain_to_packet: moka::sync::CacheBuilder::new(EXPECTED_NUM_NODES as u64)
183                .time_to_idle(DEFAULT_CACHE_TIMEOUT)
184                .build_with_hasher(ahash::RandomState::default()),
185            packet_to_chain: moka::sync::CacheBuilder::new(EXPECTED_NUM_NODES as u64)
186                .time_to_idle(DEFAULT_CACHE_TIMEOUT)
187                .build_with_hasher(ahash::RandomState::default()),
188            channel_by_id: moka::sync::CacheBuilder::new(EXPECTED_NUM_CHANNELS as u64)
189                .time_to_idle(DEFAULT_CACHE_TIMEOUT)
190                .build_with_hasher(ahash::RandomState::default()),
191            channel_by_parties: moka::sync::CacheBuilder::new(EXPECTED_NUM_CHANNELS as u64)
192                .time_to_idle(DEFAULT_CACHE_TIMEOUT)
193                .build_with_hasher(ahash::RandomState::default()),
194            // No TTL: kept fresh by the Blokli subscription handler
195            values: moka::future::CacheBuilder::new(1).build(),
196            ticket_values: Default::default(),
197        }
198    }
199
200    async fn do_connect(&self, timeout: Duration) -> Result<AbortHandle, ConnectorError> {
201        let sync_quota = self.cfg.sync_tolerance.clamp(1, 100) as f64 / 100.0;
202        let min_accounts = (self
203            .client
204            .count_accounts(blokli_client::api::AccountSelector::Any)
205            .await? as f64
206            * sync_quota)
207            .round() as u32;
208        let min_channels = (self
209            .client
210            .query_channel_stats(blokli_client::api::ChannelSelector {
211                filter: None,
212                status: Some(blokli_client::api::types::ChannelStatus::Open),
213                ..Default::default()
214            })
215            .await?
216            .count as f64
217            * sync_quota)
218            .round() as u32;
219        tracing::debug!(min_accounts, min_channels, "connection thresholds");
220
221        let server_health = self.client.query_health().await?;
222        if !server_health.eq_ignore_ascii_case("OK") {
223            tracing::error!(server_health, "blokli server not healthy");
224            return Err(ConnectorError::ServerNotHealthy);
225        }
226
227        let (abort_handle, abort_reg) = AbortHandle::new_pair();
228
229        let (connection_ready_tx, connection_ready_rx) = futures::channel::oneshot::channel();
230        let mut connection_ready_tx = Some(connection_ready_tx);
231
232        let client = self.client.clone();
233        let mapper = self.mapper.clone();
234        let backend = self.backend.clone();
235        let graph = self.graph.clone();
236        let event_tx = self.events.0.clone();
237        let me = self.chain_key.public().to_address();
238        let values_cache = self.values.clone();
239
240        let chain_to_packet = self.chain_to_packet.clone();
241        let packet_to_chain = self.packet_to_chain.clone();
242
243        let channel_by_id = self.channel_by_id.clone();
244        let channel_by_parties = self.channel_by_parties.clone();
245
246        // Query chain info to populate the cache
247        let initial_chain_values = self.query_cached_chain_info().await?;
248        self.ticket_values
249            .write()
250            .replace((initial_chain_values.ticket_win_prob, initial_chain_values.ticket_price));
251
252        #[allow(clippy::large_enum_variant)]
253        #[derive(Debug)]
254        enum SubscribedEventType {
255            Account((AccountEntry, Option<AccountEntry>)),
256            Channel((ChannelEntry, Option<Vec<ChannelChange>>)),
257            WinningProbability((WinningProbability, Option<WinningProbability>)),
258            TicketPrice((HoprBalance, Option<HoprBalance>)),
259        }
260
261        let ticket_values = self.ticket_values.clone();
262        let health = self.health.clone();
263        hopr_utils::runtime::prelude::spawn(async move {
264            let sync_started = std::time::Instant::now();
265
266            let connections = client
267                .subscribe_accounts(blokli_client::api::AccountSelector::Any)
268                .and_then(|accounts| Ok((accounts, client.subscribe_graph()?)))
269                .and_then(|(accounts, channels)| Ok((accounts, channels, client.subscribe_ticket_params()?)));
270
271            if let Err(error) = connections {
272                if let Some(connection_ready_tx) = connection_ready_tx.take() {
273                    let _ = connection_ready_tx.send(Err(error));
274                }
275                return;
276            }
277
278            let (account_stream, channel_stream, ticket_params_stream) = connections.unwrap();
279
280            // Stream of Account events (Announcements)
281            let graph_clone = graph.clone();
282            let account_stream = account_stream
283                .inspect_ok(|entry| tracing::trace!(?entry, "new account event"))
284                .map_err(ConnectorError::from)
285                .try_filter_map(|account| futures::future::ready(model_to_account_entry(account).map(Some)))
286                .and_then(move |account| {
287                    let graph = graph_clone.clone();
288                    let mapper = mapper.clone();
289                    let chain_to_packet = chain_to_packet.clone();
290                    let packet_to_chain = packet_to_chain.clone();
291                    hopr_utils::runtime::prelude::spawn_blocking(move || {
292                        mapper.key_to_id.insert(account.public_key, Some(account.key_id));
293                        mapper.id_to_key.insert(account.key_id, Some(account.public_key));
294                        chain_to_packet.insert(account.chain_addr, Some(account.public_key));
295                        packet_to_chain.insert(account.public_key, Some(account.chain_addr));
296                        graph.write().add_node(account.key_id);
297                        let old = mapper
298                            .backend
299                            .insert_account(account.clone())
300                            .map_err(ConnectorError::backend)?;
301                        if let Some(old_account) = &old {
302                            if old_account.chain_addr != account.chain_addr {
303                                chain_to_packet.invalidate(&old_account.chain_addr);
304                            }
305                            if old_account.public_key != account.public_key {
306                                packet_to_chain.invalidate(&old_account.public_key);
307                            }
308                        }
309                        Ok::<_, ConnectorError>((account, old))
310                    })
311                    .map(|result| {
312                        result
313                            .map_err(ConnectorError::backend)?
314                            .map(SubscribedEventType::Account)
315                    })
316                })
317                .fuse();
318
319            // Stream of channel graph updates
320            let channel_stream = channel_stream
321                .map_err(ConnectorError::from)
322                .inspect_ok(|entry| tracing::trace!(?entry, "new graph event"))
323                .try_filter_map(|graph_event| futures::future::ready(model_to_graph_entry(graph_event).map(Some)))
324                .and_then(move |(src, dst, channel)| {
325                    let graph = graph.clone();
326                    let backend = backend.clone();
327                    let channel_by_id = channel_by_id.clone();
328                    let channel_by_parties = channel_by_parties.clone();
329                    hopr_utils::runtime::prelude::spawn_blocking(move || {
330                        graph.write().add_edge(src.key_id, dst.key_id, *channel.get_id());
331                        backend
332                            .insert_channel(channel)
333                            .map(|old| (channel, old.map(|old| old.diff(&channel))))
334                    })
335                    .map_err(ConnectorError::backend)
336                    .and_then(move |res| {
337                        let channel_by_id = channel_by_id.clone();
338                        let channel_by_parties = channel_by_parties.clone();
339                        if let Ok((upserted_channel, _)) = &res {
340                            // Rather update the cached entry than invalidating it
341                            channel_by_id.insert(*upserted_channel.get_id(), Some(*upserted_channel));
342                            channel_by_parties.insert(ChannelParties::from(upserted_channel), Some(*upserted_channel));
343                        }
344                        futures::future::ready(res.map(SubscribedEventType::Channel).map_err(ConnectorError::backend))
345                    })
346                })
347                .fuse();
348
349            // Stream of ticket parameter updates (ticket price, minimum winning probability)
350            let ticket_params_stream = ticket_params_stream
351                .map_err(ConnectorError::from)
352                .inspect_ok(|entry| tracing::trace!(?entry, "new ticket params"))
353                .try_filter_map(|ticket_value_event| {
354                    futures::future::ready(model_to_ticket_params(ticket_value_event).map(Some))
355                })
356                .inspect_ok(|(new_ticket_price, new_win_prob)| {
357                    // This cannot block, because there are no other concurrent writers/upgradeable readers
358                    let tv = ticket_values.upgradable_read();
359                    if let Some((current_win_prob, current_ticket_price)) = tv.as_ref().copied() {
360                        if &current_ticket_price != new_ticket_price && !current_win_prob.approx_eq(new_win_prob) {
361                            parking_lot::RwLockUpgradableReadGuard::upgrade(tv)
362                                .replace((*new_win_prob, *new_ticket_price));
363                        } else if &current_ticket_price != new_ticket_price {
364                            parking_lot::RwLockUpgradableReadGuard::upgrade(tv)
365                                .replace((current_win_prob, *new_ticket_price));
366                        } else if !current_win_prob.approx_eq(new_win_prob) {
367                            parking_lot::RwLockUpgradableReadGuard::upgrade(tv)
368                                .replace((*new_win_prob, current_ticket_price));
369                        }
370                    }
371                })
372                .and_then(|(new_ticket_price, new_win_prob)| {
373                    let values_cache = values_cache.clone();
374                    async move {
375                        let mut events = Vec::<SubscribedEventType>::new();
376                        values_cache
377                            .entry(CHAIN_INFO_CACHE_KEY)
378                            .and_compute_with(|cached_entry| {
379                                futures::future::ready(match cached_entry {
380                                    Some(chain_info) => {
381                                        let mut chain_info = chain_info.into_value();
382                                        if chain_info.ticket_price != new_ticket_price {
383                                            events.push(SubscribedEventType::TicketPrice((
384                                                new_ticket_price,
385                                                Some(chain_info.ticket_price),
386                                            )));
387                                            chain_info.ticket_price = new_ticket_price;
388                                        }
389                                        if !chain_info.ticket_win_prob.approx_eq(&new_win_prob) {
390                                            events.push(SubscribedEventType::WinningProbability((
391                                                new_win_prob,
392                                                Some(chain_info.ticket_win_prob),
393                                            )));
394                                            chain_info.ticket_win_prob = new_win_prob;
395                                        }
396
397                                        if !events.is_empty() {
398                                            moka::ops::compute::Op::Put(chain_info)
399                                        } else {
400                                            moka::ops::compute::Op::Nop
401                                        }
402                                    }
403                                    None => {
404                                        tracing::warn!(
405                                            "chain info not present in the cache before ticket params update"
406                                        );
407                                        events.push(SubscribedEventType::TicketPrice((new_ticket_price, None)));
408                                        events.push(SubscribedEventType::WinningProbability((new_win_prob, None)));
409                                        moka::ops::compute::Op::Nop
410                                    }
411                                })
412                            })
413                            .await;
414                        Ok(futures::stream::iter(events).map(Ok::<_, ConnectorError>))
415                    }
416                })
417                .try_flatten()
418                .fuse();
419
420            let mut account_counter = 0;
421            let mut channel_counter = 0;
422            if min_accounts == 0 && min_channels == 0 {
423                tracing::info!(account_counter, channel_counter, time = ?sync_started.elapsed(), "on-chain graph has been synced");
424                let _ = connection_ready_tx.take().unwrap().send(Ok(()));
425            }
426
427            futures::stream::Abortable::new(
428                (account_stream, channel_stream, ticket_params_stream).merge(),
429                abort_reg,
430            )
431            .inspect_ok(move |event_type| {
432                if connection_ready_tx.is_some() {
433                    match event_type {
434                        SubscribedEventType::Account(_) => account_counter += 1,
435                        SubscribedEventType::Channel(_) => channel_counter += 1,
436                        _ => {}
437                    }
438
439                    let pct_synced =
440                        ((account_counter + channel_counter) * 100 / (min_accounts + min_channels)).clamp(0, 100);
441                    tracing::debug!(
442                        pct_synced,
443                        sync_quota,
444                        account_counter,
445                        channel_counter,
446                        "percentage of connection quota synced"
447                    );
448
449                    // Send the completion notification
450                    // once we reach the expected number of accounts and channels with
451                    // the given tolerance
452                    if account_counter >= min_accounts && channel_counter >= min_channels {
453                        tracing::info!(account_counter, channel_counter, time = ?sync_started.elapsed(), "on-chain graph has been synced");
454                        let _ = connection_ready_tx.take().unwrap().send(Ok(()));
455                    }
456                }
457            })
458            .for_each(|event_type| {
459                let event_tx = event_tx.clone();
460                async move {
461                    match event_type {
462                        Ok(SubscribedEventType::Account((new_account, old_account))) => {
463                            tracing::debug!(%new_account, "account inserted");
464                            // We only track public accounts as events and also
465                            // broadcast announcements of already existing accounts (old_account == None).
466                            if new_account.has_announced_with_routing_info()
467                                && old_account.is_none_or(|a| !a.has_announced_with_routing_info())
468                            {
469                                tracing::debug!(account = %new_account, "new announcement");
470                                let _ = event_tx
471                                    .broadcast_direct(ChainEvent::Announcement(new_account.clone()))
472                                    .await;
473                            }
474                        }
475                        Ok(SubscribedEventType::Channel((new_channel, Some(changes)))) => {
476                            tracing::debug!(
477                                id = %new_channel.get_id(),
478                                src = %new_channel.source, dst = %new_channel.destination,
479                                num_changes = changes.len(),
480                                "channel updated"
481                            );
482                            process_channel_changes_into_events(new_channel, changes, &me, &event_tx).await;
483                        }
484                        Ok(SubscribedEventType::Channel((new_channel, None))) => {
485                            tracing::debug!(
486                                id = %new_channel.get_id(),
487                                src = %new_channel.source, dst = %new_channel.destination,
488                                "channel opened"
489                            );
490                            let _ = event_tx.broadcast_direct(ChainEvent::ChannelOpened(new_channel)).await;
491                        }
492                        Ok(SubscribedEventType::WinningProbability((new, old))) => {
493                            let old = old.unwrap_or_default();
494                            match new.approx_cmp(&old) {
495                                Ordering::Less => {
496                                    tracing::debug!(%new, %old, "winning probability decreased");
497                                    let _ = event_tx
498                                        .broadcast_direct(ChainEvent::WinningProbabilityDecreased(new))
499                                        .await;
500                                }
501                                Ordering::Greater => {
502                                    tracing::debug!(%new, %old, "winning probability increased");
503                                    let _ = event_tx
504                                        .broadcast_direct(ChainEvent::WinningProbabilityIncreased(new))
505                                        .await;
506                                }
507                                Ordering::Equal => {}
508                            }
509                        }
510                        Ok(SubscribedEventType::TicketPrice((new, old))) => {
511                            tracing::debug!(%new, ?old, "ticket price changed");
512                            let _ = event_tx.broadcast_direct(ChainEvent::TicketPriceChanged(new)).await;
513                        }
514                        Err(error) => {
515                            tracing::error!(%error, "error processing account/graph/ticket params subscription");
516                        }
517                    }
518                }
519            })
520            .await;
521
522            // Only transition to SubscriptionEnded if currently Ready or Connecting —
523            // don't overwrite terminal error states (ServerNotHealthy, ConnectionFailed, etc.)
524            tracing::warn!("chain subscription stream ended, marking chain health as degraded");
525            let current = health.load(AtomicOrdering::Relaxed);
526            if matches!(current, ChainHealthState::Connecting | ChainHealthState::Ready) {
527                let _ = health.compare_exchange(
528                    current,
529                    ChainHealthState::SubscriptionEnded,
530                    AtomicOrdering::Relaxed,
531                    AtomicOrdering::Relaxed,
532                );
533            }
534        });
535
536        connection_ready_rx
537            .timeout(futures_time::time::Duration::from(timeout))
538            .map(|res| match res {
539                Ok(Ok(Ok(_))) => Ok(abort_handle),
540                Ok(Ok(Err(error))) => {
541                    abort_handle.abort();
542                    Err(ConnectorError::from(error))
543                }
544                Ok(Err(_)) => {
545                    abort_handle.abort();
546                    Err(ConnectorError::InvalidState("failed to determine connection state"))
547                }
548                Err(_) => {
549                    abort_handle.abort();
550                    tracing::error!(min_accounts, min_channels, "connection timeout when syncing");
551                    Err(ConnectorError::ConnectionTimeout)
552                }
553            })
554            .await
555    }
556
557    /// Connects to the chain using the underlying client, syncs all on-chain data,
558    /// and subscribes for all future updates.
559    ///
560    /// If the connection does not finish within
561    /// [`BlockchainConnectorConfig::connection_timeout`](BlockchainConnectorConfig)
562    /// the [`ConnectorError::ConnectionTimeout`] error is returned.
563    ///
564    /// Most of the operations with the Connector will fail if it is not connected first.
565    ///
566    /// There are some notable exceptions that DO NOT require a prior call to `connect`:
567    /// - all the [`ChainValues`](hopr_api::chain::ChainValues) methods,
568    /// - all the [`ChainReadSafeOperations`](hopr_api::chain::ChainReadSafeOperations) methods,
569    /// - all the [`ChainWriteSafeOperations`](hopr_api::chain::ChainWriteSafeOperations) methods,
570    /// - [`me`](hopr_api::chain::ChainReadChannelOperations::me)
571    ///
572    /// If you wish to only call operations from the above Chain APIs, consider constructing
573    /// the [`HoprBlockchainReader`](crate::HoprBlockchainReader) instead.
574    pub async fn connect(&mut self) -> Result<(), ConnectorError> {
575        if self
576            .connection_handle
577            .as_ref()
578            .filter(|handle| !handle.is_aborted())
579            .is_some()
580        {
581            return Err(ConnectorError::InvalidState("connector is already connected"));
582        }
583
584        self.health.store(ChainHealthState::Connecting, AtomicOrdering::Relaxed);
585
586        let abort_handle = match self
587            .do_connect(self.cfg.connection_sync_timeout.max(MIN_CONNECTION_TIMEOUT))
588            .await
589        {
590            Ok(handle) => handle,
591            Err(e @ ConnectorError::ServerNotHealthy) => {
592                self.health
593                    .store(ChainHealthState::ServerNotHealthy, AtomicOrdering::Relaxed);
594                return Err(e);
595            }
596            Err(e @ ConnectorError::ConnectionTimeout) => {
597                self.health
598                    .store(ChainHealthState::SyncTimedOut, AtomicOrdering::Relaxed);
599                return Err(e);
600            }
601            Err(e) => {
602                self.health
603                    .store(ChainHealthState::ConnectionFailed, AtomicOrdering::Relaxed);
604                return Err(e);
605            }
606        };
607
608        self.connection_handle = Some(abort_handle);
609        // Only transition to Ready if still Connecting — the subscription task
610        // may have already set SubscriptionEnded in a race.
611        let _ = self.health.compare_exchange(
612            ChainHealthState::Connecting,
613            ChainHealthState::Ready,
614            AtomicOrdering::Relaxed,
615            AtomicOrdering::Relaxed,
616        );
617
618        tracing::info!(node = %self.chain_key.public().to_address(), "connected to chain as node");
619        Ok(())
620    }
621
622    /// Returns the reference to the underlying client.
623    pub fn client(&self) -> &C {
624        self.client.as_ref()
625    }
626
627    /// Checks if the connector is [connected](HoprBlockchainConnector::connect) to the chain.
628    pub fn is_connected(&self) -> bool {
629        self.check_connection_state().is_ok()
630    }
631}
632
633impl<B, C, P> HoprBlockchainConnector<C, B, P, P::TxRequest>
634where
635    C: BlokliTransactionClient + BlokliQueryClient + Send + Sync + 'static,
636    P: PayloadGenerator + Send + Sync,
637    P::TxRequest: Send + Sync,
638{
639    async fn send_tx<'a>(
640        &'a self,
641        tx_req: P::TxRequest,
642        custom_tx_multiplier: Option<u32>,
643        custom_signer: Option<ChainKeypair>,
644    ) -> Result<impl Future<Output = Result<ChainReceipt, ConnectorError>> + Send + 'a, ConnectorError> {
645        let chain_info = self.query_cached_chain_info().await?;
646        let tx_timeout = custom_tx_multiplier.unwrap_or(self.cfg.tx_timeout_multiplier).max(1)
647            * chain_info.finality
648            * chain_info.expected_block_time;
649        Ok(self
650            .sequencer
651            .enqueue_transaction(tx_req, tx_timeout.max(MIN_TX_CONFIRM_TIMEOUT), custom_signer)
652            .await?
653            .and_then(|tx| {
654                if let Some(tx_exec) = tx.safe_execution
655                    && !tx_exec.success
656                {
657                    return futures::future::err(ConnectorError::InnerTxFailed(
658                        tx_exec.revert_reason.unwrap_or("n/a".into()),
659                    ));
660                }
661                futures::future::ready(
662                    ChainReceipt::from_str(&tx.transaction_hash.0)
663                        .map_err(|_| ConnectorError::TypeConversion("invalid tx hash".into())),
664                )
665            }))
666    }
667}
668
669impl<B, C, P, R> hopr_api::node::ComponentStatusReporter for HoprBlockchainConnector<C, B, P, R> {
670    fn component_status(&self) -> hopr_api::node::ComponentStatus {
671        self.health.load(AtomicOrdering::Relaxed).into()
672    }
673}
674
675impl<B, C, P, R> HoprBlockchainConnector<C, R, B, P> {
676    #[inline]
677    pub(crate) fn check_connection_state(&self) -> Result<(), ConnectorError> {
678        self.connection_handle
679            .as_ref()
680            .filter(|handle| !handle.is_aborted()) // Do a safety check
681            .ok_or_else(|| ConnectorError::InvalidState("connector is not connected"))
682            .map(|_| ())
683    }
684
685    /// Invalidates all cached on-chain data.
686    pub fn invalidate_caches(&self) {
687        self.channel_by_parties.invalidate_all();
688        self.channel_by_id.invalidate_all();
689        self.packet_to_chain.invalidate_all();
690        self.chain_to_packet.invalidate_all();
691        self.values.invalidate_all();
692    }
693}
694
695impl<B, C, P, R> Drop for HoprBlockchainConnector<C, R, B, P> {
696    fn drop(&mut self) {
697        self.health.store(ChainHealthState::Dropped, AtomicOrdering::Relaxed);
698        self.events.0.close();
699        if let Some(abort_handle) = self.connection_handle.take() {
700            abort_handle.abort();
701        }
702    }
703}
704
705impl<B, C, P, R> HoprBlockchainConnector<C, B, P, R>
706where
707    B: Backend + Send + Sync + 'static,
708    C: Send + Sync,
709    P: Send + Sync,
710    R: Send + Sync,
711{
712    /// Returns a [`PathAddressResolver`] using this connector.
713    pub fn as_path_resolver(&self) -> ChainPathResolver<'_, Self> {
714        self.into()
715    }
716}
717
718#[cfg(test)]
719pub(crate) mod tests {
720    use blokli_client::BlokliTestState;
721    use hex_literal::hex;
722    use hopr_api::{chain::ChainWriteTicketOperations, types::chain::contract_addresses_for_network};
723
724    use super::*;
725    use crate::{
726        InMemoryBackend,
727        testing::{BlokliTestStateBuilder, ChainMutator, FullStateEmulator},
728    };
729
730    pub const PRIVATE_KEY_1: [u8; 32] = hex!("c14b8faa0a9b8a5fa4453664996f23a7e7de606d42297d723fc4a794f375e260");
731    pub const PRIVATE_KEY_2: [u8; 32] = hex!("492057cf93e99b31d2a85bc5e98a9c3aa0021feec52c227cc8170e8f7d047775");
732    pub const MODULE_ADDR: [u8; 20] = hex!("1111111111111111111111111111111111111111");
733
734    pub type TestConnector<C> = HoprBlockchainConnector<
735        C,
736        InMemoryBackend,
737        SafePayloadGenerator,
738        <SafePayloadGenerator as PayloadGenerator>::TxRequest,
739    >;
740
741    pub fn create_connector<C>(blokli_client: C) -> anyhow::Result<TestConnector<C>>
742    where
743        C: BlokliQueryClient + BlokliTransactionClient + BlokliSubscriptionClient + Send + Sync + 'static,
744    {
745        let ckp = ChainKeypair::from_secret(&PRIVATE_KEY_1)?;
746
747        Ok(HoprBlockchainConnector::new(
748            ckp.clone(),
749            Default::default(),
750            blokli_client,
751            InMemoryBackend::default(),
752            SafePayloadGenerator::new(
753                &ckp,
754                contract_addresses_for_network("rotsee").unwrap().1,
755                MODULE_ADDR.into(),
756            ),
757        ))
758    }
759
760    #[tokio::test]
761    async fn connector_should_connect() -> anyhow::Result<()> {
762        let blokli_client = BlokliTestStateBuilder::default().build_static_client();
763
764        let mut connector = create_connector(blokli_client)?;
765        connector.connect().await?;
766
767        assert!(connector.is_connected());
768
769        Ok(())
770    }
771
772    #[tokio::test]
773    async fn connector_should_not_connect_when_blokli_not_healthy() -> anyhow::Result<()> {
774        let state = BlokliTestState {
775            health: "DOWN".into(),
776            ..Default::default()
777        };
778
779        let blokli_client = BlokliTestStateBuilder::from(state).build_static_client();
780
781        let mut connector = create_connector(blokli_client)?;
782
783        let res = connector.connect().await;
784
785        assert!(matches!(res, Err(ConnectorError::ServerNotHealthy)));
786        assert!(!connector.is_connected());
787
788        Ok(())
789    }
790
791    #[tokio::test]
792    async fn connector_should_handle_inner_tx_failure_during_redemption() -> anyhow::Result<()> {
793        let offchain_key_1 = OffchainKeypair::from_secret(&hex!(
794            "60741b83b99e36aa0c1331578156e16b8e21166d01834abb6c64b103f885734d"
795        ))?;
796        let account_1 = AccountEntry {
797            public_key: *offchain_key_1.public(),
798            chain_addr: ChainKeypair::from_secret(&PRIVATE_KEY_1)?.public().to_address(),
799            entry_type: AccountType::NotAnnounced,
800            safe_address: Some([1u8; Address::SIZE].into()),
801            key_id: 1.into(),
802        };
803        let offchain_key_2 = OffchainKeypair::from_secret(&hex!(
804            "71bf1f42ebbfcd89c3e197a3fd7cda79b92499e509b6fefa0fe44d02821d146a"
805        ))?;
806        let account_2 = AccountEntry {
807            public_key: *offchain_key_2.public(),
808            chain_addr: ChainKeypair::from_secret(&PRIVATE_KEY_2)?.public().to_address(),
809            entry_type: AccountType::NotAnnounced,
810            safe_address: Some([2u8; Address::SIZE].into()),
811            key_id: 2.into(),
812        };
813
814        let channel_1 = ChannelEntry::builder()
815            .between(
816                &ChainKeypair::from_secret(&PRIVATE_KEY_2)?,
817                &ChainKeypair::from_secret(&PRIVATE_KEY_1)?,
818            )
819            .amount(10)
820            .ticket_index(1)
821            .status(ChannelStatus::Open)
822            .epoch(1)
823            .build()?;
824
825        let blokli_client = BlokliTestStateBuilder::default()
826            .with_accounts([
827                (account_1, HoprBalance::new_base(100), XDaiBalance::new_base(1)),
828                (account_2, HoprBalance::new_base(100), XDaiBalance::new_base(1)),
829            ])
830            .with_channels([channel_1])
831            .with_hopr_network_chain_info("rotsee")
832            .build_dynamic_client_with_mutator(ChainMutator::new(
833                move |_: &[u8], state: &mut BlokliTestState| -> Result<(), blokli_client::errors::BlokliClientError> {
834                    // Update the channel ticket index, without the client noticing the change
835                    // This will cause the transaction to be rejected in the Emulator, and
836                    // not by the checks performed by the Connector before the redemption.
837                    if let Some(c) = state.get_channel_by_id_mut(&(*channel_1.get_id()).into()) {
838                        c.ticket_index = blokli_client::api::types::Uint64("2".into());
839                        Ok(())
840                    } else {
841                        Err(blokli_client::errors::ErrorKind::MockClientError(anyhow::anyhow!(
842                            "channel unexpectedly not found"
843                        ))
844                        .into())
845                    }
846                },
847                FullStateEmulator(MODULE_ADDR.into(), None),
848            ))
849            .with_tx_simulation_delay(Duration::from_millis(100))
850            .with_use_internal_txs(true);
851
852        let mut connector = create_connector(blokli_client.clone())?;
853        connector.connect().await?;
854
855        let hkc1 = ChainKeypair::from_secret(&hex!(
856            "e17fe86ce6e99f4806715b0c9412f8dad89334bf07f72d5834207a9d8f19d7f8"
857        ))?;
858        let hkc2 = ChainKeypair::from_secret(&hex!(
859            "492057cf93e99b31d2a85bc5e98a9c3aa0021feec52c227cc8170e8f7d047775"
860        ))?;
861
862        let ticket = TicketBuilder::default()
863            .counterparty(&ChainKeypair::from_secret(&PRIVATE_KEY_1)?)
864            .amount(1)
865            .index(1)
866            .channel_epoch(1)
867            .eth_challenge(
868                Challenge::from_hint_and_share(
869                    &HalfKeyChallenge::new(hkc1.public().as_ref()),
870                    &HalfKeyChallenge::new(hkc2.public().as_ref()),
871                )?
872                .to_ethereum_challenge(),
873            )
874            .build_signed(&ChainKeypair::from_secret(&PRIVATE_KEY_2)?, &Hash::default())?
875            .into_acknowledged(Response::from_half_keys(
876                &HalfKey::try_from(hkc1.secret().as_ref())?,
877                &HalfKey::try_from(hkc2.secret().as_ref())?,
878            )?)
879            .into_redeemable(&ChainKeypair::from_secret(&PRIVATE_KEY_1)?, &Hash::default())?;
880
881        let res = connector.redeem_ticket(ticket).await?;
882        let err = res.await;
883        assert!(matches!(err, Err(hopr_api::chain::TicketRedeemError::Rejected(_, _))));
884
885        Ok(())
886    }
887
888    #[test]
889    fn chain_health_all_variants_convert_to_component_status() {
890        use hopr_api::node::ComponentStatus;
891        let variants = [
892            ChainHealthState::Ready,
893            ChainHealthState::WaitingForConnection,
894            ChainHealthState::Connecting,
895            ChainHealthState::SubscriptionEnded,
896            ChainHealthState::SyncTimedOut,
897            ChainHealthState::ServerNotHealthy,
898            ChainHealthState::ConnectionFailed,
899            ChainHealthState::Dropped,
900        ];
901        for state in variants {
902            let _: ComponentStatus = state.into();
903        }
904    }
905
906    #[test]
907    fn chain_health_ready_maps_to_component_ready() {
908        use hopr_api::node::ComponentStatus;
909        let status: ComponentStatus = ChainHealthState::Ready.into();
910        assert!(status.is_ready());
911    }
912
913    #[test]
914    fn chain_health_degraded_states() {
915        use hopr_api::node::ComponentStatus;
916        let s: ComponentStatus = ChainHealthState::SubscriptionEnded.into();
917        assert!(s.is_degraded());
918        let s: ComponentStatus = ChainHealthState::SyncTimedOut.into();
919        assert!(s.is_degraded());
920    }
921
922    #[test]
923    fn chain_health_unavailable_states() {
924        use hopr_api::node::ComponentStatus;
925        let s: ComponentStatus = ChainHealthState::ServerNotHealthy.into();
926        assert!(s.is_unavailable());
927        let s: ComponentStatus = ChainHealthState::ConnectionFailed.into();
928        assert!(s.is_unavailable());
929        let s: ComponentStatus = ChainHealthState::Dropped.into();
930        assert!(s.is_unavailable());
931    }
932
933    #[test]
934    fn chain_health_initializing_states() {
935        use hopr_api::node::ComponentStatus;
936        let s: ComponentStatus = ChainHealthState::WaitingForConnection.into();
937        assert!(s.is_initializing());
938        let s: ComponentStatus = ChainHealthState::Connecting.into();
939        assert!(s.is_initializing());
940    }
941
942    #[tokio::test]
943    async fn connector_health_starts_as_initializing() -> anyhow::Result<()> {
944        use hopr_api::node::ComponentStatusReporter;
945        let blokli_client = BlokliTestStateBuilder::default().build_static_client();
946        let connector = create_connector(blokli_client)?;
947        assert!(connector.component_status().is_initializing());
948        Ok(())
949    }
950
951    #[tokio::test]
952    async fn connector_health_ready_after_connect() -> anyhow::Result<()> {
953        use hopr_api::node::ComponentStatusReporter;
954        let blokli_client = BlokliTestStateBuilder::default().build_static_client();
955        let mut connector = create_connector(blokli_client)?;
956        connector.connect().await?;
957        assert!(connector.component_status().is_ready());
958        Ok(())
959    }
960
961    #[tokio::test]
962    async fn connector_health_unavailable_when_server_not_healthy() -> anyhow::Result<()> {
963        use hopr_api::node::ComponentStatusReporter;
964        let state = BlokliTestState {
965            health: "DOWN".into(),
966            ..Default::default()
967        };
968        let blokli_client = BlokliTestStateBuilder::from(state).build_static_client();
969        let mut connector = create_connector(blokli_client)?;
970        let _ = connector.connect().await;
971        assert!(connector.component_status().is_unavailable());
972        Ok(())
973    }
974
975    #[test]
976    fn health_cas_ready_only_from_connecting() {
977        let health = AtomicChainHealthState::new(ChainHealthState::Connecting);
978        let result = health.compare_exchange(
979            ChainHealthState::Connecting,
980            ChainHealthState::Ready,
981            AtomicOrdering::Relaxed,
982            AtomicOrdering::Relaxed,
983        );
984        assert!(result.is_ok());
985        assert_eq!(health.load(AtomicOrdering::Relaxed), ChainHealthState::Ready);
986    }
987
988    #[test]
989    fn health_cas_ready_fails_from_subscription_ended() {
990        let health = AtomicChainHealthState::new(ChainHealthState::SubscriptionEnded);
991        let result = health.compare_exchange(
992            ChainHealthState::Connecting,
993            ChainHealthState::Ready,
994            AtomicOrdering::Relaxed,
995            AtomicOrdering::Relaxed,
996        );
997        assert!(result.is_err());
998        assert_eq!(
999            health.load(AtomicOrdering::Relaxed),
1000            ChainHealthState::SubscriptionEnded
1001        );
1002    }
1003
1004    #[test]
1005    fn health_subscription_ended_preserves_terminal_state() {
1006        let health = AtomicChainHealthState::new(ChainHealthState::ServerNotHealthy);
1007        let current = health.load(AtomicOrdering::Relaxed);
1008        // ServerNotHealthy is a terminal state — should NOT transition to SubscriptionEnded
1009        assert!(!matches!(
1010            current,
1011            ChainHealthState::Connecting | ChainHealthState::Ready
1012        ));
1013        // The conditional store would skip this
1014    }
1015
1016    #[test]
1017    fn health_subscription_ended_from_ready() {
1018        let health = AtomicChainHealthState::new(ChainHealthState::Ready);
1019        let current = health.load(AtomicOrdering::Relaxed);
1020        if matches!(current, ChainHealthState::Connecting | ChainHealthState::Ready) {
1021            let _ = health.compare_exchange(
1022                current,
1023                ChainHealthState::SubscriptionEnded,
1024                AtomicOrdering::Relaxed,
1025                AtomicOrdering::Relaxed,
1026            );
1027        }
1028        assert_eq!(
1029            health.load(AtomicOrdering::Relaxed),
1030            ChainHealthState::SubscriptionEnded
1031        );
1032    }
1033
1034    #[test]
1035    fn health_drop_overwrites_any_state() {
1036        for initial in [
1037            ChainHealthState::Ready,
1038            ChainHealthState::Connecting,
1039            ChainHealthState::SubscriptionEnded,
1040        ] {
1041            let health = AtomicChainHealthState::new(initial);
1042            health.store(ChainHealthState::Dropped, AtomicOrdering::Relaxed);
1043            assert_eq!(health.load(AtomicOrdering::Relaxed), ChainHealthState::Dropped);
1044        }
1045    }
1046}