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};
7use crate::requests::{BandwidthControllerRequest, BandwidthControllerRequestSender, ReturnSender};
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::HashSet;
31use std::sync::Arc;
32use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
33
34#[cfg(not(target_arch = "wasm32"))]
35use tokio::time::{interval, MissedTickBehavior};
36
37#[cfg(target_arch = "wasm32")]
38use wasmtimer::tokio::{interval, MissedTickBehavior};
39
40use crate::in_flight::{
41    global_data::{GlobalData, GlobalDataRequest},
42    FetchJob, FetchKey, FetchResult, FetchedData, InFlightFetches,
43};
44
45/// Owns all ecash credential state and is the **single writer** to the credential [`Storage`].
46///
47/// It serves ticket spending, credential acquisition, and global signing-data retrieval while
48/// keeping storage consistent by being the only component that writes to it. The acquisition and
49/// retrieval work is delegated to two pluggable, **storage-free** fetchers: they only do the
50/// network/cryptographic work and hand the results back for the controller to persist.
51///
52/// - [`CredentialPublicDataFetcher`] (`public_data_fetcher`): lazily retrieves the global ecash
53///   signing materials - master verification key, coin-index and expiration-date signatures - when
54///   they're missing locally.
55/// - [`CredentialFetcher`] (`credential_fetcher`): provisions usable ticketbooks (making deposits
56///   and aggregating wallet signatures). It is a superset of the public-data fetcher, so installing
57///   one via [`Self::with_credential_fetcher`] also registers it as the public-data fetcher.
58///
59/// It can be driven two ways:
60/// - [`Self::run`] — the event loop: handles requests from [`BandwidthControllerRequestSender`] and,
61///   on native targets, proactively restocks low ticket types in the background, keeps the global
62///   data topped up, and resolves `wait_for_ticketbooks` readiness waiters as fetches complete.
63/// - directly on a non-running instance — e.g. [`Self::fetch_ticketbook`] or
64///   [`Self::prepare_ecash_ticket`] — for one-shot use without spawning the loop.
65pub struct BandwidthController<St> {
66    storage: St,
67
68    // Channels used to receive commands from the outside.
69    request_channel: (
70        UnboundedSender<BandwidthControllerRequest>,
71        UnboundedReceiver<BandwidthControllerRequest>,
72    ),
73
74    config: BandwidthControllerConfig,
75
76    // fetches the global ecash signing materials when they are missing locally
77    public_data_fetcher: Option<Arc<dyn CredentialPublicDataFetcher>>,
78
79    // provisions usable ticketbooks. Available on all targets for manual (inline) fetching;
80    // `Arc` so the native auto path can clone it into spawned fetch tasks.
81    credential_fetcher: Option<Arc<dyn CredentialFetcher>>,
82
83    // background fetches currently in flight (both ticketbooks and global signing data); skips
84    // duplicate requests per key, drives completions, and cancels on reset/shutdown.
85    in_flight: InFlightFetches,
86
87    // callers parked on `wait_for_ticketbooks`, re-evaluated whenever a fetch completes
88    pending_readiness: Vec<ReadinessRequest>,
89}
90
91impl<St: Storage> BandwidthController<St> {
92    // ---------------------------------------------------------------------
93    // Construction & configuration
94    // ---------------------------------------------------------------------
95
96    pub fn new(storage: St) -> Self {
97        let request_channel = mpsc::unbounded_channel();
98        BandwidthController {
99            storage,
100            request_channel,
101            config: Default::default(),
102            public_data_fetcher: None,
103            credential_fetcher: None,
104            in_flight: InFlightFetches::new(),
105            pending_readiness: Vec::new(),
106        }
107    }
108    #[must_use]
109    pub fn with_config(mut self, config: BandwidthControllerConfig) -> Self {
110        self.config = config;
111        self
112    }
113
114    #[must_use]
115    pub fn with_credential_fetcher(mut self, fetcher: impl CredentialFetcher + 'static) -> Self {
116        let fetcher = Arc::new(fetcher);
117        self.credential_fetcher = Some(fetcher.clone());
118        self.public_data_fetcher = Some(fetcher);
119        self
120    }
121
122    #[must_use]
123    pub fn with_credential_public_data_fetcher(
124        mut self,
125        fetcher: impl CredentialPublicDataFetcher + 'static,
126    ) -> Self {
127        self.public_data_fetcher = Some(Arc::new(fetcher));
128        self
129    }
130
131    /// Get the request channel used to send request to the controller.
132    /// Request are handled only if the `BandwidthController` is running using `run`
133    pub fn get_request_sender(&self) -> BandwidthControllerRequestSender {
134        BandwidthControllerRequestSender::new(self.request_channel.0.clone())
135    }
136
137    // ---------------------------------------------------------------------
138    // Event loop
139    // ---------------------------------------------------------------------
140
141    /// Runs the controller event loop, handling incoming requests until the
142    /// request channel is closed or cancellation is requested. Additionally drives
143    /// the proactive restock timer and drains completed background fetches.
144    pub async fn run(mut self, shutdown_token: ShutdownToken) {
145        tracing::info!("BandwidthController started successfully");
146
147        let mut topup_interval = interval(self.config.topup_interval);
148        topup_interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
149
150        loop {
151            tokio::select! {
152                biased;
153                _ = shutdown_token.cancelled() => {
154                    log::debug!("bandwidth controller received cancellation request; shutting down");
155                    break;
156                }
157                _ = topup_interval.tick() => {
158                    let _ = self.print_info().await;
159                    self.check_and_restock(self.config.managed_ticket_types.clone()).await;
160                }
161                (key, res) = self.in_flight.next_result(), if !self.in_flight.is_empty() => {
162                    self.on_fetch_complete(key, res).await;
163                }
164                request = self.request_channel.1.recv() => match request {
165                    Some(request) => self.handle_request(request).await,
166                    None => {
167                        log::warn!("bandwidth controller request channel closed; this should never happen as we own a sender; shutting down");
168                        break;
169                    }
170                }
171            }
172        }
173
174        // wait for in-flight fetches to stop before cleaning up the fetcher they still hold
175        self.in_flight.cancel_and_join().await;
176        if let Some(fetcher) = self.credential_fetcher {
177            fetcher.cleanup().await;
178        }
179        self.storage.close().await;
180    }
181
182    // ---------------------------------------------------------------------
183    // Request dispatch & handlers
184    // ---------------------------------------------------------------------
185
186    async fn handle_request(&mut self, request: BandwidthControllerRequest) {
187        match request {
188            BandwidthControllerRequest::EcashTicket(return_sender, request) => {
189                let ticket_type = request.ticket_type;
190                let credential_result = self
191                    .prepare_ecash_ticket(
192                        ticket_type,
193                        request.gateway_id.to_bytes(),
194                        request.tickets_to_spend,
195                        request.spend_time,
196                    )
197                    .await;
198                return_sender.send(credential_result);
199                // a ticket was just requested for this type - top it up if it's now running low, but
200                // only if it's a managed type (don't start depositing for a type this controller
201                // isn't configured to proactively restock, e.g. a leftover ticketbook of another kind)
202                if self.config.managed_ticket_types.contains(&ticket_type) {
203                    self.check_and_restock(vec![ticket_type]).await;
204                }
205            }
206            BandwidthControllerRequest::UpgradeModeToken(return_sender) => {
207                return_sender.send(self.get_upgrade_mode_token().await)
208            }
209            BandwidthControllerRequest::AttemptRevertSpending(return_sender, metadata) => {
210                return_sender.send(self.attempt_revert_ticket_usage(metadata).await)
211            }
212            BandwidthControllerRequest::SetCredentialFetcher(return_sender, fetcher) => {
213                self.handle_set_credential_fetcher(fetcher).await;
214                return_sender.send(Ok(()))
215            }
216
217            BandwidthControllerRequest::SetPublicDataFetcher(return_sender, fetcher) => {
218                self.handle_set_public_data_fetcher(fetcher).await;
219                return_sender.send(Ok(()))
220            }
221            BandwidthControllerRequest::Reset(return_sender) => {
222                return_sender.send(self.handle_reset().await)
223            }
224            BandwidthControllerRequest::ClearEmergencyCredentials(return_sender) => return_sender
225                .send(
226                    self.storage
227                        .clear_emergency_credentials()
228                        .await
229                        .map_err(BandwidthControllerError::credential_storage_error),
230                ),
231            BandwidthControllerRequest::GetAvailableTicketbooks(return_sender) => {
232                return_sender.send(self.handle_get_available_ticketbooks().await)
233            }
234            BandwidthControllerRequest::RestockTicketbooks(return_sender, ticket_types) => {
235                self.check_and_restock(ticket_types).await;
236                return_sender.send(Ok(()))
237            }
238            BandwidthControllerRequest::WaitForTicketbooks(return_sender, ticket_types) => {
239                self.handle_wait_for_ticketbooks(return_sender, ticket_types)
240                    .await
241            }
242        }
243    }
244
245    async fn handle_set_credential_fetcher(&mut self, fetcher: Option<Arc<dyn CredentialFetcher>>) {
246        self.in_flight.cancel_and_join().await;
247        if let Some(old_fetcher) = self.credential_fetcher.take() {
248            old_fetcher.cleanup().await;
249        }
250        // a credential fetcher is also a public-data fetcher (explicit upcast through the Option)
251        let public_data_fetcher = fetcher
252            .clone()
253            .map(|f| f as Arc<dyn CredentialPublicDataFetcher>);
254        self.public_data_fetcher = public_data_fetcher;
255        // No need to run a global data fetch here, check_and_restock does it
256
257        self.credential_fetcher = fetcher;
258        self.check_and_restock(self.config.managed_ticket_types.clone())
259            .await;
260    }
261
262    async fn handle_set_public_data_fetcher(
263        &mut self,
264        fetcher: Option<Arc<dyn CredentialPublicDataFetcher>>,
265    ) {
266        self.public_data_fetcher = fetcher;
267        // the public-data fetcher is what provisions global data, so installing one lets us fetch
268        // anything the stored ticketbooks are still missing
269        self.prefetch_global_data().await;
270    }
271
272    // Removes fetcher, stops in flight requests, clear credentials, answer all readiness request with unavailable
273    async fn handle_reset(&mut self) -> Result<(), BandwidthControllerError> {
274        // Cancel fetching and wait for the tasks to stop before touching the fetcher or storage:
275        // this leaves no stale in-flight entries (so a following restock can spawn), and drains any
276        // fetch that completed just before cancellation so it can't resurrect a ticketbook into
277        // storage we're about to clear.
278        self.in_flight.cancel_and_join().await;
279
280        if let Some(fetcher) = &self.credential_fetcher {
281            fetcher.cleanup().await;
282        }
283        self.credential_fetcher = None;
284
285        let requests = std::mem::take(&mut self.pending_readiness);
286        requests.into_iter().for_each(|r| r.cancel());
287
288        // Clear credentials
289        self.storage
290            .clear_ticketbooks()
291            .await
292            .map_err(BandwidthControllerError::credential_storage_error)?;
293        self.storage
294            .clear_emergency_credentials()
295            .await
296            .map_err(BandwidthControllerError::credential_storage_error)
297    }
298
299    async fn handle_get_available_ticketbooks(
300        &self,
301    ) -> Result<AvailableTicketbooks, BandwidthControllerError> {
302        self.print_info().await?;
303        self.get_available_ticketbooks().await
304    }
305
306    async fn handle_wait_for_ticketbooks(
307        &mut self,
308        return_sender: ReturnSender<()>,
309        ticket_types: Vec<TicketType>,
310    ) {
311        // resolve the concrete global data those types need to be spendable (epoch + date of the
312        // ticketbook that would be taken); empty for a type that isn't stocked yet - it's refreshed
313        // once a ticketbook lands (see `resolve_pending_waiters`)
314        let global_data = self
315            .required_global_data(&ticket_types)
316            .await
317            .unwrap_or_default();
318        let request = ReadinessRequest {
319            return_sender,
320            ticket_types,
321            global_data,
322        };
323
324        let snapshot = match self.build_readiness_snapshot(None).await {
325            Ok(snapshot) => snapshot,
326            Err(err) => {
327                // transient storage failure - leave the waiter parked for the next state change
328                tracing::warn!("could not assess ticketbook readiness: {err}");
329                self.pending_readiness.push(request);
330                return;
331            }
332        };
333        tracing::debug!("Readiness snapshot : {:#?}", snapshot);
334        if let Some(request) = request.try_resolve(&snapshot) {
335            self.pending_readiness.push(request);
336        }
337    }
338
339    // ---------------------------------------------------------------------
340    // Ticket spending
341    // ---------------------------------------------------------------------
342
343    pub async fn prepare_ecash_ticket(
344        &self,
345        ticketbook_type: TicketType,
346        provider_pk: [u8; 32],
347        tickets_to_spend: u32,
348        spend_time: OffsetDateTime,
349    ) -> Result<Option<PreparedCredential>, BandwidthControllerError> {
350        let Some(retrieved_ticketbook) = self
351            .get_next_usable_ticketbook(ticketbook_type, tickets_to_spend)
352            .await?
353        else {
354            return Ok(None);
355        };
356
357        let ticketbook_id = retrieved_ticketbook.ticketbook_id;
358        let epoch_id = retrieved_ticketbook.ticketbook.epoch_id();
359
360        let used_tickets =
361            retrieved_ticketbook.ticketbook.spent_tickets() as u32 + tickets_to_spend;
362        let metadata = PreparedCredentialMetadata {
363            ticketbook_id,
364            tickets_withdrawn: tickets_to_spend,
365            used_tickets,
366        };
367
368        match self
369            .prepare_ecash_ticket_inner(
370                provider_pk,
371                spend_time,
372                tickets_to_spend,
373                retrieved_ticketbook,
374            )
375            .await
376        {
377            Ok(data) => Ok(Some(PreparedCredential {
378                data,
379                epoch_id,
380                metadata,
381            })),
382            Err(err) => {
383                error!("failed to prepare credential spending request. attempting to revert withdrawal...");
384                self.attempt_revert_ticket_usage(metadata).await?;
385                Err(err)
386            }
387        }
388    }
389
390    async fn prepare_ecash_ticket_inner(
391        &self,
392        provider_pk: [u8; 32],
393        spend_time: OffsetDateTime,
394        tickets_to_spend: u32,
395        mut retrieved_ticketbook: RetrievedTicketbook,
396    ) -> Result<CredentialSpendingData, BandwidthControllerError> {
397        let epoch_id = retrieved_ticketbook.ticketbook.epoch_id();
398        let expiration_date = retrieved_ticketbook.ticketbook.expiration_date();
399
400        let verification_key = self.ensure_master_verification_key(epoch_id).await?;
401        let expiration_signatures = self
402            .ensure_expiration_date_signatures(epoch_id, expiration_date)
403            .await?;
404        let coin_indices_signatures = self.ensure_coin_index_signatures(epoch_id).await?;
405
406        let pay_info = retrieved_ticketbook
407            .ticketbook
408            .generate_pay_info(provider_pk, spend_time);
409
410        let spend_request = retrieved_ticketbook.ticketbook.prepare_for_spending(
411            &verification_key,
412            pay_info.into(),
413            &coin_indices_signatures,
414            &expiration_signatures,
415            tickets_to_spend as u64,
416        )?;
417        Ok(spend_request)
418    }
419
420    /// Tries to retrieve one of the stored, unused credentials for the given type that hasn't yet expired.
421    /// This is spending a ticket
422    pub async fn get_next_usable_ticketbook(
423        &self,
424        ticketbook_type: TicketType,
425        tickets: u32,
426    ) -> Result<Option<RetrievedTicketbook>, BandwidthControllerError> {
427        self.storage
428            .get_next_unspent_usable_ticketbook(ticketbook_type.to_string(), tickets)
429            .await
430            .map_err(BandwidthControllerError::credential_storage_error)
431    }
432
433    async fn attempt_revert_ticket_usage(
434        &self,
435        info: PreparedCredentialMetadata,
436    ) -> Result<bool, BandwidthControllerError> {
437        self.storage
438            .attempt_revert_ticketbook_withdrawal(
439                info.ticketbook_id,
440                info.tickets_withdrawn,
441                info.used_tickets,
442            )
443            .await
444            .map_err(BandwidthControllerError::credential_storage_error)
445    }
446
447    // ---------------------------------------------------------------------
448    // Automatic restocking & background fetches
449    // ---------------------------------------------------------------------
450
451    /// Fetches a ticketbook of `ticketbook_type` via the configured credential fetcher and persists
452    /// it (together with the global signing materials it needs). The fetch runs inline, so this
453    /// works on a controller that isn't running its event loop - suitable for one-shot issuance.
454    pub async fn fetch_ticketbook(
455        &self,
456        ticketbook_type: TicketType,
457    ) -> Result<(), BandwidthControllerError> {
458        let Some(fetcher) = &self.credential_fetcher else {
459            return Err(BandwidthControllerError::MissingCredentialFetcher);
460        };
461        let credentials = fetcher
462            .fetch_ticketbooks(ticketbook_type)
463            .await
464            .map_err(BandwidthControllerError::fetcher_error)?;
465        self.store_fetched(credentials).await;
466
467        // Ensure that data inline (fetch + persist if missing). `fetch_ticketbook` only runs on a
468        // non-running controller, so blocking is fine
469        self.ensure_global_data().await;
470
471        Ok(())
472    }
473
474    /// Restocks the given ticket types that are running low or about to expire.
475    /// Also checks the global_data
476    async fn check_and_restock(&mut self, ticketbook_types: Vec<TicketType>) {
477        let available = match self.get_available_ticketbooks().await {
478            Ok(available) => available,
479            Err(err) => {
480                tracing::warn!("could not assess ticket stock for restocking: {err}");
481                return;
482            }
483        };
484
485        for typ in ticketbook_types {
486            if self.config.managed_ticket_types.contains(&typ) {
487                tracing::debug!("Checking credential stock for {typ} ticket");
488                if available.needs_restock(typ, &self.config) {
489                    tracing::debug!("{typ} tickets need a restock");
490                    self.ensure_stocked(typ);
491                }
492            } else {
493                tracing::debug!("Credential {typ} is not managed, check and restock skipped");
494            }
495        }
496        self.prefetch_global_data().await;
497    }
498
499    /// Spawns a background fetch for `ticket_type` unless one is already in flight for it.
500    /// Non-blocking: the result is drained later in the `run` loop via `on_fetch_complete`.
501    fn ensure_stocked(&mut self, ticket_type: TicketType) {
502        let Some(fetcher) = &self.credential_fetcher else {
503            tracing::debug!("No credential fetcher set. No restock possible");
504            return;
505        };
506        if self.in_flight.is_in_flight(ticket_type) {
507            // already fetching this type; don't ask again while we're still waiting
508            tracing::debug!("{ticket_type} ticket restock already in flight");
509            return;
510        }
511        tracing::debug!("requesting more {ticket_type} ticketbooks");
512        self.in_flight.spawn(FetchJob::Ticketbook {
513            ticket_type,
514            fetcher: Arc::clone(fetcher),
515        });
516    }
517
518    /// Routes a completed background fetch to the right handler based on what was requested.
519    async fn on_fetch_complete(&mut self, key: FetchKey, received: FetchResult) {
520        match key {
521            FetchKey::Ticketbook(ticket_type) => {
522                self.on_ticketbook_fetch_complete(ticket_type, received)
523                    .await
524            }
525            FetchKey::GlobalData(request) => {
526                self.on_global_data_fetch_complete(request, received).await
527            }
528        }
529    }
530
531    /// Persists a completed ticketbook fetch and re-evaluates readiness waiters. The in-flight slot
532    /// was already freed by [`InFlightFetches::next_result`].
533    async fn on_ticketbook_fetch_complete(
534        &mut self,
535        ticket_type: TicketType,
536        received: FetchResult,
537    ) {
538        // a failed fetch is surfaced to any readiness waiter that required the failed type
539        let failure = match received {
540            Ok(Some(Ok(FetchedData::Ticketbooks(credentials)))) => {
541                self.store_fetched(credentials).await;
542                tracing::info!("fetched and stored a {ticket_type} ticketbook");
543                // provision the global data those ticketbooks need in the background, off the loop
544                self.prefetch_global_data().await;
545                None
546            }
547            Ok(Some(Err(err))) => {
548                tracing::warn!("failed to fetch {ticket_type} ticketbooks: {err}");
549                Some(FetchFailure {
550                    ticket_type,
551                    error: err,
552                })
553            }
554            Ok(None) => {
555                // fetch was cancelled (reset / shutdown); next_result already freed the slot
556                tracing::debug!("fetch for {ticket_type} ticketbooks was cancelled");
557                None
558            }
559            // the task panicked (or, impossibly, returned a non-ticketbook payload); the slot was
560            // already freed, so a later restock can retry the type
561            Ok(Some(Ok(_))) | Err(_) => {
562                tracing::error!("a credential fetch task for {ticket_type} terminated abnormally");
563                None
564            }
565        };
566        self.resolve_pending_waiters(failure).await;
567    }
568
569    /// Persists fetched credential
570    async fn store_fetched(&self, credentials: Vec<NymCredential>) {
571        for credential in credentials {
572            match credential {
573                NymCredential::Ticketbook(ticketbook) => self.store_ticketbook(*ticketbook).await,
574                NymCredential::UpgradeModeToken { jwt, expiration } => {
575                    self.store_upgrade_token(jwt, expiration).await
576                }
577            }
578        }
579    }
580
581    /// Persists a fetched ticketbook
582    async fn store_ticketbook(&self, ticketbook: IssuedTicketBook) {
583        if let Err(err) = self.storage.insert_issued_ticketbook(&ticketbook).await {
584            tracing::warn!("failed to store ticketbook: {err}");
585        }
586    }
587
588    async fn store_upgrade_token(&self, jwt: String, expiration: OffsetDateTime) {
589        let credential_content = EmergencyCredentialContent {
590            typ: UPGRADE_MODE_JWT_TYPE.into(),
591            content: jwt.into_bytes(),
592            expiration: Some(expiration),
593        };
594        if let Err(e) = self
595            .storage
596            .insert_emergency_credential(&credential_content)
597            .await
598        {
599            tracing::warn!("failed to store emergency credential: {e}");
600        }
601    }
602
603    // ---------------------------------------------------------------------
604    // Ticketbook readiness
605    // ---------------------------------------------------------------------
606
607    /// Gathers the current stock, stored global data, in-flight fetches and any fetch failure, and
608    /// hands them to [`ReadinessSnapshot::build`] to assemble - the controller just collects, the
609    /// snapshot does the bookkeeping.
610    async fn build_readiness_snapshot(
611        &self,
612        failure: Option<FetchFailure>,
613    ) -> Result<ReadinessSnapshot, BandwidthControllerError> {
614        let upgrade_mode = self.get_upgrade_mode_token().await?.is_some();
615
616        let available = self.get_available_ticketbooks().await?;
617        let stocked_types = AvailableTicketbooks::ticketbook_types()
618            .into_iter()
619            .filter(|typ| available.contains_minimal_tickets(*typ, &self.config))
620            .collect();
621
622        let available_global_data = self
623            .storage
624            .get_available_global_data()
625            .await
626            .map_err(BandwidthControllerError::credential_storage_error)?;
627
628        Ok(ReadinessSnapshot::build(
629            upgrade_mode,
630            stocked_types,
631            self.in_flight.in_flight_ticketbooks(),
632            failure,
633            available_global_data,
634            self.in_flight.in_flight_global_data(),
635        ))
636    }
637
638    /// The `(epoch, date)` global data needed to spend the given types: for each stocked type, that
639    /// of the ticketbook that would be taken next. A type with no usable ticketbook contributes
640    /// nothing (its requirement is the ticketbook itself, tracked separately).
641    ///
642    /// Read-only - it peeks at the next-usable ticketbook's metadata without withdrawing (unlike
643    /// [`Self::get_next_usable_ticketbook`], which is the actual spend path).
644    async fn required_global_data(
645        &self,
646        ticket_types: &[TicketType],
647    ) -> Result<Vec<(EpochId, Date)>, BandwidthControllerError> {
648        let available = self.get_available_ticketbooks().await?;
649        Ok(ticket_types
650            .iter()
651            .filter_map(|&typ| {
652                available
653                    .next_usable(typ)
654                    .map(|ticketbook| (ticketbook.epoch_id, ticketbook.expiration))
655            })
656            .collect())
657    }
658
659    /// Re-evaluates parked `wait_for_ticketbooks` callers after stock/in-flight state changed,
660    /// answering and dropping the ones that resolved.
661    async fn resolve_pending_waiters(&mut self, failure: Option<FetchFailure>) {
662        if self.pending_readiness.is_empty() {
663            return;
664        }
665        let snapshot = match self.build_readiness_snapshot(failure).await {
666            Ok(snapshot) => snapshot,
667            Err(err) => {
668                // transient storage failure - leave waiters parked for the next state change
669                tracing::warn!("could not assess ticketbook readiness: {err}");
670                return;
671            }
672        };
673
674        tracing::debug!("Readiness snapshot : {:#?}", snapshot);
675
676        let mut still_waiting = Vec::new();
677        for mut request in std::mem::take(&mut self.pending_readiness) {
678            // a newly-stored ticketbook can introduce global-data requirements a still-unstocked
679            // type didn't have when it was parked, so refresh them before evaluating
680            request.global_data = self
681                .required_global_data(&request.ticket_types)
682                .await
683                .unwrap_or_default();
684            if let Some(request) = request.try_resolve(&snapshot) {
685                still_waiting.push(request);
686            }
687        }
688
689        self.pending_readiness = still_waiting;
690    }
691
692    // ---------------------------------------------------------------------
693    // Global signing data (fetched & cached on demand)
694    // ---------------------------------------------------------------------
695
696    /// Returns the master verification key for the epoch, fetching and persisting it via the
697    /// public-data fetcher if it isn't already in local storage.
698    async fn ensure_master_verification_key(
699        &self,
700        epoch_id: EpochId,
701    ) -> Result<VerificationKeyAuth, BandwidthControllerError> {
702        if let Some(key) = self
703            .storage
704            .get_master_verification_key(epoch_id)
705            .await
706            .map_err(BandwidthControllerError::credential_storage_error)?
707        {
708            return Ok(key);
709        }
710        self.fetch_and_store_global_data(GlobalDataRequest::MasterVerificationKey(epoch_id))
711            .await?;
712        self.storage
713            .get_master_verification_key(epoch_id)
714            .await
715            .map_err(BandwidthControllerError::credential_storage_error)?
716            .ok_or(BandwidthControllerError::MissingVerificationKey { epoch_id })
717    }
718
719    /// Returns the coin index signatures for the epoch, fetching and persisting them via the
720    /// public-data fetcher if they aren't already in local storage.
721    async fn ensure_coin_index_signatures(
722        &self,
723        epoch_id: EpochId,
724    ) -> Result<Vec<AnnotatedCoinIndexSignature>, BandwidthControllerError> {
725        if let Some(signatures) = self
726            .storage
727            .get_coin_index_signatures(epoch_id)
728            .await
729            .map_err(BandwidthControllerError::credential_storage_error)?
730        {
731            return Ok(signatures);
732        }
733        self.fetch_and_store_global_data(GlobalDataRequest::CoinIndexSignatures(epoch_id))
734            .await?;
735        self.storage
736            .get_coin_index_signatures(epoch_id)
737            .await
738            .map_err(BandwidthControllerError::credential_storage_error)?
739            .ok_or(BandwidthControllerError::MissingCoinIndexSignatures { epoch_id })
740    }
741
742    /// Returns the expiration date signatures for the epoch and expiration date, fetching and
743    /// persisting them via the public-data fetcher if they aren't already in local storage.
744    async fn ensure_expiration_date_signatures(
745        &self,
746        epoch_id: EpochId,
747        expiration_date: Date,
748    ) -> Result<Vec<AnnotatedExpirationDateSignature>, BandwidthControllerError> {
749        if let Some(signatures) = self
750            .storage
751            .get_expiration_date_signatures(expiration_date, epoch_id)
752            .await
753            .map_err(BandwidthControllerError::credential_storage_error)?
754        {
755            return Ok(signatures);
756        }
757        self.fetch_and_store_global_data(GlobalDataRequest::ExpirationDateSignatures {
758            epoch_id,
759            expiration_date,
760        })
761        .await?;
762        self.storage
763            .get_expiration_date_signatures(expiration_date, epoch_id)
764            .await
765            .map_err(BandwidthControllerError::credential_storage_error)?
766            .ok_or(BandwidthControllerError::MissingExpirationDateSignatures { epoch_id })
767    }
768
769    /// The global signing data referenced by a stored ticketbook whose fetch (master key, coin
770    /// index or expiration signatures) isn't in local storage yet.
771    async fn missing_global_data(
772        &self,
773    ) -> Result<Vec<GlobalDataRequest>, BandwidthControllerError> {
774        let available_global_data = self
775            .storage
776            .get_available_global_data()
777            .await
778            .map_err(BandwidthControllerError::credential_storage_error)?;
779
780        let ticketbooks = self
781            .storage
782            .get_ticketbooks_info()
783            .await
784            .map_err(BandwidthControllerError::credential_storage_error)?;
785
786        let required = AvailableTicketbooks::try_from(ticketbooks)?
787            .filter(|ticketbook| !ticketbook.has_expired())
788            .flat_map(|ticketbook| {
789                GlobalDataRequest::for_ticketbook(ticketbook.epoch_id, ticketbook.expiration)
790            })
791            .collect::<HashSet<_>>();
792
793        let missing = required
794            .into_iter()
795            .filter(|request| {
796                let present = match *request {
797                    GlobalDataRequest::MasterVerificationKey(epoch_id) => available_global_data
798                        .master_verification_key_epochs
799                        .contains(&epoch_id),
800                    GlobalDataRequest::CoinIndexSignatures(epoch_id) => available_global_data
801                        .coin_index_signature_epochs
802                        .contains(&epoch_id),
803                    GlobalDataRequest::ExpirationDateSignatures {
804                        epoch_id,
805                        expiration_date,
806                    } => available_global_data
807                        .expiration_date_signatures
808                        .contains(&(epoch_id, expiration_date)),
809                };
810                !present
811            })
812            .collect();
813
814        Ok(missing)
815    }
816
817    /// Kicks off background fetches for any missing global signing data. Only local storage reads
818    /// happen inline; the (slow) network fetches run off the event loop and are persisted by
819    /// `on_global_data_fetch_complete`, so the loop stays responsive instead of blocking.
820    async fn prefetch_global_data(&mut self) {
821        let missing = match self.missing_global_data().await {
822            Ok(missing) => missing,
823            Err(err) => {
824                tracing::warn!("could not assess global data for prefetch: {err}");
825                return;
826            }
827        };
828        for request in missing {
829            self.spawn_global_data_fetch(request);
830        }
831    }
832
833    /// Fetches and persists any missing global signing data inline. Blocking - only used on a
834    /// non-running controller, where there's no event loop to drain a background fetch.
835    async fn ensure_global_data(&self) {
836        let missing = match self.missing_global_data().await {
837            Ok(missing) => missing,
838            Err(err) => {
839                tracing::warn!("could not assess global data: {err}");
840                return;
841            }
842        };
843        for request in missing {
844            if let Err(err) = self.fetch_and_store_global_data(request).await {
845                tracing::warn!("failed to ensure global data ({request:?}): {err}");
846            }
847        }
848    }
849
850    /// Spawns a background fetch for one piece of global data, unless it's already in flight or no
851    /// public-data fetcher is set.
852    fn spawn_global_data_fetch(&mut self, request: GlobalDataRequest) {
853        let Some(fetcher) = &self.public_data_fetcher else {
854            tracing::debug!("no public data fetcher set; cannot prefetch {request:?}");
855            return;
856        };
857        if self.in_flight.is_in_flight(request) {
858            return;
859        }
860        tracing::debug!("prefetching global data: {request:?}");
861        self.in_flight.spawn(FetchJob::GlobalData {
862            request,
863            fetcher: Arc::clone(fetcher),
864        });
865    }
866
867    /// Fetches one piece of global data and persists it, inline. Does nothing (`Ok`) when no
868    /// public-data fetcher is configured - callers that need the value surface their own "missing"
869    /// error on the subsequent read.
870    async fn fetch_and_store_global_data(
871        &self,
872        request: GlobalDataRequest,
873    ) -> Result<(), BandwidthControllerError> {
874        let Some(fetcher) = &self.public_data_fetcher else {
875            return Ok(());
876        };
877        let data = request
878            .fetch(&**fetcher)
879            .await
880            .map_err(BandwidthControllerError::fetcher_error)?;
881        self.persist_global_data(data).await
882    }
883
884    /// Persists fetched global signing data.
885    async fn persist_global_data(&self, data: GlobalData) -> Result<(), BandwidthControllerError> {
886        match data {
887            GlobalData::MasterVerificationKey(key) => {
888                self.storage.insert_master_verification_key(&key).await
889            }
890            GlobalData::CoinIndexSignatures(signatures) => {
891                self.storage.insert_coin_index_signatures(&signatures).await
892            }
893            GlobalData::ExpirationDateSignatures(signatures) => {
894                self.storage
895                    .insert_expiration_date_signatures(&signatures)
896                    .await
897            }
898        }
899        .map_err(BandwidthControllerError::credential_storage_error)
900    }
901
902    /// Persists a completed background global-data fetch. The in-flight slot was already freed by
903    /// [`InFlightFetches::next_result`].
904    async fn on_global_data_fetch_complete(
905        &mut self,
906        request: GlobalDataRequest,
907        received: FetchResult,
908    ) {
909        match received {
910            Ok(Some(Ok(FetchedData::GlobalData(data)))) => {
911                if let Err(err) = self.persist_global_data(data).await {
912                    tracing::warn!("failed to persist prefetched global data ({request:?}): {err}");
913                }
914            }
915            Ok(Some(Err(err))) => {
916                tracing::warn!("failed to prefetch global data ({request:?}): {err}")
917            }
918            Ok(None) => tracing::debug!("global data prefetch cancelled: {request:?}"),
919            // the task panicked (or, impossibly, returned a non-global-data payload)
920            Ok(Some(Ok(_))) | Err(_) => {
921                tracing::error!("a global data prefetch task ({request:?}) terminated abnormally")
922            }
923        }
924
925        // global data settling (landed or failed) can flip a stocked type's readiness, so waiters
926        // blocked on it need re-evaluating
927        self.resolve_pending_waiters(None).await;
928    }
929
930    // ---------------------------------------------------------------------
931    // Storage queries & diagnostics
932    // ---------------------------------------------------------------------
933
934    async fn get_upgrade_mode_token(&self) -> Result<Option<String>, BandwidthControllerError> {
935        let Some(emergency_credential) = self
936            .storage
937            .get_emergency_credential(UPGRADE_MODE_JWT_TYPE)
938            .await
939            .map_err(BandwidthControllerError::credential_storage_error)?
940        else {
941            return Ok(None);
942        };
943        // upgrade mode credential is just a simple stringified JWT
944        let token = String::from_utf8(emergency_credential.data.content)
945            .map_err(|_| BandwidthControllerError::MalformedUpgradeModeToken)?;
946        Ok(Some(token))
947    }
948
949    async fn get_available_ticketbooks(
950        &self,
951    ) -> Result<AvailableTicketbooks, BandwidthControllerError> {
952        let ticketbooks_info = self
953            .storage
954            .get_ticketbooks_info()
955            .await
956            .map_err(BandwidthControllerError::credential_storage_error)?;
957        AvailableTicketbooks::try_from(ticketbooks_info)
958    }
959
960    async fn print_info(&self) -> Result<(), BandwidthControllerError> {
961        let ticketbooks_info = self.get_available_ticketbooks().await?;
962        let num_ticketbooks = ticketbooks_info.len_not_expired();
963        let num_total_ticketbooks = ticketbooks_info.len();
964        tracing::info!("Ticketbooks stored: {num_ticketbooks}");
965        tracing::debug!("Total ticketbooks stored: {num_total_ticketbooks}");
966        for ticketbook in ticketbooks_info {
967            if ticketbook.has_expired() {
968                tracing::debug!("Expired ticketbook: {ticketbook}");
969            } else if ticketbook.expired_soon(OffsetDateTime::now_utc(), &self.config) {
970                tracing::info!("Soon expired ticketbook: {ticketbook}");
971            } else {
972                tracing::info!("Ticketbook: {ticketbook}");
973            }
974        }
975
976        Ok(())
977    }
978}
979
980// So we can use the BC without making it run on its own if we don't need that
981#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
982#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
983impl<St: Storage> BandwidthTicketProvider for BandwidthController<St> {
984    async fn get_ecash_ticket(
985        &self,
986        ticket_type: TicketType,
987        gateway_id: ed25519::PublicKey,
988        tickets_to_spend: u32,
989        spend_time: OffsetDateTime,
990    ) -> Result<Option<PreparedCredential>, BandwidthControllerError> {
991        self.prepare_ecash_ticket(
992            ticket_type,
993            gateway_id.to_bytes(),
994            tickets_to_spend,
995            spend_time,
996        )
997        .await
998    }
999
1000    async fn get_upgrade_mode_token(&self) -> Result<Option<String>, BandwidthControllerError> {
1001        self.get_upgrade_mode_token().await
1002    }
1003
1004    async fn attempt_revert_spending(
1005        &self,
1006        metadata: PreparedCredentialMetadata,
1007    ) -> Result<bool, BandwidthControllerError> {
1008        self.attempt_revert_ticket_usage(metadata).await
1009    }
1010
1011    async fn close(&self) {
1012        self.storage.close().await
1013    }
1014}