1use std::{
24 fmt::{Debug, Display, Formatter},
25 sync::Arc,
26};
27
28use async_trait::async_trait;
29use dashmap::DashSet;
30use futures::StreamExt;
31use hopr_lib::{
32 ChannelChange, ChannelDirection, ChannelEntry, ChannelId, ChannelStatus, ChannelStatusDiscriminants, HoprBalance,
33 api::chain::{
34 ChainReadChannelOperations, ChainReadSafeOperations, ChainValues, ChainWriteChannelOperations, ChannelSelector,
35 SafeSelector,
36 },
37};
38use serde::{Deserialize, Serialize};
39use serde_with::{DisplayFromStr, serde_as};
40use tracing::{debug, info, warn};
41use validator::{Validate, ValidationError};
42
43use crate::{
44 Strategy,
45 errors::{StrategyError, StrategyError::CriteriaNotSatisfied},
46 strategy::SingularStrategy,
47};
48
49#[cfg(all(feature = "telemetry", not(test)))]
50lazy_static::lazy_static! {
51 static ref METRIC_COUNT_AUTO_FUNDINGS: hopr_metrics::SimpleCounter =
52 hopr_metrics::SimpleCounter::new("hopr_strategy_auto_funding_funding_count", "Count of initiated automatic fundings").unwrap();
53 static ref METRIC_COUNT_AUTO_FUNDING_FAILURES: hopr_metrics::SimpleCounter =
54 hopr_metrics::SimpleCounter::new("hopr_strategy_auto_funding_failure_count", "Count of failed automatic funding attempts").unwrap();
55}
56
57fn validate_funding_amount(amount: &HoprBalance) -> std::result::Result<(), ValidationError> {
59 if amount.is_zero() {
60 return Err(ValidationError::new("funding_amount must be greater than zero"));
61 }
62 Ok(())
63}
64
65#[serde_as]
67#[derive(Debug, Clone, Copy, PartialEq, Eq, smart_default::SmartDefault, Validate, Serialize, Deserialize)]
68pub struct AutoFundingStrategyConfig {
69 #[serde_as(as = "DisplayFromStr")]
73 #[default(HoprBalance::new_base(1))]
74 pub min_stake_threshold: HoprBalance,
75
76 #[serde_as(as = "DisplayFromStr")]
80 #[default(HoprBalance::new_base(10))]
81 #[validate(custom(function = "validate_funding_amount"))]
82 pub funding_amount: HoprBalance,
83}
84
85pub struct AutoFundingStrategy<A> {
91 hopr_chain_actions: Arc<A>,
92 cfg: AutoFundingStrategyConfig,
93 in_flight: Arc<DashSet<ChannelId>>,
98}
99
100impl<
101 A: ChainReadChannelOperations
102 + ChainReadSafeOperations
103 + ChainValues
104 + ChainWriteChannelOperations
105 + Send
106 + Sync
107 + 'static,
108> AutoFundingStrategy<A>
109{
110 pub fn new(cfg: AutoFundingStrategyConfig, hopr_chain_actions: A) -> Self {
111 if cfg.funding_amount.le(&cfg.min_stake_threshold) {
112 warn!(
113 funding_amount = %cfg.funding_amount,
114 min_stake_threshold = %cfg.min_stake_threshold,
115 "funding_amount is not greater than min_stake_threshold; \
116 successful funding may re-trigger the threshold check"
117 );
118 }
119 Self {
120 cfg,
121 hopr_chain_actions: Arc::new(hopr_chain_actions),
122 in_flight: Arc::new(DashSet::new()),
123 }
124 }
125
126 async fn try_fund_channel(&self, channel: &ChannelEntry) -> crate::errors::Result<bool> {
132 let channel_id = *channel.get_id();
133
134 if !self.in_flight.insert(channel_id) {
136 debug!(%channel, "skipping channel with in-flight funding");
137 return Ok(false);
138 }
139
140 info!(
141 %channel,
142 balance = %channel.balance,
143 threshold = %self.cfg.min_stake_threshold,
144 "stake on channel at or below threshold"
145 );
146
147 let chain_actions = Arc::clone(&self.hopr_chain_actions);
148 let funding_amount = self.cfg.funding_amount;
149 let in_flight = Arc::clone(&self.in_flight);
150
151 hopr_async_runtime::prelude::spawn(async move {
152 match chain_actions.fund_channel(&channel_id, funding_amount).await {
153 Ok(confirmation) => {
154 #[cfg(all(feature = "telemetry", not(test)))]
155 METRIC_COUNT_AUTO_FUNDINGS.increment();
156
157 info!(%channel_id, %funding_amount, "issued re-staking of channel");
158
159 if let Err(e) = confirmation.await {
160 warn!(%channel_id, error = %e, "funding transaction failed");
161 in_flight.remove(&channel_id);
162
163 #[cfg(all(feature = "telemetry", not(test)))]
164 METRIC_COUNT_AUTO_FUNDING_FAILURES.increment();
165 }
166 }
169 Err(e) => {
170 warn!(%channel_id, error = %e, "failed to enqueue funding transaction");
171 in_flight.remove(&channel_id);
172
173 #[cfg(all(feature = "telemetry", not(test)))]
174 METRIC_COUNT_AUTO_FUNDING_FAILURES.increment();
175 }
176 }
177 });
178
179 Ok(true)
180 }
181
182 async fn safe_balance_budget(&self) -> crate::errors::Result<HoprBalance> {
183 let me = *self.hopr_chain_actions.me();
184 let safe = self
185 .hopr_chain_actions
186 .safe_info(SafeSelector::Owner(me))
187 .await
188 .map_err(|e| StrategyError::Other(e.into()))?;
189
190 let Some(safe) = safe else {
191 warn!(%me, "auto-funding on_tick skipped: safe is not registered. Should never happen.");
192 return Ok(HoprBalance::zero());
193 };
194
195 let safe_balance: HoprBalance = self
196 .hopr_chain_actions
197 .balance(safe.address)
198 .await
199 .map_err(|e| StrategyError::Other(e.into()))?;
200
201 Ok(safe_balance)
202 }
203}
204
205impl<A> Debug for AutoFundingStrategy<A> {
206 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
207 write!(f, "{:?}", Strategy::AutoFunding(self.cfg))
208 }
209}
210
211impl<A> Display for AutoFundingStrategy<A> {
212 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
213 write!(f, "{}", Strategy::AutoFunding(self.cfg))
214 }
215}
216
217#[async_trait]
218impl<
219 A: ChainReadChannelOperations
220 + ChainReadSafeOperations
221 + ChainValues
222 + ChainWriteChannelOperations
223 + Send
224 + Sync
225 + 'static,
226> SingularStrategy for AutoFundingStrategy<A>
227{
228 async fn on_tick(&self) -> crate::errors::Result<()> {
237 let mut safe_balance_budget = self.safe_balance_budget().await?;
238 if safe_balance_budget < self.cfg.funding_amount {
239 debug!(
240 %safe_balance_budget,
241 funding_amount = %self.cfg.funding_amount,
242 "auto-funding on_tick skipped: safe balance below funding amount"
243 );
244 return Ok(());
245 }
246
247 let mut channels = self
248 .hopr_chain_actions
249 .stream_channels(
250 ChannelSelector::default()
251 .with_source(*self.hopr_chain_actions.me())
252 .with_allowed_states(&[ChannelStatusDiscriminants::Open]),
253 )
254 .map_err(|e| StrategyError::Other(e.into()))?;
255
256 while let Some(channel) = channels.next().await {
257 if channel.balance.le(&self.cfg.min_stake_threshold) {
258 if safe_balance_budget < self.cfg.funding_amount {
259 break;
260 }
261
262 match self.try_fund_channel(&channel).await {
263 Ok(true) => safe_balance_budget -= self.cfg.funding_amount,
264 Ok(false) => {}
265 Err(e) => warn!(%channel, error = %e, "on_tick: failed to fund channel"),
266 }
267 } else {
268 self.in_flight.remove(channel.get_id());
270 }
271 }
272
273 debug!("auto-funding on_tick scan complete");
274 Ok(())
275 }
276
277 async fn on_own_channel_changed(
278 &self,
279 channel: &ChannelEntry,
280 direction: ChannelDirection,
281 change: ChannelChange,
282 ) -> crate::errors::Result<()> {
283 if direction != ChannelDirection::Outgoing {
285 return Ok(());
286 }
287
288 if let ChannelChange::Balance { left: old, right: new } = change {
289 if new > old && self.in_flight.remove(channel.get_id()).is_some() {
291 debug!(%channel, "cleared in-flight funding state after balance increase");
292 }
293
294 if new.le(&self.cfg.min_stake_threshold) && channel.status == ChannelStatus::Open {
295 self.try_fund_channel(channel).await?;
296 }
297 Ok(())
298 } else {
299 Err(CriteriaNotSatisfied)
300 }
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use std::str::FromStr;
307
308 use futures::StreamExt;
309 use futures_time::future::FutureExt;
310 use hex_literal::hex;
311 use hopr_chain_connector::{create_trustful_hopr_blokli_connector, testing::BlokliTestStateBuilder};
312 use hopr_lib::{
313 Address, BytesRepresentable, ChainKeypair, Keypair, XDaiBalance,
314 api::chain::{ChainEvent, ChainEvents},
315 };
316
317 use super::*;
318 use crate::{
319 auto_funding::{AutoFundingStrategy, AutoFundingStrategyConfig},
320 strategy::SingularStrategy,
321 };
322
323 lazy_static::lazy_static! {
324 static ref BOB_KP: ChainKeypair = ChainKeypair::from_secret(&hex!(
325 "492057cf93e99b31d2a85bc5e98a9c3aa0021feec52c227cc8170e8f7d047775"
326 ))
327 .expect("lazy static keypair should be valid");
328
329 static ref ALICE: Address = hex!("18f8ae833c85c51fbeba29cef9fbfb53b3bad950").into();
330 static ref BOB: Address = BOB_KP.public().to_address();
331 static ref CHRIS: Address = hex!("b6021e0860dd9d96c9ff0a73e2e5ba3a466ba234").into();
332 static ref DAVE: Address = hex!("68499f50ff68d523385dc60686069935d17d762a").into();
333 }
334
335 #[test_log::test(tokio::test)]
336 async fn test_auto_funding_strategy() -> anyhow::Result<()> {
337 let stake_limit = HoprBalance::from(7_u32);
338 let fund_amount = HoprBalance::from(5_u32);
339
340 let c1 = ChannelEntry::builder()
341 .between(*ALICE, *BOB)
342 .amount(10_u32)
343 .ticket_index(0)
344 .status(ChannelStatus::Open)
345 .epoch(0)
346 .build()?;
347
348 let c2 = ChannelEntry::builder()
349 .between(*BOB, *CHRIS)
350 .amount(5_u32)
351 .ticket_index(0_u32.into())
352 .status(ChannelStatus::Open)
353 .epoch(0)
354 .build()?;
355
356 let c3 = ChannelEntry::builder()
357 .between(*CHRIS, *DAVE)
358 .amount(5)
359 .ticket_index(0)
360 .status(ChannelStatus::PendingToClose(
361 chrono::DateTime::<chrono::Utc>::from_str("2025-11-10T00:00:00+00:00")?.into(),
362 ))
363 .epoch(0)
364 .build()?;
365
366 let blokli_sim = BlokliTestStateBuilder::default()
367 .with_generated_accounts(
368 &[&*ALICE, &*BOB, &*CHRIS, &*DAVE],
369 false,
370 XDaiBalance::new_base(1),
371 HoprBalance::new_base(1000),
372 )
373 .with_channels([c1, c2, c3])
374 .build_dynamic_client([1; Address::SIZE].into());
375
376 let snapshot = blokli_sim.snapshot();
377
378 let mut chain_connector =
379 create_trustful_hopr_blokli_connector(&BOB_KP, Default::default(), blokli_sim, [1; Address::SIZE].into())
380 .await?;
381 chain_connector.connect().await?;
382 let events = chain_connector.subscribe()?;
383
384 let cfg = AutoFundingStrategyConfig {
385 min_stake_threshold: stake_limit,
386 funding_amount: fund_amount,
387 };
388
389 let afs = AutoFundingStrategy::new(cfg, chain_connector);
390 afs.on_own_channel_changed(
391 &c1,
392 ChannelDirection::Outgoing,
393 ChannelChange::Balance {
394 left: HoprBalance::zero(),
395 right: c1.balance,
396 },
397 )
398 .await?;
399
400 afs.on_own_channel_changed(
401 &c2,
402 ChannelDirection::Outgoing,
403 ChannelChange::Balance {
404 left: HoprBalance::zero(),
405 right: c2.balance,
406 },
407 )
408 .await?;
409
410 afs.on_own_channel_changed(
411 &c3,
412 ChannelDirection::Outgoing,
413 ChannelChange::Balance {
414 left: HoprBalance::zero(),
415 right: c3.balance,
416 },
417 )
418 .await?;
419
420 events
421 .filter(|event| futures::future::ready(matches!(event, ChainEvent::ChannelBalanceIncreased(c, amount) if c.get_id() == c2.get_id() && amount == &fund_amount)))
422 .next()
423 .timeout(futures_time::time::Duration::from_secs(2))
424 .await?;
425
426 insta::assert_yaml_snapshot!(*snapshot.refresh());
427
428 Ok(())
429 }
430
431 #[test]
432 fn test_config_validation_rejects_zero_funding_amount() {
433 let cfg = AutoFundingStrategyConfig {
434 min_stake_threshold: HoprBalance::new_base(1),
435 funding_amount: HoprBalance::zero(),
436 };
437 assert!(
438 cfg.validate().is_err(),
439 "config with zero funding_amount should fail validation"
440 );
441 }
442
443 #[test]
444 fn test_config_validation_accepts_valid_config() {
445 let cfg = AutoFundingStrategyConfig {
446 min_stake_threshold: HoprBalance::new_base(1),
447 funding_amount: HoprBalance::new_base(10),
448 };
449 assert!(
450 cfg.validate().is_ok(),
451 "config with valid funding_amount should pass validation"
452 );
453 }
454
455 #[test]
456 fn test_default_config_passes_validation() {
457 let cfg = AutoFundingStrategyConfig::default();
458 assert!(cfg.validate().is_ok(), "default config should pass validation");
459 }
460
461 #[test_log::test(tokio::test)]
462 async fn test_on_tick_funds_underfunded_channels() -> anyhow::Result<()> {
463 let stake_limit = HoprBalance::from(7_u32);
464 let fund_amount = HoprBalance::from(5_u32);
465
466 let c1 = ChannelEntry::builder()
468 .between(*BOB, *CHRIS)
469 .amount(3)
470 .ticket_index(0)
471 .status(ChannelStatus::Open)
472 .epoch(0_u32)
473 .build()?;
474
475 let c2 = ChannelEntry::builder()
477 .between(*BOB, *DAVE)
478 .amount(10)
479 .ticket_index(0)
480 .status(ChannelStatus::Open)
481 .epoch(0_u32)
482 .build()?;
483 let blokli_sim = BlokliTestStateBuilder::default()
484 .with_generated_accounts(
485 &[&*ALICE, &*BOB, &*CHRIS, &*DAVE],
486 false,
487 XDaiBalance::new_base(1),
488 HoprBalance::new_base(1000),
489 )
490 .with_channels([c1, c2])
491 .build_dynamic_client([1; Address::SIZE].into());
492
493 let mut chain_connector =
494 create_trustful_hopr_blokli_connector(&BOB_KP, Default::default(), blokli_sim, [1; Address::SIZE].into())
495 .await?;
496 chain_connector.connect().await?;
497 let events = chain_connector.subscribe()?;
498
499 let cfg = AutoFundingStrategyConfig {
500 min_stake_threshold: stake_limit,
501 funding_amount: fund_amount,
502 };
503
504 let afs = AutoFundingStrategy::new(cfg, chain_connector);
505
506 afs.on_tick().await?;
508
509 events
511 .filter(|event| {
512 futures::future::ready(
513 matches!(event, ChainEvent::ChannelBalanceIncreased(c, amount) if c.get_id() == c1.get_id() && amount == &fund_amount),
514 )
515 })
516 .next()
517 .timeout(futures_time::time::Duration::from_secs(2))
518 .await?;
519
520 Ok(())
521 }
522
523 #[test_log::test(tokio::test)]
524 async fn test_on_tick_skips_when_safe_balance_below_funding_amount() -> anyhow::Result<()> {
525 let stake_limit = HoprBalance::from(7_u32);
526 let fund_amount = HoprBalance::from(5_u32);
527
528 let c1 = ChannelEntry::builder()
529 .between(*BOB, *CHRIS)
530 .amount(3)
531 .ticket_index(0)
532 .status(ChannelStatus::Open)
533 .epoch(0)
534 .build()?;
535
536 let blokli_sim = BlokliTestStateBuilder::default()
537 .with_generated_accounts(
538 &[&*ALICE, &*BOB, &*CHRIS, &*DAVE],
539 false,
540 XDaiBalance::new_base(1),
541 HoprBalance::from(1_u32),
542 )
543 .with_channels([c1])
544 .build_dynamic_client([1; Address::SIZE].into());
545
546 let mut chain_connector =
547 create_trustful_hopr_blokli_connector(&BOB_KP, Default::default(), blokli_sim, [1; Address::SIZE].into())
548 .await?;
549 chain_connector.connect().await?;
550 let events = chain_connector.subscribe()?;
551
552 let cfg = AutoFundingStrategyConfig {
553 min_stake_threshold: stake_limit,
554 funding_amount: fund_amount,
555 };
556
557 let afs = AutoFundingStrategy::new(cfg, chain_connector);
558
559 afs.on_tick().await?;
560
561 let no_funding_event = events
562 .filter(|event| {
563 futures::future::ready(
564 matches!(event, ChainEvent::ChannelBalanceIncreased(c, amount) if c.get_id() == c1.get_id() && amount == &fund_amount),
565 )
566 })
567 .next()
568 .timeout(futures_time::time::Duration::from_secs(1))
569 .await;
570
571 assert!(
572 no_funding_event.is_err(),
573 "on_tick should skip funding when safe balance is below funding_amount"
574 );
575
576 Ok(())
577 }
578
579 #[test_log::test(tokio::test)]
580 async fn test_on_tick_funds_only_channels_affordable_by_safe_balance() -> anyhow::Result<()> {
581 let stake_limit = HoprBalance::from(7_u32);
582 let fund_amount = HoprBalance::from(5_u32);
583
584 let c1 = ChannelEntry::builder()
585 .between(*BOB, *CHRIS)
586 .amount(3)
587 .ticket_index(0)
588 .status(ChannelStatus::Open)
589 .epoch(0_u32)
590 .build()?;
591 let c2 = ChannelEntry::builder()
592 .between(*BOB, *DAVE)
593 .amount(2)
594 .ticket_index(0)
595 .status(ChannelStatus::Open)
596 .epoch(0_u32)
597 .build()?;
598 let c3 = ChannelEntry::builder()
599 .between(*BOB, *ALICE)
600 .amount(1)
601 .ticket_index(0)
602 .status(ChannelStatus::Open)
603 .epoch(0_u32)
604 .build()?;
605 let tracked_channels = [*c1.get_id(), *c2.get_id(), *c3.get_id()];
606
607 let blokli_sim = BlokliTestStateBuilder::default()
608 .with_generated_accounts(
609 &[&*ALICE, &*BOB, &*CHRIS, &*DAVE],
610 false,
611 XDaiBalance::new_base(1),
612 HoprBalance::from(11_u32),
613 )
614 .with_channels([c1, c2, c3])
615 .build_dynamic_client([1; Address::SIZE].into());
616
617 let mut chain_connector =
618 create_trustful_hopr_blokli_connector(&BOB_KP, Default::default(), blokli_sim, [1; Address::SIZE].into())
619 .await?;
620 chain_connector.connect().await?;
621 let events = chain_connector.subscribe()?;
622
623 let cfg = AutoFundingStrategyConfig {
624 min_stake_threshold: stake_limit,
625 funding_amount: fund_amount,
626 };
627
628 let afs = AutoFundingStrategy::new(cfg, chain_connector);
629
630 afs.on_tick().await?;
631
632 let mut funding_events = events.filter(|event| {
633 futures::future::ready(matches!(
634 event,
635 ChainEvent::ChannelBalanceIncreased(c, amount)
636 if tracked_channels.contains(c.get_id()) && amount == &fund_amount
637 ))
638 });
639
640 let first_two = funding_events
641 .by_ref()
642 .take(2)
643 .collect::<Vec<_>>()
644 .timeout(futures_time::time::Duration::from_secs(2))
645 .await?;
646 assert_eq!(
647 first_two.len(),
648 2,
649 "on_tick should fund exactly two channels with safe balance budget of 11 and funding amount 5"
650 );
651
652 let third = funding_events
653 .next()
654 .timeout(futures_time::time::Duration::from_secs(1))
655 .await;
656 assert!(
657 third.is_err(),
658 "on_tick should not fund a third channel once safe budget is depleted"
659 );
660
661 Ok(())
662 }
663
664 #[test_log::test(tokio::test)]
665 async fn test_in_flight_prevents_duplicate_funding() -> anyhow::Result<()> {
666 let stake_limit = HoprBalance::from(7_u32);
667 let fund_amount = HoprBalance::from(5_u32);
668
669 let c1 = ChannelEntry::builder()
670 .between(*BOB, *CHRIS)
671 .amount(3)
672 .ticket_index(0)
673 .status(ChannelStatus::Open)
674 .epoch(0_u32)
675 .build()?;
676
677 let blokli_sim = BlokliTestStateBuilder::default()
678 .with_generated_accounts(
679 &[&*ALICE, &*BOB, &*CHRIS, &*DAVE],
680 false,
681 XDaiBalance::new_base(1),
682 HoprBalance::new_base(1000),
683 )
684 .with_channels([c1])
685 .build_dynamic_client([1; Address::SIZE].into());
686
687 let mut chain_connector =
688 create_trustful_hopr_blokli_connector(&BOB_KP, Default::default(), blokli_sim, [1; Address::SIZE].into())
689 .await?;
690 chain_connector.connect().await?;
691 let _events = chain_connector.subscribe()?;
692
693 let cfg = AutoFundingStrategyConfig {
694 min_stake_threshold: stake_limit,
695 funding_amount: fund_amount,
696 };
697
698 let afs = AutoFundingStrategy::new(cfg, chain_connector);
699
700 afs.on_own_channel_changed(
702 &c1,
703 ChannelDirection::Outgoing,
704 ChannelChange::Balance {
705 left: HoprBalance::from(10_u32),
706 right: c1.balance,
707 },
708 )
709 .await?;
710
711 assert!(
713 afs.in_flight.contains(c1.get_id()),
714 "channel should be in the in-flight set after funding"
715 );
716
717 afs.on_own_channel_changed(
720 &c1,
721 ChannelDirection::Outgoing,
722 ChannelChange::Balance {
723 left: HoprBalance::from(10_u32),
724 right: c1.balance,
725 },
726 )
727 .await?;
728
729 assert_eq!(
731 afs.in_flight.len(),
732 1,
733 "in-flight set should still have exactly one entry"
734 );
735 assert!(
736 afs.in_flight.contains(c1.get_id()),
737 "channel should still be in the in-flight set"
738 );
739
740 Ok(())
741 }
742
743 #[test_log::test(tokio::test)]
744 async fn test_balance_increase_clears_in_flight() -> anyhow::Result<()> {
745 let stake_limit = HoprBalance::from(7_u32);
746 let fund_amount = HoprBalance::from(5_u32);
747
748 let c1 = ChannelEntry::builder()
749 .between(*BOB, *CHRIS)
750 .amount(3)
751 .ticket_index(0)
752 .status(ChannelStatus::Open)
753 .epoch(0_u32)
754 .build()?;
755
756 let blokli_sim = BlokliTestStateBuilder::default()
757 .with_generated_accounts(
758 &[&*ALICE, &*BOB, &*CHRIS, &*DAVE],
759 false,
760 XDaiBalance::new_base(1),
761 HoprBalance::new_base(1000),
762 )
763 .with_channels([c1])
764 .build_dynamic_client([1; Address::SIZE].into());
765
766 let mut chain_connector =
767 create_trustful_hopr_blokli_connector(&BOB_KP, Default::default(), blokli_sim, [1; Address::SIZE].into())
768 .await?;
769 chain_connector.connect().await?;
770 let _events = chain_connector.subscribe()?;
771
772 let cfg = AutoFundingStrategyConfig {
773 min_stake_threshold: stake_limit,
774 funding_amount: fund_amount,
775 };
776
777 let afs = AutoFundingStrategy::new(cfg, chain_connector);
778
779 afs.on_own_channel_changed(
781 &c1,
782 ChannelDirection::Outgoing,
783 ChannelChange::Balance {
784 left: HoprBalance::from(10_u32),
785 right: c1.balance,
786 },
787 )
788 .await?;
789
790 assert!(afs.in_flight.contains(c1.get_id()));
792
793 let funded_channel = ChannelEntry::builder()
795 .between(*BOB, *CHRIS)
796 .amount(3 + 5)
797 .ticket_index(0)
798 .status(ChannelStatus::Open)
799 .epoch(0)
800 .build()?;
801
802 afs.on_own_channel_changed(
803 &funded_channel,
804 ChannelDirection::Outgoing,
805 ChannelChange::Balance {
806 left: HoprBalance::from(3),
807 right: HoprBalance::from(8),
808 },
809 )
810 .await?;
811
812 assert!(
814 !afs.in_flight.contains(c1.get_id()),
815 "channel should be cleared from in-flight after balance increase"
816 );
817
818 Ok(())
819 }
820
821 #[test_log::test(tokio::test)]
822 async fn test_on_tick_skips_in_flight_channels() -> anyhow::Result<()> {
823 let stake_limit = HoprBalance::from(7_u32);
824 let fund_amount = HoprBalance::from(5_u32);
825
826 let c1 = ChannelEntry::builder()
828 .between(*BOB, *CHRIS)
829 .amount(3)
830 .ticket_index(0)
831 .status(ChannelStatus::Open)
832 .epoch(0)
833 .build()?;
834
835 let blokli_sim = BlokliTestStateBuilder::default()
836 .with_generated_accounts(
837 &[&*ALICE, &*BOB, &*CHRIS, &*DAVE],
838 false,
839 XDaiBalance::new_base(1),
840 HoprBalance::new_base(1000),
841 )
842 .with_channels([c1])
843 .build_dynamic_client([1; Address::SIZE].into());
844
845 let mut chain_connector =
846 create_trustful_hopr_blokli_connector(&BOB_KP, Default::default(), blokli_sim, [1; Address::SIZE].into())
847 .await?;
848 chain_connector.connect().await?;
849 let _events = chain_connector.subscribe()?;
850
851 let cfg = AutoFundingStrategyConfig {
852 min_stake_threshold: stake_limit,
853 funding_amount: fund_amount,
854 };
855
856 let afs = AutoFundingStrategy::new(cfg, chain_connector);
857
858 afs.on_own_channel_changed(
860 &c1,
861 ChannelDirection::Outgoing,
862 ChannelChange::Balance {
863 left: HoprBalance::from(10_u32),
864 right: c1.balance,
865 },
866 )
867 .await?;
868
869 assert!(
871 afs.in_flight.contains(c1.get_id()),
872 "channel should be in the in-flight set after funding"
873 );
874
875 afs.on_tick().await?;
877
878 assert_eq!(
880 afs.in_flight.len(),
881 1,
882 "in-flight set should still have exactly one entry"
883 );
884 assert!(
885 afs.in_flight.contains(c1.get_id()),
886 "channel should still be in the in-flight set"
887 );
888
889 Ok(())
890 }
891}