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