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