1use 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#[serde_as]
43#[derive(Debug, Clone, Copy, PartialEq, Eq, smart_default::SmartDefault, Validate, Serialize, Deserialize)]
44pub struct AutoRedeemingStrategyConfig {
45 #[serde(default = "just_true")]
50 #[default = true]
51 pub redeem_only_aggregated: bool,
52
53 #[serde(default = "just_true")]
58 #[default = true]
59 pub redeem_all_on_close: bool,
60
61 #[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
72pub 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); 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 (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(); }
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 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 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}