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