hopr_chain_connector/connector/
tickets.rs1use blokli_client::api::{BlokliQueryClient, BlokliTransactionClient};
2use futures::{FutureExt, TryFutureExt, future::BoxFuture};
3use hopr_api::{
4 chain::{ChainReceipt, TicketRedeemError},
5 types::{chain::prelude::*, crypto::prelude::*, internal::prelude::*, primitive::prelude::HoprBalance},
6};
7
8use crate::{backend::Backend, connector::HoprBlockchainConnector, errors::ConnectorError};
9
10impl<B, C, P, R> hopr_api::chain::ChainReadTicketOperations for HoprBlockchainConnector<C, B, P, R> {
11 type Error = ConnectorError;
12
13 fn incoming_ticket_values(&self) -> Result<(WinningProbability, HoprBalance), Self::Error> {
14 self.check_connection_state()?;
15
16 self.ticket_values
18 .read()
19 .as_ref()
20 .copied()
21 .ok_or_else(|| ConnectorError::InvalidState("connector is not connected"))
22 }
23}
24
25impl<B, C, P> HoprBlockchainConnector<C, B, P, P::TxRequest>
26where
27 B: Backend + Send + Sync + 'static,
28 C: BlokliTransactionClient + BlokliQueryClient + Send + Sync + 'static,
29 P: PayloadGenerator + Send + Sync + 'static,
30 P::TxRequest: Send + Sync + 'static,
31{
32 async fn prepare_ticket_redeem_payload(&self, ticket: RedeemableTicket) -> Result<P::TxRequest, ConnectorError> {
33 self.check_connection_state()?;
34
35 let channel_id = *ticket.ticket.channel_id();
36 if generate_channel_id(ticket.ticket.verified_issuer(), self.chain_key.public().as_ref()) != channel_id {
37 tracing::error!(%channel_id, "redeemed ticket is not ours");
38 return Err(ConnectorError::InvalidTicket);
39 }
40
41 let channel = self
43 .client
44 .query_channels(blokli_client::api::ChannelSelector {
45 filter: Some(blokli_client::api::ChannelFilter::ChannelId(
46 ticket.ticket.channel_id().into(),
47 )),
48 status: None,
49 ..Default::default()
50 })
51 .await?
52 .channels
53 .first()
54 .cloned()
55 .ok_or_else(|| {
56 tracing::error!(%channel_id, "trying to redeem a ticket on a channel that does not exist");
57 ConnectorError::ChannelDoesNotExist(channel_id)
58 })?;
59
60 if channel.status == blokli_client::api::types::ChannelStatus::Closed {
61 tracing::error!(%channel_id, "trying to redeem a ticket on a closed channel");
62 return Err(ConnectorError::ChannelClosed(channel_id));
63 }
64
65 if channel.epoch as u32 != ticket.verified_ticket().channel_epoch {
66 tracing::error!(
67 channel_epoch = channel.epoch,
68 ticket_epoch = ticket.verified_ticket().channel_epoch,
69 "invalid redeemed ticket epoch"
70 );
71 return Err(ConnectorError::InvalidTicket);
72 }
73
74 let channel_index: u64 = channel
75 .ticket_index
76 .0
77 .parse()
78 .map_err(|e| ConnectorError::TypeConversion(format!("unparseable channel index at redemption: {e}")))?;
79
80 if channel_index > ticket.verified_ticket().index {
81 tracing::error!(
82 channel_index,
83 ticket_index = ticket.verified_ticket().index,
84 "invalid redeemed ticket index"
85 );
86 return Err(ConnectorError::InvalidTicket);
87 }
88
89 let channel_stake: HoprBalance = channel
90 .balance
91 .0
92 .parse()
93 .map_err(|e| ConnectorError::TypeConversion(format!("unparseable channel stake at redemption: {e}")))?;
94
95 if channel_stake < ticket.verified_ticket().amount {
96 tracing::error!(
97 %channel_stake,
98 ticket_amount = %ticket.verified_ticket().amount,
99 "insufficient stake in channel to redeem ticket"
100 );
101 return Err(ConnectorError::InvalidTicket);
102 }
103
104 Ok(self.payload_generator.redeem_ticket(ticket)?)
105 }
106}
107
108#[async_trait::async_trait]
109impl<B, C, P> hopr_api::chain::ChainWriteTicketOperations for HoprBlockchainConnector<C, B, P, P::TxRequest>
110where
111 B: Backend + Send + Sync + 'static,
112 C: BlokliTransactionClient + BlokliQueryClient + Send + Sync + 'static,
113 P: PayloadGenerator + Send + Sync + 'static,
114 P::TxRequest: Send + Sync + 'static,
115{
116 type Error = ConnectorError;
117
118 async fn redeem_ticket<'a>(
119 &'a self,
120 ticket: RedeemableTicket,
121 ) -> Result<
122 BoxFuture<'a, Result<(VerifiedTicket, ChainReceipt), TicketRedeemError<Self::Error>>>,
123 TicketRedeemError<Self::Error>,
124 > {
125 match self.prepare_ticket_redeem_payload(ticket).await {
126 Ok(tx_req) => {
127 Ok(self
128 .send_tx(tx_req, None)
129 .await
130 .map_err(|e| TicketRedeemError::ProcessingError(ticket.ticket, e))?
131 .map_err(move |tx_tracking_error|
132 if let ConnectorError::InnerTxFailed(reason) = tx_tracking_error {
134 TicketRedeemError::Rejected(ticket.ticket, format!("inner transaction rejected: {reason}"))
135 } else {
136 TicketRedeemError::ProcessingError(ticket.ticket, tx_tracking_error)
137 })
138 .and_then(move |receipt| futures::future::ok((ticket.ticket, receipt)))
139 .boxed())
140 }
141 Err(e @ ConnectorError::InvalidTicket)
142 | Err(e @ ConnectorError::ChannelDoesNotExist(_))
143 | Err(e @ ConnectorError::ChannelClosed(_)) => {
144 Err(TicketRedeemError::Rejected(ticket.ticket, e.to_string()))
145 }
146 Err(e) => Err(TicketRedeemError::ProcessingError(ticket.ticket, e)),
147 }
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use std::time::Duration;
154
155 use blokli_client::BlokliTestClient;
156 use hex_literal::hex;
157 use hopr_api::{
158 chain::{ChainValues, ChainWriteChannelOperations, ChainWriteTicketOperations},
159 types::primitive::prelude::*,
160 };
161
162 use super::*;
163 use crate::{
164 connector::tests::*,
165 testing::{BlokliTestStateBuilder, FullStateEmulator},
166 };
167
168 fn prepare_client() -> anyhow::Result<BlokliTestClient<FullStateEmulator>> {
169 let offchain_key_1 = OffchainKeypair::from_secret(&hex!(
170 "60741b83b99e36aa0c1331578156e16b8e21166d01834abb6c64b103f885734d"
171 ))?;
172 let account_1 = AccountEntry {
173 public_key: *offchain_key_1.public(),
174 chain_addr: ChainKeypair::from_secret(&PRIVATE_KEY_1)?.public().to_address(),
175 entry_type: AccountType::NotAnnounced,
176 safe_address: Some([1u8; Address::SIZE].into()),
177 key_id: 1.into(),
178 };
179 let offchain_key_2 = OffchainKeypair::from_secret(&hex!(
180 "71bf1f42ebbfcd89c3e197a3fd7cda79b92499e509b6fefa0fe44d02821d146a"
181 ))?;
182 let account_2 = AccountEntry {
183 public_key: *offchain_key_2.public(),
184 chain_addr: ChainKeypair::from_secret(&PRIVATE_KEY_2)?.public().to_address(),
185 entry_type: AccountType::NotAnnounced,
186 safe_address: Some([2u8; Address::SIZE].into()),
187 key_id: 2.into(),
188 };
189
190 let channel_1 = ChannelEntry::builder()
191 .between(
192 &ChainKeypair::from_secret(&PRIVATE_KEY_2)?,
193 &ChainKeypair::from_secret(&PRIVATE_KEY_1)?,
194 )
195 .amount(10)
196 .ticket_index(1)
197 .status(ChannelStatus::Open)
198 .epoch(1)
199 .build()?;
200
201 Ok(BlokliTestStateBuilder::default()
202 .with_accounts([
203 (account_1, HoprBalance::new_base(100), XDaiBalance::new_base(1)),
204 (account_2, HoprBalance::new_base(100), XDaiBalance::new_base(1)),
205 ])
206 .with_channels([channel_1])
207 .with_hopr_network_chain_info("rotsee")
208 .build_dynamic_client(MODULE_ADDR.into())
209 .with_tx_simulation_delay(Duration::from_millis(100)))
210 }
211
212 #[tokio::test]
213 async fn connector_should_redeem_ticket() -> anyhow::Result<()> {
214 let blokli_client = prepare_client()?;
215
216 let mut connector = create_connector(blokli_client)?;
217 connector.connect().await?;
218
219 let hkc1 = ChainKeypair::from_secret(&hex!(
220 "e17fe86ce6e99f4806715b0c9412f8dad89334bf07f72d5834207a9d8f19d7f8"
221 ))?;
222 let hkc2 = ChainKeypair::from_secret(&hex!(
223 "492057cf93e99b31d2a85bc5e98a9c3aa0021feec52c227cc8170e8f7d047775"
224 ))?;
225
226 let ticket = TicketBuilder::default()
227 .counterparty(&ChainKeypair::from_secret(&PRIVATE_KEY_1)?)
228 .amount(1)
229 .index(1)
230 .channel_epoch(1)
231 .eth_challenge(
232 Challenge::from_hint_and_share(
233 &HalfKeyChallenge::new(hkc1.public().as_ref()),
234 &HalfKeyChallenge::new(hkc2.public().as_ref()),
235 )?
236 .to_ethereum_challenge(),
237 )
238 .build_signed(&ChainKeypair::from_secret(&PRIVATE_KEY_2)?, &Hash::default())?
239 .into_acknowledged(Response::from_half_keys(
240 &HalfKey::try_from(hkc1.secret().as_ref())?,
241 &HalfKey::try_from(hkc2.secret().as_ref())?,
242 )?)
243 .into_redeemable(&ChainKeypair::from_secret(&PRIVATE_KEY_1)?, &Hash::default())?;
244
245 connector.redeem_ticket(ticket).await?.await?;
246
247 insta::assert_yaml_snapshot!(*connector.client().snapshot());
248
249 Ok(())
250 }
251
252 #[tokio::test]
253 async fn connector_should_redeem_ticket_and_increase_redeemed_stats() -> anyhow::Result<()> {
254 let blokli_client = prepare_client()?;
255
256 let mut connector = create_connector(blokli_client)?;
257 connector.connect().await?;
258
259 let hkc1 = ChainKeypair::from_secret(&hex!(
260 "e17fe86ce6e99f4806715b0c9412f8dad89334bf07f72d5834207a9d8f19d7f8"
261 ))?;
262 let hkc2 = ChainKeypair::from_secret(&hex!(
263 "492057cf93e99b31d2a85bc5e98a9c3aa0021feec52c227cc8170e8f7d047775"
264 ))?;
265
266 let ticket = TicketBuilder::default()
267 .counterparty(&ChainKeypair::from_secret(&PRIVATE_KEY_1)?)
268 .amount(1)
269 .index(1)
270 .channel_epoch(1)
271 .eth_challenge(
272 Challenge::from_hint_and_share(
273 &HalfKeyChallenge::new(hkc1.public().as_ref()),
274 &HalfKeyChallenge::new(hkc2.public().as_ref()),
275 )?
276 .to_ethereum_challenge(),
277 )
278 .build_signed(&ChainKeypair::from_secret(&PRIVATE_KEY_2)?, &Hash::default())?
279 .into_acknowledged(Response::from_half_keys(
280 &HalfKey::try_from(hkc1.secret().as_ref())?,
281 &HalfKey::try_from(hkc2.secret().as_ref())?,
282 )?)
283 .into_redeemable(&ChainKeypair::from_secret(&PRIVATE_KEY_1)?, &Hash::default())?;
284
285 connector.redeem_ticket(ticket).await?.await?;
286
287 let ticket = TicketBuilder::default()
288 .counterparty(&ChainKeypair::from_secret(&PRIVATE_KEY_1)?)
289 .amount(1)
290 .index(2)
291 .channel_epoch(1)
292 .eth_challenge(
293 Challenge::from_hint_and_share(
294 &HalfKeyChallenge::new(hkc1.public().as_ref()),
295 &HalfKeyChallenge::new(hkc2.public().as_ref()),
296 )?
297 .to_ethereum_challenge(),
298 )
299 .build_signed(&ChainKeypair::from_secret(&PRIVATE_KEY_2)?, &Hash::default())?
300 .into_acknowledged(Response::from_half_keys(
301 &HalfKey::try_from(hkc1.secret().as_ref())?,
302 &HalfKey::try_from(hkc2.secret().as_ref())?,
303 )?)
304 .into_redeemable(&ChainKeypair::from_secret(&PRIVATE_KEY_1)?, &Hash::default())?;
305
306 connector.redeem_ticket(ticket).await?.await?;
307
308 insta::assert_yaml_snapshot!(*connector.client().snapshot());
309
310 let stats = connector.redemption_stats([1u8; Address::SIZE]).await?;
311 assert_eq!(2, stats.redeemed_count);
312 assert_eq!(HoprBalance::from(2), stats.redeemed_value);
313
314 Ok(())
315 }
316
317 #[tokio::test]
318 async fn connector_should_not_redeem_ticket_on_non_existing_channel() -> anyhow::Result<()> {
319 let blokli_client = prepare_client()?;
320
321 let mut connector = create_connector(blokli_client)?;
322 connector.connect().await?;
323
324 let hkc1 = ChainKeypair::from_secret(&hex!(
325 "e17fe86ce6e99f4806715b0c9412f8dad89334bf07f72d5834207a9d8f19d7f8"
326 ))?;
327 let hkc2 = ChainKeypair::from_secret(&hex!(
328 "492057cf93e99b31d2a85bc5e98a9c3aa0021feec52c227cc8170e8f7d047775"
329 ))?;
330
331 let ticket = TicketBuilder::default()
332 .counterparty(&ChainKeypair::from_secret(&PRIVATE_KEY_2)?)
333 .amount(1)
334 .index(1)
335 .channel_epoch(1)
336 .eth_challenge(
337 Challenge::from_hint_and_share(
338 &HalfKeyChallenge::new(hkc1.public().as_ref()),
339 &HalfKeyChallenge::new(hkc2.public().as_ref()),
340 )?
341 .to_ethereum_challenge(),
342 )
343 .build_signed(&ChainKeypair::from_secret(&PRIVATE_KEY_1)?, &Hash::default())?
344 .into_acknowledged(Response::from_half_keys(
345 &HalfKey::try_from(hkc1.secret().as_ref())?,
346 &HalfKey::try_from(hkc2.secret().as_ref())?,
347 )?)
348 .into_redeemable(&ChainKeypair::from_secret(&PRIVATE_KEY_2)?, &Hash::default())?;
349
350 assert!(matches!(
351 connector.redeem_ticket(ticket).await,
352 Err(TicketRedeemError::Rejected(_, _))
353 ));
354
355 insta::assert_yaml_snapshot!(*connector.client().snapshot());
356
357 Ok(())
358 }
359
360 #[tokio::test]
361 async fn connector_should_not_redeem_ticket_on_closed_channel() -> anyhow::Result<()> {
362 let blokli_client = prepare_client()?;
363
364 let mut connector = create_connector(blokli_client)?;
365 connector.connect().await?;
366
367 let hkc1 = ChainKeypair::from_secret(&hex!(
368 "e17fe86ce6e99f4806715b0c9412f8dad89334bf07f72d5834207a9d8f19d7f8"
369 ))?;
370 let hkc2 = ChainKeypair::from_secret(&hex!(
371 "492057cf93e99b31d2a85bc5e98a9c3aa0021feec52c227cc8170e8f7d047775"
372 ))?;
373
374 let ticket = TicketBuilder::default()
375 .counterparty(&ChainKeypair::from_secret(&PRIVATE_KEY_1)?)
376 .amount(1)
377 .index(1)
378 .channel_epoch(1)
379 .eth_challenge(
380 Challenge::from_hint_and_share(
381 &HalfKeyChallenge::new(hkc1.public().as_ref()),
382 &HalfKeyChallenge::new(hkc2.public().as_ref()),
383 )?
384 .to_ethereum_challenge(),
385 )
386 .build_signed(&ChainKeypair::from_secret(&PRIVATE_KEY_2)?, &Hash::default())?
387 .into_acknowledged(Response::from_half_keys(
388 &HalfKey::try_from(hkc1.secret().as_ref())?,
389 &HalfKey::try_from(hkc2.secret().as_ref())?,
390 )?)
391 .into_redeemable(&ChainKeypair::from_secret(&PRIVATE_KEY_1)?, &Hash::default())?;
392
393 connector.close_channel(ticket.ticket.channel_id()).await?.await?;
395
396 assert!(matches!(
397 connector.redeem_ticket(ticket).await,
398 Err(TicketRedeemError::Rejected(_, _))
399 ));
400
401 insta::assert_yaml_snapshot!(*connector.client().snapshot());
402
403 Ok(())
404 }
405
406 #[tokio::test]
407 async fn connector_should_not_redeem_ticket_with_old_index() -> anyhow::Result<()> {
408 let blokli_client = prepare_client()?;
409
410 let mut connector = create_connector(blokli_client)?;
411 connector.connect().await?;
412
413 let hkc1 = ChainKeypair::from_secret(&hex!(
414 "e17fe86ce6e99f4806715b0c9412f8dad89334bf07f72d5834207a9d8f19d7f8"
415 ))?;
416 let hkc2 = ChainKeypair::from_secret(&hex!(
417 "492057cf93e99b31d2a85bc5e98a9c3aa0021feec52c227cc8170e8f7d047775"
418 ))?;
419
420 let ticket = TicketBuilder::default()
421 .counterparty(&ChainKeypair::from_secret(&PRIVATE_KEY_1)?)
422 .amount(1)
423 .index(0)
424 .channel_epoch(1)
425 .eth_challenge(
426 Challenge::from_hint_and_share(
427 &HalfKeyChallenge::new(hkc1.public().as_ref()),
428 &HalfKeyChallenge::new(hkc2.public().as_ref()),
429 )?
430 .to_ethereum_challenge(),
431 )
432 .build_signed(&ChainKeypair::from_secret(&PRIVATE_KEY_2)?, &Hash::default())?
433 .into_acknowledged(Response::from_half_keys(
434 &HalfKey::try_from(hkc1.secret().as_ref())?,
435 &HalfKey::try_from(hkc2.secret().as_ref())?,
436 )?)
437 .into_redeemable(&ChainKeypair::from_secret(&PRIVATE_KEY_1)?, &Hash::default())?;
438
439 assert!(matches!(
440 connector.redeem_ticket(ticket).await,
441 Err(TicketRedeemError::Rejected(_, _))
442 ));
443
444 insta::assert_yaml_snapshot!(*connector.client().snapshot());
445
446 Ok(())
447 }
448
449 #[tokio::test]
450 async fn connector_should_not_redeem_ticket_with_amount_higher_than_channel_stake() -> anyhow::Result<()> {
451 let blokli_client = prepare_client()?;
452
453 let mut connector = create_connector(blokli_client)?;
454 connector.connect().await?;
455
456 let hkc1 = ChainKeypair::from_secret(&hex!(
457 "e17fe86ce6e99f4806715b0c9412f8dad89334bf07f72d5834207a9d8f19d7f8"
458 ))?;
459 let hkc2 = ChainKeypair::from_secret(&hex!(
460 "492057cf93e99b31d2a85bc5e98a9c3aa0021feec52c227cc8170e8f7d047775"
461 ))?;
462
463 let ticket = TicketBuilder::default()
464 .counterparty(&ChainKeypair::from_secret(&PRIVATE_KEY_1)?)
465 .amount(100000)
466 .index(1)
467 .channel_epoch(1)
468 .eth_challenge(
469 Challenge::from_hint_and_share(
470 &HalfKeyChallenge::new(hkc1.public().as_ref()),
471 &HalfKeyChallenge::new(hkc2.public().as_ref()),
472 )?
473 .to_ethereum_challenge(),
474 )
475 .build_signed(&ChainKeypair::from_secret(&PRIVATE_KEY_2)?, &Hash::default())?
476 .into_acknowledged(Response::from_half_keys(
477 &HalfKey::try_from(hkc1.secret().as_ref())?,
478 &HalfKey::try_from(hkc2.secret().as_ref())?,
479 )?)
480 .into_redeemable(&ChainKeypair::from_secret(&PRIVATE_KEY_1)?, &Hash::default())?;
481
482 assert!(matches!(
483 connector.redeem_ticket(ticket).await,
484 Err(TicketRedeemError::Rejected(_, _))
485 ));
486
487 insta::assert_yaml_snapshot!(*connector.client().snapshot());
488
489 Ok(())
490 }
491
492 #[tokio::test]
493 async fn connector_should_not_redeem_ticket_with_previous_epoch() -> anyhow::Result<()> {
494 let blokli_client = prepare_client()?;
495
496 let mut connector = create_connector(blokli_client)?;
497 connector.connect().await?;
498
499 let hkc1 = ChainKeypair::from_secret(&hex!(
500 "e17fe86ce6e99f4806715b0c9412f8dad89334bf07f72d5834207a9d8f19d7f8"
501 ))?;
502 let hkc2 = ChainKeypair::from_secret(&hex!(
503 "492057cf93e99b31d2a85bc5e98a9c3aa0021feec52c227cc8170e8f7d047775"
504 ))?;
505
506 let ticket = TicketBuilder::default()
507 .counterparty(&ChainKeypair::from_secret(&PRIVATE_KEY_1)?)
508 .amount(1)
509 .index(1)
510 .channel_epoch(0)
511 .eth_challenge(
512 Challenge::from_hint_and_share(
513 &HalfKeyChallenge::new(hkc1.public().as_ref()),
514 &HalfKeyChallenge::new(hkc2.public().as_ref()),
515 )?
516 .to_ethereum_challenge(),
517 )
518 .build_signed(&ChainKeypair::from_secret(&PRIVATE_KEY_2)?, &Hash::default())?
519 .into_acknowledged(Response::from_half_keys(
520 &HalfKey::try_from(hkc1.secret().as_ref())?,
521 &HalfKey::try_from(hkc2.secret().as_ref())?,
522 )?)
523 .into_redeemable(&ChainKeypair::from_secret(&PRIVATE_KEY_1)?, &Hash::default())?;
524
525 assert!(matches!(
526 connector.redeem_ticket(ticket).await,
527 Err(TicketRedeemError::Rejected(_, _))
528 ));
529
530 insta::assert_yaml_snapshot!(*connector.client().snapshot());
531
532 Ok(())
533 }
534}