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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq, smart_default::SmartDefault)]
78pub struct BlockchainConnectorConfig {
79 #[default(Duration::from_secs(30))]
83 pub connection_sync_timeout: Duration,
84 #[default(DEFAULT_SYNC_TOLERANCE_PCT)]
90 pub sync_tolerance: usize,
91 #[default(TX_TIMEOUT_MULTIPLIER)]
96 pub tx_timeout_multiplier: u32,
97}
98
99pub 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 mapper: HoprKeyMapper<B>,
117 chain_to_packet: moka::sync::Cache<Address, Option<OffchainPublicKey>, ahash::RandomState>,
119 packet_to_chain: moka::sync::Cache<OffchainPublicKey, Option<Address>, ahash::RandomState>,
121 channel_by_id: moka::sync::Cache<ChannelId, Option<ChannelEntry>, ahash::RandomState>,
123 channel_by_parties: moka::sync::Cache<ChannelParties, Option<ChannelEntry>, ahash::RandomState>,
125 values: moka::future::Cache<u32, ParsedChainInfo>,
127 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 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 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 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 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 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 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 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 let tv = ticket_values.upgradable_read();
359 if let Some((current_win_prob, current_ticket_price)) = tv.as_ref().copied() {
360 if ¤t_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 ¤t_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 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 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 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 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 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 pub fn client(&self) -> &C {
624 self.client.as_ref()
625 }
626
627 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()) .ok_or_else(|| ConnectorError::InvalidState("connector is not connected"))
682 .map(|_| ())
683 }
684
685 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 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 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 assert!(!matches!(
1010 current,
1011 ChainHealthState::Connecting | ChainHealthState::Ready
1012 ));
1013 }
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}