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