Skip to main content

hopr_strategy/
auto_funding.rs

1//! ## Auto Funding Strategy
2//! This strategy listens for channel state change events to check whether a channel has dropped below
3//! `min_stake_threshold` HOPR. If this happens, the strategy issues a **fund channel** transaction to re-stake the
4//! channel with `funding_amount` HOPR.
5//!
6//! Additionally, the strategy periodically scans all outgoing open channels on each tick and funds
7//! any that have fallen below the threshold. This catches channels opened with low balance and
8//! channels that were underfunded when the node started.
9//!
10//! ### In-flight tracking
11//! To prevent duplicate funding when multiple balance-decrease events arrive in quick succession,
12//! the strategy maintains a set of channel IDs currently being funded.
13//! A channel is added to the set when a funding tx is successfully enqueued, and removed when:
14//! - The spawned confirmation task observes a transaction failure,
15//! - A balance increase event is observed for that channel (indicating the funding confirmed), or
16//! - An `on_tick` scan finds the channel's balance has risen above the threshold.
17//!
18//! ### Metrics
19//! - `hopr_strategy_auto_funding_funding_count` — incremented when a funding tx is successfully enqueued
20//! - `hopr_strategy_auto_funding_failure_count` — incremented when a funding tx fails to enqueue or confirm
21//!
22//! For details on default parameters see [AutoFundingStrategyConfig].
23use 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
57/// Validates that [`AutoFundingStrategyConfig::funding_amount`] is non-zero.
58fn 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/// Configuration for `AutoFundingStrategy`
66#[serde_as]
67#[derive(Debug, Clone, Copy, PartialEq, Eq, smart_default::SmartDefault, Validate, Serialize, Deserialize)]
68pub struct AutoFundingStrategyConfig {
69    /// Minimum stake that a channel's balance must not go below.
70    ///
71    /// Default is 1 wxHOPR
72    #[serde_as(as = "DisplayFromStr")]
73    #[default(HoprBalance::new_base(1))]
74    pub min_stake_threshold: HoprBalance,
75
76    /// Funding amount. Must be greater than zero.
77    ///
78    /// Defaults to 10 wxHOPR.
79    #[serde_as(as = "DisplayFromStr")]
80    #[default(HoprBalance::new_base(10))]
81    #[validate(custom(function = "validate_funding_amount"))]
82    pub funding_amount: HoprBalance,
83}
84
85/// The `AutoFundingStrategy` automatically funds a channel that
86/// dropped its staked balance below the configured threshold.
87///
88/// Tracks channels with in-flight funding transactions to prevent duplicate
89/// funding when multiple balance-decrease events arrive in quick succession.
90pub struct AutoFundingStrategy<A> {
91    hopr_chain_actions: Arc<A>,
92    cfg: AutoFundingStrategyConfig,
93    /// Channels with in-flight funding transactions. Entries are removed when:
94    /// - The spawned confirmation task observes a tx failure,
95    /// - A balance increase is observed via `on_own_channel_changed`, or
96    /// - `on_tick` finds the balance above threshold.
97    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    /// Attempt to fund a channel if it is not already in-flight.
127    /// Returns `Ok(true)` if funding task was spawned and `Ok(false)` if the
128    /// channel was skipped because funding is already in-flight.
129    /// The actual `fund_channel` call happens inside the spawned task so the
130    /// confirmation future is `'static`.
131    async fn try_fund_channel(&self, channel: &ChannelEntry) -> crate::errors::Result<bool> {
132        let channel_id = *channel.get_id();
133
134        // Atomically check and mark as in-flight
135        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                    // On success: the ChannelBalanceIncreased event will clear the
167                    // in-flight entry via on_own_channel_changed.
168                }
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    /// Periodically scans all outgoing open channels and funds any with balance at or below
229    /// the configured threshold. Skips channels that already have in-flight funding transactions.
230    ///
231    /// This handles two cases that event-driven funding misses:
232    /// - Channels opened with balance already below threshold (only a `ChannelOpened` event is emitted, which doesn't
233    ///   trigger balance-based funding)
234    /// - Channels that were underfunded when the node started or restarted (no events are replayed to the strategy at
235    ///   startup)
236    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                // Channel is above threshold; clear any stale in-flight entry
270                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        // Can only auto-fund outgoing channels
285        if direction != ChannelDirection::Outgoing {
286            return Ok(());
287        }
288
289        if let ChannelChange::Balance { left: old, right: new } = change {
290            // If balance increased, clear in-flight state for this channel
291            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        // BOB -> CHRIS channel with balance below threshold
468        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        // BOB -> DAVE channel with balance above threshold
477        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        // on_tick should scan channels and fund c1 (below threshold) but not c2 (above threshold)
508        afs.on_tick().await?;
509
510        // Expect a ChannelBalanceIncreased event for c1
511        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        // First call should trigger funding
702        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        // Verify the channel is in the in-flight set
713        assert!(
714            afs.in_flight.contains(c1.get_id()),
715            "channel should be in the in-flight set after funding"
716        );
717
718        // Second call with same balance should be skipped due to in-flight tracking.
719        // This returns Ok(()) rather than triggering another funding tx.
720        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        // The in-flight set should still contain exactly one entry (unchanged)
731        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        // Trigger funding (balance decrease below the threshold)
781        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        // Verify channel is in-flight
792        assert!(afs.in_flight.contains(c1.get_id()));
793
794        // Simulate balance increase event (funding confirmed)
795        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        // Verify channel is no longer in-flight
814        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        // BOB -> CHRIS channel with balance below threshold
828        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        // Fund via on_own_channel_changed to populate in-flight set
860        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        // Verify the channel is in-flight
871        assert!(
872            afs.in_flight.contains(c1.get_id()),
873            "channel should be in the in-flight set after funding"
874        );
875
876        // on_tick should skip c1 because it is already in-flight
877        afs.on_tick().await?;
878
879        // Verify the in-flight set still has exactly one entry (channel was not re-funded)
880        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}