hopr_chain_connector/connector/
tickets.rs

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