1use std::{fmt, path::PathBuf};
2
3use clap::{Parser, Subcommand, ValueEnum};
4use hopr_api::{chain::ChannelId, types::primitive::prelude::HoprBalance};
5use hopr_ticket_manager::{RedbStore, TicketQueue, TicketQueueStore};
6#[cfg(feature = "serde")]
7use serde::Serialize;
8use strum::{Display, EnumString, VariantNames};
9
10#[derive(Parser)]
11#[command(name = "ticket-inspector")]
12#[command(about = "CLI tool to inspect and manipulate HOPR redeemable tickets database", long_about = None)]
13struct Cli {
14 #[arg(long, short, value_name = "FILE")]
16 db_file: PathBuf,
17 #[arg(short, long, value_enum, default_value_t = OutputFormat::Plain)]
19 format: OutputFormat,
20 #[command(subcommand)]
21 command: Commands,
22}
23
24#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug, Display, EnumString, VariantNames)]
25#[strum(serialize_all = "lowercase")]
26enum OutputFormat {
27 Plain,
29 #[cfg(feature = "serde")]
31 Json,
32}
33
34#[derive(Subcommand)]
35enum Commands {
36 #[command(short_flag = 'c')]
38 ListChannels,
39 #[command(alias = "dq")]
41 DeleteQueue {
42 #[arg(short, long)]
44 channel_id: ChannelId,
45 },
46 #[command(short_flag = 'l')]
48 ListTickets {
49 #[arg(short, long)]
51 channel_id: ChannelId,
52 },
53 #[command(short_flag = 'e')]
55 DeleteTicket {
56 #[arg(short, long)]
58 channel_id: ChannelId,
59 #[arg(short, long)]
61 index: u64,
62 },
63 #[command(short_flag = 't')]
65 TotalValue {
66 #[arg(short, long)]
68 channel_id: ChannelId,
69 },
70}
71
72#[cfg_attr(feature = "serde", derive(Serialize))]
73#[derive(Debug, PartialEq)]
74struct ChannelList {
75 channels: Vec<String>,
76}
77
78#[cfg_attr(feature = "serde", derive(Serialize))]
79#[derive(Debug, PartialEq)]
80struct DeleteQueueResult {
81 channel_id: ChannelId,
82 deleted_tickets_count: usize,
83}
84
85#[cfg_attr(feature = "serde", derive(Serialize))]
86#[derive(Debug, PartialEq)]
87struct TicketList {
88 channel_id: ChannelId,
89 #[cfg(feature = "serde")]
90 tickets: Vec<serde_json::Value>,
91 #[cfg(not(feature = "serde"))]
92 tickets: Vec<String>,
93}
94
95#[cfg_attr(feature = "serde", derive(Serialize))]
96#[derive(Debug, PartialEq)]
97struct DeleteTicketResult {
98 channel_id: ChannelId,
99 target_index: u64,
100 deleted_count: usize,
101}
102
103#[cfg_attr(feature = "serde", derive(Serialize))]
104#[derive(Debug, PartialEq)]
105struct TotalValueResult {
106 channel_id: ChannelId,
107 total_sum: String,
108}
109
110#[cfg_attr(feature = "serde", derive(Serialize))]
111#[derive(Debug, PartialEq)]
112enum CommandResult {
113 ListChannels(ChannelList),
114 DeleteQueue(DeleteQueueResult),
115 ListTickets(TicketList),
116 DeleteTicket(DeleteTicketResult),
117 TotalValue(TotalValueResult),
118}
119
120impl fmt::Display for CommandResult {
121 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122 match self {
123 CommandResult::ListChannels(res) => {
124 writeln!(f, "Queues found in DB:")?;
125 for channel in &res.channels {
126 writeln!(f, " {channel}")?;
127 }
128 }
129 CommandResult::DeleteQueue(res) => {
130 write!(
131 f,
132 "Deleted queue for channel {} with {} tickets.",
133 res.channel_id, res.deleted_tickets_count
134 )?;
135 }
136 CommandResult::ListTickets(res) => {
137 if res.tickets.is_empty() {
138 write!(f, "No queue found for channel {}", res.channel_id)?;
139 } else {
140 writeln!(f, "Tickets in queue for channel {}:", res.channel_id)?;
141 for (i, ticket) in res.tickets.iter().enumerate() {
142 if i > 0 {
143 writeln!(f)?;
144 }
145 write!(f, "{ticket:#?}")?;
146 }
147 }
148 }
149 CommandResult::DeleteTicket(res) => {
150 write!(
151 f,
152 "Deleted {} tickets up to index {} for channel {}",
153 res.deleted_count, res.target_index, res.channel_id
154 )?;
155 }
156 CommandResult::TotalValue(res) => {
157 write!(
158 f,
159 "Total ticket value for channel {}: {}",
160 res.channel_id, res.total_sum
161 )?;
162 }
163 }
164 Ok(())
165 }
166}
167
168fn main() -> anyhow::Result<()> {
169 let cli = Cli::parse();
170 if !cli.db_file.is_file() {
171 return Err(anyhow::anyhow!(
172 "Database file does not exist: {}",
173 cli.db_file.display()
174 ));
175 }
176
177 let mut store = RedbStore::new(&cli.db_file)?;
178 let format = cli.format;
179 let result = run_command(cli, &mut store)?;
180
181 match format {
182 #[cfg(feature = "serde")]
183 OutputFormat::Json => match result {
184 CommandResult::ListChannels(res) => println!("{}", serde_json::to_string_pretty(&res)?),
185 CommandResult::DeleteQueue(res) => println!("{}", serde_json::to_string_pretty(&res)?),
186 CommandResult::ListTickets(res) => println!("{}", serde_json::to_string_pretty(&res)?),
187 CommandResult::DeleteTicket(res) => println!("{}", serde_json::to_string_pretty(&res)?),
188 CommandResult::TotalValue(res) => println!("{}", serde_json::to_string_pretty(&res)?),
189 },
190 OutputFormat::Plain => {
191 println!("{result}");
192 }
193 }
194
195 Ok(())
196}
197
198fn run_command(cli: Cli, store: &mut impl TicketQueueStore) -> anyhow::Result<CommandResult> {
199 match cli.command {
200 Commands::ListChannels => {
201 let mut channels: Vec<String> = store.iter_queues()?.map(|c| c.to_string()).collect();
202 channels.sort();
203 Ok(CommandResult::ListChannels(ChannelList { channels }))
204 }
205 Commands::DeleteQueue { channel_id } => {
206 let deleted_tickets = store.delete_queue(&channel_id)?;
207 Ok(CommandResult::DeleteQueue(DeleteQueueResult {
208 channel_id,
209 deleted_tickets_count: deleted_tickets.len(),
210 }))
211 }
212 Commands::ListTickets { channel_id } => {
213 if !store.iter_queues()?.any(|c| c == channel_id) {
214 return Ok(CommandResult::ListTickets(TicketList {
215 channel_id,
216 tickets: vec![],
217 }));
218 }
219 let queue = store.open_or_create_queue(&channel_id)?;
220 let mut tickets = queue.iter_unordered()?.collect::<Result<Vec<_>, _>>()?;
221 tickets.sort();
222
223 #[cfg(feature = "serde")]
224 let tickets: Vec<serde_json::Value> = tickets
225 .into_iter()
226 .map(serde_json::to_value)
227 .collect::<Result<Vec<_>, _>>()?;
228
229 #[cfg(not(feature = "serde"))]
230 let tickets: Vec<String> = tickets.into_iter().map(|t| format!("{:?}", t)).collect();
231
232 Ok(CommandResult::ListTickets(TicketList { channel_id, tickets }))
233 }
234 Commands::DeleteTicket { channel_id, index } => {
235 if !store.iter_queues()?.any(|c| c == channel_id) {
236 return Ok(CommandResult::DeleteTicket(DeleteTicketResult {
237 channel_id,
238 target_index: index,
239 deleted_count: 0,
240 }));
241 }
242 let mut queue = store.open_or_create_queue(&channel_id)?;
243
244 let mut deleted_count = 0;
245 while let Some(ticket) = queue.peek()? {
246 if ticket.verified_ticket().index <= index {
247 queue.pop()?;
248 deleted_count += 1;
249 } else {
250 break;
251 }
252 }
253
254 Ok(CommandResult::DeleteTicket(DeleteTicketResult {
255 channel_id,
256 target_index: index,
257 deleted_count,
258 }))
259 }
260 Commands::TotalValue { channel_id } => {
261 if !store.iter_queues()?.any(|c| c == channel_id) {
262 return Ok(CommandResult::TotalValue(TotalValueResult {
263 channel_id,
264 total_sum: "0".to_string(),
265 }));
266 }
267 let queue = store.open_or_create_queue(&channel_id)?;
268 let total_sum: HoprBalance = queue
269 .iter_unordered()?
270 .map(|r| r.map_err(|e| anyhow::anyhow!("error reading ticket: {e}")))
271 .try_fold(HoprBalance::zero(), |acc, t| {
272 anyhow::Ok(acc + t?.verified_ticket().amount)
273 })?;
274
275 Ok(CommandResult::TotalValue(TotalValueResult {
276 channel_id,
277 total_sum: total_sum.to_string(),
278 }))
279 }
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use std::ops::RangeBounds;
286
287 use hopr_api::{
288 chain::{RedeemableTicket, WinningProbability},
289 types::{
290 crypto::prelude::{ChainKeypair, Challenge, HalfKey, Keypair, Response},
291 crypto_random::Randomizable,
292 internal::prelude::TicketBuilder,
293 },
294 };
295 use hopr_ticket_manager::TicketQueueStore;
296 use tempfile::tempdir;
297
298 use super::*;
299
300 pub fn generate_owned_tickets(
301 issuer: &ChainKeypair,
302 recipient: &ChainKeypair,
303 count: usize,
304 epochs: impl RangeBounds<u32> + Iterator<Item = u32>,
305 ) -> anyhow::Result<Vec<RedeemableTicket>> {
306 let mut tickets = Vec::new();
307 for epoch in epochs {
308 for i in 0..count {
309 let hk1 = HalfKey::random();
310 let hk2 = HalfKey::random();
311
312 let ticket = TicketBuilder::default()
313 .counterparty(recipient)
314 .index(i as u64)
315 .channel_epoch(epoch)
316 .win_prob(WinningProbability::ALWAYS)
317 .amount(100)
318 .challenge(Challenge::from_hint_and_share(
319 &hk1.to_challenge()?,
320 &hk2.to_challenge()?,
321 )?)
322 .build_signed(issuer, &Default::default())?
323 .into_acknowledged(Response::from_half_keys(&hk1, &hk2)?)
324 .into_redeemable(recipient, &Default::default())?;
325
326 tickets.push(ticket);
327 }
328 }
329
330 tickets.sort();
331 Ok(tickets)
332 }
333
334 pub fn fill_queue<Q: TicketQueue, I: Iterator<Item = RedeemableTicket>>(
335 queue: &mut Q,
336 iter: I,
337 ) -> anyhow::Result<()> {
338 for ticket in iter {
339 queue.push(ticket)?;
340 }
341 Ok(())
342 }
343
344 #[test]
345 fn list_channels() -> anyhow::Result<()> {
346 let dir = tempdir()?;
347 let db_path = dir.path().join("test_list.db");
348 let mut store = RedbStore::new(&db_path)?;
349
350 let channel1 = ChannelId::from([1u8; 32]);
351 let channel2 = ChannelId::from([2u8; 32]);
352
353 store.open_or_create_queue(&channel1)?;
354 store.open_or_create_queue(&channel2)?;
355
356 let cli = Cli {
357 db_file: db_path.clone(),
358 format: OutputFormat::Plain,
359 command: Commands::ListChannels,
360 };
361
362 let result = run_command(cli, &mut store)?;
363 match result {
364 CommandResult::ListChannels(res) => {
365 assert_eq!(res.channels.len(), 2);
366 assert!(res.channels.contains(&channel1.to_string()));
367 assert!(res.channels.contains(&channel2.to_string()));
368 }
369 _ => panic!("Expected ListChannels result"),
370 }
371
372 Ok(())
373 }
374
375 #[test]
376 fn delete_queue() -> anyhow::Result<()> {
377 let dir = tempdir()?;
378 let db_path = dir.path().join("test_delete_queue.db");
379 let mut store = RedbStore::new(&db_path)?;
380
381 let channel = ChannelId::from([1u8; 32]);
382 store.open_or_create_queue(&channel)?;
383
384 assert_eq!(store.iter_queues()?.count(), 1);
385
386 let cli = Cli {
387 db_file: db_path.clone(),
388 format: OutputFormat::Plain,
389 command: Commands::DeleteQueue { channel_id: channel },
390 };
391
392 let result = run_command(cli, &mut store)?;
393 match result {
394 CommandResult::DeleteQueue(res) => {
395 assert_eq!(res.channel_id, channel);
396 assert_eq!(res.deleted_tickets_count, 0); }
398 _ => panic!("Expected DeleteQueue result"),
399 }
400
401 assert_eq!(store.iter_queues()?.count(), 0);
402
403 Ok(())
404 }
405
406 #[test]
407 fn list_tickets() -> anyhow::Result<()> {
408 let dir = tempdir()?;
409 let db_path = dir.path().join("test_list_tickets.db");
410 let mut store = RedbStore::new(&db_path)?;
411
412 let channel = ChannelId::from([1u8; 32]);
413 let mut queue = store.open_or_create_queue(&channel)?;
414
415 let src = ChainKeypair::random();
416 let dst = ChainKeypair::random();
417 let tickets = generate_owned_tickets(&src, &dst, 3, 1..=1)?;
418 fill_queue(&mut queue, tickets.into_iter())?;
419
420 let cli = Cli {
421 db_file: db_path.clone(),
422 format: OutputFormat::Plain,
423 command: Commands::ListTickets { channel_id: channel },
424 };
425
426 let result = run_command(cli, &mut store)?;
427 match result {
428 CommandResult::ListTickets(res) => {
429 assert_eq!(res.channel_id, channel);
430 assert_eq!(res.tickets.len(), 3);
431 }
432 _ => panic!("Expected ListTickets result"),
433 }
434
435 Ok(())
436 }
437
438 #[test]
439 fn delete_ticket() -> anyhow::Result<()> {
440 let dir = tempdir()?;
441 let db_path = dir.path().join("test_delete_ticket.db");
442 let mut store = RedbStore::new(&db_path)?;
443
444 let channel = ChannelId::from([1u8; 32]);
445 let mut queue = store.open_or_create_queue(&channel)?;
446
447 let src = ChainKeypair::random();
448 let dst = ChainKeypair::random();
449 let tickets = generate_owned_tickets(&src, &dst, 5, 1..=1)?;
450 fill_queue(&mut queue, tickets.into_iter())?;
451
452 assert_eq!(queue.len()?, 5);
453
454 let cli = Cli {
455 db_file: db_path.clone(),
456 format: OutputFormat::Plain,
457 command: Commands::DeleteTicket {
458 channel_id: channel,
459 index: 2,
460 },
461 };
462
463 let result = run_command(cli, &mut store)?;
464 match result {
465 CommandResult::DeleteTicket(res) => {
466 assert_eq!(res.channel_id, channel);
467 assert_eq!(res.target_index, 2);
468 assert_eq!(res.deleted_count, 3);
469 }
470 _ => panic!("Expected DeleteTicket result"),
471 }
472
473 let queue = store.open_or_create_queue(&channel)?;
475 assert_eq!(queue.len()?, 2);
476
477 Ok(())
478 }
479
480 #[test]
481 fn total_sum() -> anyhow::Result<()> {
482 let dir = tempdir()?;
483 let db_path = dir.path().join("test_total_sum.db");
484 let mut store = RedbStore::new(&db_path)?;
485
486 let channel = ChannelId::from([1u8; 32]);
487 let mut queue = store.open_or_create_queue(&channel)?;
488
489 let src = ChainKeypair::random();
490 let dst = ChainKeypair::random();
491 let tickets = generate_owned_tickets(&src, &dst, 3, 1..=1)?;
492 let expected_sum: HoprBalance = tickets
493 .iter()
494 .map(|t: &hopr_api::chain::RedeemableTicket| t.verified_ticket().amount)
495 .fold(HoprBalance::zero(), |acc, x| acc + x);
496 fill_queue(&mut queue, tickets.into_iter())?;
497
498 let cli = Cli {
499 db_file: db_path.clone(),
500 format: OutputFormat::Plain,
501 command: Commands::TotalValue { channel_id: channel },
502 };
503
504 let result = run_command(cli, &mut store)?;
505 match result {
506 CommandResult::TotalValue(res) => {
507 assert_eq!(res.channel_id, channel);
508 assert_eq!(res.total_sum, expected_sum.to_string());
509 }
510 _ => panic!("Expected TotalSum result"),
511 }
512
513 Ok(())
514 }
515
516 #[test]
517 fn open_or_create_queue_inspected() -> anyhow::Result<()> {
518 let dir = tempdir()?;
519 let db_path = dir.path().join("test_inspected.db");
520 let mut store = RedbStore::new(&db_path)?;
521
522 let channel = ChannelId::from([4u8; 32]);
523
524 assert_eq!(store.iter_queues()?.count(), 0);
526
527 let cli = Cli {
529 db_file: db_path.clone(),
530 format: OutputFormat::Plain,
531 command: Commands::ListTickets { channel_id: channel },
532 };
533
534 let result = run_command(cli, &mut store)?;
535 match result {
536 CommandResult::ListTickets(res) => {
537 assert_eq!(res.channel_id, channel);
538 assert!(res.tickets.is_empty());
539 }
540 _ => panic!("Expected ListTickets result"),
541 }
542
543 assert_eq!(store.iter_queues()?.count(), 0);
545
546 Ok(())
547 }
548}