Skip to main content

ticket_inspector/
ticket_inspector.rs

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    /// Path to the database file
15    #[arg(long, short, value_name = "FILE")]
16    db_file: PathBuf,
17    /// Output format
18    #[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 text output
28    Plain,
29    /// JSON output
30    #[cfg(feature = "serde")]
31    Json,
32}
33
34#[derive(Subcommand)]
35enum Commands {
36    /// List Channel IDs of all ticket queues in the DB.
37    #[command(short_flag = 'c')]
38    ListChannels,
39    /// Delete all tickets by the Channel ID.
40    #[command(alias = "dq")]
41    DeleteQueue {
42        /// Channel ID to delete
43        #[arg(short, long)]
44        channel_id: ChannelId,
45    },
46    /// Display all tickets in a particular queue in-order.
47    #[command(short_flag = 'l')]
48    ListTickets {
49        /// Channel ID of the queue
50        #[arg(short, long)]
51        channel_id: ChannelId,
52    },
53    /// Delete all tickets in a queue up to a specified ticket matching the Channel ID and index.
54    #[command(short_flag = 'e')]
55    DeleteTicket {
56        /// Channel ID of the tickets
57        #[arg(short, long)]
58        channel_id: ChannelId,
59        /// Index of the target ticket
60        #[arg(short, long)]
61        index: u64,
62    },
63    /// Print out the total sum of all ticket amounts for a given channel ID.
64    #[command(short_flag = 't')]
65    TotalValue {
66        /// Channel ID of the queue
67        #[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); // No tickets were in the queue
397            }
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        // Should have deleted tickets with index 0, 1, 2
474        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        // Ensure no queues exist initially
525        assert_eq!(store.iter_queues()?.count(), 0);
526
527        // Test with ListTickets command on non-existent channel
528        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        // The store should still have 0 queues because run_command should check before opening
544        assert_eq!(store.iter_queues()?.count(), 0);
545
546        Ok(())
547    }
548}