hopr_chain_connector/connector/
tickets.rs1use 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 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 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 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}