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