Skip to main content

nym_bandwidth_controller/
controller.rs

1// Copyright 2026 - Nym Technologies SA <contact@nymtech.net>
2// SPDX-License-Identifier: Apache-2.0
3
4use crate::error::BandwidthControllerError;
5use crate::requests::{BandwidthControllerRequest, BandwidthControllerRequestSender};
6use crate::traits::CredentialPublicDataFetcher;
7use crate::{
8    BandwidthTicketProvider, PreparedCredential, PreparedCredentialMetadata, UPGRADE_MODE_JWT_TYPE,
9};
10use async_trait::async_trait;
11use log::error;
12use nym_credential_storage::models::RetrievedTicketbook;
13use nym_credential_storage::storage::Storage;
14use nym_credentials::ecash::bandwidth::CredentialSpendingData;
15use nym_credentials_interface::{
16    AnnotatedCoinIndexSignature, AnnotatedExpirationDateSignature, TicketType, VerificationKeyAuth,
17};
18use nym_crypto::asymmetric::ed25519;
19use nym_ecash_time::{Date, OffsetDateTime};
20use nym_task::ShutdownToken;
21use nym_validator_client::nym_api::EpochId;
22use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
23
24pub struct BandwidthController<St> {
25    storage: St,
26
27    // Channels used to receive commands from the outside.
28    request_channel: (
29        UnboundedSender<BandwidthControllerRequest>,
30        UnboundedReceiver<BandwidthControllerRequest>,
31    ),
32
33    // fetches the global ecash signing materials when they are missing locally
34    public_data_fetcher: Option<Box<dyn CredentialPublicDataFetcher>>,
35}
36
37impl<St: Storage> BandwidthController<St> {
38    pub fn new(storage: St) -> Self {
39        let request_channel = mpsc::unbounded_channel();
40        BandwidthController {
41            storage,
42            request_channel,
43            public_data_fetcher: None,
44        }
45    }
46
47    #[must_use]
48    pub fn with_credential_public_data_fetcher(
49        mut self,
50        fetcher: impl CredentialPublicDataFetcher + 'static,
51    ) -> Self {
52        self.public_data_fetcher = Some(Box::new(fetcher));
53        self
54    }
55
56    /// Get the request channel used to send request to the controller.
57    /// Request are handled only if the `BandwidthController` is running using `run`
58    pub fn get_request_sender(&self) -> BandwidthControllerRequestSender {
59        BandwidthControllerRequestSender::new(self.request_channel.0.clone())
60    }
61
62    /// Tries to retrieve one of the stored, unused credentials for the given type that hasn't yet expired.
63    pub async fn get_next_usable_ticketbook(
64        &self,
65        ticketbook_type: TicketType,
66        tickets: u32,
67    ) -> Result<Option<RetrievedTicketbook>, BandwidthControllerError> {
68        self.storage
69            .get_next_unspent_usable_ticketbook(ticketbook_type.to_string(), tickets)
70            .await
71            .map_err(BandwidthControllerError::credential_storage_error)
72    }
73
74    pub async fn attempt_revert_ticket_usage(
75        &self,
76        info: PreparedCredentialMetadata,
77    ) -> Result<bool, BandwidthControllerError> {
78        self.storage
79            .attempt_revert_ticketbook_withdrawal(
80                info.ticketbook_id,
81                info.tickets_withdrawn,
82                info.used_tickets,
83            )
84            .await
85            .map_err(BandwidthControllerError::credential_storage_error)
86    }
87
88    pub async fn get_aggregate_verification_key(
89        &self,
90        epoch_id: EpochId,
91    ) -> Result<Option<VerificationKeyAuth>, BandwidthControllerError> {
92        self.storage
93            .get_master_verification_key(epoch_id)
94            .await
95            .map_err(BandwidthControllerError::credential_storage_error)
96    }
97
98    pub async fn get_coin_index_signatures(
99        &self,
100        epoch_id: EpochId,
101    ) -> Result<Option<Vec<AnnotatedCoinIndexSignature>>, BandwidthControllerError> {
102        self.storage
103            .get_coin_index_signatures(epoch_id)
104            .await
105            .map_err(BandwidthControllerError::credential_storage_error)
106    }
107
108    pub async fn get_expiration_date_signatures(
109        &self,
110        epoch_id: EpochId,
111        expiration_date: Date,
112    ) -> Result<Option<Vec<AnnotatedExpirationDateSignature>>, BandwidthControllerError> {
113        self.storage
114            .get_expiration_date_signatures(expiration_date, epoch_id)
115            .await
116            .map_err(BandwidthControllerError::credential_storage_error)
117    }
118
119    /// Returns the master verification key for the epoch, fetching it via the configured public
120    /// data fetcher and persisting it if it isn't already in local storage.
121    async fn ensure_master_verification_key(
122        &self,
123        epoch_id: EpochId,
124    ) -> Result<VerificationKeyAuth, BandwidthControllerError> {
125        if let Some(key) = self.get_aggregate_verification_key(epoch_id).await? {
126            return Ok(key);
127        }
128        let Some(fetcher) = &self.public_data_fetcher else {
129            return Err(BandwidthControllerError::MissingVerificationKey { epoch_id });
130        };
131        let key = fetcher
132            .fetch_master_verification_key(epoch_id)
133            .await
134            .map_err(BandwidthControllerError::fetcher_error)?;
135        self.storage
136            .insert_master_verification_key(&key)
137            .await
138            .map_err(BandwidthControllerError::credential_storage_error)?;
139        Ok(key.key)
140    }
141
142    /// Returns the coin index signatures for the epoch, fetching them via the configured public
143    /// data fetcher and persisting them if they aren't already in local storage.
144    async fn ensure_coin_index_signatures(
145        &self,
146        epoch_id: EpochId,
147    ) -> Result<Vec<AnnotatedCoinIndexSignature>, BandwidthControllerError> {
148        if let Some(signatures) = self.get_coin_index_signatures(epoch_id).await? {
149            return Ok(signatures);
150        }
151        let Some(fetcher) = &self.public_data_fetcher else {
152            return Err(BandwidthControllerError::MissingCoinIndexSignatures { epoch_id });
153        };
154        let signatures = fetcher
155            .fetch_coin_index_signatures(epoch_id)
156            .await
157            .map_err(BandwidthControllerError::fetcher_error)?;
158        self.storage
159            .insert_coin_index_signatures(&signatures)
160            .await
161            .map_err(BandwidthControllerError::credential_storage_error)?;
162        Ok(signatures.signatures)
163    }
164
165    /// Returns the expiration date signatures for the epoch and expiration date, fetching them via
166    /// the configured public data fetcher and persisting them if they aren't already in local storage.
167    async fn ensure_expiration_date_signatures(
168        &self,
169        epoch_id: EpochId,
170        expiration_date: Date,
171    ) -> Result<Vec<AnnotatedExpirationDateSignature>, BandwidthControllerError> {
172        if let Some(signatures) = self
173            .get_expiration_date_signatures(epoch_id, expiration_date)
174            .await?
175        {
176            return Ok(signatures);
177        }
178        let Some(fetcher) = &self.public_data_fetcher else {
179            return Err(BandwidthControllerError::MissingExpirationDateSignatures { epoch_id });
180        };
181        let signatures = fetcher
182            .fetch_expiration_date_signatures(expiration_date, epoch_id)
183            .await
184            .map_err(BandwidthControllerError::fetcher_error)?;
185        self.storage
186            .insert_expiration_date_signatures(&signatures)
187            .await
188            .map_err(BandwidthControllerError::credential_storage_error)?;
189        Ok(signatures.signatures)
190    }
191
192    /// Ensures all global ecash signing materials for the given epoch and expiration date are
193    /// present in local storage, fetching and persisting any that are missing.
194    pub async fn ensure_global_data(
195        &self,
196        epoch_id: EpochId,
197        expiration_date: Date,
198    ) -> Result<(), BandwidthControllerError> {
199        self.ensure_master_verification_key(epoch_id).await?;
200        self.ensure_coin_index_signatures(epoch_id).await?;
201        self.ensure_expiration_date_signatures(epoch_id, expiration_date)
202            .await?;
203        Ok(())
204    }
205
206    async fn prepare_ecash_ticket_inner(
207        &self,
208        provider_pk: [u8; 32],
209        spend_time: OffsetDateTime,
210        tickets_to_spend: u32,
211        mut retrieved_ticketbook: RetrievedTicketbook,
212    ) -> Result<CredentialSpendingData, BandwidthControllerError> {
213        let epoch_id = retrieved_ticketbook.ticketbook.epoch_id();
214        let expiration_date = retrieved_ticketbook.ticketbook.expiration_date();
215
216        let verification_key = self.ensure_master_verification_key(epoch_id).await?;
217        let expiration_signatures = self
218            .ensure_expiration_date_signatures(epoch_id, expiration_date)
219            .await?;
220        let coin_indices_signatures = self.ensure_coin_index_signatures(epoch_id).await?;
221
222        let pay_info = retrieved_ticketbook
223            .ticketbook
224            .generate_pay_info(provider_pk, spend_time);
225
226        let spend_request = retrieved_ticketbook.ticketbook.prepare_for_spending(
227            &verification_key,
228            pay_info.into(),
229            &coin_indices_signatures,
230            &expiration_signatures,
231            tickets_to_spend as u64,
232        )?;
233        Ok(spend_request)
234    }
235
236    pub async fn prepare_ecash_ticket(
237        &self,
238        ticketbook_type: TicketType,
239        provider_pk: [u8; 32],
240        tickets_to_spend: u32,
241        spend_time: OffsetDateTime,
242    ) -> Result<Option<PreparedCredential>, BandwidthControllerError> {
243        let Some(retrieved_ticketbook) = self
244            .get_next_usable_ticketbook(ticketbook_type, tickets_to_spend)
245            .await?
246        else {
247            return Ok(None);
248        };
249
250        let ticketbook_id = retrieved_ticketbook.ticketbook_id;
251        let epoch_id = retrieved_ticketbook.ticketbook.epoch_id();
252
253        let used_tickets =
254            retrieved_ticketbook.ticketbook.spent_tickets() as u32 + tickets_to_spend;
255        let metadata = PreparedCredentialMetadata {
256            ticketbook_id,
257            tickets_withdrawn: tickets_to_spend,
258            used_tickets,
259        };
260
261        match self
262            .prepare_ecash_ticket_inner(
263                provider_pk,
264                spend_time,
265                tickets_to_spend,
266                retrieved_ticketbook,
267            )
268            .await
269        {
270            Ok(data) => Ok(Some(PreparedCredential {
271                data,
272                epoch_id,
273                metadata,
274            })),
275            Err(err) => {
276                error!("failed to prepare credential spending request. attempting to revert withdrawal...");
277                self.attempt_revert_ticket_usage(metadata).await?;
278                Err(err)
279            }
280        }
281    }
282
283    async fn get_upgrade_mode_token(&self) -> Result<Option<String>, BandwidthControllerError> {
284        let Some(emergency_credential) = self
285            .storage
286            .get_emergency_credential(UPGRADE_MODE_JWT_TYPE)
287            .await
288            .map_err(BandwidthControllerError::credential_storage_error)?
289        else {
290            return Ok(None);
291        };
292        // upgrade mode credential is just a simple stringified JWT
293        let token = String::from_utf8(emergency_credential.data.content)
294            .map_err(|_| BandwidthControllerError::MalformedUpgradeModeToken)?;
295        Ok(Some(token))
296    }
297
298    /// Runs the controller event loop, handling incoming requests until the
299    /// request channel is closed or cancellation is requested.
300    pub async fn run(mut self, shutdown_token: ShutdownToken) {
301        loop {
302            tokio::select! {
303                biased;
304                _ = shutdown_token.cancelled() => {
305                    log::debug!("bandwidth controller received cancellation request; shutting down");
306                    break;
307                }
308                request = self.request_channel.1.recv() => match request {
309                    Some(request) => self.handle_request(request).await,
310                    None => {
311                        log::warn!("bandwidth controller request channel closed; this should never happened as we are owning a sender; shutting down");
312                        break;
313                    }
314                }
315            }
316        }
317
318        self.storage.close().await;
319    }
320
321    async fn handle_request(&mut self, request: BandwidthControllerRequest) {
322        match request {
323            BandwidthControllerRequest::EcashTicket(return_sender, request) => {
324                let credential_result = self
325                    .prepare_ecash_ticket(
326                        request.ticket_type,
327                        request.gateway_id.to_bytes(),
328                        request.tickets_to_spend,
329                        request.spend_time,
330                    )
331                    .await;
332                return_sender.send(credential_result)
333            }
334            BandwidthControllerRequest::UpgradeModeToken(return_sender) => {
335                return_sender.send(self.get_upgrade_mode_token().await)
336            }
337            BandwidthControllerRequest::AttemptRevertSpending(return_sender, metadata) => {
338                return_sender.send(self.attempt_revert_ticket_usage(metadata).await)
339            }
340        }
341    }
342}
343
344// So we can use the BC without making it run on its own if we don't need that
345#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
346#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
347impl<St: Storage> BandwidthTicketProvider for BandwidthController<St> {
348    async fn get_ecash_ticket(
349        &self,
350        ticket_type: TicketType,
351        gateway_id: ed25519::PublicKey,
352        tickets_to_spend: u32,
353        spend_time: OffsetDateTime,
354    ) -> Result<Option<PreparedCredential>, BandwidthControllerError> {
355        self.prepare_ecash_ticket(
356            ticket_type,
357            gateway_id.to_bytes(),
358            tickets_to_spend,
359            spend_time,
360        )
361        .await
362    }
363
364    async fn get_upgrade_mode_token(&self) -> Result<Option<String>, BandwidthControllerError> {
365        self.get_upgrade_mode_token().await
366    }
367
368    async fn attempt_revert_spending(
369        &self,
370        metadata: PreparedCredentialMetadata,
371    ) -> Result<bool, BandwidthControllerError> {
372        self.attempt_revert_ticket_usage(metadata).await
373    }
374
375    async fn close(&self) {
376        self.storage.close().await
377    }
378}