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_api::chain::ChainWriteChannelOperations;
11use hopr_internal_types::prelude::*;
12use hopr_primitive_types::prelude::*;
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::CurrentBalance { 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 hex_literal::hex;
119    use hopr_api::chain::ChainReceipt;
120
121    use super::*;
122    use crate::{
123        auto_funding::{AutoFundingStrategy, AutoFundingStrategyConfig},
124        strategy::SingularStrategy,
125        tests::{MockChainActions, MockTestActions},
126    };
127
128    lazy_static::lazy_static! {
129        static ref ALICE: Address = hex!("18f8ae833c85c51fbeba29cef9fbfb53b3bad950").into();
130        static ref BOB: Address = hex!("44f23fa14130ca540b37251309700b6c281d972e").into();
131        static ref CHRIS: Address = hex!("b6021e0860dd9d96c9ff0a73e2e5ba3a466ba234").into();
132        static ref DAVE: Address = hex!("68499f50ff68d523385dc60686069935d17d762a").into();
133    }
134
135    #[tokio::test]
136    async fn test_auto_funding_strategy() -> anyhow::Result<()> {
137        let stake_limit = HoprBalance::from(7_u32);
138        let fund_amount = HoprBalance::from(5_u32);
139
140        let c1 = ChannelEntry::new(
141            *ALICE,
142            *BOB,
143            10_u32.into(),
144            0_u32.into(),
145            ChannelStatus::Open,
146            0_u32.into(),
147        );
148
149        let c2 = ChannelEntry::new(
150            *BOB,
151            *CHRIS,
152            5_u32.into(),
153            0_u32.into(),
154            ChannelStatus::Open,
155            0_u32.into(),
156        );
157
158        let c3 = ChannelEntry::new(
159            *CHRIS,
160            *DAVE,
161            5_u32.into(),
162            0_u32.into(),
163            ChannelStatus::PendingToClose(std::time::SystemTime::now()),
164            0_u32.into(),
165        );
166
167        let cfg = AutoFundingStrategyConfig {
168            min_stake_threshold: stake_limit,
169            funding_amount: fund_amount,
170        };
171
172        let mut mock = MockTestActions::new();
173        mock.expect_fund_channel()
174            .once()
175            .with(mockall::predicate::eq(c2.get_id()), mockall::predicate::eq(fund_amount))
176            .return_once(|_, _| Ok(ChainReceipt::default()));
177
178        let afs = AutoFundingStrategy::new(cfg, MockChainActions(mock.into()));
179        afs.on_own_channel_changed(
180            &c1,
181            ChannelDirection::Outgoing,
182            ChannelChange::CurrentBalance {
183                left: HoprBalance::zero(),
184                right: c1.balance,
185            },
186        )
187        .await?;
188
189        afs.on_own_channel_changed(
190            &c2,
191            ChannelDirection::Outgoing,
192            ChannelChange::CurrentBalance {
193                left: HoprBalance::zero(),
194                right: c2.balance,
195            },
196        )
197        .await?;
198
199        afs.on_own_channel_changed(
200            &c3,
201            ChannelDirection::Outgoing,
202            ChannelChange::CurrentBalance {
203                left: HoprBalance::zero(),
204                right: c3.balance,
205            },
206        )
207        .await?;
208
209        Ok(())
210    }
211}