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_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#[serde_as]
41#[derive(Debug, Clone, Copy, PartialEq, Eq, smart_default::SmartDefault, Validate, Serialize, Deserialize)]
42pub struct AutoRedeemingStrategyConfig {
43 #[serde(default = "just_true")]
48 #[default = true]
49 pub redeem_only_aggregated: bool,
50
51 #[serde(default = "just_true")]
56 #[default = true]
57 pub redeem_all_on_close: bool,
58
59 #[serde(default = "min_redeem_hopr")]
65 #[serde_as(as = "DisplayFromStr")]
66 #[default(min_redeem_hopr())]
67 pub minimum_redeem_ticket_value: HoprBalance,
68
69 #[serde(default = "just_true")]
79 #[default = true]
80 pub redeem_on_winning: bool,
81}
82
83pub 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); 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(); }
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 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 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}