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> 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 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 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 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}