1use 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#[serde_as]
45#[derive(Debug, Clone, Copy, PartialEq, Eq, smart_default::SmartDefault, Validate, Serialize, Deserialize)]
46pub struct AutoRedeemingStrategyConfig {
47 #[serde(default = "just_true")]
52 #[default = true]
53 pub redeem_only_aggregated: bool,
54
55 #[serde(default = "just_true")]
60 #[default = true]
61 pub redeem_all_on_close: bool,
62
63 #[serde(default = "min_redeem_hopr")]
69 #[serde_as(as = "DisplayFromStr")]
70 #[default(min_redeem_hopr())]
71 pub minimum_redeem_ticket_value: HoprBalance,
72}
73
74pub 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); 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 (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(); }
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 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 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}