Skip to main content

hopr_chain_connector/connector/
tickets.rs

1use 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        // Does not block unless Blokli constantly pushes updated winning probability or ticket price update
17        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        // Do a fresh Blokli query to ensure the channel is not closed.
42        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                        // For ticket redemption, certain errors are to be handled differently
133                        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        // Close the channel from the incoming side
394        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}