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            .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                // Channel is above threshold; clear any stale in-flight entry
269                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        // Can only auto-fund outgoing channels
284        if direction != ChannelDirection::Outgoing {
285            return Ok(());
286        }
287
288        if let ChannelChange::Balance { left: old, right: new } = change {
289            // If balance increased, clear in-flight state for this channel
290            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        // BOB -> CHRIS channel with balance below threshold
467        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        // BOB -> DAVE channel with balance above threshold
476        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        // on_tick should scan channels and fund c1 (below threshold) but not c2 (above threshold)
507        afs.on_tick().await?;
508
509        // Expect a ChannelBalanceIncreased event for c1
510        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        // First call should trigger funding
701        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        // Verify the channel is in the in-flight set
712        assert!(
713            afs.in_flight.contains(c1.get_id()),
714            "channel should be in the in-flight set after funding"
715        );
716
717        // Second call with same balance should be skipped due to in-flight tracking.
718        // This returns Ok(()) rather than triggering another funding tx.
719        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        // The in-flight set should still contain exactly one entry (unchanged)
730        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        // Trigger funding (balance decrease below the threshold)
780        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        // Verify channel is in-flight
791        assert!(afs.in_flight.contains(c1.get_id()));
792
793        // Simulate balance increase event (funding confirmed)
794        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        // Verify channel is no longer in-flight
813        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        // BOB -> CHRIS channel with balance below threshold
827        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        // Fund via on_own_channel_changed to populate in-flight set
859        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        // Verify the channel is in-flight
870        assert!(
871            afs.in_flight.contains(c1.get_id()),
872            "channel should be in the in-flight set after funding"
873        );
874
875        // on_tick should skip c1 because it is already in-flight
876        afs.on_tick().await?;
877
878        // Verify the in-flight set still has exactly one entry (channel was not re-funded)
879        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}