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 .await
255 .map_err(|e| StrategyError::Other(e.into()))?;
256
257 while let Some(channel) = channels.next().await {
258 if channel.balance.le(&self.cfg.min_stake_threshold) {
259 if safe_balance_budget < self.cfg.funding_amount {
260 break;
261 }
262
263 match self.try_fund_channel(&channel).await {
264 Ok(true) => safe_balance_budget -= self.cfg.funding_amount,
265 Ok(false) => {}
266 Err(e) => warn!(%channel, error = %e, "on_tick: failed to fund channel"),
267 }
268 } else {
269 self.in_flight.remove(channel.get_id());
271 }
272 }
273
274 debug!("auto-funding on_tick scan complete");
275 Ok(())
276 }
277
278 async fn on_own_channel_changed(
279 &self,
280 channel: &ChannelEntry,
281 direction: ChannelDirection,
282 change: ChannelChange,
283 ) -> crate::errors::Result<()> {
284 if direction != ChannelDirection::Outgoing {
286 return Ok(());
287 }
288
289 if let ChannelChange::Balance { left: old, right: new } = change {
290 if new > old && self.in_flight.remove(channel.get_id()).is_some() {
292 debug!(%channel, "cleared in-flight funding state after balance increase");
293 }
294
295 if new.le(&self.cfg.min_stake_threshold) && channel.status == ChannelStatus::Open {
296 self.try_fund_channel(channel).await?;
297 }
298 Ok(())
299 } else {
300 Err(CriteriaNotSatisfied)
301 }
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use std::str::FromStr;
308
309 use futures::StreamExt;
310 use futures_time::future::FutureExt;
311 use hex_literal::hex;
312 use hopr_chain_connector::{create_trustful_hopr_blokli_connector, testing::BlokliTestStateBuilder};
313 use hopr_lib::{
314 Address, BytesRepresentable, ChainKeypair, Keypair, XDaiBalance,
315 api::chain::{ChainEvent, ChainEvents},
316 };
317
318 use super::*;
319 use crate::{
320 auto_funding::{AutoFundingStrategy, AutoFundingStrategyConfig},
321 strategy::SingularStrategy,
322 };
323
324 lazy_static::lazy_static! {
325 static ref BOB_KP: ChainKeypair = ChainKeypair::from_secret(&hex!(
326 "492057cf93e99b31d2a85bc5e98a9c3aa0021feec52c227cc8170e8f7d047775"
327 ))
328 .expect("lazy static keypair should be valid");
329
330 static ref ALICE: Address = hex!("18f8ae833c85c51fbeba29cef9fbfb53b3bad950").into();
331 static ref BOB: Address = BOB_KP.public().to_address();
332 static ref CHRIS: Address = hex!("b6021e0860dd9d96c9ff0a73e2e5ba3a466ba234").into();
333 static ref DAVE: Address = hex!("68499f50ff68d523385dc60686069935d17d762a").into();
334 }
335
336 #[test_log::test(tokio::test)]
337 async fn test_auto_funding_strategy() -> anyhow::Result<()> {
338 let stake_limit = HoprBalance::from(7_u32);
339 let fund_amount = HoprBalance::from(5_u32);
340
341 let c1 = ChannelEntry::builder()
342 .between(*ALICE, *BOB)
343 .amount(10_u32)
344 .ticket_index(0)
345 .status(ChannelStatus::Open)
346 .epoch(0)
347 .build()?;
348
349 let c2 = ChannelEntry::builder()
350 .between(*BOB, *CHRIS)
351 .amount(5_u32)
352 .ticket_index(0_u32.into())
353 .status(ChannelStatus::Open)
354 .epoch(0)
355 .build()?;
356
357 let c3 = ChannelEntry::builder()
358 .between(*CHRIS, *DAVE)
359 .amount(5)
360 .ticket_index(0)
361 .status(ChannelStatus::PendingToClose(
362 chrono::DateTime::<chrono::Utc>::from_str("2025-11-10T00:00:00+00:00")?.into(),
363 ))
364 .epoch(0)
365 .build()?;
366
367 let blokli_sim = BlokliTestStateBuilder::default()
368 .with_generated_accounts(
369 &[&*ALICE, &*BOB, &*CHRIS, &*DAVE],
370 false,
371 XDaiBalance::new_base(1),
372 HoprBalance::new_base(1000),
373 )
374 .with_channels([c1, c2, c3])
375 .build_dynamic_client([1; Address::SIZE].into());
376
377 let snapshot = blokli_sim.snapshot();
378
379 let mut chain_connector =
380 create_trustful_hopr_blokli_connector(&BOB_KP, Default::default(), blokli_sim, [1; Address::SIZE].into())
381 .await?;
382 chain_connector.connect().await?;
383 let events = chain_connector.subscribe()?;
384
385 let cfg = AutoFundingStrategyConfig {
386 min_stake_threshold: stake_limit,
387 funding_amount: fund_amount,
388 };
389
390 let afs = AutoFundingStrategy::new(cfg, chain_connector);
391 afs.on_own_channel_changed(
392 &c1,
393 ChannelDirection::Outgoing,
394 ChannelChange::Balance {
395 left: HoprBalance::zero(),
396 right: c1.balance,
397 },
398 )
399 .await?;
400
401 afs.on_own_channel_changed(
402 &c2,
403 ChannelDirection::Outgoing,
404 ChannelChange::Balance {
405 left: HoprBalance::zero(),
406 right: c2.balance,
407 },
408 )
409 .await?;
410
411 afs.on_own_channel_changed(
412 &c3,
413 ChannelDirection::Outgoing,
414 ChannelChange::Balance {
415 left: HoprBalance::zero(),
416 right: c3.balance,
417 },
418 )
419 .await?;
420
421 events
422 .filter(|event| futures::future::ready(matches!(event, ChainEvent::ChannelBalanceIncreased(c, amount) if c.get_id() == c2.get_id() && amount == &fund_amount)))
423 .next()
424 .timeout(futures_time::time::Duration::from_secs(2))
425 .await?;
426
427 insta::assert_yaml_snapshot!(*snapshot.refresh());
428
429 Ok(())
430 }
431
432 #[test]
433 fn test_config_validation_rejects_zero_funding_amount() {
434 let cfg = AutoFundingStrategyConfig {
435 min_stake_threshold: HoprBalance::new_base(1),
436 funding_amount: HoprBalance::zero(),
437 };
438 assert!(
439 cfg.validate().is_err(),
440 "config with zero funding_amount should fail validation"
441 );
442 }
443
444 #[test]
445 fn test_config_validation_accepts_valid_config() {
446 let cfg = AutoFundingStrategyConfig {
447 min_stake_threshold: HoprBalance::new_base(1),
448 funding_amount: HoprBalance::new_base(10),
449 };
450 assert!(
451 cfg.validate().is_ok(),
452 "config with valid funding_amount should pass validation"
453 );
454 }
455
456 #[test]
457 fn test_default_config_passes_validation() {
458 let cfg = AutoFundingStrategyConfig::default();
459 assert!(cfg.validate().is_ok(), "default config should pass validation");
460 }
461
462 #[test_log::test(tokio::test)]
463 async fn test_on_tick_funds_underfunded_channels() -> anyhow::Result<()> {
464 let stake_limit = HoprBalance::from(7_u32);
465 let fund_amount = HoprBalance::from(5_u32);
466
467 let c1 = ChannelEntry::builder()
469 .between(*BOB, *CHRIS)
470 .amount(3)
471 .ticket_index(0)
472 .status(ChannelStatus::Open)
473 .epoch(0_u32)
474 .build()?;
475
476 let c2 = ChannelEntry::builder()
478 .between(*BOB, *DAVE)
479 .amount(10)
480 .ticket_index(0)
481 .status(ChannelStatus::Open)
482 .epoch(0_u32)
483 .build()?;
484 let blokli_sim = BlokliTestStateBuilder::default()
485 .with_generated_accounts(
486 &[&*ALICE, &*BOB, &*CHRIS, &*DAVE],
487 false,
488 XDaiBalance::new_base(1),
489 HoprBalance::new_base(1000),
490 )
491 .with_channels([c1, c2])
492 .build_dynamic_client([1; Address::SIZE].into());
493
494 let mut chain_connector =
495 create_trustful_hopr_blokli_connector(&BOB_KP, Default::default(), blokli_sim, [1; Address::SIZE].into())
496 .await?;
497 chain_connector.connect().await?;
498 let events = chain_connector.subscribe()?;
499
500 let cfg = AutoFundingStrategyConfig {
501 min_stake_threshold: stake_limit,
502 funding_amount: fund_amount,
503 };
504
505 let afs = AutoFundingStrategy::new(cfg, chain_connector);
506
507 afs.on_tick().await?;
509
510 events
512 .filter(|event| {
513 futures::future::ready(
514 matches!(event, ChainEvent::ChannelBalanceIncreased(c, amount) if c.get_id() == c1.get_id() && amount == &fund_amount),
515 )
516 })
517 .next()
518 .timeout(futures_time::time::Duration::from_secs(2))
519 .await?;
520
521 Ok(())
522 }
523
524 #[test_log::test(tokio::test)]
525 async fn test_on_tick_skips_when_safe_balance_below_funding_amount() -> anyhow::Result<()> {
526 let stake_limit = HoprBalance::from(7_u32);
527 let fund_amount = HoprBalance::from(5_u32);
528
529 let c1 = ChannelEntry::builder()
530 .between(*BOB, *CHRIS)
531 .amount(3)
532 .ticket_index(0)
533 .status(ChannelStatus::Open)
534 .epoch(0)
535 .build()?;
536
537 let blokli_sim = BlokliTestStateBuilder::default()
538 .with_generated_accounts(
539 &[&*ALICE, &*BOB, &*CHRIS, &*DAVE],
540 false,
541 XDaiBalance::new_base(1),
542 HoprBalance::from(1_u32),
543 )
544 .with_channels([c1])
545 .build_dynamic_client([1; Address::SIZE].into());
546
547 let mut chain_connector =
548 create_trustful_hopr_blokli_connector(&BOB_KP, Default::default(), blokli_sim, [1; Address::SIZE].into())
549 .await?;
550 chain_connector.connect().await?;
551 let events = chain_connector.subscribe()?;
552
553 let cfg = AutoFundingStrategyConfig {
554 min_stake_threshold: stake_limit,
555 funding_amount: fund_amount,
556 };
557
558 let afs = AutoFundingStrategy::new(cfg, chain_connector);
559
560 afs.on_tick().await?;
561
562 let no_funding_event = events
563 .filter(|event| {
564 futures::future::ready(
565 matches!(event, ChainEvent::ChannelBalanceIncreased(c, amount) if c.get_id() == c1.get_id() && amount == &fund_amount),
566 )
567 })
568 .next()
569 .timeout(futures_time::time::Duration::from_secs(1))
570 .await;
571
572 assert!(
573 no_funding_event.is_err(),
574 "on_tick should skip funding when safe balance is below funding_amount"
575 );
576
577 Ok(())
578 }
579
580 #[test_log::test(tokio::test)]
581 async fn test_on_tick_funds_only_channels_affordable_by_safe_balance() -> anyhow::Result<()> {
582 let stake_limit = HoprBalance::from(7_u32);
583 let fund_amount = HoprBalance::from(5_u32);
584
585 let c1 = ChannelEntry::builder()
586 .between(*BOB, *CHRIS)
587 .amount(3)
588 .ticket_index(0)
589 .status(ChannelStatus::Open)
590 .epoch(0_u32)
591 .build()?;
592 let c2 = ChannelEntry::builder()
593 .between(*BOB, *DAVE)
594 .amount(2)
595 .ticket_index(0)
596 .status(ChannelStatus::Open)
597 .epoch(0_u32)
598 .build()?;
599 let c3 = ChannelEntry::builder()
600 .between(*BOB, *ALICE)
601 .amount(1)
602 .ticket_index(0)
603 .status(ChannelStatus::Open)
604 .epoch(0_u32)
605 .build()?;
606 let tracked_channels = [*c1.get_id(), *c2.get_id(), *c3.get_id()];
607
608 let blokli_sim = BlokliTestStateBuilder::default()
609 .with_generated_accounts(
610 &[&*ALICE, &*BOB, &*CHRIS, &*DAVE],
611 false,
612 XDaiBalance::new_base(1),
613 HoprBalance::from(11_u32),
614 )
615 .with_channels([c1, c2, c3])
616 .build_dynamic_client([1; Address::SIZE].into());
617
618 let mut chain_connector =
619 create_trustful_hopr_blokli_connector(&BOB_KP, Default::default(), blokli_sim, [1; Address::SIZE].into())
620 .await?;
621 chain_connector.connect().await?;
622 let events = chain_connector.subscribe()?;
623
624 let cfg = AutoFundingStrategyConfig {
625 min_stake_threshold: stake_limit,
626 funding_amount: fund_amount,
627 };
628
629 let afs = AutoFundingStrategy::new(cfg, chain_connector);
630
631 afs.on_tick().await?;
632
633 let mut funding_events = events.filter(|event| {
634 futures::future::ready(matches!(
635 event,
636 ChainEvent::ChannelBalanceIncreased(c, amount)
637 if tracked_channels.contains(c.get_id()) && amount == &fund_amount
638 ))
639 });
640
641 let first_two = funding_events
642 .by_ref()
643 .take(2)
644 .collect::<Vec<_>>()
645 .timeout(futures_time::time::Duration::from_secs(2))
646 .await?;
647 assert_eq!(
648 first_two.len(),
649 2,
650 "on_tick should fund exactly two channels with safe balance budget of 11 and funding amount 5"
651 );
652
653 let third = funding_events
654 .next()
655 .timeout(futures_time::time::Duration::from_secs(1))
656 .await;
657 assert!(
658 third.is_err(),
659 "on_tick should not fund a third channel once safe budget is depleted"
660 );
661
662 Ok(())
663 }
664
665 #[test_log::test(tokio::test)]
666 async fn test_in_flight_prevents_duplicate_funding() -> anyhow::Result<()> {
667 let stake_limit = HoprBalance::from(7_u32);
668 let fund_amount = HoprBalance::from(5_u32);
669
670 let c1 = ChannelEntry::builder()
671 .between(*BOB, *CHRIS)
672 .amount(3)
673 .ticket_index(0)
674 .status(ChannelStatus::Open)
675 .epoch(0_u32)
676 .build()?;
677
678 let blokli_sim = BlokliTestStateBuilder::default()
679 .with_generated_accounts(
680 &[&*ALICE, &*BOB, &*CHRIS, &*DAVE],
681 false,
682 XDaiBalance::new_base(1),
683 HoprBalance::new_base(1000),
684 )
685 .with_channels([c1])
686 .build_dynamic_client([1; Address::SIZE].into());
687
688 let mut chain_connector =
689 create_trustful_hopr_blokli_connector(&BOB_KP, Default::default(), blokli_sim, [1; Address::SIZE].into())
690 .await?;
691 chain_connector.connect().await?;
692 let _events = chain_connector.subscribe()?;
693
694 let cfg = AutoFundingStrategyConfig {
695 min_stake_threshold: stake_limit,
696 funding_amount: fund_amount,
697 };
698
699 let afs = AutoFundingStrategy::new(cfg, chain_connector);
700
701 afs.on_own_channel_changed(
703 &c1,
704 ChannelDirection::Outgoing,
705 ChannelChange::Balance {
706 left: HoprBalance::from(10_u32),
707 right: c1.balance,
708 },
709 )
710 .await?;
711
712 assert!(
714 afs.in_flight.contains(c1.get_id()),
715 "channel should be in the in-flight set after funding"
716 );
717
718 afs.on_own_channel_changed(
721 &c1,
722 ChannelDirection::Outgoing,
723 ChannelChange::Balance {
724 left: HoprBalance::from(10_u32),
725 right: c1.balance,
726 },
727 )
728 .await?;
729
730 assert_eq!(
732 afs.in_flight.len(),
733 1,
734 "in-flight set should still have exactly one entry"
735 );
736 assert!(
737 afs.in_flight.contains(c1.get_id()),
738 "channel should still be in the in-flight set"
739 );
740
741 Ok(())
742 }
743
744 #[test_log::test(tokio::test)]
745 async fn test_balance_increase_clears_in_flight() -> anyhow::Result<()> {
746 let stake_limit = HoprBalance::from(7_u32);
747 let fund_amount = HoprBalance::from(5_u32);
748
749 let c1 = ChannelEntry::builder()
750 .between(*BOB, *CHRIS)
751 .amount(3)
752 .ticket_index(0)
753 .status(ChannelStatus::Open)
754 .epoch(0_u32)
755 .build()?;
756
757 let blokli_sim = BlokliTestStateBuilder::default()
758 .with_generated_accounts(
759 &[&*ALICE, &*BOB, &*CHRIS, &*DAVE],
760 false,
761 XDaiBalance::new_base(1),
762 HoprBalance::new_base(1000),
763 )
764 .with_channels([c1])
765 .build_dynamic_client([1; Address::SIZE].into());
766
767 let mut chain_connector =
768 create_trustful_hopr_blokli_connector(&BOB_KP, Default::default(), blokli_sim, [1; Address::SIZE].into())
769 .await?;
770 chain_connector.connect().await?;
771 let _events = chain_connector.subscribe()?;
772
773 let cfg = AutoFundingStrategyConfig {
774 min_stake_threshold: stake_limit,
775 funding_amount: fund_amount,
776 };
777
778 let afs = AutoFundingStrategy::new(cfg, chain_connector);
779
780 afs.on_own_channel_changed(
782 &c1,
783 ChannelDirection::Outgoing,
784 ChannelChange::Balance {
785 left: HoprBalance::from(10_u32),
786 right: c1.balance,
787 },
788 )
789 .await?;
790
791 assert!(afs.in_flight.contains(c1.get_id()));
793
794 let funded_channel = ChannelEntry::builder()
796 .between(*BOB, *CHRIS)
797 .amount(3 + 5)
798 .ticket_index(0)
799 .status(ChannelStatus::Open)
800 .epoch(0)
801 .build()?;
802
803 afs.on_own_channel_changed(
804 &funded_channel,
805 ChannelDirection::Outgoing,
806 ChannelChange::Balance {
807 left: HoprBalance::from(3),
808 right: HoprBalance::from(8),
809 },
810 )
811 .await?;
812
813 assert!(
815 !afs.in_flight.contains(c1.get_id()),
816 "channel should be cleared from in-flight after balance increase"
817 );
818
819 Ok(())
820 }
821
822 #[test_log::test(tokio::test)]
823 async fn test_on_tick_skips_in_flight_channels() -> anyhow::Result<()> {
824 let stake_limit = HoprBalance::from(7_u32);
825 let fund_amount = HoprBalance::from(5_u32);
826
827 let c1 = ChannelEntry::builder()
829 .between(*BOB, *CHRIS)
830 .amount(3)
831 .ticket_index(0)
832 .status(ChannelStatus::Open)
833 .epoch(0)
834 .build()?;
835
836 let blokli_sim = BlokliTestStateBuilder::default()
837 .with_generated_accounts(
838 &[&*ALICE, &*BOB, &*CHRIS, &*DAVE],
839 false,
840 XDaiBalance::new_base(1),
841 HoprBalance::new_base(1000),
842 )
843 .with_channels([c1])
844 .build_dynamic_client([1; Address::SIZE].into());
845
846 let mut chain_connector =
847 create_trustful_hopr_blokli_connector(&BOB_KP, Default::default(), blokli_sim, [1; Address::SIZE].into())
848 .await?;
849 chain_connector.connect().await?;
850 let _events = chain_connector.subscribe()?;
851
852 let cfg = AutoFundingStrategyConfig {
853 min_stake_threshold: stake_limit,
854 funding_amount: fund_amount,
855 };
856
857 let afs = AutoFundingStrategy::new(cfg, chain_connector);
858
859 afs.on_own_channel_changed(
861 &c1,
862 ChannelDirection::Outgoing,
863 ChannelChange::Balance {
864 left: HoprBalance::from(10_u32),
865 right: c1.balance,
866 },
867 )
868 .await?;
869
870 assert!(
872 afs.in_flight.contains(c1.get_id()),
873 "channel should be in the in-flight set after funding"
874 );
875
876 afs.on_tick().await?;
878
879 assert_eq!(
881 afs.in_flight.len(),
882 1,
883 "in-flight set should still have exactly one entry"
884 );
885 assert!(
886 afs.in_flight.contains(c1.get_id()),
887 "channel should still be in the in-flight set"
888 );
889
890 Ok(())
891 }
892}