1use 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#[serde_as]
32#[derive(Debug, Clone, Copy, PartialEq, Eq, smart_default::SmartDefault, Validate, Serialize, Deserialize)]
33pub struct AutoFundingStrategyConfig {
34 #[serde_as(as = "DisplayFromStr")]
38 #[default(HoprBalance::new_base(1))]
39 pub min_stake_threshold: HoprBalance,
40
41 #[serde_as(as = "DisplayFromStr")]
45 #[default(HoprBalance::new_base(10))]
46 pub funding_amount: HoprBalance,
47}
48
49pub 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 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); 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}