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//! For details on default parameters see [AutoFundingStrategyConfig].
7use std::fmt::{Debug, Display, Formatter};
8
9use async_trait::async_trait;
10use hopr_lib::{
11    ChannelChange, ChannelDirection, ChannelEntry, ChannelStatus, HoprBalance, api::chain::ChainWriteChannelOperations,
12};
13use serde::{Deserialize, Serialize};
14use serde_with::{DisplayFromStr, serde_as};
15use tracing::info;
16use validator::Validate;
17
18use crate::{
19    Strategy,
20    errors::{StrategyError, StrategyError::CriteriaNotSatisfied},
21    strategy::SingularStrategy,
22};
23
24#[cfg(all(feature = "prometheus", not(test)))]
25lazy_static::lazy_static! {
26    static ref METRIC_COUNT_AUTO_FUNDINGS: hopr_metrics::SimpleCounter =
27        hopr_metrics::SimpleCounter::new("hopr_strategy_auto_funding_funding_count", "Count of initiated automatic fundings").unwrap();
28}
29
30/// Configuration for `AutoFundingStrategy`
31#[serde_as]
32#[derive(Debug, Clone, Copy, PartialEq, Eq, smart_default::SmartDefault, Validate, Serialize, Deserialize)]
33pub struct AutoFundingStrategyConfig {
34    /// Minimum stake that a channel's balance must not go below.
35    ///
36    /// Default is 1 wxHOPR
37    #[serde_as(as = "DisplayFromStr")]
38    #[default(HoprBalance::new_base(1))]
39    pub min_stake_threshold: HoprBalance,
40
41    /// Funding amount.
42    ///
43    /// Defaults to 10 wxHOPR.
44    #[serde_as(as = "DisplayFromStr")]
45    #[default(HoprBalance::new_base(10))]
46    pub funding_amount: HoprBalance,
47}
48
49/// The `AutoFundingStrategy` automatically funds a channel that
50/// dropped it's staked balance below the configured threshold.
51pub struct AutoFundingStrategy<A> {
52    hopr_chain_actions: A,
53    cfg: AutoFundingStrategyConfig,
54}
55
56impl<A: ChainWriteChannelOperations> AutoFundingStrategy<A> {
57    pub fn new(cfg: AutoFundingStrategyConfig, hopr_chain_actions: A) -> Self {
58        Self {
59            cfg,
60            hopr_chain_actions,
61        }
62    }
63}
64
65impl<A> Debug for AutoFundingStrategy<A> {
66    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
67        write!(f, "{:?}", Strategy::AutoFunding(self.cfg))
68    }
69}
70
71impl<A> Display for AutoFundingStrategy<A> {
72    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
73        write!(f, "{}", Strategy::AutoFunding(self.cfg))
74    }
75}
76
77#[async_trait]
78impl<A: ChainWriteChannelOperations + Send + Sync> SingularStrategy for AutoFundingStrategy<A> {
79    async fn on_own_channel_changed(
80        &self,
81        channel: &ChannelEntry,
82        direction: ChannelDirection,
83        change: ChannelChange,
84    ) -> crate::errors::Result<()> {
85        // Can only auto-fund outgoing channels
86        if direction != ChannelDirection::Outgoing {
87            return Ok(());
88        }
89
90        if let ChannelChange::Balance { right: new, .. } = change {
91            if new.lt(&self.cfg.min_stake_threshold) && channel.status == ChannelStatus::Open {
92                info!(%channel, balance = %channel.balance, threshold = %self.cfg.min_stake_threshold,
93                    "stake on channel is below threshold",
94                );
95
96                #[cfg(all(feature = "prometheus", not(test)))]
97                METRIC_COUNT_AUTO_FUNDINGS.increment();
98
99                let channel_id = channel.get_id();
100                let rx = self
101                    .hopr_chain_actions
102                    .fund_channel(channel_id, self.cfg.funding_amount)
103                    .await
104                    .map_err(|e| StrategyError::Other(e.into()))?;
105
106                std::mem::drop(rx); // The Receiver is not intentionally awaited here and the oneshot Sender can fail safely
107                info!(%channel, amount = %self.cfg.funding_amount, "issued re-staking of channel", );
108            }
109            Ok(())
110        } else {
111            Err(CriteriaNotSatisfied)
112        }
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use std::str::FromStr;
119
120    use futures::StreamExt;
121    use futures_time::future::FutureExt;
122    use hex_literal::hex;
123    use hopr_chain_connector::{create_trustful_hopr_blokli_connector, testing::BlokliTestStateBuilder};
124    use hopr_lib::{
125        Address, BytesRepresentable, ChainKeypair, Keypair, XDaiBalance,
126        api::chain::{ChainEvent, ChainEvents},
127    };
128
129    use super::*;
130    use crate::{
131        auto_funding::{AutoFundingStrategy, AutoFundingStrategyConfig},
132        strategy::SingularStrategy,
133    };
134
135    lazy_static::lazy_static! {
136        static ref BOB_KP: ChainKeypair = ChainKeypair::from_secret(&hex!(
137            "492057cf93e99b31d2a85bc5e98a9c3aa0021feec52c227cc8170e8f7d047775"
138        ))
139        .expect("lazy static keypair should be valid");
140
141        static ref ALICE: Address = hex!("18f8ae833c85c51fbeba29cef9fbfb53b3bad950").into();
142        static ref BOB: Address = BOB_KP.public().to_address();
143        static ref CHRIS: Address = hex!("b6021e0860dd9d96c9ff0a73e2e5ba3a466ba234").into();
144        static ref DAVE: Address = hex!("68499f50ff68d523385dc60686069935d17d762a").into();
145    }
146
147    #[test_log::test(tokio::test)]
148    async fn test_auto_funding_strategy() -> anyhow::Result<()> {
149        let stake_limit = HoprBalance::from(7_u32);
150        let fund_amount = HoprBalance::from(5_u32);
151
152        let c1 = ChannelEntry::new(
153            *ALICE,
154            *BOB,
155            10_u32.into(),
156            0_u32.into(),
157            ChannelStatus::Open,
158            0_u32.into(),
159        );
160
161        let c2 = ChannelEntry::new(
162            *BOB,
163            *CHRIS,
164            5_u32.into(),
165            0_u32.into(),
166            ChannelStatus::Open,
167            0_u32.into(),
168        );
169
170        let c3 = ChannelEntry::new(
171            *CHRIS,
172            *DAVE,
173            5_u32.into(),
174            0_u32.into(),
175            ChannelStatus::PendingToClose(
176                chrono::DateTime::<chrono::Utc>::from_str("2025-11-10T00:00:00+00:00")?.into(),
177            ),
178            0_u32.into(),
179        );
180
181        let blokli_sim = BlokliTestStateBuilder::default()
182            .with_generated_accounts(
183                &[&*ALICE, &*BOB, &*CHRIS, &*DAVE],
184                false,
185                XDaiBalance::new_base(1),
186                HoprBalance::new_base(1000),
187            )
188            .with_channels([c1, c2, c3])
189            .build_dynamic_client([1; Address::SIZE].into());
190
191        let snapshot = blokli_sim.snapshot();
192
193        let mut chain_connector =
194            create_trustful_hopr_blokli_connector(&BOB_KP, Default::default(), blokli_sim, [1; Address::SIZE].into())
195                .await?;
196        chain_connector.connect().await?;
197        let events = chain_connector.subscribe()?;
198
199        let cfg = AutoFundingStrategyConfig {
200            min_stake_threshold: stake_limit,
201            funding_amount: fund_amount,
202        };
203
204        let afs = AutoFundingStrategy::new(cfg, chain_connector);
205        afs.on_own_channel_changed(
206            &c1,
207            ChannelDirection::Outgoing,
208            ChannelChange::Balance {
209                left: HoprBalance::zero(),
210                right: c1.balance,
211            },
212        )
213        .await?;
214
215        afs.on_own_channel_changed(
216            &c2,
217            ChannelDirection::Outgoing,
218            ChannelChange::Balance {
219                left: HoprBalance::zero(),
220                right: c2.balance,
221            },
222        )
223        .await?;
224
225        afs.on_own_channel_changed(
226            &c3,
227            ChannelDirection::Outgoing,
228            ChannelChange::Balance {
229                left: HoprBalance::zero(),
230                right: c3.balance,
231            },
232        )
233        .await?;
234
235        events
236            .filter(|event| futures::future::ready(matches!(event, ChainEvent::ChannelBalanceIncreased(c, amount) if c.get_id() == c2.get_id() && amount == &fund_amount)))
237            .next()
238            .timeout(futures_time::time::Duration::from_secs(2))
239            .await?;
240
241        insta::assert_yaml_snapshot!(*snapshot.refresh());
242
243        Ok(())
244    }
245}