1use hopr_types::{crypto::types::Hash, internal::prelude::*, primitive::prelude::*};
2use tracing::{debug, instrument};
3
4use crate::errors::{TicketValidationError, ValidationErrorKind};
5
6#[instrument(level = "trace", skip_all, err)]
9pub fn validate_unacknowledged_ticket(
10 ticket: Ticket,
11 channel: &ChannelEntry,
12 min_ticket_amount: HoprBalance,
13 required_win_prob: WinningProbability,
14 unrealized_balance: HoprBalance,
15 domain_separator: &Hash,
16) -> Result<VerifiedTicket, TicketValidationError> {
17 debug!(source = %channel.source, %ticket, "validating unacknowledged ticket");
18
19 let verified_ticket = ticket
21 .verify(&channel.source, domain_separator)
22 .map_err(|ticket| TicketValidationError {
23 kind: ValidationErrorKind::InvalidSigner,
24 ticket,
25 issuer: None,
26 })?;
27
28 let inner_ticket = verified_ticket.verified_ticket();
29
30 if !inner_ticket.amount.ge(&min_ticket_amount) {
32 return Err(TicketValidationError {
33 kind: ValidationErrorKind::LowValue(min_ticket_amount),
34 ticket: (*inner_ticket).into(),
35 issuer: Some(*verified_ticket.verified_issuer()),
36 });
37 }
38
39 if verified_ticket.win_prob().approx_cmp(&required_win_prob).is_lt() {
41 return Err(TicketValidationError {
42 kind: ValidationErrorKind::LowWinProb(required_win_prob),
43 ticket: (*inner_ticket).into(),
44 issuer: Some(*verified_ticket.verified_issuer()),
45 });
46 }
47
48 if channel.status == ChannelStatus::Closed {
50 return Err(TicketValidationError {
51 kind: ValidationErrorKind::ChannelClosed(*channel.get_id()),
52 ticket: (*inner_ticket).into(),
53 issuer: Some(*verified_ticket.verified_issuer()),
54 });
55 }
56
57 if channel.channel_epoch != inner_ticket.channel_epoch {
59 return Err(TicketValidationError {
60 kind: ValidationErrorKind::EpochMismatch(channel.channel_epoch),
61 ticket: (*inner_ticket).into(),
62 issuer: Some(*verified_ticket.verified_issuer()),
63 });
64 }
65
66 if inner_ticket.counterparty != channel.destination {
70 return Err(TicketValidationError {
71 kind: ValidationErrorKind::WrongCounterparty(channel.destination),
72 ticket: (*inner_ticket).into(),
73 issuer: Some(*verified_ticket.verified_issuer()),
74 });
75 }
76
77 if inner_ticket.index < channel.ticket_index {
79 return Err(TicketValidationError {
80 kind: ValidationErrorKind::IndexTooLow(channel.ticket_index),
81 ticket: (*inner_ticket).into(),
82 issuer: Some(*verified_ticket.verified_issuer()),
83 });
84 }
85
86 debug!(%unrealized_balance, channel_id = %channel.get_id(), "checking if sender has enough funds");
88 if inner_ticket.amount.gt(&unrealized_balance) {
89 return Err(TicketValidationError {
90 kind: ValidationErrorKind::InsufficientFunds(*channel.get_id(), unrealized_balance),
91 ticket: (*inner_ticket).into(),
92 issuer: Some(*verified_ticket.verified_issuer()),
93 });
94 }
95
96 Ok(verified_ticket)
97}
98
99#[cfg(test)]
100mod tests {
101 use std::ops::Add;
102
103 use anyhow::Context;
104 use hex_literal::hex;
105 use hopr_types::crypto::prelude::*;
106 use lazy_static::lazy_static;
107
108 use super::*;
109 use crate::validation::validate_unacknowledged_ticket;
110
111 const SENDER_PRIV_BYTES: [u8; 32] = hex!("492057cf93e99b31d2a85bc5e98a9c3aa0021feec52c227cc8170e8f7d047775");
112 const TARGET_PRIV_BYTES: [u8; 32] = hex!("5bf21ea8cccd69aa784346b07bf79c84dac606e00eecaa68bf8c31aff397b1ca");
113
114 lazy_static! {
115 static ref SENDER_PRIV_KEY: ChainKeypair =
116 ChainKeypair::from_secret(&SENDER_PRIV_BYTES).expect("lazy static keypair should be valid");
117 static ref TARGET_PRIV_KEY: ChainKeypair =
118 ChainKeypair::from_secret(&TARGET_PRIV_BYTES).expect("lazy static keypair should be valid");
119 }
120
121 fn create_valid_ticket() -> anyhow::Result<Ticket> {
122 Ok(TicketBuilder::default()
123 .counterparty(&*TARGET_PRIV_KEY)
124 .amount(1)
125 .index(1)
126 .win_prob(1.0.try_into()?)
127 .channel_epoch(1)
128 .eth_challenge(Default::default())
129 .build_signed(&SENDER_PRIV_KEY, &Hash::default())?
130 .leak())
131 }
132
133 fn create_channel_entry() -> ChannelEntry {
134 ChannelEntry::builder()
135 .between(&*SENDER_PRIV_KEY, &*TARGET_PRIV_KEY)
136 .amount(100)
137 .ticket_index(0)
138 .status(ChannelStatus::Open)
139 .epoch(1)
140 .build()
141 .unwrap()
142 }
143
144 #[tokio::test]
145 async fn test_ticket_validation_should_pass_if_ticket_ok() -> anyhow::Result<()> {
146 let ticket = create_valid_ticket()?;
147 let channel = create_channel_entry();
148
149 let more_than_ticket_balance = ticket.amount.add(500);
150
151 let ret = validate_unacknowledged_ticket(
152 ticket,
153 &channel,
154 1.into(),
155 1.0.try_into()?,
156 more_than_ticket_balance,
157 &Hash::default(),
158 );
159
160 assert!(ret.is_ok());
161
162 Ok(())
163 }
164
165 #[tokio::test]
166 async fn test_ticket_validation_should_fail_if_signer_not_sender() -> anyhow::Result<()> {
167 let ticket = create_valid_ticket()?;
168 let channel = create_channel_entry();
169
170 let ret = validate_unacknowledged_ticket(
171 ticket,
172 &channel,
173 1.into(),
174 1.0f64.try_into()?,
175 0.into(),
176 &Hash::default(),
177 );
178
179 assert!(ret.is_err());
180
181 Ok(())
182 }
183
184 #[tokio::test]
185 async fn test_ticket_validation_should_fail_if_ticket_amount_is_low() -> anyhow::Result<()> {
186 let ticket = create_valid_ticket()?;
187 let channel = create_channel_entry();
188
189 let ret =
190 validate_unacknowledged_ticket(ticket, &channel, 2.into(), 1.0.try_into()?, 0.into(), &Hash::default());
191
192 assert!(ret.is_err());
193
194 Ok(())
195 }
196
197 #[tokio::test]
198 async fn test_ticket_validation_should_fail_if_ticket_chance_is_low() -> anyhow::Result<()> {
199 let mut ticket = create_valid_ticket()?;
200 ticket.encoded_win_prob = WinningProbability::try_from(0.5f64)?.into();
201 let ticket = *ticket.sign(&SENDER_PRIV_KEY, &Hash::default()).verified_ticket();
202
203 let channel = create_channel_entry();
204
205 let ret =
206 validate_unacknowledged_ticket(ticket, &channel, 1.into(), 1.0.try_into()?, 0.into(), &Hash::default());
207
208 assert!(ret.is_err());
209
210 Ok(())
211 }
212
213 #[tokio::test]
214 async fn test_ticket_validation_should_fail_if_channel_is_closed() -> anyhow::Result<()> {
215 let ticket = create_valid_ticket()?;
216 let mut channel = create_channel_entry();
217 channel.status = ChannelStatus::Closed;
218
219 let ret =
220 validate_unacknowledged_ticket(ticket, &channel, 1.into(), 1.0.try_into()?, 0.into(), &Hash::default());
221
222 assert!(ret.is_err());
223
224 Ok(())
225 }
226
227 #[tokio::test]
228 async fn test_ticket_validation_should_fail_if_ticket_epoch_does_not_match_2() -> anyhow::Result<()> {
229 let mut ticket = create_valid_ticket()?;
230 ticket.channel_epoch = 2u32;
231 let ticket = *ticket.sign(&SENDER_PRIV_KEY, &Hash::default()).verified_ticket();
232
233 let channel = create_channel_entry();
234
235 let ret =
236 validate_unacknowledged_ticket(ticket, &channel, 1.into(), 1.0.try_into()?, 0.into(), &Hash::default());
237
238 assert!(ret.is_err());
239
240 Ok(())
241 }
242
243 #[tokio::test]
244 async fn test_ticket_validation_fail_if_does_not_have_funds() -> anyhow::Result<()> {
245 let ticket = create_valid_ticket()?;
246 let mut channel = create_channel_entry();
247 channel.balance = 0.into();
248 channel.channel_epoch = ticket.channel_epoch;
249
250 let ret =
251 validate_unacknowledged_ticket(ticket, &channel, 1.into(), 1.0.try_into()?, 0.into(), &Hash::default());
252
253 assert!(ret.is_err());
254
255 Ok(())
256 }
257
258 #[tokio::test]
259 async fn test_ticket_validation_fail_when_index_is_lower_than_channel_index() -> anyhow::Result<()> {
260 let ticket = create_valid_ticket()?;
261 let mut channel = create_channel_entry();
262 channel.ticket_index = 2;
263 channel.channel_epoch = ticket.channel_epoch;
264
265 assert!(ticket.index < channel.ticket_index);
266
267 let ret =
268 validate_unacknowledged_ticket(ticket, &channel, 1.into(), 1.0.try_into()?, 0.into(), &Hash::default());
269
270 assert!(ret.is_err());
271
272 Ok(())
273 }
274
275 #[tokio::test]
276 async fn ticket_validation_should_pass_when_index_equals_channel_index() -> anyhow::Result<()> {
277 let ticket = create_valid_ticket()?;
278 let mut channel = create_channel_entry();
279 channel.ticket_index = ticket.index;
280 channel.channel_epoch = ticket.channel_epoch;
281
282 let ret = validate_unacknowledged_ticket(
283 ticket,
284 &channel,
285 1.into(),
286 1.0.try_into()?,
287 100.into(),
288 &Hash::default(),
289 );
290
291 assert!(
292 ret.is_ok(),
293 "ticket with index == channel.ticket_index must be accepted, got: {:?}",
294 ret
295 );
296
297 Ok(())
298 }
299
300 #[tokio::test]
301 async fn ticket_validation_should_fail_if_counterparty_is_not_channel_destination() -> anyhow::Result<()> {
302 let bogus_counterparty = ChainKeypair::random();
303 let ticket = TicketBuilder::default()
304 .counterparty(&bogus_counterparty)
305 .amount(1)
306 .index(1)
307 .win_prob(1.0.try_into().context("should convert win prob")?)
308 .channel_epoch(1)
309 .eth_challenge(Default::default())
310 .build_signed(&SENDER_PRIV_KEY, &Hash::default())
311 .context("should build signed ticket")?
312 .leak();
313
314 let channel = create_channel_entry();
315 assert_ne!(ticket.counterparty, channel.destination);
316
317 let ret = validate_unacknowledged_ticket(
318 ticket,
319 &channel,
320 1.into(),
321 1.0.try_into().context("should convert required win prob")?,
322 100.into(),
323 &Hash::default(),
324 );
325
326 let error = ret.expect_err("ticket with bogus counterparty must be rejected");
327 assert!(
328 matches!(error.kind, ValidationErrorKind::WrongCounterparty(_)),
329 "expected WrongCounterparty, got: {:?}",
330 error.kind
331 );
332
333 Ok(())
334 }
335}