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::config::BandwidthControllerConfig;
5use crate::error::BandwidthControllerError;
6use crate::readiness::{FetchFailure, ReadinessRequest, ReadinessSnapshot, ReadinessStatus};
7use crate::requests::{BandwidthControllerRequest, BandwidthControllerRequestSender};
8use crate::ticketbooks::AvailableTicketbooks;
9use crate::traits::{CredentialFetcher, CredentialPublicDataFetcher};
10use crate::NymCredential;
11use crate::{
12    BandwidthTicketProvider, PreparedCredential, PreparedCredentialMetadata, UPGRADE_MODE_JWT_TYPE,
13};
14
15use nym_credential_storage::models::EmergencyCredentialContent;
16use nym_credential_storage::models::RetrievedTicketbook;
17use nym_credential_storage::storage::Storage;
18use nym_credentials::ecash::bandwidth::CredentialSpendingData;
19use nym_credentials::IssuedTicketBook;
20use nym_credentials_interface::{
21    AnnotatedCoinIndexSignature, AnnotatedExpirationDateSignature, TicketType, VerificationKeyAuth,
22};
23use nym_crypto::asymmetric::ed25519;
24use nym_ecash_time::{Date, OffsetDateTime};
25use nym_task::ShutdownToken;
26use nym_validator_client::nym_api::EpochId;
27
28use async_trait::async_trait;
29use log::error;
30use std::collections::HashMap;
31use std::collections::HashSet;
32use std::sync::Arc;
33use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
34
35#[cfg(not(target_arch = "wasm32"))]
36use tokio::time::{interval, MissedTickBehavior};
37
38#[cfg(target_arch = "wasm32")]
39use wasmtimer::tokio::{interval, MissedTickBehavior};
40
41use crate::in_flight::{FetchResult, InFlightFetches};
42
43/// Owns all ecash credential state and is the **single writer** to the credential [`Storage`].
44///
45/// It serves ticket spending, credential acquisition, and global signing-data retrieval while
46/// keeping storage consistent by being the only component that writes to it. The acquisition and
47/// retrieval work is delegated to two pluggable, **storage-free** fetchers: they only do the
48/// network/cryptographic work and hand the results back for the controller to persist.
49///
50/// - [`CredentialPublicDataFetcher`] (`public_data_fetcher`): lazily retrieves the global ecash
51///   signing materials - master verification key, coin-index and expiration-date signatures - when
52///   they're missing locally.
53/// - [`CredentialFetcher`] (`credential_fetcher`): provisions usable ticketbooks (making deposits
54///   and aggregating wallet signatures). It is a superset of the public-data fetcher, so installing
55///   one via [`Self::with_credential_fetcher`] also registers it as the public-data fetcher.
56///
57/// It can be driven two ways:
58/// - [`Self::run`] — the event loop: handles requests from [`BandwidthControllerRequestSender`] and,
59///   on native targets, proactively restocks low ticket types in the background, keeps the global
60///   data topped up, and resolves `wait_for_ticketbooks` readiness waiters as fetches complete.
61/// - directly on a non-running instance — e.g. [`Self::fetch_ticketbook`] or
62///   [`Self::prepare_ecash_ticket`] — for one-shot use without spawning the loop.
63pub struct BandwidthController<St> {
64    storage: St,
65
66    // Channels used to receive commands from the outside.
67    request_channel: (
68        UnboundedSender<BandwidthControllerRequest>,
69        UnboundedReceiver<BandwidthControllerRequest>,
70    ),
71
72    config: BandwidthControllerConfig,
73
74    // fetches the global ecash signing materials when they are missing locally
75    public_data_fetcher: Option<Arc<dyn CredentialPublicDataFetcher>>,
76
77    // provisions usable ticketbooks. Available on all targets for manual (inline) fetching;
78    // `Arc` so the native auto path can clone it into spawned fetch tasks.
79    credential_fetcher: Option<Arc<dyn CredentialFetcher>>,
80
81    // ticketbook fetches currently in flight; skips duplicate requests per type, drives
82    // completions, and cancels on reset/shutdown
83    in_flight: InFlightFetches,
84
85    // callers parked on `wait_for_ticketbooks`, re-evaluated whenever a fetch completes
86    pending_readiness: Vec<ReadinessRequest>,
87}
88
89impl<St: Storage> BandwidthController<St> {
90    // ---------------------------------------------------------------------
91    // Construction & configuration
92    // ---------------------------------------------------------------------
93
94    pub fn new(storage: St) -> Self {
95        let request_channel = mpsc::unbounded_channel();
96        BandwidthController {
97            storage,
98            request_channel,
99            config: Default::default(),
100            public_data_fetcher: None,
101            credential_fetcher: None,
102            in_flight: InFlightFetches::new(),
103            pending_readiness: Vec::new(),
104        }
105    }
106    #[must_use]
107    pub fn with_config(mut self, config: BandwidthControllerConfig) -> Self {
108        self.config = config;
109        self
110    }
111
112    #[must_use]
113    pub fn with_credential_fetcher(mut self, fetcher: impl CredentialFetcher + 'static) -> Self {
114        let fetcher = Arc::new(fetcher);
115        self.credential_fetcher = Some(fetcher.clone());
116        self.public_data_fetcher = Some(fetcher);
117        self
118    }
119
120    #[must_use]
121    pub fn with_credential_public_data_fetcher(
122        mut self,
123        fetcher: impl CredentialPublicDataFetcher + 'static,
124    ) -> Self {
125        self.public_data_fetcher = Some(Arc::new(fetcher));
126        self
127    }
128
129    /// Get the request channel used to send request to the controller.
130    /// Request are handled only if the `BandwidthController` is running using `run`
131    pub fn get_request_sender(&self) -> BandwidthControllerRequestSender {
132        BandwidthControllerRequestSender::new(self.request_channel.0.clone())
133    }
134
135    // ---------------------------------------------------------------------
136    // Event loop
137    // ---------------------------------------------------------------------
138
139    /// Runs the controller event loop, handling incoming requests until the
140    /// request channel is closed or cancellation is requested. Additionally drives
141    /// the proactive restock timer and drains completed background fetches.
142    pub async fn run(mut self, shutdown_token: ShutdownToken) {
143        tracing::info!("BandwidthController started successfully");
144
145        let mut topup_interval = interval(self.config.topup_interval);
146        topup_interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
147
148        loop {
149            tokio::select! {
150                biased;
151                _ = shutdown_token.cancelled() => {
152                    log::debug!("bandwidth controller received cancellation request; shutting down");
153                    break;
154                }
155                _ = topup_interval.tick() => {
156                    let _ = self.print_info().await;
157                    self.ensure_global_data().await;
158                    self.check_and_restock(AvailableTicketbooks::ticketbook_types()).await;
159                }
160                (typ, res) = self.in_flight.next_result(), if !self.in_flight.is_empty() => {
161                    self.on_fetch_complete(typ, res).await;
162                }
163                request = self.request_channel.1.recv() => match request {
164                    Some(request) => self.handle_request(request).await,
165                    None => {
166                        log::warn!("bandwidth controller request channel closed; this should never happen as we own a sender; shutting down");
167                        break;
168                    }
169                }
170            }
171        }
172
173        // wait for in-flight fetches to stop before cleaning up the fetcher they still hold
174        self.in_flight.cancel_and_join().await;
175        if let Some(fetcher) = self.credential_fetcher {
176            fetcher.cleanup().await;
177        }
178        self.storage.close().await;
179    }
180
181    // ---------------------------------------------------------------------
182    // Request dispatch & handlers
183    // ---------------------------------------------------------------------
184
185    async fn handle_request(&mut self, request: BandwidthControllerRequest) {
186        match request {
187            BandwidthControllerRequest::EcashTicket(return_sender, request) => {
188                let ticket_type = request.ticket_type;
189                let credential_result = self
190                    .prepare_ecash_ticket(
191                        ticket_type,
192                        request.gateway_id.to_bytes(),
193                        request.tickets_to_spend,
194                        request.spend_time,
195                    )
196                    .await;
197                return_sender.send(credential_result);
198                // a ticket was just requested for this type - top it up if it's now running low
199                self.check_and_restock(vec![ticket_type]).await;
200            }
201            BandwidthControllerRequest::UpgradeModeToken(return_sender) => {
202                return_sender.send(self.get_upgrade_mode_token().await)
203            }
204            BandwidthControllerRequest::AttemptRevertSpending(return_sender, metadata) => {
205                return_sender.send(self.attempt_revert_ticket_usage(metadata).await)
206            }
207            BandwidthControllerRequest::SetCredentialFetcher(return_sender, fetcher) => {
208                self.handle_set_credential_fetcher(fetcher).await;
209                return_sender.send(Ok(()))
210            }
211
212            BandwidthControllerRequest::SetPublicDataFetcher(return_sender, fetcher) => {
213                self.public_data_fetcher = fetcher;
214                return_sender.send(Ok(()))
215            }
216            BandwidthControllerRequest::Reset(return_sender) => {
217                return_sender.send(self.handle_reset().await)
218            }
219            BandwidthControllerRequest::ClearEmergencyCredentials(return_sender) => return_sender
220                .send(
221                    self.storage
222                        .clear_emergency_credentials()
223                        .await
224                        .map_err(BandwidthControllerError::credential_storage_error),
225                ),
226            BandwidthControllerRequest::GetAvailableTicketbooks(return_sender) => {
227                return_sender.send(self.handle_get_available_ticketbooks().await)
228            }
229            BandwidthControllerRequest::RestockTicketbooks(return_sender, ticket_types) => {
230                self.check_and_restock(ticket_types).await;
231                return_sender.send(Ok(()))
232            }
233            BandwidthControllerRequest::WaitForTicketbooks(return_sender, ticket_types) => {
234                self.handle_wait_for_ticketbooks(ReadinessRequest {
235                    return_sender,
236                    ticket_types,
237                })
238                .await
239            }
240        }
241    }
242
243    async fn handle_set_credential_fetcher(&mut self, fetcher: Option<Arc<dyn CredentialFetcher>>) {
244        self.in_flight.cancel_and_join().await;
245        if let Some(old_fetcher) = self.credential_fetcher.take() {
246            old_fetcher.cleanup().await;
247        }
248        // Explicit upcast required because of the option
249        self.public_data_fetcher = fetcher
250            .clone()
251            .map(|f| f as Arc<dyn CredentialPublicDataFetcher>);
252        self.credential_fetcher = fetcher;
253        self.check_and_restock(AvailableTicketbooks::ticketbook_types())
254            .await;
255    }
256
257    // Removes fetcher, stops in flight requests, clear credentials, answer all readiness request with unavailable
258    async fn handle_reset(&mut self) -> Result<(), BandwidthControllerError> {
259        // Cancel fetching and wait for the tasks to stop before touching the fetcher or storage:
260        // this leaves no stale in-flight entries (so a following restock can spawn), and drains any
261        // fetch that completed just before cancellation so it can't resurrect a ticketbook into
262        // storage we're about to clear.
263        self.in_flight.cancel_and_join().await;
264
265        if let Some(fetcher) = &self.credential_fetcher {
266            fetcher.cleanup().await;
267        }
268        self.credential_fetcher = None;
269
270        let requests = std::mem::take(&mut self.pending_readiness);
271        requests.into_iter().for_each(|r| r.cancel());
272
273        // Clear credentials
274        self.storage
275            .clear_ticketbooks()
276            .await
277            .map_err(BandwidthControllerError::credential_storage_error)?;
278        self.storage
279            .clear_emergency_credentials()
280            .await
281            .map_err(BandwidthControllerError::credential_storage_error)
282    }
283
284    async fn handle_get_available_ticketbooks(
285        &self,
286    ) -> Result<AvailableTicketbooks, BandwidthControllerError> {
287        self.print_info().await?;
288        self.get_available_ticketbooks().await
289    }
290
291    async fn handle_wait_for_ticketbooks(&mut self, request: ReadinessRequest) {
292        let snapshot = match self.build_readiness_snapshot(None).await {
293            Ok(snapshot) => snapshot,
294            Err(err) => {
295                // transient storage failure - leave waiters parked for the next state change
296                tracing::warn!("could not assess ticketbook readiness: {err}");
297                self.pending_readiness.push(request);
298                return;
299            }
300        };
301        tracing::debug!("Readiness snapshot : {:#?}", snapshot);
302        if let Some(request) = request.try_resolve(&snapshot) {
303            self.pending_readiness.push(request);
304        }
305    }
306
307    // ---------------------------------------------------------------------
308    // Ticket spending
309    // ---------------------------------------------------------------------
310
311    pub async fn prepare_ecash_ticket(
312        &self,
313        ticketbook_type: TicketType,
314        provider_pk: [u8; 32],
315        tickets_to_spend: u32,
316        spend_time: OffsetDateTime,
317    ) -> Result<Option<PreparedCredential>, BandwidthControllerError> {
318        let Some(retrieved_ticketbook) = self
319            .get_next_usable_ticketbook(ticketbook_type, tickets_to_spend)
320            .await?
321        else {
322            return Ok(None);
323        };
324
325        let ticketbook_id = retrieved_ticketbook.ticketbook_id;
326        let epoch_id = retrieved_ticketbook.ticketbook.epoch_id();
327
328        let used_tickets =
329            retrieved_ticketbook.ticketbook.spent_tickets() as u32 + tickets_to_spend;
330        let metadata = PreparedCredentialMetadata {
331            ticketbook_id,
332            tickets_withdrawn: tickets_to_spend,
333            used_tickets,
334        };
335
336        match self
337            .prepare_ecash_ticket_inner(
338                provider_pk,
339                spend_time,
340                tickets_to_spend,
341                retrieved_ticketbook,
342            )
343            .await
344        {
345            Ok(data) => Ok(Some(PreparedCredential {
346                data,
347                epoch_id,
348                metadata,
349            })),
350            Err(err) => {
351                error!("failed to prepare credential spending request. attempting to revert withdrawal...");
352                self.attempt_revert_ticket_usage(metadata).await?;
353                Err(err)
354            }
355        }
356    }
357
358    async fn prepare_ecash_ticket_inner(
359        &self,
360        provider_pk: [u8; 32],
361        spend_time: OffsetDateTime,
362        tickets_to_spend: u32,
363        mut retrieved_ticketbook: RetrievedTicketbook,
364    ) -> Result<CredentialSpendingData, BandwidthControllerError> {
365        let epoch_id = retrieved_ticketbook.ticketbook.epoch_id();
366        let expiration_date = retrieved_ticketbook.ticketbook.expiration_date();
367
368        let verification_key = self.ensure_master_verification_key(epoch_id).await?;
369        let expiration_signatures = self
370            .ensure_expiration_date_signatures(epoch_id, expiration_date)
371            .await?;
372        let coin_indices_signatures = self.ensure_coin_index_signatures(epoch_id).await?;
373
374        let pay_info = retrieved_ticketbook
375            .ticketbook
376            .generate_pay_info(provider_pk, spend_time);
377
378        let spend_request = retrieved_ticketbook.ticketbook.prepare_for_spending(
379            &verification_key,
380            pay_info.into(),
381            &coin_indices_signatures,
382            &expiration_signatures,
383            tickets_to_spend as u64,
384        )?;
385        Ok(spend_request)
386    }
387
388    /// Tries to retrieve one of the stored, unused credentials for the given type that hasn't yet expired.
389    pub async fn get_next_usable_ticketbook(
390        &self,
391        ticketbook_type: TicketType,
392        tickets: u32,
393    ) -> Result<Option<RetrievedTicketbook>, BandwidthControllerError> {
394        self.storage
395            .get_next_unspent_usable_ticketbook(ticketbook_type.to_string(), tickets)
396            .await
397            .map_err(BandwidthControllerError::credential_storage_error)
398    }
399
400    async fn attempt_revert_ticket_usage(
401        &self,
402        info: PreparedCredentialMetadata,
403    ) -> Result<bool, BandwidthControllerError> {
404        self.storage
405            .attempt_revert_ticketbook_withdrawal(
406                info.ticketbook_id,
407                info.tickets_withdrawn,
408                info.used_tickets,
409            )
410            .await
411            .map_err(BandwidthControllerError::credential_storage_error)
412    }
413
414    // ---------------------------------------------------------------------
415    // Automatic restocking & background fetches
416    // ---------------------------------------------------------------------
417
418    /// Fetches a ticketbook of `ticketbook_type` via the configured credential fetcher and persists
419    /// it (together with the global signing materials it needs). The fetch runs inline, so this
420    /// works on a controller that isn't running its event loop - suitable for one-shot issuance.
421    pub async fn fetch_ticketbook(
422        &self,
423        ticketbook_type: TicketType,
424    ) -> Result<(), BandwidthControllerError> {
425        let Some(fetcher) = &self.credential_fetcher else {
426            return Err(BandwidthControllerError::MissingCredentialFetcher);
427        };
428        let credentials = fetcher
429            .fetch_ticketbooks(ticketbook_type)
430            .await
431            .map_err(BandwidthControllerError::fetcher_error)?;
432        self.store_fetched(credentials).await;
433        Ok(())
434    }
435
436    /// Restocks the given ticket types that are running low or about to expire.
437    async fn check_and_restock(&mut self, ticketbook_types: Vec<TicketType>) {
438        let available = match self.get_available_ticketbooks().await {
439            Ok(available) => available,
440            Err(err) => {
441                tracing::warn!("could not assess ticket stock for restocking: {err}");
442                return;
443            }
444        };
445
446        for typ in ticketbook_types {
447            tracing::debug!("Checking credential stock for {typ} ticket");
448            if available.needs_restock(typ, self.config) {
449                tracing::debug!("{typ} tickets need a restock");
450                self.ensure_stocked(typ);
451            }
452        }
453    }
454
455    /// Spawns a background fetch for `ticketbook_type` unless one is already in flight for it.
456    /// Non-blocking: the result is drained later in the `run` loop via `on_fetch_complete`.
457    fn ensure_stocked(&mut self, ticketbook_type: TicketType) {
458        if self.in_flight.contains(ticketbook_type) {
459            // already fetching this type; don't ask again while we're still waiting
460            tracing::debug!("{ticketbook_type} ticket restock already in flight");
461            return;
462        }
463        let Some(fetcher) = &self.credential_fetcher else {
464            tracing::debug!("No credential fetcher set. No restock possible");
465            return;
466        };
467        tracing::debug!("requesting more {ticketbook_type} ticketbooks");
468        self.in_flight.spawn(ticketbook_type, Arc::clone(fetcher));
469    }
470
471    /// Persists the credentials returned by a completed fetch. The in-flight slot was already
472    /// freed by [`InFlightFetches::next_result`].
473    async fn on_fetch_complete(&mut self, ticket_type: TicketType, received: FetchResult) {
474        // a failed fetch is surfaced to any readiness waiter that required the failed type
475        let failure = match received {
476            Ok(Some(Ok(credentials))) => {
477                self.store_fetched(credentials).await;
478                tracing::info!("fetched and stored a {ticket_type} ticketbook");
479                None
480            }
481            Ok(Some(Err(err))) => {
482                tracing::warn!("failed to fetch {ticket_type} ticketbooks: {err}");
483                Some(FetchFailure {
484                    ticket_type,
485                    error: err,
486                })
487            }
488            Ok(None) => {
489                // fetch was cancelled (reset / shutdown); next_result already freed the slot
490                tracing::debug!("fetch for {ticket_type} ticketbooks was cancelled");
491                None
492            }
493            Err(_recv_err) => {
494                // the task dropped its sender without a result: it panicked.
495                // next_result already freed the slot, so a later restock can retry the type.
496                tracing::error!("a credential fetch task for {ticket_type} terminated abnormally");
497                None
498            }
499        };
500        self.resolve_pending_waiters(failure).await;
501    }
502
503    /// Persists fetched credential
504    async fn store_fetched(&self, credentials: Vec<NymCredential>) {
505        for credential in credentials {
506            match credential {
507                NymCredential::Ticketbook(ticketbook) => self.store_ticketbook(*ticketbook).await,
508                NymCredential::UpgradeModeToken { jwt, expiration } => {
509                    self.store_upgrade_token(jwt, expiration).await
510                }
511            }
512        }
513    }
514
515    /// Persists fetched ticketbooks together with the global materials they need to be spent.
516    ///
517    /// Best-effort: each step (storing a material that came with the credential, or ensuring a
518    /// missing one is fetched, or storing the ticketbook itself) is logged on failure and the rest
519    /// still proceed - a single failure never aborts the batch.
520    async fn store_ticketbook(&self, ticketbook: IssuedTicketBook) {
521        // This path is used when a CredentialFetcher is there, so there will be a way to get the public data as well
522        let epoch_id = ticketbook.epoch_id();
523
524        if let Err(err) = self.ensure_master_verification_key(epoch_id).await {
525            tracing::warn!("failed to ensure master verification key for epoch {epoch_id}: {err}");
526        }
527        if let Err(err) = self.ensure_coin_index_signatures(epoch_id).await {
528            tracing::warn!("failed to ensure coin index signatures for epoch {epoch_id}: {err}");
529        }
530
531        if let Err(err) = self
532            .ensure_expiration_date_signatures(epoch_id, ticketbook.expiration_date())
533            .await
534        {
535            tracing::warn!(
536                "failed to ensure expiration date signatures for epoch {epoch_id}: {err}"
537            );
538        }
539
540        if let Err(err) = self.storage.insert_issued_ticketbook(&ticketbook).await {
541            tracing::warn!("failed to store ticketbook: {err}");
542        }
543    }
544
545    async fn store_upgrade_token(&self, jwt: String, expiration: OffsetDateTime) {
546        let credential_content = EmergencyCredentialContent {
547            typ: UPGRADE_MODE_JWT_TYPE.into(),
548            content: jwt.into_bytes(),
549            expiration: Some(expiration),
550        };
551        if let Err(e) = self
552            .storage
553            .insert_emergency_credential(&credential_content)
554            .await
555        {
556            tracing::warn!("failed to store emergency credential: {e}");
557        }
558    }
559
560    // ---------------------------------------------------------------------
561    // Ticketbook readiness
562    // ---------------------------------------------------------------------
563
564    fn is_in_flight(&self, typ: TicketType) -> bool {
565        self.in_flight.contains(typ)
566    }
567
568    async fn build_readiness_snapshot(
569        &self,
570        failure: Option<FetchFailure>,
571    ) -> Result<ReadinessSnapshot, BandwidthControllerError> {
572        let upgrade_mode = self.get_upgrade_mode_token().await?.is_some();
573        let available = self.get_available_ticketbooks().await?;
574
575        let mut tickets_readiness = HashMap::new();
576        for typ in AvailableTicketbooks::ticketbook_types() {
577            let status = if available.contains_minimal_tickets(typ, self.config) {
578                ReadinessStatus::Ready
579            } else if self.is_in_flight(typ) {
580                ReadinessStatus::InFlight
581            } else {
582                match failure.as_ref() {
583                    Some(failure) if failure.ticket_type == typ => {
584                        ReadinessStatus::FetchFailed(failure.error.to_string())
585                    }
586                    _ => ReadinessStatus::Unavailable,
587                }
588            };
589            tickets_readiness.insert(typ, status);
590        }
591
592        Ok(ReadinessSnapshot {
593            upgrade_mode,
594            tickets_readiness,
595        })
596    }
597
598    /// Re-evaluates parked `wait_for_ticketbooks` callers after stock/in-flight state changed,
599    /// answering and dropping the ones that resolved.
600    async fn resolve_pending_waiters(&mut self, failure: Option<FetchFailure>) {
601        if self.pending_readiness.is_empty() {
602            return;
603        }
604        let snapshot = match self.build_readiness_snapshot(failure).await {
605            Ok(snapshot) => snapshot,
606            Err(err) => {
607                // transient storage failure - leave waiters parked for the next state change
608                tracing::warn!("could not assess ticketbook readiness: {err}");
609                return;
610            }
611        };
612
613        tracing::debug!("Readiness snapshot : {:#?}", snapshot);
614
615        let requests = std::mem::take(&mut self.pending_readiness);
616
617        let still_waiting = requests
618            .into_iter()
619            .filter_map(|request| request.try_resolve(&snapshot))
620            .collect();
621
622        self.pending_readiness = still_waiting;
623    }
624
625    // ---------------------------------------------------------------------
626    // Global signing data (fetched & cached on demand)
627    // ---------------------------------------------------------------------
628
629    /// Returns the master verification key for the epoch, fetching it via the configured public
630    /// data fetcher and persisting it if it isn't already in local storage.
631    async fn ensure_master_verification_key(
632        &self,
633        epoch_id: EpochId,
634    ) -> Result<VerificationKeyAuth, BandwidthControllerError> {
635        if let Some(key) = self
636            .storage
637            .get_master_verification_key(epoch_id)
638            .await
639            .map_err(BandwidthControllerError::credential_storage_error)?
640        {
641            return Ok(key);
642        }
643        let Some(fetcher) = &self.public_data_fetcher else {
644            return Err(BandwidthControllerError::MissingVerificationKey { epoch_id });
645        };
646        let key = fetcher
647            .fetch_master_verification_key(epoch_id)
648            .await
649            .map_err(BandwidthControllerError::fetcher_error)?;
650        self.storage
651            .insert_master_verification_key(&key)
652            .await
653            .map_err(BandwidthControllerError::credential_storage_error)?;
654        Ok(key.key)
655    }
656
657    /// Returns the coin index signatures for the epoch, fetching them via the configured public
658    /// data fetcher and persisting them if they aren't already in local storage.
659    async fn ensure_coin_index_signatures(
660        &self,
661        epoch_id: EpochId,
662    ) -> Result<Vec<AnnotatedCoinIndexSignature>, BandwidthControllerError> {
663        if let Some(signatures) = self
664            .storage
665            .get_coin_index_signatures(epoch_id)
666            .await
667            .map_err(BandwidthControllerError::credential_storage_error)?
668        {
669            return Ok(signatures);
670        }
671        let Some(fetcher) = &self.public_data_fetcher else {
672            return Err(BandwidthControllerError::MissingCoinIndexSignatures { epoch_id });
673        };
674        let signatures = fetcher
675            .fetch_coin_index_signatures(epoch_id)
676            .await
677            .map_err(BandwidthControllerError::fetcher_error)?;
678        self.storage
679            .insert_coin_index_signatures(&signatures)
680            .await
681            .map_err(BandwidthControllerError::credential_storage_error)?;
682        Ok(signatures.signatures)
683    }
684
685    /// Returns the expiration date signatures for the epoch and expiration date, fetching them via
686    /// the configured public data fetcher and persisting them if they aren't already in local storage.
687    async fn ensure_expiration_date_signatures(
688        &self,
689        epoch_id: EpochId,
690        expiration_date: Date,
691    ) -> Result<Vec<AnnotatedExpirationDateSignature>, BandwidthControllerError> {
692        if let Some(signatures) = self
693            .storage
694            .get_expiration_date_signatures(expiration_date, epoch_id)
695            .await
696            .map_err(BandwidthControllerError::credential_storage_error)?
697        {
698            return Ok(signatures);
699        }
700        let Some(fetcher) = &self.public_data_fetcher else {
701            return Err(BandwidthControllerError::MissingExpirationDateSignatures { epoch_id });
702        };
703        let signatures = fetcher
704            .fetch_expiration_date_signatures(expiration_date, epoch_id)
705            .await
706            .map_err(BandwidthControllerError::fetcher_error)?;
707        self.storage
708            .insert_expiration_date_signatures(&signatures)
709            .await
710            .map_err(BandwidthControllerError::credential_storage_error)?;
711        Ok(signatures.signatures)
712    }
713
714    /// Ensures the global signing data (master key, coin-index and expiration-date signatures) is
715    /// present locally for every epoch/expiration referenced by a stored ticketbook, fetching
716    /// whatever is missing.
717    ///
718    /// Best-effort: each missing piece is fetched independently and failures are logged, so one
719    /// failure never blocks the rest.
720    async fn ensure_global_data(&self) {
721        let ticketbooks = match self.storage.get_ticketbooks_info().await {
722            Ok(ticketbooks) => ticketbooks,
723            Err(err) => {
724                tracing::warn!("could not read ticketbooks to ensure global data: {err}");
725                return;
726            }
727        };
728
729        // master key and coin-index signatures are per-epoch
730        let epochs = ticketbooks
731            .iter()
732            .map(|ticketbook| EpochId::from(ticketbook.epoch_id))
733            .collect::<HashSet<_>>();
734        for epoch_id in epochs {
735            if let Err(err) = self.ensure_master_verification_key(epoch_id).await {
736                tracing::warn!(
737                    "failed to ensure master verification key for epoch {epoch_id}: {err}"
738                );
739            }
740            if let Err(err) = self.ensure_coin_index_signatures(epoch_id).await {
741                tracing::warn!(
742                    "failed to ensure coin index signatures for epoch {epoch_id}: {err}"
743                );
744            }
745        }
746
747        // expiration-date signatures are keyed by (epoch, expiration date)
748        let expirations = ticketbooks
749            .iter()
750            .map(|ticketbook| {
751                (
752                    EpochId::from(ticketbook.epoch_id),
753                    ticketbook.expiration_date,
754                )
755            })
756            .collect::<HashSet<_>>();
757        for (epoch_id, expiration_date) in expirations {
758            if let Err(err) = self
759                .ensure_expiration_date_signatures(epoch_id, expiration_date)
760                .await
761            {
762                tracing::warn!(
763                    "failed to ensure expiration date signatures for epoch {epoch_id}: {err}"
764                );
765            }
766        }
767    }
768
769    // ---------------------------------------------------------------------
770    // Storage queries & diagnostics
771    // ---------------------------------------------------------------------
772
773    async fn get_upgrade_mode_token(&self) -> Result<Option<String>, BandwidthControllerError> {
774        let Some(emergency_credential) = self
775            .storage
776            .get_emergency_credential(UPGRADE_MODE_JWT_TYPE)
777            .await
778            .map_err(BandwidthControllerError::credential_storage_error)?
779        else {
780            return Ok(None);
781        };
782        // upgrade mode credential is just a simple stringified JWT
783        let token = String::from_utf8(emergency_credential.data.content)
784            .map_err(|_| BandwidthControllerError::MalformedUpgradeModeToken)?;
785        Ok(Some(token))
786    }
787
788    async fn get_available_ticketbooks(
789        &self,
790    ) -> Result<AvailableTicketbooks, BandwidthControllerError> {
791        let ticketbooks_info = self
792            .storage
793            .get_ticketbooks_info()
794            .await
795            .map_err(BandwidthControllerError::credential_storage_error)?;
796        AvailableTicketbooks::try_from(ticketbooks_info)
797    }
798
799    async fn print_info(&self) -> Result<(), BandwidthControllerError> {
800        let ticketbooks_info = self.get_available_ticketbooks().await?;
801        let num_ticketbooks = ticketbooks_info.len_not_expired();
802        let num_total_ticketbooks = ticketbooks_info.len();
803        tracing::info!("Ticketbooks stored: {num_ticketbooks}");
804        tracing::debug!("Total ticketbooks stored: {num_total_ticketbooks}");
805        for ticketbook in ticketbooks_info {
806            if ticketbook.has_expired() {
807                tracing::debug!("Expired ticketbook: {ticketbook}");
808            } else if ticketbook.expired_soon(OffsetDateTime::now_utc(), self.config) {
809                tracing::info!("Soon expired ticketbook: {ticketbook}");
810            } else {
811                tracing::info!("Ticketbook: {ticketbook}");
812            }
813        }
814
815        Ok(())
816    }
817}
818
819// So we can use the BC without making it run on its own if we don't need that
820#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
821#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
822impl<St: Storage> BandwidthTicketProvider for BandwidthController<St> {
823    async fn get_ecash_ticket(
824        &self,
825        ticket_type: TicketType,
826        gateway_id: ed25519::PublicKey,
827        tickets_to_spend: u32,
828        spend_time: OffsetDateTime,
829    ) -> Result<Option<PreparedCredential>, BandwidthControllerError> {
830        self.prepare_ecash_ticket(
831            ticket_type,
832            gateway_id.to_bytes(),
833            tickets_to_spend,
834            spend_time,
835        )
836        .await
837    }
838
839    async fn get_upgrade_mode_token(&self) -> Result<Option<String>, BandwidthControllerError> {
840        self.get_upgrade_mode_token().await
841    }
842
843    async fn attempt_revert_spending(
844        &self,
845        metadata: PreparedCredentialMetadata,
846    ) -> Result<bool, BandwidthControllerError> {
847        self.attempt_revert_ticket_usage(metadata).await
848    }
849
850    async fn close(&self) {
851        self.storage.close().await
852    }
853}