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_chain_actions::channels::ChannelActions;
11use hopr_internal_types::prelude::*;
12#[cfg(all(feature = "prometheus", not(test)))]
13use hopr_metrics::metrics::SimpleCounter;
14use hopr_primitive_types::prelude::*;
15use serde::{Deserialize, Serialize};
16use serde_with::{DisplayFromStr, serde_as};
17use tracing::info;
18use validator::Validate;
19
20use crate::{Strategy, errors::StrategyError::CriteriaNotSatisfied, strategy::SingularStrategy};
21
22#[cfg(all(feature = "prometheus", not(test)))]
23lazy_static::lazy_static! {
24    static ref METRIC_COUNT_AUTO_FUNDINGS: SimpleCounter =
25        SimpleCounter::new("hopr_strategy_auto_funding_funding_count", "Count of initiated automatic fundings").unwrap();
26}
27
28/// Configuration for `AutoFundingStrategy`
29#[serde_as]
30#[derive(Debug, Clone, Copy, PartialEq, Eq, smart_default::SmartDefault, Validate, Serialize, Deserialize)]
31pub struct AutoFundingStrategyConfig {
32    /// Minimum stake that a channel's balance must not go below.
33    ///
34    /// Default is 1 wxHOPR
35    #[serde_as(as = "DisplayFromStr")]
36    #[default(HoprBalance::new_base(1))]
37    pub min_stake_threshold: HoprBalance,
38
39    /// Funding amount.
40    ///
41    /// Defaults to 10 wxHOPR.
42    #[serde_as(as = "DisplayFromStr")]
43    #[default(HoprBalance::new_base(10))]
44    pub funding_amount: HoprBalance,
45}
46
47/// The `AutoFundingStrategy` automatically funds a channel that
48/// dropped it's staked balance below the configured threshold.
49pub struct AutoFundingStrategy<A: ChannelActions> {
50    hopr_chain_actions: A,
51    cfg: AutoFundingStrategyConfig,
52}
53
54impl<A: ChannelActions> AutoFundingStrategy<A> {
55    pub fn new(cfg: AutoFundingStrategyConfig, hopr_chain_actions: A) -> Self {
56        Self {
57            cfg,
58            hopr_chain_actions,
59        }
60    }
61}
62
63impl<A: ChannelActions> Debug for AutoFundingStrategy<A> {
64    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
65        write!(f, "{:?}", Strategy::AutoFunding(self.cfg))
66    }
67}
68
69impl<A: ChannelActions> Display for AutoFundingStrategy<A> {
70    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
71        write!(f, "{}", Strategy::AutoFunding(self.cfg))
72    }
73}
74
75#[async_trait]
76impl<A: ChannelActions + Send + Sync> SingularStrategy for AutoFundingStrategy<A> {
77    async fn on_own_channel_changed(
78        &self,
79        channel: &ChannelEntry,
80        direction: ChannelDirection,
81        change: ChannelChange,
82    ) -> crate::errors::Result<()> {
83        // Can only auto-fund outgoing channels
84        if direction != ChannelDirection::Outgoing {
85            return Ok(());
86        }
87
88        if let ChannelChange::CurrentBalance { right: new, .. } = change {
89            if new.lt(&self.cfg.min_stake_threshold) && channel.status == ChannelStatus::Open {
90                info!(%channel, balance = %channel.balance, threshold = %self.cfg.min_stake_threshold,
91                    "stake on channel is below threshold",
92                );
93
94                #[cfg(all(feature = "prometheus", not(test)))]
95                METRIC_COUNT_AUTO_FUNDINGS.increment();
96
97                let rx = self
98                    .hopr_chain_actions
99                    .fund_channel(channel.get_id(), self.cfg.funding_amount)
100                    .await?;
101                std::mem::drop(rx); // The Receiver is not intentionally awaited here and the oneshot Sender can fail safely
102                info!(%channel, amount = %self.cfg.funding_amount, "issued re-staking of channel", );
103            }
104            Ok(())
105        } else {
106            Err(CriteriaNotSatisfied)
107        }
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use async_trait::async_trait;
114    use futures::{FutureExt, future::ok};
115    use hex_literal::hex;
116    use hopr_chain_actions::{
117        action_queue::{ActionConfirmation, PendingAction},
118        channels::ChannelActions,
119    };
120    use hopr_chain_types::{actions::Action, chain_events::ChainEventType};
121    use hopr_crypto_random::random_bytes;
122    use hopr_crypto_types::types::Hash;
123    use hopr_internal_types::prelude::*;
124    use hopr_primitive_types::prelude::*;
125    use mockall::mock;
126
127    use crate::{
128        auto_funding::{AutoFundingStrategy, AutoFundingStrategyConfig},
129        strategy::SingularStrategy,
130    };
131
132    lazy_static::lazy_static! {
133        static ref ALICE: Address = hex!("18f8ae833c85c51fbeba29cef9fbfb53b3bad950").into();
134        static ref BOB: Address = hex!("44f23fa14130ca540b37251309700b6c281d972e").into();
135        static ref CHRIS: Address = hex!("b6021e0860dd9d96c9ff0a73e2e5ba3a466ba234").into();
136        static ref DAVE: Address = hex!("68499f50ff68d523385dc60686069935d17d762a").into();
137    }
138
139    mock! {
140        ChannelAct { }
141        #[async_trait]
142        impl ChannelActions for ChannelAct {
143            async fn open_channel(&self, destination: Address, amount: HoprBalance) -> hopr_chain_actions::errors::Result<PendingAction>;
144            async fn fund_channel(&self, channel_id: Hash, amount: HoprBalance) -> hopr_chain_actions::errors::Result<PendingAction>;
145            async fn close_channel(
146                &self,
147                counterparty: Address,
148                direction: ChannelDirection,
149                redeem_before_close: bool,
150            ) -> hopr_chain_actions::errors::Result<PendingAction>;
151        }
152    }
153
154    fn mock_action_confirmation(channel: ChannelEntry, balance: HoprBalance) -> ActionConfirmation {
155        let random_hash = Hash::from(random_bytes::<{ Hash::SIZE }>());
156        ActionConfirmation {
157            tx_hash: random_hash,
158            event: Some(ChainEventType::ChannelBalanceIncreased(channel, balance)),
159            action: Action::FundChannel(channel, balance),
160        }
161    }
162
163    #[tokio::test]
164    async fn test_auto_funding_strategy() -> anyhow::Result<()> {
165        let stake_limit = HoprBalance::from(7_u32);
166        let fund_amount = HoprBalance::from(5_u32);
167
168        let c1 = ChannelEntry::new(
169            *ALICE,
170            *BOB,
171            10_u32.into(),
172            0_u32.into(),
173            ChannelStatus::Open,
174            0_u32.into(),
175        );
176
177        let c2 = ChannelEntry::new(
178            *BOB,
179            *CHRIS,
180            5_u32.into(),
181            0_u32.into(),
182            ChannelStatus::Open,
183            0_u32.into(),
184        );
185
186        let c3 = ChannelEntry::new(
187            *CHRIS,
188            *DAVE,
189            5_u32.into(),
190            0_u32.into(),
191            ChannelStatus::PendingToClose(std::time::SystemTime::now()),
192            0_u32.into(),
193        );
194
195        let mut actions = MockChannelAct::new();
196        let fund_amount_c = fund_amount;
197        actions
198            .expect_fund_channel()
199            .times(1)
200            .withf(move |h, balance| c2.get_id().eq(h) && fund_amount_c.eq(balance))
201            .return_once(move |_, _| Ok(ok(mock_action_confirmation(c2, fund_amount)).boxed()));
202
203        let cfg = AutoFundingStrategyConfig {
204            min_stake_threshold: stake_limit,
205            funding_amount: fund_amount,
206        };
207
208        let afs = AutoFundingStrategy::new(cfg, actions);
209        afs.on_own_channel_changed(
210            &c1,
211            ChannelDirection::Outgoing,
212            ChannelChange::CurrentBalance {
213                left: HoprBalance::zero(),
214                right: c1.balance,
215            },
216        )
217        .await?;
218
219        afs.on_own_channel_changed(
220            &c2,
221            ChannelDirection::Outgoing,
222            ChannelChange::CurrentBalance {
223                left: HoprBalance::zero(),
224                right: c2.balance,
225            },
226        )
227        .await?;
228
229        afs.on_own_channel_changed(
230            &c3,
231            ChannelDirection::Outgoing,
232            ChannelChange::CurrentBalance {
233                left: HoprBalance::zero(),
234                right: c3.balance,
235            },
236        )
237        .await?;
238
239        Ok(())
240    }
241}