hopr_strategy/
auto_redeeming.rs

1//! ## Auto Redeeming Strategy
2//! This strategy listens for newly added acknowledged tickets and automatically issues a redeem transaction on that
3//! ticket. It can be configured to automatically redeem all tickets or only aggregated tickets (which results in far
4//! fewer on-chain transactions being issued).
5//!
6//! For details on default parameters, see [AutoRedeemingStrategyConfig].
7use std::{
8    fmt::{Debug, Display, Formatter},
9    str::FromStr,
10};
11
12use async_trait::async_trait;
13use hopr_chain_actions::redeem::TicketRedeemActions;
14use hopr_internal_types::{prelude::*, tickets::AcknowledgedTicket};
15#[cfg(all(feature = "prometheus", not(test)))]
16use hopr_metrics::metrics::SimpleCounter;
17use hopr_primitive_types::prelude::*;
18use serde::{Deserialize, Serialize};
19use serde_with::{DisplayFromStr, serde_as};
20use tracing::{debug, info};
21use validator::Validate;
22
23use crate::{Strategy, errors::StrategyError::CriteriaNotSatisfied, strategy::SingularStrategy};
24
25#[cfg(all(feature = "prometheus", not(test)))]
26lazy_static::lazy_static! {
27    static ref METRIC_COUNT_AUTO_REDEEMS: SimpleCounter =
28        SimpleCounter::new("hopr_strategy_auto_redeem_redeem_count", "Count of initiated automatic redemptions").unwrap();
29}
30
31fn just_true() -> bool {
32    true
33}
34
35fn min_redeem_hopr() -> HoprBalance {
36    HoprBalance::from_str("0.09 wxHOPR").unwrap()
37}
38
39/// Configuration object for the `AutoRedeemingStrategy`
40#[serde_as]
41#[derive(Debug, Clone, Copy, PartialEq, Eq, smart_default::SmartDefault, Validate, Serialize, Deserialize)]
42pub struct AutoRedeemingStrategyConfig {
43    /// If set, the strategy will redeem only aggregated tickets.
44    /// Otherwise, it redeems all acknowledged winning tickets.
45    ///
46    /// Default is `true`.
47    #[serde(default = "just_true")]
48    #[default = true]
49    pub redeem_only_aggregated: bool,
50
51    /// If set to true, will redeem all tickets in the channel (which are over the
52    /// `minimum_redeem_ticket_value` threshold) once it transitions to `PendingToClose`.
53    ///
54    /// Default is `true`.
55    #[serde(default = "just_true")]
56    #[default = true]
57    pub redeem_all_on_close: bool,
58
59    /// The strategy will only redeem an acknowledged winning ticket if it has at least this value of HOPR.
60    /// If 0 is given, the strategy will redeem tickets regardless of their value.
61    /// This is not used for cases where `on_close_redeem_single_tickets_value_min` applies.
62    ///
63    /// Default is `0.09 wxHOPR`.
64    #[serde(default = "min_redeem_hopr")]
65    #[serde_as(as = "DisplayFromStr")]
66    #[default(min_redeem_hopr())]
67    pub minimum_redeem_ticket_value: HoprBalance,
68
69    /// If set, the strategy will redeem each incoming winning ticket.
70    /// Otherwise, it will try to redeem tickets in all channels periodically.
71    ///
72    /// Set this to `true` when winning tickets are not happening too often (e.g., when winning probability
73    /// is below 1%).
74    /// Set this to `false` when winning tickets are happening very often (e.g., when winning probability
75    /// is above 1%).
76    ///
77    /// Default is `true`
78    #[serde(default = "just_true")]
79    #[default = true]
80    pub redeem_on_winning: bool,
81}
82
83/// The `AutoRedeemingStrategy` automatically sends an acknowledged ticket
84/// for redemption once encountered.
85/// The strategy does not await the result of the redemption.
86pub struct AutoRedeemingStrategy<A: TicketRedeemActions> {
87    hopr_chain_actions: A,
88    cfg: AutoRedeemingStrategyConfig,
89}
90
91impl<A: TicketRedeemActions> Debug for AutoRedeemingStrategy<A> {
92    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
93        write!(f, "{:?}", Strategy::AutoRedeeming(self.cfg))
94    }
95}
96
97impl<A: TicketRedeemActions> Display for AutoRedeemingStrategy<A> {
98    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
99        write!(f, "{}", Strategy::AutoRedeeming(self.cfg))
100    }
101}
102
103impl<A: TicketRedeemActions> AutoRedeemingStrategy<A> {
104    pub fn new(cfg: AutoRedeemingStrategyConfig, hopr_chain_actions: A) -> Self {
105        Self {
106            cfg,
107            hopr_chain_actions,
108        }
109    }
110}
111
112#[async_trait]
113impl<A> SingularStrategy for AutoRedeemingStrategy<A>
114where
115    A: TicketRedeemActions + Send + Sync,
116{
117    async fn on_tick(&self) -> crate::errors::Result<()> {
118        if !self.cfg.redeem_on_winning {
119            debug!("trying to redeem all tickets in all channels");
120
121            let count = self
122                .hopr_chain_actions
123                .redeem_all_tickets(self.cfg.minimum_redeem_ticket_value, self.cfg.redeem_only_aggregated)
124                .await?
125                .len();
126            if count > 0 {
127                #[cfg(all(feature = "prometheus", not(test)))]
128                METRIC_COUNT_AUTO_REDEEMS.increment_by(count as u64);
129
130                info!(count, "strategy issued ticket redemptions");
131            } else {
132                debug!(count, "strategy issued no ticket redemptions");
133            }
134
135            Ok(())
136        } else {
137            Err(CriteriaNotSatisfied)
138        }
139    }
140
141    async fn on_acknowledged_winning_ticket(&self, ack: &AcknowledgedTicket) -> crate::errors::Result<()> {
142        if self.cfg.redeem_on_winning
143            && ((!self.cfg.redeem_only_aggregated || ack.verified_ticket().is_aggregated())
144                && ack.verified_ticket().amount.ge(&self.cfg.minimum_redeem_ticket_value))
145        {
146            info!(%ack, "redeeming");
147
148            #[cfg(all(feature = "prometheus", not(test)))]
149            METRIC_COUNT_AUTO_REDEEMS.increment();
150
151            let rx = self.hopr_chain_actions.redeem_ticket(ack.clone()).await?;
152            std::mem::drop(rx); // The Receiver is not intentionally awaited here and the oneshot Sender can fail safely
153            Ok(())
154        } else {
155            Err(CriteriaNotSatisfied)
156        }
157    }
158
159    async fn on_own_channel_changed(
160        &self,
161        channel: &ChannelEntry,
162        direction: ChannelDirection,
163        change: ChannelChange,
164    ) -> crate::errors::Result<()> {
165        if direction != ChannelDirection::Incoming || !self.cfg.redeem_all_on_close {
166            return Ok(());
167        }
168
169        if let ChannelChange::Status { left: old, right: new } = change {
170            if old != ChannelStatus::Open || !matches!(new, ChannelStatus::PendingToClose(_)) {
171                debug!(?channel, "ignoring channel state change that's not in PendingToClose");
172                return Ok(());
173            }
174            info!(%channel, "channel transitioned to PendingToClose, checking if it has tickets to redeem");
175
176            let count = self
177                .hopr_chain_actions
178                .redeem_tickets_in_channel(
179                    channel,
180                    self.cfg.minimum_redeem_ticket_value,
181                    self.cfg.redeem_only_aggregated,
182                )
183                .await?
184                .len();
185
186            #[cfg(all(feature = "prometheus", not(test)))]
187            METRIC_COUNT_AUTO_REDEEMS.increment_by(count as u64);
188
189            if count > 0 {
190                info!(count, %channel, "tickets in channel being closed sent for redemption");
191                Ok(())
192            } else {
193                info!(%channel, "no redeemable tickets with minimum amount in channel being closed");
194                Err(CriteriaNotSatisfied)
195            }
196        } else {
197            Err(CriteriaNotSatisfied)
198        }
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use std::{
205        ops::Add,
206        time::{Duration, SystemTime},
207    };
208
209    use async_trait::async_trait;
210    use futures::{FutureExt, future::ok};
211    use hex_literal::hex;
212    use hopr_chain_actions::{
213        action_queue::{ActionConfirmation, PendingAction},
214        redeem::TicketRedeemActions,
215    };
216    use hopr_chain_types::{actions::Action, chain_events::ChainEventType};
217    use hopr_crypto_random::{Randomizable, random_bytes};
218    use hopr_crypto_types::prelude::*;
219    use hopr_db_sql::{
220        api::{info::DomainSeparator, tickets::TicketSelector},
221        db::HoprDb,
222        info::HoprDbInfoOperations,
223    };
224    use mockall::mock;
225
226    use super::*;
227
228    lazy_static::lazy_static! {
229        static ref ALICE: ChainKeypair = ChainKeypair::from_secret(&hex!("492057cf93e99b31d2a85bc5e98a9c3aa0021feec52c227cc8170e8f7d047775")).expect("lazy static keypair should be valid");
230        static ref BOB: ChainKeypair = ChainKeypair::from_secret(&hex!("48680484c6fc31bc881a0083e6e32b6dc789f9eaba0f8b981429fd346c697f8c")).expect("lazy static keypair should be valid");
231        static ref PRICE_PER_PACKET: U256 = 10000000000000000_u128.into(); // 0.01 HOPR
232    }
233
234    fn generate_random_ack_ticket(
235        index: u64,
236        idx_offset: u32,
237        worth_packets: u32,
238    ) -> anyhow::Result<AcknowledgedTicket> {
239        let hk1 = HalfKey::random();
240        let hk2 = HalfKey::random();
241
242        let challenge = Response::from_half_keys(&hk1, &hk2)?.to_challenge()?;
243
244        Ok(TicketBuilder::default()
245            .addresses(&*BOB, &*ALICE)
246            .amount(PRICE_PER_PACKET.div_f64(1.0f64)? * worth_packets)
247            .index(index)
248            .index_offset(idx_offset)
249            .win_prob(WinningProbability::ALWAYS)
250            .channel_epoch(4)
251            .challenge(challenge)
252            .build_signed(&BOB, &Hash::default())?
253            .into_acknowledged(Response::from_half_keys(&hk1, &hk2)?))
254    }
255
256    mock! {
257        TicketRedeemAct { }
258        #[async_trait]
259        impl TicketRedeemActions for TicketRedeemAct {
260            async fn redeem_all_tickets(&self, min_value: HoprBalance, only_aggregated: bool) -> hopr_chain_actions::errors::Result<Vec<PendingAction>>;
261            async fn redeem_tickets_with_counterparty(
262                &self,
263                counterparty: &Address,
264                min_value: HoprBalance,
265                only_aggregated: bool,
266            ) -> hopr_chain_actions::errors::Result<Vec<PendingAction>>;
267            async fn redeem_tickets_in_channel(
268                &self,
269                channel: &ChannelEntry,
270                min_value: HoprBalance,
271                only_aggregated: bool,
272            ) -> hopr_chain_actions::errors::Result<Vec<PendingAction >>;
273            async fn redeem_tickets(&self, selector: TicketSelector) -> hopr_chain_actions::errors::Result<Vec<PendingAction>>;
274            async fn redeem_ticket(&self, ack: AcknowledgedTicket) -> hopr_chain_actions::errors::Result<PendingAction>;
275        }
276    }
277
278    fn mock_action_confirmation(ack: AcknowledgedTicket) -> anyhow::Result<ActionConfirmation> {
279        let random_hash = Hash::from(random_bytes::<{ Hash::SIZE }>());
280        Ok(ActionConfirmation {
281            tx_hash: random_hash,
282            event: Some(ChainEventType::TicketRedeemed(
283                ChannelEntry::new(
284                    BOB.public().to_address(),
285                    ALICE.public().to_address(),
286                    10.into(),
287                    U256::zero(),
288                    ChannelStatus::Open,
289                    U256::zero(),
290                ),
291                Some(ack.clone()),
292            )),
293            action: Action::RedeemTicket(ack.into_redeemable(&ALICE, &Hash::default())?),
294        })
295    }
296
297    #[tokio::test]
298    async fn test_auto_redeeming_strategy_redeem() -> anyhow::Result<()> {
299        let db = HoprDb::new_in_memory(ALICE.clone()).await?;
300        db.set_domain_separator(None, DomainSeparator::Channel, Default::default())
301            .await?;
302
303        let ack_ticket = generate_random_ack_ticket(0, 1, 5)?;
304
305        let mut actions = MockTicketRedeemAct::new();
306        let mock_confirm = mock_action_confirmation(ack_ticket.clone())?;
307        actions
308            .expect_redeem_ticket()
309            .once()
310            .with(mockall::predicate::eq(ack_ticket.clone()))
311            .return_once(move |_| Ok(ok(mock_confirm).boxed()));
312
313        let cfg = AutoRedeemingStrategyConfig {
314            redeem_only_aggregated: false,
315            minimum_redeem_ticket_value: 0.into(),
316            redeem_on_winning: true,
317            ..Default::default()
318        };
319
320        let ars = AutoRedeemingStrategy::new(cfg, actions);
321        ars.on_acknowledged_winning_ticket(&ack_ticket).await?;
322        assert!(ars.on_tick().await.is_err());
323
324        Ok(())
325    }
326
327    #[tokio::test]
328    async fn test_auto_redeeming_strategy_redeem_on_tick() -> anyhow::Result<()> {
329        let ack_ticket = generate_random_ack_ticket(0, 1, 5)?;
330
331        let mut actions = MockTicketRedeemAct::new();
332        let mock_confirm = mock_action_confirmation(ack_ticket.clone())?;
333        actions
334            .expect_redeem_all_tickets()
335            .once()
336            .with(
337                mockall::predicate::eq(HoprBalance::from(*PRICE_PER_PACKET * 5)),
338                mockall::predicate::eq(false),
339            )
340            .return_once(move |_, _| Ok(vec![ok(mock_confirm).boxed()]));
341
342        let cfg = AutoRedeemingStrategyConfig {
343            redeem_only_aggregated: false,
344            minimum_redeem_ticket_value: HoprBalance::from(*PRICE_PER_PACKET * 5),
345            redeem_on_winning: false,
346            ..Default::default()
347        };
348
349        let ars = AutoRedeemingStrategy::new(cfg, actions);
350        ars.on_tick().await?;
351        assert!(ars.on_acknowledged_winning_ticket(&ack_ticket).await.is_err());
352
353        Ok(())
354    }
355
356    #[tokio::test]
357    async fn test_auto_redeeming_strategy_should_not_redeem_unworthy_tickets_on_tick() -> anyhow::Result<()> {
358        // Make the ticket worth less than the threshold
359        let ack_ticket = generate_random_ack_ticket(0, 1, 4)?;
360
361        let mut actions = MockTicketRedeemAct::new();
362        let mock_confirm = mock_action_confirmation(ack_ticket.clone())?;
363        actions
364            .expect_redeem_all_tickets()
365            .once()
366            .with(
367                mockall::predicate::eq(HoprBalance::from(*PRICE_PER_PACKET * 5)),
368                mockall::predicate::eq(false),
369            )
370            .return_once(move |_, _| Ok(vec![ok(mock_confirm).boxed()]));
371
372        let cfg = AutoRedeemingStrategyConfig {
373            redeem_only_aggregated: false,
374            minimum_redeem_ticket_value: HoprBalance::from(*PRICE_PER_PACKET * 5),
375            redeem_on_winning: false,
376            ..Default::default()
377        };
378
379        let ars = AutoRedeemingStrategy::new(cfg, actions);
380        ars.on_tick().await?;
381        assert!(ars.on_acknowledged_winning_ticket(&ack_ticket).await.is_err());
382
383        Ok(())
384    }
385
386    #[tokio::test]
387    async fn test_auto_redeeming_strategy_redeem_agg_only() -> anyhow::Result<()> {
388        let ack_ticket_unagg = generate_random_ack_ticket(0, 1, 5)?;
389        let ack_ticket_agg = generate_random_ack_ticket(0, 3, 5)?;
390
391        let mut actions = MockTicketRedeemAct::new();
392        let mock_confirm = mock_action_confirmation(ack_ticket_agg.clone())?;
393        actions
394            .expect_redeem_ticket()
395            .once()
396            .with(mockall::predicate::eq(ack_ticket_agg.clone()))
397            .return_once(|_| Ok(ok(mock_confirm).boxed()));
398
399        let cfg = AutoRedeemingStrategyConfig {
400            redeem_only_aggregated: true,
401            minimum_redeem_ticket_value: 0.into(),
402            redeem_on_winning: true,
403            ..Default::default()
404        };
405
406        let ars = AutoRedeemingStrategy::new(cfg, actions);
407        ars.on_acknowledged_winning_ticket(&ack_ticket_unagg)
408            .await
409            .expect_err("non-agg ticket should not satisfy");
410        ars.on_acknowledged_winning_ticket(&ack_ticket_agg).await?;
411
412        Ok(())
413    }
414
415    #[tokio::test]
416    async fn test_auto_redeeming_strategy_redeem_minimum_ticket_amount() -> anyhow::Result<()> {
417        let ack_ticket_below = generate_random_ack_ticket(1, 1, 4)?;
418        let ack_ticket_at = generate_random_ack_ticket(1, 1, 5)?;
419
420        let mock_confirm = mock_action_confirmation(ack_ticket_at.clone())?;
421        let mut actions = MockTicketRedeemAct::new();
422        actions
423            .expect_redeem_ticket()
424            .once()
425            .with(mockall::predicate::eq(ack_ticket_at.clone()))
426            .return_once(|_| Ok(ok(mock_confirm).boxed()));
427
428        let cfg = AutoRedeemingStrategyConfig {
429            redeem_only_aggregated: false,
430            minimum_redeem_ticket_value: HoprBalance::from(*PRICE_PER_PACKET * 5),
431            redeem_on_winning: true,
432            ..Default::default()
433        };
434
435        let ars = AutoRedeemingStrategy::new(cfg, actions);
436        ars.on_acknowledged_winning_ticket(&ack_ticket_below)
437            .await
438            .expect_err("ticket below threshold should not satisfy");
439        ars.on_acknowledged_winning_ticket(&ack_ticket_at).await?;
440
441        Ok(())
442    }
443
444    #[tokio::test]
445    async fn test_auto_redeeming_strategy_should_redeem_singular_ticket_on_close() -> anyhow::Result<()> {
446        let channel = ChannelEntry::new(
447            BOB.public().to_address(),
448            ALICE.public().to_address(),
449            10.into(),
450            0.into(),
451            ChannelStatus::PendingToClose(SystemTime::now().add(Duration::from_secs(100))),
452            4.into(),
453        );
454
455        // Make the ticket worth exactly the threshold
456        let ack_ticket = generate_random_ack_ticket(0, 1, 5)?;
457
458        let mut actions = MockTicketRedeemAct::new();
459        let mock_confirm = mock_action_confirmation(ack_ticket)?;
460        actions
461            .expect_redeem_tickets_in_channel()
462            .once()
463            .with(
464                mockall::predicate::eq(channel),
465                mockall::predicate::eq(HoprBalance::from(*PRICE_PER_PACKET * 5)),
466                mockall::predicate::eq(true),
467            )
468            .return_once(move |_, _, _| Ok(vec![ok(mock_confirm).boxed()]));
469
470        let cfg = AutoRedeemingStrategyConfig {
471            redeem_only_aggregated: true,
472            redeem_all_on_close: true,
473            minimum_redeem_ticket_value: HoprBalance::from(*PRICE_PER_PACKET * 5),
474            ..Default::default()
475        };
476
477        let ars = AutoRedeemingStrategy::new(cfg, actions);
478        ars.on_own_channel_changed(
479            &channel,
480            ChannelDirection::Incoming,
481            ChannelChange::Status {
482                left: ChannelStatus::Open,
483                right: channel.status,
484            },
485        )
486        .await?;
487
488        Ok(())
489    }
490
491    #[tokio::test]
492    async fn test_auto_redeeming_strategy_should_not_redeem_unworthy_tickets_on_close() -> anyhow::Result<()> {
493        let channel = ChannelEntry::new(
494            BOB.public().to_address(),
495            ALICE.public().to_address(),
496            10.into(),
497            0.into(),
498            ChannelStatus::PendingToClose(SystemTime::now().add(Duration::from_secs(100))),
499            4.into(),
500        );
501
502        let ack_ticket = generate_random_ack_ticket(1, 1, 3)?;
503
504        let mut actions = MockTicketRedeemAct::new();
505        let mock_confirm = mock_action_confirmation(ack_ticket.clone())?;
506        actions
507            .expect_redeem_tickets_in_channel()
508            .once()
509            .with(
510                mockall::predicate::eq(channel),
511                mockall::predicate::eq(HoprBalance::from(*PRICE_PER_PACKET * 5)),
512                mockall::predicate::eq(false),
513            )
514            .return_once(move |_, _, _| Ok(vec![ok(mock_confirm).boxed()]));
515
516        let cfg = AutoRedeemingStrategyConfig {
517            minimum_redeem_ticket_value: HoprBalance::from(*PRICE_PER_PACKET * 5),
518            redeem_only_aggregated: false,
519            redeem_all_on_close: true,
520            ..Default::default()
521        };
522
523        let ars = AutoRedeemingStrategy::new(cfg, actions);
524        ars.on_own_channel_changed(
525            &channel,
526            ChannelDirection::Incoming,
527            ChannelChange::Status {
528                left: ChannelStatus::Open,
529                right: channel.status,
530            },
531        )
532        .await?;
533
534        Ok(())
535    }
536}