Skip to main content

hopr_lib/
helpers.rs

1use std::{
2    pin::Pin,
3    task::{Context, Poll},
4    time::Duration,
5};
6
7use hopr_api::{
8    chain::ChainValues,
9    types::primitive::{
10        balance::{Balance, Currency},
11        prelude::Address,
12    },
13};
14
15/// Polls the on-chain balance of `address` for currency `C` with exponential backoff,
16/// returning `Ok` as soon as it reaches `min_balance`, or `Err` once the per-iteration
17/// backoff grows past `max_delay`.
18pub(crate) async fn wait_for_balance<C: Currency, R: ChainValues>(
19    min_balance: Balance<C>,
20    max_delay: Duration,
21    address: Address,
22    resolver: &R,
23) -> Result<(), ()> {
24    let multiplier = 1.05;
25    let mut current_delay = Duration::from_secs(2).min(max_delay);
26
27    while current_delay <= max_delay {
28        match resolver.balance::<C, _>(address).await {
29            Ok(current_balance) => {
30                tracing::info!(%address, balance = %current_balance, "balance status");
31                if current_balance.ge(&min_balance) {
32                    return Ok(());
33                } else {
34                    tracing::warn!(%address, "still underfunded, trying again soon");
35                }
36            }
37            Err(error) => tracing::error!(%address, %error, "failed to fetch balance from the chain"),
38        }
39
40        hopr_utils::runtime::prelude::sleep(current_delay).await;
41        current_delay = current_delay.mul_f64(multiplier);
42    }
43
44    Err(())
45}
46
47#[cfg(test)]
48mod tests {
49    use std::{
50        sync::atomic::{AtomicUsize, Ordering},
51        time::Duration,
52    };
53
54    use hopr_api::{
55        chain::{ChainInfo, ChainValues, DomainSeparators, RedemptionStats, WinningProbability},
56        types::primitive::{
57            balance::{Balance, Currency},
58            prelude::{Address, HoprBalance},
59        },
60    };
61
62    use super::wait_for_balance;
63
64    #[derive(Debug, thiserror::Error)]
65    #[error("mock chain error")]
66    struct MockError;
67
68    /// Mock [`ChainValues`] resolver that scripts a sequence of `balance()` outcomes.
69    ///
70    /// The first `fail_first` calls return an error (simulating a flaky RPC), after which
71    /// the reported balance is `initial_base` until `bump_after` successful reads have
72    /// happened, at which point it jumps to `funded_base` (simulating the safe being topped up).
73    #[derive(Default)]
74    struct MockChain {
75        calls: AtomicUsize,
76        fail_first: usize,
77        bump_after: usize,
78        initial_base: u64,
79        funded_base: u64,
80    }
81
82    impl MockChain {
83        /// Number of times `balance()` was invoked.
84        fn call_count(&self) -> usize {
85            self.calls.load(Ordering::SeqCst)
86        }
87    }
88
89    #[async_trait::async_trait]
90    impl ChainValues for MockChain {
91        type Error = MockError;
92
93        async fn balance<C: Currency, A: Into<Address> + Send>(&self, _address: A) -> Result<Balance<C>, Self::Error> {
94            let n = self.calls.fetch_add(1, Ordering::SeqCst);
95            if n < self.fail_first {
96                return Err(MockError);
97            }
98            let base = if n >= self.bump_after {
99                self.funded_base
100            } else {
101                self.initial_base
102            };
103            Ok(Balance::<C>::new_base(base))
104        }
105
106        async fn domain_separators(&self) -> Result<DomainSeparators, Self::Error> {
107            unimplemented!("not used by wait_for_balance")
108        }
109
110        async fn minimum_incoming_ticket_win_prob(&self) -> Result<WinningProbability, Self::Error> {
111            unimplemented!("not used by wait_for_balance")
112        }
113
114        async fn minimum_ticket_price(&self) -> Result<HoprBalance, Self::Error> {
115            unimplemented!("not used by wait_for_balance")
116        }
117
118        async fn key_binding_fee(&self) -> Result<HoprBalance, Self::Error> {
119            unimplemented!("not used by wait_for_balance")
120        }
121
122        async fn channel_closure_notice_period(&self) -> Result<Duration, Self::Error> {
123            unimplemented!("not used by wait_for_balance")
124        }
125
126        async fn chain_info(&self) -> Result<ChainInfo, Self::Error> {
127            unimplemented!("not used by wait_for_balance")
128        }
129
130        async fn redemption_stats<A: Into<Address> + Send>(
131            &self,
132            _safe_addr: A,
133        ) -> Result<RedemptionStats, Self::Error> {
134            unimplemented!("not used by wait_for_balance")
135        }
136
137        async fn typical_resolution_time(&self) -> Result<Duration, Self::Error> {
138            unimplemented!("not used by wait_for_balance")
139        }
140    }
141
142    #[tokio::test(start_paused = true)]
143    async fn returns_immediately_when_safe_is_already_funded() {
144        let chain = MockChain {
145            initial_base: 100,
146            funded_base: 100,
147            ..Default::default()
148        };
149
150        let result = wait_for_balance(
151            HoprBalance::new_base(50),
152            Duration::from_secs(200),
153            Address::default(),
154            &chain,
155        )
156        .await;
157
158        assert!(result.is_ok());
159        // A single read is enough since the safe already holds the required balance.
160        assert_eq!(chain.call_count(), 1);
161    }
162
163    #[tokio::test(start_paused = true)]
164    async fn errors_when_safe_stays_underfunded_until_deadline() {
165        let chain = MockChain {
166            initial_base: 10,
167            funded_base: 10,
168            ..Default::default()
169        };
170
171        let result = wait_for_balance(
172            HoprBalance::new_base(50),
173            Duration::from_secs(200),
174            Address::default(),
175            &chain,
176        )
177        .await;
178
179        assert_eq!(result, Err(()), "expected the wait to time out, got {result:?}");
180    }
181
182    #[tokio::test(start_paused = true)]
183    async fn succeeds_after_the_safe_gets_topped_up() {
184        // Underfunded for the first two reads, then funded.
185        let chain = MockChain {
186            initial_base: 10,
187            funded_base: 100,
188            bump_after: 2,
189            ..Default::default()
190        };
191
192        let result = wait_for_balance(
193            HoprBalance::new_base(50),
194            Duration::from_secs(200),
195            Address::default(),
196            &chain,
197        )
198        .await;
199
200        assert!(result.is_ok());
201        // Two underfunded reads plus the successful third one.
202        assert_eq!(chain.call_count(), chain.bump_after + 1);
203    }
204
205    #[tokio::test(start_paused = true)]
206    async fn tolerates_transient_fetch_errors_then_succeeds() {
207        // First read errors out, the safe is funded from the second read onwards.
208        let chain = MockChain {
209            initial_base: 100,
210            funded_base: 100,
211            fail_first: 1,
212            ..Default::default()
213        };
214
215        let result = wait_for_balance(
216            HoprBalance::new_base(50),
217            Duration::from_secs(200),
218            Address::default(),
219            &chain,
220        )
221        .await;
222
223        assert!(result.is_ok());
224        // One failed read followed by a successful one.
225        assert_eq!(chain.call_count(), chain.fail_first + 1);
226    }
227}
228
229#[derive(Clone)]
230pub struct BroadcastSenderSink<T>(pub async_broadcast::Sender<T>);
231
232impl<T: Clone> futures::Sink<T> for BroadcastSenderSink<T> {
233    type Error = async_broadcast::TrySendError<T>;
234
235    fn poll_ready(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
236        Poll::Ready(Ok(()))
237    }
238
239    fn start_send(self: Pin<&mut Self>, item: T) -> Result<(), Self::Error> {
240        self.0.try_broadcast(item).map(|_| ())
241    }
242
243    fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
244        Poll::Ready(Ok(()))
245    }
246
247    fn poll_close(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
248        self.0.close();
249        Poll::Ready(Ok(()))
250    }
251}