Skip to main content

nym_bandwidth_controller/
ticketbooks.rs

1// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
2// SPDX-License-Identifier: GPL-3.0-only
3
4use std::fmt;
5
6use nym_credential_storage::models::BasicTicketbookInformation;
7use nym_credentials_interface::TicketType;
8use nym_ecash_time::{Date, EcashTime, OffsetDateTime};
9use strum::IntoEnumIterator;
10
11use crate::{config::BandwidthControllerConfig, error::BandwidthControllerError};
12
13#[derive(Debug, Clone, PartialEq)]
14pub struct AvailableTicketbook {
15    pub id: i64,
16    pub typ: TicketType,
17    pub expiration: Date,
18    pub issued_tickets: u32,
19    pub claimed_tickets: u32,
20    pub ticket_size: u64,
21}
22
23impl AvailableTicketbook {
24    pub fn issued_tickets_si(&self) -> String {
25        si_scale::helpers::bibytes2(self.issued_tickets as u64 * self.ticket_size)
26    }
27
28    pub fn remaining_tickets(&self) -> u32 {
29        self.issued_tickets.saturating_sub(self.claimed_tickets)
30    }
31
32    pub fn remaining_tickets_si(&self) -> String {
33        si_scale::helpers::bibytes2(self.remaining_tickets() as u64 * self.ticket_size)
34    }
35
36    pub fn ticket_size_si(&self) -> String {
37        si_scale::helpers::bibytes2(self.ticket_size)
38    }
39
40    pub fn has_expired(&self) -> bool {
41        self.expiration <= nym_ecash_time::ecash_today().date()
42    }
43
44    // If that ticketbook will be expired in SOON_EXPIRY_THRESHOLD
45    pub fn expired_soon(
46        &self,
47        datetime: OffsetDateTime,
48        bc_config: BandwidthControllerConfig,
49    ) -> bool {
50        self.expiration.ecash_datetime() < datetime + bc_config.soon_expiry_threshold
51    }
52}
53
54impl fmt::Display for AvailableTicketbook {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        let ecash_today = nym_ecash_time::ecash_today().date();
57
58        let expiration = if self.expiration <= ecash_today {
59            format!("EXPIRED ON: {}", self.expiration)
60        } else {
61            format!("expires: {}", self.expiration)
62        };
63
64        write!(
65            f,
66            "{{ id: {}, type: {}, tickets: {}/{}, size: {}, remaining: {}/{}, {} }}",
67            self.id,
68            self.typ,
69            self.remaining_tickets(),
70            self.issued_tickets,
71            self.ticket_size_si(),
72            self.remaining_tickets_si(),
73            self.issued_tickets_si(),
74            expiration
75        )
76    }
77}
78
79impl TryFrom<BasicTicketbookInformation> for AvailableTicketbook {
80    type Error = BandwidthControllerError;
81
82    fn try_from(value: BasicTicketbookInformation) -> Result<Self, Self::Error> {
83        let typ = value
84            .ticketbook_type
85            .parse()
86            .map_err(|_| BandwidthControllerError::ParseTicketType(value.ticketbook_type))?;
87        Ok(AvailableTicketbook {
88            id: value.id,
89            typ,
90            expiration: value.expiration_date,
91            issued_tickets: value.total_tickets,
92            claimed_tickets: value.used_tickets,
93            ticket_size: typ.to_repr().bandwidth_value(),
94        })
95    }
96}
97
98#[derive(Debug, Clone, PartialEq)]
99pub struct AvailableTicketbooks {
100    pub ticketbooks: Vec<AvailableTicketbook>,
101}
102
103impl AvailableTicketbooks {
104    pub fn remaining_tickets(&self, typ: TicketType) -> u64 {
105        self.tickets_by_type(typ)
106            .filter(|ticketbook| !ticketbook.has_expired())
107            .map(|ticketbook| ticketbook.remaining_tickets())
108            .fold(0, |acc, remaining| acc.saturating_add(remaining.into()))
109    }
110
111    pub fn remaining_data(&self, typ: TicketType) -> u64 {
112        self.remaining_tickets(typ) * typ.to_repr().bandwidth_value()
113    }
114
115    pub fn remaining_data_si(&self, typ: TicketType) -> String {
116        si_scale::helpers::bibytes2(self.remaining_data(typ) as f64)
117    }
118
119    fn tickets_by_type(&self, typ: TicketType) -> impl Iterator<Item = &AvailableTicketbook> {
120        self.ticketbooks
121            .iter()
122            .filter(move |ticketbook| ticketbook.typ == typ)
123    }
124
125    pub fn remaining_tickets_long_lasting(
126        &self,
127        typ: TicketType,
128        bc_config: BandwidthControllerConfig,
129    ) -> u64 {
130        self.tickets_by_type(typ)
131            .filter(|ticketbook| !ticketbook.expired_soon(OffsetDateTime::now_utc(), bc_config))
132            .map(|ticketbook| ticketbook.remaining_tickets())
133            .fold(0, |acc, remaining| acc.saturating_add(remaining.into()))
134    }
135
136    pub fn remaining_unexpired_tickets(&self, typ: TicketType) -> u64 {
137        self.tickets_by_type(typ)
138            .filter(|ticketbook| !ticketbook.has_expired())
139            .map(|ticketbook| ticketbook.remaining_tickets())
140            .fold(0, |acc, remaining| acc.saturating_add(remaining.into()))
141    }
142
143    /// Whether `typ` should be proactively restocked
144    pub fn needs_restock(&self, typ: TicketType, bc_config: BandwidthControllerConfig) -> bool {
145        let remaining = self.remaining_tickets_long_lasting(typ, bc_config);
146        remaining <= bc_config.nb_ticket_restock
147    }
148
149    pub fn contains_minimal_tickets(
150        &self,
151        typ: TicketType,
152        bc_config: BandwidthControllerConfig,
153    ) -> bool {
154        let remaining = self.remaining_unexpired_tickets(typ);
155        remaining > bc_config.min_nb_ticket_needed
156    }
157
158    pub fn len(&self) -> usize {
159        self.ticketbooks.len()
160    }
161
162    pub fn is_empty(&self) -> bool {
163        self.ticketbooks.is_empty()
164    }
165
166    pub fn len_not_expired(&self) -> usize {
167        self.ticketbooks
168            .iter()
169            .filter(|ticketbook| !ticketbook.has_expired())
170            .count()
171    }
172
173    pub fn ticketbook_types() -> Vec<TicketType> {
174        // We don't include the mixnet exit ticket type as it's not used by the client
175        TicketType::iter()
176            .filter(|&t| t != TicketType::V1MixnetExit)
177            .collect()
178    }
179}
180
181impl Iterator for AvailableTicketbooks {
182    type Item = AvailableTicketbook;
183
184    fn next(&mut self) -> Option<Self::Item> {
185        self.ticketbooks.pop()
186    }
187}
188
189impl From<Vec<AvailableTicketbook>> for AvailableTicketbooks {
190    fn from(ticketbooks: Vec<AvailableTicketbook>) -> Self {
191        Self { ticketbooks }
192    }
193}
194
195impl TryFrom<Vec<BasicTicketbookInformation>> for AvailableTicketbooks {
196    type Error = BandwidthControllerError;
197
198    fn try_from(value: Vec<BasicTicketbookInformation>) -> Result<Self, Self::Error> {
199        let ticketbooks: Vec<_> = value
200            .into_iter()
201            .filter_map(|ticketbook| {
202                AvailableTicketbook::try_from(ticketbook)
203                    .inspect_err(|err| {
204                        tracing::error!("Failed to parse ticketbook {err}");
205                    })
206                    .ok()
207            })
208            .collect();
209        Ok(AvailableTicketbooks::from(ticketbooks))
210    }
211}